Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+216
View File
@@ -0,0 +1,216 @@
# Photon iMessage platform plugin
This plugin connects Hermes Agent to iMessage (and other Spectrum
interfaces) through [Photon][photon] — a managed service that handles
iMessage line allocation, delivery, and abuse-prevention so users don't
have to run their own Mac relay.
The free tier uses Photon's shared iMessage line pool and is the path we
recommend for everyone who doesn't already pay for a dedicated number.
## Architecture
Like Discord and Slack, Photon is a **persistent-connection** channel — no
public URL, no webhook, no signing secret. The `spectrum-ts` SDK holds a
long-lived **gRPC stream** to Photon for both directions. Because the SDK is
TypeScript-only, Hermes runs it inside a small supervised Node sidecar and
talks to it over loopback.
```
gRPC (spectrum-ts)
┌─────────────────────────┐ ◄───────────────► ┌──────────────────────┐
│ Photon Spectrum cloud │ app.messages │ Node sidecar │
│ (iMessage line owner) │ space.send() │ (plugins/…/sidecar) │
└─────────────────────────┘ └──────────┬───────────┘
GET /inbound (NDJSON) │ ▲ POST /send
inbound events ▼ │ /send-richlink
│ │ /typing
┌──────────────────────┐
│ PhotonAdapter │
│ (Python, in gateway) │
└──────────────────────┘
```
- **Inbound**: the sidecar consumes the SDK's `app.messages` gRPC stream,
normalizes each message, and streams it to the adapter over a loopback
`GET /inbound` (NDJSON). The adapter dedupes on `messageId` and dispatches
a `MessageEvent` to the gateway. It reconnects automatically if the stream
drops; the sidecar owns the gRPC reconnect to Photon.
- **Outbound**: `send` / `send_typing` / reaction tapbacks are loopback POSTs
to the sidecar (`/send`, `/send-richlink`, `/send-attachment`, `/typing`,
`/react`, `/unreact`), authenticated with a shared
`X-Hermes-Sidecar-Token`.
## First-time setup
```bash
# One-shot setup: device login (opens browser) + project + user + sidecar deps
hermes photon setup --phone +15551234567
# Start the gateway
hermes gateway start
```
`hermes photon setup` does, in order:
1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens
`https://app.photon.codes/` for approval and stores the bearer token.
2. **Find or create** the `Hermes Agent` project on the Photon dashboard.
3. **Provision the project secret** — mint a fresh project secret (the
dashboard reveals it only once) and persist it to `~/.hermes/.env` so the
sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no
separate enable step.
4. **Register your phone number** as a Spectrum user (idempotent — skipped if
a user with that number already exists).
5. **Print the assigned iMessage line** — the number you text to reach your
agent.
6. **Install the sidecar deps** (`npm ci` — installs the committed lockfile
verbatim, so every setup runs the exact `spectrum-ts` version this plugin
was written against).
There is no separate `login` command; like every other Hermes channel,
onboarding goes through one setup surface. Re-running `setup` reuses an
existing token/project, so it's safe to run again to finish a partial setup.
Run `hermes photon status` to see what's configured.
## Credentials
Runtime SDK credentials live in `~/.hermes/.env` (the same place every other
channel keeps its token), and the adapter reads them from the environment:
```bash
PHOTON_PROJECT_ID=<projectId> # the SDK's projectId (same as the dashboard project id)
PHOTON_PROJECT_SECRET=<projectSecret>
```
Management metadata lives in `~/.hermes/auth.json` under `credential_pool`:
```jsonc
{
"credential_pool": {
"photon": [
{ "access_token": "<device-bearer>", "issued_at": ... }
],
"photon_project": [
{
"dashboard_project_id": "<project id>",
"spectrum_project_id": "<project id>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
]
}
}
```
> **Note on ids.** A Photon project's dashboard id and its Spectrum project id
> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id`
> and `spectrum_project_id` keys in `auth.json` both hold that id.
## Configuration knobs
All env vars are documented in `plugin.yaml`. The most important:
| Env var | Default | Meaning |
|---------------------------|----------------------------|--------------------------------------|
| `PHOTON_PROJECT_ID` | from .env / auth.json | Spectrum project id (SDK `projectId`)|
| `PHOTON_PROJECT_SECRET` | from .env / auth.json | Project secret |
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
| `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` | 20 MB | Max inbound attachment size the sidecar reads & inlines |
| `PHOTON_TELEMETRY` | false | Spectrum SDK telemetry — toggle with `hermes photon telemetry on\|off` (restart the gateway to apply) |
| `PHOTON_MARKDOWN` | true | Send agent replies as markdown (iMessage renders natively). `false` strips formatting to plain text |
| `PHOTON_REACTIONS` | false | Tapback 👀/👍/👎 as processing status; tapbacks on bot messages reach the agent as `reaction:added:<emoji>` |
## Attachments & limitations
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Mixed iMessage
bubbles that contain both text and attachments are normalized as a grouped
payload so the user's typed text is preserved alongside the cached media.
Media larger than `PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or
any byte read that fails, falls back to a text marker (`[Photon attachment
received: …]` or `[Photon voice received: …]`) so the agent still knows
something arrived. If Spectrum emits a `richlink` content object, Hermes
preserves its URL plus any title/summary metadata Spectrum already exposed;
current Spectrum versions may still deliver ordinary inbound links as plain
`text`. iMessage may also emit rich-link preview artwork as
`.pluginPayloadAttachment` images immediately after the URL; Hermes coalesces
those artifacts so the agent receives one link message instead of a follow-up
`(attachment)` prompt.
- **Outbound attachments are supported.** Images, voice notes, video, and
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
endpoint; a caption is delivered as a separate text bubble after the media.
- **Markdown is rendered.** Replies go out via spectrum-ts' `markdown()`
builder; iMessage renders bold/italics/lists/code natively and other
Spectrum platforms degrade to readable plain text. URL-only replies go out
via spectrum-ts' `richlink()` builder so iMessage can render a native link
preview card. `PHOTON_MARKDOWN=false` reverts to stripped plain text and
disables rich-link routing.
- **Reactions (tapbacks) are supported** behind `PHOTON_REACTIONS` (default
off): the adapter tapbacks 👀 while processing and swaps it for 👍/👎 on
completion, and a user tapback on a bot-sent message is routed to the agent
as a synthetic `reaction:added:<emoji>` event. Removal after a sidecar
restart is best-effort — the live reaction handle is lost, so a stale
tapback heals when the next reaction replaces it. Group spaces stay
reachable across restarts via spectrum-ts' `space.get` rehydration.
- **Read receipts are supported.** The sidecar marks an inbound iMessage read
after forwarding it to Hermes, so the sender sees `Read` without waiting for
a model/tool turn. Inbound receipts for Hermes-sent messages are consumed as
presence telemetry and never create an agent turn. Set
`PHOTON_READ_RECEIPTS=false` to keep messages at `Delivered`.
- **Native polls are supported.** Hermes posts poll content through
`spectrum-ts`' `poll(...)` builder via the sidecar's `/send-poll` endpoint.
- **Message effects are supported.** Text can be sent with native iMessage
bubble/screen effects through `spectrum-ts`' iMessage `effect(...)` builder
via the sidecar's `/send-effect` endpoint.
- **Cron/standalone sends require a running gateway.** Processes outside
the gateway (cron subprocesses, `hermes send`) cannot spawn the sidecar;
they authenticate to the gateway's live sidecar via the runtime record at
`<hermes-home>/runtime/photon-sidecar.json` (written after the sidecar's
`/healthz` readiness check, `0600`, removed on stop/failed start). Also
note that shared/free-tier Photon lines cannot INITIATE conversations
with numbers that never texted the line — that's Photon-side policy, not
a Hermes limitation.
## Upgrading spectrum-ts
`spectrum-ts` is pinned to an **exact version** in `sidecar/package.json`
(no `^` range) and installed with `npm ci`, because the SDK ships breaking
majors (v2 removed `defineFusorPlatform`; v3 reworked space construction; v5
split it into `@spectrum-ts/*` packages, with `spectrum-ts` as the umbrella
that re-exports them; v8 made `richlink` primarily outbound, so many inbound
links now arrive as plain `text`). A floating range or `npm install spectrum-ts@latest`
would let a breaking release take down fresh setups silently. Upgrades are
deliberate:
1. Read the [SDK release notes](https://github.com/photon-hq/spectrum-ts/releases)
for every version between the current pin and the target.
2. Bump the exact pin in `sidecar/package.json`, then run `npm install`
inside `sidecar/` to regenerate `package-lock.json`. Commit both.
3. Migrate `sidecar/index.mjs` against the new typings. `spectrum-ts` re-exports
`@spectrum-ts/core` (the framework: `Spectrum`, content builders,
`Space`/`Message`) and `@spectrum-ts/imessage` (the provider), so the source
of truth is `sidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts`
(the hosted docs can lag).
4. Re-validate `sidecar/patch-spectrum-mixed-attachments.mjs`. It rewrites the
compiled iMessage inbound mappers in `@spectrum-ts/imessage/dist/index.js`
so a bubble with both text and attachments keeps its typed text; the anchors
are tied to that build's output. `npm install` runs it via `postinstall` and
fails loudly if the anchors no longer match — update them to the new output
(`test_spectrum_patch.py` covers the patch).
5. Run `pytest tests/plugins/platforms/photon/`.
6. Verify end-to-end: `hermes photon status`, a DM and a group roundtrip,
and an agent reply into a group right after a gateway restart (exercises
`space.get` rehydration).
[photon]: https://photon.codes/
+4
View File
@@ -0,0 +1,4 @@
"""Photon Spectrum (iMessage) platform plugin entry point."""
from .adapter import register
__all__ = ["register"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+540
View File
@@ -0,0 +1,540 @@
"""
``hermes photon ...`` CLI subcommands — registered by the plugin via
``ctx.register_cli_command()``.
Subcommands:
setup full first-time setup (device login + project + user + sidecar)
status show login + project + sidecar dep state
install-sidecar npm install inside plugins/platforms/photon/sidecar/
telemetry show or toggle Spectrum SDK telemetry (on/off)
The device-code login runs automatically as the first step of ``setup``;
there is no standalone ``login`` verb (matching how every other Hermes
gateway channel onboards through a single setup surface).
Photon uses the spectrum-ts gRPC stream for inbound — there is no webhook
to register, so there are no webhook subcommands.
"""
from __future__ import annotations
import argparse
import getpass
import os
import shutil
import subprocess
import sys
from pathlib import Path
from hermes_cli.colors import Colors, color
from . import auth as photon_auth
from .adapter import _NPM_ERROR_LOG_MAX_CHARS, sidecar_deps_installed
from .sidecar_paths import resolve_sidecar_dir
# Writable sidecar runtime dir (mirrors to HERMES_HOME on immutable
# installs — NS-606). All npm/setup work happens here. Resolved lazily on
# first use — resolve_sidecar_dir() probes the filesystem and may mirror
# files, side effects that must not fire at import time (e.g. when argparse
# wiring imports this module for `hermes --help`).
# Tests monkeypatch these module globals directly; the accessors honor a
# non-None value and only resolve/derive when unset.
_SIDECAR_DIR: Path | None = None
# Written on npm failure so check_requirements() can surface the root cause
# when called later (gateway start, hermes status). Cleared on success.
_NPM_ERROR_LOG: Path | None = None
def _sidecar_dir() -> Path:
"""Sidecar runtime dir, resolved once on first use (never at import)."""
global _SIDECAR_DIR
if _SIDECAR_DIR is None:
_SIDECAR_DIR = resolve_sidecar_dir()
return _SIDECAR_DIR
def _npm_error_log() -> Path:
"""Path of the persisted npm-failure log (derived from the sidecar dir)."""
if _NPM_ERROR_LOG is not None:
return _NPM_ERROR_LOG
return _sidecar_dir() / ".photon-npm-error.log"
# ---------------------------------------------------------------------------
# argparse wiring
def register_cli(parser: argparse.ArgumentParser) -> None:
"""Wire up `hermes photon ...` subcommands."""
subs = parser.add_subparsers(dest="photon_command", required=False)
p_setup = subs.add_parser(
"setup",
help="First-time setup (device login + project + user + sidecar)",
)
p_setup.add_argument("--project-name", default=None,
help="Project name (default: 'Hermes Agent')")
p_setup.add_argument("--phone", default=None,
help="Your E.164 phone number (e.g. +15551234567)")
p_setup.add_argument("--first-name", default=None)
p_setup.add_argument("--last-name", default=None)
p_setup.add_argument("--email", default=None)
p_setup.add_argument("--no-browser", action="store_true",
help="Don't try to open a browser for device login; print the URL only")
p_setup.add_argument("--skip-sidecar-install", action="store_true",
help="Skip `npm install` inside the sidecar directory")
subs.add_parser("status", help="Show login + project + sidecar dep state")
subs.add_parser("install-sidecar", help="Run npm install inside the sidecar directory")
p_telemetry = subs.add_parser(
"telemetry",
help="Show or toggle Spectrum SDK telemetry (on/off)",
)
p_telemetry.add_argument(
"state", nargs="?", choices=("on", "off"),
help="Turn telemetry on or off (omit to show the current state)",
)
parser.set_defaults(func=dispatch)
# ---------------------------------------------------------------------------
# Dispatch
def dispatch(args: argparse.Namespace) -> int:
sub = getattr(args, "photon_command", None)
if sub is None:
# No subcommand given — show status by default.
return _cmd_status(args)
if sub == "setup":
return _cmd_setup(args)
if sub == "status":
return _cmd_status(args)
if sub == "install-sidecar":
return _cmd_install_sidecar(args)
if sub == "telemetry":
return _cmd_telemetry(args)
print(f"unknown subcommand: {sub}", file=sys.stderr)
return 2
# ---------------------------------------------------------------------------
# Subcommand handlers
def _run_device_login(args: argparse.Namespace) -> int:
"""Run the RFC 8628 device-code login flow and persist the token.
Internal helper — invoked as the first step of ``setup``. There is
no standalone ``hermes photon login`` command; Photon onboards
through the single ``setup`` surface like every other channel.
"""
def _print_code(code):
target = code.verification_uri_complete or code.verification_uri
print()
print("┌─ Photon device login ────────────────────────────────────────")
print(f"│ Open this URL: {target}")
print(f"│ Enter the code: {code.user_code}")
print("│ (waiting for approval — Ctrl-C to cancel)")
print("└──────────────────────────────────────────────────────────────")
print()
try:
token = photon_auth.login_device_flow(
open_browser=not args.no_browser,
on_user_code=_print_code,
)
except Exception as e:
print(f"login failed: {e}", file=sys.stderr)
return 1
# Don't print any portion of the token — even a prefix can help a
# shoulder-surfer or accidentally leak into a screen recording.
_ = token
print(f"✓ logged in — token saved to {photon_auth._auth_json_path()}")
return 0
def _cmd_setup(args: argparse.Namespace) -> int:
# 1. Login (skip if we already have a valid token).
token = photon_auth.load_photon_token()
if token:
# Validate the existing token — the dashboard token has a short TTL
# and can go stale between runs (observed: ~3-4 days). Reusing a
# stale token causes every management call to fail with 401 and
# leaves the operator confused about why setup "succeeds" but nothing
# works. Check upfront so we fail fast and fall back to fresh login.
print("[1/5] Checking existing Photon token...")
if photon_auth.check_photon_token_valid(token):
print(" ✓ token is valid")
else:
print(" ✗ token is stale (dashboard rejected it) — re-authenticating")
photon_auth.clear_photon_token()
token = None
if not token:
print("[1/5] No valid Photon token found — running device login...")
rc = _run_device_login(args)
if rc != 0:
return rc
token = photon_auth.load_photon_token()
if not token:
print("login completed but token was not stored", file=sys.stderr)
return 1
else:
print("[1/5] Reusing existing Photon token")
# 2. Find or create the "Hermes Agent" project.
name = args.project_name or photon_auth.DEFAULT_PROJECT_NAME
dashboard_id = photon_auth.load_dashboard_project_id()
try:
if dashboard_id:
print("[2/5] Reusing configured Photon project")
else:
existing = photon_auth.find_project_by_name(token, name)
if existing and existing.get("id"):
dashboard_id = existing["id"]
print(f"[2/5] Found existing project '{name}'")
else:
print(f"[2/5] Creating Photon project '{name}'...")
created = photon_auth.create_project(token, name=name)
dashboard_id = created.get("id")
print(" ✓ project created")
except Exception as e:
print(f"project setup failed: {e}", file=sys.stderr)
return 1
if not dashboard_id:
print("could not resolve a Photon project id", file=sys.stderr)
return 1
# 3. Provision Spectrum credentials (runtime -> ~/.hermes/.env,
# ids -> auth.json). Spectrum is always enabled and provisioned at
# create-time, and the dashboard project id *is* the Spectrum project id
# (ids unified), so there's nothing to enable — the id we already have is
# the Spectrum id.
#
# On re-run we reuse an existing valid secret instead of regenerating.
# Regenerating invalidates the credential that a running sidecar holds
# in its process env, causing all outbound sends to fail with
# AuthenticationError until the gateway is restarted (GH #50755).
try:
print("[3/5] Provisioning Spectrum credentials...")
spectrum_id = dashboard_id
existing_id, existing_secret = photon_auth.load_project_credentials()
secret: str = ""
reused = False
if existing_id and existing_secret:
# Validate the existing credential with a lightweight API call.
try:
photon_auth.list_users(existing_id, existing_secret)
secret = existing_secret
reused = True
except Exception:
secret = "" # fall through to regeneration
if not secret:
secret = photon_auth.regenerate_project_secret(token, dashboard_id)
photon_auth.store_project_credentials(
spectrum_project_id=spectrum_id,
project_secret=secret,
dashboard_project_id=dashboard_id,
name=name,
)
# spectrum_id is an opaque non-secret id; safe to show.
if reused:
print(f" ✓ Spectrum ready (project id {spectrum_id}) — existing credentials valid")
else:
print(f" ✓ Spectrum ready (project id {spectrum_id}) — new secret saved")
print(
" ⚠ Project secret was regenerated. If the gateway is running, "
"restart it so the sidecar picks up the new secret:\n"
" hermes gateway restart"
)
except Exception as e:
print(f"spectrum provisioning failed: {e}", file=sys.stderr)
return 1
# 4. Register the operator's phone number as a Spectrum user (idempotent).
phone = args.phone or _prompt(
color(
"[4/5] Your iMessage phone number (E.164, e.g. +15551234567): ",
Colors.CYAN,
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
# Name/email are optional and never prompted for — pass --first-name /
# --email if you want them sent to the dashboard.
first_name = args.first_name
email = args.email
try:
user, created = photon_auth.register_user_if_absent(
spectrum_id, secret,
phone_number=phone,
first_name=first_name,
last_name=args.last_name,
email=email,
)
except ValueError as e:
print(f" invalid phone number: {e}", file=sys.stderr)
return 1
except Exception as e:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" ✓ phone registered" if created else " ✓ phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
# truth — and we already have it from the (reused) user object.
agent_number = photon_auth.user_assigned_line(user)
# Allowlist the operator and make their DM the cron home channel —
# otherwise the gateway denies their own inbound messages
# ("Unauthorized user") and has no default space for cron delivery.
_autoconfigure_access(phone)
# 5. Surface the agent's iMessage number (the number to text the agent).
if not agent_number:
# No per-user assignment — fall back to a dedicated line if the project
# has one provisioned in its line inventory.
try:
line = photon_auth.get_imessage_line(token, dashboard_id)
if line:
agent_number = line.get("phoneNumber")
except Exception as e:
print(f" (could not fetch the assigned line: {e})", file=sys.stderr)
if agent_number:
print()
print(color("┌─ Your agent's iMessage number ───────────────────────────────", Colors.GREEN))
print(
color("│ 📱 ", Colors.GREEN)
+ color(str(agent_number), Colors.GREEN, Colors.BOLD)
)
print(color("│ Text this number from your phone to talk to your agent.", Colors.GREEN))
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
else:
print(" No iMessage line assigned yet — check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
print("[5/5] Skipping sidecar npm install (--skip-sidecar-install)")
else:
print("[5/5] Installing Node sidecar deps (spectrum-ts)...")
rc = _install_sidecar()
if rc != 0:
return rc
# 7. Ensure the photon platform is enabled in config.yaml so the
# gateway loads it on next start. Without this the channel stays
# disabled even after a successful provisioning run, silently
# keeping iMessage offline.
try:
from hermes_cli.config import write_platform_config_field
write_platform_config_field("photon", "enabled", True, raw=True)
print(" ✓ photon platform enabled in config.yaml")
except Exception as e:
print(f" (could not enable Photon in config: {e})", file=sys.stderr)
print()
print("✓ Photon setup complete.")
print(" Start the gateway: hermes gateway start")
return 0
def _autoconfigure_access(phone: str) -> None:
"""Allowlist the operator and set their DM as the cron home channel.
Writes ``PHOTON_ALLOWED_USERS`` (so the gateway authorizes the operator's
own inbound messages instead of denying them) and ``PHOTON_HOME_CHANNEL``
(the default space for cron delivery) to the operator's E.164 number. Each
is only filled when unset, so a hand-tuned allowlist / home channel is
never clobbered on a re-run.
"""
try:
from hermes_cli.config import get_env_value, save_env_value
except ImportError:
return
for key, label in (
("PHOTON_ALLOWED_USERS", "allowlisted your number"),
("PHOTON_HOME_CHANNEL", "set your DM as the cron home channel"),
):
try:
if get_env_value(key):
print(f" {key} already set — leaving it as-is.")
continue
save_env_value(key, phone)
print(f"{label} ({key})")
except Exception as e:
print(f" could not set {key}: {e}", file=sys.stderr)
def _cmd_status(_args: argparse.Namespace) -> int:
_refresh_status_numbers()
# Defer the credential rows to auth.print_credential_summary — its emit
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
photon_auth.print_credential_summary(print)
node_bin = os.getenv("PHOTON_NODE_BIN") or shutil.which("node")
sidecar_installed = sidecar_deps_installed()
print(f" node binary : {node_bin or '✗ missing (install Node 18+)'}")
print(f" sidecar deps : {'✓ installed' if sidecar_installed else '✗ run `hermes photon install-sidecar`'}")
print(f" telemetry : {'on' if _telemetry_enabled() else 'off'} (`hermes photon telemetry on|off`)")
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
spectrum_id, project_secret = photon_auth.load_project_credentials()
if not spectrum_id or not project_secret:
return
try:
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
return _install_sidecar()
def _telemetry_enabled() -> bool:
"""Read PHOTON_TELEMETRY from the env / ~/.hermes/.env.
Mirrors the sidecar's truthy set (index.mjs) so the state shown here
always matches what the sidecar will actually do.
"""
try:
from hermes_cli.config import get_env_value
raw = get_env_value("PHOTON_TELEMETRY")
except ImportError:
raw = os.getenv("PHOTON_TELEMETRY")
return (raw or "").strip().lower() in ("1", "true", "yes", "on")
def _cmd_telemetry(args: argparse.Namespace) -> int:
state = getattr(args, "state", None)
if state is None:
print(f"Photon telemetry: {'on' if _telemetry_enabled() else 'off'}")
print(" Toggle with `hermes photon telemetry on` / `hermes photon telemetry off`.")
return 0
try:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_TELEMETRY", "true" if state == "on" else "false")
except Exception as e:
print(f"could not save PHOTON_TELEMETRY: {e}", file=sys.stderr)
return 1
print(f"✓ Spectrum telemetry turned {state} (PHOTON_TELEMETRY in ~/.hermes/.env)")
print(" Restart the gateway for the sidecar to pick it up: hermes gateway restart")
return 0
def _install_sidecar() -> int:
npm = shutil.which("npm") or "npm"
if not shutil.which(npm):
print(
"npm is not on PATH. Install Node.js 18+ (https://nodejs.org/) "
"and re-run.",
file=sys.stderr,
)
return 1
# spectrum-ts is pinned exactly in package.json/package-lock.json because
# the SDK ships breaking majors (v2 removed defineFusorPlatform; v3
# reworked space construction; v5 split it into @spectrum-ts/* packages).
# Upgrades are deliberate: bump the pin, migrate sidecar/index.mjs, re-run
# the photon tests — never `@latest` (see README "Upgrading spectrum-ts").
# `npm ci` installs the committed lockfile verbatim; fall back to
# `npm install` when the lockfile is missing or drifted (e.g. a dev
# checkout mid-upgrade).
print(f" $ cd {_sidecar_dir()} && {npm} ci")
# stdout is not captured so npm progress prints to the terminal in real
# time. stderr is captured so we can persist the failure reason for
# check_requirements() to surface after the process exits.
proc = subprocess.run( # noqa: S603
[npm, "ci"],
cwd=str(_sidecar_dir()),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print(f" npm ci failed — falling back to: {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install"],
cwd=str(_sidecar_dir()),
check=False,
stderr=subprocess.PIPE,
text=True,
)
if proc.stderr:
print(proc.stderr, end="", file=sys.stderr)
if proc.returncode != 0:
print("npm install failed", file=sys.stderr)
# Bound to the same length check_requirements() truncates to on
# read, so the log file never holds more than what's ever surfaced.
error = (proc.stderr or "").strip()[:_NPM_ERROR_LOG_MAX_CHARS]
if error:
try:
_npm_error_log().write_text(error, encoding="utf-8")
except OSError:
pass
else:
try:
_npm_error_log().unlink()
except OSError:
pass
return proc.returncode
# ---------------------------------------------------------------------------
# Gateway-setup entry point
#
# `hermes gateway setup` discovers platforms via the registry and calls each
# entry's zero-arg ``setup_fn``. Photon registers this function so it appears
# in the unified setup wizard alongside every other channel — same onboarding
# surface, no Photon-specific detour. It runs the identical device-login +
# project + user + sidecar flow as ``hermes photon setup`` with interactive
# defaults (phone is prompted when stdin is a TTY).
def gateway_setup() -> None:
"""Run Photon first-time setup from the `hermes gateway setup` wizard."""
args = argparse.Namespace(
photon_command="setup",
project_name=None,
phone=None,
first_name=None,
last_name=None,
email=None,
no_browser=False,
skip_sidecar_install=False,
)
_cmd_setup(args)
# ---------------------------------------------------------------------------
# Small interactive helpers
def _prompt(prompt: str, *, secret: bool = False) -> str:
if not sys.stdin.isatty():
return ""
try:
if secret:
return getpass.getpass(prompt).strip()
return input(prompt).strip()
except (KeyboardInterrupt, EOFError):
print()
return ""
+92
View File
@@ -0,0 +1,92 @@
name: photon-platform
label: iMessage via Photon
kind: platform
version: 0.3.0
description: >
Photon Spectrum gateway adapter for Hermes Agent.
Connects to iMessage (and other Spectrum interfaces) through Photon's
managed Spectrum platform. Both directions run over the `spectrum-ts`
SDK's long-lived gRPC stream via a small supervised Node sidecar —
inbound messages arrive on the SDK's `app.messages` stream (no webhook,
no public URL, no signing secret), and outbound messages are sent over
the same sidecar.
The plugin ships with a `hermes photon` CLI for the one-time device
login + project + user setup. Runtime credentials are written to
``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = the Spectrum project id,
``PHOTON_PROJECT_SECRET``) like every other channel, with management
metadata (device token, dashboard project id) in ``~/.hermes/auth.json``.
Photon's free shared-line model lets users get started without a paid plan.
author: NousResearch
requires_env:
- name: PHOTON_PROJECT_ID
description: "Spectrum project id (the project's spectrumProjectId; set by `hermes photon setup`)"
prompt: "Photon Spectrum project id"
url: "https://app.photon.codes/"
password: false
- name: PHOTON_PROJECT_SECRET
description: "Project secret paired with the Spectrum project id (set by `hermes photon setup`)"
prompt: "Photon project secret"
url: "https://app.photon.codes/"
password: true
optional_env:
- name: PHOTON_SIDECAR_PORT
description: "Loopback port for the Node sidecar control + inbound channel (default 8789)"
prompt: "Sidecar control port"
password: false
- name: PHOTON_SIDECAR_AUTOSTART
description: "Spawn the Node sidecar on connect (true/false, default true)"
prompt: "Auto-start the sidecar?"
password: false
- name: PHOTON_NODE_BIN
description: "Path to the node binary (default: shutil.which('node'))"
prompt: "Node executable path"
password: false
- name: PHOTON_DASHBOARD_HOST
description: "Photon Dashboard API host (default https://app.photon.codes)"
prompt: "Dashboard host"
password: false
- name: PHOTON_SPECTRUM_HOST
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_ALLOWED_USERS
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
password: false
- name: PHOTON_ALLOW_ALL_USERS
description: "Allow any sender to trigger the bot (dev only — disables allowlist)"
prompt: "Allow all users? (true/false)"
password: false
- name: PHOTON_READ_RECEIPTS
description: "Mark inbound iMessages read after forwarding to Hermes (true/false, default true)"
prompt: "Send read receipts? (true/false)"
password: false
- name: PHOTON_REQUIRE_MENTION
description: "Ignore group-chat messages unless they match a mention wake word (true/false, default false)"
prompt: "Require a mention in group chats?"
password: false
- name: PHOTON_MENTION_PATTERNS
description: "Mention wake-word regexes for group chats (JSON list or comma/newline-separated; defaults to Hermes wake words)"
prompt: "Group mention patterns"
password: false
- name: PHOTON_HOME_CHANNEL
description: "Default Photon target for cron / notification delivery: Spectrum space id, DM GUID, or bare E.164 phone number"
prompt: "Home Photon target"
password: false
- name: PHOTON_HOME_CHANNEL_NAME
description: "Human label for the home channel"
prompt: "Home channel display name"
password: false
- name: PHOTON_TELEMETRY
description: "Enable Spectrum SDK telemetry in the sidecar (true/false, default false; toggle with `hermes photon telemetry on|off`)"
prompt: "Enable Spectrum telemetry? (true/false)"
password: false
- name: PHOTON_MARKDOWN
description: "Send agent replies as markdown — iMessage renders it natively, other Spectrum platforms degrade to plain text (true/false, default true)"
prompt: "Render replies as markdown? (true/false)"
password: false
- name: PHOTON_REACTIONS
description: "Tapback 👀/👍/👎 on messages as processing status and route tapbacks on bot messages to the agent (true/false, default false)"
prompt: "Enable reaction tapbacks? (true/false)"
password: false
@@ -0,0 +1,2 @@
node_modules/
.photon-npm-error.log
@@ -0,0 +1,50 @@
# Photon sidecar
Small Node helper that bridges Hermes Agent to Photon's Spectrum SDK
(`spectrum-ts`). Hermes is Python; Photon has no public HTTP
send-message endpoint today; replies therefore go through this sidecar.
The sidecar:
- runs `Spectrum({ projectId, projectSecret, providers: [imessage.config()] })`
- exposes a loopback-only HTTP control channel for the Python adapter
to push send/typing requests (auth via `X-Hermes-Sidecar-Token`)
- drains the inbound message stream so `spectrum-ts` keeps its
reconnect/heartbeat machinery alive and Hermes can receive inbound messages
over the adapter's loopback `GET /inbound` stream
## Install
```bash
cd plugins/platforms/photon/sidecar
npm install
```
The Hermes plugin's `hermes photon setup` command runs `npm install`
here automatically.
## Run standalone
For debugging:
```bash
PHOTON_PROJECT_ID=... PHOTON_PROJECT_SECRET=... \
PHOTON_SIDECAR_PORT=8789 PHOTON_SIDECAR_TOKEN=$(openssl rand -hex 16) \
node index.mjs
```
In normal use, the Python adapter supervises this process — start,
restart on crash, kill on shutdown — and never asks the user to run
it by hand.
## Why a sidecar at all?
Photon's Spectrum send path is exposed through the TypeScript SDK's
`Space.send(...)` API. Hermes is Python, so replies go through this sidecar
until Photon ships a public HTTP send endpoint.
When Photon ships an HTTP send endpoint, the plan is to retire this
sidecar entirely and call it directly from Python. The plugin's
outbound code path is already isolated behind small helpers
(`_sidecar_send`, `_sidecar_send_richlink`, and `_sidecar_send_attachment` in
`adapter.py`) to make that swap localized.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
{
"name": "@hermes-agent/photon-sidecar",
"private": true,
"version": "0.4.0",
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
"type": "module",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs",
"postinstall": "node patch-spectrum-mixed-attachments.mjs"
},
"engines": {
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "12.7.0"
},
"overrides": {
"protobufjs": "8.7.1",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
"@opentelemetry/exporter-logs-otlp-http": "0.218.0",
"@opentelemetry/core": "2.10.0"
}
}
@@ -0,0 +1,188 @@
#!/usr/bin/env node
// Patch spectrum-ts' iMessage inbound mapper until upstream preserves mixed
// text + attachment Apple events. The mapper returns only
// buildAttachmentMessage(...) whenever attachments are present, which drops
// `message.content.text` before Hermes can see it. We rewrite the two inbound
// mappers — `rebuildFromAppleMessage` (used by `space.getMessage`) and
// `toInboundMessages` (used by the live stream) — so a bubble carrying both
// text and attachment(s) surfaces as a group whose first child is the typed
// text. Paths with no text are rewritten to byte-identical behavior, so only
// mixed text+attachment messages change shape.
//
// Since spectrum-ts 5.x split the SDK into scoped packages, the iMessage mapper
// lives in `@spectrum-ts/imessage/dist/index.js` (it used to be a chunk under
// `spectrum-ts/dist`). The published output is tab-indented and uses
// `const ... = async` declarations; the anchors below match that exactly and
// fail loudly if a future spectrum-ts reshapes the mapper.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const MARKER = "Hermes patch: Preserve mixed text + attachment iMessage payloads";
function scriptDir() {
return path.dirname(fileURLToPath(import.meta.url));
}
function replaceOnce(source, from, to, label) {
const count = source.split(from).length - 1;
if (count !== 1) {
throw new Error(`expected exactly one ${label} match, found ${count}`);
}
return source.replace(from, to);
}
function replaceExactly(source, from, to, expected, label) {
const count = source.split(from).length - 1;
if (count !== expected) {
throw new Error(
`expected exactly ${expected} ${label} matches, found ${count}`
);
}
return source.split(from).join(to);
}
// The text-first child of a mixed text+attachment group, indented `tabs` deep
// (the object's closing brace sits at `tabs`; its properties one level in).
function textChild(tabs) {
const t = "\t".repeat(tabs);
return (
`{\n${t}\t...base,\n${t}\tid: formatChildId(0, messageGuidStr),` +
`\n${t}\tcontent: asText(text2),\n${t}\tpartIndex: 0,` +
`\n${t}\tparentId: messageGuidStr\n${t}}`
);
}
function patchRebuild(source) {
// Capture the bubble text before the attachment branches consume it. The
// existing no-attachment branch keeps its own `const text` declaration, so a
// distinct name avoids a redeclaration.
source = replaceOnce(
source,
`\tconst attachments = messageAttachments(message);\n\tif (attachments.length === 1) {`,
`\tconst attachments = messageAttachments(message);\n\tconst text2 = message.content.text;\n\tif (attachments.length === 1) {`,
"rebuild text capture"
);
// Single attachment: when text is present, push it to slot 0 and the
// attachment to slot 1, then wrap both in a group.
source = replaceOnce(
source,
`\t\treturn buildAttachmentMessage(client, base, info, messageGuidStr, 0);`,
`\t\tconst msg2 = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\treturn {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg2])\n\t\t\t};\n\t\t}\n\t\treturn msg2;`,
"rebuild single attachment"
);
// Multi attachment: prepend the text child to the group's items.
source = replaceOnce(
source,
`\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\treturn {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
"rebuild multi attachment text child"
);
return source;
}
function patchInbound(source) {
source = replaceOnce(
source,
`\tconst attachments = messageAttachments(event.message);\n\tif (attachments.length === 1) {`,
`\tconst attachments = messageAttachments(event.message);\n\tconst text2 = event.message.content.text;\n\tif (attachments.length === 1) {`,
"inbound text capture"
);
source = replaceOnce(
source,
`\t\tconst msg = await buildAttachmentMessage(client, base, info, messageGuidStr, 0);\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
`\t\tconst msg = await buildAttachmentMessage(client, base, info, text2 ? formatChildId(1, messageGuidStr) : messageGuidStr, text2 ? 1 : 0, text2 ? messageGuidStr : void 0);\n\t\tif (text2) {\n\t\t\tconst textMsg = ${textChild(3)};\n\t\t\tconst parent = {\n\t\t\t\t...base,\n\t\t\t\tid: messageGuidStr,\n\t\t\t\tcontent: asProviderGroup([textMsg, msg])\n\t\t\t};\n\t\t\tcacheMessage(cache, parent);\n\t\t\treturn [parent];\n\t\t}\n\t\tcacheMessage(cache, msg);\n\t\treturn [msg];`,
"inbound single attachment"
);
source = replaceOnce(
source,
`\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
`\t\tif (text2) {\n\t\t\titems.unshift(${textChild(3)});\n\t\t}\n\t\tconst parent = {\n\t\t\t...base,\n\t\t\tid: messageGuidStr,\n\t\t\tcontent: asProviderGroup(items)\n\t\t};`,
"inbound multi attachment text child"
);
return source;
}
// Shift attachment part indices by one when a text child occupies slot 0. The
// push line is byte-identical in both mappers, so patch both occurrences.
function patchChildIndices(source) {
return replaceExactly(
source,
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(i, messageGuidStr), i, messageGuidStr));`,
`items.push(await buildAttachmentMessage(client, base, info, formatChildId(text2 ? i + 1 : i, messageGuidStr), text2 ? i + 1 : i, messageGuidStr));`,
2,
"multi attachment child index"
);
}
export function patchSpectrumTs(root = scriptDir()) {
const dist = path.join(
root,
"node_modules",
"@spectrum-ts",
"imessage",
"dist"
);
if (!fs.existsSync(dist)) {
throw new Error(`@spectrum-ts/imessage dist not found: ${dist}`);
}
const files = fs.readdirSync(dist)
.filter((name) => name.endsWith(".js"))
.map((name) => path.join(dist, name));
for (const file of files) {
const raw = fs.readFileSync(file, "utf8");
if (raw.includes(MARKER)) {
return { patched: false, file, reason: "already patched" };
}
// Normalize to LF for matching so the patch works regardless of the
// checkout's line-ending style (Windows git autocrlf produces CRLF,
// which would otherwise defeat the \n-based search strings). The
// original EOL style is restored on write. Indentation in the published
// tarball is tabs; the anchors match that directly.
const CR = String.fromCharCode(13);
const CRLF = CR + "\n";
const usedCRLF = raw.includes(CRLF);
const original = usedCRLF ? raw.split(CRLF).join("\n") : raw;
if (!original.includes("const toInboundMessages = async") ||
!original.includes("const rebuildFromAppleMessage = async")) {
continue;
}
// spectrum-ts 12.x replaced the attachment-only branches with
// `buildUnwrappedContentMessage` + `toOrderedParts`, which already emits a
// group containing both text and attachments. There is nothing left for
// Hermes to patch; keep the legacy v8 path below for older pinned installs.
if (
original.includes("const buildUnwrappedContentMessage = async") &&
original.includes("const parts = toOrderedParts(message.content.text, attachments);")
) {
return { patched: false, file, reason: "upstream preserves mixed payloads" };
}
let patched = original;
patched = patchRebuild(patched);
patched = patchInbound(patched);
patched = patchChildIndices(patched);
patched = `// ${MARKER}\n${patched}`;
if (usedCRLF) {
patched = patched.split("\n").join(CRLF);
}
fs.writeFileSync(file, patched, "utf8");
return { patched: true, file };
}
throw new Error("could not find @spectrum-ts/imessage iMessage inbound chunk to patch");
}
const _invokedDirectly =
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href;
if (_invokedDirectly) {
try {
const root = process.argv[2] ? path.resolve(process.argv[2]) : scriptDir();
const result = patchSpectrumTs(root);
const action = result.patched ? "patched" : "ok";
console.error(`photon-sidecar: spectrum mixed attachment patch ${action}: ${result.file}`);
} catch (err) {
console.error(`photon-sidecar: spectrum mixed attachment patch failed: ${err?.stack || err}`);
process.exit(1);
}
}
@@ -0,0 +1,27 @@
// Outbound /send builder selection for the Photon sidecar.
//
// spectrumMarkdown() enables data detection (enableDataDetection) in the
// underlying iMessage API, which can 500 on messages containing raw URLs.
// Plain-text URLs are auto-linked by iMessage anyway, so markdown messages
// that contain a URL are routed through the text builder, while URL-free
// markdown keeps native markdown rendering.
//
// This lives in its own module (rather than inline in index.mjs) so tests can
// execute the real decision logic under node instead of grepping source —
// see tests/plugins/platforms/photon/test_url_send_path.py.
const URL_RE = /https?:\/\/[^\s)'"<>]+/i;
/**
* Decide which spectrum-ts builder the /send handler should use.
*
* @param {string} format "markdown" | "text" (already validated by /send)
* @param {string} text the outbound message body
* @returns {"markdown"|"text"}
*/
export function chooseSendFormat(format, text) {
if (format === "markdown" && !URL_RE.test(String(text))) {
return "markdown";
}
return "text";
}
@@ -0,0 +1,80 @@
// Pure decision helpers for the zombie-stream (half-open gRPC) watchdog.
//
// spectrum-ts only reconnects when its inbound async iterator throws or ends.
// A half-open ("zombie") socket makes the iterator hang forever — no error,
// no end — so inbound silently dies while /healthz still looks fine. The
// watchdog in index.mjs tracks the last time the inbound iterator yielded and,
// once the stream has been silent past a conservative threshold, drives a
// cheap authenticated unary read over the same channel. STRICT semantics:
//
// - probe resolves, or rejects with a not-found-shaped error for our
// synthetic id -> ALIVE (the wire round-tripped)
// - probe rejects any other way (UNAVAILABLE, DEADLINE_EXCEEDED, network
// down, ...) -> INCONCLUSIVE — never treated as alive, and
// never treated as zombie-proof either
//
// A zombie is only declared when the stream is silent past the threshold AND
// a probe proves connectivity (the wire works but the stream is deaf). Silence
// alone NEVER degrades the stream: shared lines can be legitimately quiet for
// hours. Inconclusive probes NEVER degrade it either: the network may simply
// be down, and in that case the iterator will eventually throw and the
// existing re-subscribe loop recovers on its own.
//
// These helpers are pure (no SDK, no timers) so tests can execute them under
// node — see tests/plugins/platforms/photon/test_zombie_stream_watchdog.py.
// gRPC NOT_FOUND is code 5; SDKs also surface it as "not found" / "NotFound"
// message text. Anything not clearly not-found is inconclusive.
const NOT_FOUND_RE = /not[\s_-]?found/i;
/**
* Classify the rejection of the synthetic-id probe read.
*
* @param {unknown} err error thrown by `space.getMessage(<synthetic id>)`
* @returns {{alive: boolean, inconclusive: boolean, reason: string}}
*/
export function classifyProbeRejection(err) {
const code = err && typeof err === "object" ? err.code : undefined;
const message =
err && typeof err === "object" && err.message
? String(err.message)
: String(err);
if (code === 5 || code === "notFound" || NOT_FOUND_RE.test(message)) {
// Expected: the synthetic id doesn't exist. The unary call completed a
// round-trip, so the channel is provably alive.
return { alive: true, inconclusive: false, reason: "not-found round-trip" };
}
// Anything else (UNAVAILABLE, DEADLINE_EXCEEDED, TLS, auth, ...) does NOT
// prove liveness — and doesn't prove a zombie either.
return { alive: false, inconclusive: true, reason: message };
}
/**
* Should the watchdog probe at all this tick?
*
* @param {number} silentForMs ms since the inbound iterator last yielded
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
* @param {number} sinceLastProbeMs ms since the previous probe attempt
* @param {number} probeCooldownMs min spacing between probe attempts
* @returns {boolean}
*/
export function shouldProbe(silentForMs, thresholdMs, sinceLastProbeMs, probeCooldownMs) {
if (!(thresholdMs > 0)) return false;
if (silentForMs < thresholdMs) return false;
return sinceLastProbeMs >= probeCooldownMs;
}
/**
* Final classification: zombie only on silence past threshold + probe-proven
* connectivity. Never on silence alone, never on an inconclusive probe.
*
* @param {number} silentForMs ms since the inbound iterator last yielded
* @param {number} thresholdMs silence threshold (<= 0 disables the watchdog)
* @param {{alive: boolean}} probeOutcome
* @returns {boolean}
*/
export function isZombieSuspect(silentForMs, thresholdMs, probeOutcome) {
if (!(thresholdMs > 0)) return false;
if (silentForMs < thresholdMs) return false;
return probeOutcome != null && probeOutcome.alive === true;
}
+141
View File
@@ -0,0 +1,141 @@
"""
Resolve where the Photon sidecar runs from and where its Node deps live.
The sidecar source ships inside the installed plugin tree
(``plugins/platforms/photon/sidecar/``). On dev/source installs that tree is
writable and everything — ``npm ci``, the spectrum patch, the sidecar itself —
happens in place. Hosted/managed images instead keep the whole install tree
under an immutable ``/opt/hermes`` (read-only for the hermes user), which
broke every install/self-heal path with EROFS (NS-606).
Resolution order (mirrors ``resolve_whatsapp_bridge_dir`` for the Baileys
bridge, which hit the same wall):
1. ``PHOTON_SIDECAR_DIR`` env override — operator escape hatch, used as-is.
2. Source dir writable → run in place (dev installs, unchanged behavior).
3. Source dir read-only but ``node_modules`` is baked and current → run in
place. This is the managed-image happy path: the Dockerfile bakes the
sidecar deps with ``npm ci`` at build time (deterministic installs,
NS-559), so no runtime install is ever needed.
4. Source dir read-only and deps missing or stale → mirror the sidecar
source files to ``$HERMES_HOME/photon/sidecar`` (the durable data volume,
e.g. ``/opt/data`` on hosted) and return that. The caller's normal
install/self-heal machinery then works there because it is writable.
The mirror is refreshed on every resolve: when an image update changes a
sidecar source file, the changed file is re-copied (content compare, not
mtime) while ``node_modules`` is left in place — the adapter's existing
lockfile-vs-install-marker staleness check then triggers the ``npm ci``
self-heal inside the mirror.
This module is import-light on purpose: both ``adapter.py`` (gateway) and
``cli.py`` (``hermes photon ...``) use it.
"""
from __future__ import annotations
import filecmp
import logging
import os
import shutil
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
SOURCE_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# The files that define the sidecar. Mirrored into the writable runtime dir
# when the install tree is read-only. node_modules is deliberately absent —
# it is either baked (managed image) or installed by npm in the mirror.
_MIRROR_FILES = (
"index.mjs",
"package.json",
"package-lock.json",
"patch-spectrum-mixed-attachments.mjs",
)
def dir_writable(path: Path) -> bool:
"""True when we can create files in ``path`` (probe-based, not stat).
A stat-mode check lies on containers (root-squash, read-only bind
mounts), so probe with a real create+unlink like the WhatsApp bridge
resolver does.
"""
probe = path / ".hermes-write-probe"
try:
probe.touch()
probe.unlink()
return True
except OSError:
return False
# Backwards-friendly private alias for module-internal use.
_dir_writable = dir_writable
def _lock_newer_than_install(sidecar_dir: Path) -> bool:
"""True when the committed lockfile postdates npm's install marker.
Same signal as ``adapter._sidecar_deps_stale`` — duplicated here (three
lines) rather than imported so this module stays import-light for the
CLI. Returns False on any stat failure so an odd filesystem never forces
the mirror path.
"""
lockfile = sidecar_dir / "package-lock.json"
marker = sidecar_dir / "node_modules" / ".package-lock.json"
try:
return lockfile.stat().st_mtime > marker.stat().st_mtime
except OSError:
return False
def resolve_sidecar_dir(source_dir: Optional[Path] = None) -> Path:
"""Return the directory the sidecar should run from (see module doc).
``source_dir`` defaults to the installed plugin tree; tests and callers
that monkeypatch the adapter's ``_SIDECAR_DIR`` pass it through so the
override keeps working.
"""
source = Path(source_dir) if source_dir is not None else SOURCE_SIDECAR_DIR
override = os.getenv("PHOTON_SIDECAR_DIR")
if override:
return Path(override)
if _dir_writable(source):
return source
# Read-only install tree (hosted/managed image). If the image baked the
# deps at build time and they match the lockfile, run in place — the
# sidecar itself never writes inside its own directory.
if (source / "node_modules").exists() and not _lock_newer_than_install(source):
return source
# Deps missing or stale inside a read-only tree: mirror to the durable
# data volume so the normal install/self-heal machinery has somewhere
# writable to work.
from hermes_constants import get_hermes_home
mirror = get_hermes_home() / "photon" / "sidecar"
try:
mirror.mkdir(parents=True, exist_ok=True)
for name in _MIRROR_FILES:
src = source / name
if not src.exists():
continue
dst = mirror / name
if not dst.exists() or not filecmp.cmp(str(src), str(dst), shallow=False):
shutil.copy2(str(src), str(dst))
return mirror
except OSError as exc:
logger.warning(
"[photon] install tree is read-only and mirroring the sidecar "
"to %s failed (%s) — falling back to the read-only source dir; "
"dependency installs will not be possible",
mirror,
exc,
)
return source