hermes-agent/plugins/platforms/photon
joaomarcos de5c39c033 fix(photon): surface npm install failures in check_requirements() diagnostic chain
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>
2026-07-28 18:20:06 -07:00
..
sidecar fix(photon): surface npm install failures in check_requirements() diagnostic chain 2026-07-28 18:20:06 -07:00
__init__.py feat(gateway): add Photon Spectrum (iMessage) platform plugin 2026-06-08 13:38:30 -07:00
adapter.py fix(photon): surface npm install failures in check_requirements() diagnostic chain 2026-07-28 18:20:06 -07:00
auth.py refactor(photon): route setup token check through validate_photon_token 2026-07-28 18:20:06 -07:00
cli.py fix(photon): surface npm install failures in check_requirements() diagnostic chain 2026-07-28 18:20:06 -07:00
plugin.yaml feat(photon): upgrade to spectrum-ts 3.0.0 (pinned) with markdown + reactions 2026-06-12 01:07:38 -07:00
README.md fix(photon): upgrade spectrum-ts sidecar to v8.0.0 2026-06-27 00:51:34 -07:00

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.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-attachment, /typing, /react, /unreact), authenticated with a shared X-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:

  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:

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. 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.
  • 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. PHOTON_MARKDOWN=false reverts 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 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(id).
  • Message effects, polls — supported by spectrum-ts but 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:

  1. Read the SDK release notes 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).