feat(plugin): bundle relay management surface
Align the relay plugin/server metadata to 1.1.0 and add a version-track checker for Android, server/plugin, and desktop release surfaces.
This commit is contained in:
+11
-1
@@ -10,6 +10,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Added
|
||||
|
||||
- **Relay plugin diagnostics and install guidance.** `hermes relay doctor` now reports standard upstream API/dashboard reachability, Relay loopback state, dashboard plugin presence, plugin-manager layout, and whether the legacy bootstrap monkeypatch is installed. The plugin manifest now advertises its Android and desktop tools, and `after-install.md` gives the upstream plugin manager a first-run handoff.
|
||||
|
||||
- **Plugin-owned compatibility hook lifecycle.** `hermes relay compat status/install/remove` now owns the optional `hermes_relay_bootstrap.pth` startup hook, so the monkeypatch can be inspected, added, or removed without rerunning the legacy installer. The standard v1.0.0 path does not require this hook.
|
||||
|
||||
- **Legacy cleanup alignment.** The legacy installer now installs the optional `.pth` hook through the plugin compat lifecycle, and the uninstaller removes every shell shim it creates (`hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale`) while delegating hook cleanup to `hermes relay compat remove` when available.
|
||||
|
||||
- **Gateway chat transport with live thinking.** Chat can ride the upstream dashboard `/api/ws` (the `tui_gateway` surface the official hermes-desktop client speaks) — the only vanilla-upstream path that streams reasoning *live*, so the Thinking block and sphere light up during generation. "Auto" prefers it when the dashboard is reachable and Manage is signed in, and falls back to the SSE endpoints per turn.
|
||||
|
||||
- **Gateway desktop parity.** Native image/PDF/file attachments (with an in-chat notice when a turn falls back to a transport that can't carry files), mid-turn **steering**, **edit & resend**, interactive **approval / clarify / sudo / secret** cards, live **subagent lanes**, a **context-window meter**, server **slash commands** in autocomplete, and **turn-complete notifications** when the app is backgrounded.
|
||||
@@ -30,6 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
### Changed
|
||||
|
||||
- **Relay plugin/server version aligned to v1.0.0.** The Python package, plugin manifest, dashboard manifest, and relay runtime now use the same `1.0.0` line as the stable Android release so a retagged source checkout describes one product version.
|
||||
|
||||
- **The standard (no-plugin) path is first-class.** Chat, Manage, and voice all work against an unmodified upstream Hermes agent; standard voice rides the dashboard audio surface (`/api/audio/*`) with the Manage sign-in, and relay-paired voice is the profile-aware fallback. The relay plugin is now purely additive.
|
||||
|
||||
- **Seamless connection UX.** LAN↔Tailscale handoffs and reconnects no longer reload the chat; connection and update status are now in-theme slide-down toasts over the content instead of banners that pushed the UI around.
|
||||
@@ -1225,7 +1233,9 @@ MVP release — native Android companion app for Hermes agent with direct API ch
|
||||
- **Dev scripts** — build, install, run, test, relay via scripts/dev.bat
|
||||
- **ProGuard rules** — okhttp-sse, markdown renderer, intellij-markdown parser
|
||||
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...HEAD
|
||||
[Unreleased]: https://github.com/Codename-11/hermes-relay/compare/android-v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v1.0.0
|
||||
[0.8.1]: https://github.com/Codename-11/hermes-relay/compare/android-v0.8.0...android-v0.8.1
|
||||
[0.8.0]: https://github.com/Codename-11/hermes-relay/compare/v0.7.0...android-v0.8.0
|
||||
[0.7.0]: https://github.com/Codename-11/hermes-relay/compare/v0.6.1...v0.7.0
|
||||
[0.1.0]: https://github.com/Codename-11/hermes-relay/compare/v0.1.0-beta...v0.1.0
|
||||
|
||||
@@ -4,18 +4,20 @@
|
||||
|
||||
## What This Is
|
||||
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with a Python relay server (aiohttp) for the Hermes agent platform. Chat connects directly to the Hermes API Server via HTTP/SSE; bridge and terminal use a relay over WSS.
|
||||
A native Android app (Kotlin + Jetpack Compose) paired with an optional Python relay plugin/server (aiohttp) for the Hermes agent platform. Standard chat, Manage, and dashboard voice work against unmodified upstream Hermes. Relay adds phone control, terminal, remote desktop tooling, extra voice engines, and dashboard Relay management.
|
||||
|
||||
**Current state:** v0.8.0 (release-prep on `dev`) — Phase 0–3 complete. Direct API chat, session management, pairing + security (now multi-endpoint, ADR 24), inbound media, voice mode (stable Hermes Chat + Voice Output plus opt-in provider-native Realtime Agent with reliable low-latency playback and a text/mic Voice Lab), bridge/accessibility control, notification companion, safety rails, multi-Connection, agent profiles + inspector, connection diagnostics, and first-class Tailscale (ADR 25). Two product flavors: `googlePlay` (conservative, Bridge Core without Device Control) and `sideload` (full-capability).
|
||||
**Current state:** v1.0.0 stable. The default no-plugin path supports chat, Manage, and voice on vanilla upstream Hermes. Chat auto-prefers the dashboard `/api/ws` gateway transport when Manage auth is ready, then falls back to API-server SSE routes. Standard voice uses dashboard `/api/audio/*` with the Manage session. Relay remains an additive power path for terminal, bridge/device control, notification companion, extra/provider-native voice, remote access, and desktop tooling. Two Android product flavors ship: `googlePlay` (conservative, no unattended Device Control surface) and `sideload` (full-capability).
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
Phone (WS) -> Hermes dashboard (:9119) [standard gateway chat, live thinking]
|
||||
Phone (HTTP/SSE) -> Hermes API Server (:8642) [standard chat fallback, sessions, runs]
|
||||
Phone (HTTP) -> Hermes dashboard (:9119) [standard Manage + voice]
|
||||
Phone (WSS/HTTP) -> Relay plugin/server (:8767) [optional bridge, terminal, relay voice, remote tools]
|
||||
```
|
||||
|
||||
Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is optional — most local setups run without one. Terminal will go through tmux via the relay. Bridge wraps existing relay protocol. See docs/decisions.md for why.
|
||||
The standard path must stay vanilla upstream only. API-server bearer auth and dashboard cookie auth are separate. Terminal and bridge require Relay pairing; standard chat, Manage, and dashboard voice must not.
|
||||
|
||||
### Upstream Hermes API Reference
|
||||
|
||||
@@ -42,7 +44,7 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
|
||||
Upstream main now contains the focused session-control API (`#33134`) and read-only skills/toolsets (`#33016`). The original broad PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) was closed as superseded. Keep these distinctions straight:
|
||||
|
||||
1. **Native upstream** — `/api/sessions`, `/api/sessions/{id}/messages`, `/api/sessions/{id}/chat`, `/api/sessions/{id}/chat/stream`, `/v1/capabilities`, `/v1/skills`, and `/v1/toolsets` exist in current `gateway/platforms/api_server.py`.
|
||||
2. **Bootstrap compatibility** (`hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path.
|
||||
2. **Bootstrap compatibility** (`plugin/hermes_relay_bootstrap/`) — monkey-patches aiohttp on startup via `.pth` file for older or partial core builds. It skips native routes per method/path and should be retired per surface, not treated as the preferred path. The repo-root `hermes_relay_bootstrap/` package is a legacy import shim.
|
||||
3. **Legacy fork branches** — useful as lineage only. Do not cite `feat/session-api` / `#8556` as the current upstream contract.
|
||||
|
||||
| Endpoint | Purpose | Provided by |
|
||||
@@ -65,7 +67,7 @@ The Android client probes per-endpoint capability via `HermesApiClient.probeCapa
|
||||
|
||||
hermes-agent ships a second web server at `hermes_cli/web_server.py` that hosts the React admin dashboard at `hermes_cli/web_dist/`. It has its **own** `/api/*` routes that **do not live on `api_server.py`** — notably: `GET/PUT /api/config` (full tree), `GET /api/config/schema`, `GET /api/config/defaults`, `GET/PUT /api/config/raw` (YAML text), `GET/PUT/DELETE /api/env` + `POST /api/env/reveal`, `PUT /api/skills/toggle`, `/api/cron/jobs/*` (different shape from `/api/jobs/*`), `/api/providers/oauth/*`, `/api/dashboard/themes`, `/api/dashboard/plugins`, `/api/model/info` + `/api/model/options` + `POST /api/model/set`, `/api/profiles/*` (CRUD, `POST /api/profiles/active`, per-profile soul/description/model), `/api/mcp/*`, `/api/logs`, `/api/analytics/usage`, and **`POST /api/audio/transcribe` + `POST /api/audio/speak`** (base64 data-url contract, built for hermes-desktop voice). The API server has **no audio routes** — its `/v1/capabilities` advertises `audio_api: false`; PR #8199 (`/v1/audio/*`) is the canonical future surface but is unmerged. Android's **standard (no-plugin) voice** therefore rides this dashboard surface via `StandardHermesVoiceClient` with the per-connection dashboard cookie session (Manage sign-in unlocks voice); `AutoVoiceAudioClient` prefers Relay when paired and falls back to standard.
|
||||
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`; Android Chat still uses the API-server bearer path until a dashboard `/api/ws` chat adapter is wired. Note the event-richness gap: `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths emit reasoning only post-hoc (`reasoning.available` → `tool.progress` with `tool_name:"_thinking"`, ≤500 chars; full text in `run.completed.messages[].reasoning`). Android Manage may consume this dashboard surface directly, but relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
Current upstream supports two auth modes on this surface. Loopback dashboards still use the injected `window.__HERMES_SESSION_TOKEN__` path. Remote/non-loopback dashboards use the Desktop-style dashboard auth gate: `/api/status` advertises `auth_required` and providers, `/auth/password-login` handles password providers, `/auth/login?provider=...` handles Nous/OIDC redirects, `/api/auth/me` returns the verified session, and `/api/auth/ws-ticket` mints a short-lived ticket for `/api/ws` / `/api/pty`. This dashboard session is **not** an `API_SERVER_KEY`. Android uses it for Manage, standard voice, and the gateway chat transport. `/api/ws` is backed by `tui_gateway/server.py` (what hermes-desktop + the Ink TUI speak) and is the only upstream surface with **live** `reasoning.delta`/`thinking.delta` streaming; the api_server SSE paths remain the standard fallback. Relay-only capabilities remain behind Relay pairing. **Do not proxy dashboard auth or dashboard admin APIs over the relay.**
|
||||
|
||||
**Tool call rendering paths:**
|
||||
1. **Runs API** — Emits `tool.started`/`tool.completed` as real SSE events → `ToolProgressCard` in real-time.
|
||||
@@ -73,10 +75,10 @@ Current upstream supports two auth modes on this surface. Loopback dashboards st
|
||||
3. **Annotation parser** — Fallback for servers emitting inline markdown annotations (`` `💻 terminal` ``).
|
||||
|
||||
## Key Instructions
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — chat via the API server, standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Standard path = vanilla upstream only.** The default (no-plugin) connection path — gateway/API chat, Manage, and standard voice via the dashboard surface — must work against **unmodified upstream hermes-agent**: no fork patches, no bespoke server config as a dependency. The app ships on Google Play to users whose servers we don't control. Features that need server-side changes go through upstream PRs (with graceful degradation until merged) or live behind the opt-in relay plugin.
|
||||
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document whether bootstrap injects it or it requires the fork.
|
||||
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver degrades gracefully.
|
||||
- **Bootstrap maintenance:** Retire `hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
- **Bootstrap maintenance:** Retire `plugin/hermes_relay_bootstrap/` per surface. Sessions and read-only skills/toolsets now have native upstream replacements; config, memory, legacy skill detail/toggle, available-models, and slash middleware still need explicit replacement decisions before full removal.
|
||||
|
||||
## Repository Layout
|
||||
|
||||
@@ -116,7 +118,7 @@ hermes-android/
|
||||
│ ├── tools/ # android_navigate.py, android_notifications.py
|
||||
│ └── dashboard/ # hermes-agent dashboard plugin — manifest, React UI, FastAPI proxy
|
||||
├── relay_server/ ← Thin compat shim → plugin.relay (legacy entrypoint)
|
||||
├── hermes_relay_bootstrap/ ← Runtime compatibility patch; retire per surface as upstream replaces it
|
||||
├── hermes_relay_bootstrap/ ← Legacy import shim for older startup hooks
|
||||
├── skills/devops/hermes-relay-pair/ ← /hermes-relay-pair slash command
|
||||
├── scripts/ ← dev.bat, bridge-smoke.sh, bump-version.sh
|
||||
└── docs/ ← spec, decisions, security, relay-server, mcp-tooling
|
||||
@@ -260,9 +262,12 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
|
||||
| `plugin/tools/android_tool.py` | 18 `android_*` tool handlers (14 baseline + send_sms, call, search_contacts, return_to_hermes); `android_screenshot` first consumer of `register_media()` |
|
||||
| `plugin/tools/android_navigate.py` | Vision-driven navigation loop; up to 20 iterations; `llm_gap` error until vision client wired |
|
||||
| `plugin/pair.py` | QR payload builder + CLI; `build_payload(sign=True)`; `--register-code` fallback |
|
||||
| `plugin/doctor.py` | `hermes relay doctor`; checks standard upstream API/dashboard reachability, Relay loopback state, plugin layout, and compat hook state |
|
||||
| `plugin/compat.py` | `hermes relay compat status/install/remove`; owns the optional `hermes_relay_bootstrap.pth` lifecycle |
|
||||
| `plugin/hermes_relay_bootstrap/` | Plugin-owned runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `install.sh` | Canonical installer — 6 steps; idempotent; drops `hermes-relay-update` shim |
|
||||
| `uninstall.sh` | Canonical uninstaller; reverses install.sh; never touches `.env` or `state.db` |
|
||||
| `hermes_relay_bootstrap/` | Runtime compatibility patch; skips native routes per method/path; retire only after remaining config/memory/legacy skill/slash gaps are handled |
|
||||
| `hermes_relay_bootstrap/` | Legacy import shim for old `.pth` files and editable installs |
|
||||
| **Plugin — Dashboard** | |
|
||||
| `plugin/dashboard/manifest.json` | Declares tab, entry bundle, and FastAPI module for hermes-agent discovery |
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
@@ -378,6 +383,12 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
**Update:** `hermes-relay-update` (idempotent, re-fetches install.sh). Or manually: `git pull --ff-only && systemctl --user restart hermes-relay`.
|
||||
|
||||
**Compat hook:** `hermes relay compat status/install/remove` manages only the
|
||||
optional `hermes_relay_bootstrap.pth` startup hook. New installs load the
|
||||
plugin-owned bootstrap from `plugin/hermes_relay_bootstrap/`; the repo-root
|
||||
package is only a legacy import shim. Standard chat, Manage, and dashboard voice
|
||||
must not depend on this hook.
|
||||
|
||||
**Key conventions:**
|
||||
- Phone re-pairs after each relay restart (SessionManager is in-memory; wiped on restart)
|
||||
- Use `python -m unittest` not `pytest` — conftest imports `responses` which may not be installed
|
||||
@@ -396,20 +407,25 @@ Server is a Linux box running hermes-agent with hermes-relay editable-installed
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
|
||||
- **Version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`)
|
||||
- **Bump atomically:** `bash scripts/bump-version.sh <new-version>` — updates all three sources
|
||||
- **`appVersionCode` is monotonic** — always increment across prereleases
|
||||
- **Cut a release:** bump → commit → `git tag vMAJOR.MINOR.PATCH` → push tag → CI builds + GitHub Release
|
||||
- **Android version source:** `gradle/libs.versions.toml` (`appVersionName`, `appVersionCode`); bump with `scripts/bump-android-version.sh`
|
||||
- **Relay plugin/server version source:** `pyproject.toml`; keep plugin/dashboard metadata synced with `scripts/check-server-version-sync.py`; bump with `scripts/bump-server-version.sh`
|
||||
- **Desktop CLI version source:** `desktop/package.json`; regenerate `desktop/src/version.ts` with `npm run gen:version`
|
||||
- **Track audit:** `python scripts/check-version-tracks.py` reports Android, server/plugin, and desktop CLI versions without forcing them to match
|
||||
- **`appVersionCode` is monotonic** — always increment across Android prereleases
|
||||
- **Cut a release:** bump the target surface → commit → merge `dev` to `main` → tag with `android-v*`, `server-v*`, or `desktop-v*` → push tag → CI builds + GitHub Release
|
||||
- **Required secrets:** `HERMES_KEYSTORE_BASE64`, `HERMES_KEYSTORE_PASSWORD`, `HERMES_KEY_ALIAS`, `HERMES_KEY_PASSWORD`
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Surface | Endpoint | Notes |
|
||||
|---------|----------|-------|
|
||||
| Chat (gateway) | Dashboard `POST /api/auth/ws-ticket` -> WS `/api/ws` | Standard upstream dashboard/tui_gateway path; live thinking/reasoning; requires dashboard auth |
|
||||
| Chat streaming | `POST /v1/runs` → `GET /v1/runs/{id}/events` | Structured tool events; async run-control path |
|
||||
| Chat (sessions) | `POST /api/sessions/{id}/chat/stream` | Native upstream session-persisted SSE; preferred when capability probe finds it |
|
||||
| Chat (compat) | `POST /v1/chat/completions` (stream=true) | Inline tool annotations only |
|
||||
| Session CRUD | `GET/POST/PATCH/DELETE /api/sessions` | Native upstream (#33134); bootstrap fallback only for old builds |
|
||||
| Manage | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Standard upstream dashboard surface; do not proxy through Relay |
|
||||
| Standard voice | Dashboard `POST /api/audio/transcribe`, `POST /api/audio/speak` | Standard no-plugin voice; uses dashboard session from Manage |
|
||||
| Pairing (QR) | `POST /pairing/register` (loopback only) | Via `/hermes-relay-pair` or `hermes-pair` shim; accepts optional `endpoints` for multi-endpoint QRs |
|
||||
| Pairing (multi-endpoint) | QR `endpoints` array (ADR 24) | `hermes: 3` schema; ordered `lan`/`tailscale`/`public`/... candidates; phone re-probes on network change |
|
||||
| Pairing auth | WSS `auth.ok` payload | Includes `expires_at`, `grants`, `transport_hint` |
|
||||
@@ -420,6 +436,8 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Voice transcribe | `POST /voice/transcribe` | multipart/form-data; bearer auth |
|
||||
| Voice synthesize | `POST /voice/synthesize` | JSON → audio/mpeg; max 5000 chars |
|
||||
| Voice config | `GET /voice/config` | Returns current tts/stt provider info |
|
||||
| Plugin diagnostics | `hermes relay doctor --json` | Reports upstream route reachability, Relay loopback state, plugin layout, and legacy bootstrap state |
|
||||
| Compat hook lifecycle | `hermes relay compat status/install/remove` | Optional legacy API compatibility hook; not required for the standard path |
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `GET /v1/capabilities` plus targeted `HEAD` probes | Prefer capabilities when present; HEAD probes keep mixed-version fallback working |
|
||||
|
||||
@@ -80,7 +80,7 @@ Open the app and pick how to connect — any of:
|
||||
|
||||
- **Standard Hermes** → tap **Scan for Hermes on LAN** to auto-find the server, then enter your key.
|
||||
- **Standard Hermes** → type the address (`http://<host>:8642`) and key by hand.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>"}`) and scan it.
|
||||
- **Scan setup QR** → ask your Hermes agent to generate a QR with your URL + key (e.g. `{"api_url":"http://<host>:8642","api_key":"<key>","dashboard_url":"http://<host>:9119"}`) and scan it. `dashboard_url` is optional when the dashboard uses the conventional same-host `:9119` URL.
|
||||
|
||||
The wizard probes everything and finishes with a capability card:
|
||||
|
||||
@@ -101,20 +101,34 @@ If your dashboard requires sign-in, do it once under the **Manage** tab — the
|
||||
Install the Relay plugin on the server only when you want Terminal, Bridge phone control, relay sessions, media routes, or the realtime voice engine:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
|
||||
The installer clones to `~/.hermes/hermes-relay/`, registers the plugin/skill paths, and can install a systemd user service. Scan the QR from the phone's Connections screen — or use `hermes pair --register-code ABCD12` with the manual code from Android **Settings → Connections → Advanced**.
|
||||
Use the legacy installer instead if you also want the systemd user service,
|
||||
shell shims, and the full clone/update workflow:
|
||||
|
||||
- **Update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — reverses every step, never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The plugin-manager install owns the plugin code, dashboard tab, CLI commands,
|
||||
and agent tools. `hermes relay compat status/install/remove` manages only the
|
||||
optional legacy API compatibility hook when an older Hermes build needs it. Scan
|
||||
the QR from the phone's Connections screen — or use
|
||||
`hermes pair --register-code ABCD12` with the manual code from Android
|
||||
**Settings → Connections → Advanced**.
|
||||
|
||||
- **Plugin-manager uninstall:** `hermes relay compat remove --all` if you installed the optional hook, then `hermes plugins remove hermes-relay`.
|
||||
- **Legacy installer update:** `hermes-relay-update` (idempotent) — or re-run the install one-liner.
|
||||
- **Legacy installer uninstall:** `bash ~/.hermes/hermes-relay/uninstall.sh` — removes the service, shims, clone, external skill path, editable package, and compat hook. It never touches shared Hermes state. Flags: `--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
- **Dashboard plugin:** installs with the same symlink — restart the gateway and a **Relay** tab (paired devices, bridge activity, media tokens) appears in the web UI.
|
||||
|
||||
Full server setup, TLS, and systemd details: [docs/relay-server.md](docs/relay-server.md).
|
||||
|
||||
**Requirements:** Android 8.0+ (SDK 26) · [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+ with Python 3.11+ on the server.
|
||||
**Requirements:** Android 8.0+ (SDK 26) · current upstream [hermes-agent](https://github.com/NousResearch/hermes-agent) with the API server and dashboard enabled · Python 3.11+ on the server.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -175,13 +189,19 @@ It pairs against the **same relay and credential store** as the Android app —
|
||||
## How It Works
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) --> Hermes Dashboard (:9119) [manage + standard voice — cookie sign-in]
|
||||
Phone (HTTP/WSS) --> Hermes Dashboard (:9119) [chat gateway, manage, standard voice]
|
||||
Phone (HTTP/SSE) --> Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (WSS/HTTP) --> Relay (:8767) [terminal, bridge, media, relay voice, sessions]
|
||||
CLI (WSS) --> Relay (:8767) [machine tools, tui, terminal]
|
||||
```
|
||||
|
||||
Chat connects **directly** to the Hermes API server with the API key — the same pattern Open WebUI and other Hermes frontends use. Manage and standard voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla install needs no plugin for either. The optional relay on `:8767` adds the power surfaces — terminal, bridge phone control, media handoff, machine tools, and relay-side voice (preferred automatically when paired). One QR can configure API, dashboard, and relay routes without merging their auth models.
|
||||
Chat prefers the Hermes dashboard gateway when Manage auth is ready, then falls
|
||||
back to the upstream API server SSE path with the API key. Manage and standard
|
||||
voice ride the Hermes dashboard with its own one-time sign-in, so a vanilla
|
||||
install needs no plugin for either. The optional relay on `:8767` adds the power
|
||||
surfaces: terminal, bridge phone control, media handoff, machine tools, and
|
||||
relay-side voice, which is preferred automatically when paired. One QR can
|
||||
configure API, dashboard, and relay routes without merging their auth models.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
+16
-3
@@ -96,6 +96,17 @@ Check the current metadata with:
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
Check all release tracks at once with:
|
||||
|
||||
```bash
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
This aggregate check reports Android, server/plugin, and desktop CLI versions
|
||||
side by side and validates that each track's own source files are internally
|
||||
consistent. It deliberately does not require all three tracks to share the same
|
||||
SemVer.
|
||||
|
||||
The `server-v*` release workflow validates the tag against the same metadata,
|
||||
runs server tests, builds a wheel and sdist, generates checksums, and publishes
|
||||
a GitHub Release with the package artifacts.
|
||||
@@ -480,9 +491,11 @@ git push origin server-v0.6.2
|
||||
|
||||
Pushing `server-v*` triggers `.github/workflows/release-server.yml`, which
|
||||
validates all server-owned version metadata with
|
||||
`scripts/check-server-version-sync.py`, runs server tests, builds a wheel and
|
||||
sdist, generates `SHA256SUMS.txt`, and creates a GitHub Release for the server
|
||||
package.
|
||||
`scripts/check-server-version-sync.py`. Run
|
||||
`python scripts/check-version-tracks.py` locally before tagging when a change
|
||||
touches more than one release surface. The workflow also runs server tests,
|
||||
builds a wheel and sdist, generates `SHA256SUMS.txt`, and creates a GitHub
|
||||
Release for the server package.
|
||||
|
||||
### 5. Upload to Play Console
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ Verify integrity with `SHA256SUMS.txt` from the same release. See the [Sideload
|
||||
|
||||
### Standard path is first-class — no plugin required
|
||||
|
||||
Chat, Manage, and voice now work against an unmodified upstream Hermes agent. Chat streams over the API server; Manage and voice use the Hermes dashboard with a single sign-in. The relay plugin stays optional and only adds power tools.
|
||||
Chat, Manage, and voice now work against an unmodified upstream Hermes agent. Chat prefers the dashboard `/api/ws` gateway when Manage auth is ready and falls back to API-server SSE; Manage and standard voice use the Hermes dashboard with a single sign-in. The relay plugin stays optional and only adds power tools.
|
||||
|
||||
### Gateway chat transport with live thinking
|
||||
|
||||
|
||||
@@ -537,7 +537,7 @@ fun ConnectionWizard(
|
||||
reorderedPayload.serverUrl,
|
||||
reorderedPayload.key,
|
||||
"",
|
||||
"",
|
||||
reorderedPayload.dashboardUrl.orEmpty(),
|
||||
reorderedPayload.endpoints,
|
||||
)
|
||||
} else {
|
||||
@@ -626,6 +626,7 @@ fun ConnectionWizard(
|
||||
if (payload.relay == null) {
|
||||
standardApiUrl = payload.serverUrl
|
||||
standardApiKey = payload.key
|
||||
standardDashboardUrl = payload.dashboardUrl.orEmpty()
|
||||
standardError = null
|
||||
standardSuccess = null
|
||||
}
|
||||
@@ -705,7 +706,7 @@ fun ConnectionWizard(
|
||||
payload.serverUrl,
|
||||
payload.key,
|
||||
"",
|
||||
"",
|
||||
payload.dashboardUrl.orEmpty(),
|
||||
payload.endpoints,
|
||||
)
|
||||
} else {
|
||||
@@ -2180,7 +2181,10 @@ private fun ConfirmStep(
|
||||
if (relayUrl == null) {
|
||||
LabeledLine(
|
||||
label = "Dashboard",
|
||||
value = Connection.deriveDefaultDashboardUrl(payload.serverUrl)
|
||||
value = payload.dashboardUrl
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: Connection.deriveDefaultDashboardUrl(payload.serverUrl)
|
||||
?: "Derived from API URL",
|
||||
hint = "Manage",
|
||||
)
|
||||
|
||||
@@ -163,6 +163,8 @@ data class HermesPairingPayload(
|
||||
val port: Int = 8642,
|
||||
val key: String = "",
|
||||
val tls: Boolean = false,
|
||||
@SerialName("dashboard_url")
|
||||
val dashboardUrl: String? = null,
|
||||
val relay: RelayPairing? = null,
|
||||
val sig: String? = null,
|
||||
/**
|
||||
@@ -228,7 +230,8 @@ private val json = Json {
|
||||
* For standard Hermes setup, also accepts generic API-only QRs:
|
||||
* - a plain `http://host:8642` or `https://host:8642` URL
|
||||
* - JSON with `api_url`, `apiUrl`, `server_url`, `serverUrl`, or `url`, plus
|
||||
* optional `api_key`, `apiKey`, or `key`
|
||||
* optional `api_key`, `apiKey`, or `key`, and optional `dashboard_url` or
|
||||
* `dashboardUrl`
|
||||
*
|
||||
* **Endpoint synthesis (ADR 24):** when the payload has no `endpoints`
|
||||
* array (v1/v2 QRs), a single priority-0 [EndpointCandidate] is materialized
|
||||
@@ -257,6 +260,13 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
if (version < 1) return null
|
||||
val decoded = json.decodeFromString<HermesPairingPayload>(raw)
|
||||
if (decoded.host.isBlank()) return null
|
||||
val dashboardAlias = firstString(obj, "dashboardUrl")
|
||||
val decodedWithAliases =
|
||||
if (decoded.dashboardUrl.isNullOrBlank() && dashboardAlias != null) {
|
||||
decoded.copy(dashboardUrl = dashboardAlias)
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
|
||||
// TODO(security): verify `decoded.sig` against the server's HMAC
|
||||
// secret once the pairing protocol exposes a public verification
|
||||
@@ -267,10 +277,12 @@ private fun parseHermesRelayQr(raw: String): HermesPairingPayload? {
|
||||
// Synthesize a single priority-0 candidate from the top-level fields
|
||||
// when the wire payload didn't carry an explicit `endpoints` array.
|
||||
// v3+ payloads with an explicit array pass through untouched.
|
||||
if (decoded.endpoints.isNullOrEmpty()) {
|
||||
decoded.copy(endpoints = listOf(synthesizeLegacyEndpoint(decoded)))
|
||||
if (decodedWithAliases.endpoints.isNullOrEmpty()) {
|
||||
decodedWithAliases.copy(
|
||||
endpoints = listOf(synthesizeLegacyEndpoint(decodedWithAliases)),
|
||||
)
|
||||
} else {
|
||||
decoded
|
||||
decodedWithAliases
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
@@ -289,7 +301,8 @@ private fun parseGenericApiJsonQr(raw: String): HermesPairingPayload? {
|
||||
"url",
|
||||
) ?: return null
|
||||
val apiKey = firstString(obj, "api_key", "apiKey", "key").orEmpty()
|
||||
payloadFromApiUrl(apiUrl, apiKey)
|
||||
val dashboardUrl = firstString(obj, "dashboard_url", "dashboardUrl")
|
||||
payloadFromApiUrl(apiUrl, apiKey, dashboardUrl)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
@@ -305,7 +318,11 @@ private fun firstString(obj: JsonObject, vararg names: String): String? {
|
||||
}
|
||||
}
|
||||
|
||||
private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayload? {
|
||||
private fun payloadFromApiUrl(
|
||||
apiUrl: String,
|
||||
apiKey: String,
|
||||
dashboardUrl: String? = null,
|
||||
): HermesPairingPayload? {
|
||||
val uri = runCatching { URI(apiUrl.trim().trimEnd('/')) }.getOrNull() ?: return null
|
||||
val scheme = uri.scheme?.lowercase()
|
||||
val tls = when (scheme) {
|
||||
@@ -319,6 +336,7 @@ private fun payloadFromApiUrl(apiUrl: String, apiKey: String): HermesPairingPayl
|
||||
port = if (uri.port > 0) uri.port else 8642,
|
||||
key = apiKey.trim(),
|
||||
tls = tls,
|
||||
dashboardUrl = dashboardUrl?.trim()?.takeIf { it.isNotBlank() },
|
||||
relay = null,
|
||||
)
|
||||
return payload.copy(endpoints = listOf(synthesizeGenericEndpoint(payload)))
|
||||
|
||||
@@ -3642,13 +3642,18 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
.firstOrNull { it.id == activeId }
|
||||
if (current != null) {
|
||||
val newRelayUrl = payload.relay?.url ?: current.relayUrl
|
||||
val newDashboardUrl = if (
|
||||
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
|
||||
) {
|
||||
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
|
||||
} else {
|
||||
current.dashboardUrl
|
||||
}
|
||||
val payloadDashboardUrl = payload.dashboardUrl
|
||||
?.trim()
|
||||
?.trimEnd('/')
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val newDashboardUrl = payloadDashboardUrl
|
||||
?: if (
|
||||
Connection.isAutoManagedDashboardUrl(current.dashboardUrl, current.apiServerUrl)
|
||||
) {
|
||||
Connection.deriveDefaultDashboardUrl(payload.serverUrl)
|
||||
} else {
|
||||
current.dashboardUrl
|
||||
}
|
||||
val needsUpdate = current.apiServerUrl != payload.serverUrl ||
|
||||
current.relayUrl != newRelayUrl ||
|
||||
current.dashboardUrl != newDashboardUrl ||
|
||||
|
||||
+29
-1
@@ -57,6 +57,32 @@ class HermesPairingPayloadTest {
|
||||
assertEquals("ws://192.168.1.100:8767", ep.relay.url)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayPayload_preservesDashboardUrlWhenPresent() {
|
||||
val raw = """
|
||||
{
|
||||
"hermes": 3,
|
||||
"host": "192.168.1.100",
|
||||
"port": 8642,
|
||||
"key": "bearer-token",
|
||||
"tls": false,
|
||||
"dashboard_url": "https://dashboard.example.com/hermes",
|
||||
"relay": { "url": "ws://192.168.1.100:8767", "code": "ABCD12" },
|
||||
"endpoints": [
|
||||
{ "role": "lan", "priority": 0,
|
||||
"api": { "host": "192.168.1.100", "port": 8642, "tls": false },
|
||||
"relay": { "url": "ws://192.168.1.100:8767",
|
||||
"transport_hint": "ws" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val payload = parseHermesPairingQr(raw)
|
||||
|
||||
assertNotNull(payload)
|
||||
assertEquals("https://dashboard.example.com/hermes", payload!!.dashboardUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun v1LegacyPayload_withTailscaleHost_synthesizesTailscaleEndpoint() {
|
||||
// v1 QR pointing at a Tailscale MagicDNS host. Parser heuristic
|
||||
@@ -292,7 +318,8 @@ class HermesPairingPayloadTest {
|
||||
val raw = """
|
||||
{
|
||||
"api_url": "http://192.168.1.50:8642",
|
||||
"api_key": "dev-key"
|
||||
"api_key": "dev-key",
|
||||
"dashboardUrl": "https://dash.example.com"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@@ -304,6 +331,7 @@ class HermesPairingPayloadTest {
|
||||
assertEquals(8642, parsed.port)
|
||||
assertFalse(parsed.tls)
|
||||
assertEquals("dev-key", parsed.key)
|
||||
assertEquals("https://dash.example.com", parsed.dashboardUrl)
|
||||
assertNull(parsed.relay)
|
||||
assertEquals("lan", parsed.endpoints.orEmpty()[0].role)
|
||||
}
|
||||
|
||||
@@ -323,4 +323,4 @@ Chat modes:
|
||||
| Streaming format | OpenAI delta format | Custom SSE events (see above) |
|
||||
| Tool visibility | Hidden | Exposed via events (pending/started/completed/failed) |
|
||||
| Thinking/Reasoning | Not exposed | Exposed via `tool.progress` events |
|
||||
| Memory/Skills | Not applicable | Native read-only skills/toolsets; memory and skill detail/toggle remain compatibility surfaces |
|
||||
| Memory/Skills | Not applicable | Native read-only skills/toolsets; memory and skill detail/toggle remain compatibility surfaces. The optional bootstrap hook is managed by `hermes relay compat status/install/remove` and is not required for the v1.0.0 standard path. |
|
||||
|
||||
+67
-1
@@ -145,6 +145,9 @@ Phone (WSS) → Relay Server (:8767) [bridge, terminal]
|
||||
- Old API-only QRs (`{hermes, host, port, key, tls}`) still parse cleanly — the `relay` field is nullable and `kotlinx.serialization` runs with `ignoreUnknownKeys = true`.
|
||||
- When `--no-relay` is passed to the pair command, or the relay isn't running, the QR omits the `relay` block and the command prints an `[info]` pointing at `hermes relay start`.
|
||||
- Top-level `key` is always the Hermes API bearer token for direct chat/session HTTP. The relay pairing code lives only at `relay.code`; putting an empty API key in a dashboard-minted QR will pair voice/relay successfully but leaves direct chat unauthenticated when the gateway requires `API_SERVER_KEY`.
|
||||
- Top-level `dashboard_url` is optional. When present, Android stores it for
|
||||
Manage and standard dashboard voice instead of deriving the conventional
|
||||
same-host `:9119` URL from the API server.
|
||||
|
||||
**Pairing alphabet change:** `PAIRING_ALPHABET` in `plugin/relay/config.py` was widened from `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` (32 chars, no ambiguous 0/O/1/I) to the full `A-Z / 0-9` (36 chars) to match the phone-side `AuthManager.PAIRING_CODE_CHARS`. The old restriction only mattered when a human had to retype a code from a display; now that the code flows phone ↔ server through a QR + HTTP, the restriction silently rejected ~12% of valid codes and had to go.
|
||||
|
||||
@@ -443,7 +446,7 @@ The bare-path fetch is therefore safe as long as operators treat the allowed-roo
|
||||
**Supporting infrastructure:**
|
||||
|
||||
- **Device revocation UI** — new Paired Devices screen on the phone, backed by `GET /sessions` (list all paired devices, tokens masked to first 8 chars) and `DELETE /sessions/{token_prefix}` (revoke). Self-revoke is allowed and flagged via `revoked_self: true` so the phone can wipe local state and redirect to pairing.
|
||||
- **QR payload v2 + HMAC signing** — payload version bumped from 1 to 2 when any new field is present (`ttl_seconds`, `grants`, `transport_hint`). Signed with HMAC-SHA256 using a host-local secret at `~/.hermes/hermes-relay-qr-secret` (32 bytes, `0o600`, auto-created). Phone parses and stores the `sig` field but does NOT verify it yet — full verification requires a secret-distribution mechanism we don't have defined. The server-side infrastructure is in place so phone-side verification can land in a follow-up.
|
||||
- **QR payload v2 + HMAC signing** — payload version bumped from 1 to 2 when Relay metadata fields are present (`ttl_seconds`, `grants`, `transport_hint`). Signed with HMAC-SHA256 using a host-local secret at `~/.hermes/hermes-relay-qr-secret` (32 bytes, `0o600`, auto-created). Phone parses and stores the `sig` field but does NOT verify it yet — full verification requires a secret-distribution mechanism we don't have defined. The server-side infrastructure is in place so phone-side verification can land in a follow-up.
|
||||
- **Rate-limit clear on pair** — `/pairing/register` now calls `RateLimiter.clear_all_blocks()` on success. An operator explicitly re-pairing wants a clean slate; the stale rate-limit state otherwise blocks the legitimate re-pair attempt for 5 minutes. This was an actual bug biting the operator at the start of this session.
|
||||
- **Transport security UI** — badge component with three states (secure green 🔒 / insecure amber with reason / insecure unknown red), three sizes (chip / row / large). Rendered in Settings Connection section, Session info sheet, and on each Paired Device card.
|
||||
- **Insecure ack dialog** — first-time toggle-on shows a plain-language threat-model dialog with a reason picker (LAN only / Tailscale or VPN / Local dev only). Reason persists for display purposes; does NOT gate anything per the operator's trust-model direction.
|
||||
@@ -1715,3 +1718,66 @@ default-on is unblocked.
|
||||
- `docs/relay-protocol.md`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`
|
||||
- `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`
|
||||
|
||||
### 34. v1.0.0 Standard-First Relay Plugin Boundary (2026-06-16)
|
||||
|
||||
**Status:** Accepted.
|
||||
|
||||
**Context:** v1.0.0 made a plain upstream Hermes install sufficient for Android
|
||||
chat, Manage, and voice. The remaining Relay code should be treated as a
|
||||
third-party plugin surface: useful and powerful, but additive and cleanly
|
||||
manageable. The legacy installer still creates side effects outside the upstream
|
||||
plugin manager, including an editable/root package install, `.pth` bootstrap,
|
||||
systemd user unit, shell shims, and external skill-path entries.
|
||||
|
||||
**Decision:** Treat `plugin/` as the plugin-manager-owned root for the current
|
||||
repo layout and document the canonical install identifier as
|
||||
`Codename-11/hermes-relay/plugin`. The plugin manifest carries the tool list,
|
||||
dashboard plugin metadata stays under `plugin/dashboard/`, and the plugin CLI now
|
||||
includes `hermes relay doctor` as the single read-only diagnostics surface for
|
||||
agents and operators. The optional legacy compatibility startup hook is managed
|
||||
by `hermes relay compat status/install/remove` rather than an undocumented
|
||||
installer side effect. New compat hooks load the bootstrap implementation from
|
||||
`plugin/hermes_relay_bootstrap/`; the repo-root `hermes_relay_bootstrap/`
|
||||
package remains only as a backward-compatible import shim for old `.pth` files.
|
||||
The Python package, plugin manifest, dashboard manifest, and relay runtime all
|
||||
use version `1.0.0` for this stable line.
|
||||
|
||||
**Runtime boundary:**
|
||||
|
||||
- Standard upstream owns chat, Manage, dashboard auth, dashboard voice, sessions,
|
||||
runs, responses, capabilities, skills, and toolset discovery.
|
||||
- Relay owns pairing, terminal, bridge/device control, relay voice extensions,
|
||||
remote access, media relay, notification companion, desktop tools, and the
|
||||
dashboard Relay tab.
|
||||
- `plugin/hermes_relay_bootstrap` is legacy compatibility only. It must not be a
|
||||
hidden prerequisite for the standard path.
|
||||
|
||||
**Why not add a repo-root `plugin.yaml` now:** Current upstream imports directory
|
||||
plugins from the directory that contains both `plugin.yaml` and `__init__.py`.
|
||||
Adding a root manifest while the actual `register(ctx)` entry point remains in
|
||||
`plugin/__init__.py` would make the repository look installable as a root plugin
|
||||
but fail or mislead at load time. A future repo restructure can move the plugin
|
||||
entry point to root or split a dedicated plugin repository; until then the
|
||||
subdirectory install is explicit and correct.
|
||||
|
||||
**Operational rule:** `hermes plugins remove hermes-relay` removes only the
|
||||
plugin tree. It cannot clean every legacy external artifact today.
|
||||
`hermes relay compat remove` owns the bootstrap `.pth` hook, while
|
||||
`uninstall.sh` remains the cleanup surface for legacy service/shim/root-package
|
||||
installs until plugin lifecycle hooks or plugin-owned service commands replace
|
||||
those pieces.
|
||||
The legacy installer now calls `hermes relay compat install` for the hook, and
|
||||
the uninstaller delegates hook removal to `hermes relay compat remove` when that
|
||||
command is available, falling back to deleting only the Relay `.pth` file.
|
||||
|
||||
**Key files:**
|
||||
|
||||
- `plugin/plugin.yaml`
|
||||
- `plugin/__init__.py`
|
||||
- `plugin/cli.py`
|
||||
- `plugin/compat.py`
|
||||
- `plugin/doctor.py`
|
||||
- `plugin/hermes_relay_bootstrap/`
|
||||
- `plugin/after-install.md`
|
||||
- `docs/upstream-surface-matrix.md`
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Legacy Cleanup Agent Prompt
|
||||
|
||||
Use this prompt when a local coding agent or operations agent is asked to clean
|
||||
up pre-v1.0.0 Hermes-Relay artifacts after moving to the upstream plugin-manager
|
||||
install path.
|
||||
|
||||
```text
|
||||
Goal: Audit and clean Hermes-Relay legacy install artifacts while preserving the
|
||||
standard upstream Hermes path. Standard Android chat, Manage, and dashboard voice
|
||||
must continue to work against unmodified hermes-agent. Relay power features
|
||||
should remain available only through the Hermes-Relay plugin if the operator
|
||||
wants them.
|
||||
|
||||
Repository or host context:
|
||||
- Hermes-Relay plugin-manager install id: Codename-11/hermes-relay/plugin
|
||||
- Legacy clone path: ~/.hermes/hermes-relay
|
||||
- Shared Hermes state that must be preserved unless explicitly approved:
|
||||
~/.hermes/.env, ~/.hermes/state.db, the hermes-agent install, and
|
||||
~/.hermes/hermes-relay-qr-secret
|
||||
|
||||
Rules:
|
||||
1. Do not delete shared Hermes state.
|
||||
2. Do not patch or fork upstream hermes-agent for the standard path.
|
||||
3. Treat the optional hermes_relay_bootstrap.pth hook as legacy compatibility
|
||||
only. It is not required for standard chat, Manage, or dashboard voice.
|
||||
4. Prefer plugin-owned commands over manual file deletion when available.
|
||||
5. Use dry-run output before removing legacy installer artifacts.
|
||||
|
||||
Inspect:
|
||||
1. Run `hermes relay doctor --json` if the command exists.
|
||||
2. Run `hermes relay compat status --json` if the command exists.
|
||||
3. Check for legacy shell shims:
|
||||
`hermes-pair`, `hermes-status`, `hermes-relay`,
|
||||
`hermes-relay-update`, `hermes-relay-tailscale`.
|
||||
4. Check for the legacy clone at `~/.hermes/hermes-relay`.
|
||||
5. Check for a systemd user unit named `hermes-relay.service`.
|
||||
6. Check the Hermes Python environment for `hermes_relay_bootstrap.pth`.
|
||||
7. Check Hermes config for a `skills.external_dirs` entry pointing at the Relay
|
||||
clone.
|
||||
|
||||
Clean plugin-manager install only:
|
||||
1. If the optional compat hook is installed, run:
|
||||
`hermes relay compat remove --all`
|
||||
2. Remove the plugin-manager install only if the operator wants Relay removed:
|
||||
`hermes plugins remove hermes-relay`
|
||||
|
||||
Clean legacy installer artifacts:
|
||||
1. If `~/.hermes/hermes-relay/uninstall.sh` exists, run:
|
||||
`bash ~/.hermes/hermes-relay/uninstall.sh --dry-run`
|
||||
2. Review the planned removals. They should include the systemd user service,
|
||||
legacy shell shims, editable Python package, external skill path, optional
|
||||
compat hook, and clone unless `--keep-clone` is requested.
|
||||
3. Run:
|
||||
`bash ~/.hermes/hermes-relay/uninstall.sh`
|
||||
4. Use `--keep-clone` if the operator wants the git tree preserved.
|
||||
5. Use `--remove-secret` only when the operator explicitly wants the QR signing
|
||||
identity removed.
|
||||
|
||||
If the clone is already gone:
|
||||
1. Prefer `hermes relay compat remove --all` for compat hook cleanup.
|
||||
2. Remove any remaining Relay shell shims listed above.
|
||||
3. Remove only the Relay-owned `.pth` file, not unrelated site-packages files.
|
||||
4. Remove only the Relay `skills.external_dirs` entry, preserving other entries.
|
||||
5. Stop and remove only the `hermes-relay.service` user unit.
|
||||
|
||||
Reinstall Relay through the plugin manager when requested:
|
||||
1. Run `hermes plugins install Codename-11/hermes-relay/plugin --enable`.
|
||||
2. Run `hermes relay doctor`.
|
||||
3. Start Relay only if power features are wanted:
|
||||
`hermes relay start --no-ssl`
|
||||
4. Generate a QR or manual code:
|
||||
`hermes pair`
|
||||
or `hermes pair --register-code <code>`
|
||||
|
||||
Verify app flow:
|
||||
1. Save a standard API/dashboard connection in Android.
|
||||
2. Verify Chat works without Relay.
|
||||
3. Verify Manage signs in through the dashboard.
|
||||
4. Verify standard voice uses dashboard audio when available.
|
||||
5. Pair Relay by QR only for Terminal, Bridge, media, relay sessions, desktop
|
||||
tools, notification companion, and Relay voice extras.
|
||||
6. Confirm Terminal and Bridge stay gated when no Relay session is paired.
|
||||
|
||||
Report:
|
||||
- What was installed.
|
||||
- What legacy artifacts were removed.
|
||||
- What was intentionally preserved.
|
||||
- Whether the standard path was verified.
|
||||
- Whether Relay power features were reinstalled and paired.
|
||||
```
|
||||
@@ -193,7 +193,7 @@ Or use a reverse proxy (nginx/Caddy) to terminate TLS in front of the relay. Ful
|
||||
|
||||
The relay uses a QR-driven two-step auth flow:
|
||||
|
||||
1. **Pairing** — the pair command runs on the Hermes host (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The same payload is also printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
|
||||
1. **Pairing** — the pair command runs on the Hermes host (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim), mints a fresh 6-char code (`A-Z / 0-9`), pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, and embeds the relay URL + code in the scanned QR payload. The payload can also carry `dashboard_url` for custom Manage/dashboard routes. The same payload is printed as a paste-friendly `hermes-relay://pair?payload=...` invite URL for desktop GUI/CLI setup. The phone sends the code in its first `system/auth` envelope; the relay consumes it and issues a session token. Codes are one-shot and expire 10 minutes after registration. Android clears a failed scanned code after `auth.fail` so a stale QR cannot keep reconnecting into the rate limiter.
|
||||
2. **Session token** — Stored in Android's EncryptedSharedPreferences. Used for subsequent relay connections and Relay-protected HTTP routes. Expires after 30 days by default and carries per-channel grants, including `voice:config`, `voice:stt`, `voice:tts`, and `voice:realtime`.
|
||||
|
||||
Voice endpoints also accept the existing Hermes API bearer token used by API-server clients such as the Obsidian Hermes Client. That API bearer path is limited to `/voice/config`, `/voice/transcribe`, `/voice/synthesize`, `/voice/output/*`, `/voice/realtime/*`, and `/voice/realtime-agent/*`; it is not accepted for sessions, media, clipboard, terminal, TUI, bridge, profile writes, or Android control routes. Android derives the conventional Relay URL from the configured API URL (`http(s)://host:8642` to `ws(s)://host:8767`) and probes the voice routes, with a manual Relay URL override for custom routing. For non-loopback callers, Hermes API bearer auth requires HTTPS by default, either direct TLS or trusted `X-Forwarded-Proto: https` from an explicitly trusted proxy.
|
||||
@@ -286,7 +286,7 @@ See [`docs/spec.md` §3.3](spec.md) for the full auth flow and the QR wire forma
|
||||
| `/health` | GET | Returns `{status, version, clients, sessions}` JSON. |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code. Returns `{"code": "ABC123"}`. Unrestricted (intended for host-local callers). |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can appear in a QR payload before the phone scans it. Request body: `{"code": "ABCD12", "ttl_seconds": 2592000, "grants": {"terminal": 604800, "bridge": 86400}, "transport_hint": "wss"}` — `ttl_seconds` / `grants` / `transport_hint` are all optional; if omitted the phone's chosen values (or the SessionManager defaults) are used. Response: `{"ok": true, "code": "ABCD12"}`. Returns HTTP 403 for any `request.remote` other than `127.0.0.1` / `::1`. **As of ADR 15 this endpoint clears all rate-limit blocks on success** — the operator is explicitly re-pairing, stale blocks should not prevent the new code from being consumed. Used by `hermes pair` / `/hermes-relay-pair`; `hermes-pair` remains a compatibility shim. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard and desktop pair/repair flows. Reads `API_SERVER_KEY` from the host-local config chain when the dashboard does not pass `api_key` explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/pairing/approve` | POST | **Loopback only, Phase 3 stub.** Same wire shape and loopback gate as `/pairing/register` — present so the Android client can target the route today. The semantic difference (operator reviewing a phone-initiated pending code before approval) still needs the pending-codes store + approval UX, marked `# TODO(Phase 3)` in the handler. |
|
||||
| `/sessions` | GET | Bearer-auth'd. Returns `{"sessions": [ {token_prefix, device_name, device_id, created_at, last_seen, expires_at, grants, transport_hint, is_current}, ... ]}` for all currently-active paired devices. `token_prefix` is the first 8 characters of the session token — full tokens are NEVER included, so a caller holding one session token can't extract another. `expires_at` and grant values that are `math.inf` serialize as `null` (never expire). `is_current` is true for the session matching the caller's bearer. 401 on missing/invalid bearer. Used by the Android Paired Devices screen. **Loopback branch (2026-04-18):** callers on `127.0.0.1` / `::1` may skip the bearer and receive the same `{sessions: [...]}` payload without the `is_current` flag (no caller context). Added so the dashboard plugin proxy can list paired devices without needing to mint its own bearer. Non-loopback callers still require the bearer and retain `is_current`. |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by first-N-char token prefix (N ≥ 4). Returns 200 `{"ok": true, "revoked_self": bool}` on exact match; 404 on zero matches; 409 on ambiguous (2+) matches with the count in the body. Self-revoke is allowed and flagged via `revoked_self: true` so the caller knows to wipe local state. Any paired device can revoke any other — see ADR 15 for the trade-off rationale. |
|
||||
|
||||
+74
-71
@@ -1,10 +1,10 @@
|
||||
# Hermes-Relay — Android App
|
||||
|
||||
## Specification v1.3
|
||||
## Specification v1.4
|
||||
|
||||
**Status:** v0.3.0 shipped to Play Store internal testing + sideload track. Phase 0, Phase 1, Phase 2 (terminal preview), Phase 3 (bridge channel), Phase 4 (security hardening per ADR 15), and Phase 5 (polish + CI/CD) are partially-or-fully shipped. Phase V (voice mode) shipped 2026-04-12. v0.4 bridge feature expansion in progress on `feature/bridge-feature-expansion` — see `docs/plans/2026-04-13-bridge-feature-expansion.md`.
|
||||
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
|
||||
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
|
||||
**Updated:** 2026-04-18
|
||||
**Updated:** 2026-06-16
|
||||
|
||||
---
|
||||
|
||||
@@ -12,15 +12,18 @@
|
||||
|
||||
A **native Android app** for the Hermes agent platform. Not just remote phone control — a full bidirectional interface between you and your Hermes server from anywhere.
|
||||
|
||||
Three capabilities in one app:
|
||||
Current capabilities are split between standard upstream Hermes and optional Relay surfaces:
|
||||
|
||||
| Channel | Direction | What |
|
||||
|---------|-----------|------|
|
||||
| **Chat** | Phone ↔ Agent | Talk to any Hermes agent profile (Victor, Mizu, etc.) with full streaming |
|
||||
| **Terminal** | Phone ↔ Server | Secure remote shell access to the Hermes server via tmux |
|
||||
| **Bridge** | Agent → Phone | Agent controls the phone (taps, types, screenshots — upstream functionality) |
|
||||
| Surface | Requires Relay | What |
|
||||
|---------|----------------|------|
|
||||
| **Chat** | No | Talk to any Hermes agent profile with dashboard `/api/ws` live thinking when signed in, or API-server SSE fallback |
|
||||
| **Manage** | No | Dashboard-backed config, profiles, model/provider keys, skills, MCP, and diagnostics |
|
||||
| **Standard voice** | No | Dashboard `/api/audio/transcribe` + `/api/audio/speak` with the Manage session |
|
||||
| **Terminal** | Yes | Secure remote shell access to the Hermes server via tmux |
|
||||
| **Bridge / Device Control** | Yes | Agent controls the sideload phone with explicit safety gates |
|
||||
| **Relay power features** | Yes | Remote access, notification companion, provider-native voice, desktop tooling, media relay |
|
||||
|
||||
One persistent WSS connection. One pairing flow. Three multiplexed channels.
|
||||
Standard mode may work with only API/dashboard routes. Pairing adds the Relay URL, session token, terminal/bridge grants, and optional network candidates.
|
||||
|
||||
**What it is not:**
|
||||
- Not a web wrapper — native Kotlin + Jetpack Compose
|
||||
@@ -31,12 +34,12 @@ One persistent WSS connection. One pairing flow. Three multiplexed channels.
|
||||
|
||||
## 2. Design Principles
|
||||
|
||||
1. **Secure by default** — WSS only (no plaintext WebSocket option). TLS certificate pinning for production.
|
||||
2. **Realtime everything** — streaming chat responses, live terminal output, instant bridge feedback. No polling.
|
||||
3. **Clean UX** — Material 3, minimal setup, one pairing flow for all channels.
|
||||
4. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
|
||||
5. **Single connection** — one WSS pipe multiplexes all three channels. Efficient, simple to reason about.
|
||||
6. **Server-side state** — the app is a thin client. Sessions, history, memory all live on the Hermes server.
|
||||
1. **Standard first** — chat, Manage, and voice must work against unmodified upstream Hermes before any Relay power path is considered.
|
||||
2. **Secure by default** — WSS/HTTPS for remote Relay paths; dashboard and API auth stay on their native upstream surfaces.
|
||||
3. **Realtime where the surface supports it** — gateway chat can stream live thinking; API-server SSE remains the fallback; terminal and bridge stay realtime through Relay.
|
||||
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Standard vs Relay.
|
||||
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
|
||||
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
|
||||
|
||||
---
|
||||
|
||||
@@ -45,46 +48,31 @@ One persistent WSS connection. One pairing flow. Three multiplexed channels.
|
||||
### 3.1 High-Level
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ Android App (Compose) │
|
||||
│ │
|
||||
│ ┌─────────┐ ┌──────────┐ ┌────────┐ ┌────────┐ │
|
||||
│ │ Chat │ │ Terminal │ │ Bridge │ │Settings│ │
|
||||
│ │ Tab │ │ Tab │ │ Tab │ │ Tab │ │
|
||||
│ └────┬────┘ └────┬─────┘ └───┬────┘ └────────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌────┴────────────┴─────────────┴────┐ │
|
||||
│ │ Connection Manager (WSS) │ │
|
||||
│ │ Channel Multiplexer │ │
|
||||
│ │ Auth + Session Management │ │
|
||||
│ └────────────────┬───────────────────┘ │
|
||||
└───────────────────┼──────────────────────────────────┘
|
||||
│ WSS (TLS 1.3)
|
||||
│
|
||||
┌───────────────────┼──────────────────────────────────┐
|
||||
│ Hermes Server (Docker-Server) │
|
||||
│ │ │
|
||||
│ ┌────────────────┴───────────────────┐ │
|
||||
│ │ Relay Server (Python) │ │
|
||||
│ │ Port 8767 (WSS) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─────────┐ ┌────────┐ ┌───────┐ │ │
|
||||
│ │ │ Chat │ │Terminal│ │Bridge │ │ │
|
||||
│ │ │ Router │ │ PTY │ │Router │ │ │
|
||||
│ │ └────┬────┘ └───┬───┘ └───┬───┘ │ │
|
||||
│ └───────┼──────────┼─────────┼─────┘ │
|
||||
│ │ │ │ │
|
||||
│ ┌───────┴───┐ ┌───┴──┐ ┌──┴──────────────┐ │
|
||||
│ │ WebAPI │ │ tmux │ │AccessibilityServ.│ │
|
||||
│ │ /api/... │ │ PTY │ │(on phone) │ │
|
||||
│ │ (aiohttp) │ │ │ │ │ │
|
||||
│ └───────────┘ └──────┘ └──────────────────┘ │
|
||||
└──────────────────────────────────────────────────────┘
|
||||
Android app
|
||||
|-- Standard chat -> dashboard /api/ws, then API-server SSE fallback
|
||||
|-- Standard Manage -> dashboard /api/*
|
||||
|-- Standard voice -> dashboard /api/audio/*
|
||||
|-- Relay terminal -> relay WSS :8767
|
||||
|-- Relay bridge/tools -> relay WSS/HTTP :8767
|
||||
`-- Relay voice extras -> relay /voice/*
|
||||
|
||||
Hermes upstream
|
||||
|-- API server -> /v1/* and /api/sessions/*
|
||||
|-- Dashboard web -> Manage, audio, auth, /api/ws tickets
|
||||
`-- tui_gateway -> /api/ws live chat/reasoning transport
|
||||
|
||||
Hermes-Relay plugin
|
||||
|-- plugin manager root -> plugin/
|
||||
|-- relay server -> plugin/relay/server.py
|
||||
|-- dashboard tab -> plugin/dashboard/
|
||||
`-- diagnostics -> hermes relay doctor
|
||||
```
|
||||
|
||||
### 3.2 Protocol
|
||||
|
||||
All communication flows over a single WebSocket connection. Messages use a typed envelope:
|
||||
Relay realtime communication flows over a single WebSocket connection. Standard
|
||||
chat, Manage, and standard voice use upstream dashboard/API HTTP and WebSocket
|
||||
surfaces directly. Relay messages use a typed envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -117,7 +105,17 @@ Connection lifecycle, auth, keepalive.
|
||||
| `profiles` | `[{name, model, description, system_message, api_server_*}]` | **Added v0.6.0; expanded 2026-05-18.** Relay-advertised list of upstream Hermes profiles discovered at `~/.hermes/profiles/*/`, plus a synthetic `"default"` entry for the root config. `system_message` carries the profile's `SOUL.md` content and may be `null`. `api_server_enabled`, `api_server_url`, `api_server_host`, `api_server_port`, and `api_server_key_present` let Android route chat/session calls through a profile's own Hermes API server when it is running, without exposing the key. Empty list when `RELAY_PROFILE_DISCOVERY_ENABLED=0`. See `docs/decisions.md` §21. |
|
||||
|
||||
#### Channel: `chat`
|
||||
**Note:** Chat connects directly to the Hermes API Server via HTTP/SSE (see Section 6.2) — it does not traverse the relay. Voice, bridge, terminal, notifications, and inbound media DO go through the relay. Voice HTTP/WSS routes accept either a Relay session token with an active `voice:config`, `voice:stt`, `voice:tts`, or `voice:realtime` grant, depending on the route, or the Hermes API bearer token; that API bearer exception does not apply to bridge, terminal, TUI, sessions, media, clipboard, profile writes, or Android control routes. Non-loopback API-bearer voice calls require HTTPS unless the local operator enables the runtime dev toggle with `hermes relay insecure-api-key on`. The chat SSE event types are:
|
||||
**Note:** Standard chat prefers the upstream dashboard `/api/ws` gateway when
|
||||
Manage auth is ready, then falls back to Hermes API Server HTTP/SSE paths (see
|
||||
Section 6.2). It does not traverse the Relay server. Relay voice, bridge,
|
||||
terminal, notifications, and inbound media do go through Relay. Relay voice
|
||||
HTTP/WSS routes accept either a Relay session token with an active
|
||||
`voice:config`, `voice:stt`, `voice:tts`, or `voice:realtime` grant, depending
|
||||
on the route, or the Hermes API bearer token; that API bearer exception does not
|
||||
apply to bridge, terminal, TUI, sessions, media, clipboard, profile writes, or
|
||||
Android control routes. Non-loopback API-bearer voice calls require HTTPS unless
|
||||
the local operator enables the runtime dev toggle with
|
||||
`hermes relay insecure-api-key on`. The chat SSE event types are:
|
||||
|
||||
| Event | Direction | Payload |
|
||||
|-------|-----------|---------|
|
||||
@@ -161,14 +159,15 @@ Phone control — mirrors upstream relay protocol.
|
||||
|
||||
### 3.3 Auth Flow
|
||||
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (and the API server credentials) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
Pairing is QR-driven. The operator runs the pair command on the host — `hermes pair`, `/hermes-relay-pair` from any Hermes chat surface, or the compatibility `hermes-pair` shell shim. All share the same implementation in `plugin/pair.py`. The command probes for a running relay, generates a fresh 6-char code, pre-registers it with the relay via the loopback-only `POST /pairing/register` endpoint, then embeds the relay URL + code + **chosen TTL + per-channel grants + HMAC signature** (plus the API server credentials and optional dashboard URL) in a single QR payload. The phone scans once, **confirms the TTL and grants via a picker dialog**, and is configured for both chat AND terminal/bridge.
|
||||
|
||||
As of **v3 (ADR 24)**, the QR can also carry an ordered list of **endpoint candidates** (`lan` / `tailscale` / `public` / operator-defined roles). A single pairing covers every network the phone might be on — the phone picks the highest-priority reachable candidate at connect time and re-probes on network change. The single-URL top-level fields still appear in v3 QRs for backward compatibility; old phones ignore `endpoints` via `ignoreUnknownKeys = true`, new phones prefer `endpoints` and fall back to the top-level URL when the array is absent. See [`docs/remote-access.md`](remote-access.md) for the operator-facing setup per mode.
|
||||
|
||||
```
|
||||
1. Operator runs `hermes pair` (or `/hermes-relay-pair`) on the Hermes host,
|
||||
optionally with --ttl <duration>, --grants terminal=7d,bridge=1d,
|
||||
--mode {auto,lan,tailscale,public} (default auto), --public-url <url>.
|
||||
--mode {auto,lan,tailscale,public} (default auto), --public-url <url>,
|
||||
and optionally --dashboard-url <url>.
|
||||
2. The pair command reads the API server config (host/port/key) from
|
||||
~/.hermes/config.yaml or ~/.hermes/.env, and auto-detects candidate
|
||||
endpoints: LAN IP via routing lookup; Tailscale hostname via
|
||||
@@ -270,7 +269,7 @@ Biometric gate on the app side for terminal access (fingerprint/face) remains pl
|
||||
- The Android parser uses `kotlinx.serialization` with `ignoreUnknownKeys = true`, so future fields can be added without breaking older app builds. `RelayPairing.ttlSeconds` / `grants` / `transportHint` are all nullable with defaults.
|
||||
|
||||
Implementation references:
|
||||
- Server-side payload builder + CLI flags: `plugin/pair.py` → `build_payload(sign=True, endpoints=...)` / `pair_command()` / `parse_duration()` / `parse_grants()`; `--mode {auto,lan,tailscale,public}` + `--public-url <url>`
|
||||
- Server-side payload builder + CLI flags: `plugin/pair.py` → `build_payload(sign=True, endpoints=..., dashboard_url=...)` / `pair_command()` / `parse_duration()` / `parse_grants()`; `--mode {auto,lan,tailscale,public}` + `--public-url <url>` + `--dashboard-url <url>`
|
||||
- Server-side HMAC: `plugin/relay/qr_sign.py` → `canonicalize` / `sign_payload` / `verify_payload` / `load_or_create_secret` — canonical form preserves `endpoints` array order and role strings verbatim
|
||||
- Phone-side endpoint model: `app/src/main/kotlin/.../data/Endpoint.kt` → `EndpointCandidate` / `ApiEndpoint` / `RelayEndpoint` / `displayLabel()`
|
||||
- Phone-side parser: `app/src/main/kotlin/.../ui/components/QrPairingScanner.kt` → `HermesPairingPayload.endpoints` + v1/v2 synthesizer
|
||||
@@ -394,7 +393,7 @@ The bridge UI drives — and is driven by — Tier 5 safety-rails (`BridgeSafety
|
||||
- **Connections** (v0.6.0+) — lists every paired Hermes server with a per-card status chip. Actions: rename (inline), re-pair (reuses `ConnectionWizard` with `connectionId` nav arg), revoke, remove. Add-connection button launches the standard QR flow. Settings briefly treats a paired + disconnected relay as **Connecting** during the reconnect grace window, then promotes it to **Relay unreachable - tap to reconnect** if the live socket does not recover. API / Relay / Session detail sheets include compact sanitized recent-activity tails, and **Settings -> Diagnostics** shows the consolidated app-level API, relay, session, endpoint, and voice activity buffer. See `docs/decisions.md` §19.
|
||||
- **Connection (single-server settings)** — unified "Pair with your server" card (primary action: Scan QR) with a single status summary covering API server, relay, and the active paired session. Collapsible "Manual configuration" card exposes API URL / API key / Relay URL / insecure-transport toggle + "Save & Test" (calls `RelayHttpClient.probeHealth`). **Pair wizard cross-validates URL schemes** in v0.6.0 (e.g. an API field with `wss://` surfaces an inline hint), and **stamps the active Connection's pairing metadata** on successful auth. Collapsible "Manual pairing code (fallback)" card for camera-less / SSH-only setups. Transport security badge (🔒 secure / 🔓 insecure-with-reason / 🔓 insecure-unknown) rendered inline. Paired Devices screen linked from here for the full device list + per-channel grant revoke.
|
||||
- **Chat** — Show reasoning toggle, smooth auto-scroll toggle (live-follow streaming, default on), show token usage toggle, app context prompt toggle, tool call display (Off/Compact/Detailed), streaming endpoint selector (`auto` / `sessions` / `runs`), Stats for Nerds (analytics charts)
|
||||
- **Voice** — voice engine selector (`Hermes Chat + Voice Output` stable, `Realtime Agent` experimental), global interaction mode (tap / hold / continuous), silence threshold slider, Auto-TTS toggle, selected-engine cards for default voice-output renderer settings from `/voice/output/config` or Realtime Agent broker settings from `/voice/realtime-agent/config`, global fallback TTS and STT provider info from `/voice/config`, language picker, and a Test Current Engine card that plays stable Voice Output or opens a provider-native Realtime Agent test session. Voice turns run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Voice** — route-aware voice engine selector (`Standard Hermes` via dashboard audio, `Relay Voice Output`, and experimental `Realtime Agent`), global interaction mode (tap / hold / continuous), silence threshold slider, Auto-TTS toggle, selected-engine cards for dashboard or relay-backed settings, language picker, and a Test Current Engine card. Standard voice depends on Manage/dashboard auth; Relay-backed engines run a fast relay health preflight before uploading audio or opening a realtime provider session so a hung relay surfaces as a connection error instead of an indefinite Thinking state.
|
||||
- **Notification companion** — opt-in status, "Open Android Settings" action, test notification dump
|
||||
- **Appearance** — theme (auto/light/dark), dynamic colors toggle
|
||||
- **Data** — Backup, restore, reset with confirmation dialogs
|
||||
@@ -432,15 +431,19 @@ HTTP routes registered by `create_app()` in `plugin/relay/server.py`:
|
||||
| `/health` | GET | Health check — returns `{status, version, clients, sessions}` |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code. Used by the pair command (`hermes pair`, `/hermes-relay-pair`, or compatibility `hermes-pair`) to inject codes that will appear in QR payloads. Request: `{"code": "ABCD12"}`. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard, desktop GUI, and CLI pair/repair flows. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) for dashboard, desktop GUI, and CLI pair/repair flows. Optional request field `dashboard_url` is copied into the QR payload for custom dashboard routes. |
|
||||
| `/api/profiles/{name}/config` | GET | Profile-scoped read-only config. Returns `{profile, path, config, readonly: true}` — `config` is the parsed `config.yaml` for `~/.hermes/` (when `name == "default"`) or `~/.hermes/profiles/<name>/`. Loopback callers skip bearer; remote callers require the relay session bearer. 404 on missing profile / missing config.yaml; 500 on yaml parse error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/skills` | GET | Profile-scoped skill enumeration. Walks `<profile>/skills/<category>/<skill>/SKILL.md` recursively; returns `{profile, skills: [{name, category, description, path, enabled: true}], total}`. Same auth model as `/config`. `name`/`description` come from YAML frontmatter when present, else directory basename. All skills report `enabled: true` today — see §22 for the toggle stub. |
|
||||
| `/api/profiles/{name}/soul` | GET | Profile-scoped raw `SOUL.md` read. Returns `{profile, path, content, exists, size_bytes}` with optional `truncated: true` when content exceeds the 200KB inline cap. Absent SOUL.md returns 200 with `exists: false` and an empty content string so the Inspector can distinguish "no soul" from transport failure. Same auth model as `/config`. 404 on unknown profile; 500 `{error: "soul_read_failed"}` on decode error. See §22 in decisions.md. |
|
||||
| `/api/profiles/{name}/memory` | GET | Profile-scoped memory listing. Returns `{profile, memories_dir, entries: [{name, filename, path, content, size_bytes, truncated}], total}` for `*.md` files directly under `<profile>/memories/` (non-recursive). Ordering: `MEMORY.md` first, `USER.md` second, remainder alphabetical. Each entry capped at 50KB inline with `truncated: true` when larger. Absent memories dir → 200 with empty `entries` array. Same auth model as `/config`. 404 on unknown profile. See §22 in decisions.md. |
|
||||
|
||||
### 6.2 Chat — Direct API Connection
|
||||
### 6.2 Chat — Standard Gateway with API Fallback
|
||||
|
||||
Chat connects directly from the Android app to the Hermes API Server, bypassing the relay server entirely. This uses the Hermes Sessions API:
|
||||
Chat bypasses the Relay server entirely. In `Auto`, Android uses the upstream
|
||||
dashboard `/api/ws` gateway when dashboard auth is ready because that is the
|
||||
vanilla upstream path with live thinking/reasoning events. When that gateway is
|
||||
unavailable, Android falls back to API-server SSE routes. The native Sessions
|
||||
API fallback looks like:
|
||||
|
||||
```
|
||||
1. POST /api/sessions → create session → get session_id
|
||||
@@ -746,7 +749,7 @@ The `ActionResult.data` field indicates which tier succeeded (`"direct"` / `"par
|
||||
- [x] App: Terminal resize on orientation change
|
||||
|
||||
### Phase 3 — Bridge Channel
|
||||
**Status: shipped in v0.3.0 (2026-04-13). v0.4 bridge feature expansion is in progress on `feature/bridge-feature-expansion` — adds long-press / drag / macro / clipboard / intent-send / location / contacts / call / SMS and multi-window screen reading.**
|
||||
**Status: shipped and expanded.** The original bridge channel shipped in v0.3.0 (2026-04-13); the later bridge expansion added long-press / drag / macro / clipboard / intent-send / location / contacts / call / SMS and multi-window screen reading. Bridge remains a Relay-required sideload power surface, not part of the standard no-plugin path.
|
||||
|
||||
- [x] Migrate upstream bridge protocol into multiplexed WSS — Phase 3 Wave 1, 2026-04-12 (routes registered in `plugin/relay/server.py` delegating to `plugin/relay/channels/bridge.py`)
|
||||
- [x] Update `plugin/tools/android_tool.py` to route through the unified relay on port 8767 (was the standalone `android_relay.py` on 8766)
|
||||
@@ -840,7 +843,7 @@ See `docs/decisions.md` → **Voice Mode — Architecture** for the historical b
|
||||
|
||||
## 8. Current Scope
|
||||
|
||||
As of v0.3.0, Phases 0–5 plus Phase V (voice) have shipped in some form. The current release cadence focuses on **v0.4 bridge feature expansion** — see `docs/plans/2026-04-13-bridge-feature-expansion.md`.
|
||||
As of v1.0.0, the current scope is maintaining the standard-first contract while keeping Relay power features additive and cleanly manageable. Standard chat, Manage, and dashboard voice must continue to work against unmodified upstream Hermes. Relay work should be plugin-owned, diagnosable through `hermes relay doctor`, and removable without becoming a hidden requirement for the standard app path.
|
||||
|
||||
**Still non-goals for the current cadence:**
|
||||
- Biometric session lock (fingerprint/face gate on terminal and/or chat resume). Tracked under Phase 4.
|
||||
@@ -855,7 +858,7 @@ See `Appendix A — Original Phase 0 Scope` at the end of this document for the
|
||||
|
||||
## 9. Key Dependencies
|
||||
|
||||
Current versions as of v0.3.0. Source of truth is `gradle/libs.versions.toml` — this table is a human-readable snapshot, not authoritative.
|
||||
Current Android dependency versions. Source of truth is `gradle/libs.versions.toml` — this table is a human-readable snapshot, not authoritative.
|
||||
|
||||
| Dependency | Version | Purpose |
|
||||
|------------|---------|---------|
|
||||
@@ -885,18 +888,18 @@ Current versions as of v0.3.0. Source of truth is `gradle/libs.versions.toml`
|
||||
|
||||
| Surface | How We Connect |
|
||||
|---------|---------------|
|
||||
| **WebAPI chat** | HTTP to `localhost:8642/api/sessions/*/chat/stream` (SSE) |
|
||||
| **WebAPI sessions** | `GET/POST/PATCH/DELETE /api/sessions` for CRUD |
|
||||
| **Personalities** | `GET /api/config` → `config.agent.personalities` for picker + command palette |
|
||||
| **Server skills** | `GET /api/skills` — dynamic skill discovery for command palette + autocomplete |
|
||||
| **Plugin system** | `register_tool()` via `ctx` for `android_*` tools |
|
||||
| **Gateway** | Chat channel goes through WebAPI, not directly to gateway |
|
||||
| **Memory/Skills** | Accessible through agent chat (no direct API needed for MVP) |
|
||||
| **Gateway chat** | Dashboard `/api/auth/ws-ticket` + `/api/ws` for live thinking/reasoning when Manage auth is ready |
|
||||
| **API-server chat fallback** | `/api/sessions/*/chat/stream`, `/v1/chat/completions`, or `/v1/runs` based on capability probes |
|
||||
| **API-server sessions** | `GET/POST/PATCH/DELETE /api/sessions` for CRUD |
|
||||
| **Manage** | Dashboard `/api/status`, `/api/auth/me`, `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` |
|
||||
| **Standard voice** | Dashboard `POST /api/audio/transcribe` and `POST /api/audio/speak` |
|
||||
| **Plugin system** | `register_tool()` via `ctx` for `android_*` and `desktop_*` tools |
|
||||
| **Relay plugin** | `hermes pair`, `hermes relay start`, `hermes relay doctor`, `hermes relay compat`, dashboard `/relay` plugin tab |
|
||||
| **Dashboard plugin** | Lives at `plugin/dashboard/`; see §10.1 below |
|
||||
|
||||
### 10.1 Dashboard plugin
|
||||
|
||||
Hermes-Relay ships a hermes-agent Dashboard Plugin (upstream `axiom` branch, commit `01214a7f`) that surfaces relay-specific state in the gateway's web UI. The plugin subtree at `plugin/dashboard/` is discovered automatically: the canonical `install.sh` symlinks `~/.hermes/plugins/hermes-relay` → `<repo>/plugin`, and the gateway scans `~/.hermes/plugins/<name>/dashboard/manifest.json` at startup. Manifest fields (`name: "hermes-relay"`, `label: "Relay"`, `icon: "Activity"` from the 20-name Lucide whitelist, `tab.path: "/relay"`, `tab.position: "after:skills"`) place the tab after Skills in the dashboard nav.
|
||||
Hermes-Relay ships a hermes-agent Dashboard Plugin that surfaces relay-specific state in the gateway's web UI. The plugin subtree at `plugin/dashboard/` is discovered when `~/.hermes/plugins/hermes-relay` points at `<repo>/plugin` or when the upstream plugin manager installs `Codename-11/hermes-relay/plugin`. The gateway scans `~/.hermes/plugins/<name>/dashboard/manifest.json` at startup. Manifest fields (`name: "hermes-relay"`, `label: "Relay"`, `icon: "Activity"`, `tab.path: "/relay"`, `tab.position: "after:skills"`) place the tab after Skills in the dashboard nav.
|
||||
|
||||
**Four internal tabs** render inside the single `/relay` route via a shadcn `Tabs` component:
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ Improvements that would benefit hermes-relay (and other frontends) if added to [
|
||||
|
||||
## 3. Third-Party Plugin CLI Commands (Resolved Upstream)
|
||||
|
||||
**Current state (2026-06-07 source check):** current upstream discovers plugins before top-level CLI parser finalization and iterates `get_plugin_manager()._cli_commands.values()` in `hermes_cli/main.py`. Third-party plugins that call `ctx.register_cli_command(...)` now reach plugin-provided commands such as `hermes pair` and `hermes relay` through the plugin-native path.
|
||||
**Current state (2026-06-16 source check against `55cb4103`):** current upstream discovers plugins before top-level CLI parser finalization and iterates `get_plugin_manager()._cli_commands.values()` in `hermes_cli/main.py`. Third-party plugins that call `ctx.register_cli_command(...)` now reach plugin-provided commands such as `hermes pair` and `hermes relay` through the plugin-native path.
|
||||
|
||||
**Impact:** no new upstream patch is needed for generic plugin CLI command dispatch. Hermes-Relay should prefer plugin-registered `hermes pair` / `hermes relay` on current upstream installs once the Hermes-Relay plugin is installed and enabled. These are not built-in Hermes core commands.
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
| Agent tools | Tool Gateway tools registered through plugin context | `ctx.register_tool(...)` in `plugin/__init__.py`; schemas and handlers in `plugin/tools/*` | Aligned with custom transports | Tool registration should stay in `register(ctx)`; transport details stay behind handlers. |
|
||||
| Dashboard tab and plugin API | Dashboard plugin manifest plus plugin API routes under the Hermes dashboard plugin mount | `plugin/dashboard/manifest.json`, `plugin/dashboard/plugin_api.py` | Aligned wrapper | Dashboard routes may proxy relay state, but discovery and mounting should stay upstream-native. |
|
||||
| Chat and model API | OpenAI-compatible API server routes such as `/v1/chat/completions`, `/v1/models`, `/v1/capabilities`, `/health`, and supported streaming routes | Android `HermesApiClient`, relay docs, Web API docs | Mixed | Prefer `/v1/capabilities` when present, then targeted probes for mixed-version fallback. |
|
||||
| Sessions API | Native API-server session controls merged in NousResearch/hermes-agent PR #33134 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; older-build compatibility overlay in `hermes_relay_bootstrap/*` | Native upstream with fallback | Prefer native `/api/sessions/*`. Bootstrap must skip native routes per method/path and only inject missing compatibility routes for old core builds. |
|
||||
| Sessions API | Native API-server session controls merged in NousResearch/hermes-agent PR #33134 (`/api/sessions`, messages, fork, chat, chat stream) | Android `HermesApiClient`; older-build compatibility overlay in `plugin/hermes_relay_bootstrap/*` | Native upstream with fallback | Prefer native `/api/sessions/*`. Bootstrap must skip native routes per method/path and only inject missing compatibility routes for old core builds. |
|
||||
| Skills and toolsets discovery | Native read-only `/v1/skills` and `/v1/toolsets` merged in NousResearch/hermes-agent PR #33016 | Android `HermesApiClient.getSkills()` prefers `/v1/skills`; desktop/CLI tool surfaces should prefer `/v1/toolsets` where applicable | Native upstream with legacy fallback | Retire `/api/skills` list dependence from clients; keep legacy detail/toggle only where no native equivalent exists. |
|
||||
| Config, memory, legacy skills, available-models APIs | Not stable current upstream API-server routes as of the 2026-06-07 source check | `hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions/skills retirement path. Do not keep the bootstrap solely for sessions or read-only skill lists once supported baselines include #33134/#33016. |
|
||||
| Config, memory, legacy skills, available-models APIs | Not stable current upstream API-server routes as of the 2026-06-16 source check against `55cb4103` | `plugin/hermes_relay_bootstrap/*`, `docs/HERMES-WEBAPI-REFERENCE.md` | Compatibility layer | Keep separate from the sessions/skills retirement path. Do not keep the bootstrap solely for sessions or read-only skill lists once supported baselines include #33134/#33016. |
|
||||
| Mobile, desktop, and terminal relay transport | No general upstream plugin WSS transport for persistent remote clients in current public docs | `plugin/relay/server.py`, `plugin/relay/channels/*` | Custom | Keep the relay protocol documented and avoid leaking relay-only assumptions into upstream API clients. |
|
||||
| Pairing QR and relay session minting | No upstream pairing or device-registration method for remote mobile clients in current public docs | `plugin/pair.py`, relay `/pairing/*`, Android QR parser | Custom | QR payloads should keep API credentials (`key`) separate from relay credentials (`relay.code`). |
|
||||
| Pairing QR and relay session minting | No upstream pairing or device-registration method for remote mobile clients in current public docs | `plugin/pair.py`, relay `/pairing/*`, Android QR parser | Custom | QR payloads should keep API credentials (`key`), dashboard URL (`dashboard_url`), and relay credentials (`relay.code`) as separate fields. |
|
||||
| Basic STT/TTS over HTTP | Proposed upstream API-server audio endpoints in PR #8199 (`/v1/audio/transcriptions`, `/v1/audio/speech`) | Relay `/voice/config`, `/voice/transcribe`, `/voice/synthesize`; Android `RelayVoiceClient`; `plugin/relay/upstream_voice.py` | Custom wrapper pending upstream replacement | Keep `/voice/*` as the relay auth/session compatibility facade. Once core audio endpoints land, prefer proxying to native `/v1/audio/*` for STT/TTS work before falling back to private helper imports. |
|
||||
| Realtime and streaming voice provider routes | No stable upstream mobile realtime voice provider interface in current public docs | `plugin/relay/voice_output.py`, `plugin/relay/realtime_voice.py`, `plugin.voice_lab`, Android voice mode, Android realtime voice dev screen | Custom relay-mediated voice output path | Keep `/voice/config`, `/voice/transcribe`, and `/voice/synthesize` available as fallback utilities; keep provider secrets server-side; route deterministic assistant speech through `/voice/output/*`, not provider-agent reasoning. |
|
||||
| TUI and desktop remote terminal | Upstream TUI gateway JSON-RPC and types | `plugin/relay/channels/tui.py`, `desktop/src/gatewayTypes.ts`, `desktop/src/transport/RelayTransport.ts` | Mixed | Keep JSON-RPC payloads in lockstep with upstream TUI; document relay lifecycle changes separately. |
|
||||
@@ -54,10 +54,10 @@ relay, dashboard, Android app, desktop app, bootstrap package, or user docs.
|
||||
|
||||
| Deviation | Owner files | Why it exists | Guard or fallback | Retirement condition |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| API bootstrap route and middleware injection | `hermes_relay_bootstrap/*` | Older native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. Current upstream already covers sessions plus read-only skills/toolsets. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions once the supported Hermes baseline includes #33134; read-only skill lists once clients use `/v1/skills`; config/memory/legacy skill detail/toggle/available-models after stable core replacements or local UX removal; slash middleware after native preprocessing exists. |
|
||||
| API bootstrap route and middleware injection | `plugin/hermes_relay_bootstrap/*`; repo-root `hermes_relay_bootstrap/*` is a legacy import shim | Older native installs need session/config/skills/memory endpoints and slash-command preprocessing before upstream exposes stable equivalents. Current upstream already covers sessions plus read-only skills/toolsets. | Method/path feature detection skips native upstream routes and injects only missing compatibility gaps; upstream-module checks skip middleware when native slash preprocessing exists. | Retire per surface: sessions once the supported Hermes baseline includes #33134; read-only skill lists once clients use `/v1/skills`; config/memory/legacy skill detail/toggle/available-models after stable core replacements or local UX removal; slash middleware after native preprocessing exists. |
|
||||
| Plugin CLI shim fallback | `plugin/__init__.py`, `plugin/cli.py`, install scripts | Current upstream wires third-party plugin CLI commands into the top-level parser, but older supported Hermes builds and scripts may still call the dashed shims. | Prefer `ctx.register_cli_command` / plugin-provided `hermes pair` on current upstream after Hermes-Relay is installed and enabled; standalone shims stay as compatibility wrappers. | Remove shims only after the supported Hermes baseline includes the upstream CLI discovery fix and release/install docs have switched away from the dashed names. |
|
||||
| Relay HTTP and WSS server | `plugin/relay/server.py`, `plugin/relay/channels/*` | Mobile, desktop, terminal, media, push, and bridge features need persistent client channels and relay-owned session state. | Keep upstream API calls separate from relay session calls and document the protocol in `docs/relay-protocol.md`. | Replace pieces only when upstream provides equivalent remote-client transport or platform adapters. |
|
||||
| Pairing schema with `relay.code` | `plugin/pair.py`, Android pairing parser, relay `/pairing/*` | An API bearer key authenticates Hermes API calls but does not create relay sessions or describe WSS endpoints. | QR payloads carry direct API credentials and relay credentials as separate families. | Remove custom pairing when upstream offers native remote-device registration and relay discovery. |
|
||||
| Pairing schema with `relay.code` | `plugin/pair.py`, Android pairing parser, relay `/pairing/*` | An API bearer key authenticates Hermes API calls but does not create relay sessions or describe WSS endpoints. | QR payloads carry direct API credentials, optional dashboard URL, and relay credentials as separate families. | Remove custom pairing when upstream offers native remote-device registration and relay discovery. |
|
||||
| Voice `/voice/*` endpoints | `plugin/relay/voice.py`, `plugin/relay/upstream_voice.py`, `plugin/relay/voice_auth.py`, Android voice client | Relay clients need paired-session auth, profile labels, transport guards, and stable `/voice/*` shapes even while core audio APIs evolve. | Use native `/v1/audio/*` once available for STT/TTS execution, with helper imports as fallback; pass selected Hermes profile context; require relay session or valid Hermes API bearer auth. | Keep `/voice/*` as a compatibility facade until mobile clients can safely target core audio directly without losing relay auth/grants/profile behavior. |
|
||||
| Voice output and realtime endpoints | `plugin/relay/voice_output.py`, `plugin/relay/realtime_voice.py`, `plugin/relay/realtime_agent/*`, `plugin/relay/profile_voice.py`, `plugin/relay/provider_options.py`, `plugin/voice_lab/*`, `app/src/main/kotlin/com/hermesandroid/relay/viewmodel/VoiceViewModel.kt`, `app/src/main/kotlin/com/hermesandroid/relay/audio/VoiceRecorder.kt`, `app/src/main/kotlin/com/hermesandroid/relay/audio/RealtimePcmPlayer.kt`, `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/VoiceSettingsScreen.kt`, `app/src/main/kotlin/com/hermesandroid/relay/ui/screens/RealtimeVoiceTestScreen.kt`, `RelayVoiceClient.kt` | Android conversational voice now prefers `/voice/output/*` streaming TTS for exact assistant speech while keeping the Hermes chat/tool loop server-owned. Realtime providers are retained for lab experiments, and `/voice/realtime-agent/*` adds an experimental Hermes-brokered engine for provider speech with mirrored tool/timeline state. | Voice output is enabled by default but can be disabled with `RELAY_VOICE_OUTPUT_ENABLED=0`; realtime-agent mode can be disabled with `RELAY_REALTIME_VOICE_ENABLED=0`; basic `/voice/synthesize` fallback remains; provider secrets stay server-side; selected profiles can supply experimental `voice_output` / `realtime_voice` defaults with explicit fallback metadata; provider option discovery stays relay-scoped with static fallbacks, cached/paginated xAI custom voices, dynamic ElevenLabs fetches, grouped picker metadata, and pre-save validation where provider APIs support it. | Replace or thin after upstream ships a stable mobile voice output API with server-side auth, deterministic TTS rendering, and tool-loop integration. |
|
||||
| Relay dashboard proxy endpoints | `plugin/dashboard/plugin_api.py` | Dashboard plugin mounting is upstream-native, but the dashboard needs relay runtime data from the local relay process. | Keep dashboard API loopback-scoped and proxy only documented relay routes. | Keep as long as relay runtime state exists outside Hermes dashboard process. |
|
||||
@@ -97,12 +97,19 @@ upgrading the supported Hermes baseline.
|
||||
python scripts/check-server-version-sync.py
|
||||
```
|
||||
|
||||
When Android, server/plugin, or desktop release surfaces are touched together,
|
||||
also run:
|
||||
|
||||
```powershell
|
||||
python scripts/check-version-tracks.py
|
||||
```
|
||||
|
||||
3. Inspect local integration points:
|
||||
- `plugin/plugin.yaml`
|
||||
- `plugin/__init__.py`
|
||||
- `plugin/dashboard/manifest.json`
|
||||
- `plugin/dashboard/plugin_api.py`
|
||||
- `hermes_relay_bootstrap/*`
|
||||
- `plugin/hermes_relay_bootstrap/*`
|
||||
- `plugin/relay/server.py`
|
||||
- `plugin/relay/voice.py`
|
||||
- `plugin/relay/realtime_voice.py`
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Hermes-Relay Surface Matrix
|
||||
|
||||
Updated: 2026-06-16
|
||||
|
||||
This matrix records the v1.0.0 route ownership contract. It is meant to keep
|
||||
future app, plugin, and agent work honest about what is standard upstream
|
||||
Hermes, what belongs to the Relay plugin, and what is only legacy compatibility.
|
||||
|
||||
Verified upstream source snapshot:
|
||||
|
||||
- Repository: `NousResearch/hermes-agent`
|
||||
- Commit: `55cb4103beba5822303c06b662635e1491ae72f5`
|
||||
- Primary files checked: `gateway/platforms/api_server.py`,
|
||||
`hermes_cli/web_server.py`, `hermes_cli/dashboard_auth/routes.py`,
|
||||
`tui_gateway/server.py`, `hermes_cli/plugins.py`,
|
||||
`hermes_cli/plugins_cmd.py`
|
||||
|
||||
## Ownership
|
||||
|
||||
| Surface | Owner | Requires Relay | Android usage | Notes |
|
||||
|---------|-------|----------------|---------------|-------|
|
||||
| `/v1/capabilities` | Upstream API server | No | Capability probe | Source of truth for API-server features; current upstream advertises no audio API. |
|
||||
| `/v1/chat/completions` | Upstream API server | No | Chat fallback | OpenAI-compatible streaming. Tool events may degrade to inline annotations. |
|
||||
| `/v1/runs`, `/v1/runs/{id}/events` | Upstream API server | No | Chat fallback | Structured run events and stop/approval support. |
|
||||
| `/api/sessions/*` | Upstream API server | No | Session CRUD and SSE chat | Native upstream session list/create/read/update/delete/messages/fork/chat/chat-stream. Bootstrap is old-build fallback only. |
|
||||
| `/v1/skills`, `/v1/toolsets` | Upstream API server | No | Discovery | Read-only API-server skill/toolset inventory. |
|
||||
| Dashboard `/api/status`, `/api/auth/me` | Upstream dashboard | No | Manage auth | Dashboard cookie/session path; separate from API bearer. |
|
||||
| Dashboard `/api/auth/ws-ticket`, `/api/ws` | Upstream dashboard/tui_gateway | No | Preferred chat transport | Standard gateway chat path with live reasoning/thinking events. |
|
||||
| Dashboard `/api/audio/transcribe`, `/api/audio/speak` | Upstream dashboard | No | Standard voice | Manage sign-in unlocks standard voice. API server has no `/v1/audio/*` route today. |
|
||||
| Dashboard `/api/config`, `/api/profiles/*`, `/api/env`, `/api/model/*`, `/api/mcp/*` | Upstream dashboard | No | Manage | Do not proxy through Relay. |
|
||||
| `/pairing/*`, `/sessions`, `/voice/*`, `/desktop/*`, `/media/*`, `/notifications/*` on Relay | Hermes-Relay plugin/server | Yes | Relay pairing, terminal, bridge, relay voice, desktop tools | Owned by `plugin/relay/server.py`; Android must gate behind Relay readiness/session grants. |
|
||||
| Dashboard `/api/plugins/hermes-relay/*` | Hermes-Relay dashboard plugin | Yes for live data | Relay dashboard tab | FastAPI plugin backend proxies loopback requests to the Relay server. |
|
||||
| `hermes relay doctor` | Hermes-Relay plugin CLI | No for diagnostics | Operator/agent diagnostics | Reports standard route reachability, plugin layout, Relay loopback state, and legacy bootstrap presence. |
|
||||
| `hermes_relay_bootstrap` routes | Legacy compatibility monkeypatch | No, but non-standard | Fallback only | Installed via `.pth` by legacy installer. Keep only for older Hermes builds or compatibility-only gaps. |
|
||||
|
||||
## Plugin Lifecycle Contract
|
||||
|
||||
The upstream Hermes plugin manager installs, updates, enables, disables, and
|
||||
removes a plugin tree under `~/.hermes/plugins/<name>`.
|
||||
|
||||
Hermes-Relay's plugin tree is currently `plugin/`, so the plugin-manager install
|
||||
identifier is:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin
|
||||
```
|
||||
|
||||
This manages the plugin code, CLI command registration, dashboard manifest, and
|
||||
agent tools. The optional compatibility startup hook is plugin-owned through:
|
||||
|
||||
```bash
|
||||
hermes relay compat status
|
||||
hermes relay compat install
|
||||
hermes relay compat remove
|
||||
```
|
||||
|
||||
Those commands install or remove only `hermes_relay_bootstrap.pth` in the target
|
||||
Python environment. New hooks are path-pinned to
|
||||
`plugin/hermes_relay_bootstrap/` so plugin-manager installs do not depend on a
|
||||
repo-root Python package. Existing legacy hooks that import the top-level
|
||||
`hermes_relay_bootstrap` shim are still detected and removable.
|
||||
`hermes relay doctor --json` includes the same status.
|
||||
|
||||
The upstream plugin manager still does not clean legacy installer artifacts such
|
||||
as editable/root package installs, systemd user units, shell shims, or external
|
||||
skill-path entries. Clean removal of those full legacy installs remains
|
||||
`uninstall.sh` until upstream plugin lifecycle hooks or plugin-owned service
|
||||
commands exist. For a bounded cleanup handoff, see
|
||||
`docs/legacy-cleanup-agent-prompt.md`.
|
||||
|
||||
## Legacy Artifact Ownership
|
||||
|
||||
| Artifact | Created by | Owner now | Cleanup command | Remaining risk |
|
||||
|----------|------------|-----------|-----------------|----------------|
|
||||
| Plugin tree at `~/.hermes/plugins/hermes-relay` | `hermes plugins install ...` or legacy `install.sh` symlink | Upstream plugin manager for plugin-manager installs; `uninstall.sh` for legacy symlinks | `hermes plugins remove hermes-relay` or `bash ~/.hermes/hermes-relay/uninstall.sh` | Plugin manager removal does not remove the legacy clone or host service. |
|
||||
| `hermes_relay_bootstrap.pth` in Hermes venv `site-packages` | `hermes relay compat install` or legacy `install.sh` | Plugin compat CLI | `hermes relay compat remove --all`; legacy `uninstall.sh` delegates to this when available | Keep only for older Hermes builds or compatibility-only API gaps. |
|
||||
| Editable/root Python package install `hermes-relay` | Legacy `install.sh` | Legacy installer | `bash ~/.hermes/hermes-relay/uninstall.sh` | Needed only for old clone/update workflow and shell shim entry points. |
|
||||
| Systemd user unit `~/.config/systemd/user/hermes-relay.service` | Legacy `install.sh` | Legacy installer | `bash ~/.hermes/hermes-relay/uninstall.sh` | Plugin manager has no service lifecycle hook yet; manual `hermes relay start` remains the plugin-native foreground path. |
|
||||
| Shell shims `hermes-pair`, `hermes-status`, `hermes-relay`, `hermes-relay-update`, `hermes-relay-tailscale` | Legacy `install.sh` | Legacy installer | `bash ~/.hermes/hermes-relay/uninstall.sh` | Current upstream CLI prefers `hermes pair` and `hermes relay`; shims are older-build/script compatibility. |
|
||||
| `skills.external_dirs` entry pointing at the Relay clone | Legacy `install.sh` | Legacy installer | `bash ~/.hermes/hermes-relay/uninstall.sh` | Plugin tools do not require this; retained only for legacy slash-skill discovery. |
|
||||
| Relay clone at `~/.hermes/hermes-relay` | Legacy `install.sh` | Legacy installer | `bash ~/.hermes/hermes-relay/uninstall.sh` unless `--keep-clone` | Removing the clone also removes local scripts and legacy skill files; shared Hermes state is preserved. |
|
||||
| QR signing secret `~/.hermes/hermes-relay-qr-secret` | Pairing CLI/dashboard mint | Operator identity state | `bash ~/.hermes/hermes-relay/uninstall.sh --remove-secret` | Kept by default so reinstall preserves QR signing identity. |
|
||||
|
||||
## App Flow Rule
|
||||
|
||||
The app should present Standard as the default path:
|
||||
|
||||
1. Connect API server and dashboard.
|
||||
2. Sign in to Manage when dashboard auth is required.
|
||||
3. Use gateway chat when `/api/ws` is ready; otherwise fall back to API-server
|
||||
SSE.
|
||||
4. Use standard dashboard voice when audio routes are present.
|
||||
5. Offer Relay pairing only for Relay-owned power features.
|
||||
|
||||
When Auto voice selects Relay because a paired Relay is healthy, the UI should
|
||||
make that active route visible and continue to fall back to Standard voice when
|
||||
Relay fails but dashboard audio is ready.
|
||||
|
||||
Setup QR payloads may include top-level `dashboard_url`. Android uses that value
|
||||
for Manage and standard dashboard voice; when absent it derives the conventional
|
||||
same-host `:9119` dashboard URL from the API server.
|
||||
@@ -1,45 +1,15 @@
|
||||
"""hermes_relay_bootstrap — runtime patch for vanilla upstream hermes-agent.
|
||||
"""Legacy import shim for the plugin-owned Hermes-Relay bootstrap.
|
||||
|
||||
This package is loaded at Python interpreter startup via a `.pth` file in the
|
||||
hermes-agent venv's site-packages. It installs a `sys.meta_path` import hook
|
||||
that waits for `aiohttp.web` to be imported, then replaces `web.Application`
|
||||
with a thin subclass that detects when hermes-agent's `APIServerAdapter`
|
||||
attaches itself to a fresh app and:
|
||||
|
||||
1. **Injects missing compatibility routes** — older hermes-agent builds may
|
||||
lack `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, and
|
||||
`/api/available-models`. Current upstream already has the session API and
|
||||
read-only `/v1/skills` + `/v1/toolsets`, so native routes win per method/path
|
||||
and the bootstrap only fills gaps.
|
||||
|
||||
2. **Installs slash-command middleware** — an aiohttp middleware that intercepts
|
||||
`/v1/chat/completions` and `/v1/runs` to handle gateway slash commands
|
||||
(`/help`, `/commands`, `/profile`, `/provider`) and return decline notices
|
||||
for stateful commands (`/model`, `/new`, `/retry`, etc.), preventing the
|
||||
LLM from hallucinating responses for them. This mirrors the upstream
|
||||
Stage 1 preprocessor from `gateway/platforms/api_server_slash.py`.
|
||||
|
||||
Chat streaming prefers upstream's native
|
||||
`/api/sessions/{session_id}/chat/stream` endpoint when it is advertised. Older
|
||||
builds that only get bootstrap-provided session CRUD fall back to standard
|
||||
`/v1/chat/completions` or `/v1/runs` paths.
|
||||
|
||||
This module retires per surface, not as one broad PR cleanup. Sessions can go
|
||||
once the supported hermes-agent baseline includes PR #33134, read-only skills
|
||||
should use PR #33016's `/v1/skills`, and the remaining config/memory/legacy
|
||||
skill/available-model/slash-command surfaces need stable replacements or local
|
||||
UX removal before the package and `.pth` hook can disappear.
|
||||
New installs load ``plugin/hermes_relay_bootstrap`` through ``hermes relay
|
||||
compat install``. This top-level package stays as a backward-compatible import
|
||||
target for older editable installs and existing ``hermes_relay_bootstrap.pth``
|
||||
files.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from importlib import import_module
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import and install the meta_path finder. Kept in a sub-module so this file
|
||||
# stays tiny and the actual patch logic is easy to audit / disable.
|
||||
from . import _patch # noqa: E402
|
||||
|
||||
_patch.install_finder()
|
||||
_module = import_module("plugin.hermes_relay_bootstrap")
|
||||
sys.modules[__name__] = _module
|
||||
|
||||
+24
-19
@@ -8,11 +8,11 @@
|
||||
# 1. The hermes-relay repo to ~/.hermes/hermes-relay (editable, git-backed)
|
||||
# 2. The Python package (plugin + relay server + bootstrap injection) via
|
||||
# `pip install -e` into the hermes-agent venv so `python -m plugin.pair`
|
||||
# works from anywhere AND the hermes_relay_bootstrap package is on the
|
||||
# Python path. The bootstrap is also wired up via a `.pth` file dropped
|
||||
# directly into the venv's site-packages so Python's `site` module
|
||||
# auto-loads it at every interpreter startup. The bootstrap monkey-
|
||||
# patches `aiohttp.web.Application` so when the gateway builds its app,
|
||||
# works from anywhere and the plugin-owned compatibility bootstrap is
|
||||
# available. The bootstrap is wired up via a `.pth` file dropped directly
|
||||
# into the venv's site-packages so Python's `site` module auto-loads it at
|
||||
# every interpreter startup. The bootstrap monkey-patches
|
||||
# `aiohttp.web.Application` so when the gateway builds its app,
|
||||
# missing compatibility routes can be injected onto the same router the
|
||||
# gateway is in the middle of populating. Current upstream already serves
|
||||
# `/api/sessions/*` and read-only `/v1/skills` + `/v1/toolsets`; the
|
||||
@@ -29,7 +29,9 @@
|
||||
# 5. Shell shims at:
|
||||
# - ~/.local/bin/hermes-pair → `<venv>/python -m plugin.pair "$@"`
|
||||
# - ~/.local/bin/hermes-status → `<venv>/python -m plugin.status "$@"`
|
||||
# - ~/.local/bin/hermes-relay → `<venv>/python -m plugin.cli "$@"`
|
||||
# - ~/.local/bin/hermes-relay-update → curl-pipe re-runs install.sh
|
||||
# - ~/.local/bin/hermes-relay-tailscale → `<venv>/python -m plugin.relay.tailscale_cli "$@"`
|
||||
# Current upstream exposes plugin CLI commands, so `hermes pair` should
|
||||
# work when the plugin is enabled. The pair/status shims remain
|
||||
# script-friendly older-build fallbacks. The update shim is just a
|
||||
@@ -44,9 +46,8 @@
|
||||
# Updates:
|
||||
# cd ~/.hermes/hermes-relay && git pull
|
||||
# (No reinstall needed — editable pip install + external_dirs scan mean
|
||||
# changes go live on the next invocation. The bootstrap .pth file is
|
||||
# overwritten on every install.sh re-run, so changes to the bootstrap
|
||||
# package itself need a fresh `bash install.sh` to land in site-packages.)
|
||||
# changes go live on the next invocation. The bootstrap .pth hook is
|
||||
# refreshed through `hermes relay compat install` on every install.sh re-run.)
|
||||
#
|
||||
# Uninstall:
|
||||
# bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
@@ -316,26 +317,30 @@ spin $! "pip install -e $(basename "$RELAY_HOME")" \
|
||||
|| die "pip install -e $RELAY_HOME failed"
|
||||
ok "Installed $("$VENV_PY" -m pip show hermes-relay 2>/dev/null | awk '/^Name:/{n=$2}/^Version:/{print n" "$2}')"
|
||||
|
||||
# Drop the bootstrap .pth into the venv's site-packages so Python loads
|
||||
# `hermes_relay_bootstrap` at interpreter startup. This is what allows the
|
||||
# plugin to inject missing compatibility routes onto the gateway's aiohttp app
|
||||
# at startup. The .pth has to live directly in site-packages —
|
||||
# setuptools' editable install does NOT ship data-files there, so we drop
|
||||
# it manually here. Idempotent: a second run overwrites the same file.
|
||||
# Drop the legacy bootstrap .pth into the venv's site-packages so Python loads
|
||||
# the plugin-owned compatibility bootstrap at interpreter startup. This is what
|
||||
# allows the plugin to inject missing compatibility routes onto the gateway's
|
||||
# aiohttp app at startup. The .pth has to live directly in site-packages —
|
||||
# setuptools' editable install does NOT ship data-files there, so we drop it
|
||||
# manually here. Idempotent: a second run overwrites the same file.
|
||||
#
|
||||
# Removal: the bootstrap package and its .pth come out together via
|
||||
# `bash uninstall.sh`. The bootstrap also feature-detects on route paths,
|
||||
# so it cleanly no-ops on hermes-agent builds that already serve the same
|
||||
# routes natively — leaving it installed is safe across all versions.
|
||||
SITE_PKGS="$("$VENV_PY" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
||||
PTH_SRC="$RELAY_HOME/hermes_relay_bootstrap.pth"
|
||||
if [ -n "$SITE_PKGS" ] && [ -d "$SITE_PKGS" ] && [ -f "$PTH_SRC" ]; then
|
||||
cp "$PTH_SRC" "$SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
ok "Installed bootstrap .pth → $SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
if [ -n "$SITE_PKGS" ] && [ -d "$SITE_PKGS" ]; then
|
||||
if "$VENV_PY" -m plugin.cli relay compat install --site-packages "$SITE_PKGS" >/dev/null 2>&1; then
|
||||
ok "Installed compat hook via hermes relay compat → $SITE_PKGS/hermes_relay_bootstrap.pth"
|
||||
else
|
||||
warn "Could not install compat hook through plugin CLI"
|
||||
info " Modern standard chat, Manage, and dashboard voice do not require it."
|
||||
info " For older Hermes compatibility routes, retry: hermes relay compat install"
|
||||
fi
|
||||
else
|
||||
info " Could not determine venv site-packages — bootstrap .pth NOT installed"
|
||||
info " This means older hermes-agent builds won't get relay compatibility"
|
||||
info " routes. Manually copy $PTH_SRC into your venv's site-packages."
|
||||
info " routes. Retry later with: hermes relay compat install"
|
||||
fi
|
||||
|
||||
# ── 3/6 Symlink plugin into Hermes plugin dir ─────────────────────────────
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Hermes-Relay Plugin Installed
|
||||
|
||||
Enable the plugin in Hermes, restart the Hermes process, then verify the install:
|
||||
|
||||
```bash
|
||||
hermes relay doctor
|
||||
```
|
||||
|
||||
Use `hermes pair` to create a QR pairing payload. Use `hermes relay start` to run
|
||||
the relay foreground server, or use the legacy installer if you still need the
|
||||
systemd user service and shell shims.
|
||||
|
||||
The optional legacy compatibility hook is managed separately:
|
||||
|
||||
```bash
|
||||
hermes relay compat status
|
||||
hermes relay compat install # only for older Hermes builds or route gaps
|
||||
hermes relay compat remove
|
||||
```
|
||||
|
||||
Standard Android chat, Manage, and dashboard voice use vanilla upstream Hermes.
|
||||
The Relay plugin is additive: phone control, terminal, remote desktop tooling,
|
||||
dashboard Relay management, and optional compatibility diagnostics.
|
||||
|
||||
Legacy installs may also have a `hermes_relay_bootstrap.pth` monkeypatch in the
|
||||
Hermes Python environment. `hermes relay doctor --json` and
|
||||
`hermes relay compat status --json` report whether that bootstrap is present.
|
||||
Keep it only for older Hermes builds or compatibility-only route gaps.
|
||||
+111
-3
@@ -3,13 +3,18 @@
|
||||
Registers the following top-level `hermes` sub-commands:
|
||||
|
||||
hermes pair [--png] [--no-qr] [--host HOST] [--port PORT]
|
||||
[--dashboard-url URL] [--register-code CODE]
|
||||
[--mode auto|lan|tailscale|public] [--public-url URL]
|
||||
[--prefer ROLE]
|
||||
hermes relay start [--port PORT] [--no-ssl] [--log-level LEVEL]
|
||||
hermes relay doctor [--json]
|
||||
hermes relay compat [status|install|remove]
|
||||
hermes relay insecure-api-key [status|on|off]
|
||||
hermes-relay insecure-api-key [status|on|off]
|
||||
|
||||
Discovered by the hermes-agent v0.8.0+ plugin CLI registration system. The
|
||||
plugin loader calls ``register_cli(subparser)`` with a freshly-built parser
|
||||
for each sub-command; handlers are dispatched via ``args.func``.
|
||||
Discovered by the current hermes-agent plugin CLI registration system. The
|
||||
plugin loader calls ``register_cli(subparser)`` with a freshly-built parser for
|
||||
each sub-command; handlers are dispatched via ``args.func``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -58,6 +63,11 @@ def register_cli(subparser) -> None:
|
||||
type=int,
|
||||
help="Override API server port (default: 8642)",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--dashboard-url",
|
||||
metavar="URL",
|
||||
help="Embed an explicit Hermes dashboard URL for Manage/standard voice",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--ttl",
|
||||
metavar="DURATION",
|
||||
@@ -79,6 +89,36 @@ def register_cli(subparser) -> None:
|
||||
"capped at 7d)."
|
||||
),
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--register-code",
|
||||
metavar="CODE",
|
||||
help=(
|
||||
"Pre-register a phone-supplied six-character code and exit. "
|
||||
"Use when QR scanning is unavailable."
|
||||
),
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--transport-hint",
|
||||
choices=("ws", "wss"),
|
||||
default=None,
|
||||
help="Transport hint for --register-code and QR metadata",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--mode",
|
||||
choices=("auto", "lan", "tailscale", "public"),
|
||||
default="auto",
|
||||
help="Endpoint set to embed in the QR (default: auto)",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--public-url",
|
||||
metavar="URL",
|
||||
help="Public API or relay-proxy URL to include in the QR endpoint list",
|
||||
)
|
||||
subparser.add_argument(
|
||||
"--prefer",
|
||||
metavar="ROLE",
|
||||
help="Promote a QR endpoint role such as tailscale or public to priority 0",
|
||||
)
|
||||
subparser.set_defaults(func=pair_command)
|
||||
|
||||
|
||||
@@ -140,6 +180,56 @@ def register_relay_cli(subparser) -> None:
|
||||
)
|
||||
start.set_defaults(func=relay_start_command)
|
||||
|
||||
doctor = sub.add_parser(
|
||||
"doctor",
|
||||
help="Inspect plugin, standard Hermes, relay, and legacy bootstrap state",
|
||||
)
|
||||
doctor.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
doctor.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="Exit non-zero when any warning or error is present",
|
||||
)
|
||||
doctor.add_argument("--api-url", help="Hermes API base URL")
|
||||
doctor.add_argument("--dashboard-url", help="Hermes dashboard base URL")
|
||||
doctor.add_argument("--relay-port", type=int, help="Relay loopback port")
|
||||
doctor.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=2.0,
|
||||
help="Probe timeout in seconds",
|
||||
)
|
||||
doctor.set_defaults(func=relay_doctor_command)
|
||||
|
||||
compat = sub.add_parser(
|
||||
"compat",
|
||||
help="Manage the optional legacy API compatibility startup hook",
|
||||
)
|
||||
compat_sub = compat.add_subparsers(dest="compat_cmd", required=True)
|
||||
|
||||
compat_status = compat_sub.add_parser("status", help="Show compat hook status")
|
||||
compat_status.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
compat_status.add_argument(
|
||||
"--site-packages",
|
||||
help="Inspect a specific Python site-packages directory",
|
||||
)
|
||||
compat_status.set_defaults(func=relay_compat_command)
|
||||
|
||||
compat_install = compat_sub.add_parser("install", help="Install the compat startup hook")
|
||||
compat_install.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
compat_install.add_argument("--site-packages", help="Target site-packages directory")
|
||||
compat_install.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||
compat_install.add_argument("--force", action="store_true", help="Replace an existing hook file")
|
||||
compat_install.set_defaults(func=relay_compat_command)
|
||||
|
||||
compat_remove = compat_sub.add_parser("remove", help="Remove the compat startup hook")
|
||||
compat_remove.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
compat_remove.add_argument("--site-packages", help="Target site-packages directory")
|
||||
compat_remove.add_argument("--all", action="store_true", help="Remove all discovered hook files")
|
||||
compat_remove.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||
compat_remove.add_argument("--force", action="store_true", help="Remove unexpected hook content")
|
||||
compat_remove.set_defaults(func=relay_compat_command)
|
||||
|
||||
insecure = sub.add_parser(
|
||||
"insecure-api-key",
|
||||
aliases=["insecure-api-bearer"],
|
||||
@@ -170,6 +260,24 @@ def relay_command(args):
|
||||
return relay_start_command(args)
|
||||
|
||||
|
||||
def relay_doctor_command(args) -> None:
|
||||
"""Run the read-only Relay/plugin diagnostic report."""
|
||||
from .doctor import doctor_command
|
||||
|
||||
code = doctor_command(args)
|
||||
if code:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def relay_compat_command(args) -> None:
|
||||
"""Manage the optional legacy compatibility startup hook."""
|
||||
from .compat import compat_command
|
||||
|
||||
code = compat_command(args)
|
||||
if code:
|
||||
raise SystemExit(code)
|
||||
|
||||
|
||||
def relay_start_command(args) -> None:
|
||||
"""Run the relay server in the foreground.
|
||||
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
"""Plugin-owned lifecycle for the legacy Hermes-Relay compatibility hook."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import importlib.util
|
||||
import json
|
||||
import site
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
PTH_NAME = "hermes_relay_bootstrap.pth"
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
BOOTSTRAP_PACKAGE = "hermes_relay_plugin_bootstrap"
|
||||
PLUGIN_BOOTSTRAP_DIR_NAME = "hermes_relay_bootstrap"
|
||||
LEGACY_BOOTSTRAP_PACKAGE = "hermes_relay_bootstrap"
|
||||
LEGACY_PTH_CONTENT = "import hermes_relay_bootstrap\n"
|
||||
|
||||
|
||||
def _plugin_bootstrap_init(plugin_dir: Path | None = None) -> Path:
|
||||
return (plugin_dir or PLUGIN_DIR) / PLUGIN_BOOTSTRAP_DIR_NAME / "__init__.py"
|
||||
|
||||
|
||||
def _managed_pth_content(plugin_dir: Path | None = None) -> str:
|
||||
init_path = _plugin_bootstrap_init(plugin_dir)
|
||||
return (
|
||||
"import importlib.util, pathlib, sys; "
|
||||
f"p=pathlib.Path({str(init_path)!r}); "
|
||||
"spec=importlib.util.spec_from_file_location("
|
||||
f"{BOOTSTRAP_PACKAGE!r}, p, submodule_search_locations=[str(p.parent)]"
|
||||
") if p.exists() else None; "
|
||||
"mod=importlib.util.module_from_spec(spec) if spec and spec.loader else None; "
|
||||
"mod and sys.modules.__setitem__(spec.name, mod); "
|
||||
"mod and spec.loader.exec_module(mod)\n"
|
||||
)
|
||||
|
||||
|
||||
PTH_CONTENT = _managed_pth_content()
|
||||
_BOOTSTRAP_MARKERS = (
|
||||
BOOTSTRAP_PACKAGE,
|
||||
PLUGIN_BOOTSTRAP_DIR_NAME,
|
||||
LEGACY_BOOTSTRAP_PACKAGE,
|
||||
)
|
||||
|
||||
|
||||
def _site_dirs(site_dirs: Iterable[Path] | None = None) -> list[Path]:
|
||||
if site_dirs is not None:
|
||||
return [Path(p) for p in site_dirs]
|
||||
|
||||
candidates: list[Path] = []
|
||||
for entry in sys.path:
|
||||
p = Path(entry)
|
||||
if p.name == "site-packages":
|
||||
candidates.append(p)
|
||||
try:
|
||||
candidates.extend(Path(p) for p in site.getsitepackages())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
candidates.append(Path(site.getusersitepackages()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[Path] = []
|
||||
for candidate in candidates:
|
||||
key = str(candidate)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def _default_site_dir(site_dirs: Iterable[Path] | None = None) -> Path:
|
||||
dirs = _site_dirs(site_dirs)
|
||||
if not dirs:
|
||||
raise RuntimeError("Could not determine a Python site-packages directory.")
|
||||
for candidate in dirs:
|
||||
if candidate.exists():
|
||||
return candidate
|
||||
return dirs[0]
|
||||
|
||||
|
||||
def _bootstrap_package_status(plugin_dir: Path | None = None) -> dict[str, Any]:
|
||||
plugin_init = _plugin_bootstrap_init(plugin_dir)
|
||||
if plugin_init.exists():
|
||||
return {
|
||||
"importable": True,
|
||||
"path": str(plugin_init),
|
||||
"package": BOOTSTRAP_PACKAGE,
|
||||
"source": "plugin",
|
||||
}
|
||||
|
||||
spec = importlib.util.find_spec(LEGACY_BOOTSTRAP_PACKAGE)
|
||||
return {
|
||||
"importable": spec is not None,
|
||||
"path": spec.origin if spec is not None else None,
|
||||
"package": LEGACY_BOOTSTRAP_PACKAGE,
|
||||
"source": "legacy" if spec is not None else None,
|
||||
}
|
||||
|
||||
|
||||
def _read_pth(path: Path) -> str:
|
||||
try:
|
||||
return path.read_text(encoding="utf-8")
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _pth_entry(path: Path, *, plugin_dir: Path | None = None) -> dict[str, Any]:
|
||||
content = _read_pth(path)
|
||||
managed_contents = {
|
||||
_managed_pth_content(plugin_dir),
|
||||
LEGACY_PTH_CONTENT,
|
||||
}
|
||||
return {
|
||||
"path": str(path),
|
||||
"references_bootstrap": any(marker in content for marker in _BOOTSTRAP_MARKERS),
|
||||
"managed_content": content in managed_contents,
|
||||
"plugin_owned_content": content == _managed_pth_content(plugin_dir),
|
||||
}
|
||||
|
||||
|
||||
def collect_compat_status(
|
||||
*,
|
||||
site_dirs: Iterable[Path] | None = None,
|
||||
target_dir: Path | None = None,
|
||||
plugin_dir: Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
dirs = [Path(target_dir)] if target_dir is not None else _site_dirs(site_dirs)
|
||||
pth_files = [
|
||||
_pth_entry(path / PTH_NAME, plugin_dir=plugin_dir)
|
||||
for path in dirs
|
||||
if (path / PTH_NAME).exists()
|
||||
]
|
||||
package = _bootstrap_package_status(plugin_dir)
|
||||
try:
|
||||
default_site_dir = str(_default_site_dir(site_dirs)) if target_dir is None else str(target_dir)
|
||||
except RuntimeError:
|
||||
default_site_dir = None
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"hook": "legacy-bootstrap",
|
||||
"package": package,
|
||||
"installed": bool(pth_files),
|
||||
"pth_files": pth_files,
|
||||
"site_dirs": [str(p) for p in dirs],
|
||||
"default_site_dir": default_site_dir,
|
||||
"standard_path_requires_compat": False,
|
||||
"plugin_bootstrap_init": str(_plugin_bootstrap_init(plugin_dir)),
|
||||
"recommendation": (
|
||||
"Leave compat uninstalled on modern Hermes unless an older server "
|
||||
"still needs compatibility-only API routes or slash middleware."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def install_compat(
|
||||
*,
|
||||
site_packages: Path | None = None,
|
||||
site_dirs: Iterable[Path] | None = None,
|
||||
plugin_dir: Path | None = None,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
package = _bootstrap_package_status(plugin_dir)
|
||||
if not package["importable"]:
|
||||
raise RuntimeError(
|
||||
"Cannot install compat hook because the plugin-owned bootstrap "
|
||||
"package is not present. Reinstall the Hermes-Relay plugin, or "
|
||||
"skip compat on modern Hermes."
|
||||
)
|
||||
|
||||
target_dir = Path(site_packages) if site_packages is not None else _default_site_dir(site_dirs)
|
||||
target = target_dir / PTH_NAME
|
||||
before = _pth_entry(target, plugin_dir=plugin_dir) if target.exists() else None
|
||||
desired_content = _managed_pth_content(plugin_dir)
|
||||
|
||||
if target.exists() and not before["managed_content"] and not force:
|
||||
raise RuntimeError(
|
||||
f"{target} already exists with different content. Re-run with "
|
||||
"--force to replace it."
|
||||
)
|
||||
|
||||
changed = not target.exists() or _read_pth(target) != desired_content
|
||||
if changed and not dry_run:
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(desired_content, encoding="utf-8")
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "install",
|
||||
"changed": changed,
|
||||
"dry_run": dry_run,
|
||||
"target": str(target),
|
||||
"before": before,
|
||||
"after": _pth_entry(target, plugin_dir=plugin_dir) if target.exists() and not dry_run else None,
|
||||
"package": package,
|
||||
}
|
||||
|
||||
|
||||
def remove_compat(
|
||||
*,
|
||||
site_packages: Path | None = None,
|
||||
site_dirs: Iterable[Path] | None = None,
|
||||
plugin_dir: Path | None = None,
|
||||
all_found: bool = False,
|
||||
dry_run: bool = False,
|
||||
force: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if site_packages is not None:
|
||||
targets = [Path(site_packages) / PTH_NAME]
|
||||
elif all_found:
|
||||
targets = [Path(p) / PTH_NAME for p in _site_dirs(site_dirs) if (Path(p) / PTH_NAME).exists()]
|
||||
else:
|
||||
targets = [_default_site_dir(site_dirs) / PTH_NAME]
|
||||
|
||||
removed: list[dict[str, Any]] = []
|
||||
skipped: list[dict[str, Any]] = []
|
||||
for target in targets:
|
||||
if not target.exists():
|
||||
skipped.append({"path": str(target), "reason": "not_found"})
|
||||
continue
|
||||
entry = _pth_entry(target, plugin_dir=plugin_dir)
|
||||
if not entry["references_bootstrap"] and not force:
|
||||
raise RuntimeError(
|
||||
f"{target} does not look like a Hermes-Relay compat hook. "
|
||||
"Re-run with --force to remove it anyway."
|
||||
)
|
||||
if not dry_run:
|
||||
target.unlink()
|
||||
removed.append(entry)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"action": "remove",
|
||||
"changed": bool(removed),
|
||||
"dry_run": dry_run,
|
||||
"removed": removed,
|
||||
"skipped": skipped,
|
||||
}
|
||||
|
||||
|
||||
def render_compat_text(data: dict[str, Any]) -> str:
|
||||
if data.get("action") == "install":
|
||||
state = "would install" if data.get("dry_run") else "installed"
|
||||
if not data.get("changed"):
|
||||
state = "already installed"
|
||||
return f"Compat hook {state}: {data.get('target')}"
|
||||
|
||||
if data.get("action") == "remove":
|
||||
state = "would remove" if data.get("dry_run") else "removed"
|
||||
removed = data.get("removed") or []
|
||||
if not removed:
|
||||
return "Compat hook not found."
|
||||
return "\n".join(f"Compat hook {state}: {item['path']}" for item in removed)
|
||||
|
||||
lines = ["Hermes-Relay compat status", ""]
|
||||
package = data.get("package") or {}
|
||||
lines.append(f"Bootstrap package importable: {'yes' if package.get('importable') else 'no'}")
|
||||
if package.get("path"):
|
||||
lines.append(f"Bootstrap package path: {package['path']}")
|
||||
lines.append(f"Compat hook installed: {'yes' if data.get('installed') else 'no'}")
|
||||
for item in data.get("pth_files") or []:
|
||||
lines.append(f" {item['path']}")
|
||||
lines.append("")
|
||||
lines.append("Standard chat, Manage, and dashboard voice do not require compat.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _site_packages_arg(args: argparse.Namespace) -> Path | None:
|
||||
raw = getattr(args, "site_packages", None)
|
||||
return Path(raw).expanduser() if raw else None
|
||||
|
||||
|
||||
def compat_command(args: argparse.Namespace) -> int:
|
||||
action = getattr(args, "compat_cmd", "status")
|
||||
try:
|
||||
if action == "status":
|
||||
data = collect_compat_status(target_dir=_site_packages_arg(args))
|
||||
elif action == "install":
|
||||
data = install_compat(
|
||||
site_packages=_site_packages_arg(args),
|
||||
dry_run=bool(getattr(args, "dry_run", False)),
|
||||
force=bool(getattr(args, "force", False)),
|
||||
)
|
||||
elif action == "remove":
|
||||
data = remove_compat(
|
||||
site_packages=_site_packages_arg(args),
|
||||
all_found=bool(getattr(args, "all", False)),
|
||||
dry_run=bool(getattr(args, "dry_run", False)),
|
||||
force=bool(getattr(args, "force", False)),
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(f"Unknown compat action: {action}")
|
||||
except RuntimeError as exc:
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps({"ok": False, "error": str(exc)}, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(data, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(render_compat_text(data))
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes-relay-compat",
|
||||
description="Manage the optional legacy Hermes-Relay compatibility hook.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="compat_cmd", required=True)
|
||||
|
||||
status = sub.add_parser("status", help="Show compat hook status")
|
||||
status.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
status.add_argument("--site-packages", help="Inspect a specific site-packages directory")
|
||||
|
||||
install = sub.add_parser("install", help="Install the compat startup hook")
|
||||
install.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
install.add_argument("--site-packages", help="Target site-packages directory")
|
||||
install.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||
install.add_argument("--force", action="store_true", help="Replace an existing hook file")
|
||||
|
||||
remove = sub.add_parser("remove", help="Remove the compat startup hook")
|
||||
remove.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
remove.add_argument("--site-packages", help="Target site-packages directory")
|
||||
remove.add_argument("--all", action="store_true", help="Remove all discovered compat hook files")
|
||||
remove.add_argument("--dry-run", action="store_true", help="Show what would change")
|
||||
remove.add_argument("--force", action="store_true", help="Remove even if content is unexpected")
|
||||
|
||||
return compat_command(parser.parse_args(argv))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -72,6 +72,18 @@ const { useState, useEffect, ... } = SDK.hooks;
|
||||
so that esbuild's classic JSX transform (`--jsx=transform
|
||||
--jsx-factory=React.createElement`) resolves against the runtime React.
|
||||
|
||||
## Styling contract
|
||||
|
||||
The dashboard host injects this plugin's `dist/style.css` as a plain `<link>`.
|
||||
Keep every rule scoped under `.hermes-relay-plugin` and use the host's current
|
||||
theme tokens: `--color-card`, `--color-card-foreground`, `--color-foreground`,
|
||||
`--color-muted-foreground`, `--color-border`, `--color-ring`,
|
||||
`--color-destructive`, `--color-success`, `--color-warning`, `--radius-*`, and
|
||||
`--theme-font-*`. Avoid old unprefixed shadcn aliases such as `--card` or
|
||||
`--muted`; they are not part of the current dashboard plugin contract. Fixed
|
||||
white/black colors are acceptable only for QR canvases and image export surfaces
|
||||
where scan/read correctness requires them.
|
||||
|
||||
## Backend routes consumed
|
||||
|
||||
All proxied by `plugin_api.py` under `/api/plugins/hermes-relay/`:
|
||||
|
||||
Vendored
+47
-32
@@ -1,17 +1,30 @@
|
||||
.hermes-relay-plugin {
|
||||
--hr-bg: var(--background, #0b0b0c);
|
||||
--hr-surface: color-mix(in srgb, var(--card, #141416) 88%, transparent);
|
||||
--hr-surface-muted: color-mix(in srgb, var(--muted, #27272a) 55%, transparent);
|
||||
--hr-border: var(--border, rgba(255, 255, 255, 0.16));
|
||||
--hr-text: var(--foreground, #f4f4f5);
|
||||
--hr-muted: var(--muted-foreground, rgba(244, 244, 245, 0.68));
|
||||
--hr-danger: var(--destructive, #ef4444);
|
||||
--hr-bg: var(--color-card, color-mix(in srgb, var(--midground-base, #f4f4f5) 4%, var(--background-base, #0b0b0c)));
|
||||
--hr-surface: color-mix(in srgb, var(--color-card, var(--hr-bg)) 90%, transparent);
|
||||
--hr-surface-muted: color-mix(in srgb, var(--color-muted, var(--hr-bg)) 76%, transparent);
|
||||
--hr-border: var(--color-border, color-mix(in srgb, var(--midground-base, #f4f4f5) 16%, transparent));
|
||||
--hr-text: var(--color-foreground, var(--midground, #f4f4f5));
|
||||
--hr-card-text: var(--color-card-foreground, var(--hr-text));
|
||||
--hr-muted: var(--color-muted-foreground, color-mix(in srgb, var(--hr-text) 68%, transparent));
|
||||
--hr-danger: var(--color-destructive, #ef4444);
|
||||
--hr-success: var(--color-success, #22c55e);
|
||||
--hr-warning: var(--color-warning, #f59e0b);
|
||||
--hr-ring: var(--color-ring, var(--hr-text));
|
||||
--hr-popover: var(--color-popover, var(--hr-bg));
|
||||
--hr-popover-text: var(--color-popover-foreground, var(--hr-card-text));
|
||||
--hr-overlay: color-mix(in srgb, var(--background-base, #020617) 78%, transparent);
|
||||
color: var(--hr-text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
font-family: var(
|
||||
--theme-font-sans,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif
|
||||
);
|
||||
font-size: 1rem;
|
||||
line-height: var(--theme-line-height, 1.45);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -45,7 +58,7 @@
|
||||
.hermes-relay-plugin pre,
|
||||
.hermes-relay-plugin .font-mono {
|
||||
font-family:
|
||||
"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
var(--theme-font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin pre {
|
||||
@@ -67,7 +80,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -80,14 +93,15 @@
|
||||
.hermes-relay-plugin select {
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--hr-bg);
|
||||
color: var(--hr-card-text);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: #60a5fa;
|
||||
accent-color: var(--hr-ring);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin canvas {
|
||||
@@ -394,11 +408,11 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .rounded {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .rounded-md {
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .border,
|
||||
@@ -438,6 +452,7 @@
|
||||
|
||||
.hermes-relay-plugin .bg-background {
|
||||
background: var(--hr-bg);
|
||||
color: var(--hr-card-text);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-white {
|
||||
@@ -452,7 +467,7 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-background\/80 {
|
||||
background: rgba(8, 8, 10, 0.78);
|
||||
background: color-mix(in srgb, var(--hr-bg) 82%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-destructive\/10 {
|
||||
@@ -465,20 +480,20 @@
|
||||
|
||||
.hermes-relay-plugin .bg-amber-500\/10,
|
||||
.hermes-relay-plugin .bg-amber-500\/15 {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
background: color-mix(in srgb, var(--hr-warning) 16%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .border-amber-500\/50,
|
||||
.hermes-relay-plugin .border-amber-500\/60 {
|
||||
border-color: rgba(245, 158, 11, 0.58);
|
||||
border-color: color-mix(in srgb, var(--hr-warning) 58%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-emerald-500 {
|
||||
background: #10b981;
|
||||
background: var(--hr-success);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-amber-500 {
|
||||
background: #f59e0b;
|
||||
background: var(--hr-warning);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-destructive {
|
||||
@@ -505,7 +520,7 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .focus-visible\:ring-1:focus-visible {
|
||||
outline: 1px solid #60a5fa;
|
||||
outline: 1px solid var(--hr-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -585,7 +600,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(3, 7, 18, 0.72);
|
||||
background: var(--hr-overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -617,7 +632,7 @@
|
||||
width: 100vw !important;
|
||||
min-height: 100vh !important;
|
||||
padding: clamp(1rem, 3vw, 2rem) !important;
|
||||
background: rgba(2, 6, 16, 0.88) !important;
|
||||
background: var(--hr-overlay) !important;
|
||||
backdrop-filter: blur(6px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -626,10 +641,10 @@
|
||||
width: min(38rem, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 54rem);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 12, 16, 0.98) !important;
|
||||
color: var(--hr-text);
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: var(--hr-popover) !important;
|
||||
color: var(--hr-popover-text);
|
||||
box-shadow: 0 28px 96px rgba(0, 0, 0, 0.68);
|
||||
}
|
||||
|
||||
@@ -664,8 +679,8 @@
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: #fff !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"label": "Relay",
|
||||
"description": "Paired devices, bridge activity, media inspection, and remote access for hermes-relay",
|
||||
"icon": "Activity",
|
||||
"version": "0.7.0",
|
||||
"version": "1.1.0",
|
||||
"tab": {
|
||||
"path": "/relay",
|
||||
"position": "after:skills"
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "0.7.0",
|
||||
"version": "1.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "0.7.0",
|
||||
"version": "1.1.0",
|
||||
"devDependencies": {
|
||||
"esbuild": "^0.25.12",
|
||||
"qrcode": "^1.5.4"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hermes-relay-dashboard",
|
||||
"version": "0.7.0",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"description": "Hermes-Relay dashboard plugin frontend (IIFE bundle). Loaded verbatim by the hermes-agent dashboard via the Plugin SDK global.",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
.hermes-relay-plugin {
|
||||
--hr-bg: var(--background, #0b0b0c);
|
||||
--hr-surface: color-mix(in srgb, var(--card, #141416) 88%, transparent);
|
||||
--hr-surface-muted: color-mix(in srgb, var(--muted, #27272a) 55%, transparent);
|
||||
--hr-border: var(--border, rgba(255, 255, 255, 0.16));
|
||||
--hr-text: var(--foreground, #f4f4f5);
|
||||
--hr-muted: var(--muted-foreground, rgba(244, 244, 245, 0.68));
|
||||
--hr-danger: var(--destructive, #ef4444);
|
||||
--hr-bg: var(--color-card, color-mix(in srgb, var(--midground-base, #f4f4f5) 4%, var(--background-base, #0b0b0c)));
|
||||
--hr-surface: color-mix(in srgb, var(--color-card, var(--hr-bg)) 90%, transparent);
|
||||
--hr-surface-muted: color-mix(in srgb, var(--color-muted, var(--hr-bg)) 76%, transparent);
|
||||
--hr-border: var(--color-border, color-mix(in srgb, var(--midground-base, #f4f4f5) 16%, transparent));
|
||||
--hr-text: var(--color-foreground, var(--midground, #f4f4f5));
|
||||
--hr-card-text: var(--color-card-foreground, var(--hr-text));
|
||||
--hr-muted: var(--color-muted-foreground, color-mix(in srgb, var(--hr-text) 68%, transparent));
|
||||
--hr-danger: var(--color-destructive, #ef4444);
|
||||
--hr-success: var(--color-success, #22c55e);
|
||||
--hr-warning: var(--color-warning, #f59e0b);
|
||||
--hr-ring: var(--color-ring, var(--hr-text));
|
||||
--hr-popover: var(--color-popover, var(--hr-bg));
|
||||
--hr-popover-text: var(--color-popover-foreground, var(--hr-card-text));
|
||||
--hr-overlay: color-mix(in srgb, var(--background-base, #020617) 78%, transparent);
|
||||
color: var(--hr-text);
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI",
|
||||
sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
font-family: var(
|
||||
--theme-font-sans,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif
|
||||
);
|
||||
font-size: 1rem;
|
||||
line-height: var(--theme-line-height, 1.45);
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -45,7 +58,7 @@
|
||||
.hermes-relay-plugin pre,
|
||||
.hermes-relay-plugin .font-mono {
|
||||
font-family:
|
||||
"JetBrains Mono", "SFMono-Regular", Consolas, "Liberation Mono", monospace;
|
||||
var(--theme-font-mono, "SFMono-Regular", Consolas, "Liberation Mono", monospace);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin pre {
|
||||
@@ -67,7 +80,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -80,14 +93,15 @@
|
||||
.hermes-relay-plugin select {
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
background: var(--hr-bg);
|
||||
color: var(--hr-card-text);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
accent-color: #60a5fa;
|
||||
accent-color: var(--hr-ring);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin canvas {
|
||||
@@ -394,11 +408,11 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .rounded {
|
||||
border-radius: 4px;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .rounded-md {
|
||||
border-radius: 6px;
|
||||
border-radius: var(--radius-md, 6px);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .border,
|
||||
@@ -438,6 +452,7 @@
|
||||
|
||||
.hermes-relay-plugin .bg-background {
|
||||
background: var(--hr-bg);
|
||||
color: var(--hr-card-text);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-white {
|
||||
@@ -452,7 +467,7 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-background\/80 {
|
||||
background: rgba(8, 8, 10, 0.78);
|
||||
background: color-mix(in srgb, var(--hr-bg) 82%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-destructive\/10 {
|
||||
@@ -465,20 +480,20 @@
|
||||
|
||||
.hermes-relay-plugin .bg-amber-500\/10,
|
||||
.hermes-relay-plugin .bg-amber-500\/15 {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
background: color-mix(in srgb, var(--hr-warning) 16%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .border-amber-500\/50,
|
||||
.hermes-relay-plugin .border-amber-500\/60 {
|
||||
border-color: rgba(245, 158, 11, 0.58);
|
||||
border-color: color-mix(in srgb, var(--hr-warning) 58%, transparent);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-emerald-500 {
|
||||
background: #10b981;
|
||||
background: var(--hr-success);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-amber-500 {
|
||||
background: #f59e0b;
|
||||
background: var(--hr-warning);
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .bg-destructive {
|
||||
@@ -505,7 +520,7 @@
|
||||
}
|
||||
|
||||
.hermes-relay-plugin .focus-visible\:ring-1:focus-visible {
|
||||
outline: 1px solid #60a5fa;
|
||||
outline: 1px solid var(--hr-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -585,7 +600,7 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(3, 7, 18, 0.72);
|
||||
background: var(--hr-overlay);
|
||||
backdrop-filter: blur(2px);
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -617,7 +632,7 @@
|
||||
width: 100vw !important;
|
||||
min-height: 100vh !important;
|
||||
padding: clamp(1rem, 3vw, 2rem) !important;
|
||||
background: rgba(2, 6, 16, 0.88) !important;
|
||||
background: var(--hr-overlay) !important;
|
||||
backdrop-filter: blur(6px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -626,10 +641,10 @@
|
||||
width: min(38rem, calc(100vw - 2rem));
|
||||
max-height: min(90vh, 54rem);
|
||||
overflow-y: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 8px;
|
||||
background: rgba(10, 12, 16, 0.98) !important;
|
||||
color: var(--hr-text);
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: var(--hr-popover) !important;
|
||||
color: var(--hr-popover-text);
|
||||
box-shadow: 0 28px 96px rgba(0, 0, 0, 0.68);
|
||||
}
|
||||
|
||||
@@ -664,8 +679,8 @@
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--hr-border);
|
||||
border-radius: var(--radius-lg, 8px);
|
||||
background: #fff !important;
|
||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""Hermes-Relay plugin diagnostics.
|
||||
|
||||
The doctor command is intentionally local and read-only. It gives operators and
|
||||
agents one stable surface for checking which parts of the Relay install are
|
||||
plugin-owned, which standard upstream Hermes routes are reachable, and whether
|
||||
the legacy bootstrap monkeypatch is still installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import site
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
from .compat import collect_compat_status
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
PLUGIN_NAME = "hermes-relay"
|
||||
DEFAULT_API_URL = "http://localhost:8642"
|
||||
DEFAULT_DASHBOARD_URL = "http://localhost:9119"
|
||||
DEFAULT_RELAY_PORT = 8767
|
||||
|
||||
Probe = Callable[..., dict[str, Any]]
|
||||
|
||||
|
||||
def _read_simple_manifest(path: Path) -> dict[str, Any]:
|
||||
"""Read the small manifest fields the doctor needs without requiring yaml."""
|
||||
data: dict[str, Any] = {}
|
||||
if not path.exists():
|
||||
return data
|
||||
try:
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
if not raw or raw.lstrip().startswith("#") or ":" not in raw:
|
||||
continue
|
||||
key, value = raw.split(":", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if key in {"name", "version", "description", "author"}:
|
||||
data[key] = value
|
||||
except OSError:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def _base_url(value: str | None, default: str) -> str:
|
||||
raw = (value or "").strip() or default
|
||||
return raw.rstrip("/")
|
||||
|
||||
|
||||
def _url(base: str, path: str) -> str:
|
||||
return f"{base.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def _default_api_url() -> str:
|
||||
return _base_url(
|
||||
os.environ.get("HERMES_API_URL")
|
||||
or os.environ.get("RELAY_WEBAPI_URL")
|
||||
or os.environ.get("WEBAPI_URL"),
|
||||
DEFAULT_API_URL,
|
||||
)
|
||||
|
||||
|
||||
def _default_dashboard_url() -> str:
|
||||
return _base_url(
|
||||
os.environ.get("HERMES_DASHBOARD_URL")
|
||||
or os.environ.get("HERMES_WEB_URL")
|
||||
or os.environ.get("DASHBOARD_URL"),
|
||||
DEFAULT_DASHBOARD_URL,
|
||||
)
|
||||
|
||||
|
||||
def _default_relay_port() -> int:
|
||||
raw = os.environ.get("RELAY_PORT", "").strip()
|
||||
if not raw:
|
||||
return DEFAULT_RELAY_PORT
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return DEFAULT_RELAY_PORT
|
||||
|
||||
|
||||
def _route_exists_status(status: int | None) -> bool:
|
||||
"""Treat auth and method errors as evidence that a route exists."""
|
||||
if status is None:
|
||||
return False
|
||||
return status != 404
|
||||
|
||||
|
||||
def http_probe(
|
||||
url: str,
|
||||
*,
|
||||
method: str = "GET",
|
||||
timeout: float = 2.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a URL without sending credentials or request bodies."""
|
||||
req = urllib.request.Request(url, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read(65536).decode("utf-8", errors="replace")
|
||||
parsed: Any = None
|
||||
try:
|
||||
parsed = json.loads(body) if body else None
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
status = int(resp.status)
|
||||
return {
|
||||
"ok": 200 <= status < 400,
|
||||
"exists": _route_exists_status(status),
|
||||
"status": status,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"json": parsed if isinstance(parsed, dict) else None,
|
||||
}
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = int(exc.code)
|
||||
return {
|
||||
"ok": False,
|
||||
"exists": _route_exists_status(status),
|
||||
"status": status,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"error": str(exc.reason),
|
||||
}
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"exists": False,
|
||||
"status": None,
|
||||
"method": method,
|
||||
"url": url,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _site_dirs(site_dirs: Iterable[Path] | None = None) -> list[Path]:
|
||||
if site_dirs is not None:
|
||||
return [Path(p) for p in site_dirs]
|
||||
|
||||
candidates: list[Path] = []
|
||||
try:
|
||||
candidates.extend(Path(p) for p in site.getsitepackages())
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
candidates.append(Path(site.getusersitepackages()))
|
||||
except Exception:
|
||||
pass
|
||||
for entry in sys.path:
|
||||
p = Path(entry)
|
||||
if p.name == "site-packages":
|
||||
candidates.append(p)
|
||||
|
||||
seen: set[str] = set()
|
||||
unique: list[Path] = []
|
||||
for candidate in candidates:
|
||||
key = str(candidate)
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(candidate)
|
||||
return unique
|
||||
|
||||
|
||||
def _bootstrap_status(site_dirs: Iterable[Path] | None = None) -> dict[str, Any]:
|
||||
status = collect_compat_status(site_dirs=site_dirs)
|
||||
package = status["package"]
|
||||
return {
|
||||
"package_importable": package["importable"],
|
||||
"package_path": package["path"],
|
||||
"pth_files": [item["path"] for item in status["pth_files"]],
|
||||
"installed": bool(status["installed"]),
|
||||
"mode": "legacy-compatibility",
|
||||
"recommendation": status["recommendation"],
|
||||
}
|
||||
|
||||
|
||||
def _plugin_manager_layout(plugin_dir: Path) -> dict[str, Any]:
|
||||
parent = plugin_dir.parent
|
||||
return {
|
||||
"plugin_dir": str(plugin_dir),
|
||||
"has_plugin_yaml": (plugin_dir / "plugin.yaml").exists(),
|
||||
"has_register_init": (plugin_dir / "__init__.py").exists(),
|
||||
"has_dashboard_manifest": (plugin_dir / "dashboard" / "manifest.json").exists(),
|
||||
"looks_like_user_plugin": parent.name == "plugins" and parent.parent.name == ".hermes",
|
||||
"recommended_install_identifier": "Codename-11/hermes-relay/plugin",
|
||||
}
|
||||
|
||||
|
||||
def _check(checks: list[dict[str, str]], check_id: str, status: str, summary: str) -> None:
|
||||
checks.append({"id": check_id, "status": status, "summary": summary})
|
||||
|
||||
|
||||
def collect_doctor_report(
|
||||
*,
|
||||
api_url: str | None = None,
|
||||
dashboard_url: str | None = None,
|
||||
relay_port: int | None = None,
|
||||
timeout: float = 2.0,
|
||||
probe: Probe = http_probe,
|
||||
site_dirs: Iterable[Path] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a stable, JSON-serializable diagnostic report."""
|
||||
manifest = _read_simple_manifest(PLUGIN_DIR / "plugin.yaml")
|
||||
api_base = _base_url(api_url, _default_api_url())
|
||||
dashboard_base = _base_url(dashboard_url, _default_dashboard_url())
|
||||
port = relay_port if relay_port is not None else _default_relay_port()
|
||||
|
||||
api_capabilities = probe(_url(api_base, "/v1/capabilities"), method="GET", timeout=timeout)
|
||||
dashboard_status = probe(_url(dashboard_base, "/api/status"), method="GET", timeout=timeout)
|
||||
dashboard_audio = probe(
|
||||
_url(dashboard_base, "/api/audio/transcribe"),
|
||||
method="HEAD",
|
||||
timeout=timeout,
|
||||
)
|
||||
dashboard_ws_ticket = probe(
|
||||
_url(dashboard_base, "/api/auth/ws-ticket"),
|
||||
method="POST",
|
||||
timeout=timeout,
|
||||
)
|
||||
relay_info = probe(
|
||||
f"http://127.0.0.1:{int(port)}/relay/info",
|
||||
method="GET",
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
layout = _plugin_manager_layout(PLUGIN_DIR)
|
||||
bootstrap = _bootstrap_status(site_dirs)
|
||||
checks: list[dict[str, str]] = []
|
||||
|
||||
_check(
|
||||
checks,
|
||||
"plugin-root",
|
||||
"ok" if layout["has_plugin_yaml"] and layout["has_register_init"] else "error",
|
||||
"plugin root has plugin.yaml and register-capable __init__.py",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"dashboard-plugin",
|
||||
"ok" if layout["has_dashboard_manifest"] else "warn",
|
||||
"dashboard manifest is present under the plugin root",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"api-capabilities",
|
||||
"ok" if api_capabilities.get("ok") else "warn",
|
||||
"standard API /v1/capabilities reachable"
|
||||
if api_capabilities.get("ok")
|
||||
else "standard API not reachable from this host",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"dashboard-status",
|
||||
"ok" if dashboard_status.get("ok") else "warn",
|
||||
"dashboard /api/status reachable"
|
||||
if dashboard_status.get("ok")
|
||||
else "dashboard not reachable from this host",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"dashboard-audio",
|
||||
"ok" if dashboard_audio.get("exists") else "warn",
|
||||
"dashboard audio route exists"
|
||||
if dashboard_audio.get("exists")
|
||||
else "dashboard audio route was not detected",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"dashboard-ws-ticket",
|
||||
"ok" if dashboard_ws_ticket.get("exists") else "warn",
|
||||
"dashboard WebSocket ticket route exists"
|
||||
if dashboard_ws_ticket.get("exists")
|
||||
else "dashboard WebSocket ticket route was not detected",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"relay-loopback",
|
||||
"ok" if relay_info.get("ok") else "warn",
|
||||
"relay loopback /relay/info reachable"
|
||||
if relay_info.get("ok")
|
||||
else "relay is not reachable on loopback",
|
||||
)
|
||||
_check(
|
||||
checks,
|
||||
"legacy-bootstrap",
|
||||
"warn" if bootstrap["installed"] else "ok",
|
||||
"legacy bootstrap monkeypatch is installed"
|
||||
if bootstrap["installed"]
|
||||
else "legacy bootstrap monkeypatch is not installed",
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"plugin": {
|
||||
"name": manifest.get("name", PLUGIN_NAME),
|
||||
"version": manifest.get("version", ""),
|
||||
"layout": layout,
|
||||
},
|
||||
"standard": {
|
||||
"api_url": api_base,
|
||||
"dashboard_url": dashboard_base,
|
||||
"api": {"capabilities": api_capabilities},
|
||||
"dashboard": {
|
||||
"status": dashboard_status,
|
||||
"audio_transcribe": dashboard_audio,
|
||||
"ws_ticket": dashboard_ws_ticket,
|
||||
},
|
||||
},
|
||||
"relay": {
|
||||
"port": int(port),
|
||||
"info": relay_info,
|
||||
},
|
||||
"bootstrap": bootstrap,
|
||||
"lifecycle": {
|
||||
"upstream_plugin_remove_cleans_external_artifacts": False,
|
||||
"external_artifacts": [
|
||||
"editable/root Python package installs",
|
||||
"systemd user service",
|
||||
"shell shims",
|
||||
"external skill path entries",
|
||||
],
|
||||
"compat_cleanup_surface": "hermes relay compat remove",
|
||||
"legacy_cleanup_surface": (
|
||||
"uninstall.sh for service/shim/root-package legacy installs; "
|
||||
"delegates compat hook removal to hermes relay compat when available"
|
||||
),
|
||||
},
|
||||
"checks": checks,
|
||||
}
|
||||
|
||||
|
||||
def render_doctor_text(report: dict[str, Any]) -> str:
|
||||
plugin = report.get("plugin", {})
|
||||
standard = report.get("standard", {})
|
||||
relay = report.get("relay", {})
|
||||
bootstrap = report.get("bootstrap", {})
|
||||
layout = plugin.get("layout", {})
|
||||
|
||||
lines = [
|
||||
"Hermes-Relay doctor",
|
||||
"",
|
||||
f"Plugin: {plugin.get('name', PLUGIN_NAME)} {plugin.get('version', '')}".rstrip(),
|
||||
f"Path: {layout.get('plugin_dir', '')}",
|
||||
f"Install identifier: {layout.get('recommended_install_identifier', '')}",
|
||||
"",
|
||||
"Standard Hermes:",
|
||||
f" API: {standard.get('api_url', '')}",
|
||||
f" Dashboard: {standard.get('dashboard_url', '')}",
|
||||
"",
|
||||
"Relay:",
|
||||
f" Loopback port: {relay.get('port', DEFAULT_RELAY_PORT)}",
|
||||
"",
|
||||
"Legacy bootstrap:",
|
||||
f" Installed: {'yes' if bootstrap.get('installed') else 'no'}",
|
||||
]
|
||||
|
||||
pth_files = bootstrap.get("pth_files") or []
|
||||
if pth_files:
|
||||
lines.append(" .pth files:")
|
||||
lines.extend(f" {p}" for p in pth_files)
|
||||
|
||||
lines.append("")
|
||||
lines.append("Checks:")
|
||||
for check in report.get("checks", []):
|
||||
status = str(check.get("status", "unknown")).upper()
|
||||
lines.append(f" [{status}] {check.get('id')}: {check.get('summary')}")
|
||||
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Note: standard chat, Manage, and dashboard voice should work against "
|
||||
"vanilla upstream Hermes. Relay and bootstrap surfaces are additive."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def doctor_command(args: argparse.Namespace) -> int:
|
||||
report = collect_doctor_report(
|
||||
api_url=getattr(args, "api_url", None),
|
||||
dashboard_url=getattr(args, "dashboard_url", None),
|
||||
relay_port=getattr(args, "relay_port", None),
|
||||
timeout=float(getattr(args, "timeout", 2.0)),
|
||||
)
|
||||
|
||||
if getattr(args, "json", False):
|
||||
print(json.dumps(report, indent=2, sort_keys=True))
|
||||
else:
|
||||
print(render_doctor_text(report))
|
||||
|
||||
strict = bool(getattr(args, "strict", False))
|
||||
if strict and any(c.get("status") in {"warn", "error"} for c in report["checks"]):
|
||||
return 1
|
||||
if any(c.get("status") == "error" for c in report["checks"]):
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hermes relay doctor",
|
||||
description="Inspect Hermes-Relay plugin, standard Hermes, relay, and legacy bootstrap state.",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Emit JSON")
|
||||
parser.add_argument("--strict", action="store_true", help="Exit non-zero on warnings")
|
||||
parser.add_argument("--api-url", help="Hermes API base URL")
|
||||
parser.add_argument("--dashboard-url", help="Hermes dashboard base URL")
|
||||
parser.add_argument("--relay-port", type=int, help="Relay loopback port")
|
||||
parser.add_argument("--timeout", type=float, default=2.0, help="Probe timeout in seconds")
|
||||
return doctor_command(parser.parse_args(argv))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,45 @@
|
||||
"""hermes_relay_bootstrap — runtime patch for vanilla upstream hermes-agent.
|
||||
|
||||
This package is loaded at Python interpreter startup via a `.pth` file in the
|
||||
hermes-agent venv's site-packages. It installs a `sys.meta_path` import hook
|
||||
that waits for `aiohttp.web` to be imported, then replaces `web.Application`
|
||||
with a thin subclass that detects when hermes-agent's `APIServerAdapter`
|
||||
attaches itself to a fresh app and:
|
||||
|
||||
1. **Injects missing compatibility routes** — older hermes-agent builds may
|
||||
lack `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, and
|
||||
`/api/available-models`. Current upstream already has the session API and
|
||||
read-only `/v1/skills` + `/v1/toolsets`, so native routes win per method/path
|
||||
and the bootstrap only fills gaps.
|
||||
|
||||
2. **Installs slash-command middleware** — an aiohttp middleware that intercepts
|
||||
`/v1/chat/completions` and `/v1/runs` to handle gateway slash commands
|
||||
(`/help`, `/commands`, `/profile`, `/provider`) and return decline notices
|
||||
for stateful commands (`/model`, `/new`, `/retry`, etc.), preventing the
|
||||
LLM from hallucinating responses for them. This mirrors the upstream
|
||||
Stage 1 preprocessor from `gateway/platforms/api_server_slash.py`.
|
||||
|
||||
Chat streaming prefers upstream's native
|
||||
`/api/sessions/{session_id}/chat/stream` endpoint when it is advertised. Older
|
||||
builds that only get bootstrap-provided session CRUD fall back to standard
|
||||
`/v1/chat/completions` or `/v1/runs` paths.
|
||||
|
||||
This module retires per surface, not as one broad PR cleanup. Sessions can go
|
||||
once the supported hermes-agent baseline includes PR #33134, read-only skills
|
||||
should use PR #33016's `/v1/skills`, and the remaining config/memory/legacy
|
||||
skill/available-model/slash-command surfaces need stable replacements or local
|
||||
UX removal before the package and `.pth` hook can disappear.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Import and install the meta_path finder. Kept in a sub-module so this file
|
||||
# stays tiny and the actual patch logic is easy to audit / disable.
|
||||
from . import _patch # noqa: E402
|
||||
|
||||
_patch.install_finder()
|
||||
@@ -0,0 +1,686 @@
|
||||
"""Slash-command middleware for the bootstrap-patched API server.
|
||||
|
||||
Mirrors the upstream Stage 1 slash-command preprocessor from
|
||||
``gateway/platforms/api_server_slash.py`` (commit ``73de7736`` in the
|
||||
upstream PR prep tree). Installed as an aiohttp middleware on vanilla
|
||||
upstream hermes-agent installs so that ``/help``, ``/commands``,
|
||||
``/profile``, ``/provider``, and stateful-decline notices work on
|
||||
``/v1/chat/completions`` and ``/v1/runs`` without waiting for the
|
||||
upstream PR to land.
|
||||
|
||||
The middleware is installed from ``_patch._maybe_register_routes()``
|
||||
alongside the route injection, using the same ``__setitem__`` timing
|
||||
window where the app is still mutable. It feature-detects at install
|
||||
time: if ``gateway.platforms.api_server_slash`` already exists (meaning
|
||||
the upstream PR landed or the fork is deployed), the middleware is
|
||||
skipped entirely.
|
||||
|
||||
Design principles:
|
||||
- **Zero-cost fast path**: requests that don't target the two chat
|
||||
endpoints skip the body parse entirely.
|
||||
- **Auth first**: ``adapter._check_auth(request)`` runs before any
|
||||
command logic, matching upstream's order.
|
||||
- **Fail-open**: any exception in the middleware falls through to the
|
||||
original handler via ``return await handler(request)``. A middleware
|
||||
bug must never break normal chat.
|
||||
- **Body re-reading**: ``await request.json()`` caches internally in
|
||||
aiohttp, so the handler can call it again after a fall-through.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INTERCEPTED_PATHS = frozenset({"/v1/chat/completions", "/v1/runs"})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SlashCommandResult (mirrors upstream api_server_slash.SlashCommandResult)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlashCommandResult:
|
||||
"""Result of a handled gateway slash command."""
|
||||
|
||||
text: str
|
||||
command: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stateless command handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
# Each returns the textual body of a synthetic reply. They import their
|
||||
# dependencies lazily so the middleware stays cheap on code paths that never
|
||||
# trigger a command. Each is wrapped in try/except at the call site so a
|
||||
# missing upstream symbol degrades to a simple fallback.
|
||||
|
||||
|
||||
def _handle_help(_args: str) -> str:
|
||||
"""Return the canonical gateway help listing."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
lines = ["**Hermes Commands**", ""]
|
||||
lines.extend(gateway_help_lines())
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Note: commands that mutate session state (for example `/model`, "
|
||||
"`/new`, `/retry`, `/yolo`) are not available on the stateless "
|
||||
"`/v1/runs` and `/v1/chat/completions` endpoints. Use a channel "
|
||||
"with persistent session state (CLI, Discord, Telegram, Slack) "
|
||||
"for those."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _handle_commands(args: str) -> str:
|
||||
"""Return the paginated ``/commands`` listing."""
|
||||
from hermes_cli.commands import gateway_help_lines
|
||||
|
||||
entries = list(gateway_help_lines())
|
||||
if not entries:
|
||||
return "No commands available."
|
||||
|
||||
raw = (args or "").strip()
|
||||
if raw:
|
||||
try:
|
||||
requested_page = int(raw)
|
||||
except ValueError:
|
||||
return "Usage: `/commands [page]`"
|
||||
else:
|
||||
requested_page = 1
|
||||
|
||||
page_size = 20
|
||||
total_pages = max(1, (len(entries) + page_size - 1) // page_size)
|
||||
page = max(1, min(requested_page, total_pages))
|
||||
start = (page - 1) * page_size
|
||||
page_entries = entries[start:start + page_size]
|
||||
|
||||
lines = [
|
||||
f"**Commands** ({len(entries)} total, page {page}/{total_pages})",
|
||||
"",
|
||||
*page_entries,
|
||||
]
|
||||
if total_pages > 1:
|
||||
nav_parts = []
|
||||
if page > 1:
|
||||
nav_parts.append(f"`/commands {page - 1}` \u2190 prev")
|
||||
if page < total_pages:
|
||||
nav_parts.append(f"next \u2192 `/commands {page + 1}`")
|
||||
lines.extend(["", " | ".join(nav_parts)])
|
||||
if page != requested_page:
|
||||
lines.append(
|
||||
f"_(Requested page {requested_page} was out of range, "
|
||||
f"showing page {page}.)_"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _handle_profile(_args: str) -> str:
|
||||
"""Return the active profile name and home directory."""
|
||||
from pathlib import Path
|
||||
|
||||
from hermes_constants import display_hermes_home, get_hermes_home
|
||||
|
||||
home = get_hermes_home()
|
||||
display = display_hermes_home()
|
||||
|
||||
profiles_parent = Path.home() / ".hermes" / "profiles"
|
||||
try:
|
||||
rel = home.relative_to(profiles_parent)
|
||||
profile_name = str(rel).split("/")[0]
|
||||
except ValueError:
|
||||
profile_name = None
|
||||
|
||||
if profile_name:
|
||||
return f"**Profile:** `{profile_name}`\n**Home:** `{display}`"
|
||||
return f"**Profile:** default\n**Home:** `{display}`"
|
||||
|
||||
|
||||
def _handle_provider(_args: str) -> str:
|
||||
"""Return the current provider plus the list of available providers."""
|
||||
import yaml
|
||||
|
||||
from hermes_cli.models import (
|
||||
_PROVIDER_LABELS,
|
||||
list_available_providers,
|
||||
normalize_provider,
|
||||
)
|
||||
|
||||
# Resolve current provider from config
|
||||
current_provider = "openrouter"
|
||||
model_cfg: dict = {}
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
config_path = get_hermes_home() / "config.yaml"
|
||||
if config_path.exists():
|
||||
with open(config_path, encoding="utf-8") as fh:
|
||||
cfg = yaml.safe_load(fh) or {}
|
||||
raw_model_cfg = cfg.get("model", {})
|
||||
if isinstance(raw_model_cfg, dict):
|
||||
model_cfg = raw_model_cfg
|
||||
current_provider = model_cfg.get("provider", current_provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
current_provider = normalize_provider(current_provider)
|
||||
if current_provider == "auto":
|
||||
try:
|
||||
from hermes_cli.auth import resolve_provider as _resolve_provider
|
||||
|
||||
current_provider = _resolve_provider(current_provider)
|
||||
except Exception:
|
||||
current_provider = "openrouter"
|
||||
|
||||
# Detect custom endpoint from config base_url
|
||||
if current_provider == "openrouter":
|
||||
cfg_base = (
|
||||
model_cfg.get("base_url", "") if isinstance(model_cfg, dict) else ""
|
||||
)
|
||||
if cfg_base and "openrouter.ai" not in cfg_base:
|
||||
current_provider = "custom"
|
||||
|
||||
current_label = _PROVIDER_LABELS.get(current_provider, current_provider)
|
||||
|
||||
lines = [
|
||||
f"**Current provider:** {current_label} (`{current_provider}`)",
|
||||
"",
|
||||
"**Available providers:**",
|
||||
]
|
||||
try:
|
||||
providers = list_available_providers()
|
||||
except Exception:
|
||||
providers = []
|
||||
for provider in providers:
|
||||
marker = " \u2190 active" if provider["id"] == current_provider else ""
|
||||
auth = (
|
||||
"authenticated" if provider["authenticated"] else "not authenticated"
|
||||
)
|
||||
aliases = (
|
||||
f" _(also: {', '.join(provider['aliases'])})_"
|
||||
if provider["aliases"]
|
||||
else ""
|
||||
)
|
||||
lines.append(
|
||||
f"- `{provider['id']}` \u2014 {provider['label']} "
|
||||
f"({auth}){aliases}{marker}"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Note: provider switching via `/model` is not available on "
|
||||
"the stateless `/v1/runs` and `/v1/chat/completions` endpoints."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_STATELESS_HANDLERS: Dict[str, Callable[[str], str]] = {
|
||||
"help": _handle_help,
|
||||
"commands": _handle_commands,
|
||||
"profile": _handle_profile,
|
||||
"provider": _handle_provider,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stateful-command decline notice
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stateful_decline_notice(user_token: str, canonical_name: str) -> str:
|
||||
"""Build the decline notice for a stateful command."""
|
||||
displayed = user_token or canonical_name
|
||||
return (
|
||||
f"The `/{displayed}` command requires a persistent session and "
|
||||
f"isn't available on the stateless `/v1/runs` and "
|
||||
f"`/v1/chat/completions` endpoints. Use a channel with session "
|
||||
f"state (CLI, Discord, Telegram, Slack) for commands that mutate "
|
||||
f"per-session configuration.\n\n"
|
||||
f"For the commands that *are* supported here, type `/help`."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Core command resolution (mirrors api_server_slash.maybe_handle_gateway_command)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _maybe_handle_command(user_message: str) -> Optional[SlashCommandResult]:
|
||||
"""Check *user_message* for a leading ``/`` and resolve against the
|
||||
command registry.
|
||||
|
||||
Returns a :class:`SlashCommandResult` if the message is a recognized
|
||||
gateway command. Returns ``None`` for non-slash text, unknown
|
||||
commands, and strictly CLI-only commands — the caller then falls
|
||||
through to the normal LLM handler.
|
||||
"""
|
||||
try:
|
||||
if not user_message:
|
||||
return None
|
||||
stripped = user_message.lstrip()
|
||||
if not stripped.startswith("/"):
|
||||
return None
|
||||
|
||||
parts = stripped.split(None, 1)
|
||||
first_token = parts[0][1:] # drop the leading slash
|
||||
args = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
if not first_token:
|
||||
return None
|
||||
|
||||
from hermes_cli.commands import resolve_command
|
||||
|
||||
cmd = resolve_command(first_token)
|
||||
if cmd is None:
|
||||
return None
|
||||
|
||||
# Strictly CLI-only commands are never reachable via the gateway.
|
||||
# Config-gated commands (cli_only=True + gateway_config_gate) are
|
||||
# still gateway-reachable, so let them through to the decline path.
|
||||
if cmd.cli_only and not cmd.gateway_config_gate:
|
||||
return None
|
||||
|
||||
handler = _STATELESS_HANDLERS.get(cmd.name)
|
||||
if handler is not None:
|
||||
try:
|
||||
return SlashCommandResult(text=handler(args), command=cmd.name)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: stateless handler for /%s "
|
||||
"raised %s — returning fallback",
|
||||
cmd.name,
|
||||
exc,
|
||||
)
|
||||
return SlashCommandResult(
|
||||
text=f"The `/{cmd.name}` command is available but "
|
||||
f"encountered an error: {exc}",
|
||||
command=cmd.name,
|
||||
)
|
||||
|
||||
return SlashCommandResult(
|
||||
text=_stateful_decline_notice(first_token, cmd.name),
|
||||
command=cmd.name,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: command preprocessor failed for "
|
||||
"%r — falling through to LLM path: %s",
|
||||
user_message[:80] if user_message else "",
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# User-message extraction helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_user_message_completions(body: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract the last user message from a ``/v1/chat/completions`` body."""
|
||||
messages = body.get("messages")
|
||||
if not isinstance(messages, list) or not messages:
|
||||
return None
|
||||
# Walk backwards to find the last user message.
|
||||
for msg in reversed(messages):
|
||||
if isinstance(msg, dict) and msg.get("role") == "user":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
def _extract_user_message_runs(body: Dict[str, Any]) -> Optional[str]:
|
||||
"""Extract the user message from a ``/v1/runs`` body."""
|
||||
raw_input = body.get("input")
|
||||
if isinstance(raw_input, str):
|
||||
return raw_input
|
||||
if isinstance(raw_input, list) and raw_input:
|
||||
last = raw_input[-1]
|
||||
if isinstance(last, dict):
|
||||
content = last.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic response builders
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _build_chat_completion_json(
|
||||
text: str,
|
||||
model: str,
|
||||
completion_id: str,
|
||||
created: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Build a non-streaming ``chat.completion`` response."""
|
||||
return {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": text,
|
||||
},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _write_sse_chat_completion(
|
||||
request: Any,
|
||||
*,
|
||||
text: str,
|
||||
model: str,
|
||||
completion_id: str,
|
||||
created: int,
|
||||
) -> Any:
|
||||
"""Write a synthetic SSE stream for ``/v1/chat/completions``.
|
||||
|
||||
Emits the same three-chunk shape as upstream's
|
||||
``_write_sse_slash_command``: role chunk, content chunk, finish
|
||||
chunk, ``[DONE]`` terminator.
|
||||
"""
|
||||
from aiohttp import web
|
||||
|
||||
sse_headers: dict[str, str] = {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
# CORS: StreamResponse headers must be set before prepare() because
|
||||
# they're sent with the initial 200 response. Web clients (Open WebUI
|
||||
# on a different port, etc.) need these.
|
||||
origin = request.headers.get("Origin", "")
|
||||
if origin:
|
||||
sse_headers["Access-Control-Allow-Origin"] = origin
|
||||
sse_headers["Access-Control-Allow-Credentials"] = "true"
|
||||
|
||||
response = web.StreamResponse(status=200, headers=sse_headers)
|
||||
await response.prepare(request)
|
||||
|
||||
try:
|
||||
role_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}
|
||||
],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(role_chunk)}\n\n".encode())
|
||||
|
||||
content_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{"index": 0, "delta": {"content": text}, "finish_reason": None}
|
||||
],
|
||||
}
|
||||
await response.write(f"data: {json.dumps(content_chunk)}\n\n".encode())
|
||||
|
||||
finish_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
},
|
||||
}
|
||||
await response.write(f"data: {json.dumps(finish_chunk)}\n\n".encode())
|
||||
await response.write(b"data: [DONE]\n\n")
|
||||
except (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, OSError):
|
||||
logger.info(
|
||||
"SSE client disconnected during slash-command reply %s",
|
||||
completion_id,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def _inject_run_events(
|
||||
adapter: Any,
|
||||
run_id: str,
|
||||
text: str,
|
||||
) -> bool:
|
||||
"""Inject ``message.delta`` + ``run.completed`` + sentinel into the
|
||||
adapter's run event queue.
|
||||
|
||||
Returns ``True`` if successful, ``False`` if the adapter's internals
|
||||
don't match what we expect (in which case the caller falls through to
|
||||
the original handler).
|
||||
"""
|
||||
try:
|
||||
q: asyncio.Queue[Optional[Dict]] = asyncio.Queue()
|
||||
ts = time.time()
|
||||
q.put_nowait({
|
||||
"event": "message.delta",
|
||||
"run_id": run_id,
|
||||
"timestamp": ts,
|
||||
"delta": text,
|
||||
})
|
||||
q.put_nowait({
|
||||
"event": "run.completed",
|
||||
"run_id": run_id,
|
||||
"timestamp": ts,
|
||||
"output": text,
|
||||
"usage": {
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
},
|
||||
})
|
||||
q.put_nowait(None) # sentinel
|
||||
|
||||
adapter._run_streams[run_id] = q
|
||||
adapter._run_streams_created[run_id] = ts
|
||||
return True
|
||||
except (AttributeError, TypeError) as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot inject run events into adapter "
|
||||
"internals — _run_streams access failed: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Middleware factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def make_command_middleware(adapter: Any) -> Any:
|
||||
"""Build the aiohttp middleware function for slash-command interception.
|
||||
|
||||
*adapter* is the ``APIServerAdapter`` instance, captured by the closure
|
||||
so we can call ``_check_auth()`` and inject run events without repeated
|
||||
``request.app`` lookups.
|
||||
"""
|
||||
from aiohttp import web
|
||||
|
||||
@web.middleware
|
||||
async def command_middleware(
|
||||
request: web.Request,
|
||||
handler: Callable,
|
||||
) -> web.StreamResponse:
|
||||
# Fast path: skip non-chat endpoints entirely.
|
||||
if request.path not in _INTERCEPTED_PATHS:
|
||||
return await handler(request)
|
||||
|
||||
# Only intercept POST.
|
||||
if request.method != "POST":
|
||||
return await handler(request)
|
||||
|
||||
try:
|
||||
# Auth check — must run before any command logic, matching
|
||||
# upstream's order.
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
# Parse the request body.
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
# Let the handler deal with malformed JSON.
|
||||
return await handler(request)
|
||||
|
||||
# Extract user message based on endpoint.
|
||||
if request.path == "/v1/chat/completions":
|
||||
user_message = _extract_user_message_completions(body)
|
||||
else:
|
||||
user_message = _extract_user_message_runs(body)
|
||||
|
||||
if not user_message:
|
||||
return await handler(request)
|
||||
|
||||
# Resolve against the command registry.
|
||||
result = _maybe_handle_command(user_message)
|
||||
if result is None:
|
||||
return await handler(request)
|
||||
|
||||
# --- Command matched: build synthetic response ---
|
||||
|
||||
if request.path == "/v1/chat/completions":
|
||||
model_name = body.get("model") or "hermes-agent"
|
||||
completion_id = f"chatcmpl-{uuid.uuid4().hex[:29]}"
|
||||
created = int(time.time())
|
||||
stream = body.get("stream", False)
|
||||
|
||||
logger.info(
|
||||
"[bootstrap] intercepted gateway command /%s on "
|
||||
"/v1/chat/completions (stream=%s)",
|
||||
result.command,
|
||||
bool(stream),
|
||||
)
|
||||
|
||||
if stream:
|
||||
return await _write_sse_chat_completion(
|
||||
request,
|
||||
text=result.text,
|
||||
model=model_name,
|
||||
completion_id=completion_id,
|
||||
created=created,
|
||||
)
|
||||
return web.json_response(
|
||||
_build_chat_completion_json(
|
||||
text=result.text,
|
||||
model=model_name,
|
||||
completion_id=completion_id,
|
||||
created=created,
|
||||
),
|
||||
)
|
||||
|
||||
else:
|
||||
# /v1/runs — inject events into the adapter's queue.
|
||||
run_id = f"run_{uuid.uuid4().hex}"
|
||||
|
||||
logger.info(
|
||||
"[bootstrap] intercepted gateway command /%s on /v1/runs",
|
||||
result.command,
|
||||
)
|
||||
|
||||
if _inject_run_events(adapter, run_id, result.text):
|
||||
return web.json_response(
|
||||
{"run_id": run_id, "status": "started"},
|
||||
status=202,
|
||||
)
|
||||
# Injection failed — fall through to the handler.
|
||||
return await handler(request)
|
||||
|
||||
except Exception as exc:
|
||||
# Fail-open: any unexpected error falls through to the
|
||||
# original handler. A middleware bug must never break chat.
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: command middleware failed for "
|
||||
"%s %s — falling through: %s",
|
||||
request.method,
|
||||
request.path,
|
||||
exc,
|
||||
)
|
||||
return await handler(request)
|
||||
|
||||
return command_middleware
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installation helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def maybe_install_middleware(app: Any, adapter: Any) -> bool:
|
||||
"""Install the command middleware on *app* if the upstream PR hasn't
|
||||
landed yet.
|
||||
|
||||
Returns ``True`` if the middleware was installed, ``False`` if it was
|
||||
skipped (because the upstream preprocessor is already present).
|
||||
"""
|
||||
# Feature-detect: if the upstream module exists, the native handler
|
||||
# already intercepts commands — no middleware needed.
|
||||
try:
|
||||
import gateway.platforms.api_server_slash # noqa: F401
|
||||
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: gateway.platforms.api_server_slash "
|
||||
"exists — upstream slash-command preprocessor is active; "
|
||||
"skipping middleware installation."
|
||||
)
|
||||
return False
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
middleware = make_command_middleware(adapter)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: failed to create command middleware: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
# Insert the middleware. aiohttp stores middlewares as a FrozenList on
|
||||
# app._middlewares; it's still mutable here because AppRunner.setup()
|
||||
# (which calls .freeze()) hasn't run yet. Append in place so the
|
||||
# container type is preserved for the later freeze() call — replacing
|
||||
# it with a tuple causes `'tuple' object has no attribute 'freeze'`.
|
||||
try:
|
||||
app._middlewares.append(middleware)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: failed to install command middleware "
|
||||
"on app._middlewares: %s",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: installed slash-command middleware for "
|
||||
"/v1/chat/completions and /v1/runs"
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,783 @@
|
||||
"""Compatibility handlers from the pre-upstream Hermes-Relay API branch.
|
||||
|
||||
The original broad branch was superseded upstream. Current Hermes main has
|
||||
native session controls via PR #33134 and read-only skills/toolsets via PR
|
||||
#33016; these handlers remain for older core builds and for compatibility-only
|
||||
surfaces that do not yet have stable API-server replacements.
|
||||
|
||||
This file mirrors the management endpoints from the fork branch, adapted to
|
||||
take the `APIServerAdapter` instance as an explicit parameter rather than
|
||||
relying on `self`. That keeps the patch loosely coupled to upstream's class
|
||||
shape — we don't bind methods onto the adapter, just register closures that
|
||||
capture an `adapter` reference.
|
||||
|
||||
Endpoints injected (all bearer-auth gated via `adapter._check_auth`):
|
||||
|
||||
GET /api/sessions — list sessions
|
||||
POST /api/sessions — create a new session
|
||||
GET /api/sessions/search?q=... — full-text message search
|
||||
GET /api/sessions/{session_id} — fetch one session
|
||||
GET /api/sessions/{session_id}/messages — fetch session messages
|
||||
PATCH /api/sessions/{session_id} — rename / update metadata
|
||||
DELETE /api/sessions/{session_id} — delete a session
|
||||
POST /api/sessions/{session_id}/fork — clone a session
|
||||
|
||||
GET /api/memory — read memory state
|
||||
POST /api/memory — append memory entry
|
||||
PATCH /api/memory — replace memory entry
|
||||
DELETE /api/memory — remove memory entry
|
||||
|
||||
GET /api/skills — list skills (optional ?category=)
|
||||
GET /api/skills/{name} — fetch skill body
|
||||
PUT /api/skills/toggle — STUB (501 Not Implemented).
|
||||
Registered so the Android client's
|
||||
capability probe observes the route
|
||||
and renders the UI as disabled
|
||||
instead of missing. See
|
||||
``toggle_skill`` docstring for the
|
||||
upstream gap explanation.
|
||||
|
||||
GET /api/config — read model + config
|
||||
PATCH /api/config — update model/provider/base_url
|
||||
|
||||
GET /api/available-models — provider model list
|
||||
|
||||
NOT injected:
|
||||
|
||||
- `POST /api/sessions/{session_id}/chat/stream` — native upstream provides
|
||||
this in PR #33134. The bootstrap does not inject a chat-stream handler for
|
||||
older builds because that path requires coordinating with `_create_agent` /
|
||||
`run_conversation` — the fork's riskiest cross-cutting dependencies. Clients
|
||||
should fall back to `/v1/chat/completions` or `/v1/runs` when chat streaming
|
||||
is not advertised.
|
||||
|
||||
- `GET /api/skills/categories` — removed from upstream as dead code in commit
|
||||
8d023e43 ("refactor: remove dead code — 1,784 lines across 77 files"). The
|
||||
app does not call this endpoint; skill browsing uses `/api/skills?category=`.
|
||||
Re-injecting it would require importing a symbol that no longer exists.
|
||||
|
||||
Removal note: upstream is moving toward focused native surfaces rather than one
|
||||
large frontend API patch. As each method/path lands in hermes-agent, route
|
||||
registration below skips that native route and keeps only the missing
|
||||
compatibility gaps. Cleanup should therefore happen per surface: sessions can
|
||||
retire once the supported core baseline includes PR #33134, read-only skill
|
||||
lists should use `/v1/skills` from PR #33016, while config/memory/legacy skill
|
||||
detail/toggle/available-models remain until core exposes stable equivalents or
|
||||
Hermes-Relay stops depending on them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Lazy upstream imports
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# These get pulled in only when `register_routes()` runs (i.e. only when the
|
||||
# gateway is actually starting up an APIServerAdapter against vanilla
|
||||
# upstream). The bootstrap's `__init__.py` deliberately avoids importing
|
||||
# anything from hermes-agent so it stays cheap for unrelated Python processes
|
||||
# in the same venv.
|
||||
|
||||
def _resolve_upstream():
|
||||
"""Pull in upstream symbols. Returns a dict or raises if anything is missing."""
|
||||
from aiohttp import web
|
||||
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli.config import load_config, save_config
|
||||
from hermes_cli.models import (
|
||||
curated_models_for_provider,
|
||||
list_available_providers,
|
||||
)
|
||||
from tools.skills_tool import skill_view, skills_list
|
||||
|
||||
# MemoryStore lives at tools/memory_tool.py upstream. We import it lazily
|
||||
# because it pulls in a chain of optional deps that we don't want to crash
|
||||
# the bootstrap over if memory tooling is misconfigured.
|
||||
try:
|
||||
from tools.memory_tool import MemoryStore
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("hermes_relay_bootstrap: MemoryStore unavailable: %s", exc)
|
||||
MemoryStore = None # type: ignore[assignment]
|
||||
|
||||
return {
|
||||
"web": web,
|
||||
"SessionDB": SessionDB,
|
||||
"MemoryStore": MemoryStore,
|
||||
"load_config": load_config,
|
||||
"save_config": save_config,
|
||||
"curated_models_for_provider": curated_models_for_provider,
|
||||
"list_available_providers": list_available_providers,
|
||||
"skills_list": skills_list,
|
||||
"skill_view": skill_view,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-adapter state cache
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# We can't add attributes directly to upstream's `APIServerAdapter` instance
|
||||
# without risking name collisions on future refactors. Instead we keep a
|
||||
# small WeakKeyDictionary keyed on the adapter, holding our SessionDB and
|
||||
# MemoryStore references. The lifetime of the cache entries matches the
|
||||
# adapter's lifetime — when the adapter is garbage-collected, the cache
|
||||
# entries follow.
|
||||
|
||||
import weakref
|
||||
|
||||
_adapter_state: "weakref.WeakKeyDictionary[Any, Dict[str, Any]]" = weakref.WeakKeyDictionary()
|
||||
|
||||
|
||||
def _state_for(adapter) -> Dict[str, Any]:
|
||||
state = _adapter_state.get(adapter)
|
||||
if state is None:
|
||||
state = {}
|
||||
_adapter_state[adapter] = state
|
||||
return state
|
||||
|
||||
|
||||
def _get_session_db(adapter, upstream):
|
||||
state = _state_for(adapter)
|
||||
db = state.get("session_db")
|
||||
if db is None:
|
||||
db = upstream["SessionDB"]()
|
||||
state["session_db"] = db
|
||||
return db
|
||||
|
||||
|
||||
def _get_memory_store(adapter, upstream):
|
||||
if upstream["MemoryStore"] is None:
|
||||
return None
|
||||
state = _state_for(adapter)
|
||||
store = state.get("memory_store")
|
||||
if store is None:
|
||||
store = upstream["MemoryStore"]()
|
||||
store.load_from_disk()
|
||||
state["memory_store"] = store
|
||||
return store
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure helpers (no adapter coupling)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_session_record(session: Optional[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
||||
"""Parse serialized session fields into API-friendly JSON."""
|
||||
if session is None:
|
||||
return None
|
||||
normalized = dict(session)
|
||||
model_config = normalized.get("model_config")
|
||||
if model_config:
|
||||
try:
|
||||
normalized["model_config"] = json.loads(model_config)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
pass
|
||||
return normalized
|
||||
|
||||
|
||||
def _current_model_settings(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Extract model/provider/base_url/api_mode from config.yaml."""
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
return {
|
||||
"model": str(model_cfg.get("default") or model_cfg.get("model") or "").strip(),
|
||||
"provider": str(model_cfg.get("provider") or "").strip(),
|
||||
"api_mode": str(model_cfg.get("api_mode") or "").strip(),
|
||||
"base_url": str(model_cfg.get("base_url") or "").strip(),
|
||||
}
|
||||
if isinstance(model_cfg, str):
|
||||
return {
|
||||
"model": model_cfg.strip(),
|
||||
"provider": "",
|
||||
"api_mode": "",
|
||||
"base_url": "",
|
||||
}
|
||||
return {"model": "", "provider": "", "api_mode": "", "base_url": ""}
|
||||
|
||||
|
||||
def _parse_int(value: Any, default: int, minimum: int = 0) -> int:
|
||||
"""Parse an integer query parameter with bounds."""
|
||||
if value in (None, ""):
|
||||
return default
|
||||
parsed = int(value)
|
||||
if parsed < minimum:
|
||||
raise ValueError(f"Value must be >= {minimum}")
|
||||
return parsed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sessions handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_sessions_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
|
||||
async def list_sessions(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
limit = _parse_int(request.query.get("limit"), 50)
|
||||
offset = _parse_int(request.query.get("offset"), 0)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
source = (request.query.get("source") or "").strip() or None
|
||||
db = _get_session_db(adapter, upstream)
|
||||
items = [
|
||||
_normalize_session_record(item)
|
||||
for item in db.list_sessions_rich(source=source, limit=limit, offset=offset)
|
||||
]
|
||||
total = db.session_count(source=source)
|
||||
return web.json_response({"items": items, "total": total})
|
||||
|
||||
async def create_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
title = body.get("title")
|
||||
source = str(body.get("source") or "api_server").strip() or "api_server"
|
||||
model = body.get("model")
|
||||
system_prompt = body.get("system_prompt")
|
||||
session_id = f"sess_{uuid.uuid4().hex}"
|
||||
db = _get_session_db(adapter, upstream)
|
||||
|
||||
try:
|
||||
db.create_session(
|
||||
session_id=session_id,
|
||||
source=source,
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
if title is not None:
|
||||
db.set_session_title(session_id, str(title))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def search_sessions(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
query = (request.query.get("q") or "").strip()
|
||||
if not query:
|
||||
return web.json_response({"error": "Missing query parameter: q"}, status=400)
|
||||
try:
|
||||
limit = _parse_int(request.query.get("limit"), 20)
|
||||
offset = _parse_int(request.query.get("offset"), 0)
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
|
||||
db = _get_session_db(adapter, upstream)
|
||||
results = db.search_messages(query=query, limit=limit, offset=offset)
|
||||
return web.json_response({"query": query, "count": len(results), "results": results})
|
||||
|
||||
async def get_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
if session is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def get_session_messages(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
if db.get_session(session_id) is None:
|
||||
db.ensure_session(session_id, source="web")
|
||||
items = db.get_messages(session_id)
|
||||
return web.json_response({"items": items, "total": len(items)})
|
||||
|
||||
async def update_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
if db.get_session(session_id) is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
try:
|
||||
if "title" in body:
|
||||
db.set_session_title(session_id, body.get("title"))
|
||||
if "system_prompt" in body:
|
||||
db.update_system_prompt(session_id, body.get("system_prompt"))
|
||||
if "end_reason" in body:
|
||||
db.end_session(session_id, str(body.get("end_reason") or "updated"))
|
||||
except ValueError as exc:
|
||||
return web.json_response({"error": str(exc)}, status=400)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(session_id))
|
||||
return web.json_response({"session": session})
|
||||
|
||||
async def delete_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
deleted = db.delete_session(session_id)
|
||||
if not deleted:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
return web.json_response({"ok": True})
|
||||
|
||||
async def fork_session(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
session_id = request.match_info["session_id"]
|
||||
db = _get_session_db(adapter, upstream)
|
||||
original = db.get_session(session_id)
|
||||
if original is None:
|
||||
return web.json_response({"error": "Session not found"}, status=404)
|
||||
|
||||
forked_id = f"sess_{uuid.uuid4().hex}"
|
||||
try:
|
||||
db.create_session(
|
||||
session_id=forked_id,
|
||||
source=original.get("source") or "api_server",
|
||||
model=original.get("model"),
|
||||
system_prompt=original.get("system_prompt"),
|
||||
user_id=original.get("user_id"),
|
||||
parent_session_id=session_id,
|
||||
)
|
||||
for message in db.get_messages(session_id):
|
||||
db.append_message(
|
||||
session_id=forked_id,
|
||||
role=message.get("role"),
|
||||
content=message.get("content"),
|
||||
tool_name=message.get("tool_name"),
|
||||
tool_calls=message.get("tool_calls"),
|
||||
tool_call_id=message.get("tool_call_id"),
|
||||
token_count=message.get("token_count"),
|
||||
finish_reason=message.get("finish_reason"),
|
||||
reasoning=message.get("reasoning"),
|
||||
)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
session = _normalize_session_record(db.get_session(forked_id))
|
||||
return web.json_response({"session": session, "forked_from": session_id})
|
||||
|
||||
return {
|
||||
"list_sessions": list_sessions,
|
||||
"create_session": create_session,
|
||||
"search_sessions": search_sessions,
|
||||
"get_session": get_session,
|
||||
"get_session_messages": get_session_messages,
|
||||
"update_session": update_session,
|
||||
"delete_session": delete_session,
|
||||
"fork_session": fork_session,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Memory handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_memory_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
|
||||
def _memory_unavailable_response():
|
||||
return web.json_response(
|
||||
{"error": "MemoryStore unavailable in this hermes-agent install"},
|
||||
status=503,
|
||||
)
|
||||
|
||||
async def get_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
target = (request.query.get("target") or "all").strip().lower()
|
||||
if target not in {"all", "memory", "user"}:
|
||||
return web.json_response(
|
||||
{"error": "target must be one of: all, memory, user"},
|
||||
status=400,
|
||||
)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
store.load_from_disk()
|
||||
targets = []
|
||||
if target in {"all", "memory"}:
|
||||
targets.append({
|
||||
"target": "memory",
|
||||
"entries": store.memory_entries,
|
||||
"entry_count": len(store.memory_entries),
|
||||
})
|
||||
if target in {"all", "user"}:
|
||||
targets.append({
|
||||
"target": "user",
|
||||
"entries": store.user_entries,
|
||||
"entry_count": len(store.user_entries),
|
||||
})
|
||||
return web.json_response({"targets": targets})
|
||||
|
||||
async def add_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
content = str(body.get("content") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.add(target, content)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
async def replace_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
old_text = str(body.get("old_text") or "")
|
||||
content = str(body.get("content") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.replace(target, old_text, content)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
async def delete_memory(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
target = str(body.get("target") or "").strip().lower()
|
||||
old_text = str(body.get("old_text") or "")
|
||||
if target not in {"memory", "user"}:
|
||||
return web.json_response({"error": "target must be 'memory' or 'user'"}, status=400)
|
||||
store = _get_memory_store(adapter, upstream)
|
||||
if store is None:
|
||||
return _memory_unavailable_response()
|
||||
result = store.remove(target, old_text)
|
||||
status = 200 if result.get("success") else 400
|
||||
return web.json_response(result, status=status)
|
||||
|
||||
return {
|
||||
"get_memory": get_memory,
|
||||
"add_memory": add_memory,
|
||||
"replace_memory": replace_memory,
|
||||
"delete_memory": delete_memory,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skills handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_skills_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
skills_list = upstream["skills_list"]
|
||||
skill_view = upstream["skill_view"]
|
||||
|
||||
async def list_skills(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
category = (request.query.get("category") or "").strip() or None
|
||||
return web.json_response(json.loads(skills_list(category=category)))
|
||||
|
||||
async def view_skill(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
name = request.match_info["name"]
|
||||
file_path = (request.query.get("file_path") or "").strip() or None
|
||||
return web.json_response(json.loads(skill_view(name, file_path=file_path)))
|
||||
|
||||
async def toggle_skill(request):
|
||||
"""PUT /api/skills/toggle — stubbed 501.
|
||||
|
||||
Body: ``{"skill": "<name>", "enabled": true|false}``
|
||||
Response (always 501):
|
||||
``{"error": "skill_toggle_not_implemented",
|
||||
"detail": "Skill enable/disable requires upstream
|
||||
hermes_cli.web_server API — proxy not yet available"}``
|
||||
|
||||
Rationale: ``tools.skills_tool`` (the symbol we already import for
|
||||
``list_skills``/``view_skill``) has no clean enable/disable hook.
|
||||
Upstream's dashboard implements the toggle in
|
||||
``hermes_cli.web_server`` — a separate, loopback-only web server —
|
||||
which we don't proxy through the relay today. Surfacing the
|
||||
endpoint with a clear 501 keeps the Kotlin client's capability
|
||||
probe honest: it can observe the route exists, parse the
|
||||
structured error, and hide the toggle UI until a real backend
|
||||
lands. Returning 404 would make the probe confuse "server
|
||||
doesn't know about toggles" (upstream gap) with "wrong URL"
|
||||
(client bug).
|
||||
"""
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
# We still accept + ignore the body so misconfigured clients get
|
||||
# a stable shape — not a JSON-decode error on top of the 501.
|
||||
try:
|
||||
await request.json()
|
||||
except Exception:
|
||||
pass
|
||||
return web.json_response(
|
||||
{
|
||||
"error": "skill_toggle_not_implemented",
|
||||
"detail": (
|
||||
"Skill enable/disable requires upstream "
|
||||
"hermes_cli.web_server API — proxy not yet available"
|
||||
),
|
||||
},
|
||||
status=501,
|
||||
)
|
||||
|
||||
return {
|
||||
"list_skills": list_skills,
|
||||
"view_skill": view_skill,
|
||||
"toggle_skill": toggle_skill,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config + available-models handlers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_config_handlers(adapter, upstream):
|
||||
web = upstream["web"]
|
||||
load_config = upstream["load_config"]
|
||||
save_config = upstream["save_config"]
|
||||
curated_models_for_provider = upstream["curated_models_for_provider"]
|
||||
list_available_providers = upstream["list_available_providers"]
|
||||
|
||||
async def get_config(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
config = load_config()
|
||||
current = _current_model_settings(config)
|
||||
return web.json_response({
|
||||
"model": current["model"],
|
||||
"provider": current["provider"],
|
||||
"api_mode": current["api_mode"],
|
||||
"base_url": current["base_url"],
|
||||
"config": config,
|
||||
})
|
||||
|
||||
async def update_config(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
try:
|
||||
body = await request.json()
|
||||
except (json.JSONDecodeError, Exception):
|
||||
return web.json_response({"error": "Invalid JSON in request body"}, status=400)
|
||||
|
||||
config = load_config()
|
||||
model_cfg = config.get("model")
|
||||
if isinstance(model_cfg, dict):
|
||||
updated_model_cfg = dict(model_cfg)
|
||||
elif isinstance(model_cfg, str) and model_cfg.strip():
|
||||
updated_model_cfg = {"default": model_cfg.strip()}
|
||||
else:
|
||||
updated_model_cfg = {}
|
||||
|
||||
if "model" in body:
|
||||
updated_model_cfg["default"] = str(body.get("model") or "").strip()
|
||||
if "provider" in body:
|
||||
updated_model_cfg["provider"] = str(body.get("provider") or "").strip()
|
||||
if "base_url" in body:
|
||||
updated_model_cfg["base_url"] = str(body.get("base_url") or "").strip()
|
||||
|
||||
config["model"] = updated_model_cfg
|
||||
try:
|
||||
save_config(config)
|
||||
except Exception as exc:
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
current = _current_model_settings(config)
|
||||
return web.json_response({
|
||||
"ok": True,
|
||||
"model": current["model"],
|
||||
"provider": current["provider"],
|
||||
"base_url": current["base_url"],
|
||||
})
|
||||
|
||||
async def available_models(request):
|
||||
auth_err = adapter._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
config = load_config()
|
||||
current = _current_model_settings(config)
|
||||
provider = (request.query.get("provider") or current["provider"] or "openrouter").strip()
|
||||
models = [
|
||||
{"id": model_id, "description": description}
|
||||
for model_id, description in curated_models_for_provider(provider)
|
||||
]
|
||||
providers = list_available_providers()
|
||||
return web.json_response({"provider": provider, "models": models, "providers": providers})
|
||||
|
||||
return {
|
||||
"get_config": get_config,
|
||||
"update_config": update_config,
|
||||
"available_models": available_models,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _route_exists(app, method: str, path: str) -> bool:
|
||||
"""Return true when *method path* is already registered on the app."""
|
||||
wanted_method = method.upper()
|
||||
try:
|
||||
resources = list(app.router.resources())
|
||||
except Exception:
|
||||
# If introspection fails, report the route as present. That prevents
|
||||
# duplicate registration from crashing gateway startup.
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot inspect routes while checking "
|
||||
"%s %s; skipping that compatibility route.",
|
||||
wanted_method,
|
||||
path,
|
||||
)
|
||||
return True
|
||||
|
||||
for resource in resources:
|
||||
if getattr(resource, "canonical", None) != path:
|
||||
continue
|
||||
try:
|
||||
routes = list(resource)
|
||||
except TypeError:
|
||||
routes = []
|
||||
for route in routes:
|
||||
route_method = str(getattr(route, "method", "")).upper()
|
||||
if route_method in {wanted_method, "*"}:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _add_route_if_missing(app, method: str, path: str, handler) -> bool:
|
||||
"""Register a compatibility route only when upstream has not done so."""
|
||||
method = method.upper()
|
||||
if _route_exists(app, method, path):
|
||||
logger.debug(
|
||||
"hermes_relay_bootstrap: native route exists; skipping %s %s",
|
||||
method,
|
||||
path,
|
||||
)
|
||||
return False
|
||||
if method == "GET":
|
||||
# Preserve aiohttp's add_get default behavior: HEAD should work for
|
||||
# capability probes even when this route is bootstrap-provided.
|
||||
app.router.add_get(path, handler)
|
||||
elif method == "POST":
|
||||
app.router.add_post(path, handler)
|
||||
elif method == "PATCH":
|
||||
app.router.add_patch(path, handler)
|
||||
elif method == "DELETE":
|
||||
app.router.add_delete(path, handler)
|
||||
elif method == "PUT":
|
||||
app.router.add_put(path, handler)
|
||||
else:
|
||||
app.router.add_route(method, path, handler)
|
||||
return True
|
||||
|
||||
|
||||
def register_routes(app, adapter) -> int:
|
||||
"""Bind missing compatibility routes to the live aiohttp router.
|
||||
|
||||
Called from `_patch._maybe_register_routes()` after the adapter has just
|
||||
finished its own setup. Routes are added directly to `app.router`, which
|
||||
aiohttp keeps mutable until `AppRunner.setup()` freezes it shortly after
|
||||
`connect()` returns. Native upstream routes win per method/path.
|
||||
|
||||
Returns the number of compatibility routes actually added.
|
||||
"""
|
||||
upstream = _resolve_upstream()
|
||||
|
||||
sessions = _make_sessions_handlers(adapter, upstream)
|
||||
memory = _make_memory_handlers(adapter, upstream)
|
||||
skills = _make_skills_handlers(adapter, upstream)
|
||||
config = _make_config_handlers(adapter, upstream)
|
||||
|
||||
routes = [
|
||||
("GET", "/api/sessions", sessions["list_sessions"]),
|
||||
("POST", "/api/sessions", sessions["create_session"]),
|
||||
("GET", "/api/sessions/search", sessions["search_sessions"]),
|
||||
("GET", "/api/sessions/{session_id}", sessions["get_session"]),
|
||||
("GET", "/api/sessions/{session_id}/messages", sessions["get_session_messages"]),
|
||||
("PATCH", "/api/sessions/{session_id}", sessions["update_session"]),
|
||||
("DELETE", "/api/sessions/{session_id}", sessions["delete_session"]),
|
||||
("POST", "/api/sessions/{session_id}/fork", sessions["fork_session"]),
|
||||
("GET", "/api/memory", memory["get_memory"]),
|
||||
("POST", "/api/memory", memory["add_memory"]),
|
||||
("PATCH", "/api/memory", memory["replace_memory"]),
|
||||
("DELETE", "/api/memory", memory["delete_memory"]),
|
||||
("GET", "/api/skills", skills["list_skills"]),
|
||||
("GET", "/api/skills/{name}", skills["view_skill"]),
|
||||
]
|
||||
# Stubbed 501 — see `toggle_skill` docstring. Registered so the
|
||||
# Kotlin client's capability probe observes the route and renders a
|
||||
# disabled toggle rather than hitting a 404 and showing "unknown
|
||||
# feature."
|
||||
routes.append(("PUT", "/api/skills/toggle", skills["toggle_skill"]))
|
||||
|
||||
routes.extend(
|
||||
[
|
||||
("GET", "/api/config", config["get_config"]),
|
||||
("PATCH", "/api/config", config["update_config"]),
|
||||
("GET", "/api/available-models", config["available_models"]),
|
||||
]
|
||||
)
|
||||
|
||||
injected = 0
|
||||
for method, path, handler in routes:
|
||||
if _add_route_if_missing(app, method, path, handler):
|
||||
injected += 1
|
||||
return injected
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Import-hook plumbing + Application monkey-patch.
|
||||
|
||||
When `aiohttp.web` is imported anywhere in the running interpreter, our
|
||||
`_AioHttpWebFinder` intercepts the import, lets the real loader finish,
|
||||
then swaps `web.Application` for a `_PatchedApplication` subclass.
|
||||
|
||||
The subclass overrides `__setitem__` so we can detect the moment hermes-agent's
|
||||
`APIServerAdapter` does `self._app["api_server_adapter"] = self` — that's the
|
||||
single line in the upstream `connect()` method that gives us a reference to
|
||||
the adapter while the app is still being built (router not yet frozen).
|
||||
|
||||
At that point, we call `_register_routes()` to bind any missing compatibility
|
||||
handlers to the same router the gateway is in the middle of populating. Route
|
||||
registration is granular: native upstream routes win per method/path, and the
|
||||
bootstrap only fills gaps that still do not exist. We also install the
|
||||
slash-command middleware that intercepts
|
||||
``/v1/chat/completions`` and ``/v1/runs`` to handle gateway commands like
|
||||
``/help``, ``/commands``, ``/profile``, and ``/provider`` without forwarding
|
||||
them to the LLM. The gateway then continues with its own route registrations
|
||||
and starts the server normally.
|
||||
|
||||
Failure modes are silent-but-logged: if anything in this chain breaks
|
||||
(unexpected upstream refactor, aiohttp version incompatibility, missing
|
||||
SessionDB methods, etc.), we log a warning and let the gateway start without
|
||||
the injected routes. The Android client's capability detection will fall back
|
||||
to the standard upstream endpoints automatically.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Compatibility paths that `_handlers.register_routes()` can fill when native
|
||||
# upstream support is absent. Registration is method-aware in `_handlers.py`;
|
||||
# this set is only used for logging/cheap diagnostics in the import hook.
|
||||
_COMPATIBILITY_PATHS: frozenset[str] = frozenset({
|
||||
"/api/sessions",
|
||||
"/api/sessions/search",
|
||||
"/api/sessions/{session_id}",
|
||||
"/api/sessions/{session_id}/messages",
|
||||
"/api/sessions/{session_id}/fork",
|
||||
"/api/memory",
|
||||
"/api/skills",
|
||||
"/api/skills/{name}",
|
||||
"/api/config",
|
||||
"/api/available-models",
|
||||
})
|
||||
|
||||
|
||||
class _AioHttpWebFinder:
|
||||
"""sys.meta_path finder that wraps the loader for `aiohttp.web`.
|
||||
|
||||
Removes itself from `sys.meta_path` after firing once, so subsequent
|
||||
imports of unrelated modules don't pay the find-spec cost.
|
||||
"""
|
||||
|
||||
def find_spec(self, fullname, path, target=None):
|
||||
if fullname != "aiohttp.web":
|
||||
return None
|
||||
|
||||
# Remove ourselves so this only runs on the first import.
|
||||
try:
|
||||
sys.meta_path.remove(self)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Resolve the real spec via the remaining finders. We can't recurse
|
||||
# back into our own find_spec because we just removed ourselves.
|
||||
for finder in sys.meta_path:
|
||||
if not hasattr(finder, "find_spec"):
|
||||
continue
|
||||
spec = finder.find_spec(fullname, path, target)
|
||||
if spec is None:
|
||||
continue
|
||||
|
||||
original_loader = spec.loader
|
||||
spec.loader = _PatchingLoader(original_loader)
|
||||
return spec
|
||||
|
||||
# No finder could resolve aiohttp.web — fall through, normal import
|
||||
# error will surface.
|
||||
return None
|
||||
|
||||
|
||||
class _PatchingLoader:
|
||||
"""Loader wrapper that runs `_apply_patch()` after the module is exec'd."""
|
||||
|
||||
def __init__(self, wrapped):
|
||||
self._wrapped = wrapped
|
||||
|
||||
def create_module(self, spec):
|
||||
if hasattr(self._wrapped, "create_module"):
|
||||
return self._wrapped.create_module(spec)
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
self._wrapped.exec_module(module)
|
||||
try:
|
||||
_apply_patch(module)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: failed to install Application patch: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def install_finder() -> None:
|
||||
"""Insert the import-hook finder at the front of `sys.meta_path`."""
|
||||
finder = _AioHttpWebFinder()
|
||||
sys.meta_path.insert(0, finder)
|
||||
|
||||
|
||||
def _apply_patch(web_module) -> None:
|
||||
"""Replace `web_module.Application` with `_PatchedApplication`.
|
||||
|
||||
No-op if `web_module` lacks the expected `Application` attribute (e.g.
|
||||
aiohttp version skew or stripped-down build).
|
||||
"""
|
||||
original_application = getattr(web_module, "Application", None)
|
||||
if original_application is None:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: aiohttp.web has no Application attribute "
|
||||
"— version mismatch? skipping injection."
|
||||
)
|
||||
return
|
||||
|
||||
if getattr(original_application, "_hermes_relay_patched", False):
|
||||
return # Idempotent — already patched in a previous import.
|
||||
|
||||
class _PatchedApplication(original_application):
|
||||
"""Subclass of aiohttp.web.Application with adapter-detection hook.
|
||||
|
||||
Hermes-agent's `APIServerAdapter.connect()` does
|
||||
`self._app["api_server_adapter"] = self` immediately after building
|
||||
the application. We catch that and use the adapter reference to
|
||||
register our extra routes on `self.router` while it's still mutable.
|
||||
"""
|
||||
|
||||
_hermes_relay_patched = True
|
||||
|
||||
def __setitem__(self, key, value):
|
||||
super().__setitem__(key, value)
|
||||
if key != "api_server_adapter":
|
||||
return
|
||||
try:
|
||||
_maybe_register_routes(self, value)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: route injection failed: %s",
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
_maybe_install_command_middleware(self, value)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: command middleware "
|
||||
"installation failed: %s",
|
||||
exc,
|
||||
)
|
||||
|
||||
web_module.Application = _PatchedApplication
|
||||
|
||||
|
||||
def _maybe_register_routes(app, adapter) -> None:
|
||||
"""Register missing compatibility routes without shadowing upstream.
|
||||
|
||||
Current Hermes core work is split across focused upstream PRs rather than
|
||||
one broad session-management branch. For example, native `/api/sessions/*`
|
||||
may exist while `/api/config`, `/api/skills`, or `/api/memory` still need
|
||||
the relay bootstrap. Do not use all-or-nothing route detection here.
|
||||
"""
|
||||
existing_paths: set[str] = set()
|
||||
try:
|
||||
for resource in app.router.resources():
|
||||
canonical = getattr(resource, "canonical", None)
|
||||
if canonical:
|
||||
existing_paths.add(canonical)
|
||||
except Exception:
|
||||
# If router introspection fails, err on the side of NOT injecting —
|
||||
# double-registration would crash the whole gateway startup.
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot inspect existing routes; "
|
||||
"skipping injection (gateway will run with whatever routes "
|
||||
"it natively provides)."
|
||||
)
|
||||
return
|
||||
|
||||
if existing_paths & _COMPATIBILITY_PATHS:
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: detected native /api/* routes; "
|
||||
"will inject only missing compatibility routes."
|
||||
)
|
||||
|
||||
# Defer the heavy import until we're actually going to register. This
|
||||
# keeps the bootstrap cheap for `python -c "1+1"` style invocations
|
||||
# that never use aiohttp meaningfully.
|
||||
try:
|
||||
from . import _handlers
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot import _handlers (%s); "
|
||||
"skipping injection.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
injected = _handlers.register_routes(app, adapter)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: register_routes raised %s; "
|
||||
"the gateway will run without injected compatibility API.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
if injected:
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: injected %d missing /api/* "
|
||||
"compatibility routes onto upstream hermes-agent gateway",
|
||||
injected,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"hermes_relay_bootstrap: all compatibility /api/* routes are "
|
||||
"already native; no route injection needed."
|
||||
)
|
||||
|
||||
|
||||
def _maybe_install_command_middleware(app, adapter) -> None:
|
||||
"""Install the slash-command middleware on the app if not already present.
|
||||
|
||||
The middleware intercepts ``/v1/chat/completions`` and ``/v1/runs``
|
||||
to handle gateway commands (``/help``, ``/commands``, ``/profile``,
|
||||
``/provider``) and return decline notices for stateful commands,
|
||||
preventing the LLM from hallucinating responses for them.
|
||||
|
||||
If the upstream ``api_server_slash`` module already exists (meaning
|
||||
the PR has been merged or the fork is deployed), the middleware is
|
||||
skipped — the native handler handles interception.
|
||||
"""
|
||||
try:
|
||||
from . import _command_middleware
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: cannot import _command_middleware (%s); "
|
||||
"skipping middleware installation.",
|
||||
exc,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
_command_middleware.maybe_install_middleware(app, adapter)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"hermes_relay_bootstrap: maybe_install_middleware raised %s; "
|
||||
"slash commands will fall through to the LLM.",
|
||||
exc,
|
||||
)
|
||||
+32
-1
@@ -15,6 +15,7 @@ version bit when it's actually emitting endpoints::
|
||||
{
|
||||
"hermes": 3,
|
||||
"host": "<ip>", "port": <port>, "key": "<token>", "tls": <bool>,
|
||||
"dashboard_url": "https://dashboard.example.com",
|
||||
"relay": { ... same as v2 ... },
|
||||
"endpoints": [
|
||||
{ "role": "lan", "priority": 0,
|
||||
@@ -190,6 +191,7 @@ def build_payload(
|
||||
relay: Optional[dict] = None,
|
||||
sign: bool = True,
|
||||
endpoints: Optional[list[dict]] = None,
|
||||
dashboard_url: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Build compact JSON payload matching HermesPairingPayload.kt format.
|
||||
|
||||
@@ -211,6 +213,10 @@ def build_payload(
|
||||
for backward compat — phones on v1 / v2 parsers synthesize a single
|
||||
candidate from those fields and ignore ``endpoints``.
|
||||
|
||||
``dashboard_url`` is optional. When present, Android stores it as the
|
||||
Manage/dashboard URL instead of deriving the conventional same-host
|
||||
``:9119`` URL from the API server.
|
||||
|
||||
If ``sign`` is true the payload is signed with the host-local QR
|
||||
secret and the base64 HMAC is added as a top-level ``sig`` field.
|
||||
"""
|
||||
@@ -231,6 +237,8 @@ def build_payload(
|
||||
payload["relay"] = relay
|
||||
if endpoints:
|
||||
payload["endpoints"] = endpoints
|
||||
if dashboard_url:
|
||||
payload["dashboard_url"] = dashboard_url
|
||||
|
||||
if sign:
|
||||
try:
|
||||
@@ -279,6 +287,7 @@ def build_pairing_qr_payload(
|
||||
tls: bool,
|
||||
relay: Optional[dict] = None,
|
||||
endpoints: Optional[list[dict]] = None,
|
||||
dashboard_url: Optional[str] = None,
|
||||
sign: bool = True,
|
||||
) -> str:
|
||||
"""Build the Android pairing QR payload shared by CLI and dashboard mint."""
|
||||
@@ -289,6 +298,7 @@ def build_pairing_qr_payload(
|
||||
tls=tls,
|
||||
relay=relay,
|
||||
endpoints=endpoints,
|
||||
dashboard_url=dashboard_url,
|
||||
sign=sign,
|
||||
)
|
||||
|
||||
@@ -983,6 +993,7 @@ def render_text_block(
|
||||
tls: bool,
|
||||
relay: Optional[dict] = None,
|
||||
invite_url: Optional[str] = None,
|
||||
dashboard_url: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Return formatted connection details — always shown (works in any terminal).
|
||||
|
||||
@@ -1000,12 +1011,14 @@ def render_text_block(
|
||||
" " + "-" * 40,
|
||||
"",
|
||||
f" Server : {url}",
|
||||
f" Dashboard: {dashboard_url}" if dashboard_url else None,
|
||||
f" API Key: {_mask_key(key)}",
|
||||
f" Auth : {auth_status}",
|
||||
"",
|
||||
" Enter manually in the app if QR won't scan:",
|
||||
f" URL: {url}",
|
||||
]
|
||||
lines = [line for line in lines if line is not None]
|
||||
if key:
|
||||
lines.append(f" Key: {key}")
|
||||
|
||||
@@ -1222,6 +1235,9 @@ def pair_command(args) -> None:
|
||||
port = config["port"]
|
||||
key = config["key"]
|
||||
tls = config["tls"]
|
||||
dashboard_url = (
|
||||
str(getattr(args, "dashboard_url", "") or "").strip().rstrip("/") or None
|
||||
)
|
||||
|
||||
# ── Relay pre-pairing ────────────────────────────────────────────────
|
||||
#
|
||||
@@ -1329,11 +1345,22 @@ def pair_command(args) -> None:
|
||||
tls=tls,
|
||||
relay=relay_block,
|
||||
endpoints=endpoints or None,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
invite_url = build_pairing_invite_url(payload)
|
||||
|
||||
# Always show text block — works in any terminal including Hermes TUI
|
||||
print(render_text_block(host, port, key, tls, relay=relay_block, invite_url=invite_url))
|
||||
print(
|
||||
render_text_block(
|
||||
host,
|
||||
port,
|
||||
key,
|
||||
tls,
|
||||
relay=relay_block,
|
||||
invite_url=invite_url,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
)
|
||||
|
||||
png_only = getattr(args, "png", False)
|
||||
no_qr = getattr(args, "no_qr", False)
|
||||
@@ -1375,6 +1402,10 @@ if __name__ == "__main__":
|
||||
)
|
||||
parser.add_argument("--host", help="Override API server host")
|
||||
parser.add_argument("--port", type=int, help="Override API server port")
|
||||
parser.add_argument(
|
||||
"--dashboard-url",
|
||||
help="Embed an explicit Hermes dashboard URL for Manage/standard voice",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ttl",
|
||||
default="30d",
|
||||
|
||||
+64
-2
@@ -1,5 +1,67 @@
|
||||
name: hermes-relay
|
||||
version: 0.7.0
|
||||
description: "Hermes-Relay plugin — QR pairing, relay sessions, and sideload Android Device Control. Device Control tools read screen, tap, type, open apps, and take screenshots only when a paired sideload phone reports support. Includes `hermes pair` and `hermes relay` CLI commands."
|
||||
manifest_version: 1
|
||||
version: 1.1.0
|
||||
description: "Hermes-Relay plugin for QR pairing, relay sessions, dashboard management, remote desktop/phone tooling, and optional legacy compatibility diagnostics. Standard chat, Manage, and dashboard voice remain vanilla upstream Hermes surfaces."
|
||||
author: Axiom Labs
|
||||
requires_env: []
|
||||
provides_tools:
|
||||
- android_ping
|
||||
- android_read_screen
|
||||
- android_find_nodes
|
||||
- android_tap
|
||||
- android_tap_text
|
||||
- android_long_press
|
||||
- android_type
|
||||
- android_swipe
|
||||
- android_drag
|
||||
- android_open_app
|
||||
- android_press_key
|
||||
- android_screenshot
|
||||
- android_scroll
|
||||
- android_wait
|
||||
- android_get_apps
|
||||
- android_current_app
|
||||
- android_media
|
||||
- android_describe_node
|
||||
- android_setup
|
||||
- android_macro
|
||||
- android_clipboard_read
|
||||
- android_clipboard_write
|
||||
- android_screen_hash
|
||||
- android_diff_screen
|
||||
- android_send_intent
|
||||
- android_broadcast
|
||||
- android_events
|
||||
- android_event_stream
|
||||
- android_location
|
||||
- android_search_contacts
|
||||
- android_call
|
||||
- android_send_sms
|
||||
- android_return_to_hermes
|
||||
- android_share_media
|
||||
- android_send_mms
|
||||
- desktop_read_file
|
||||
- desktop_write_file
|
||||
- desktop_search_files
|
||||
- desktop_terminal
|
||||
- desktop_patch
|
||||
- desktop_powershell
|
||||
- desktop_spawn_detached
|
||||
- desktop_list_processes
|
||||
- desktop_kill_process
|
||||
- desktop_find_pid_by_port
|
||||
- desktop_job_start
|
||||
- desktop_job_status
|
||||
- desktop_job_logs
|
||||
- desktop_job_cancel
|
||||
- desktop_job_list
|
||||
- desktop_copy_directory
|
||||
- desktop_zip
|
||||
- desktop_unzip
|
||||
- desktop_checksum
|
||||
- desktop_health
|
||||
- desktop_computer_status
|
||||
- desktop_computer_screenshot
|
||||
- desktop_computer_action
|
||||
- desktop_computer_grant_request
|
||||
- desktop_computer_cancel
|
||||
|
||||
@@ -19,7 +19,7 @@ See ``plugin/relay/server.py`` for the aiohttp server,
|
||||
# desktop CLI releases use desktop/package.json and desktop-v* tags. The
|
||||
# /health endpoint reports this server version, and stale values make live
|
||||
# server diagnosis harder than it should be.
|
||||
__version__ = "0.7.0"
|
||||
__version__ = "1.1.0"
|
||||
|
||||
from .server import create_app, main # noqa: E402 — must come after __version__
|
||||
|
||||
|
||||
@@ -385,6 +385,7 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
api_host_override = payload.get("host")
|
||||
api_port_override = payload.get("port")
|
||||
api_tls_override = payload.get("tls")
|
||||
dashboard_url_raw = payload.get("dashboard_url") or payload.get("dashboardUrl")
|
||||
|
||||
raw_api_host = str(
|
||||
api_host_override
|
||||
@@ -430,6 +431,11 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
exc,
|
||||
)
|
||||
api_key = ""
|
||||
dashboard_url = (
|
||||
str(dashboard_url_raw).strip().rstrip("/")
|
||||
if dashboard_url_raw is not None
|
||||
else None
|
||||
) or None
|
||||
|
||||
# ── Pairing metadata ─────────────────────────────────────────────────
|
||||
ttl_seconds, grants, transport_hint, err = _parse_pairing_metadata(payload)
|
||||
@@ -503,6 +509,7 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
relay=relay_block,
|
||||
sign=True,
|
||||
endpoints=endpoints_list or None,
|
||||
dashboard_url=dashboard_url,
|
||||
)
|
||||
pairing_url = build_pairing_invite_url(qr_payload)
|
||||
|
||||
@@ -546,6 +553,8 @@ async def handle_pairing_mint(request: web.Request) -> web.Response:
|
||||
"tls": api_tls,
|
||||
"relay_url": relay_url,
|
||||
}
|
||||
if dashboard_url is not None:
|
||||
mint_response["dashboard_url"] = dashboard_url
|
||||
if endpoints_list is not None:
|
||||
# Mirror the endpoints back verbatim so the dashboard can render
|
||||
# the same list it sent (useful for "edit URL" round-trips) and
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest import mock
|
||||
|
||||
from plugin import cli, compat
|
||||
|
||||
|
||||
class CompatTests(unittest.TestCase):
|
||||
def test_status_detects_managed_pth(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
(root / compat.PTH_NAME).write_text(compat.PTH_CONTENT, encoding="utf-8")
|
||||
|
||||
with mock.patch(
|
||||
"importlib.util.find_spec",
|
||||
return_value=SimpleNamespace(origin="/tmp/bootstrap/__init__.py"),
|
||||
):
|
||||
status = compat.collect_compat_status(site_dirs=[root])
|
||||
|
||||
self.assertTrue(status["installed"])
|
||||
self.assertTrue(status["package"]["importable"])
|
||||
self.assertTrue(status["pth_files"][0]["managed_content"])
|
||||
|
||||
def test_install_writes_hook_when_package_importable(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
|
||||
with mock.patch(
|
||||
"importlib.util.find_spec",
|
||||
return_value=SimpleNamespace(origin="/tmp/bootstrap/__init__.py"),
|
||||
):
|
||||
result = compat.install_compat(site_packages=root)
|
||||
|
||||
self.assertTrue(result["changed"])
|
||||
self.assertEqual((root / compat.PTH_NAME).read_text(encoding="utf-8"), compat.PTH_CONTENT)
|
||||
|
||||
def test_install_refuses_missing_package(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
plugin_dir = root / "missing-plugin"
|
||||
|
||||
with mock.patch("importlib.util.find_spec", return_value=None):
|
||||
with self.assertRaises(RuntimeError):
|
||||
compat.install_compat(site_packages=root, plugin_dir=plugin_dir)
|
||||
|
||||
self.assertFalse((root / compat.PTH_NAME).exists())
|
||||
|
||||
def test_install_replaces_legacy_managed_hook(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
target = root / compat.PTH_NAME
|
||||
target.write_text(compat.LEGACY_PTH_CONTENT, encoding="utf-8")
|
||||
|
||||
result = compat.install_compat(site_packages=root)
|
||||
|
||||
self.assertTrue(result["changed"])
|
||||
self.assertIn("hermes_relay_plugin_bootstrap", target.read_text(encoding="utf-8"))
|
||||
|
||||
def test_remove_deletes_hook(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
root = Path(raw)
|
||||
target = root / compat.PTH_NAME
|
||||
target.write_text(compat.PTH_CONTENT, encoding="utf-8")
|
||||
|
||||
result = compat.remove_compat(site_packages=root)
|
||||
|
||||
self.assertTrue(result["changed"])
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_relay_cli_registers_compat_command(self) -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
cli.register_relay_cli(parser)
|
||||
|
||||
parsed = parser.parse_args(["compat", "status", "--json"])
|
||||
|
||||
self.assertTrue(parsed.json)
|
||||
self.assertEqual(parsed.compat_cmd, "status")
|
||||
self.assertIs(parsed.func, cli.relay_compat_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import unittest
|
||||
from unittest import mock
|
||||
from pathlib import Path
|
||||
|
||||
from plugin import cli, doctor
|
||||
|
||||
|
||||
def _fake_probe(url: str, *, method: str = "GET", timeout: float = 2.0):
|
||||
return {
|
||||
"ok": True,
|
||||
"exists": True,
|
||||
"status": 200,
|
||||
"method": method,
|
||||
"timeout": timeout,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
class DoctorTests(unittest.TestCase):
|
||||
def test_collect_doctor_report_uses_standard_route_probes(self) -> None:
|
||||
with mock.patch("importlib.util.find_spec", return_value=None):
|
||||
report = doctor.collect_doctor_report(
|
||||
api_url="http://api.example:8642",
|
||||
dashboard_url="http://dash.example:9119",
|
||||
relay_port=9999,
|
||||
timeout=0.25,
|
||||
probe=_fake_probe,
|
||||
site_dirs=[],
|
||||
)
|
||||
|
||||
self.assertEqual(report["schema_version"], 1)
|
||||
self.assertEqual(report["plugin"]["name"], "hermes-relay")
|
||||
self.assertEqual(
|
||||
report["standard"]["api"]["capabilities"]["url"],
|
||||
"http://api.example:8642/v1/capabilities",
|
||||
)
|
||||
self.assertEqual(
|
||||
report["standard"]["dashboard"]["audio_transcribe"]["method"],
|
||||
"HEAD",
|
||||
)
|
||||
self.assertEqual(
|
||||
report["standard"]["dashboard"]["ws_ticket"]["url"],
|
||||
"http://dash.example:9119/api/auth/ws-ticket",
|
||||
)
|
||||
self.assertEqual(
|
||||
report["relay"]["info"]["url"],
|
||||
"http://127.0.0.1:9999/relay/info",
|
||||
)
|
||||
self.assertFalse(report["bootstrap"]["installed"])
|
||||
|
||||
def test_bootstrap_status_detects_legacy_pth(self) -> None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as raw:
|
||||
tmp_path = Path(raw)
|
||||
(tmp_path / "hermes_relay_bootstrap.pth").write_text(
|
||||
"import hermes_relay_bootstrap\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
with mock.patch("importlib.util.find_spec", return_value=None):
|
||||
status = doctor._bootstrap_status([tmp_path])
|
||||
|
||||
self.assertTrue(status["installed"])
|
||||
self.assertEqual(
|
||||
status["pth_files"],
|
||||
[str(tmp_path / "hermes_relay_bootstrap.pth")],
|
||||
)
|
||||
|
||||
def test_render_doctor_text_includes_checks(self) -> None:
|
||||
with mock.patch("importlib.util.find_spec", return_value=None):
|
||||
report = doctor.collect_doctor_report(
|
||||
api_url="http://api.example:8642",
|
||||
dashboard_url="http://dash.example:9119",
|
||||
relay_port=9999,
|
||||
probe=_fake_probe,
|
||||
site_dirs=[],
|
||||
)
|
||||
|
||||
rendered = doctor.render_doctor_text(report)
|
||||
|
||||
self.assertIn("Hermes-Relay doctor", rendered)
|
||||
self.assertIn("[OK] plugin-root", rendered)
|
||||
self.assertIn("vanilla upstream Hermes", rendered)
|
||||
|
||||
def test_relay_cli_registers_doctor_command(self) -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
cli.register_relay_cli(parser)
|
||||
|
||||
parsed = parser.parse_args(["doctor", "--json", "--timeout", "0.1"])
|
||||
|
||||
self.assertTrue(parsed.json)
|
||||
self.assertEqual(parsed.timeout, 0.1)
|
||||
self.assertIs(parsed.func, cli.relay_doctor_command)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -144,6 +144,24 @@ class PairingMintSchemaTests(AioHTTPTestCase):
|
||||
self.assertEqual(qr["port"], 443)
|
||||
self.assertTrue(qr["tls"])
|
||||
|
||||
async def test_dashboard_url_flows_into_response_and_qr_payload(self) -> None:
|
||||
result = await self._mint({
|
||||
"dashboard_url": "https://dash.example.com/hermes/",
|
||||
})
|
||||
qr = json.loads(result["qr_payload"])
|
||||
|
||||
self.assertEqual(result["dashboard_url"], "https://dash.example.com/hermes")
|
||||
self.assertEqual(qr["dashboard_url"], "https://dash.example.com/hermes")
|
||||
|
||||
async def test_dashboard_url_camel_alias_is_accepted(self) -> None:
|
||||
result = await self._mint({
|
||||
"dashboardUrl": "https://dash.example.com",
|
||||
})
|
||||
qr = json.loads(result["qr_payload"])
|
||||
|
||||
self.assertEqual(result["dashboard_url"], "https://dash.example.com")
|
||||
self.assertEqual(qr["dashboard_url"], "https://dash.example.com")
|
||||
|
||||
async def test_ttl_and_transport_hint_flow_through_to_relay_block(self) -> None:
|
||||
result = await self._mint({
|
||||
"ttl_seconds": 3600,
|
||||
|
||||
@@ -19,6 +19,7 @@ Coverage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import base64
|
||||
@@ -28,7 +29,7 @@ from contextlib import redirect_stderr, redirect_stdout
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import pair
|
||||
from plugin import cli, pair
|
||||
|
||||
|
||||
# ── normalize_pairing_code ───────────────────────────────────────────────────
|
||||
@@ -125,6 +126,34 @@ def _pair_args(**overrides: object) -> types.SimpleNamespace:
|
||||
|
||||
|
||||
class PairCommandTests(unittest.TestCase):
|
||||
def test_plugin_cli_registers_full_pair_surface(self) -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
cli.register_cli(parser)
|
||||
|
||||
args = parser.parse_args(
|
||||
[
|
||||
"--register-code",
|
||||
"ABC123",
|
||||
"--transport-hint",
|
||||
"wss",
|
||||
"--dashboard-url",
|
||||
"https://dash.example.com",
|
||||
"--mode",
|
||||
"auto",
|
||||
"--public-url",
|
||||
"https://hermes.example.com",
|
||||
"--prefer",
|
||||
"tailscale",
|
||||
]
|
||||
)
|
||||
|
||||
self.assertEqual(args.register_code, "ABC123")
|
||||
self.assertEqual(args.transport_hint, "wss")
|
||||
self.assertEqual(args.dashboard_url, "https://dash.example.com")
|
||||
self.assertEqual(args.mode, "auto")
|
||||
self.assertEqual(args.public_url, "https://hermes.example.com")
|
||||
self.assertEqual(args.prefer, "tailscale")
|
||||
|
||||
def test_pairing_invite_url_round_trips_payload(self) -> None:
|
||||
payload = json.dumps({
|
||||
"hermes": 2,
|
||||
@@ -154,6 +183,7 @@ class PairCommandTests(unittest.TestCase):
|
||||
relay=None,
|
||||
sign=True,
|
||||
endpoints=None,
|
||||
dashboard_url=None,
|
||||
):
|
||||
payload = {
|
||||
"hermes": 2 if relay else 1,
|
||||
@@ -166,6 +196,8 @@ class PairCommandTests(unittest.TestCase):
|
||||
payload["relay"] = relay
|
||||
if endpoints:
|
||||
payload["endpoints"] = endpoints
|
||||
if dashboard_url:
|
||||
payload["dashboard_url"] = dashboard_url
|
||||
captured["payload"] = payload
|
||||
return json.dumps(payload)
|
||||
|
||||
@@ -197,18 +229,22 @@ class PairCommandTests(unittest.TestCase):
|
||||
):
|
||||
out = io.StringIO()
|
||||
with redirect_stdout(out):
|
||||
pair.pair_command(_pair_args())
|
||||
pair.pair_command(
|
||||
_pair_args(dashboard_url="https://dash.example.com")
|
||||
)
|
||||
|
||||
payload = captured["payload"]
|
||||
self.assertEqual(payload["host"], "10.0.0.42")
|
||||
self.assertEqual(payload["port"], 8642)
|
||||
self.assertEqual(payload["key"], "sk-cli-config")
|
||||
self.assertEqual(payload["dashboard_url"], "https://dash.example.com")
|
||||
self.assertNotEqual(payload["key"], "ABCD12")
|
||||
relay = payload["relay"]
|
||||
self.assertEqual(relay["url"], "ws://10.0.0.42:8767")
|
||||
self.assertEqual(relay["code"], "ABCD12")
|
||||
self.assertIn("Copy/paste pairing invite", out.getvalue())
|
||||
self.assertIn("hermes-relay://pair?payload=", out.getvalue())
|
||||
self.assertIn("Dashboard: https://dash.example.com", out.getvalue())
|
||||
|
||||
|
||||
class RegisterCodeCommandTests(unittest.TestCase):
|
||||
|
||||
+5
-3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-relay"
|
||||
version = "0.7.0"
|
||||
version = "1.1.0"
|
||||
description = "Hermes-Relay plugin — Android device control toolset, QR pairing CLI, and WSS relay server for hermes-agent"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -45,5 +45,7 @@ plugin = ["skill.md", "plugin.yaml"]
|
||||
# Note: `hermes_relay_bootstrap.pth` at the repo root is NOT installed by
|
||||
# `pip install -e` automatically — setuptools' data-files doesn't ship to
|
||||
# site-packages reliably for editable installs. install.sh copies the .pth
|
||||
# file into the venv's site-packages as a separate step. The bootstrap
|
||||
# package itself (hermes_relay_bootstrap/) IS installed via the find above.
|
||||
# file into the venv's site-packages as a separate step. The active bootstrap
|
||||
# implementation now lives under plugin/hermes_relay_bootstrap/; the top-level
|
||||
# hermes_relay_bootstrap package is a backward-compatible import shim for old
|
||||
# hooks and editable installs.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Report and validate Hermes-Relay monorepo release tracks.
|
||||
|
||||
The repo ships three independently versioned artifacts:
|
||||
|
||||
- Android app: ``android-v*`` tags, source in ``gradle/libs.versions.toml``.
|
||||
- Server/plugin: ``server-v*`` tags, source in ``pyproject.toml``.
|
||||
- Desktop CLI: ``desktop-v*`` tags, source in ``desktop/package.json``.
|
||||
|
||||
This script gives release prep one command that checks the sources without
|
||||
forcing unrelated artifacts to share the same SemVer.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
REPO_ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||||
SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Track:
|
||||
name: str
|
||||
version: str
|
||||
source: str
|
||||
tag: str
|
||||
details: str = ""
|
||||
|
||||
|
||||
def _read_text(rel_path: str) -> str:
|
||||
return (REPO_ROOT / rel_path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _read_json(rel_path: str) -> object:
|
||||
return json.loads(_read_text(rel_path))
|
||||
|
||||
|
||||
def _regex_version(rel_path: str, pattern: str) -> str:
|
||||
match = re.search(pattern, _read_text(rel_path), re.MULTILINE)
|
||||
if not match:
|
||||
raise ValueError(f"{rel_path} has no parseable version")
|
||||
return match.group(1)
|
||||
|
||||
|
||||
def _json_version(rel_path: str) -> str:
|
||||
data = _read_json(rel_path)
|
||||
if not isinstance(data, dict) or not isinstance(data.get("version"), str):
|
||||
raise ValueError(f"{rel_path} has no parseable version")
|
||||
return data["version"]
|
||||
|
||||
|
||||
def _package_lock_root_version() -> str:
|
||||
data = _read_json("plugin/dashboard/package-lock.json")
|
||||
if not isinstance(data, dict) or not isinstance(data.get("packages"), dict):
|
||||
raise ValueError("plugin/dashboard/package-lock.json has no packages object")
|
||||
root_pkg = data["packages"].get("")
|
||||
if not isinstance(root_pkg, dict) or not isinstance(root_pkg.get("version"), str):
|
||||
raise ValueError('plugin/dashboard/package-lock.json packages[""] has no version')
|
||||
return root_pkg["version"]
|
||||
|
||||
|
||||
def _android_track(errors: list[str]) -> Track:
|
||||
data = tomllib.loads(_read_text("gradle/libs.versions.toml"))
|
||||
versions = data.get("versions")
|
||||
if not isinstance(versions, dict):
|
||||
raise ValueError("gradle/libs.versions.toml has no [versions] table")
|
||||
version = str(versions.get("appVersionName", ""))
|
||||
code = str(versions.get("appVersionCode", ""))
|
||||
if not SEMVER_RE.match(version):
|
||||
errors.append(f"android version is not SemVer: {version!r}")
|
||||
if not code.isdigit():
|
||||
errors.append(f"android versionCode is not numeric: {code!r}")
|
||||
return Track(
|
||||
name="android",
|
||||
version=version,
|
||||
source="gradle/libs.versions.toml",
|
||||
tag=f"android-v{version}",
|
||||
details=f"versionCode {code}",
|
||||
)
|
||||
|
||||
|
||||
def _server_track(errors: list[str]) -> Track:
|
||||
pyproject = tomllib.loads(_read_text("pyproject.toml"))
|
||||
project = pyproject.get("project")
|
||||
if not isinstance(project, dict):
|
||||
raise ValueError("pyproject.toml has no [project] table")
|
||||
version = str(project.get("version", ""))
|
||||
versions = [
|
||||
("pyproject.toml", version),
|
||||
(
|
||||
"plugin/relay/__init__.py",
|
||||
_regex_version("plugin/relay/__init__.py", r'^__version__\s*=\s*"([^"]+)"'),
|
||||
),
|
||||
("plugin/plugin.yaml", _regex_version("plugin/plugin.yaml", r"^version:\s*([^\s#]+)")),
|
||||
("plugin/dashboard/manifest.json", _json_version("plugin/dashboard/manifest.json")),
|
||||
("plugin/dashboard/package.json", _json_version("plugin/dashboard/package.json")),
|
||||
("plugin/dashboard/package-lock.json", _json_version("plugin/dashboard/package-lock.json")),
|
||||
('plugin/dashboard/package-lock.json packages[""]', _package_lock_root_version()),
|
||||
]
|
||||
if not SEMVER_RE.match(version):
|
||||
errors.append(f"server/plugin version is not SemVer: {version!r}")
|
||||
for source, found in versions:
|
||||
if found != version:
|
||||
errors.append(f"server/plugin version mismatch: {source} has {found}, expected {version}")
|
||||
return Track(
|
||||
name="server/plugin",
|
||||
version=version,
|
||||
source="pyproject.toml",
|
||||
tag=f"server-v{version}",
|
||||
details="plugin + dashboard metadata",
|
||||
)
|
||||
|
||||
|
||||
def _desktop_track(errors: list[str]) -> Track:
|
||||
version = _json_version("desktop/package.json")
|
||||
generated = _regex_version("desktop/src/version.ts", r'^export const VERSION = "([^"]+)" as const')
|
||||
if not SEMVER_RE.match(version):
|
||||
errors.append(f"desktop CLI version is not SemVer: {version!r}")
|
||||
if generated != version:
|
||||
errors.append(
|
||||
"desktop CLI version mismatch: desktop/src/version.ts has "
|
||||
f"{generated}, expected {version}; run npm run gen:version in desktop/"
|
||||
)
|
||||
return Track(
|
||||
name="desktop-cli",
|
||||
version=version,
|
||||
source="desktop/package.json",
|
||||
tag=f"desktop-v{version}",
|
||||
details="src/version.ts generated",
|
||||
)
|
||||
|
||||
|
||||
def collect_tracks() -> tuple[list[Track], list[str]]:
|
||||
errors: list[str] = []
|
||||
tracks = [
|
||||
_android_track(errors),
|
||||
_server_track(errors),
|
||||
_desktop_track(errors),
|
||||
]
|
||||
return tracks, errors
|
||||
|
||||
|
||||
def _print_table(tracks: list[Track]) -> None:
|
||||
headers = ("track", "version", "source", "tag", "details")
|
||||
rows = [(t.name, t.version, t.source, t.tag, t.details) for t in tracks]
|
||||
widths = [
|
||||
max(len(headers[index]), *(len(row[index]) for row in rows))
|
||||
for index in range(len(headers))
|
||||
]
|
||||
print(" ".join(header.ljust(widths[index]) for index, header in enumerate(headers)))
|
||||
print(" ".join("-" * width for width in widths))
|
||||
for row in rows:
|
||||
print(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Validate monorepo release track versions.")
|
||||
parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
tracks, errors = collect_tracks()
|
||||
except Exception as exc:
|
||||
print(f"version track check failed: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"tracks": [track.__dict__ for track in tracks],
|
||||
"ok": not errors,
|
||||
"errors": errors,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
else:
|
||||
_print_table(tracks)
|
||||
if not errors:
|
||||
print("\nrelease track versions are internally consistent")
|
||||
|
||||
if errors:
|
||||
for error in errors:
|
||||
print(error, file=sys.stderr)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+29
-7
@@ -15,9 +15,11 @@
|
||||
# What it removes (in reverse install order):
|
||||
# [6] systemd user service — `systemctl --user disable --now`,
|
||||
# unit file deletion, daemon-reload
|
||||
# [5] hermes-pair + hermes-status — ~/.local/bin/hermes-pair,
|
||||
# + hermes-relay-update shims ~/.local/bin/hermes-status,
|
||||
# ~/.local/bin/hermes-relay-update
|
||||
# [5] shell shims — ~/.local/bin/hermes-pair,
|
||||
# ~/.local/bin/hermes-status,
|
||||
# ~/.local/bin/hermes-relay,
|
||||
# ~/.local/bin/hermes-relay-update,
|
||||
# ~/.local/bin/hermes-relay-tailscale
|
||||
# [4] skills external_dirs entry — removes the relay's path from
|
||||
# ~/.hermes/config.yaml (other
|
||||
# entries preserved)
|
||||
@@ -72,7 +74,9 @@ HERMES_CONFIG="$HERMES_HOME/config.yaml"
|
||||
QR_SECRET="$HERMES_HOME/hermes-relay-qr-secret"
|
||||
SHIM_PATH="$HOME/.local/bin/hermes-pair"
|
||||
STATUS_SHIM_PATH="$HOME/.local/bin/hermes-status"
|
||||
RELAY_SHIM_PATH="$HOME/.local/bin/hermes-relay"
|
||||
UPDATE_SHIM_PATH="$HOME/.local/bin/hermes-relay-update"
|
||||
TS_SHIM_PATH="$HOME/.local/bin/hermes-relay-tailscale"
|
||||
DOCTOR_SHIM_PATH="$HOME/.local/bin/hermes-relay-doctor"
|
||||
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
|
||||
SERVICE_DST="$SYSTEMD_USER_DIR/hermes-relay.service"
|
||||
@@ -131,8 +135,8 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 5/6 Remove hermes-pair + hermes-status + hermes-relay-update shims ───
|
||||
info "[5/6] Removing hermes-pair + hermes-status + hermes-relay-update + hermes-relay-doctor shims..."
|
||||
# ── 5/6 Remove shell shims ───────────────────────────────────────────────
|
||||
info "[5/6] Removing shell shims..."
|
||||
if [ -f "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then
|
||||
run "rm -f \"$SHIM_PATH\""
|
||||
ok "Removed $SHIM_PATH"
|
||||
@@ -145,12 +149,24 @@ if [ -f "$STATUS_SHIM_PATH" ] || [ -L "$STATUS_SHIM_PATH" ]; then
|
||||
else
|
||||
warn "$STATUS_SHIM_PATH does not exist"
|
||||
fi
|
||||
if [ -f "$RELAY_SHIM_PATH" ] || [ -L "$RELAY_SHIM_PATH" ]; then
|
||||
run "rm -f \"$RELAY_SHIM_PATH\""
|
||||
ok "Removed $RELAY_SHIM_PATH"
|
||||
else
|
||||
warn "$RELAY_SHIM_PATH does not exist"
|
||||
fi
|
||||
if [ -f "$UPDATE_SHIM_PATH" ] || [ -L "$UPDATE_SHIM_PATH" ]; then
|
||||
run "rm -f \"$UPDATE_SHIM_PATH\""
|
||||
ok "Removed $UPDATE_SHIM_PATH"
|
||||
else
|
||||
warn "$UPDATE_SHIM_PATH does not exist"
|
||||
fi
|
||||
if [ -f "$TS_SHIM_PATH" ] || [ -L "$TS_SHIM_PATH" ]; then
|
||||
run "rm -f \"$TS_SHIM_PATH\""
|
||||
ok "Removed $TS_SHIM_PATH"
|
||||
else
|
||||
warn "$TS_SHIM_PATH does not exist"
|
||||
fi
|
||||
if [ -f "$DOCTOR_SHIM_PATH" ] || [ -L "$DOCTOR_SHIM_PATH" ]; then
|
||||
run "rm -f \"$DOCTOR_SHIM_PATH\""
|
||||
ok "Removed $DOCTOR_SHIM_PATH"
|
||||
@@ -287,8 +303,14 @@ info "[2/6] Removing bootstrap .pth + pip package..."
|
||||
if [ -x "$VENV_PY" ]; then
|
||||
SITE_PKGS="$("$VENV_PY" -c 'import site; print(site.getsitepackages()[0])' 2>/dev/null || true)"
|
||||
if [ -n "$SITE_PKGS" ] && [ -f "$SITE_PKGS/$PTH_NAME" ]; then
|
||||
run "rm -f \"$SITE_PKGS/$PTH_NAME\""
|
||||
ok "Removed $SITE_PKGS/$PTH_NAME"
|
||||
if [ -n "$DRY_RUN" ]; then
|
||||
echo " [dry-run] \"$VENV_PY\" -m plugin.cli relay compat remove --site-packages \"$SITE_PKGS\""
|
||||
elif "$VENV_PY" -m plugin.cli relay compat remove --site-packages "$SITE_PKGS" >/dev/null 2>&1; then
|
||||
ok "Removed compat hook via hermes relay compat"
|
||||
else
|
||||
run "rm -f \"$SITE_PKGS/$PTH_NAME\""
|
||||
ok "Removed $SITE_PKGS/$PTH_NAME (compat command unavailable)"
|
||||
fi
|
||||
else
|
||||
warn "$PTH_NAME not present in venv site-packages"
|
||||
fi
|
||||
|
||||
@@ -146,6 +146,7 @@ export default defineConfig({
|
||||
{ text: 'Hermes API', link: '/reference/api' },
|
||||
{ text: 'Configuration', link: '/reference/configuration' },
|
||||
{ text: 'Relay Server', link: '/reference/relay-server' },
|
||||
{ text: 'Agent Cleanup Prompt', link: '/reference/agent-cleanup-prompt' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -54,30 +54,38 @@ v0.2 ran the bridge as a standalone service on port 8766 (`plugin/tools/android_
|
||||
|
||||
---
|
||||
|
||||
## ADR-4: Chat via Direct API, Not Relay Proxy
|
||||
## ADR-4: Chat via Upstream Gateway/API, Not Relay Proxy
|
||||
|
||||
**Decision:** Chat connects directly from the Android app to the Hermes API Server via HTTP/SSE. The relay server is only used for bridge and terminal channels.
|
||||
**Decision:** Chat uses upstream Hermes surfaces from the Android app. It
|
||||
prefers the dashboard `/api/ws` gateway when Manage auth is ready, then falls
|
||||
back to Hermes API Server HTTP/SSE. The relay server is only used for bridge,
|
||||
terminal, media, notifications, and relay-backed voice channels.
|
||||
|
||||
### Original Approach
|
||||
|
||||
Chat was originally proxied through the relay server, which converted SSE responses to WebSocket envelopes.
|
||||
|
||||
### Why Direct API Won
|
||||
### Why Upstream Direct Paths Won
|
||||
|
||||
- The relay was an unnecessary middleman — it just converted SSE to WebSocket.
|
||||
- Every other Hermes frontend (Open WebUI, ClawPort, LobeChat) connects directly.
|
||||
- The Sessions API (`/api/sessions/{id}/chat/stream`) provides SSE streaming with rich event types.
|
||||
- The relay was an unnecessary middleman — it just converted upstream events to
|
||||
another WebSocket.
|
||||
- Other Hermes frontends use upstream API or dashboard surfaces without Relay.
|
||||
- The dashboard gateway provides live thinking/reasoning events, while the
|
||||
Sessions API (`/api/sessions/{id}/chat/stream`) provides SSE fallback with rich
|
||||
event types.
|
||||
- Simpler, lower latency, removes relay as single point of failure for chat.
|
||||
|
||||
### Result
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (HTTP) → Relay Server (:8767) [voice routes]
|
||||
Phone (WSS) → Relay Server (:8767) [terminal, bridge, notifications]
|
||||
Phone (WS) → Hermes dashboard (:9119) [gateway chat]
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat fallback]
|
||||
Phone (HTTP) → Hermes dashboard (:9119) [Manage + standard voice]
|
||||
Phone (HTTP/WSS) → Relay Server (:8767) [relay voice, terminal, bridge, notifications]
|
||||
```
|
||||
|
||||
Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run without one.
|
||||
API-server fallback auth uses optional Bearer token (`API_SERVER_KEY`). Dashboard
|
||||
gateway auth uses dashboard cookies plus `/api/auth/ws-ticket`.
|
||||
|
||||
---
|
||||
|
||||
@@ -107,7 +115,7 @@ Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run withou
|
||||
|
||||
## ADR-7: Pairing Code Auth for Relay (QR-driven, updated 2026-04-11)
|
||||
|
||||
**Decision:** Initial Relay pairing via 6-char code generated by the pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that can also carry API server credentials. Standard API/dashboard setup can be saved without Relay pairing. Session tokens handle all subsequent relay reconnects.
|
||||
**Decision:** Initial Relay pairing via 6-char code generated by the pair command (`hermes pair`, `/hermes-relay-pair`, or the compatibility `hermes-pair` shell shim) on the Hermes host, pre-registered with the relay via a loopback-only `/pairing/register` endpoint, and embedded in the same QR payload that can also carry API server credentials plus optional `dashboard_url`. Standard API/dashboard setup can be saved without Relay pairing. Session tokens handle all subsequent relay reconnects.
|
||||
|
||||
### Rationale
|
||||
|
||||
@@ -118,6 +126,9 @@ Auth uses optional Bearer token (`API_SERVER_KEY`). Most local setups run withou
|
||||
- Tokens stored in EncryptedSharedPreferences (AES-256-GCM, Android Keystore-backed).
|
||||
- Codes use the full `A-Z / 0-9` alphabet (36 chars). The earlier "no ambiguous 0/O/1/I" restriction only mattered when a human had to retype a code from a display; with QR + HTTP the restriction silently rejected valid codes.
|
||||
- Old API-only QRs (no `relay` block) still parse cleanly — the `relay` field is nullable and the Android parser runs with `ignoreUnknownKeys = true`.
|
||||
- QRs may carry `dashboard_url` when Manage and standard dashboard voice should
|
||||
use a custom dashboard/reverse-proxy route instead of derived same-host
|
||||
`:9119`.
|
||||
- A future symmetric phone-generates, host-approves flow for the bridge channel will reuse `/pairing/register` from the opposite direction; phone-side `AuthManager.generatePairingCode()` is retained for that reason.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,6 +142,8 @@ For the full wire-shape of each route (query params, response schemas, redaction
|
||||
|
||||
**No "Relay" tab appears after gateway restart.** Confirm the symlink resolves: `ls -lL ~/.hermes/plugins/hermes-relay/dashboard/manifest.json` should show the file. If it doesn't, the installer symlink was broken — re-run `hermes-relay-update` or the `install.sh` one-liner. Check the gateway log (`journalctl --user -u hermes-gateway -f`) for plugin-load errors during startup.
|
||||
|
||||
**The Relay tab appears but text, colors, or cards are hard to read.** Update the Hermes-Relay plugin and restart or rescan the dashboard plugin list. The plugin stylesheet is loaded by the upstream dashboard and follows its active theme tokens; stale `dist/style.css` files from older installs can render poorly after Hermes dashboard theme changes.
|
||||
|
||||
**Bridge Activity tab is empty but the phone is issuing commands.** The ring buffer is in-memory and wipes on relay restart. If you just restarted the relay, you need the phone to issue at least one command before the tab has anything to show. If commands are going through but not appearing, confirm they're reaching the relay (`journalctl --user -u hermes-relay -f` should show the command round-trips).
|
||||
|
||||
**Media Inspector shows tokens but files won't download.** That's a separate path — the inspector lists registered tokens but the actual download goes through `/media/{token}` (bearer-gated, via the phone). If the phone can't fetch a token, check the bearer's `media` grant and `RELAY_MEDIA_TTL_SECONDS` hasn't elapsed since registration.
|
||||
|
||||
@@ -87,15 +87,24 @@ Five STT providers are supported:
|
||||
**On your phone:**
|
||||
|
||||
- Microphone permission (requested the first time you tap the mic).
|
||||
- A connected relay at `:8767` with the voice routes available (any hermes-relay build from 0.2.0 onwards).
|
||||
- For Standard voice: a saved dashboard URL and dashboard sign-in when the
|
||||
dashboard requires auth.
|
||||
- For Relay voice extras or Realtime Agent: a reachable relay at `:8767` with
|
||||
the voice routes available.
|
||||
|
||||
Phone voice mode can authenticate two ways: a paired Relay session token with `voice:config`, `voice:stt`, and `voice:tts` grants, or the same saved Hermes API key used for chat. That means chat+voice-only phone setups can skip the full Relay pairing flow: enter the API URL and API key, and the app derives the Relay URL from the same host on port `8767`. If the `/voice/config` probe fails, the app reveals a manual Relay URL override. The API-key exception is limited to voice routes and requires HTTPS outside loopback. For a temporary plain-LAN phone test, run `hermes relay insecure-api-key on` on the relay host, then turn it back off with `hermes relay insecure-api-key off`.
|
||||
Relay voice mode can authenticate two ways: a paired Relay session token with
|
||||
`voice:config`, `voice:stt`, and `voice:tts` grants, or the same saved Hermes API
|
||||
key used for API-server fallback chat. That means relay-backed chat+voice-only
|
||||
setups can skip full Relay pairing when only `/voice/*` is needed. The API-key
|
||||
exception is limited to Relay voice routes and requires HTTPS outside loopback.
|
||||
For a temporary plain-LAN phone test, run `hermes relay insecure-api-key on` on
|
||||
the relay host, then turn it back off with `hermes relay insecure-api-key off`.
|
||||
|
||||
Before a voice turn is submitted, Android now runs a fast relay health preflight.
|
||||
If the host accepts TCP but does not answer HTTP, voice mode fails quickly with a
|
||||
Connections-facing error instead of sitting in Thinking until the long realtime
|
||||
turn timeout expires. The check is recorded in **Settings -> Diagnostics** and in
|
||||
the Relay detail sheet's recent activity tail.
|
||||
Before a Relay-backed voice turn is submitted, Android runs a fast relay health
|
||||
preflight. If the host accepts TCP but does not answer HTTP, Relay voice fails
|
||||
quickly with a Connections-facing error instead of sitting in Thinking until the
|
||||
long realtime turn timeout expires. The check is recorded in **Settings ->
|
||||
Diagnostics** and in the Relay detail sheet's recent activity tail.
|
||||
|
||||
## Entering Voice Mode
|
||||
|
||||
|
||||
@@ -93,9 +93,8 @@ If the hashes don't match, **don't install** — redownload and try again.
|
||||
|
||||
**5. Verify the signing certificate (advanced).** The APK is signed with the
|
||||
Codename-11 release keystore. To confirm the signature matches the one Google
|
||||
Play pins to the app:
|
||||
Play pins to the app, compare this fingerprint:
|
||||
|
||||
- **Subject:** `CN=Bailey Dixon, Codename-11`
|
||||
- **SHA256 fingerprint:**
|
||||
```
|
||||
A9:A4:2D:94:20:8B:94:B3:68:5B:01:93:E3:94:9B:90:50:AD:80:60:56:E7:16:3C:FC:E5:11:AF:68:0D:79:4B
|
||||
@@ -129,9 +128,9 @@ URL and key**, you're done with this step — skip straight to
|
||||
Hermes server itself the first time.
|
||||
:::
|
||||
|
||||
You'll need a reachable [Hermes Agent](https://hermes-agent.nousresearch.com)
|
||||
instance (v0.8.0+ recommended). The Relay power-user plugin (step 4) additionally
|
||||
needs Python 3.11+ on the server.
|
||||
You'll need a reachable current [Hermes Agent](https://hermes-agent.nousresearch.com)
|
||||
instance with the API server and dashboard enabled. The Relay power-user plugin
|
||||
(step 4) additionally needs Python 3.11+ on the server.
|
||||
|
||||
::::details Set up Hermes + an API key (first-time server setup)
|
||||
**What the app actually needs** is three things: the Hermes API server
|
||||
@@ -278,7 +277,9 @@ On first launch:
|
||||
- **Scan setup QR** → scan a QR containing your URL and key. There's no
|
||||
upstream mobile-pairing command for the standard path yet, so the handy
|
||||
trick is to ask your **Hermes agent to generate one** — a QR encoding
|
||||
`{"api_url":"http://192.168.1.100:8642","api_key":"<your-key>"}` is accepted.
|
||||
`{"api_url":"http://192.168.1.100:8642","api_key":"<your-key>","dashboard_url":"http://192.168.1.100:9119"}`
|
||||
is accepted. `dashboard_url` is optional when the dashboard uses the
|
||||
conventional same-host `:9119` URL.
|
||||
3. Optional: add a Tailscale API URL such as `https://your-host.ts.net:8642` in
|
||||
the **Remote access** field.
|
||||
4. Tap **Connect**.
|
||||
@@ -332,7 +333,8 @@ and Manage all work without it.
|
||||
On the Hermes host:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
hermes relay start --no-ssl
|
||||
hermes pair
|
||||
```
|
||||
@@ -342,6 +344,26 @@ plugin CLI support; it is not a built-in Hermes core command. Then scan the QR i
|
||||
Android from **Settings → Connections → Pair Relay**, or from onboarding's **Scan
|
||||
setup QR** path. If the relay isn't running, the plugin can still print an
|
||||
API-only QR, so Chat works and Relay can be paired later.
|
||||
The QR may include `dashboard_url` for custom dashboard/reverse-proxy layouts;
|
||||
otherwise Android derives the dashboard from the API host on port `9119`.
|
||||
|
||||
Use the legacy installer only when you also want the systemd user service, shell
|
||||
shims, external skill-path registration, and the old clone/update workflow:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
|
||||
```
|
||||
|
||||
The optional compatibility monkeypatch is separate from normal Relay pairing.
|
||||
Modern standard chat, Manage, and dashboard voice do not need it. Check it with
|
||||
`hermes relay compat status`; install it only for older Hermes builds or
|
||||
compatibility-only route gaps:
|
||||
|
||||
```bash
|
||||
hermes relay compat status
|
||||
hermes relay compat install
|
||||
hermes relay compat remove
|
||||
```
|
||||
|
||||
::: tip Start the relay
|
||||
```bash
|
||||
@@ -418,8 +440,8 @@ separately from Relay pairing credentials.
|
||||
Relay pairing does not replace dashboard login, and dashboard login does not mint
|
||||
an API key: it matches the Hermes Desktop remote-gateway path by authenticating
|
||||
`/api/ws` and `/api/pty` with dashboard cookies plus a single-use ticket from
|
||||
`/api/auth/ws-ticket`. API-key chat remains the fallback until Android's native
|
||||
dashboard-gateway chat adapter is wired in.
|
||||
`/api/auth/ws-ticket`. Android uses that gateway path when it is ready and falls
|
||||
back to API-server SSE when it is not.
|
||||
:::
|
||||
|
||||
::: details Manual connection setup (no QR)
|
||||
@@ -445,13 +467,25 @@ if it's API-only, Android saves the standard API/dashboard connection.
|
||||
:::
|
||||
|
||||
::: details Uninstall the Relay plugin
|
||||
If you used the upstream plugin manager:
|
||||
|
||||
```bash
|
||||
hermes relay compat remove --all # optional; only removes legacy compat hooks
|
||||
hermes plugins remove hermes-relay
|
||||
```
|
||||
|
||||
If you used the legacy installer:
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
# or, if the clone is already gone:
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
|
||||
```
|
||||
Idempotent, and never touches state shared with other Hermes tools. Flags:
|
||||
`--dry-run`, `--keep-clone`, `--remove-secret`.
|
||||
It removes the legacy systemd service, shell shims, editable package, external
|
||||
skill path, clone, and compat hook. It is idempotent and never touches state
|
||||
shared with other Hermes tools. Flags: `--dry-run`, `--keep-clone`,
|
||||
`--remove-secret`. For agent-assisted cleanup, use the
|
||||
[Agent Cleanup Prompt](/reference/agent-cleanup-prompt).
|
||||
:::
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -19,6 +19,13 @@ See [Installation & Setup](/guide/getting-started) for copy/paste host commands
|
||||
|
||||
If you installed the optional Relay plugin and want to uninstall it later:
|
||||
|
||||
```bash
|
||||
hermes relay compat remove --all # optional legacy compatibility hook cleanup
|
||||
hermes plugins remove hermes-relay
|
||||
```
|
||||
|
||||
If you used the legacy installer instead:
|
||||
|
||||
```bash
|
||||
bash ~/.hermes/hermes-relay/uninstall.sh
|
||||
```
|
||||
@@ -34,11 +41,16 @@ The uninstaller is idempotent and never touches state shared with other Hermes t
|
||||
## Connection Model
|
||||
|
||||
```
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat — direct]
|
||||
Phone (WSS/HTTP) → Relay Server (:8767) [Bridge Core, terminal, TUI, media, voice]
|
||||
Phone (WS) → Hermes dashboard (:9119) [gateway chat with live thinking]
|
||||
Phone (HTTP/SSE) → Hermes API Server (:8642) [chat fallback, sessions, runs]
|
||||
Phone (HTTP) → Hermes dashboard (:9119) [Manage + standard voice]
|
||||
Phone (WSS/HTTP) → Relay Server (:8767) [Bridge Core, terminal, TUI, media, relay voice]
|
||||
```
|
||||
|
||||
Chat connects directly to the Hermes API Server using the Sessions API with SSE streaming. The relay server handles Bridge Core, terminal, TUI, media, notification companion, sessions, and `/voice/*` routes. Sideload builds additionally expose Android Device Control routes.
|
||||
Chat prefers the dashboard gateway when Manage auth is ready and falls back to
|
||||
the Hermes API Server's SSE routes. The relay server handles Bridge Core,
|
||||
terminal, TUI, media, notification companion, relay sessions, and relay-backed
|
||||
voice routes. Sideload builds additionally expose Android Device Control routes.
|
||||
|
||||
## Current Status — v1.0.0
|
||||
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Cleanup Prompt
|
||||
|
||||
This prompt helps a local agent clean up older Hermes-Relay install artifacts
|
||||
after moving to the upstream plugin-manager path.
|
||||
|
||||
Use it when you want to remove legacy service/shim/bootstrap pieces without
|
||||
breaking standard Android chat, Manage, or dashboard voice.
|
||||
|
||||
```text
|
||||
Goal: Audit and clean Hermes-Relay legacy install artifacts while preserving the
|
||||
standard upstream Hermes path. Standard Android chat, Manage, and dashboard voice
|
||||
must continue to work against unmodified hermes-agent. Relay power features
|
||||
should remain available only through the Hermes-Relay plugin if the operator
|
||||
wants them.
|
||||
|
||||
Repository or host context:
|
||||
- Hermes-Relay plugin-manager install id: Codename-11/hermes-relay/plugin
|
||||
- Legacy clone path: ~/.hermes/hermes-relay
|
||||
- Shared Hermes state that must be preserved unless explicitly approved:
|
||||
~/.hermes/.env, ~/.hermes/state.db, the hermes-agent install, and
|
||||
~/.hermes/hermes-relay-qr-secret
|
||||
|
||||
Rules:
|
||||
1. Do not delete shared Hermes state.
|
||||
2. Do not patch or fork upstream hermes-agent for the standard path.
|
||||
3. Treat the optional hermes_relay_bootstrap.pth hook as legacy compatibility
|
||||
only. It is not required for standard chat, Manage, or dashboard voice.
|
||||
4. Prefer plugin-owned commands over manual file deletion when available.
|
||||
5. Use dry-run output before removing legacy installer artifacts.
|
||||
|
||||
Inspect:
|
||||
1. Run `hermes relay doctor --json` if the command exists.
|
||||
2. Run `hermes relay compat status --json` if the command exists.
|
||||
3. Check for legacy shell shims:
|
||||
`hermes-pair`, `hermes-status`, `hermes-relay`,
|
||||
`hermes-relay-update`, `hermes-relay-tailscale`.
|
||||
4. Check for the legacy clone at `~/.hermes/hermes-relay`.
|
||||
5. Check for a systemd user unit named `hermes-relay.service`.
|
||||
6. Check the Hermes Python environment for `hermes_relay_bootstrap.pth`.
|
||||
7. Check Hermes config for a `skills.external_dirs` entry pointing at the Relay
|
||||
clone.
|
||||
|
||||
Clean plugin-manager install only:
|
||||
1. If the optional compat hook is installed, run:
|
||||
`hermes relay compat remove --all`
|
||||
2. Remove the plugin-manager install only if the operator wants Relay removed:
|
||||
`hermes plugins remove hermes-relay`
|
||||
|
||||
Clean legacy installer artifacts:
|
||||
1. If `~/.hermes/hermes-relay/uninstall.sh` exists, run:
|
||||
`bash ~/.hermes/hermes-relay/uninstall.sh --dry-run`
|
||||
2. Review the planned removals. They should include the systemd user service,
|
||||
legacy shell shims, editable Python package, external skill path, optional
|
||||
compat hook, and clone unless `--keep-clone` is requested.
|
||||
3. Run:
|
||||
`bash ~/.hermes/hermes-relay/uninstall.sh`
|
||||
4. Use `--keep-clone` if the operator wants the git tree preserved.
|
||||
5. Use `--remove-secret` only when the operator explicitly wants the QR signing
|
||||
identity removed.
|
||||
|
||||
If the clone is already gone:
|
||||
1. Prefer `hermes relay compat remove --all` for compat hook cleanup.
|
||||
2. Remove any remaining Relay shell shims listed above.
|
||||
3. Remove only the Relay-owned `.pth` file, not unrelated site-packages files.
|
||||
4. Remove only the Relay `skills.external_dirs` entry, preserving other entries.
|
||||
5. Stop and remove only the `hermes-relay.service` user unit.
|
||||
|
||||
Reinstall Relay through the plugin manager when requested:
|
||||
1. Run `hermes plugins install Codename-11/hermes-relay/plugin --enable`.
|
||||
2. Run `hermes relay doctor`.
|
||||
3. Start Relay only if power features are wanted:
|
||||
`hermes relay start --no-ssl`
|
||||
4. Generate a QR or manual code:
|
||||
`hermes pair`
|
||||
or `hermes pair --register-code <code>`
|
||||
|
||||
Verify app flow:
|
||||
1. Save a standard API/dashboard connection in Android.
|
||||
2. Verify Chat works without Relay.
|
||||
3. Verify Manage signs in through the dashboard.
|
||||
4. Verify standard voice uses dashboard audio when available.
|
||||
5. Pair Relay by QR only for Terminal, Bridge, media, relay sessions, desktop
|
||||
tools, notification companion, and Relay voice extras.
|
||||
6. Confirm Terminal and Bridge stay gated when no Relay session is paired.
|
||||
|
||||
Report:
|
||||
- What was installed.
|
||||
- What legacy artifacts were removed.
|
||||
- What was intentionally preserved.
|
||||
- Whether the standard path was verified.
|
||||
- Whether Relay power features were reinstalled and paired.
|
||||
```
|
||||
@@ -18,7 +18,7 @@ Hermes-Relay voice endpoints can reuse this same Bearer token. Relay validates i
|
||||
|
||||
## How endpoints get served
|
||||
|
||||
Current upstream Hermes serves the session API natively on `/api/sessions/*`, advertises it through `/v1/capabilities`, and exposes read-only skill/toolset discovery through `/v1/skills` and `/v1/toolsets`. Installing Hermes-Relay via `install.sh` still adds a bootstrap compatibility hook for older hermes-agent builds and for remaining management surfaces that are not current API-server routes (`/api/memory`, legacy `/api/skills`, `/api/config`, `/api/available-models`).
|
||||
Current upstream Hermes serves the session API natively on `/api/sessions/*`, advertises it through `/v1/capabilities`, and exposes read-only skill/toolset discovery through `/v1/skills` and `/v1/toolsets`. Standard chat, Manage, and dashboard voice do not need the Hermes-Relay compatibility hook. If an older hermes-agent build still needs compatibility-only routes (`/api/memory`, legacy `/api/skills`, `/api/config`, `/api/available-models`) or slash-command middleware, manage the optional plugin-owned startup hook with `hermes relay compat status/install/remove`. Legacy `install.sh` installs a backward-compatible hook as part of its full-service setup.
|
||||
|
||||
**Chat streaming uses `Auto` by default.** The app probes `/v1/capabilities` first, then legacy route probes, and prefers native `/api/sessions/{id}/chat/stream` when available. Older builds fall back to `/v1/chat/completions` or `/v1/runs`; you can manually force `Sessions`, `Completions`, or `Runs` mode for debugging.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ These are configured during onboarding or from the **Settings → Connections**
|
||||
Hermes-Relay now treats connection auth as three related but separate contexts:
|
||||
|
||||
- **Dashboard sign-in** (`:9119`) — upstream-preferred remote identity for the standard dashboard/desktop path. Manage uses dashboard cookies; dashboard-gateway chat uses short-lived `/api/ws` tickets minted from those cookies. Android supports username/password and redirect providers such as Nous/OIDC for this surface.
|
||||
- **API connection** (`:8642`) — OpenAI-compatible chat, sessions, and portable API calls. If the Hermes API server is configured with `API_SERVER_KEY`, Android stores that bearer key and uses it for Chat while dashboard-gateway JSON-RPC chat is being wired in.
|
||||
- **API connection** (`:8642`) — OpenAI-compatible chat, sessions, and portable API calls. If the Hermes API server is configured with `API_SERVER_KEY`, Android stores that bearer key and uses it for API-server SSE fallback paths.
|
||||
- **Pairing** (`:8767`) — relay grants for Terminal, Bridge, relay sessions, media relay inspection, and profile memory file editing. Pairing is not required for standard dashboard/API use, but it is required for relay power tools.
|
||||
|
||||
The default bottom navigation is **Chat**, **Manage**, and **Settings**. Terminal and Bridge are still available from **Settings → Power tools** and deep links, but unpaired devices see a clear **Requires pairing** / **Pair to unlock** gate before those relay-only screens load.
|
||||
@@ -35,7 +35,7 @@ The **Manage** tab uses the dashboard session, not relay pairing. It covers Skil
|
||||
|---------|---------|-------------|
|
||||
| API Server URL | EncryptedSharedPreferences | Base URL of the Hermes API Server (e.g., `http://192.168.1.100:8642`) |
|
||||
| API Key | EncryptedSharedPreferences | Bearer token for API-server authentication. Used by Android Chat fallback, not by dashboard login. |
|
||||
| Dashboard URL | DataStore | Hermes dashboard/admin URL, conventionally the same host as the API server on port `9119`. Derived automatically from the API URL unless explicitly overridden. |
|
||||
| Dashboard URL | DataStore | Hermes dashboard/admin URL, conventionally the same host as the API server on port `9119`. Derived automatically from the API URL unless explicitly overridden or supplied as `dashboard_url` in a setup QR. |
|
||||
| Dashboard session cookies | EncryptedSharedPreferences | Auth cookies for the dashboard/admin server. Stored separately from API keys and relay session tokens. OAuth/NouS WebView sign-in imports these cookies into the native dashboard client. |
|
||||
| Relay URL | EncryptedSharedPreferences | WebSocket URL for the Relay Server (optional, for bridge/terminal) |
|
||||
| Relay Session Token | **Keystore** (StrongBox when available), with fallback to EncryptedSharedPreferences | Persistent token from relay pairing flow. Migrated automatically from the legacy EncryptedSharedPreferences file on first launch post-upgrade. |
|
||||
@@ -197,6 +197,32 @@ python -m plugin.relay --no-ssl
|
||||
|
||||
For Docker, systemd, and TLS setup, see [docs/relay-server.md](https://github.com/Codename-11/hermes-relay/blob/main/docs/relay-server.md).
|
||||
|
||||
### Compatibility Hook
|
||||
|
||||
The legacy `hermes_relay_bootstrap.pth` hook is optional. It fills route gaps for
|
||||
older Hermes builds and installs slash-command middleware, but modern standard
|
||||
chat, Manage, and dashboard voice do not depend on it.
|
||||
|
||||
```bash
|
||||
hermes relay compat status
|
||||
hermes relay compat install # only when an older server needs it
|
||||
hermes relay compat remove
|
||||
```
|
||||
|
||||
The compat command manages only the `.pth` startup hook in the target Python
|
||||
environment. New hooks load the plugin-owned bootstrap implementation from the
|
||||
installed Relay plugin; older hooks that import the top-level bootstrap shim are
|
||||
still removable. Legacy service units, shell shims, root package installs, and
|
||||
external skill-path entries are still cleaned up by `uninstall.sh` when they
|
||||
were created by the legacy installer.
|
||||
|
||||
Cleanup is intentionally split: `hermes plugins remove hermes-relay` removes a
|
||||
plugin-manager install, `hermes relay compat remove --all` removes optional
|
||||
compat hooks, and `bash ~/.hermes/hermes-relay/uninstall.sh` removes legacy
|
||||
installer artifacts such as the systemd user service, shell shims, editable
|
||||
Python package, external skill path, and clone. For a copy/paste cleanup handoff,
|
||||
see the [Agent Cleanup Prompt](/reference/agent-cleanup-prompt).
|
||||
|
||||
### Skills (`external_dirs`)
|
||||
|
||||
Hermes-Relay's `/hermes-relay-pair` slash command is implemented as a skill at `~/.hermes/hermes-relay/skills/devops/hermes-relay-pair/SKILL.md`. Rather than hand-copying it into `~/.hermes/skills/`, the installer registers the clone's `skills/` directory in your `~/.hermes/config.yaml`:
|
||||
|
||||
@@ -7,7 +7,8 @@ The relay server is a lightweight Python WSS/HTTP service that enables **termina
|
||||
| Feature | Relay required? | Auth path |
|
||||
|---------|-----------------|-----------|
|
||||
| Chat | No | Hermes API key direct to API server |
|
||||
| Voice Mode | Yes for TTS/STT endpoints; pairing optional when the API key is present | Hermes API bearer or relay session |
|
||||
| Standard Voice Mode | No | Dashboard session from Manage |
|
||||
| Relay voice extras | Yes for relay TTS/STT/realtime endpoints; pairing optional when the API key is present | Hermes API bearer or relay session |
|
||||
| Realtime Agent voice engine | Yes; experimental `/voice/realtime-agent/*` broker | Hermes API bearer or relay session with `voice:realtime` |
|
||||
| Inbound media (screenshots from tools) | Yes | Relay session |
|
||||
| Terminal | Yes | Relay session |
|
||||
@@ -49,6 +50,56 @@ python -m relay_server --no-ssl
|
||||
|
||||
Both entry points load `~/.hermes/.env` into the process environment **on import**, so API keys (`VOICE_TOOLS_OPENAI_KEY`, `ELEVENLABS_API_KEY`, etc.) are always present on start regardless of how the process was launched — no need to `source` anything first. This is the same pattern `hermes-gateway` uses, which is why neither unit needs an `EnvironmentFile=` directive. See [`docs/relay-server.md`](https://github.com/Codename-11/hermes-relay/blob/main/docs/relay-server.md#env-auto-loading) for the precedence rules.
|
||||
|
||||
### Plugin manager install
|
||||
|
||||
Current upstream Hermes can install the plugin tree directly:
|
||||
|
||||
```bash
|
||||
hermes plugins install Codename-11/hermes-relay/plugin --enable
|
||||
hermes relay doctor
|
||||
```
|
||||
|
||||
That path manages plugin code, CLI command registration, dashboard metadata, and
|
||||
agent tools. It does not install the systemd user service or shell shims. Use the
|
||||
legacy `install.sh` only when you want those host-level artifacts.
|
||||
|
||||
The optional compatibility startup hook is managed by the plugin:
|
||||
|
||||
```bash
|
||||
hermes relay compat status
|
||||
hermes relay compat install # older Hermes builds only
|
||||
hermes relay compat remove
|
||||
```
|
||||
|
||||
Standard chat, Manage, and dashboard voice do not require the compat hook.
|
||||
New compat installs load the bootstrap implementation from the installed plugin
|
||||
tree. Existing legacy hooks remain visible in `hermes relay compat status` and
|
||||
can be removed with `hermes relay compat remove`.
|
||||
|
||||
Pairing/setup QRs can include top-level `dashboard_url`; Android uses it for
|
||||
Manage and standard dashboard voice instead of deriving same-host `:9119`.
|
||||
|
||||
### Legacy cleanup ownership
|
||||
|
||||
| Artifact | Remove with |
|
||||
|----------|-------------|
|
||||
| Plugin-manager install | `hermes plugins remove hermes-relay` |
|
||||
| Optional compat hook | `hermes relay compat remove --all` |
|
||||
| Legacy systemd service | `bash ~/.hermes/hermes-relay/uninstall.sh` |
|
||||
| Legacy shell shims | `bash ~/.hermes/hermes-relay/uninstall.sh` |
|
||||
| Legacy editable Python package | `bash ~/.hermes/hermes-relay/uninstall.sh` |
|
||||
| Legacy `skills.external_dirs` entry | `bash ~/.hermes/hermes-relay/uninstall.sh` |
|
||||
| Legacy clone | `bash ~/.hermes/hermes-relay/uninstall.sh` unless `--keep-clone` is used |
|
||||
|
||||
The legacy uninstaller delegates compat hook removal to
|
||||
`hermes relay compat remove` when that command is available, then falls back to
|
||||
removing only the Relay `.pth` file. It preserves `~/.hermes/.env`,
|
||||
`~/.hermes/state.db`, the Hermes agent install, and the QR signing secret unless
|
||||
`--remove-secret` is passed.
|
||||
|
||||
For agent-assisted cleanup, use the bounded
|
||||
[Agent Cleanup Prompt](/reference/agent-cleanup-prompt).
|
||||
|
||||
## Deployment Options
|
||||
|
||||
### Docker
|
||||
@@ -125,6 +176,9 @@ hermes relay start [OPTIONS] (or: python -m plugin.relay)
|
||||
hermes relay insecure-api-key [status|on|off]
|
||||
hermes-relay insecure-api-key [status|on|off]
|
||||
Toggle plain-LAN API-key voice auth on the running relay
|
||||
|
||||
hermes relay compat [status|install|remove]
|
||||
Manage the optional legacy API compatibility startup hook
|
||||
```
|
||||
|
||||
## HTTP Routes
|
||||
@@ -135,7 +189,7 @@ hermes-relay insecure-api-key [status|on|off]
|
||||
| `/health` | GET | `{status, version, clients, sessions}` JSON |
|
||||
| `/pairing` | POST | Generate a new relay-side pairing code |
|
||||
| `/pairing/register` | POST | **Loopback only.** Pre-register an externally-provided pairing code so it can be embedded in a QR payload. Optional body fields `ttl_seconds` / `grants` / `transport_hint` attach pairing metadata that applies to the session when the phone consumes the code — operator policy wins over phone-sent values. Also **clears all rate-limit blocks on success** so legitimate re-pair after a relay restart works immediately. Used by `hermes pair` / `/hermes-relay-pair` on the same host; `hermes-pair` remains a compatibility shim. Rejects non-loopback peers with HTTP 403. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes pair` when not supplied explicitly. |
|
||||
| `/pairing/mint` | POST | **Loopback only.** Mint a fresh pairing code and return the signed QR payload plus `pairing_url` (`hermes-relay://pair?payload=...`) used by dashboard, desktop GUI, and CLI pair/repair flows. Reads the API key from the same host-local config chain as `hermes pair` when not supplied explicitly. Optional request field `dashboard_url` is mirrored into the QR payload and response. |
|
||||
| `/pairing/approve` | POST | **Loopback only, reserved for future use.** Same wire shape as `/pairing/register`. Placeholder for a future phone-generates-code / host-approves flow that would complement the existing QR pairing direction. |
|
||||
| `/sessions` | GET | Bearer-auth'd (same token the WSS channel uses). Returns all active paired devices with metadata — device name, token prefix (first 8 chars, full token never exposed), created/last-seen timestamps, session expiry, per-channel grants, transport hint, and `is_current` for the device matching the bearer. `math.inf` expiries serialize as `null` (never expire). |
|
||||
| `/sessions/{token_prefix}` | DELETE | Bearer-auth'd. Revoke a paired device by token-prefix (≥ 4 chars). 200 on exact match, 404 on zero, 409 on ambiguous matches. Self-revoke is allowed and flagged via `revoked_self: true`. |
|
||||
@@ -233,6 +287,7 @@ curl http://localhost:8767/health
|
||||
## Troubleshooting
|
||||
|
||||
- **Connection refused** — Is the relay running? `systemctl --user status hermes-relay` (installed via `install.sh`) or `docker logs hermes-relay` (container) or `pgrep -af "python -m plugin.relay"` (manual launch).
|
||||
- **Unsure what is installed** — Run `hermes relay doctor --json` for a full route/plugin/compat report, or `hermes relay compat status` for only the legacy hook.
|
||||
- **Voice endpoints 500 with "no API key available"** — The relay process doesn't have the right keys. The Python bootstrap loads `~/.hermes/.env` automatically, so this almost always means the key just isn't in `.env` yet. Double-check with `grep VOICE_TOOLS_OPENAI_KEY ~/.hermes/.env` (for STT) or `grep ELEVENLABS_API_KEY ~/.hermes/.env` (for TTS). If you just edited `.env`, restart the service so Python re-imports: `systemctl --user restart hermes-relay`.
|
||||
- **Service starts but port bind fails** — Check for an orphan manual launch: `pgrep -f "python -m plugin.relay"`. Kill it with `pkill -f "python -m plugin.relay"` then `systemctl --user restart hermes-relay`.
|
||||
- **Auth failure** — Pairing codes expire 10 minutes after registration and are one-shot. Re-run `hermes pair` (or `/hermes-relay-pair`) to mint a fresh code and get a new QR.
|
||||
|
||||
Reference in New Issue
Block a user