feat(plugin): runtime API bootstrap + capability auto-detect + canonical uninstaller

Adds hermes_relay_bootstrap/ — a Python package shipped via .pth in the
hermes-agent venv site-packages — that monkey-patches aiohttp.web.Application
at interpreter startup. When the gateway builds its app, the bootstrap
intercepts app["api_server_adapter"] = self and injects 14 management
handlers onto the same router: /api/sessions/* CRUD, /api/memory,
/api/skills, /api/config, /api/available-models. Feature-detects on route
path and no-ops cleanly when these routes are already present, so it's
safe to ship across all hermes-agent versions.

Chat streaming continues to use standard upstream /v1/runs (which already
emits structured tool events). The Android client gains a new
ServerCapabilities probe + streamingEndpoint = "auto" default that picks
the best chat path automatically based on what each server actually
exposes (Auto/Sessions/Runs).

Also adds canonical uninstall.sh — reverses every install.sh step in the
opposite order, idempotent, never touches state shared with other Hermes
tools (.env, sessions DB, hermes-agent venv core). Flags: --dry-run,
--keep-clone, --remove-secret. install.sh header + success summary +
README + user-docs/guide/getting-started.md + user-docs/reference/api.md
all updated to mention the uninstall path.

Verified end-to-end on the server: bootstrap loads via .pth, intercepts
aiohttp.web import, injects 11 unique paths onto a vanilla aiohttp app,
GET /api/sessions returns real production data via SessionDB, OPTIONS
probes return the expected 405/404 codes for capability detection, and
feature detection no-ops cleanly when routes already exist.

See docs/decisions.md ADR 16 for full rationale and DEVLOG.md
2026-04-12 entries for the work breakdown.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bailey Dixon
2026-04-12 17:42:58 -04:00
co-authored by Claude Opus 4.6
parent 44732f5502
commit b6db95caf9
18 changed files with 1646 additions and 55 deletions
+26 -10
View File
@@ -33,16 +33,26 @@ Chat goes directly to the API server via HTTP/SSE. The API key (Bearer token) is
| `GET /health` | Health check | — |
| `GET/POST/PATCH/DELETE /api/jobs/*` | Cron job management | — |
**Non-standard endpoints (may be version-specific):**
**Non-standard endpoints (provided by fork OR by plugin bootstrap):**
These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream source**. They may be fork-specific, version-specific, or added by plugins. Always use `detectChatMode()` to probe availability.
These endpoints are not in stock upstream `gateway/platforms/api_server.py`. There are three ways a hermes-agent install can serve them:
| Endpoint | Purpose | Fallback |
|----------|---------|----------|
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Use `/v1/runs` or `/v1/chat/completions` |
| `GET/POST/PATCH/DELETE /api/sessions` | Session CRUD | Use `X-Hermes-Session-Id` header with `/v1/chat/completions` |
| `GET /api/skills` | Skill discovery | Hardcoded command list |
| `GET /api/config` | Server config (personalities, model) | No fallback — personality picker empty |
1. **Codename-11 fork** (`feat/api-server-enhancements` branch, currently merged into `axiom`) — adds them natively in `gateway/platforms/api_server.py`. Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556).
2. **Bootstrap injection** (`hermes_relay_bootstrap/`) — installed alongside the plugin via `install.sh`, runs at Python interpreter startup (via a `.pth` file in the venv's site-packages), monkey-patches `aiohttp.web.Application` so that when `APIServerAdapter.connect()` builds its app, our extra routes are added to the same router. **Vanilla upstream + plugin = these endpoints work too.** The bootstrap deliberately does NOT inject `/api/sessions/{id}/chat/stream` — chat goes through standard `/v1/runs` instead, which has live tool events and avoids touching `_create_agent` / `run_conversation` internals.
3. **Upstream-merged** (post PR #8556) — same paths, native upstream support. The bootstrap feature-detects on route paths and no-ops in this case.
| Endpoint | Purpose | Provided by |
|----------|---------|-------------|
| `GET /api/sessions` (CRUD) | Session list/create/rename/delete/fork | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/{id}/messages` | Conversation history | Fork OR bootstrap OR upstream-merged |
| `GET /api/sessions/search` | Full-text message search | Fork OR bootstrap OR upstream-merged |
| `POST /api/sessions/{id}/chat/stream` | Session-based SSE chat | Fork OR upstream-merged ONLY (NOT bootstrap — use `/v1/runs`) |
| `GET /api/config`, `PATCH /api/config` | Personalities + model config | Fork OR bootstrap OR upstream-merged |
| `GET /api/skills`, `/categories`, `/{name}` | Skill discovery | Fork OR bootstrap OR upstream-merged |
| `GET/POST/PATCH/DELETE /api/memory` | Memory CRUD | Fork OR bootstrap OR upstream-merged |
| `GET /api/available-models` | Provider model list | Fork OR bootstrap OR upstream-merged |
The Android client probes per-endpoint capability via `HermesApiClient.probeCapabilities()` (returns `ServerCapabilities`). When `streamingEndpoint = "auto"` (the default for new installs), `ConnectionViewModel.resolveStreamingEndpoint()` reads the capability snapshot and picks `sessions` (when the chat-stream handler is present) or `runs` (otherwise). Users can still force `sessions` or `runs` manually in Settings → Chat → Streaming endpoint.
**Tool call rendering paths:**
1. **Runs API** (`/v1/runs`) — Best for tool display. Emits `tool.started`/`tool.completed` as real SSE events → rendered as ToolProgressCards in real-time.
@@ -50,9 +60,10 @@ These endpoints work on our hermes-agent v0.7.0 but are **not in the upstream so
3. **Annotation parser** (`ChatHandler.parseAnnotationLine` + `finalizeAnnotations`) — Fallback for servers that inject inline markdown annotations (`` `💻 terminal` ``). Parses during streaming + reconciliation pass on stream end. If your Hermes version uses a different format, check `adb logcat -s HermesApiClient` for raw SSE events and update the regex.
## Key Instructions
- **Always verify upstream before assuming an endpoint exists.** Check `gateway/platforms/api_server.py` in hermes-agent. If an endpoint isn't there, document it as non-standard and implement a fallback.
- **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 the bootstrap injects it (`hermes_relay_bootstrap/_handlers.py`) or if it requires the fork.
- When building features that interface with hermes-agent, reference the upstream source — not just our spec docs. Our spec may be aspirational or based on a specific server version.
- If we use a non-standard endpoint, mark it clearly in code comments and ensure `detectChatMode()` handles its absence gracefully.
- If we use a non-standard endpoint, ensure `probeCapabilities()` covers it and the auto-resolver in `ConnectionViewModel.resolveStreamingEndpoint()` (or equivalent) degrades gracefully.
- **Bootstrap maintenance:** When upstream PR #8556 merges and reaches a released hermes-agent version, the entire `hermes_relay_bootstrap/` package and its `.pth` file in `install.sh` can be deleted. The bootstrap is no-op-compatible with both fork and upstream-merged installs (feature detection by route path), so leaving it in place during the rollout window is harmless.
## Repository Layout
@@ -156,6 +167,11 @@ hermes-android/ ← Android Studio opens this root
| `app/src/main/res/drawable/splash_icon.xml` | Splash screen icon (0.9x scale) |
| `app/src/main/res/drawable/splash_icon_animated.xml` | Animated splash (scale + overshoot + fade) |
| `plugin/relay/server.py` | Canonical relay server — WSS + HTTP routes (health, /pairing, /pairing/register) |
| `hermes_relay_bootstrap/` | Runtime patch package for vanilla upstream hermes-agent. Loaded via `hermes_relay_bootstrap.pth` in the venv site-packages (dropped by `install.sh` step 2). `__init__.py` installs a `sys.meta_path` finder; `_patch.py` swaps `aiohttp.web.Application` for a subclass that intercepts `app["api_server_adapter"] = self` and triggers `_handlers.register_routes()`; `_handlers.py` ports ~14 management handlers (sessions CRUD, memory, skills, config, available-models) from the fork. Feature-detects on route paths so it no-ops on fork or upstream-merged installs. Removable in one PR once PR #8556 lands. |
| `hermes_relay_bootstrap.pth` | Single-line `.pth` file at repo root: `import hermes_relay_bootstrap`. `install.sh` copies this into the hermes-agent venv's `site-packages/` so Python's `site` module loads the bootstrap at every interpreter startup. NOT installed automatically by `pip install -e` — setuptools' data-files doesn't ship to site-packages reliably for editable installs. |
| `uninstall.sh` | Canonical uninstaller — reverses every `install.sh` step in opposite order: stops + disables systemd unit, removes shim, removes skills external_dirs entry from config.yaml (preserves other entries), removes plugin symlink + legacy stales, removes bootstrap `.pth` from site-packages, `pip uninstall hermes-relay`, removes the clone (unless `--keep-clone`). Idempotent. Never touches `~/.hermes/.env`, `state.db`, or the hermes-agent venv core. Flags: `--dry-run`, `--keep-clone`, `--remove-secret` (the last preserves QR signing identity by default). |
| `app/src/main/kotlin/.../network/HermesApiClient.kt` (capability detection) | `data class ServerCapabilities(sessionsApi, sessionsChatStream, runs, portable, healthy)` + `suspend fun probeCapabilities()`. Uses OPTIONS-method probes against `/api/sessions/probe/chat/stream` and `/v1/runs` to distinguish "endpoint exists, just POST-only" (405) from "endpoint missing" (404). The result drives `streamingEndpoint = "auto"` resolution. |
| `app/src/main/kotlin/.../viewmodel/ConnectionViewModel.kt` (auto-resolver) | `resolveStreamingEndpoint(preference)` collapses `"auto"` to a concrete `"sessions"` or `"runs"` based on the latest `serverCapabilities` snapshot. Manual `"sessions"` / `"runs"` settings pass through unchanged. |
| `plugin/relay/auth.py` | PairingManager (generate + register_code), SessionManager, RateLimiter |
| `plugin/relay/config.py` | RelayConfig + PAIRING_ALPHABET (full A-Z / 0-9 as of 2026-04-11) |
| `plugin/relay/channels/terminal.py` | Phase 2 PTY-backed terminal handler |
+53
View File
@@ -1,5 +1,58 @@
# Hermes-Relay — Dev Log
## 2026-04-12 — Add canonical uninstall.sh + bootstrap docs
Companion to the bootstrap injection work below. There was no formal uninstall path before — `install.sh` is idempotent so most update flows worked, but cleanly removing the plugin (e.g., to test that install.sh works on a truly fresh state) required manually undoing 6 install steps. New `uninstall.sh` reverses them in opposite order:
1. Stops + disables `hermes-relay.service`, removes the systemd unit, daemon-reloads
2. Removes `~/.local/bin/hermes-pair` shim
3. Scrubs the relay's `skills.external_dirs` entry from `~/.hermes/config.yaml` via the same yaml parsing pattern install.sh uses, with a `.bak` backup before write — preserves all other entries
4. Removes `~/.hermes/plugins/hermes-relay` symlink + any legacy stales (`hermes-android`, etc.)
5. Removes `hermes_relay_bootstrap.pth` from venv site-packages, `pip uninstall hermes-relay`
6. Removes `~/.hermes/hermes-relay` clone (sanity-checked: refuses to delete a directory that doesn't have `.git` + `install.sh`)
What it never touches: `~/.hermes/.env` (other tools authenticate against this), `~/.hermes/state.db` (sessions DB shared with the gateway), `~/.hermes/hermes-agent/` (the agent itself), `~/.hermes/hermes-agent/venv/` (only our `.pth` is removed, not the venv core), and `~/.hermes/hermes-relay-qr-secret` (kept by default — the QR signing identity is precious; opt in to wipe with `--remove-secret`).
Flags: `--dry-run` previews without changing anything, `--keep-clone` leaves the git tree in place, `--remove-secret` wipes the QR secret. Help text: `bash uninstall.sh --help`.
`install.sh` header docs updated to mention `bootstrap injection` (step 2) and the uninstall path. Success summary now prints both `git pull && bash install.sh` for updates and `bash uninstall.sh --dry-run` for previewing removal. README.md and `user-docs/guide/getting-started.md` got equivalent updates with the bootstrap explanation + uninstall flags.
## 2026-04-12 — Bootstrap injection: vanilla upstream hermes-agent now works with the plugin
Closed the "you must run our hermes-agent fork to get full features" gap. The Codename-11 fork (`feat/api-server-enhancements`, 13 commits, submitted as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556)) adds 20 management endpoints — `/api/sessions/*` CRUD, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`, `/api/sessions/{id}/chat/stream` — that the Android app depends on. Until #8556 merges and reaches a release, vanilla upstream users were missing the sessions browser, conversation-history-on-restart, personality picker, command palette, and memory management.
**The fix: a single `.pth` file that runs at Python interpreter startup.** New `hermes_relay_bootstrap/` package ships with the plugin, gets loaded by Python's `site` module before anything in hermes-agent imports `aiohttp.web`. The bootstrap installs a `sys.meta_path` finder that wraps the loader for `aiohttp.web`. When the import resolves, our wrapper replaces `web.Application` with a thin subclass. The subclass overrides `__setitem__` to detect `app["api_server_adapter"] = self` — the line at `gateway/platforms/api_server.py:1735` where the gateway gives us a reference to the adapter while the router is still mutable. At that moment we feature-detect by route path and bind ~14 management handlers from `_handlers.py` directly onto the same router. The gateway then continues with its own route registrations and starts the server. From the outside, vanilla upstream now serves all the fork's management endpoints.
**What is NOT injected and why:** the chat-stream handler (`/api/sessions/{id}/chat/stream`). It depends on `_create_agent` and `agent.run_conversation` with multimodal content — the riskiest cross-cutting upstream methods that the fork may have implicitly modified. Instead, chat goes through standard upstream `/v1/runs`, which already emits structured `tool.started`/`tool.completed` SSE events. This is arguably an upgrade — `/v1/runs` has live tool events whereas the sessions chat-stream path required a post-stream message-history reload to render tool cards. The Android client adapts via a new `streamingEndpoint = "auto"` mode (default for new installs).
**Files added:**
- `hermes_relay_bootstrap/__init__.py` (~30 lines) — installs the meta_path finder
- `hermes_relay_bootstrap/_patch.py` (~170 lines) — `_AioHttpWebFinder`, `_PatchingLoader`, `_PatchedApplication`, `_maybe_register_routes` with feature detection by route path
- `hermes_relay_bootstrap/_handlers.py` (~500 lines) — 14 ported management handlers + helpers (sessions CRUD, memory CRUD, skills, config, available-models). Handlers take `adapter` as a closure parameter rather than being bound methods, so we don't pollute upstream's class.
- `hermes_relay_bootstrap.pth` — single line: `import hermes_relay_bootstrap`
**Files changed:**
- `pyproject.toml` — added `hermes_relay_bootstrap*` to packages.find include list
- `install.sh` step 2 — copies the `.pth` into the venv's `site-packages/` after `pip install -e`. Verified empirically: setuptools' editable install does NOT ship `data-files` to site-packages reliably (it puts them in `venv/data/` instead, where Python's `site` module never looks). Manual copy is necessary.
- `app/src/main/kotlin/.../HermesApiClient.kt` — new `ServerCapabilities` data class + `probeCapabilities()` method that returns per-endpoint presence. Uses OPTIONS-method probes against `/api/sessions/probe/chat/stream` and `/v1/runs` to distinguish "endpoint exists, just POST-only" (405) from "endpoint missing" (404). `detectChatMode()` becomes a thin compatibility wrapper around `probeCapabilities().toChatMode()`.
- `app/src/main/kotlin/.../ConnectionViewModel.kt` — exposes `serverCapabilities: StateFlow<ServerCapabilities>`, populates it from `probeCapabilities()` in `rebuildApiClient()`, adds `resolveStreamingEndpoint(preference)` helper that collapses `"auto"` to a concrete `"sessions"` or `"runs"` based on the latest snapshot. Default endpoint preference for new installs flipped from `"sessions"` to `"auto"`.
- `app/src/main/kotlin/.../ChatViewModel.kt` — `streamingEndpoint` default flipped from `"sessions"` to `"runs"` (the safer fallback before RelayApp pushes the resolved value).
- `app/src/main/kotlin/.../ui/RelayApp.kt` — `LaunchedEffect(streamingEndpoint, serverCapabilities)` recomputes the resolved endpoint when either changes, pushes into ChatViewModel.
- `app/src/main/kotlin/.../ui/screens/ChatSettingsScreen.kt` — Settings → Streaming endpoint dropdown gains an "Auto" option (alongside Sessions/Runs). Helper text dynamically shows which path Auto is currently using.
- `CLAUDE.md` — non-standard endpoints table rewritten to show all three "provided by" mechanisms (fork, bootstrap, upstream-merged), plus key files entries for bootstrap + capability detection.
- `docs/decisions.md` — new ADR 16 covering the runtime injection rationale, options considered (A/B/C/D), risks accepted, removal path.
- `vault/Hermes-Relay.md` — new "Bootstrap Injection Architecture" section with the compatibility matrix; updated "Hermes Integration" table to show two install paths (fork or bootstrap).
**Compatibility matrix** (all three combinations safe to ship the bootstrap with):
| Gateway version | Bootstrap behavior | Result |
|---|---|---|
| Codename-11 fork (`axiom`) | Detects existing `/api/sessions`, no-ops | Fork serves everything natively ✓ |
| Vanilla upstream main | Detects no `/api/sessions`, injects routes | Bootstrap-injected endpoints serve ✓ |
| Post-PR-#8556 upstream-merged | Detects existing `/api/sessions`, no-ops | Upstream serves everything natively ✓ |
**Removal path** (when PR #8556 reaches a released hermes-agent version): delete `hermes_relay_bootstrap/`, delete `hermes_relay_bootstrap.pth`, remove the `.pth` drop block from `install.sh`. The Android client `probeCapabilities()` + `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
## 2026-04-12 — Fix: TTS waveform stays alive through multi-sentence playback
The waveform was flatlinining after the first sentence while audio kept playing. Root cause: `maybeAutoResume()` fired after every sentence in the TTS consumer loop. The SSE stream finishes before TTS plays all queued sentences, so `streamObserverJob?.isActive` was already false → state flipped to Idle → amplitude bridge stopped → waveform died. Fix: restructured TTS consumer from `for` loop to `while` + `tryReceive` peek. `maybeAutoResume` only fires when the queue is actually drained (`tryReceive` returns failure), not between sentences. Between sentences within the same response, `tryReceive` succeeds immediately and the consumer skips the Idle transition. Additionally, the consumer re-asserts Speaking state before each synthesis call to handle the edge case where the queue was briefly empty between observer pushes.
+4 -2
View File
@@ -61,14 +61,16 @@ On the machine running your Hermes agent:
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash
```
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, and drops a thin `hermes-pair` shim into `~/.local/bin/`. After restart, pair your phone via either of these equivalent entry points:
The installer clones Hermes-Relay to `~/.hermes/hermes-relay/` (override with `$HERMES_RELAY_HOME`), `pip install -e`s the package into the hermes-agent venv, registers the `skills/` directory in your `~/.hermes/config.yaml` under `skills.external_dirs` (so updates flow through `git pull`), symlinks the plugin into `~/.hermes/plugins/hermes-relay`, drops a thin `hermes-pair` shim into `~/.local/bin/`, and (optionally) installs a systemd user service for the WSS relay. After restart, pair your phone via either of these equivalent entry points:
- **From any Hermes chat surface** (CLI, Discord, Telegram, etc.): type `/hermes-relay-pair` and the `hermes-relay-pair` skill renders the QR inline. Shortest path if you're already chatting with the agent.
- **From a shell**: `hermes-pair` (dashed) — a thin wrapper around `python -m plugin.pair` in the hermes-agent venv. Use this in scripts or when you want the raw output.
Scan the QR from the Android app's onboarding screen and you're connected. One scan configures **both** the direct-chat API server **and** the WSS relay (for terminal/bridge) — if a local relay is running at `localhost:8767`, the pair command pre-registers a fresh 6-char pairing code with it and embeds the relay URL + code in the same QR. If you only want direct chat, pass `--no-relay` (or just don't start the relay). Plain-text connection details are always printed alongside the QR so you can copy values by hand if your terminal can't render QR blocks.
**Updating:** `cd ~/.hermes/hermes-relay && git pull` — pulls new plugin, skill, and docs in one step. Because the installer uses `pip install -e` and `external_dirs`, nothing needs to be re-copied; restart hermes-agent and the updated skill + plugin are picked up on next load.
**Updating:** `cd ~/.hermes/hermes-relay && git pull && bash install.sh` — pulls new code and re-runs the installer (idempotent). Restart `hermes-gateway` and `hermes-relay` to pick up changes. For routine plugin/skill updates a plain `git pull` is enough.
**Uninstalling:** `bash ~/.hermes/hermes-relay/uninstall.sh` reverses every install step in the opposite order. Idempotent, never touches state shared with other Hermes tools (`.env`, sessions DB, hermes-agent venv core). Flags: `--dry-run`, `--keep-clone`, `--remove-secret`. Or pull the script via curl if you've already removed the clone.
**Requirements:** Android 8.0+ (SDK 26), [hermes-agent](https://github.com/NousResearch/hermes-agent) v0.8.0+, Python 3.11+.
@@ -50,6 +50,53 @@ enum class ChatMode {
DISCONNECTED
}
/**
* Per-endpoint capability snapshot. Populated by [HermesApiClient.probeCapabilities].
*
* The Android client uses this to pick the best chat path automatically when
* `streamingEndpoint = "auto"`. The bootstrap-injected vanilla-upstream case
* is the interesting one: `sessionsApi=true` (we injected it) but
* `sessionsChatStream=false` (we deliberately didn't inject the chat
* handler — runs is better). The auto-resolver picks `runs` for chat in that
* case while still using sessions endpoints for browse/rename/delete.
*/
data class ServerCapabilities(
/** `/api/sessions` (CRUD) — true on fork, upstream-merged, OR bootstrap-injected. */
val sessionsApi: Boolean,
/** `/api/sessions/{id}/chat/stream` (SSE) — true ONLY on fork or upstream-merged. */
val sessionsChatStream: Boolean,
/** `/v1/runs` (structured-event SSE) — standard upstream chat path. */
val runs: Boolean,
/** `/v1/chat/completions` — OpenAI-compatible fallback. */
val portable: Boolean,
/** `/health` — basic reachability. */
val healthy: Boolean,
) {
/** Resolve `streamingEndpoint = "auto"` to the best concrete choice. */
fun preferredChatEndpoint(): String = when {
sessionsChatStream -> "sessions"
runs -> "runs"
else -> "sessions" // last-resort: try sessions, will surface a clear error
}
fun toChatMode(): ChatMode = when {
!healthy -> ChatMode.DISCONNECTED
sessionsApi -> ChatMode.ENHANCED_HERMES
portable || runs -> ChatMode.PORTABLE
else -> ChatMode.DISCONNECTED
}
companion object {
val DISCONNECTED = ServerCapabilities(
sessionsApi = false,
sessionsChatStream = false,
runs = false,
portable = false,
healthy = false,
)
}
}
/**
* Direct HTTP/SSE client for the Hermes API Server.
*
@@ -770,37 +817,99 @@ class HermesApiClient(
/**
* Probe the server to determine which chat API is available.
* Checks /health, then /api/sessions (enhanced), then /v1/models (portable).
* Convenience wrapper around [probeCapabilities] that collapses the
* per-endpoint result into the older 3-state ChatMode enum for callers
* that don't need the detail.
*/
suspend fun detectChatMode(): ChatMode = withContext(Dispatchers.IO) {
// 1. Basic connectivity
try {
val healthReq = authRequest("$baseUrl/health").get().build()
client.newCall(healthReq).execute().use { response ->
if (!response.isSuccessful) return@withContext ChatMode.DISCONNECTED
suspend fun detectChatMode(): ChatMode = probeCapabilities().toChatMode()
/**
* Probe each endpoint we care about and return a per-route capability
* snapshot. This is the source of truth for "which chat path should we
* use" — see [ServerCapabilities.preferredChatEndpoint].
*
* Probe order:
* 1. `/health` — if this fails, everything else is moot.
* 2. `/api/sessions?limit=1` — sessions CRUD (true on fork OR
* bootstrap-injected vanilla upstream).
* 3. `OPTIONS /api/sessions/probe/chat/stream` — chat-stream handler
* presence. We use OPTIONS because the actual handler only accepts
* POST. aiohttp returns 405 (Method Not Allowed) when the route is
* registered but the method doesn't match, and 404 when the route
* doesn't exist at all. The 405 is the positive signal we want.
* 4. `OPTIONS /v1/runs` — runs endpoint presence (same 405 vs 404
* logic).
* 5. `/v1/models` — OpenAI-compat reachability.
*
* All probes are bearer-auth'd. A 401 still tells us "endpoint exists,
* just not authorised right now" — that counts as present for capability
* purposes, since the user will retry once auth is fixed.
*/
suspend fun probeCapabilities(): ServerCapabilities = withContext(Dispatchers.IO) {
// 1. Health
val healthy = try {
val req = authRequest("$baseUrl/health").get().build()
client.newCall(req).execute().use { it.isSuccessful }
} catch (_: Exception) {
false
}
if (!healthy) return@withContext ServerCapabilities.DISCONNECTED
// 2. Sessions CRUD
val sessionsApi = try {
val req = authRequest("$baseUrl/api/sessions?limit=1").get().build()
client.newCall(req).execute().use { response ->
// 200 = present + authed; 401 = present but not authed
response.code in setOf(200, 401)
}
} catch (_: Exception) {
return@withContext ChatMode.DISCONNECTED
false
}
// 2. Try enhanced sessions API
try {
val sessionsReq = authRequest("$baseUrl/api/sessions?limit=1").get().build()
client.newCall(sessionsReq).execute().use { response ->
if (response.isSuccessful) return@withContext ChatMode.ENHANCED_HERMES
// 3. Sessions chat stream — OPTIONS probe so we don't kick off a real
// chat. aiohttp returns 405 when the path is registered but the
// method isn't allowed (POST-only), and 404 when the path is
// unknown. 405 = endpoint exists.
val sessionsChatStream = try {
val req = authRequest("$baseUrl/api/sessions/probe/chat/stream")
.method("OPTIONS", null)
.build()
client.newCall(req).execute().use { response ->
response.code in setOf(200, 401, 405)
}
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
false
}
// 3. Try OpenAI-compatible models endpoint
try {
val modelsReq = authRequest("$baseUrl/v1/models").get().build()
client.newCall(modelsReq).execute().use { response ->
if (response.isSuccessful) return@withContext ChatMode.PORTABLE
// 4. /v1/runs — same OPTIONS-probe trick.
val runs = try {
val req = authRequest("$baseUrl/v1/runs")
.method("OPTIONS", null)
.build()
client.newCall(req).execute().use { response ->
response.code in setOf(200, 401, 405)
}
} catch (_: Exception) { /* fall through */ }
} catch (_: Exception) {
false
}
// Server is reachable but neither API is available
ChatMode.DISCONNECTED
// 5. /v1/models — OpenAI-compat reachability
val portable = try {
val req = authRequest("$baseUrl/v1/models").get().build()
client.newCall(req).execute().use { response ->
response.code in setOf(200, 401)
}
} catch (_: Exception) {
false
}
ServerCapabilities(
sessionsApi = sessionsApi,
sessionsChatStream = sessionsChatStream,
runs = runs,
portable = portable,
healthy = true,
)
}
// --- Lifecycle ---
@@ -232,10 +232,14 @@ fun RelayApp() {
connectionViewModel.chatHandler.parseToolAnnotations = parseAnnotations
}
// Sync streaming endpoint preference to chat
// Sync streaming endpoint preference to chat. Resolves "auto" against the
// current server capabilities so vanilla upstream + bootstrap-injected
// sessions API picks /v1/runs for chat (which has live tool events)
// while still using /api/sessions/* for browse/rename/delete.
val streamingEndpoint by connectionViewModel.streamingEndpoint.collectAsState()
LaunchedEffect(streamingEndpoint) {
chatViewModel.streamingEndpoint = streamingEndpoint
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
LaunchedEffect(streamingEndpoint, serverCapabilities) {
chatViewModel.streamingEndpoint = connectionViewModel.resolveStreamingEndpoint(streamingEndpoint)
}
// What's New auto-show
@@ -273,14 +273,28 @@ fun ChatSettingsScreen(
text = "Streaming endpoint",
style = MaterialTheme.typography.bodyMedium
)
val serverCaps by connectionViewModel.serverCapabilities.collectAsState()
val resolvedHelp = when (streamingEndpoint) {
"auto" -> {
val resolved = serverCaps.preferredChatEndpoint()
"Auto: picks the best path based on what your server exposes. " +
"Currently using: $resolved" +
if (!serverCaps.sessionsChatStream && serverCaps.sessionsApi)
" (sessions browse via /api/sessions, chat via /v1/runs)"
else ""
}
"sessions" -> "Sessions: tool calls shown as inline text annotations."
"runs" -> "Runs: structured tool events with real-time progress cards."
else -> ""
}
Text(
text = "Sessions: tool calls shown as inline text annotations. Runs: structured tool events with real-time progress cards.",
text = resolvedHelp,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
val endpointOptions = listOf("sessions", "runs")
val endpointLabels = listOf("Sessions", "Runs")
val endpointOptions = listOf("auto", "sessions", "runs")
val endpointLabels = listOf("Auto", "Sessions", "Runs")
val selectedEndpointIndex = endpointOptions.indexOf(streamingEndpoint).coerceAtLeast(0)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -125,8 +125,18 @@ class ChatViewModel : ViewModel() {
/** Whether to include the brief app context system message */
var appContextEnabled: Boolean = true
/** Streaming endpoint: "sessions" or "runs" */
var streamingEndpoint: String = "sessions"
/**
* Streaming endpoint to use for the next chat turn. Always one of
* "sessions" or "runs" — never "auto", since the auto-resolver in
* ConnectionViewModel.resolveStreamingEndpoint() collapses "auto" to
* a concrete value before this field is written from RelayApp.
*
* Defaults to "runs" so that a fresh ChatViewModel (before RelayApp
* pushes the resolved value) prefers the standard upstream chat path.
* That's the safer fallback than the previous "sessions" default,
* which would 404 on vanilla upstream installs.
*/
var streamingEndpoint: String = "runs"
fun selectPersonality(name: String) {
_selectedPersonality.value = name
@@ -24,6 +24,7 @@ import com.hermesandroid.relay.network.ChatMode
import com.hermesandroid.relay.network.ConnectionManager
import com.hermesandroid.relay.network.ConnectionState
import com.hermesandroid.relay.network.HermesApiClient
import com.hermesandroid.relay.network.ServerCapabilities
import com.hermesandroid.relay.network.RelayHttpClient
import com.hermesandroid.relay.network.handlers.ChatHandler
import com.hermesandroid.relay.util.MediaCacheWriter
@@ -170,6 +171,13 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
private val _chatMode = MutableStateFlow(ChatMode.DISCONNECTED)
val chatMode: StateFlow<ChatMode> = _chatMode.asStateFlow()
// Per-endpoint capability snapshot from the most recent probe. Used by
// ChatViewModel to resolve `streamingEndpoint = "auto"` to a concrete
// sessions/runs choice without round-tripping to the network on every
// send. Refreshed inside `rebuildApiClient()`.
private val _serverCapabilities = MutableStateFlow(ServerCapabilities.DISCONNECTED)
val serverCapabilities: StateFlow<ServerCapabilities> = _serverCapabilities.asStateFlow()
// Chat is ready when API client exists and server is reachable
val chatReady: StateFlow<Boolean> = combine(_apiClient, _apiServerReachable) { client, reachable ->
client != null && reachable
@@ -296,10 +304,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
// Streaming endpoint: "sessions" = /api/sessions/{id}/chat/stream, "runs" = /v1/runs
// Streaming endpoint preference. Three values:
// "auto" — pick based on per-endpoint capability detection (default
// for new installs as of v0.3.0). Resolves to "sessions"
// when the server has /api/sessions/{id}/chat/stream
// (fork or upstream-merged), otherwise "runs".
// "sessions" — force /api/sessions/{id}/chat/stream
// "runs" — force /v1/runs
//
// Existing users keep whatever they previously chose. Only fresh installs
// (no value persisted yet) get the new "auto" default.
val streamingEndpoint: StateFlow<String> = application.relayDataStore.data
.map { it[KEY_STREAMING_ENDPOINT] ?: "sessions" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "sessions")
.map { it[KEY_STREAMING_ENDPOINT] ?: "auto" }
.stateIn(viewModelScope, SharingStarted.Eagerly, "auto")
fun setStreamingEndpoint(endpoint: String) {
viewModelScope.launch {
@@ -309,6 +326,19 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
}
}
/**
* Resolve the user's `streamingEndpoint` preference to a concrete value
* based on the latest capability probe. Returns "sessions" or "runs"
* (never "auto"). Used by ChatViewModel right before kicking off a stream.
*
* - "sessions" / "runs" pass through unchanged (manual override wins).
* - "auto" → reads `serverCapabilities.value.preferredChatEndpoint()`.
*/
fun resolveStreamingEndpoint(preference: String): String = when (preference) {
"sessions", "runs" -> preference
else -> _serverCapabilities.value.preferredChatEndpoint()
}
// Parse tool annotations from text markers toggle
val parseToolAnnotations: StateFlow<Boolean> = application.relayDataStore.data
.map { it[KEY_PARSE_TOOL_ANNOTATIONS] ?: false }
@@ -524,14 +554,17 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
oldClient?.shutdown()
_apiServerReachable.value = client.checkHealth()
// Detect chat mode
val mode = client.detectChatMode()
_chatMode.value = mode
// Probe per-endpoint capabilities. The result drives both the
// legacy chatMode flow and the auto-resolver in ChatViewModel.
val caps = client.probeCapabilities()
_serverCapabilities.value = caps
_chatMode.value = caps.toChatMode()
} else {
_apiClient.value = null
oldClient?.shutdown()
_apiServerReachable.value = false
_chatMode.value = ChatMode.DISCONNECTED
_serverCapabilities.value = ServerCapabilities.DISCONNECTED
}
}
+49 -1
View File
@@ -80,7 +80,13 @@ The app supports two streaming endpoints, selectable in Settings:
| **Sessions** (`/api/sessions/{id}/chat/stream`) | Inline text annotations (`` `💻 terminal` ``) — client parses from markdown | Hermes-native SSE (assistant.delta, tool.progress, etc.) or OpenAI-format (delta.content) |
| **Runs** (`/v1/runs` + `/v1/runs/{run_id}/events`) | **Structured events** (tool.started, tool.completed) — real-time tool cards | Hermes lifecycle events (message.delta, tool.started, tool.completed, run.completed) |
**Important upstream note:** The `/api/sessions` CRUD endpoints may not exist in all hermes-agent versions. The upstream codebase registers `/v1/chat/completions`, `/v1/responses`, and `/v1/runs` as the standard endpoints. Session management via `/api/sessions` may be version-specific (confirmed working in v0.7.0). The app's `detectChatMode()` probes the server and falls back gracefully.
**Important upstream note:** The `/api/sessions` CRUD endpoints are not in vanilla upstream hermes-agent. They are provided by one of three mechanisms:
1. The Codename-11 fork (`feat/api-server-enhancements` branch, deployed on the `axiom` deploy branch). Submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556).
2. **Bootstrap injection** — `hermes_relay_bootstrap/` ships with the plugin and runs at Python interpreter startup (via a `.pth` file in the venv site-packages). It monkey-patches `aiohttp.web.Application` to add the management endpoints to upstream's `APIServerAdapter` at the moment it builds its app. See ADR 8 below.
3. Once PR #8556 merges, upstream-merged. The bootstrap detects this and no-ops.
The app's `probeCapabilities()` returns a per-endpoint snapshot, and `ConnectionViewModel.resolveStreamingEndpoint()` collapses `streamingEndpoint = "auto"` (the default for new installs) to a concrete `"sessions"` or `"runs"` choice based on what the server actually exposes.
**Tool call transparency:** In `/v1/chat/completions` streaming, tool calls are NOT emitted as separate SSE events. They are injected as inline markdown text (e.g., `` `💻 pwd` ``). The app's annotation parser (`ChatHandler.parseAnnotationLine`) detects these and renders them as tool progress cards. The `/v1/runs` endpoint is the only path that provides structured `tool.started`/`tool.completed` events.
@@ -476,6 +482,48 @@ Adopting from ARC's workflow patterns:
3. **Concurrency groups:** Cancel in-progress CI on new push to same branch
4. **Dependabot:** Auto-merge minor/patch dependency updates
### 16. Runtime API Server Patch via .pth Bootstrap (2026-04-12)
**Context:** The Codename-11 fork of hermes-agent (`feat/api-server-enhancements` branch) adds ~14 management endpoints — `/api/sessions/*` CRUD, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models` — that the Android app depends on for its sessions browser, personality picker, command palette, and history-on-restart. These are submitted upstream as PR [#8556](https://github.com/NousResearch/hermes-agent/pull/8556) but not yet merged. Until then, users running vanilla upstream hermes-agent + our plugin would lose these features and see a blank chat window when reopening the app to a previous session.
We considered four options:
- **A. Stay fork-only.** Reject vanilla upstream users until PR #8556 lands. Penalises onboarding.
- **B. Read-only sessions browser via plugin relay.** Add `GET /api/sessions/*` to the relay at port 8767. Forces the client to know which URL each operation goes to.
- **C. Full parity by porting all 800 lines onto the plugin relay.** Same architectural pollution as B, plus duplicates ~250 lines of chat-stream handler with cross-cutting `_create_agent` / `run_conversation` dependencies that the fork may have implicitly modified.
- **D. Runtime injection via Python interpreter startup hook.** Ship a `.pth` file in the venv site-packages that imports a bootstrap module, which installs a `sys.meta_path` finder for `aiohttp.web`. When the gateway eventually imports `aiohttp.web`, our finder wraps the loader and replaces `web.Application` with a thin subclass. The subclass overrides `__setitem__` to detect `app["api_server_adapter"] = self` (the line in upstream's `connect()` that gives us a reference to the adapter while the router is still mutable). At that point we feature-detect by route path and bind our extra handlers directly onto the same router the gateway is in the middle of populating.
**Decision: D, scoped to management endpoints only.** The chat-stream handler is intentionally NOT injected — chat goes through standard upstream `/v1/runs`, which already emits structured `tool.started`/`tool.completed` events. This avoids touching `_create_agent` / `run_conversation` (the fork's riskiest cross-cutting dependencies) and is arguably an upgrade — `/v1/runs` has live tool events whereas the sessions chat-stream path required a post-stream message-history reload to render tool cards.
**Why this is the right answer despite being a clever hack:**
1. **Zero modifications to hermes-agent's filesystem.** `git pull` / `hermes update` see no local changes, so they always work cleanly. The patch lives entirely in `hermes_relay_bootstrap/` inside our own repo.
2. **Single-file containment of all ported logic.** `_handlers.py` is 500 lines of straight-line aiohttp handler code with explicit `adapter` parameters (closures, not bound methods). Easy to audit, easy to delete.
3. **Feature detection by route path, not method name.** The bootstrap checks if `/api/sessions` is already in the router and no-ops if so. This means fork users, bootstrap-injected vanilla-upstream users, AND post-PR-#8556 upstream-merged users all run safely with the bootstrap installed. Three of the four valid combinations require zero changes; only the bootstrap-injected one actively patches.
4. **Trust model already established.** The user installed our plugin into their hermes-agent venv. They've already consented to having the plugin import hermes-agent internals (it does this for relay tools, voice endpoints, media registry). Monkey-patching `aiohttp.web.Application` is in the same trust bucket.
5. **Trivial removal.** When PR #8556 reaches a released hermes-agent version: delete `hermes_relay_bootstrap/`, delete the `.pth` from `install.sh` step 2, bump plugin version. The Android client's capability detection still works because it probes routes by path, not by source.
6. **`/v1/runs` is genuinely better for chat than `/api/sessions/{id}/chat/stream`.** It's standard upstream, supports `X-Hermes-Session-Id` for continuation, and emits live structured tool events. The fork's chat handler exists because upstream didn't HAVE this clean structured-event runs API at the time the fork was cut — but upstream does now.
**The Android client adapts via `streamingEndpoint = "auto"`.** New `ServerCapabilities` data class returned by `HermesApiClient.probeCapabilities()` captures per-endpoint presence (`sessionsApi`, `sessionsChatStream`, `runs`, `portable`, `healthy`). `ConnectionViewModel.resolveStreamingEndpoint()` collapses `"auto"` to `"sessions"` (when chat-stream handler is present, i.e. fork or upstream-merged) or `"runs"` (otherwise, i.e. bootstrap-injected vanilla upstream). The setting still supports manual `"sessions"` / `"runs"` overrides for debugging.
**Risks accepted:**
- **Plugin load order** — verified: `.pth` files are processed by Python's `site` module BEFORE any application code runs, so our import hook is in place before hermes-agent imports `aiohttp.web`.
- **Upstream refactor of the route-registration block in `connect()`** — handled by feature detection on route path. Worst case: bootstrap logs a warning and gateway runs without injected routes. The Android client falls back to `/v1/runs` automatically.
- **Editable pip install doesn't ship `.pth` files reliably** — verified empirically (test in `/tmp/pth-test` on the server during scoping). Solved by `install.sh` copying the `.pth` directly into the venv's `site-packages/` after `pip install -e`.
**File locations:**
- `hermes_relay_bootstrap/__init__.py` — installs the meta_path finder (~30 lines)
- `hermes_relay_bootstrap/_patch.py` — `_AioHttpWebFinder`, `_PatchingLoader`, `_PatchedApplication`, `_maybe_register_routes` (~170 lines)
- `hermes_relay_bootstrap/_handlers.py` — 14 ported handlers + helpers (~500 lines)
- `hermes_relay_bootstrap.pth` — single line: `import hermes_relay_bootstrap`
- `install.sh` step 2 — copies the `.pth` into the venv site-packages
**Removal path** (when PR #8556 lands and reaches a released hermes-agent):
1. Delete `hermes_relay_bootstrap/` directory
2. Delete `hermes_relay_bootstrap.pth` from repo root
3. Remove the `.pth` drop block from `install.sh` step 2
4. Update CLAUDE.md to drop the bootstrap reference
5. The Android client `probeCapabilities()` and `streamingEndpoint = "auto"` plumbing stays — it's permanent infrastructure that handles mixed-version deployments.
---
## Voice Mode — Architecture
+1
View File
@@ -0,0 +1 @@
import hermes_relay_bootstrap
+36
View File
@@ -0,0 +1,36 @@
"""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 injects extra `/api/sessions/*`,
`/api/memory`, `/api/skills`, `/api/config`, and `/api/available-models`
routes.
The point: a vanilla upstream hermes-agent install with no custom server-side
patches still serves the management endpoints the Hermes-Relay Android app
expects, as long as the hermes-relay plugin is installed in the same venv.
Chat streaming continues to use upstream's standard `/v1/runs` endpoint, which
already emits structured tool events — that's why we don't need to inject any
chat handlers.
This module is removed in its entirety once upstream PR
https://github.com/NousResearch/hermes-agent/pull/8556 lands and reaches a
released hermes-agent version. The bootstrap feature-detects on route paths
and silently no-ops when the upstream-merged or fork-built endpoints are
already present, so it stays harmless during the rollout window.
"""
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()
+656
View File
@@ -0,0 +1,656 @@
"""Ported handlers from `feat/api-server-enhancements` (Codename-11/hermes-agent).
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/categories — list skill categories
GET /api/skills/{name} — fetch skill body
GET /api/config — read model + config
PATCH /api/config — update model/provider/base_url
GET /api/available-models — provider model list
NOT injected — chat streaming intentionally goes through upstream's standard
`/v1/runs` endpoint, which already emits `tool.started`/`tool.completed` SSE
events. Avoiding the chat handlers also means we don't have to coordinate
with `_create_agent` / `agent.run_conversation`, which the fork modified in
ways we'd otherwise have to mirror exactly.
Removal note: when upstream PR #8556 merges and a released hermes-agent
version contains these endpoints, this entire file becomes dead weight. The
bootstrap's feature detection no-ops on the existing routes, so leaving it in
place is harmless during the rollout window. Cleanup is a clean delete of
the `hermes_relay_bootstrap/` package and its `.pth` file.
"""
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_categories, 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,
"skills_categories": skills_categories,
"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"]
skills_categories = upstream["skills_categories"]
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 skill_categories(request):
auth_err = adapter._check_auth(request)
if auth_err:
return auth_err
return web.json_response(json.loads(skills_categories()))
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)))
return {
"list_skills": list_skills,
"skill_categories": skill_categories,
"view_skill": view_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 register_routes(app, adapter) -> None:
"""Bind every injected route to the live aiohttp router.
Called from `_patch._maybe_register_routes()` after feature detection
determines we're on a vanilla upstream server and 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.
"""
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)
app.router.add_get("/api/sessions", sessions["list_sessions"])
app.router.add_post("/api/sessions", sessions["create_session"])
app.router.add_get("/api/sessions/search", sessions["search_sessions"])
app.router.add_get("/api/sessions/{session_id}", sessions["get_session"])
app.router.add_get("/api/sessions/{session_id}/messages", sessions["get_session_messages"])
app.router.add_patch("/api/sessions/{session_id}", sessions["update_session"])
app.router.add_delete("/api/sessions/{session_id}", sessions["delete_session"])
app.router.add_post("/api/sessions/{session_id}/fork", sessions["fork_session"])
app.router.add_get("/api/memory", memory["get_memory"])
app.router.add_post("/api/memory", memory["add_memory"])
app.router.add_patch("/api/memory", memory["replace_memory"])
app.router.add_delete("/api/memory", memory["delete_memory"])
app.router.add_get("/api/skills", skills["list_skills"])
app.router.add_get("/api/skills/categories", skills["skill_categories"])
app.router.add_get("/api/skills/{name}", skills["view_skill"])
app.router.add_get("/api/config", config["get_config"])
app.router.add_patch("/api/config", config["update_config"])
app.router.add_get("/api/available-models", config["available_models"])
+212
View File
@@ -0,0 +1,212 @@
"""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 feature-detect by route path and call `_register_routes()`
to bind our handlers to the same router the gateway is in the middle of
populating. 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__)
# Routes we inject. The keys MUST match the path strings registered by
# `_register_routes()` in `_handlers.py`. The values are unused — only the
# set of paths matters for feature detection.
_INJECTED_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/categories",
"/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,
)
web_module.Application = _PatchedApplication
def _maybe_register_routes(app, adapter) -> None:
"""Feature-detect on route paths and register if absent.
The fork (`feat/api-server-enhancements`) and the upstream-merged version
(post-PR-#8556) both register the same paths. If any of our injected
paths are already on the router, we no-op so we don't double-register.
"""
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 & _INJECTED_PATHS:
logger.info(
"hermes_relay_bootstrap: detected existing /api/* routes "
"(fork or upstream-merged); skipping injection."
)
return
# 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:
_handlers.register_routes(app, adapter)
except Exception as exc:
logger.warning(
"hermes_relay_bootstrap: register_routes raised %s; "
"the gateway will run without injected sessions API.",
exc,
)
return
logger.info(
"hermes_relay_bootstrap: injected %d /api/* routes onto upstream "
"hermes-agent gateway",
len(_INJECTED_PATHS),
)
+54 -3
View File
@@ -6,8 +6,18 @@
#
# Installs:
# 1. The hermes-relay repo to ~/.hermes/hermes-relay (editable, git-backed)
# 2. The Python package (plugin + relay server) via `pip install -e` into
# the hermes-agent venv so `python -m plugin.pair` works from anywhere
# 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,
# our extra `/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`
# and `/api/available-models` routes get injected onto the same router
# the gateway is in the middle of populating. Feature-detected by route
# path — if your hermes-agent build already has these endpoints natively,
# the bootstrap no-ops cleanly so it's safe to ship across all versions.
# 3. A symlink at ~/.hermes/plugins/hermes-relay → the clone's plugin/ dir
# so Hermes's plugin loader discovers + enables the plugin
# 4. The skill(s) under skills/ into ~/.hermes/config.yaml as a scanned
@@ -25,7 +35,20 @@
# Updates:
# cd ~/.hermes/hermes-relay && git pull
# (No reinstall needed — editable pip install + external_dirs scan mean
# changes go live on the next invocation.)
# 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.)
#
# Uninstall:
# bash ~/.hermes/hermes-relay/uninstall.sh
# (Or, if you don't have the clone any more:
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash)
#
# The uninstaller reverses every step here in the opposite order. It is
# idempotent and never touches shared state (~/.hermes/.env, the gateway's
# state.db, the hermes-agent venv core). Use --keep-clone to leave the git
# tree in place, --remove-secret to also wipe the QR signing identity,
# --dry-run to preview without changing anything.
#
# Overrides:
# HERMES_RELAY_HOME Target directory (default: ~/.hermes/hermes-relay)
@@ -104,6 +127,28 @@ info "[2/6] Installing plugin into hermes venv (editable)..."
|| 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 `/api/sessions/*` (and friends) 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"
else
info " Could not determine venv site-packages — bootstrap .pth NOT installed"
info " This means /api/sessions/* won't be injected onto vanilla upstream"
info " hermes-agent. Manually copy $PTH_SRC into your venv's site-packages."
fi
# ── 3/6 Symlink plugin into Hermes plugin dir ─────────────────────────────
info "[3/6] Registering plugin with Hermes..."
mkdir -p "$(dirname "$PLUGIN_LINK")"
@@ -272,7 +317,13 @@ echo " $VENV_PY -m plugin.pair"
echo ""
echo " To update later:"
echo " cd $RELAY_HOME && git pull"
echo " bash $RELAY_HOME/install.sh # re-runs all steps idempotently"
echo " systemctl --user restart hermes-relay # if installed as a service"
echo " systemctl --user restart hermes-gateway # if you changed bootstrap code"
echo ""
echo " To uninstall:"
echo " bash $RELAY_HOME/uninstall.sh"
echo " bash $RELAY_HOME/uninstall.sh --dry-run # preview without changing anything"
echo ""
echo " Manage the relay service (if installed):"
echo " systemctl --user status hermes-relay"
+7 -1
View File
@@ -33,9 +33,15 @@ tmux = ["libtmux>=0.37.0"] # optional — enables persistent tmux sessions in t
# keeps working via the compat layer.
[tool.setuptools.packages.find]
where = ["."]
include = ["plugin*", "relay_server*"]
include = ["plugin*", "relay_server*", "hermes_relay_bootstrap*"]
exclude = ["plugin.tests*", "plugin.skills*", "app*", "docs*", "user-docs*", "scripts*"]
[tool.setuptools.package-data]
plugin = ["skill.md", "plugin.yaml"]
"plugin.skills" = ["android/*.md"]
# 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.
+306
View File
@@ -0,0 +1,306 @@
#!/usr/bin/env bash
# Hermes-Relay — canonical uninstaller.
#
# Reverses every step of install.sh in the opposite order. Idempotent: safe
# to run twice; missing artifacts produce a warning, not an error. Never
# touches state shared with other tools (~/.hermes/.env, the gateway's
# state.db, the hermes-agent venv core, etc.).
#
# Usage:
# bash uninstall.sh # Standard uninstall (keeps QR secret)
# bash uninstall.sh --remove-secret # Also wipe ~/.hermes/hermes-relay-qr-secret
# bash uninstall.sh --keep-clone # Don't remove ~/.hermes/hermes-relay
# bash uninstall.sh --dry-run # Print what would be removed, don't touch anything
#
# What it removes (in reverse install order):
# [6] systemd user service — `systemctl --user disable --now`,
# unit file deletion, daemon-reload
# [5] hermes-pair shell shim — ~/.local/bin/hermes-pair
# [4] skills external_dirs entry — removes the relay's path from
# ~/.hermes/config.yaml (other
# entries preserved)
# [3] plugin symlink + legacy stales — ~/.hermes/plugins/hermes-relay
# and any deprecated names
# [2] bootstrap .pth + pip package — venv site-packages drop and
# `pip uninstall hermes-relay`
# [1] git clone — ~/.hermes/hermes-relay (skipped
# with --keep-clone)
#
# What it does NOT touch (ever):
# - ~/.hermes/.env (other tools authenticate against this)
# - ~/.hermes/state.db (sessions DB shared with the gateway)
# - ~/.hermes/hermes-agent/ (the agent itself)
# - ~/.hermes/hermes-agent/venv/ (venv core; we only remove our own .pth)
# - ~/.hermes/hermes-relay-qr-secret (kept by default — wipe with --remove-secret)
# - The phone's stored session token (becomes stale on relay restart anyway)
#
# Overrides (mirror install.sh):
# HERMES_HOME Hermes config home (default: ~/.hermes)
# HERMES_RELAY_HOME Target directory (default: $HERMES_HOME/hermes-relay)
# HERMES_VENV_PY Path to hermes-agent venv python
# (default: $HERMES_HOME/hermes-agent/venv/bin/python)
set -euo pipefail
# ── Argument parsing ───────────────────────────────────────────────────────
KEEP_CLONE=""
REMOVE_SECRET=""
DRY_RUN=""
while [ $# -gt 0 ]; do
case "$1" in
--keep-clone) KEEP_CLONE=1 ;;
--remove-secret) REMOVE_SECRET=1 ;;
--dry-run) DRY_RUN=1 ;;
-h|--help)
sed -n '2,40p' "$0"
exit 0
;;
*) echo "Unknown flag: $1" >&2; exit 1 ;;
esac
shift
done
# ── Config ─────────────────────────────────────────────────────────────────
HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
RELAY_HOME="${HERMES_RELAY_HOME:-$HERMES_HOME/hermes-relay}"
VENV_PY="${HERMES_VENV_PY:-$HERMES_HOME/hermes-agent/venv/bin/python}"
PLUGIN_LINK="$HERMES_HOME/plugins/hermes-relay"
HERMES_CONFIG="$HERMES_HOME/config.yaml"
QR_SECRET="$HERMES_HOME/hermes-relay-qr-secret"
SHIM_PATH="$HOME/.local/bin/hermes-pair"
SYSTEMD_USER_DIR="$HOME/.config/systemd/user"
SERVICE_DST="$SYSTEMD_USER_DIR/hermes-relay.service"
PTH_NAME="hermes_relay_bootstrap.pth"
# ── Helpers ────────────────────────────────────────────────────────────────
info() { echo " $*"; }
ok() { echo " [ok] $*"; }
warn() { echo " [skip] $*"; }
note() { echo " [note] $*"; }
run() {
if [ -n "$DRY_RUN" ]; then
echo " [dry-run] $*"
else
eval "$@"
fi
}
# ── Banner ─────────────────────────────────────────────────────────────────
echo ""
echo " Hermes-Relay Uninstaller"
echo " ------------------------"
echo " Hermes home: $HERMES_HOME"
echo " Clone: $RELAY_HOME"
echo " Venv python: $VENV_PY"
[ -n "$KEEP_CLONE" ] && echo " --keep-clone: yes (clone will be left in place)"
[ -n "$REMOVE_SECRET" ] && echo " --remove-secret: yes (QR secret will be wiped)"
[ -n "$DRY_RUN" ] && echo " --dry-run: yes (no filesystem changes)"
echo ""
# ── 6/6 Stop + remove systemd user service ────────────────────────────────
info "[6/6] Removing systemd user service..."
if command -v systemctl >/dev/null 2>&1 && systemctl --user show-environment >/dev/null 2>&1; then
if systemctl --user list-unit-files hermes-relay.service >/dev/null 2>&1 && \
systemctl --user list-unit-files hermes-relay.service 2>/dev/null | grep -q hermes-relay.service; then
run "systemctl --user disable --now hermes-relay.service >/dev/null 2>&1 || true"
ok "Stopped + disabled hermes-relay.service"
else
warn "hermes-relay.service was not registered with systemd — nothing to stop"
fi
if [ -f "$SERVICE_DST" ]; then
run "rm -f \"$SERVICE_DST\""
run "systemctl --user daemon-reload >/dev/null 2>&1 || true"
ok "Removed $SERVICE_DST"
else
warn "$SERVICE_DST does not exist"
fi
else
warn "systemd user session not available — nothing to stop"
# Catch a stray manually-launched relay if there is one
if pgrep -f "python -m plugin.relay" >/dev/null 2>&1; then
note "A manual 'python -m plugin.relay' is still running."
note "Stop it yourself with: pkill -f 'python -m plugin.relay'"
fi
fi
# ── 5/6 Remove hermes-pair shell shim ─────────────────────────────────────
info "[5/6] Removing hermes-pair shell shim..."
if [ -f "$SHIM_PATH" ] || [ -L "$SHIM_PATH" ]; then
run "rm -f \"$SHIM_PATH\""
ok "Removed $SHIM_PATH"
else
warn "$SHIM_PATH does not exist"
fi
# ── 4/6 Remove skills external_dirs entry from config.yaml ────────────────
info "[4/6] Removing skills external_dirs entry..."
if [ -f "$HERMES_CONFIG" ] && [ -x "$VENV_PY" ]; then
if [ -n "$DRY_RUN" ]; then
echo " [dry-run] would scrub skills.external_dirs entry pointing at $RELAY_HOME/skills"
else
"$VENV_PY" - <<PY
import sys
from pathlib import Path
try:
import yaml
except ImportError:
print(" [skip] pyyaml not available — config.yaml left untouched")
sys.exit(0)
cfg_path = Path("$HERMES_CONFIG")
target_str = "$RELAY_HOME/skills"
target = str(Path(target_str).expanduser().resolve()) if Path(target_str).expanduser().exists() else target_str
if not cfg_path.is_file():
print(" [skip] $HERMES_CONFIG not present")
sys.exit(0)
try:
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
except Exception as exc:
print(f" [skip] Could not parse {cfg_path}: {exc}")
sys.exit(0)
if not isinstance(data, dict):
print(" [skip] config.yaml is not a mapping — nothing to remove")
sys.exit(0)
skills_section = data.get("skills")
if not isinstance(skills_section, dict):
print(" [skip] no skills section in config.yaml")
sys.exit(0)
external_dirs = skills_section.get("external_dirs")
if not isinstance(external_dirs, list):
print(" [skip] no external_dirs entry to clean")
sys.exit(0)
# Match by both raw string AND resolved path so we catch both forms
remaining = []
removed = 0
for entry in external_dirs:
if not isinstance(entry, str):
remaining.append(entry)
continue
try:
resolved = str(Path(entry).expanduser().resolve())
except Exception:
resolved = entry
if entry == target_str or resolved == target or entry.endswith("/hermes-relay/skills"):
removed += 1
else:
remaining.append(entry)
if removed == 0:
print(" [skip] no relay skills entry was registered")
sys.exit(0)
if remaining:
skills_section["external_dirs"] = remaining
else:
skills_section.pop("external_dirs", None)
if not skills_section:
data.pop("skills", None)
backup = cfg_path.with_suffix(cfg_path.suffix + ".bak")
backup.write_text(cfg_path.read_text(encoding="utf-8"), encoding="utf-8")
print(f" [ok] Backed up existing config to {backup}")
cfg_path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
print(f" [ok] Removed {removed} relay skills entry from {cfg_path}")
PY
fi
else
warn "$HERMES_CONFIG missing or venv python unavailable — skipped"
fi
# ── 3/6 Remove plugin symlink (and legacy stales) ─────────────────────────
info "[3/6] Removing plugin symlink..."
removed_any=""
for path in "$PLUGIN_LINK" \
"$HERMES_HOME/plugins/hermes-android" \
"$HERMES_HOME/hermes-agent/plugins/hermes-android" \
"$HERMES_HOME/hermes-agent/plugins/hermes-relay"; do
if [ -L "$path" ] || [ -e "$path" ]; then
run "rm -rf \"$path\""
ok "Removed $path"
removed_any=1
fi
done
[ -z "$removed_any" ] && warn "No plugin symlinks were registered"
# ── 2/6 Remove bootstrap .pth + pip package ───────────────────────────────
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"
else
warn "$PTH_NAME not present in venv site-packages"
fi
if "$VENV_PY" -m pip show hermes-relay >/dev/null 2>&1; then
run "\"$VENV_PY\" -m pip uninstall --quiet --yes hermes-relay >/dev/null 2>&1 || true"
ok "pip uninstall hermes-relay"
else
warn "hermes-relay pip package was not installed"
fi
else
warn "Venv python not found at $VENV_PY — skipped pip uninstall + .pth removal"
fi
# ── 1/6 Remove the git clone ──────────────────────────────────────────────
info "[1/6] Removing git clone..."
if [ -n "$KEEP_CLONE" ]; then
note "--keep-clone set — leaving $RELAY_HOME in place"
elif [ -d "$RELAY_HOME" ]; then
# Sanity guard: refuse to remove a directory that doesn't look like our clone
if [ -d "$RELAY_HOME/.git" ] && [ -f "$RELAY_HOME/install.sh" ]; then
run "rm -rf \"$RELAY_HOME\""
ok "Removed $RELAY_HOME"
else
warn "$RELAY_HOME exists but doesn't look like a hermes-relay clone — left untouched"
fi
else
warn "$RELAY_HOME does not exist"
fi
# ── Optional: QR signing secret ────────────────────────────────────────────
info "[opt] QR signing secret..."
if [ -f "$QR_SECRET" ]; then
if [ -n "$REMOVE_SECRET" ]; then
run "rm -f \"$QR_SECRET\""
ok "Removed $QR_SECRET (--remove-secret)"
note "All paired phones will need to re-trust the next QR code on their next pair."
else
note "$QR_SECRET preserved (use --remove-secret to wipe)."
note "Without removing it, re-installing keeps the same QR signing identity"
note "and any phones still holding their session tokens stay valid."
fi
else
warn "$QR_SECRET does not exist"
fi
# ── Done ───────────────────────────────────────────────────────────────────
echo ""
if [ -n "$DRY_RUN" ]; then
echo " [dry-run complete] No changes made."
else
echo " [OK] Hermes-Relay uninstalled."
fi
echo ""
echo " Preserved (other tools depend on these):"
echo " - $HERMES_HOME/.env"
echo " - $HERMES_HOME/state.db (sessions database)"
echo " - $HERMES_HOME/config.yaml (only the relay's skills entry was removed)"
echo " - $HERMES_HOME/hermes-agent/ (the agent itself)"
[ -z "$REMOVE_SECRET" ] && [ -f "$QR_SECRET" ] && echo " - $QR_SECRET (QR signing secret)"
echo ""
echo " To reinstall:"
echo " curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/install.sh | bash"
echo ""
+30 -2
View File
@@ -33,13 +33,16 @@ The installer follows Hermes's canonical skill-distribution pattern:
3. Adds `~/.hermes/hermes-relay/skills` to `skills.external_dirs` in `~/.hermes/config.yaml` (idempotent YAML edit) so the `hermes-relay-pair` skill is picked up on every hermes-agent load
4. Symlinks `~/.hermes/plugins/hermes-relay` → the clone's `plugin/` subdir
5. Installs a thin `~/.local/bin/hermes-pair` shim that execs `python -m plugin.pair` inside the hermes-agent venv
6. Installs a systemd user unit at `~/.config/systemd/user/hermes-relay.service` (optional — skipped on macOS, WSL-without-systemd, bare chroots)
Restart hermes-agent after install.
::: tip What you get
- **Full Hermes-Relay Android app features** — sessions browser, conversation history on app restart, personality picker, command palette, memory management. Just install the plugin and it works.
- **14 `android_*` device control tools** (tap, type, read screen, screenshot, open apps, etc.) — registered by the plugin
- **`/hermes-relay-pair` slash command** — backed by the `devops/hermes-relay-pair` skill and usable from any Hermes chat surface
- **`hermes-pair` shell shim** — for scripts and power-user flows
- **Voice mode endpoints** on the WSS relay (transcribe, synthesize, voice config) wired into the Android app's voice mode UI
No separate skill install, no `qrencode` binary needed.
:::
@@ -48,10 +51,35 @@ No separate skill install, no `qrencode` binary needed.
Because the installer uses `pip install -e` for the plugin and `external_dirs` for the skill, updates are a single command:
```bash
cd ~/.hermes/hermes-relay && git pull
cd ~/.hermes/hermes-relay && git pull && bash install.sh
systemctl --user restart hermes-gateway hermes-relay
```
Restart hermes-agent and the updated plugin, skill, and docs are live. There's no separate `hermes skills update` step — `external_dirs` is scanned fresh on every hermes-agent invocation.
`bash install.sh` is idempotent — safe to re-run as often as you like. It re-applies every step against the existing install, picks up any new files, and rebuilds the systemd unit from the latest template.
:::
::: info Uninstalling
A clean uninstaller ships in the same repo:
```bash
bash ~/.hermes/hermes-relay/uninstall.sh
```
Or if you don't have the clone any more:
```bash
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/uninstall.sh | bash
```
The uninstaller reverses every install step in the opposite order, is idempotent, and never touches state shared with other Hermes tools (`~/.hermes/.env`, the gateway's `state.db`, the `hermes-agent` venv core). Useful flags:
```bash
bash uninstall.sh --dry-run # preview without changing anything
bash uninstall.sh --keep-clone # leave ~/.hermes/hermes-relay in place
bash uninstall.sh --remove-secret # also wipe the QR signing identity
```
By default the QR signing secret at `~/.hermes/hermes-relay-qr-secret` is preserved, so re-installing keeps the same identity and any phones still holding their session tokens stay valid.
:::
### 3. Pair your phone
+6
View File
@@ -14,6 +14,12 @@ http(s)://<server>:8642
Authorization: Bearer <API_SERVER_KEY> (optional — only if server has a key configured)
```
## How endpoints get served
Installing the plugin via `install.sh` is enough to make all of the endpoints below work — including the management ones (`/api/sessions/*`, `/api/memory`, `/api/skills`, `/api/config`, `/api/available-models`). The plugin wires the gateway up at install time so these are served on the same `:8642` host as the standard `/v1/*` endpoints, with the same `Authorization: Bearer …` auth.
**Chat streaming uses standard `/v1/runs`** by default — it emits structured `tool.started`/`tool.completed` SSE events for live tool progress cards in the Android app. The app's `Settings → Chat → Streaming endpoint = "Auto"` (default) probes per-endpoint capability and picks the best chat path automatically; you can manually force `Sessions` or `Runs` mode for debugging.
## Endpoints
### Health Check