Problema
--------
Quando o npm install do sidecar Photon falhava, o Hermes descartava toda
evidencia e continuava normalmente - deixando o adapter de iMessage
silenciosamente ausente, sem nenhuma mensagem de erro acionavel.
Tres falhas independentes formavam o caminho de falha silenciosa:
1. check_requirements() sem logging
Cada branch de return False retornava sem emitir nenhum log. O core em
platform_registry.py so consome o bool de check_fn() e loga uma mensagem
generica com o install_hint - sem acesso ao motivo real da falha.
if not HTTPX_AVAILABLE: return False # sem log
if not shutil.which(node): return False # sem log
if not node_modules.exists: return False # sem log
2. node_modules/ parcialmente criado passava o guard (Risk 2)
npm cria node_modules/ antes de abortar em ENOSPC, timeout de rede ou
EACCES. O diretorio existia, check_requirements() retornava True (falso
positivo), o adapter era registrado, e o crash acontecia em runtime com
um erro de modulo ausente aparentemente nao relacionado ao setup.
3. stderr do npm descartado (Risk 3)
subprocess.run sem stderr=PIPE. O output de erro aparecia no terminal
durante o setup e sumia depois - diagnostico impossivel em CI/CD, Docker,
VPS headless, e qualquer reinstalacao posterior.
Correcoes
---------
adapter.py - check_requirements() agora loga por branch:
- httpx ausente -> logger.warning com nome do pacote
- node nao no PATH -> logger.warning com nome do binario e env var
- spectrum-ts ausente -> logger.debug com path do sidecar + ultimo erro npm
(DEBUG nao WARNING: estado normal pre-setup; check_fn() e chamado de
5 hot paths do core incluindo polling do /api/status)
adapter.py - content check em vez de existence check (Risk 2):
antes: if not (_SIDECAR_DIR / node_modules).exists()
depois: if not (_SIDECAR_DIR / node_modules / spectrum-ts).exists()
spectrum-ts e a unica dependencia do package.json. Checar sua presenca
garante que instalacao parcial/abortada e detectada no boot do gateway,
nao na primeira mensagem recebida via gRPC.
cli.py - stderr capturado e persistido (Risk 3):
subprocess.run passa agora stderr=subprocess.PIPE, text=True em ambas as
chamadas (npm ci e npm install fallback). O stderr capturado e:
- impresso em sys.stderr imediatamente (output visivel no terminal)
- persistido em _NPM_ERROR_LOG = sidecar/.photon-npm-error.log se
returncode != 0, limitado a 300 chars
- apagado de _NPM_ERROR_LOG se returncode == 0 (evita erro stale)
check_requirements() le _NPM_ERROR_LOG quando spectrum-ts esta ausente e
inclui o conteudo no DEBUG log - o erro do npm sobrevive ao terminal, ao
restart do gateway e a reinicializacao da maquina.
sidecar/.gitignore - adicionado node_modules/ e .photon-npm-error.log.
Isolamento - sem impacto no core:
- check_fn() continua retornando apenas bool; core nao e modificado
- Logging usa namespace plugins.platforms.photon.adapter, isolado de
gateway.* e hermes_cli.*
- Cada plugin tem seu proprio check_requirements() independente
- OSError no write/read de _NPM_ERROR_LOG e silenciado - nunca propaga
Testes - 24/24 passando:
test_check_requirements_risks.py (7 testes):
WARNING emitido quando httpx ausente
WARNING emitido quando node nao no PATH
DEBUG emitido (nao WARNING) quando spectrum-ts ausente, com path
node_modules/ vazio agora retorna False (Risk 2 resolvido)
_NPM_ERROR_LOG escrito no stderr do npm em falha
_NPM_ERROR_LOG apagado apos npm bem-sucedido
erro npm aparece no DEBUG log quando node_modules ausente
test_npm_error_log_regression.py (9 testes - vetores de falha da solucao):
return code contrato intacto (0 em sucesso, nao-zero em falha)
OSError no write do log silenciado, exit code ainda propagado
OSError no read do log silenciado, check_requirements() retorna False
stderr vazio nao cria arquivo de log
proc.stderr=None nao lanca AttributeError
log stale apagado apos reinstall bem-sucedido
DEBUG emitido mesmo sem log de erro (setup pela primeira vez)
Closes #50981
Co-Authored-By: Claude Sonnet 4.6 <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.
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).