tests: pin ink engine in _make_tui_argv npm-bootstrap tests (post-merge semantic fix)

Main's rewritten test_tui_npm_install.py tests call _make_tui_argv expecting
the Ink/npm flow unconditionally; with the dual-engine dispatch merged in,
_resolve_tui_engine() auto-selects opentui whenever ui-opentui/dist is built
in the repo, routing the call away from the path under test (first subprocess
became 'node --version' instead of 'npm run build'). Pin the engine to ink
via an autouse fixture, mirroring the existing pinning precedent in
test_tui_resume_flow.py.
This commit is contained in:
alt-glitch 2026-06-12 10:32:40 +05:30
parent ab37440ce6
commit e1067dbbe5
756 changed files with 79874 additions and 19585 deletions

View file

@ -20,6 +20,7 @@ import tempfile
import threading
import time
from collections import defaultdict
from contextlib import suppress
from typing import Callable, Dict, List, Optional, Any, Tuple
logger = logging.getLogger(__name__)
@ -68,6 +69,43 @@ from gateway.platforms.base import (
from tools.url_safety import is_safe_url
async def _wait_for_ready_or_bot_exit(
ready_event: asyncio.Event,
bot_task: asyncio.Task,
timeout: float,
) -> None:
"""Wait until Discord is ready, or surface early bot startup failure.
``discord.py`` startup errors (including SOCKS/proxy failures from
aiohttp-socks/python-socks) happen inside ``Bot.start()``. If ``connect()``
only waits on ``ready_event``, a dead background task still burns the full
ready timeout before the gateway supervisor can reconnect. Racing the ready
event against the bot task keeps failures fast and preserves the original
exception for logging/classification.
"""
ready_task = asyncio.create_task(ready_event.wait())
try:
done, _pending = await asyncio.wait(
{ready_task, bot_task},
timeout=timeout,
return_when=asyncio.FIRST_COMPLETED,
)
if not done:
raise asyncio.TimeoutError
if bot_task in done:
exc = bot_task.exception()
if exc is not None:
raise exc
if not ready_task.done():
raise RuntimeError("Discord bot task exited before ready")
await ready_task
finally:
if not ready_task.done():
ready_task.cancel()
with suppress(asyncio.CancelledError):
await ready_task
def _find_discord_windows_bundled_opus(discord_module: Any = None) -> Optional[str]:
"""Return discord.py's bundled Windows opus DLL path when present."""
if sys.platform != "win32":
@ -520,6 +558,7 @@ class VoiceReceiver:
],
check=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
finally:
try:
@ -601,6 +640,11 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
# linked text-channel id; set by run.py. Lets the inactivity timer leave
# the bot in the channel when the user deliberately picked text-only
# (/voice off) instead of leaving (/voice leave).
self._voice_mode_getter: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
# loop overlap in one outgoing stream instead of stop-and-swap.
@ -616,6 +660,10 @@ class DiscordAdapter(BasePlatformAdapter):
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._bot_task: Optional[asyncio.Task] = None
self._post_connect_task: Optional[asyncio.Task] = None
# True while disconnect() is intentionally closing discord.py. The
# bot task's done callback uses this to distinguish an operator/service
# shutdown from a runtime websocket crash.
self._disconnecting = False
# Dedup cache: prevents duplicate bot responses when Discord
# RESUME replays events after reconnects.
self._dedup = MessageDeduplicator()
@ -628,6 +676,65 @@ class DiscordAdapter(BasePlatformAdapter):
# scanning channel.history() on cache miss (cold start / restart).
self._last_self_message_id: Dict[str, str] = {}
def _handle_bot_task_done(self, task: asyncio.Task) -> None:
"""Surface post-startup discord.py task exits to the gateway supervisor.
discord.py reconnects normal gateway interruptions internally. When its
top-level ``Bot.start()`` task actually exits after the adapter has been
marked running, the Discord websocket is dead while the Hermes gateway
process can remain alive. Treat that split-brain state as a retryable
fatal adapter error so ``GatewayRunner._handle_adapter_fatal_error`` can
remove this adapter and queue Discord for the existing reconnect watcher.
"""
if getattr(self, "_disconnecting", False):
# Intentional service/operator shutdown. Drain the task result so
# asyncio doesn't emit "exception was never retrieved" warnings.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
# Ignore stale callbacks from an older client if a reconnect already
# installed a newer Bot.start() task on this adapter instance.
if self._bot_task is not None and task is not self._bot_task:
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
if not self._running:
# Startup failures are handled by _wait_for_ready_or_bot_exit() in
# connect(); this callback is only for post-startup split-brain.
with suppress(asyncio.CancelledError, Exception):
task.exception()
return
try:
exc = task.exception()
except asyncio.CancelledError:
return
except Exception as err: # pragma: no cover - defensive
exc = err
if exc is None:
message = "Discord gateway task exited without an exception"
else:
message = f"Discord gateway task exited: {exc}"
logger.error("[%s] %s", self.name, message, exc_info=exc if exc else False)
self._set_fatal_error("discord_gateway_task_exited", message, retryable=True)
async def _notify() -> None:
try:
await self._notify_fatal_error()
except Exception as notify_exc: # pragma: no cover - defensive logging
logger.warning(
"[%s] Failed to notify gateway supervisor about Discord task exit: %s",
self.name,
notify_exc,
exc_info=True,
)
asyncio.create_task(_notify())
async def connect(self) -> bool:
"""Connect to Discord and start receiving events."""
if not DISCORD_AVAILABLE:
@ -788,6 +895,7 @@ class DiscordAdapter(BasePlatformAdapter):
# Must run BEFORE the user allowlist check so that bots
# permitted by DISCORD_ALLOW_BOTS are not rejected for
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
_role_authorized = False
if getattr(message.author, "bot", False):
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
if allow_bots == "none":
@ -811,6 +919,7 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=_is_dm,
):
return
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
# Multi-agent filtering: if the message mentions specific bots
# but NOT this bot, the sender is talking to another agent —
@ -852,7 +961,7 @@ class DiscordAdapter(BasePlatformAdapter):
if "*" not in _free_channels and not (_channel_ids & _free_channels):
return
await self._handle_message(message)
await self._handle_message(message, role_authorized=_role_authorized)
@self._client.event
async def on_voice_state_update(member, before, after):
@ -892,25 +1001,55 @@ class DiscordAdapter(BasePlatformAdapter):
self._register_slash_commands()
# Start the bot in background
self._disconnecting = False
self._bot_task = asyncio.create_task(self._client.start(self.config.token))
self._bot_task.add_done_callback(self._handle_bot_task_done)
# Wait for ready
await asyncio.wait_for(self._ready_event.wait(), timeout=30)
# Wait for ready, but fail fast if discord.py's background startup
# task dies first (for example on SOCKS/proxy connect errors).
await _wait_for_ready_or_bot_exit(self._ready_event, self._bot_task, timeout=30)
self._running = True
return True
except asyncio.TimeoutError:
logger.error("[%s] Timeout waiting for connection to Discord", self.name, exc_info=True)
# Cancel the background bot task so it cannot fire on_message after
# this adapter is discarded. Without this, the task keeps running and
# a later successful reconnect leaves two active Discord clients that
# each process every message, producing duplicate threads/responses.
await self._cancel_bot_task()
self._release_platform_lock()
return False
except Exception as e: # pragma: no cover - defensive logging
logger.error("[%s] Failed to connect to Discord: %s", self.name, e, exc_info=True)
# Same zombie-client hazard as the timeout branch: the background
# client.start() task may already be running when a later setup
# step raises. Cancel it so the discarded adapter cannot connect.
await self._cancel_bot_task()
self._release_platform_lock()
return False
async def _cancel_bot_task(self) -> None:
"""Cancel and await the background client.start() task, if running."""
if self._bot_task and not self._bot_task.done():
self._bot_task.cancel()
try:
await self._bot_task
except (asyncio.CancelledError, Exception):
pass
self._bot_task = None
async def disconnect(self) -> None:
"""Disconnect from Discord."""
self._disconnecting = True
# Cancel the bot task before closing the client. If connect() timed out
# and returned False, the background client.start() task may still be
# running; calling client.close() alone is not enough to stop it because
# discord.py's reconnect loop can ignore the closed flag while a
# WebSocket handshake is in flight. Explicitly cancelling the task here
# ensures the zombie client cannot receive or dispatch any further events.
await self._cancel_bot_task()
# Clean up all active voice connections before closing the client
for guild_id in list(self._voice_clients.keys()):
try:
@ -2264,6 +2403,20 @@ class DiscordAdapter(BasePlatformAdapter):
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
# the channel (leaving is ``/voice leave``). The inactivity timer only
# counts the bot's OWN audio as activity, so under voice-off mode it
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
# text channel with "Left voice channel (inactivity timeout)." Honor the
# user's choice: skip the auto-disconnect while voice replies are off.
# (The timer re-arms when the bot next speaks or hears a user.)
_mode_getter = getattr(self, "_voice_mode_getter", None)
if text_ch_id is not None and _mode_getter is not None:
try:
if _mode_getter(str(text_ch_id)) == "off":
return
except Exception:
pass
await self.leave_voice_channel(guild_id)
# Notify the runner so it can clean up voice_mode state
if self._on_voice_disconnect and text_ch_id:
@ -2394,6 +2547,11 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=False,
):
continue
# A user speaking to the bot is activity too — not just the
# bot's own playback. Reset the inactivity timer so an active
# listener isn't disconnected mid-conversation (this also
# covers voice-on text-only sessions that never play audio).
self._reset_voice_timeout(guild_id)
await self._process_voice_input(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
@ -4701,7 +4859,7 @@ class DiscordAdapter(BasePlatformAdapter):
raise Exception(f"HTTP {resp.status}")
return await resp.read()
async def _handle_message(self, message: DiscordMessage) -> None:
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
# UNLESS the channel is in the free-response list or the message is
@ -4885,6 +5043,7 @@ class DiscordAdapter(BasePlatformAdapter):
guild_id=str(guild.id) if guild else None,
parent_chat_id=parent_channel_id,
message_id=str(message.id),
role_authorized=role_authorized,
)
# Build media URLs -- download image attachments to local cache so the
@ -5610,6 +5769,7 @@ def _define_discord_view_classes() -> None:
self.allowed_role_ids = allowed_role_ids or set()
self.resolved = False
self._selected_provider: str = ""
self._pending_expensive_model: str = ""
self._build_provider_select()
@ -5692,6 +5852,41 @@ def _define_discord_view_classes() -> None:
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
def _build_expensive_confirm(self, model_id: str):
"""Build confirmation buttons for unusually expensive models."""
self.clear_items()
self._pending_expensive_model = model_id
confirm_btn = discord.ui.Button(
label="Switch anyway",
style=discord.ButtonStyle.red,
custom_id="model_expensive_confirm",
)
confirm_btn.callback = self._on_expensive_confirm
self.add_item(confirm_btn)
cancel_btn = discord.ui.Button(
label="Cancel",
style=discord.ButtonStyle.grey,
custom_id="model_expensive_cancel",
)
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
async def _expensive_warning_for(self, model_id: str):
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
return await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=self._selected_provider,
)
except Exception:
return None
async def _on_provider_selected(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
@ -5721,7 +5916,11 @@ def _define_discord_view_classes() -> None:
view=self,
)
async def _on_model_selected(self, interaction: discord.Interaction):
async def _switch_selected_model(
self,
interaction: discord.Interaction,
model_id: str,
):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
@ -5734,7 +5933,6 @@ def _define_discord_view_classes() -> None:
return
self.resolved = True
model_id = interaction.data["values"][0]
self.clear_items()
await interaction.response.edit_message(
embed=discord.Embed(
@ -5763,6 +5961,50 @@ def _define_discord_view_classes() -> None:
view=None,
)
async def _on_model_selected(self, interaction: discord.Interaction):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
model_id = interaction.data["values"][0]
warning = await self._expensive_warning_for(model_id)
if warning is not None:
self._build_expensive_confirm(model_id)
await interaction.response.edit_message(
embed=discord.Embed(
title="⚠ Expensive Model Warning",
description=warning.message,
color=discord.Color.red(),
),
view=self,
)
return
await self._switch_selected_model(interaction, model_id)
async def _on_expensive_confirm(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
if not self._pending_expensive_model:
await interaction.response.send_message(
"Model selection expired.", ephemeral=True
)
return
await self._switch_selected_model(
interaction,
self._pending_expensive_model,
)
async def _on_back(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(

View file

@ -320,6 +320,7 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
],
capture_output=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.warning("decode_to_pcm failed for %s: %s", path, e)

View file

@ -0,0 +1,137 @@
# 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` are loopback POSTs to the sidecar,
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 --platform photon
```
`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** (`spectrum-ts`).
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 |
## 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.
- **Reactions, message effects, polls** — supported by `spectrum-ts` but not
yet exposed; the sidecar is the natural place to add them.
[photon]: https://photon.codes/

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

View file

@ -0,0 +1,387 @@
"""
``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/
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")
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)
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 --platform photon")
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`'}")
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 _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
# Always pull the newest published spectrum-ts so every setup runs against
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
# to the current release before installing — a plain `npm install` would
# stay pinned to whatever the committed lockfile already resolved.
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
proc = subprocess.run( # noqa: S603
[npm, "install", "spectrum-ts@latest"],
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 ""

View file

@ -0,0 +1,76 @@
name: photon-platform
label: iMessage via Photon
kind: platform
version: 0.2.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

View file

@ -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.

View file

@ -0,0 +1,542 @@
// 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": "..."}
// - POST /send-attachment -> {"ok": true, "messageId": "..."}
// body: {"spaceId": "...", "path": "...", "name": "..." | null,
// "mimeType": "..." | null, "caption": "..." | null,
// "kind": "attachment" | "voice"}
// - 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.
//
// 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)
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;
// 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;
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, spectrumTyping;
try {
({
Spectrum,
attachment,
voice,
text: spectrumText,
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 },
});
// ---------------------------------------------------------------------------
// 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();
function rememberKnownSpace(id, space) {
if (!id || typeof id !== "string" || !space) return;
if (knownSpaces.has(id)) knownSpaces.delete(id);
knownSpaces.set(id, space);
if (knownSpaces.size > MAX_KNOWN_SPACES) {
const oldest = knownSpaces.keys().next().value;
if (oldest) knownSpaces.delete(oldest);
}
}
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);
}
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);
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 phoneTarget = phoneTargetFromSpaceId(spaceId);
// A bare E.164 phone number addresses a DM. Resolve the user, then the (DM)
// space — `imessage(app).user(phone)` -> `im.space(user)` — 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 so replies to inbound
// DMs still resolve after Python stores the inbound `space.id`.
if (phoneTarget && imessage) {
try {
const im = imessage(app);
const user = await im.user(phoneTarget);
const space = await im.space(user);
rememberKnownSpace(spaceId, space);
rememberKnownSpace(phoneTarget, space);
rememberKnownSpace(space?.id, space);
return space;
} catch (e) {
console.error(
"photon-sidecar: phone->DM resolution failed: " +
(e && e.stack ? e.stack : String(e))
);
}
}
// No cache hit and not a phone/DM target. spectrum-ts exposes no API to
// rehydrate an arbitrary opaque space id: a Space is only obtained from the
// inbound `[space, message]` stream (cached above in `knownSpaces`) or
// reconstructed for a DM from its phone number. So a group space whose cache
// entry was lost — e.g. after a sidecar restart with no fresh inbound message
// in that group — cannot be resolved here; a new inbound message in the group
// re-warms the cache. DMs are unaffected (reconstructed from the phone).
throw new Error(`unable to resolve space id ${spaceId}`);
}
// 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 } = body || {};
if (!spaceId || typeof text !== "string") {
return badRequest(res, "spaceId and text are required");
}
const space = await resolveSpace(spaceId);
const result = await space.send(spectrumText(text));
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 === "/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}`);
});
async function shutdown(signal) {
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"));
// 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

View file

@ -0,0 +1,24 @@
{
"name": "@hermes-agent/photon-sidecar",
"private": true,
"version": "0.2.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": "^1.18.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"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
name: simplex-platform
label: SimpleX Chat
kind: platform
version: 1.0.0
version: 1.1.0
description: >
SimpleX Chat gateway adapter for Hermes Agent.
Connects to a local simplex-chat daemon via WebSocket and relays
@ -9,7 +9,7 @@ description: >
SimpleX is decentralised and assigns no persistent user IDs —
every contact is an opaque internal ID generated at connection
time, making it one of the most private messengers available.
author: Mibayy
author: Mibayy, jooray
# ``requires_env`` and ``optional_env`` entries are surfaced in the
# ``hermes config`` UI via the platform-plugin env var injector in
# ``hermes_cli/config.py``.
@ -27,6 +27,18 @@ optional_env:
description: "Allow any contact to talk to the bot (dev only — disables allowlist)"
prompt: "Allow all contacts? (true/false)"
password: false
- name: SIMPLEX_AUTO_ACCEPT
description: "Auto-accept incoming contact requests (default: true)"
prompt: "Auto-accept contact requests? (true/false)"
password: false
- name: SIMPLEX_GROUP_ALLOWED
description: >-
Comma-separated SimpleX group IDs the bot should participate in, or
'*' to allow any group. Omit to ignore group messages entirely
(safer default — a bot in a group otherwise processes every
member's traffic).
prompt: "Allowed group IDs (comma-separated, or '*' for any)"
password: false
- name: SIMPLEX_HOME_CHANNEL
description: "Default contact/group ID for cron / notification delivery"
prompt: "Home channel contact/group ID (or empty)"
@ -35,3 +47,10 @@ optional_env:
description: "Human label for the home channel (defaults to the ID)"
prompt: "Home channel display name (or empty)"
password: false
- name: HERMES_SIMPLEX_TEXT_BATCH_DELAY
description: >-
Quiet-period seconds (default: 0.8) used to concatenate rapid-fire
inbound text messages into a single MessageEvent — same pattern as
Telegram's text batching.
prompt: "Text batch flush delay in seconds (default 0.8)"
password: false