Follow-up to #69112. That PR hardened the shared gateway dispatch path in gateway/run.py against the *caller* being cancelled. A second, self-referential cancellation specific to PhotonAdapter sits one layer underneath it and survived that fix. PhotonAdapter is the only platform adapter that awaits _notify_fatal_error() inline, on the same task that detected the fault. Both _monitor_sidecar_health and _supervise_sidecar run as self._sidecar_health_task / self._sidecar_supervisor_task, and the notification routes into GatewayRunner._handle_adapter_fatal_error_impl, which tears the adapter down via _safe_adapter_disconnect -> disconnect(). disconnect() then cancels self._sidecar_health_task and awaits it -- which, when the health task is what raised the notification, means disconnect() cancels its own caller several plain-await frames up. disconnect()'s `task is not asyncio.current_task()` guard does not catch this. The current task where that guard evaluates is the wrapper _await_adapter_cleanup_with_timeout creates around disconnect() via asyncio.ensure_future, not the health task further up the chain, so the guard passes and the cancel lands. CancelledError stopped subclassing Exception in Python 3.8, so the `except Exception` that wrapped the inline notify call never saw it. The health task died silently mid-handoff: no log line, no "exception never retrieved" warning (cancellation is normal asyncio), and no retry. The platform stayed stranded until the gateway was restarted by hand. Fix: dispatch the notification onto a new task, the same pattern DiscordAdapter._handle_bot_task_done already uses for this reason. disconnect() can then cancel the health/supervisor task freely without that cancellation reaching the code still running the handoff, so the handoff always reaches the reconnect queue. Both Photon fatal call sites are converted: the health-poll path (observed wedging) and the sidecar-crash path (same shape, not yet observed). gateway/run.py is untouched. Observed twice on a self-hosted gateway, ~4h38m and ~52min of silent inbound outage, both cleared only by a manual restart, both post-dating #69112's merge. In each case the fatal log line appears and `queued for background reconnection` never does. Tests: new tests/plugins/platforms/photon/test_fatal_notify_self_cancel.py covers the self-cancellation (fails with CancelledError without this change), that the dispatch does not block its caller, that a failing notification warns rather than raising, and a source guard against reintroducing either inline await. Two assertions in test_overflow_recovery.py that drove these coroutines directly now drain pending tasks before asserting delivery, since the notification is deliberately no longer awaited inline. Prepared with agent assistance and reviewed before submission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|---|---|---|
| .. | ||
| sidecar | ||
| __init__.py | ||
| adapter.py | ||
| auth.py | ||
| cli.py | ||
| plugin.yaml | ||
| README.md | ||
Photon iMessage platform plugin
This plugin connects Hermes Agent to iMessage (and other Spectrum interfaces) through 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 ▼ │ /typing
┌──────────────────────┐
│ PhotonAdapter │
│ (Python, in gateway) │
└──────────────────────┘
- Inbound: the sidecar consumes the SDK's
app.messagesgRPC stream, normalizes each message, and streams it to the adapter over a loopbackGET /inbound(NDJSON). The adapter dedupes onmessageIdand dispatches aMessageEventto 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-attachment,/typing,/react,/unreact), authenticated with a sharedX-Hermes-Sidecar-Token.
First-time setup
# 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:
- Device login (RFC 8628,
client_id=photon-cli) — openshttps://app.photon.codes/for approval and stores the bearer token. - Find or create the
Hermes Agentproject on the Photon dashboard. - Provision the project secret — mint a fresh project secret (the
dashboard reveals it only once) and persist it to
~/.hermes/.envso the sidecar can authenticatespectrum-ts. Spectrum is always on, so there's no separate enable step. - Register your phone number as a Spectrum user (idempotent — skipped if a user with that number already exists).
- Print the assigned iMessage line — the number you text to reach your agent.
- Install the sidecar deps (
npm ci— installs the committed lockfile verbatim, so every setup runs the exactspectrum-tsversion 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:
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:
{
"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. Thedashboard_project_idandspectrum_project_idkeys inauth.jsonboth 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 populatesmedia_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 thanPHOTON_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. - Outbound attachments are supported. Images, voice notes, video, and
documents are sent via
space.send(attachment(...))/space.send(voice(...))through the sidecar's/send-attachmentendpoint; 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.PHOTON_MARKDOWN=falsereverts to stripped plain text. - 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 syntheticreaction: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(id). - Message effects, polls — supported by
spectrum-tsbut not yet exposed; the sidecar is the natural place to add them. - 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/healthzreadiness 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 outbound-only, so inbound rich 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:
- Read the SDK release notes for every version between the current pin and the target.
- Bump the exact pin in
sidecar/package.json, then runnpm installinsidesidecar/to regeneratepackage-lock.json. Commit both. - Migrate
sidecar/index.mjsagainst the new typings.spectrum-tsre-exports@spectrum-ts/core(the framework:Spectrum, content builders,Space/Message) and@spectrum-ts/imessage(the provider), so the source of truth issidecar/node_modules/@spectrum-ts/{core,imessage}/dist/*.d.ts(the hosted docs can lag). - Re-validate
sidecar/patch-spectrum-mixed-attachments.mjs. It rewrites the compiled iMessage inbound mappers in@spectrum-ts/imessage/dist/index.jsso a bubble with both text and attachments keeps its typed text; the anchors are tied to that build's output.npm installruns it viapostinstalland fails loudly if the anchors no longer match — update them to the new output (test_spectrum_patch.pycovers the patch). - Run
pytest tests/plugins/platforms/photon/. - 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 (exercisesspace.getrehydration).