Hermes-agent

This commit is contained in:
Zakaria
2026-06-14 14:30:48 -04:00
commit dac4b88b94
5058 changed files with 1884848 additions and 0 deletions
+174
View File
@@ -0,0 +1,174 @@
# 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 ▼ │ /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
```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. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the
project secret, and persist both.
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=<spectrumProjectId> # the SDK's projectId
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": "<dashboard id>",
"spectrum_project_id": "<spectrumProjectId>",
"project_secret": "<projectSecret>",
"name": "Hermes Agent"
}
]
}
}
```
> **Note on ids.** A Photon project has two identifiers: the dashboard `id`
> (used for management API calls) and the `spectrumProjectId` (what the SDK
> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** 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. 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 v3's `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).
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
(`sidecar/node_modules/spectrum-ts/dist/*.d.ts` is the source of truth —
the hosted docs can lag).
4. Run `pytest tests/plugins/platforms/photon/`.
5. 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
+441
View File
@@ -0,0 +1,441 @@
"""
``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
_SIDECAR_DIR = Path(__file__).parent / "sidecar"
# ---------------------------------------------------------------------------
# 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 token).
token = photon_auth.load_photon_token()
if not token:
print("[1/5] No 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. Enable Spectrum, fetch the spectrum project id, rotate the secret,
# and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json).
try:
print("[3/5] Enabling Spectrum and provisioning credentials...")
proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id)
spectrum_id = proj.get("spectrumProjectId")
if not spectrum_id:
print("spectrum provisioning failed: no spectrum project id", file=sys.stderr)
return 1
spectrum_id = str(spectrum_id)
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.
print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved")
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
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_DIR / "node_modules").exists()
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). 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")
proc = subprocess.run( # noqa: S603
[npm, "ci"],
cwd=str(_SIDECAR_DIR),
check=False,
)
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,
)
if proc.returncode != 0:
print("npm install failed", file=sys.stderr)
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 ""
+88
View File
@@ -0,0 +1,88 @@
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_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,52 @@
# 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 (real inbound delivery is via
Photon's signed webhook hitting our Python aiohttp server)
## 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 publishes webhooks (inbound) but their docs state explicitly:
> Pass `space.id` to `Space.send(...)` from a separate `spectrum-ts`
> SDK instance to reply. No public HTTP send endpoint exists today.
— https://photon.codes/docs/webhooks/events
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 a single helper
(`_sidecar_send` in `adapter.py`) to make that swap a one-file change.
+690
View File
@@ -0,0 +1,690 @@
// Hermes Agent — Photon Spectrum sidecar
//
// Spawned by `plugins/platforms/photon/adapter.py` to bridge BOTH directions
// of messaging to Photon's Spectrum platform via the `spectrum-ts` SDK (the
// SDK is TypeScript-only, so a Node sidecar is unavoidable — there is no
// Python SDK and no public HTTP message API).
//
// Inbound (gRPC -> Hermes): the SDK's `app.messages` async iterator is a
// long-lived gRPC stream. We serialize each `[space, message]` to a
// normalized JSON event and stream it to the Python adapter over a
// loopback `GET /inbound` (NDJSON). We pause pulling from the stream while
// no consumer is attached so a backlog isn't pulled-and-lost before the
// gateway connects.
// Outbound (Hermes -> gRPC): `/send` drives `space.send(...)`; `/typing`
// sends the documented `typing("start" | "stop")` content builder.
//
// Protocol (all requests require `X-Hermes-Sidecar-Token: ${TOKEN}`):
// - GET /inbound -> 200 NDJSON stream; one JSON event per line, blank
// lines are heartbeats. One consumer at a time.
// - POST /healthz -> {"ok": true}
// - POST /send -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "text": "...",
// "format": "text" | "markdown" (default "text")}
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
// "mimeType": "..." | null, "caption": "..." | null,
// "kind": "attachment" | "voice"}
// - POST /react -> {"ok": true, "reactionId": "..." | null}
// body: {"spaceId": "...", "messageId": "<target msg id>",
// "emoji": "👀"}
// - POST /unreact -> {"ok": true} | 400 soft failure
// body: {"spaceId": "...", "messageId": "<target msg id>",
// "reactionId": "..." | null (restart-recovery fallback)}
// - POST /typing -> {"ok": true}
// body: {"spaceId": "...", "state": "start" | "stop"}
// - POST /shutdown -> {"ok": true}; then process exits
//
// On SIGINT/SIGTERM the sidecar calls `app.stop()` (3s graceful) before
// exiting. Logs go to stderr; Python supervises restart.
//
// Requires spectrum-ts 3.x — pinned exactly in package.json because the SDK
// ships breaking majors; see README "Upgrading spectrum-ts".
//
// Env vars (required):
// PHOTON_PROJECT_ID (== the project's spectrumProjectId)
// PHOTON_PROJECT_SECRET
// PHOTON_SIDECAR_PORT
// PHOTON_SIDECAR_TOKEN
// Optional:
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
// PHOTON_SIDECAR_WATCH_STDIN "1" = exit when stdin hits EOF (set by the
// adapter, which holds our stdin pipe — parent-death
// detection so a dead gateway can't orphan us)
// PHOTON_TELEMETRY enable Spectrum SDK telemetry ("true"/"1"/"on"/"yes";
// default off — toggle with `hermes photon telemetry`)
import http from "node:http";
import crypto from "node:crypto";
import { once } from "node:events";
const projectId = process.env.PHOTON_PROJECT_ID;
const projectSecret = process.env.PHOTON_PROJECT_SECRET;
const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
const telemetry = /^(1|true|yes|on)$/i.test(
(process.env.PHOTON_TELEMETRY || "").trim()
);
// Inbound binary content is read into memory and base64-inlined on the NDJSON
// event so the Python adapter can cache the real bytes (and the agent can see
// images / transcribe voice). Cap the size we inline — above it we forward
// metadata only and the adapter surfaces a text marker, so one large clip can't
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
const MAX_INLINE_ATTACHMENT_BYTES =
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
const E164_RE = /^\+\d{6,}$/;
const MAX_KNOWN_SPACES = 2048;
const MAX_KNOWN_MESSAGES = 1024;
const MAX_REACTION_HANDLES = 512;
if (!projectId || !projectSecret || !sharedToken) {
console.error(
"photon-sidecar: PHOTON_PROJECT_ID, PHOTON_PROJECT_SECRET and " +
"PHOTON_SIDECAR_TOKEN must all be set."
);
process.exit(2);
}
// Lazy-load spectrum-ts so a missing install fails with a clear message
// instead of a cryptic module-resolution error during import.
let Spectrum,
imessage,
attachment,
voice,
spectrumText,
spectrumMarkdown,
spectrumTyping;
try {
({
Spectrum,
attachment,
voice,
text: spectrumText,
markdown: spectrumMarkdown,
typing: spectrumTyping,
} = await import("spectrum-ts"));
({ imessage } = await import("spectrum-ts/providers/imessage"));
} catch (e) {
console.error(
"photon-sidecar: spectrum-ts is not installed. Run `npm install` " +
"inside plugins/platforms/photon/sidecar/. Original error: " +
(e && e.stack ? e.stack : String(e))
);
process.exit(3);
}
const app = await Spectrum({
projectId,
projectSecret,
providers: [imessage.config()],
options: { flattenGroups: true },
telemetry,
});
// ---------------------------------------------------------------------------
// Inbound: forward `app.messages` (gRPC stream) to the Python consumer.
// At most one Python consumer is attached at a time (the gateway adapter).
let consumerRes = null;
let consumerWaiters = [];
const knownSpaces = new Map();
// Inbound Message objects by id, so /react can usually skip a
// `space.getMessage` round trip when tapping back on a recent message.
const knownMessages = new Map();
// One reaction handle per reacted-to message (key `${spaceId}\0${messageId}`,
// value {emoji, handle}) — mirrors iMessage's one-tapback-per-sender
// semantics; a new /react on the same target overwrites the slot. The handle
// is the outbound reaction Message returned by `target.react()`, kept so
// /unreact can `unsend()` it later.
const reactionHandles = new Map();
function lruSet(map, key, value, cap) {
if (map.has(key)) map.delete(key);
map.set(key, value);
if (map.size > cap) {
const oldest = map.keys().next().value;
if (oldest !== undefined) map.delete(oldest);
}
}
function rememberKnownSpace(id, space) {
if (!id || typeof id !== "string" || !space) return;
lruSet(knownSpaces, id, space, MAX_KNOWN_SPACES);
}
function rememberKnownMessage(message) {
const id = message?.id;
if (!id || typeof id !== "string") return;
lruSet(knownMessages, id, message, MAX_KNOWN_MESSAGES);
}
function phoneTargetFromSpaceId(spaceId) {
if (typeof spaceId !== "string") return null;
if (E164_RE.test(spaceId)) return spaceId;
const dmGuid = spaceId.match(DM_CHAT_GUID_RE);
return dmGuid ? dmGuid[1] : null;
}
function rememberInboundSpace(space, message) {
const msgSpace = message?.space || {};
const ids = [space?.id, msgSpace.id];
for (const id of ids) {
rememberKnownSpace(id, space);
const phone = phoneTargetFromSpaceId(id);
if (phone) rememberKnownSpace(phone, space);
}
}
function waitForConsumer() {
if (consumerRes) return Promise.resolve();
return new Promise((resolve) => consumerWaiters.push(resolve));
}
function setConsumer(res) {
consumerRes = res;
const waiters = consumerWaiters;
consumerWaiters = [];
for (const resolve of waiters) resolve();
}
function clearConsumer(res) {
if (consumerRes === res) consumerRes = null;
}
// Write one NDJSON line to the active consumer. Blocks until a consumer is
// connected; if the write fails (consumer vanished mid-flight) we wait for a
// new consumer and retry, so a message is never silently dropped here.
async function deliver(line) {
for (;;) {
await waitForConsumer();
const res = consumerRes;
if (!res) continue;
try {
const flushed = res.write(line + "\n");
if (!flushed) await once(res, "drain");
return;
} catch {
clearConsumer(res);
}
}
}
async function normalizeBinaryContent(content) {
const meta = {
type: content.type,
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
if (content.type === "voice" && typeof content.duration === "number") {
meta.duration = content.duration;
}
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees images and can run
// STT on voice notes). Spectrum content objects may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
// size is known up front) is forwarded as metadata only and the adapter falls
// back to a text marker. A read failure must never break the inbound loop.
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
`photon-sidecar: failed to read ${content.type} bytes ` +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
async function normalizeContent(content) {
if (!content || typeof content !== "object") {
return { type: "unknown" };
}
if (content.type === "text") {
return { type: "text", text: content.text || "" };
}
if (content.type === "attachment" || content.type === "voice") {
return await normalizeBinaryContent(content);
}
if (content.type === "reaction") {
return {
type: "reaction",
emoji: content.emoji || "",
targetMessageId: content.target?.id ?? null,
// Lets Python gate "is this a reaction to one of MY messages" without
// tracking every outbound id. May be null if the provider doesn't
// hydrate the target — Python falls back to its own sent-id cache.
targetDirection: content.target?.direction ?? null,
};
}
return { type: content.type || "unknown" };
}
async function normalizeEvent(space, message) {
try {
const msgSpace = message.space || {};
const ts = message.timestamp;
return {
messageId: message.id ?? null,
platform: message.platform || space.__platform || "iMessage",
space: {
id: space.id ?? msgSpace.id ?? null,
// iMessage spaces carry `type` ("dm"|"group") and `phone` directly.
type: space.type ?? msgSpace.type ?? "dm",
phone: space.phone ?? msgSpace.phone ?? null,
},
sender: { id: message.sender ? message.sender.id : null },
content: await normalizeContent(message.content),
timestamp:
ts instanceof Date ? ts.toISOString() : ts ? String(ts) : null,
};
} catch (e) {
console.error(
"photon-sidecar: failed to normalize inbound message: " + String(e)
);
return null;
}
}
// spectrum-ts handles in-session gRPC reconnects internally, but if the async
// iterator itself throws or ends, this consumer would stop forever. Wrap it in
// a re-subscribe loop with capped exponential backoff + jitter so inbound
// always recovers (the adapter dedupes any catch-up replay).
(async () => {
let backoff = 1000;
for (;;) {
try {
for await (const [space, message] of app.messages) {
backoff = 1000; // healthy traffic — reset
// Only forward inbound messages (ignore our own outbound echoes).
if (message && message.direction && message.direction !== "inbound") {
continue;
}
rememberInboundSpace(space, message);
rememberKnownMessage(message);
const event = await normalizeEvent(space, message);
if (!event) continue;
await deliver(JSON.stringify(event));
}
console.error("photon-sidecar: inbound stream ended — re-subscribing");
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored — restarting: " +
(e && e.message ? e.message : String(e))
);
}
await new Promise((r) =>
setTimeout(r, backoff + Math.random() * backoff * 0.2)
);
backoff = Math.min(backoff * 2, 30000);
}
})();
// ---------------------------------------------------------------------------
// HTTP control + inbound server (loopback only).
// Control-message bodies are tiny; cap the body so a compromised local peer
// can't OOM the sidecar by streaming an unbounded request (defence-in-depth on
// the loopback channel).
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MiB
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy();
throw new Error("request body too large");
}
chunks.push(chunk);
}
const raw = Buffer.concat(chunks).toString("utf-8");
if (!raw) return {};
try {
return JSON.parse(raw);
} catch (e) {
throw new Error("invalid JSON body");
}
}
function unauthorized(res) {
res.statusCode = 401;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
}
function badRequest(res, msg) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: false, error: msg }));
}
function serverError(res) {
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
// Don't leak stack traces or raw exception text to the caller — even
// though we listen on loopback, the supervisor logs the real error
// and the client only needs a generic failure signal.
res.end(JSON.stringify({ ok: false, error: "internal sidecar error" }));
}
function ok(res, data) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ ok: true, ...data }));
}
function handleInbound(req, res) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/x-ndjson");
res.setHeader("Cache-Control", "no-store");
res.setHeader("Connection", "keep-alive");
// One consumer at a time — a fresh connection (e.g. after a reconnect)
// supersedes the previous one.
if (consumerRes && consumerRes !== res) {
try {
consumerRes.end();
} catch {
/* ignore */
}
}
setConsumer(res);
// Heartbeat keeps the socket warm through idle periods and lets the Python
// side detect a dead pipe promptly.
const heartbeat = setInterval(() => {
try {
res.write("\n");
} catch {
/* ignore */
}
}, 25000);
const cleanup = () => {
clearInterval(heartbeat);
clearConsumer(res);
};
req.on("close", cleanup);
req.on("aborted", cleanup);
res.on("error", cleanup);
}
async function resolveSpace(spaceId) {
const cached = knownSpaces.get(spaceId);
if (cached) return cached;
const im = imessage(app);
const phoneTarget = phoneTargetFromSpaceId(spaceId);
let space = null;
// A bare E.164 phone number addresses a DM, so callers can pass just
// "+1..." (e.g. PHOTON_HOME_CHANNEL for cron delivery) instead of an opaque
// inbound space id. Photon also represents DM chat ids as `any;-;+1...`;
// normalize those through the same path. `space.create` accepts the raw
// phone string directly.
if (phoneTarget) {
try {
space = await im.space.create(phoneTarget);
} catch (e) {
console.error(
"photon-sidecar: phone->DM space.create failed: " +
(e && e.stack ? e.stack : String(e))
);
}
}
// Anything else — typically an opaque group GUID — is rehydrated from the
// persisted id via `space.get`, so group spaces stay reachable after a
// sidecar restart even before any fresh inbound message in that group.
if (!space) {
try {
space = await im.space.get(spaceId);
} catch (e) {
console.error(
"photon-sidecar: space.get failed: " +
(e && e.stack ? e.stack : String(e))
);
}
}
if (!space) throw new Error(`unable to resolve space id ${spaceId}`);
rememberKnownSpace(spaceId, space);
if (phoneTarget) rememberKnownSpace(phoneTarget, space);
rememberKnownSpace(space?.id, space);
return space;
}
// Constant-time token comparison — don't leak the token via `!==` timing.
const _tokenBuf = Buffer.from(sharedToken);
function tokenOk(header) {
if (typeof header !== "string") return false;
const h = Buffer.from(header);
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
}
const server = http.createServer(async (req, res) => {
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
return unauthorized(res);
}
// Long-lived inbound NDJSON stream.
if (req.method === "GET" && req.url === "/inbound") {
return handleInbound(req, res);
}
if (req.method !== "POST") {
res.statusCode = 405;
return res.end();
}
try {
if (req.url === "/healthz") {
return ok(res, {});
}
if (req.url === "/shutdown") {
ok(res, {});
setTimeout(() => process.kill(process.pid, "SIGTERM"), 50);
return;
}
const body = await readBody(req);
if (req.url === "/send") {
const { spaceId, text, format = "text" } = body || {};
if (!spaceId || typeof text !== "string") {
return badRequest(res, "spaceId and text are required");
}
if (format !== "text" && format !== "markdown") {
return badRequest(res, "format must be text or markdown");
}
const space = await resolveSpace(spaceId);
// iMessage renders markdown natively; spectrum-ts degrades it to
// readable plain text on platforms that don't.
const builder =
format === "markdown" ? spectrumMarkdown(text) : spectrumText(text);
const result = await space.send(builder);
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/send-attachment") {
const { spaceId, path, name, mimeType, caption, kind } =
body || {};
if (!spaceId || typeof path !== "string" || !path) {
return badRequest(res, "spaceId and path are required");
}
const space = await resolveSpace(spaceId);
// spectrum-ts infers name + MIME from the file extension; pass
// overrides only when Hermes supplied them so a known-good
// inference isn't clobbered with an empty string.
const opts = {};
if (name) opts.name = name;
if (mimeType) opts.mimeType = mimeType;
const builder =
kind === "voice"
? voice(path, Object.keys(opts).length ? opts : undefined)
: attachment(path, Object.keys(opts).length ? opts : undefined);
const result = await space.send(builder);
// iMessage delivers the caption as a separate bubble; send it
// after the media so the attachment renders first.
if (caption && typeof caption === "string") {
try {
await space.send(spectrumText(caption));
} catch (e) {
console.error(
"photon-sidecar: attachment sent but caption failed: " +
(e && e.stack ? e.stack : String(e))
);
}
}
return ok(res, { messageId: result?.id || null });
}
if (req.url === "/react") {
const { spaceId, messageId, emoji } = body || {};
if (!spaceId || !messageId || typeof emoji !== "string" || !emoji) {
return badRequest(res, "spaceId, messageId and emoji are required");
}
const space = await resolveSpace(spaceId);
const target =
knownMessages.get(messageId) ?? (await space.getMessage(messageId));
if (!target) {
return badRequest(res, "message not found");
}
const handle = await target.react(emoji);
if (!handle) {
return badRequest(res, "reactions not supported on this platform");
}
lruSet(
reactionHandles,
`${spaceId}\u0000${messageId}`,
{ emoji, handle },
MAX_REACTION_HANDLES
);
return ok(res, { reactionId: handle.id ?? null });
}
if (req.url === "/unreact") {
const { spaceId, messageId, reactionId } = body || {};
if (!spaceId || !messageId) {
return badRequest(res, "spaceId and messageId are required");
}
const key = `${spaceId}\u0000${messageId}`;
const slot = reactionHandles.get(key);
if (slot) {
await slot.handle.unsend();
reactionHandles.delete(key);
return ok(res, {});
}
// Restart-recovery: the live handle is gone, so try rehydrating the
// reaction message by id and retracting it. Only outbound messages can
// be unsent — if the provider rehydrates it as inbound (or not at all)
// this throws, and that's an expected soft failure, not a sidecar bug:
// a stale tapback self-heals when the next /react replaces it.
if (reactionId) {
try {
const space = await resolveSpace(spaceId);
const msg = await space.getMessage(reactionId);
if (msg) {
await space.unsend(msg);
return ok(res, {});
}
} catch (e) {
console.error(
"photon-sidecar: best-effort unreact failed: " +
(e && e.message ? e.message : String(e))
);
}
return badRequest(res, "reaction not removable");
}
return badRequest(res, "no tracked reaction for message");
}
if (req.url === "/typing") {
const { spaceId, state = "start" } = body || {};
if (!spaceId) return badRequest(res, "spaceId is required");
if (state !== "start" && state !== "stop") {
return badRequest(res, "state must be start or stop");
}
const space = await resolveSpace(spaceId);
await space.send(spectrumTyping(state));
return ok(res, {});
}
res.statusCode = 404;
res.setHeader("Content-Type", "application/json");
return res.end(JSON.stringify({ ok: false, error: "not found" }));
} catch (e) {
console.error(
"photon-sidecar: handler error: " +
(e && e.stack ? e.stack : String(e))
);
// serverError() intentionally returns a generic message — see its
// body for the rationale.
return serverError(res);
}
});
server.listen(port, bind, () => {
console.error(`photon-sidecar: listening on ${bind}:${port}`);
});
let stopping = false;
async function shutdown(signal) {
// Re-entry guard: stdin EOF, a signal and /shutdown can all fire together
// during one teardown.
if (stopping) return;
stopping = true;
console.error(`photon-sidecar: received ${signal}, stopping...`);
try {
await Promise.race([
app.stop(),
new Promise((resolve) => setTimeout(resolve, 3000)),
]);
} catch (e) {
console.error("photon-sidecar: app.stop() failed: " + String(e));
}
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 500).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
// Lifetime binding to the parent. The adapter spawns us with stdin as a pipe
// it holds open; EOF means the gateway process is gone — including hard
// deaths (crash, SIGKILL) where no signal and no /shutdown ever reaches us.
// Without this, an orphaned sidecar squats the port and keeps consuming the
// inbound gRPC stream, and every replacement spawn dies on EADDRINUSE.
// Opt-in via env so manual `node index.mjs` runs aren't affected.
if (process.env.PHOTON_SIDECAR_WATCH_STDIN === "1") {
process.stdin.resume();
process.stdin.on("end", () => shutdown("stdin EOF (parent exited)"));
process.stdin.on("error", () => shutdown("stdin error (parent exited)"));
}
// Don't let a stray promise rejection take the process down silently — handlers
// catch their own errors, so log and keep serving (Python supervises restart on
// a real fatal exit).
process.on("unhandledRejection", (reason) => {
console.error(
"photon-sidecar: unhandledRejection: " +
(reason && reason.stack ? reason.stack : String(reason))
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
{
"name": "@hermes-agent/photon-sidecar",
"private": true,
"version": "0.3.0",
"description": "Spectrum-ts bridge for the Hermes Agent Photon platform plugin.",
"type": "module",
"main": "index.mjs",
"scripts": {
"start": "node index.mjs"
},
"engines": {
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "3.1.0"
},
"overrides": {
"protobufjs": "8.6.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"
}
}