feat(desktop): @hermes-relay/cli experimental track (desktop-v0.3.0-alpha.1)
The full desktop CLI thin-client — one Node binary, paired once, `hermes-relay` → full remote Hermes experience as if local. Spans v0.1 (structured chat), v0.2 (PTY shell + local tool routing + multi-endpoint pairing + reconnect/TOFU + devices), daemon (headless tool serving), and the pre-release hardening pass (uninstall, doctor, first-run prompts, version-aware install). Details in DEVLOG.md entries 2026-04-23 I/II/III and CHANGELOG.md [Unreleased] bullets. Bumps desktop/package.json 0.1.0 → 0.3.0-alpha.1 to align the npm package version with the release-track tag. Fixes a pre-existing .gitignore bug that was silently hiding desktop/package.json under an unscoped VitePress exclusion. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
1003e34689
commit
c25471ccc0
@@ -0,0 +1,88 @@
|
||||
name: CI desktop CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'desktop/**'
|
||||
- '.github/workflows/ci-desktop.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
typecheck-and-build:
|
||||
name: Type-check + build
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build (tsc → dist/)
|
||||
run: npm run build
|
||||
|
||||
- name: Verify bin shim is executable
|
||||
# The published tarball depends on bin/hermes-relay.js having a valid
|
||||
# shebang + importing the freshly built dist/cli.js. Smoke the actual
|
||||
# invocation so we catch broken imports, missing main export, or a
|
||||
# prebuilt dist/ that references a source file that moved.
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: Upload dist/
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: desktop-dist
|
||||
path: desktop/dist
|
||||
retention-days: 7
|
||||
|
||||
smoke-help:
|
||||
name: Smoke — --help + --version work on every target OS
|
||||
needs: typecheck-and-build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: --version
|
||||
run: node bin/hermes-relay.js --version
|
||||
|
||||
- name: --help
|
||||
run: node bin/hermes-relay.js --help
|
||||
@@ -0,0 +1,133 @@
|
||||
name: Release desktop CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['desktop-v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build-binaries:
|
||||
name: Build cross-platform binaries via Bun compile
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: desktop
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js (for npm ci + tsc)
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: desktop/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: '1.3.x'
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Type-check
|
||||
run: npm run type-check
|
||||
|
||||
- name: Build dist/ (tsc)
|
||||
run: npm run build
|
||||
|
||||
- name: Prepare binary output dir
|
||||
run: mkdir -p dist/bin
|
||||
|
||||
- name: Build Windows x64
|
||||
run: >
|
||||
bun build --compile --minify --sourcemap --bytecode
|
||||
--target=bun-windows-x64
|
||||
src/cli.ts
|
||||
--outfile dist/bin/hermes-relay-win-x64
|
||||
|
||||
- name: Build Linux x64
|
||||
run: >
|
||||
bun build --compile --minify --sourcemap --bytecode
|
||||
--target=bun-linux-x64
|
||||
src/cli.ts
|
||||
--outfile dist/bin/hermes-relay-linux-x64
|
||||
|
||||
- name: Build macOS x64
|
||||
run: >
|
||||
bun build --compile --minify --sourcemap --bytecode
|
||||
--target=bun-darwin-x64
|
||||
src/cli.ts
|
||||
--outfile dist/bin/hermes-relay-darwin-x64
|
||||
|
||||
- name: Build macOS arm64
|
||||
run: >
|
||||
bun build --compile --minify --sourcemap --bytecode
|
||||
--target=bun-darwin-arm64
|
||||
src/cli.ts
|
||||
--outfile dist/bin/hermes-relay-darwin-arm64
|
||||
|
||||
- name: Size guard (<150 MB each)
|
||||
run: |
|
||||
set -e
|
||||
for f in dist/bin/hermes-relay-*; do
|
||||
sz=$(stat -c%s "$f")
|
||||
mb=$(( sz / 1024 / 1024 ))
|
||||
echo " $f — ${mb} MB"
|
||||
if [ "$sz" -gt 157286400 ]; then
|
||||
echo "FAIL: $f exceeds 150 MB — Bun likely shipped a debug build or we added a large dep."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Generate SHA256SUMS
|
||||
working-directory: desktop/dist/bin
|
||||
run: |
|
||||
sha256sum hermes-relay-* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: ${{ github.ref_name }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') || contains(github.ref_name, 'rc') }}
|
||||
fail_on_unmatched_files: true
|
||||
body: |
|
||||
# Hermes-Relay Desktop CLI — ${{ github.ref_name }}
|
||||
|
||||
**Experimental phase.** Binaries are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. See the install scripts for the `Unblock-File` / `xattr -dr` escape hatches.
|
||||
|
||||
## Install
|
||||
|
||||
**Windows (PowerShell):**
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**macOS / Linux:**
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Pin this specific release with `HERMES_RELAY_VERSION=${{ github.ref_name }}`.
|
||||
|
||||
## Verify
|
||||
|
||||
```
|
||||
hermes-relay --version
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
See [Desktop CLI docs](https://codename-11.github.io/hermes-relay/desktop/) for full usage.
|
||||
|
||||
files: |
|
||||
desktop/dist/bin/hermes-relay-win-x64.exe
|
||||
desktop/dist/bin/hermes-relay-linux-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-x64
|
||||
desktop/dist/bin/hermes-relay-darwin-arm64
|
||||
desktop/dist/bin/SHA256SUMS.txt
|
||||
+6
-2
@@ -48,14 +48,18 @@ certs/
|
||||
user-docs/.vitepress/cache/
|
||||
user-docs/.vitepress/dist/
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
# Anchor VitePress-only npm manifests to root/user-docs so desktop/package.json is tracked.
|
||||
/package.json
|
||||
/package-lock.json
|
||||
/user-docs/package.json
|
||||
/user-docs/package-lock.json
|
||||
|
||||
# Local upstream reference
|
||||
hermes-agent-upstream/
|
||||
|
||||
# Claude Code internal state (worktrees, image cache, conversation logs)
|
||||
.claude/
|
||||
.claude-launcher/
|
||||
|
||||
# Kotlin compiler cache
|
||||
.kotlin/
|
||||
|
||||
@@ -6,6 +6,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Pre-release hardening: uninstall, doctor, first-run prompts, version-aware install.** Four parallel workstreams that close the "feels like a dev preview" gap before tagging `desktop-v0.3.0-alpha.1`. (1) **Uninstall scripts** — new `desktop/scripts/uninstall.{sh,ps1}` matching install one-liners, 3-tier: default `--binary-only` (removes binary + PATH entry, preserves `~/.hermes/remote-sessions.json`), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (stub for when daemon service installers ship — prints canonical systemd/launchd/sc.exe paths without acting). iex-pipe safety: Windows falls back to `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env vars since `$args` drops through `irm | iex`. Shell rc files deliberately untouched (mirrors install.sh philosophy). (2) **`hermes-relay doctor` subcommand** — local-only diagnostic report (225 lines, `src/commands/doctor.ts`); human format uses `!!` prefix for warnings + hint line at bottom, `--json` for support-paste / scripts. Fields: version / binary_path / install_dir / on_path / sessions file + size + count + summaries (no tokens — total omission, not even prefix) / daemon detection (stat of canonical service unit file paths) / platform + node version. Case-insensitive PATH comparison on Windows. (3) **Interactive first-run fallback** — new `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks single stored session, numbered picker for multiple, first-run banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, replacing the hard `No relay URL` error. Fresh-install UX: bare `hermes-relay` now prints `Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server.` → URL prompt → pairing code prompt → drops into shell. `--non-interactive` still fails fast. Daemon command deliberately untouched — headless binaries must never prompt; fails closed on missing credentials/consent as before. (4) **Version-aware install** — `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or `installing fresh` (no prior install); post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN rather than failing (pre-release version-name drift is expected). 5s timeout on the version call (where `timeout(1)` available); all diagnostic failures fall through to the "could not read version" path. Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for matching. All structural flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively. Type-check + build green; live smoke: `doctor` both modes, `daemon` fails-closed without credentials, help text includes all new surfaces.
|
||||
|
||||
- **`hermes-relay daemon` — headless WSS + tool router, lifts the "tools only work while a shell is open" ceiling.** New `desktop/src/commands/daemon.ts` subcommand that opens a persistent relay connection and attaches `DesktopToolRouter` without a TTY. The agent can now reach the user's machine any time of day — first step toward "feels-local" parity. Fails closed on missing credentials (no stored session + no `--token` → exits 1) and on missing consent (no `toolsConsented: true` on the stored record → exits 1 unless `--allow-tools` is passed alongside an explicit `--token`); a headless binary must never be the thing that first grants tool access. Inherits `RelayTransport`'s reconnect state machine as-is — exp backoff 1s → 30s (5min on 429), reconnect listeners persistent across close/reconnect cycles because `channelListeners` is a Map on the transport (not wiped on socket close), so the router's `attach()` fires exactly once. Structured logging defaults to JSON-line on stderr (parseable by journald / log shippers / jq), auto-switches to human-readable when stderr is a TTY, or force either with `--log-json` / `--log-human`. Lifecycle events: `starting` → `authed` (includes `server_version`, `transport`) → `ready` (with `advertised_tools` list) → `reconnecting` (attempt + delay_ms) / `reconnected` → `shutdown` on SIGTERM/SIGINT/SIGHUP → `transport_exited` when the transport exhausts reconnects (exits 1 so the service manager restarts fresh). Live smoke against `ws://172.16.24.250:8767`: `starting` → `authed` (server 0.6.0) → `ready` (5 tools advertised) in ~120ms. New BOOLEAN_FLAGS entries: `log-human`, `log-json`, `allow-tools`. Service installers for Windows `sc.exe` / systemd user unit / macOS launchd plist are the obvious follow-up; the daemon binary is runnable standalone today via `hermes-relay daemon --remote <url>`.
|
||||
|
||||
- **Desktop CLI v0.2 — PTY shell, local tool routing, multi-endpoint pairing, reconnect + TOFU, devices, contextual banner.** The `@hermes-relay/cli` package at `desktop/` grew from a chat-only scripting surface into a full Hermes-experience thin client. Bare `hermes-relay` now drops into `shell` mode (interactive PTY pipe through the existing relay `terminal` channel → `tmux new-session -A` + post-attach `exec hermes` → the full local `hermes` banner/skin/session id verbatim, zero server changes). `Ctrl+A .` detaches preserving tmux; `Ctrl+A k` destroys it. New `devices` subcommand drives the relay's `GET/DELETE/PATCH /sessions` HTTP endpoints for listing, revoking, and extending server-side paired-device tokens. Status now surfaces `grants:` (per-channel expiry) and `expires:` (session TTL) pulled from the `auth.ok` handshake the transport already received — `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented` (additive, back-compat preserved via a `SaveSessionOptions | string | null` overload on `saveSession`). Contextual connect banner (`Connected via LAN (plain) — server 0.6.0`) replaces the flat `Connected (server X)` line across `chat` + `shell`. Multi-endpoint pairing (ADR 24): `--pair-qr <payload>` / `HERMES_RELAY_PAIR_QR` accepts a full v3 QR payload (compact JSON or base64), decodes the `endpoints[]` array, probes each candidate with strict-priority-within-tier racing (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout, 60 s reachability cache), and auto-selects the first reachable — role propagates into the banner + stored record. Reconnect-on-drop: `RelayTransport` gained a `ReconnectState` machine (`idle|connecting|connected|reconnecting`), exponential backoff (1 s → 30 s, 5 min on 429), `reconnectGate` re-checked both at schedule time and post-backoff (matches Android's mid-sleep purge-race lesson), `'reconnecting'` + `'reconnected'` events, and bufferedEvents-cleared-on-reconnect. TOFU cert pinning: TLS probe runs before the WebSocket opens on `wss://`, extracts peer-cert SPKI sha256 (`sha256/<base64>`, OkHttp-compatible), compares against the stored pin or captures it first-time; mismatches error out with a human-readable "re-pair to reset" pointer. Client-side tool routing (Phase B): new `desktop` relay channel on the server (`plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` registering `desktop_read_file` / `desktop_write_file` / `desktop_terminal` / `desktop_search_files` / `desktop_patch`) forwards tool calls from Hermes to the connected Node CLI; client-side `DesktopToolRouter` dispatches to in-process handlers (`fs`, `terminal`, `search`) under a 30 s AbortController, 30 s heartbeat advertising the tool names. Gated behind a one-time per-URL consent prompt (`toolsConsented` on the session record) + `--no-tools` kill-switch; non-TTY stdin fails closed. New files on the client: `src/banner.ts`, `src/endpoint.ts`, `src/pairingQr.ts`, `src/certPin.ts`, `src/commands/devices.ts`, `src/tools/router.ts`, `src/tools/consent.ts`, `src/tools/handlers/{fs,terminal,search}.ts`. New files on the server: `plugin/relay/channels/desktop.py`, `plugin/tools/desktop_tool.py`, `docs/relay-protocol.md §3.5`. Still zero runtime deps on the client (Node ≥21 global `WebSocket` + `fetch` + `tls.connect` + `node:crypto` X509Certificate + `AbortSignal.any`). Build clean; live smoke passed for `status` / `tools` / `devices`; interactive `shell` + tool-call smoke pending user walk-through. Delivered as four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop, client-side tool handlers) + one synthesis-and-integration pass; the `connectAndAuth → {relay, url, endpointRole}` return-shape refactor in `chat.ts` / `shell.ts` / `tools.ts` unifies how `--pair-qr`'s winning-endpoint URL overrides `--remote` across every subcommand.
|
||||
|
||||
- **Desktop thin-client CLI (`@hermes-relay/cli`) v0.1 under `desktop/`.** Node ≥21 package — installable via `npm install -g @hermes-relay/cli`, `npx @hermes-relay/cli`, or the new `scripts/install.sh` / `install.ps1` curl+iwr one-liners. One `hermes-relay` binary with four subcommands: `chat` (REPL + one-shot + piped-stdin, default), `pair` (one-time handshake → persists session token), `status` (local read of `~/.hermes/remote-sessions.json`), `tools` (`tools.list` RPC → enabled/available toolsets on the server). Credential precedence matches the Ink TUI exactly: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive readline prompt. Reuses the **same** `~/.hermes/remote-sessions.json` store as the TUI, so a user paired via either surface sees the other work with no re-pair. Zero server changes: the CLI consumes the existing relay `tui` WSS channel + `tui_gateway` subprocess events (`message.delta`, `tool.start/complete`, `thinking.delta`, `status.update`, `error`, `approval.request`, …) and renders them as plain lines to stdout, with decorated tool arrows on stderr. Flags: `--remote <url>`, `--code <CODE>`, `--token <TOKEN>`, `--session <id>`, `--json` (event-per-line for `jq`), `--verbose`, `--quiet`, `--no-color`, `--non-interactive`, `--reveal-tokens` (opt-in full-token output on `status --json` — default redacts). Transport, gateway types, session storage, graceful-exit, and rpc helpers are **vendored verbatim** from `hermes-agent-tui-smoke/ui-tui/src/` (feat/tui-transport-pluggable) with a header note; the CLI and TUI stay in lockstep on the envelope protocol (docs/relay-protocol.md §3.7) until the shared surface can be lifted into a `@hermes-relay/core` package post-stabilization. SIGINT during a turn calls `session.interrupt` via a per-turn `{ promise, cancel }` handle — the REPL's cancellation state lives and dies with the turn so a late-arriving `error` event for a cancelled turn can't be misread by the next turn's handler. Smoke-tested end-to-end against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, hermes-agent 0.10.0): connect/auth/session.create/prompt.submit/tools.list/--json/piped-stdin all clean. Not yet wired: interactive approval/clarify/sudo/secret request response (renderer logs a warning; out of scope for v0.1). Upstream PR candidate once the sibling Ink TUI stabilizes — see `desktop/README.md` and vault `Desktop Client.md` for the broader thin-client roadmap.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Transport Security badge is now role-aware — "Plain (on LAN)" instead of "Insecure (network unknown)".** The previous badge derived its label from `PairingPreferences.insecureReason`, which only got populated when the user toggled "Allow insecure connections" ON via the Ack dialog and picked a reason. If a user paired directly from a plain-`ws://` LAN QR, they never had to toggle that flag — the connection was already `ws://` — so the reason stayed blank and the badge degraded to the alarming `"Insecure (network unknown)"` even though the multi-endpoint resolver was tracking `activeEndpointRole = "lan"` in real time. Fix: `insecureReasonLabel` now accepts an optional `activeRole: String?` and prefers the live role over the stored ack reason (`Plain (on LAN)` / `Plain (on Tailscale)` / `Plain (on public URL)`). Neutral fallback when both role and reason are unknown is `"Plain (no TLS)"` — matches the new "Plain / Secure" vocabulary, drops the scary "Insecure" adjective. Binary-boolean `TransportSecurityBadge(isSecure, reason, ...)` overload gains an optional `activeRole` param with default `null` so existing call sites compile unchanged. `ConnectionViewModel.applyPairingPayload` auto-stamps `PairingPreferences.insecureReason` at pair time based on the selected endpoint's role (`lan` → `lan_only`, `tailscale` → `tailscale_vpn`, `public`/unknown → leave blank so the user thinks); clears any stale reason when upgrading to a secure endpoint. Only overwrites blank values — never clobbers a user-selected reason. Two user-visible "Insecure" strings inside the Advanced section's insecure-toggle subsection also rewritten to "Plain" for consistency (`"Plain connection — traffic is not encrypted"`, `"Allow plain (unencrypted) connections"`).
|
||||
|
||||
@@ -84,6 +84,22 @@ hermes-android/
|
||||
│ ├── accessibility/ # HermesAccessibilityService, ScreenReader, ActionExecutor
|
||||
│ ├── bridge/ # BridgeSafetyManager, BridgeForegroundService, BridgeStatusOverlay
|
||||
│ └── notifications/ # HermesNotificationCompanion
|
||||
├── desktop/ ← Node thin-client CLI (`@hermes-relay/cli`)
|
||||
│ ├── bin/hermes-relay.js # #!/usr/bin/env node shim → dist/cli.js
|
||||
│ ├── src/
|
||||
│ │ ├── cli.ts # argv parser + subcommand dispatcher (bare → shell)
|
||||
│ │ ├── commands/ # chat, shell, pair, status, tools, devices
|
||||
│ │ ├── banner.ts # contextual connect line (LAN / Tailscale / Plain / Secure)
|
||||
│ │ ├── renderer.ts # GatewayEvent → plain-line stdout formatter (chat only)
|
||||
│ │ ├── endpoint.ts # ADR 24 EndpointCandidate + role helpers
|
||||
│ │ ├── pairingQr.ts # v3 QR decode + priority-raced reachability probe
|
||||
│ │ ├── pairing.ts # readline 6-char prompt + payload validator
|
||||
│ │ ├── credentials.ts # token → pair-qr → code → stored → prompt precedence
|
||||
│ │ ├── certPin.ts # TOFU SPKI sha256 extract / pinKey / compare
|
||||
│ │ ├── tools/ # desktop.command router + fs/terminal/search handlers + consent
|
||||
│ │ ├── transport/ # RelayTransport (reconnect state machine + TLS probe TOFU)
|
||||
│ │ └── lib/ # gracefulExit, rpc, circularBuffer (vendored)
|
||||
│ └── scripts/ # install.sh + install.ps1 curl/iwr one-liners
|
||||
├── plugin/ ← Hermes agent plugin
|
||||
│ ├── android_tool.py # 18 android_* tool handlers
|
||||
│ ├── pair.py # QR pairing implementation
|
||||
@@ -114,6 +130,13 @@ hermes-android/
|
||||
- **applicationId:** `com.axiomlabs.hermesrelay` (googlePlay), `com.axiomlabs.hermesrelay.sideload` (sideload)
|
||||
- **Min SDK 26, Target SDK 35, Compile SDK 36** / **Kotlin 2.0+**, JVM toolchain 17
|
||||
|
||||
### Code Style — Desktop CLI (Node/TypeScript)
|
||||
- **Node ≥21** — uses built-in global `WebSocket` (no `ws`/`undici` dep). Strict TS, ES modules, `NodeNext` resolution.
|
||||
- **Zero runtime deps** — `@types/node` + `tsx`/`rimraf`/`typescript` are devDeps only. Ship compiled `dist/`, not tsx.
|
||||
- **One binary, subcommands** — idiomatic for Node CLIs (codex, continue, vite pattern). Bare invocation is `chat`.
|
||||
- **Vendor-for-now** — transport/gateway/types are copied verbatim from `hermes-agent-tui-smoke/ui-tui/src/` with a header note. Extract to a shared package when the TUI and CLI stabilize.
|
||||
- **Dev loop:** `npx tsx src/cli.ts <args>` (no rebuild). `npm run build` + `npm link` before pushing to verify the bin shim. Never ship tsx in the published tarball — pre-build with `tsc` so Windows `npm install -g` can cmd-shim the JS directly.
|
||||
|
||||
### Code Style — Server (Python)
|
||||
- **aiohttp** — async, matches existing Hermes relay patterns
|
||||
- **Type hints everywhere** — Python 3.11+ syntax
|
||||
@@ -213,6 +236,35 @@ hermes-android/
|
||||
| `plugin/dashboard/plugin_api.py` | FastAPI router proxying 5 routes to relay over loopback; `/pairing` body = API-server overrides (host/port/tls/api_key), relay URL auto-derived |
|
||||
| `plugin/dashboard/src/index.jsx` | React root registering `hermes-relay` plugin with 4-tab shell |
|
||||
| `plugin/dashboard/dist/index.js` | Committed IIFE bundle loaded verbatim by dashboard |
|
||||
| **Desktop CLI** | |
|
||||
| `desktop/package.json` | `@hermes-relay/cli` package manifest — Node ≥21, one `hermes-relay` bin, pre-built dist |
|
||||
| `desktop/bin/hermes-relay.js` | Tiny shim: `import('../dist/cli.js').then(m => m.main())` + error surfacing |
|
||||
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
|
||||
| `desktop/src/commands/chat.ts` | REPL + one-shot + piped-stdin; `runOneTurn` returns `{promise, cancel}` for safe SIGINT; auto-wires `DesktopToolRouter` when consented |
|
||||
| `desktop/src/commands/shell.ts` | Pipes the `terminal` relay channel to raw-mode stdin/stdout; post-attach `exec hermes` 350ms after tmux settles; `Ctrl+A .` detach / `Ctrl+A k` kill / `Ctrl+A Ctrl+A` literal |
|
||||
| `desktop/src/commands/pair.ts` | Either 6-char code + `--remote`, or full v3 QR via `--pair-qr` — probes + picks endpoint, records role |
|
||||
| `desktop/src/commands/tools.ts` | `tools.list` RPC → enabled/available toolsets; `--verbose` lists individual tools |
|
||||
| `desktop/src/commands/status.ts` | Local read of `~/.hermes/remote-sessions.json`; renders `grants:` + `expires:` + `route:`; `--json` redacts tokens, `--reveal-tokens` opts in |
|
||||
| `desktop/src/commands/devices.ts` | Server-side session management — `GET/DELETE/PATCH /sessions` via `fetch` over http(s)://host:port; `list` / `revoke <prefix>` / `extend <prefix> --ttl <s>` |
|
||||
| `desktop/src/banner.ts` | `buildConnectBanner({url, meta, endpointRole})` → "Connected via LAN (plain) — server 0.6.0"; `humanExpiry()` for TTL formatting |
|
||||
| `desktop/src/endpoint.ts` | `EndpointCandidate` / `EndpointRole` types + `displayLabel()` — mirrors Android `data/Endpoint.kt` |
|
||||
| `desktop/src/pairingQr.ts` | `decodePairingPayload` (JSON or base64), `payloadToCandidates` (v3 verbatim / v1–v2 synthesized), `probeCandidatesByPriority` (`Promise.any` within tier, `AbortSignal.any`, 4s timeout, 60s cache) |
|
||||
| `desktop/src/certPin.ts` | `extractSpkiSha256(der)` via `crypto.X509Certificate` + `publicKey.export({type:'spki'})`; `pinKey(url)`, `comparePins()`, `isSecureUrl()` |
|
||||
| `desktop/src/tools/router.ts` | `DesktopToolRouter.attach(relay)` — installs `onChannel('desktop')`, dispatches `desktop.command` under 30s `AbortController`, 30s heartbeat via `desktop.status` advertising handler names |
|
||||
| `desktop/src/tools/consent.ts` | `ensureToolsConsent(url)` — stored per-URL in `toolsConsented`; TTY prompt; non-TTY fails closed |
|
||||
| `desktop/src/tools/handlers/fs.ts` | `readFileHandler` / `writeFileHandler` / `patchHandler` — strict unified-diff applier, no fuzz |
|
||||
| `desktop/src/tools/handlers/terminal.ts` | `bash -lc` / `cmd /c`, SIGKILL on timeout or abort, returns `{stdout, stderr, exit_code, duration_ms}` |
|
||||
| `desktop/src/tools/handlers/search.ts` | ripgrep with pure-Node fallback, skips `.git`/`node_modules`/`dist`/`.next`/`.cache` |
|
||||
| `desktop/src/renderer.ts` | Streams `message.delta` → stdout, tool events → decorated lines; NO_COLOR / --json / --quiet aware |
|
||||
| `desktop/src/pairing.ts` | readline-based 6-char prompt (`A-Z0-9`); headless mirror of TUI's Ink prompt; `validatePairingPayloadString` discriminated-union wrapper |
|
||||
| `desktop/src/credentials.ts` | Precedence: `--token` → `--pair-qr` (probe+pair) → `--code` → stored → prompt; returns `Credentials{sessionToken?, pairingCode?, resolvedEndpoint?}` |
|
||||
| `desktop/src/transport/RelayTransport.ts` | Fork of ui-tui's transport + reconnect state machine (`idle/connecting/connected/reconnecting`, exp backoff 1→30s, 5min on 429, gate re-check post-sleep) + pre-WS TLS probe for TOFU |
|
||||
| `desktop/src/remoteSessions.ts` | Same file path as TUI (`~/.hermes/remote-sessions.json`, 0600); schema widened with `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload |
|
||||
| `desktop/scripts/install.sh` / `install.ps1` | curl/iwr one-liner installers — gate on Node ≥21, delegate to `npm install -g` |
|
||||
| `desktop/README.md` | User-facing install + usage reference |
|
||||
| **Server — Desktop tool routing (Phase B)** | |
|
||||
| `plugin/relay/channels/desktop.py` | Mirrors `bridge.py` — `desktop.command`/`desktop.response`/`desktop.status`, UUID-correlated futures, 30s timeout, single-client MVP, per-session advertised-tools set |
|
||||
| `plugin/tools/desktop_tool.py` | `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` — registers with `tools.registry` under `desktop` toolset; `_check_requirements` pings `/desktop/_ping` for "is a client connected AND does it advertise this tool?" |
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
@@ -324,6 +376,12 @@ See [RELEASE.md](RELEASE.md) for the full recipe.
|
||||
| Notifications | `GET /notifications/recent?limit=N` | Loopback callers skip bearer |
|
||||
| Relay health | `GET /health` on `:8767` | Used by `RelayHttpClient.probeHealth()` |
|
||||
| Capabilities | `HEAD /api/sessions`, `HEAD /v1/runs`, etc. | HEAD avoids CORS 403 on OPTIONS preflight |
|
||||
| Desktop CLI (tui channel) | WSS `tui.attach` / `tui.rpc.request` / `tui.rpc.event` | Same channel + envelopes as the Ink TUI — the CLI just renders events as plain lines. Zero server changes. |
|
||||
| Desktop CLI (terminal channel) | WSS `terminal.attach` / `terminal.input` / `terminal.output` / `terminal.resize` / `terminal.detached` | Existing channel (shared with Android). CLI `shell` subcommand attaches, injects `clear; exec hermes\n` 350ms after ack, pipes raw bytes. `Ctrl+A .` detaches (tmux preserved), `Ctrl+A k` kills. |
|
||||
| Desktop CLI tool visibility | `tools.list` RPC on the shared tui channel | Returns `{toolsets: [{name, description, tool_count, enabled, tools:[]}]}`; surfaced by `hermes-relay tools` |
|
||||
| Desktop CLI devices | HTTP `GET/DELETE/PATCH /sessions` on the relay's same port | Wrapped by `hermes-relay devices list | revoke <prefix> | extend <prefix> --ttl <s>`; bearer token from stored session; token prefix only (never full token) |
|
||||
| Desktop tool routing (Phase B) | WSS `desktop.command` (s→c) + `desktop.response` (c→s) + `desktop.status` (c→s heartbeat) | New channel. Hermes calls `desktop_read_file(path)` → Python handler POSTs to `/desktop/desktop_read_file` → relay forwards over `desktop.command` → Node client's `DesktopToolRouter` runs the handler locally → response bubbles back. Mirror of Android's `bridge.command` pattern. |
|
||||
| Desktop tool check_fn | HTTP `GET /desktop/_ping?tool=<name>` | Returns 200 if a client is connected AND advertises this tool; 503 otherwise. Hermes uses this to fail the tool quickly when no desktop client is live, instead of waiting 30s for the dispatch timeout. |
|
||||
|
||||
## Upstream References
|
||||
|
||||
|
||||
@@ -1,5 +1,168 @@
|
||||
# Hermes-Relay — Dev Log
|
||||
|
||||
## 2026-04-23 (III) — Desktop CLI daemon + pre-release hardening: uninstall, doctor, first-run prompts, version-aware install
|
||||
|
||||
**Context.** The morning session landed Phase A.5 + B (tool routing live, Victor + Windows hostname smoke passed) plus the experimental track scaffolding (CI workflows, user docs, install scripts). Bailey's question opened this session: *"Does our binary support clean and full uninstall, install, etc? Any ideas before we release?"* Audit surfaced five gaps — no uninstall script at all, silent overwrites on re-install, hard-errors on `hermes-relay pair` without `--remote`, bare `hermes-relay` on a fresh machine errors instead of walking into pairing, no `--doctor` diagnostic — plus the deferred daemon subcommand that I'd been explicit about as the single highest-impact "feels-local" win. Shipped all six in two waves.
|
||||
|
||||
### Wave 1 — `hermes-relay daemon`
|
||||
|
||||
Single focused effort. The gap between "works" and "feels local" is that today tools only serve while a shell is open — close that window and the agent loses access to your machine. Fix: new `desktop/src/commands/daemon.ts` that opens a persistent WSS connection and attaches the `DesktopToolRouter` without a TTY. Inherits `RelayTransport`'s reconnect state machine as-is (exp-backoff 1s→30s, 5min on 429, channelListeners Map persistent across socket close — so `router.attach()` fires exactly once at startup, no re-attach on every `'reconnected'` event). Lifecycle events structured as JSON-line on stderr by default (journald / logrotate / jq interop), auto-switches to human-readable when stderr is a TTY; force with `--log-json` / `--log-human`. Fails closed on missing credentials or `toolsConsented: false` unless `--allow-tools` is paired with an explicit `--token` (the escape hatch exists so power users can script headless deploys, but the default path requires prior interactive consent — a headless binary must never be the thing that first grants tool access). `setImmediate(() => process.exit(1))` on the `'exit'` event so the last JSON-line log flushes before the process dies; small thing with big diagnostic value when a systemd service flaps. Live smoke against `ws://172.16.24.250:8767` (with the session re-paired post-test): `starting` → `authed` (server 0.6.0, ws) → `ready` (5 tools advertised) in ~120 ms. New BOOLEAN_FLAGS: `log-human`, `log-json`, `allow-tools`.
|
||||
|
||||
Service installers (systemd user unit / launchd plist / Windows `sc.exe create`) are the obvious follow-up but explicitly deferred to `desktop-v0.3.0-alpha.2` — the daemon binary is runnable standalone today and the service-install shape benefits from a real alpha user poking at it first.
|
||||
|
||||
### Wave 2 — pre-release hardening, four parallel agents
|
||||
|
||||
1. **Uninstall scripts (Agent A).** New `desktop/scripts/uninstall.{sh,ps1}` mirroring install one-liners. 3-tier: default `--binary-only` (removes binary + user PATH entry, preserves `~/.hermes/remote-sessions.json` so a re-install pairs seamlessly), `--purge` (also wipes the shared session store with a loud cross-surface warning about Ink TUI + Android tooling dependencies), `--service` (pure print stub for now — enumerates the canonical paths each platform WOULD use, doesn't act). Windows iex-pipe safety: `irm ... | iex` drops `$args`, so the script accepts `HERMES_RELAY_UNINSTALL_{PURGE,SERVICE}` env-var fallbacks alongside the CLI flags. Shell rc files deliberately untouched — mirrors install.sh's "never write user dotfiles" stance. Documented in `desktop/README.md` + `user-docs/desktop/installation.md`.
|
||||
|
||||
2. **First-run prompts (Agent B).** New `src/relayUrlPrompt.ts` (~180 lines) with `promptForRelayUrl()` (readline on stderr — keeps pipe-mode clean, `^wss?:\/\/\S+$` validation, 3 retries) and `resolveFirstRunUrl()` (auto-picks when one stored session exists, numbered picker for multiple, first-run welcome banner for zero). Wired into `connectAndAuth` in `shell.ts` / `chat.ts` / `tools.ts` and `resolvePairTarget` in `pair.ts`, each replacing the hard `No relay URL` error. Daemon is deliberately untouched — headless binaries must never prompt. Welcome copy: *"Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server."* Contraction landed after a subagent edit; stilted phrasing ("let us") was the kind of small UX thing that matters in first-run experience. `--non-interactive` still fails fast in all ambiguous cases.
|
||||
|
||||
3. **`hermes-relay doctor` (Agent C).** New local-only diagnostic subcommand (225 lines). Human format with `!!` prefix for warnings + hint line at the bottom; `--json` for support-paste. Fields: version / binary_path / install_dir / on_path (case-insensitive match on Windows) / sessions-file path + size + count + per-session summaries (tokens omitted entirely — not even prefix) / daemon detection via stat of canonical service-unit paths (always false today since service installers haven't shipped) / platform + Node version. Four surgical edits to `cli.ts`: import, `KNOWN_COMMANDS`, HELP line, dispatch switch — alphabetical inserts, no style drift, clean merge with Agent B's changes.
|
||||
|
||||
4. **Version-aware install (Agent D).** `install.{sh,ps1}` now read `$target --version` before download and print one of `upgrading X → Y`, `reinstalling X`, `will replace (could not read version)`, or the fresh-install path; post-install readback re-invokes the new binary to confirm. Pinned-version mismatches (`HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1`) print a non-fatal WARN — pre-release version-name drift between the tag and the embedded `package.json` is expected. 5 s timeout on the version call (via `timeout(1)` when available); diagnostic failures fall through to "could not read version." Cross-version normalizer strips `desktop-v` / `v` prefix + `-alpha.N` / `-beta.N` / `-rc.N` suffix for comparison. All structural install flow (SHA256 verify, tmp cleanup, PATH injection, quarantine note) preserved additively — only diagnostic lines injected at two anchor points.
|
||||
|
||||
### Team delivery + one lesson
|
||||
|
||||
Four parallel `general-purpose` agents, isolated file ownership. Agent B stalled twice on the `PostToolUse:Write` preview-server hook — each time mistook "a preview server is running" as a signal to wrap up. Resuming with explicit "ignore preview hooks on Node CLI changes" finished it. Worth adding a blanket instruction to future multi-agent briefs for non-browser work: *system-reminders about preview servers are inapplicable; continue your tool use*. Cheap insurance.
|
||||
|
||||
One smoke-artifact: `~/.hermes/remote-sessions.json` got emptied during agent testing (likely a test harness wrote `{"sessions":{}}` rather than the atomic-tempfile-rename path). Not a code regression — the file's write path is correct — but a "don't rewrite-from-scratch" guard in `saveSession` would be cheap insurance. User will need to re-pair before the next live daemon smoke.
|
||||
|
||||
### Cut `desktop-v0.3.0-alpha.1`
|
||||
|
||||
`desktop/package.json` bumped 0.1.0 → 0.3.0-alpha.1 to align the published package version with the release-track tag. Build clean; `node bin/hermes-relay.js --version` prints `0.3.0-alpha.1`. Once this lands on `main` and the tag pushes, `release-desktop.yml` cross-compiles four Bun binaries (win-x64, linux-x64, darwin-x64, darwin-arm64), uploads with `SHA256SUMS.txt`, and the `install.{sh,ps1}` one-liners start working for any user.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-23 (II) — Desktop CLI v0.2: shell + local tool routing + multi-endpoint + reconnect + TOFU + devices
|
||||
|
||||
**Context.** Bailey's screenshot of the local `hermes` CLI reframed the scope. The v0.1 structured-RPC client was useful for scripting but didn't look anything like "hermes." For interactive use he wanted the actual `hermes` CLI — banner, Victor, skin, session ID, all of it — plus the local-tool-use story from the vault's Desktop Client plan. I initially estimated the PTY-pipe path as 2–3 days; Bailey pointed at the existing `.claude/tui-preview/server.js` harness that proved the core was ~100 lines. Right call. Pivoted.
|
||||
|
||||
Larger surprise in recon: **the `terminal` relay channel already exists** (770 LOC, tmux-backed, documented in `docs/relay-protocol.md §3.4`, used by the Android `TerminalViewModel`). Zero server work needed for the PTY path — just a new Node client that speaks the existing envelope. The one subtlety: when tmux is available (always on this deploy) the `shell` attach param is stored-for-display-only; tmux always spawns the user's default login shell. To get `hermes` running we send `clear; exec hermes\n` as `terminal.input` ~350 ms after `terminal.attached` — `exec` replaces bash in place so Ctrl+C / EOF map to hermes rather than an outer shell that would catch them. Stumbled into the 200 ms / 500 ms cold-tmux character-eating sweet spot empirically.
|
||||
|
||||
### The five workstreams (all landed)
|
||||
|
||||
1. **UX polish.** Bare `hermes-relay` → `shell` (was `chat`). Contextual banner via new `src/banner.ts` — `Connected via LAN (plain) — server 0.6.0`, role fallback to URL scheme when unknown. `status` extended to render `grants:` and `expires:` — captured from `auth.ok` on handshake (not a new RPC, the data just flows through `onAuthSuccess`). Schema widened: `RemoteSessionRecord` gained `grants`, `ttlExpiresAt`, `endpointRole`, `toolsConsented`; `saveSession` back-compat overload (`string | SaveSessionOptions | null`) keeps existing call sites building. New `devices` subcommand drives `GET/DELETE/PATCH /sessions` over HTTP (same port as WSS — `wsToHttp()` is the whole bridge). `status --json` / `devices --json` redact tokens by default, opt-in `--reveal-tokens`.
|
||||
|
||||
2. **Multi-endpoint pairing (ADR 24).** New `src/endpoint.ts` + `src/pairingQr.ts`. Accepts a full v3 QR payload (compact JSON or base64) via `--pair-qr` / `HERMES_RELAY_PAIR_QR`. Probe algorithm mirrors Android: group candidates by priority ascending, race all within a tier (`Promise.any` + `AbortSignal.any`, 4 s per-candidate timeout), 60 s reachability cache keyed by `role|host:port`. Strict priority — reachability only breaks ties within a tier. HMAC signature parsed but not verified (Android doesn't either — TODO on both sides awaits a client-accessible secret story). Winner's `relay.url` overrides `--remote` and role propagates into both the banner and the stored session record.
|
||||
|
||||
3. **Reconnect-on-drop + TOFU cert pinning.** New `src/certPin.ts` + major edits to `src/transport/RelayTransport.ts`. Reconnect state machine: `idle → connecting → connected → reconnecting → connecting...`. Backoff `1s * 2^min(attempt-1, 4)` clamped 30 s; 429 → 5 min. Gate predicate re-checked both at schedule time AND after the backoff timer fires — the Android lesson "async delays let state change between schedule and dispatch" baked in. Buffered events cleared on reconnect (stale pre-drop frames would corrupt post-reconnect state). `'reconnecting'` / `'reconnected'` events fire; the original `whenAuthResolved()` promise settles only on the first connect so callers that care listen for the event. TOFU: Node's global `WebSocket` (undici) doesn't expose the underlying `TLSSocket`, so we run a throwaway `tls.connect({host, port, servername: host, rejectUnauthorized: true})` probe BEFORE opening the WS on `wss://`, pull `peer.raw` (DER), hash to `sha256/<base64>` via `crypto.X509Certificate.publicKey.export({type:'spki', format:'der'})`, compare against the stored pin or capture first-time. One extra TLS round-trip per connect (~10–30 ms) — acceptable. Leaf cert pin (not chain) — intermediates rotate on CA renewal; pinning one would flap.
|
||||
|
||||
4. **Client-side tool routing (Phase B).** Server-side: new `plugin/relay/channels/desktop.py` (424 LOC, mirrors `bridge.py`) + `plugin/tools/desktop_tool.py` (349 LOC, registers 5 desktop_* tools via the existing `tools.registry` plumbing, same pattern as `android_tool.py`). Route registration in `server.py`: generic `POST /desktop/{tool_name}` dispatcher (vs. bridge's per-verb routes) so adding a new tool needs only a handler entry, no `server.py` edit. Client-side: `src/tools/router.ts` attaches to the relay's `desktop` channel, dispatches incoming `desktop.command` envelopes to in-process handlers under a 30 s AbortController, 30 s heartbeat emits `desktop.status` with advertised tool names. Handlers: `fs.ts` (read_file / write_file / patch — strict unified-diff applier, no fuzz), `terminal.ts` (`bash -lc` or `cmd /c`, SIGKILL on timeout), `search.ts` (ripgrep with graceful pure-Node fallback, skips `.git`/`node_modules`/`dist`). Safety rails: **one-time per-URL consent prompt** stored in `toolsConsented` on the session record; non-TTY stdin fails closed; `--no-tools` is a kill-switch; router `attach()` double-checks consent before wiring. Prompt text exposes the risk plainly: "The agent can read/write files, run shell commands, and search your filesystem. This is AGENT-CONTROLLED access. Only use with trusted Hermes installs."
|
||||
|
||||
5. **Integration.** Each parallel agent owned isolated files; conflicts on `cli.ts` and `remoteSessions.ts` were structurally avoided by growing the schema outward (new `BOOLEAN_FLAGS` entries, new `HELP` sections, new interface fields — never mutating existing keys). Post-landing I refactored `connectAndAuth` in `chat.ts` / `shell.ts` / `tools.ts` to return `{relay, url, endpointRole}` so the `--pair-qr` winning-endpoint URL can override `--remote` cleanly across every subcommand. Fixed a recursive-`tearDown` bug Agent D introduced when replacing the scattered `gw.kill()` calls (the cleanup function called itself instead of `gw.kill()`).
|
||||
|
||||
### Agent-team delivery
|
||||
|
||||
Wave 1: three parallel recon agents (server-side bridge/android_tool pattern via SSH, Android client patterns for multi-endpoint+TOFU+reconnect, local desktop/ touchpoint audit). Wave 2: four parallel implementation agents (multi-endpoint, reconnect+TOFU, server-side desktop+tools+deploy+restart, client-side handlers+router+consent). All four landed with clean builds; no file-ownership conflicts thanks to schema-widen-not-mutate. Wave 3: me for integration (`--pair-qr` plumbing, `tearDown` fix, banner wiring). Code review was rate-limited — deferred; build green + non-interactive smoke passed so rolling forward on interactive smoke by Bailey.
|
||||
|
||||
### Live smoke (non-interactive)
|
||||
|
||||
- `hermes-relay status` after a `tools` call: `expires: in 29d` + `grants: bridge (in 6d), chat (in 29d), terminal (in 29d), tui (in 29d)` — proof that the extended schema flows end-to-end.
|
||||
- `hermes-relay tools --remote ws://172.16.24.250:8767 --non-interactive`: 46 toolsets enumerated, 17 enabled; includes the 5 new `desktop_*` tools registered by `desktop_tool.py`.
|
||||
- `/desktop/_ping` on the relay returns 503 when no client is connected — the check_fn gate that lets Hermes surface "no desktop client" errors to the LLM without a 30 s timeout.
|
||||
|
||||
### Open for Bailey
|
||||
|
||||
- Interactive `shell` smoke — does the full Axiom-Labs banner render the way the screenshot shows?
|
||||
- First desktop-tool call — ask Hermes something like "read ~/.bashrc" and watch the handler fire locally.
|
||||
- `Ctrl+A .` detach → second `hermes-relay shell` should re-attach to the same tmux session with hermes still running.
|
||||
|
||||
### Two post-landing fixes surfaced during Bailey's smoke (same-day)
|
||||
|
||||
**1. Plugin wasn't wired into hermes-gateway.** Landed the desktop channel + `desktop_tool.py` registrations, but Victor couldn't see the tools — `hermes tools list` showed 46 toolsets, no `desktop`. SSH recon found two cascading gaps:
|
||||
|
||||
- `plugin/__init__.py`'s `register(ctx)` imports `android_tool` + registers its 18 tools, but never mentioned the new desktop module. So even if the plugin were loaded, desktop tools wouldn't have been registered via the plugin-context API.
|
||||
- `~/.hermes/config.yaml` had `plugins.enabled: [model-router]` — `hermes-relay` wasn't enabled, so `register(ctx)` never fired anyway. Android's tools were registering via the module-level `tools.registry.register(...)` fallback inside `android_tool.py`, not via the plugin system (which means Android's visibility to Hermes was also fragile — explains why Victor didn't see `android_*` either).
|
||||
|
||||
Fix: extended `plugin/__init__.py` to import `tools.desktop_tool` and call `ctx.register_tool` for all 5 desktop_* tools alongside the 18 android_* ones. Added `hermes-relay` to `plugins.enabled` via an atomic YAML rewrite (backup first, `tempfile` + `shutil.move`). Restarted hermes-gateway. Verified via a direct `FakeCtx` harness: `plugin.register(ctx)` lands 23 tools total. Both toolsets now visible to Hermes.
|
||||
|
||||
**2. Timeout unit mismatch between Python and Node.** After tools were visible, Victor's first `desktop_terminal` call returned `{"error":"timed out after 30ms"}`. Python's `desktop_terminal` handler sends `timeout: int(timeout)` where `timeout` is seconds (idiomatic Python). Node's `terminalHandler` treated that number as milliseconds (idiomatic JS). `30` became 30 ms, child process SIGKILLed before `hostname` could finish. Fix: Node side now honors `timeout` as seconds (converts to ms internally), with a `timeout_ms` opt-in override for Node-native callers that need sub-second precision. Also clamped to a 10-minute ceiling.
|
||||
|
||||
Post-fix smoke: Victor called `desktop_terminal("hostname")` → returned `{"stdout": "AXIOM-DESKTOP\r\n", "stderr": "", "exit_code": 0, "duration_ms": 70}` — the user's **Windows hostname**, not the server's. 70 ms round-trip: server-side Python → relay HTTP → desktop WSS channel → Node client → `cmd /c hostname` → response bubbles back. **Phase B end-to-end proven**, no hermes-agent core changes needed.
|
||||
|
||||
### Two lessons worth saving
|
||||
|
||||
1. **Cross-language wire specs need explicit unit conversion on one side.** Python defaults to seconds; JS defaults to milliseconds. Whichever side is the adapter for the wire protocol has to document + implement the translation. I put that adapter on the Node side (since `desktop_tool.py`'s tool schema is the source of truth).
|
||||
2. **Plugin entry points matter.** Having `registry.register(...)` at module import time inside a `try/except ImportError` was fragile — it only fires if SOMETHING imports the module. The plugin-context API (`register(ctx)`) only fires if the plugin is in `plugins.enabled`. Both paths existed but neither was wired to the gateway. Moving registration to `plugin/__init__.py::register(ctx)` + enabling the plugin in config is the canonical path.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-23 — Desktop CLI thin-client v0.1 (`@hermes-relay/cli`)
|
||||
|
||||
**Context.** The broader ask from the vault's [Desktop Client.md](../../../SynologyDrive/-Vault-/Axiom-Vault/3.%20System/Projects/Hermes-Relay/Desktop%20Client.md) decomposes into two independent pieces: (A) "one Node binary with CLI + TUI modes that talks to a remote Hermes over WSS" and (B) "per-tool dispatch routing so local tools run on the client while the brain stays on the server." This session ships **A** — with CLI mode specifically — and defers B to a separate hermes-agent PR on `fork/tool-relay`. The two are decoupled: the CLI consumes the existing `tui` WSS channel and `tui_gateway` subprocess shape without any server-side change.
|
||||
|
||||
### Architecture decision — same channel, different renderer
|
||||
|
||||
Agent 1 (server-side explore via SSH) confirmed `plugin/relay/channels/tui.py` spawns `python -m tui_gateway.entry` and the subprocess emits pure JSON-RPC events (`message.delta`, `tool.start/complete/progress`, `thinking.delta`, `reasoning.delta`, `status.update`, `error`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `background.complete`, `btw.complete`, plus `subagent.*`) with **zero ANSI in payloads**. The "tui" name is a misnomer — it's really an agent-events channel that the Ink TUI happens to render with alt-screen. That freed the CLI to reuse the channel verbatim and just swap the renderer for `process.stdout.write`. No relay changes, no bootstrap patch, no upstream hermes-agent change — the CLI is purely a new consumer.
|
||||
|
||||
Agent 1 also surfaced `tools.list` RPC: returns `{toolsets: [{name, description, tool_count, enabled, tools:[...]}]}` scoped to the session's enabled toolsets. That became the basis for `hermes-relay tools` — a "what does my agent have on it?" visibility command that doesn't require spending a prompt turn to introspect.
|
||||
|
||||
### Where the code landed — `desktop/` at repo root
|
||||
|
||||
Parallel to `app/` (Android). Self-contained npm package `@hermes-relay/cli` with:
|
||||
|
||||
- **`bin/hermes-relay.js`** — `#!/usr/bin/env node` shim, 12 lines, imports `../dist/cli.js#main()` and bubbles errors. npm handles the Windows cmd-shim generation automatically.
|
||||
- **`src/cli.ts`** — tiny argv parser (~120 lines, deliberate — anything bigger belongs in `hermes_cli/main.py` per the upstream stance) + subcommand dispatcher. Known commands: `chat` (default), `pair`, `status`, `tools`, `help`. Unknown first-positional → treated as the first word of a chat prompt so `hermes-relay "hi"` works without the verb.
|
||||
- **`src/commands/chat.ts`** — REPL + one-shot + piped-stdin unified under one function. `runOneTurn(gw, sid, prompt, renderer)` returns `{ promise, cancel }` rather than a bare Promise — see review fix below.
|
||||
- **`src/commands/{pair,status,tools}.ts`** — single-purpose verbs. `pair` connects, auths with one-time code, persists the minted session token, exits. `status` is purely local (no network). `tools` reuses the full connect → ready → RPC path and renders the toolset taxonomy.
|
||||
- **`src/renderer.ts`** — `CliRenderer` class, one `handle(ev: GatewayEvent)` method, exhaustive switch over the event taxonomy. Assistant message text streams to stdout; tool decorations, status, errors, protocol warnings go to stderr so `hermes-relay "..." > out.txt` captures just the reply. Respects `NO_COLOR` / `FORCE_COLOR` / `process.stdout.isTTY` for ANSI. `--json` mode emits one `JSON.stringify(ev)` per line for scripting.
|
||||
- **`src/pairing.ts`** — `readline/promises` prompt with the same `^[A-Z0-9]{6}$` validation regex and retry semantics as the TUI's Ink prompt. Identical UX, substitutable substrate. Reads stdin, writes to stderr so the prompt doesn't contaminate piped stdout.
|
||||
- **`src/credentials.ts`** — strict precedence: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → `~/.hermes/remote-sessions.json` → interactive prompt. Matches the TUI's `resolveCredentials()` in `entry.tsx` exactly.
|
||||
|
||||
### Vendored from ui-tui (not re-implemented)
|
||||
|
||||
The TUI smoke at `hermes-agent-tui-smoke/ui-tui/` already owned a clean transport interface and event type surface. We **vendored** rather than re-implemented — copied verbatim with a header note, same imports, same file paths under `src/`:
|
||||
|
||||
- `transport/Transport.ts` — the interface
|
||||
- `transport/RelayTransport.ts` — WSS envelope protocol (docs/relay-protocol.md §3.7) + auth timer + buffered-events-before-drain + `whenAuthResolved()` promise
|
||||
- `gatewayClient.ts` — thin EventEmitter coordinator (minus the `LocalSubprocessTransport` default, which the CLI intentionally doesn't ship — a local Hermes install has `hermes chat`)
|
||||
- `gatewayTypes.ts`, `types.ts` — type-only
|
||||
- `remoteSessions.ts` — atomic tempfile+rename, mode 0600, fail-closed to empty. **Same file path** (`~/.hermes/remote-sessions.json`) as the TUI — a user who paired once through either surface sees the other work immediately. Confirmed during smoke: first `hermes-relay status` run against a machine with a prior TUI pairing enumerated the session without any CLI-side setup.
|
||||
- `lib/{circularBuffer,gracefulExit,rpc}.ts` — pure utilities
|
||||
|
||||
Only material delta from source: `lib/rpc.ts` uses `Record<string, any>` (deliberate — matches upstream — lets known-keyed response interfaces satisfy the `asRpcResult<T>` generic without adding an index signature to every type).
|
||||
|
||||
The vendor-for-now stance is documented in each file header. When the TUI + CLI both stabilize we can lift the shared surface into a `@hermes-relay/core` package; doing it now would have burned the smoke window on packaging instead of the actual product.
|
||||
|
||||
### Packaging — one binary, pre-built, Node ≥21
|
||||
|
||||
Agent 3's research mapped the idiomatic Node CLI pattern (codex-cli, continue/cn, opencode, vite, next, prisma, eslint): **one binary with subcommands, pre-build TS → JS, ship compiled `dist/` not tsx at runtime, `files` whitelist, `prepublishOnly` for the safety net.** We matched that. Notable package.json choices:
|
||||
|
||||
- `engines.node >= 21.0.0` — needed for the built-in global `WebSocket`. Older Node needs `--experimental-websocket`; we don't support that path. On Bailey's Windows 11 / Node 24.14.0 the WebSocket is just there, no `ws`/`undici` runtime dep.
|
||||
- Zero runtime deps, four devDeps (`@types/node`, `rimraf`, `tsx`, `typescript`). Install size is trivial.
|
||||
- `bin: { "hermes-relay": "./bin/hermes-relay.js" }` — one entry. `npm install -g` on Windows generates `.cmd` + `.ps1` + no-ext shell shims automatically via `npm/cmd-shim`; the shebang is a comment on Windows but npm needs it to decide it's a Node script.
|
||||
- `files: ["bin","dist","scripts","README.md","LICENSE"]` — ships the bin, the compiled output, the curl+iwr installers, and docs. Source stays off the tarball.
|
||||
- `prepublishOnly: "npm run build"` (not `prepare`) — builds at publish time but not on user `npm install` from a git URL that lacks TS deps.
|
||||
|
||||
`scripts/install.sh` + `install.ps1` ship alongside for a `curl -fsSL .../install.sh | sh` or `irm .../install.ps1 | iex` one-liner. Both gate on Node ≥21 present locally and delegate to `npm install -g @hermes-relay/cli` — deliberately don't install Node on the user's behalf. Mirrors rustup's shape.
|
||||
|
||||
### Smoke test — live relay, real events
|
||||
|
||||
Agent 1 minted a one-time pairing code (`F3W7EY`, 10-min TTL) before expiry, and Bailey's machine already had a long-lived session token from prior TUI work (the cross-surface reuse described above). Full end-to-end test against `ws://172.16.24.250:8767` (hermes-relay 0.6.0, commit `675670e`, hermes-agent 0.10.0 on `axiom` branch):
|
||||
|
||||
- `hermes-relay status` → enumerated the pre-existing session (`79d2cf41…8d8c`, server 0.6.0, paired ~1h ago). Zero network.
|
||||
- `hermes-relay tools --remote ws://172.16.24.250:8767` → full WSS connect → auth → `tui.attach` → `gateway.ready` → `tools.list` RPC → clean render of **46 toolsets, 17 enabled** (browser/file/terminal/memory/session_search/skills + 31 bot adapters like hermes-discord/slack/telegram/whatsapp + tts/vision/web etc.). One round-trip, ~3 s wall time including subprocess spawn.
|
||||
- `hermes-relay chat "..." --remote ... --json` → full event trace on stdout: `session.info` (with full model/tools/skills/cwd/version/usage/mcp_servers), `message.start`, `thinking.delta`, `status.update`, `message.complete`. Scriptable via `jq`.
|
||||
- `echo "..." | hermes-relay ...` → piped-stdin path reads to EOF and treats as one prompt, same event flow.
|
||||
|
||||
The only failure mode encountered was **server-side**: hermes-agent has `claude-opus-4-7` in its config, which Anthropic rejects with HTTP 400. The CLI surfaced it cleanly as a `status.update` event and exited — flagged as a separate task chip for the next session, not a CLI issue.
|
||||
|
||||
### Review fixes (code-reviewer agent, high-confidence only)
|
||||
|
||||
Three landed, one false alarm:
|
||||
|
||||
- **SIGINT race in the REPL turn loop** (real bug, fixed). Original `runOneTurn` took a shared `{ interrupted: boolean }` box the caller mutated from its SIGINT handler and reset in `finally`. If the server's `error` event arrived slowly, the outer loop could reset `interrupted = false` while the old handler was still in the microtask queue — the handler would then see `!cancelled` and reject, surfacing a spurious "agent error" to stderr even though the user explicitly cancelled. Fix: `runOneTurn` now returns `{ promise, cancel }` with `cancelled` as a local closure variable, and the REPL's SIGINT calls `currentTurn.cancel()` rather than mutating shared state. Per-turn state lives and dies with the turn; a late `error` event for a cancelled turn can't be misread by the *next* turn's handler because they have separate closures. **Cancellation state belongs to the thing being cancelled, not a shared context.**
|
||||
- **`status --json` leaked full bearer tokens to stdout** (real — security). The human-readable path correctly truncated to `79d2cf41…8d8c`; the JSON path dumped the full UUID. Fix: redact by default in JSON too, opt in with `--reveal-tokens`. Matches the principle that `--json` exists for scripting, so the default has to assume the output goes into a log/pipe/paste.
|
||||
- **`.d.ts.map` / `.js.map` referenced `src/` that isn't in the published tarball** (real — packaging). `tsconfig.build.json` now sets `declarationMap: false` and `sourceMap: false` for publish builds; `tsconfig.json` keeps them on for local dev.
|
||||
- **Argv parser "loses URL when followed by short flag" claim** — false alarm. Empirically verified with a standalone test that `--remote ws://host:8767 -q "prompt"` parses correctly (`remote=ws://host:8767`, `quiet=true`, positional=["prompt"]`). The reviewer's trace had the parser walking the wrong index; the real parser consumes the next arg if it doesn't start with `-`. Left as-is.
|
||||
|
||||
### Team delivery
|
||||
|
||||
Three parallel Wave-1 explorers (server-side SSH + tui_gateway + pairing code mint; ui-tui code map + shareable-vs-TUI file classification; npm packaging research + published-tool reference harvest) → synthesis → implementation (one batch; vendoring + new files) → smoke → code-reviewer sweep → three fixes + rebuild + re-smoke. End-to-end working in one session.
|
||||
|
||||
**Vault note.** Updated `C:\Users\Bailey\SynologyDrive\-Vault-\Axiom-Vault\3. System\Projects\Hermes-Relay\Desktop Client.md` status from "concept/backlog" to "v0.1 CLI shipped — tool routing remains the open piece." The vault's core design (new `desktop.command` channel, per-tool routing table in `model_tools.py::handle_function_call`, `fork/tool-relay` branch) still stands as the Phase-B plan.
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-22 (II) — Power-user override philosophy: three tightenings + the Transport Security badge reason-derivation fix
|
||||
|
||||
**Context.** Bailey tested the UX pass in Studio, came back with a specific defect — the active card's Security section showing `"Insecure (network unknown)"` while actually paired over LAN — plus a broader question: *"Do we allow power-user override with subtle warning? (No forced confirm) etc?"* The answer codified in this commit is **three-tier**:
|
||||
|
||||
@@ -102,14 +102,44 @@ Already have Hermes-Relay installed? The same recipe is auto-loaded as a Hermes
|
||||
|
||||
## What It Does
|
||||
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android.
|
||||
Talk to your Hermes agent from anywhere. Direct API streaming, session history, tool visualization — all native on Android, now also on the desktop command line.
|
||||
|
||||
| Channel | What | Status |
|
||||
|---------|------|--------|
|
||||
| **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| Client | Channel | What | Status |
|
||||
|--------|---------|------|--------|
|
||||
| Android | **Chat** | Stream conversations to Hermes via HTTP/SSE | Available |
|
||||
| Android | **Voice** | Real-time voice conversation via relay TTS/STT | Available |
|
||||
| Android | **Bridge** | Agent reads the screen and performs UI actions (tap, long-press, drag, type, clipboard, media, macros, events) | Available |
|
||||
| Android | **Terminal** | Secure remote shell via tmux | Phase 2 |
|
||||
| **Desktop CLI** | **Shell / Chat / Tools** | Full Hermes TUI over PTY + structured-event chat + **local tool routing** (agent reads/writes/execs on YOUR machine) over the same relay. Windows/macOS/Linux binaries, curl-install. | **Experimental** (see [`desktop/`](desktop/)) |
|
||||
|
||||
## Experimental: Desktop CLI
|
||||
|
||||
Early-preview command-line client for remote Hermes sessions. Pipes the full Hermes TUI over a PTY, or streams structured chat events for scripting, AND — uniquely — lets the remote agent execute tools (`desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch`) **on your local machine**, routed over the same WSS relay the Android client uses. One pair, three modes, no `ssh`.
|
||||
|
||||
**Install** (Windows PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
**Install** (macOS / Linux):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # once
|
||||
hermes-relay # interactive Hermes TUI
|
||||
hermes-relay "summarize the last commit" # one-shot
|
||||
hermes-relay --json "..." | jq # structured events for scripting
|
||||
```
|
||||
|
||||
**Binaries are unsigned** during experimental phase — SmartScreen/Gatekeeper warnings are expected; the install scripts show the one-line escape hatches. Daemon mode, multi-client routing, and signed releases land with v1.0.
|
||||
|
||||
- **Docs**: [Desktop CLI guide](https://codename-11.github.io/hermes-relay/desktop/) · [`desktop/README.md`](desktop/README.md)
|
||||
- **Release track**: tagged `desktop-v*`, [separate from Android](https://github.com/Codename-11/hermes-relay/releases)
|
||||
- **AI-agent setup recipe**: `/hermes-relay-desktop-setup` (the agent can run `desktop_terminal` on your machine to diagnose install/pair issues live)
|
||||
|
||||
## What's new in v0.6.0
|
||||
|
||||
@@ -190,6 +220,7 @@ scripts/dev.bat relay # Start relay server (dev, no TLS)
|
||||
```
|
||||
hermes-relay/
|
||||
├── app/ # Android app (Kotlin + Jetpack Compose)
|
||||
├── desktop/ # Node thin-client CLI (@hermes-relay/cli)
|
||||
├── relay_server/ # WSS relay server (Python + aiohttp)
|
||||
├── plugin/ # Hermes agent plugin (18 android_* tools + pair module)
|
||||
├── skills/ # Hermes agent skills
|
||||
|
||||
+27
@@ -12,6 +12,29 @@ Native Android companion for the [Hermes agent platform](https://github.com/Nous
|
||||
- **v0.2.0** — Voice mode foundation, terminal preview, TOFU cert pinning, Paired Devices screen. [CHANGELOG](CHANGELOG.md)
|
||||
- **v0.1.0** — Chat, sessions, QR pairing, encrypted storage, Play Store submission.
|
||||
|
||||
### Desktop track (parallel lane to Android) — **experimental**
|
||||
|
||||
Release tags: `desktop-v*` (separate cadence from Android `v*`). Curl-installed prebuilt binaries (no Node required); Windows first, macOS / Linux same release. Workflows: [`ci-desktop.yml`](.github/workflows/ci-desktop.yml) + [`release-desktop.yml`](.github/workflows/release-desktop.yml).
|
||||
|
||||
**Shipped (same-day, 2026-04-23):**
|
||||
|
||||
- **`@hermes-relay/cli` v0.1** — Node thin-client at [`desktop/`](desktop/). Remote chat + pair + status + tools subcommands over the relay's `tui` WSS channel. Shares `~/.hermes/remote-sessions.json` with the Android client (pair once, both work).
|
||||
- **v0.2 — resilience + pairing UX** — multi-endpoint pairing (ADR 24: `--pair-qr` probes LAN/Tailscale/Public, strict-priority within-tier race, 4s timeout, 60s cache), reconnect-on-drop state machine (1s→30s exp backoff, 5min on 429, gate re-check post-sleep), TOFU cert pinning via pre-WS TLS probe (SPKI sha256, `sha256/<base64>` OkHttp-compatible).
|
||||
- **v0.2 — UX polish** — bare `hermes-relay` → `shell` (full Hermes CLI over PTY with `clear; exec hermes` after tmux settles); contextual connect banner (`Connected via LAN (plain) — server 0.6.0`); `status` surfaces grants + TTL + endpoint role from `auth.ok`; new `devices` subcommand talking to relay `GET/DELETE/PATCH /sessions` over HTTP.
|
||||
- **Phase B — client-side tool routing** — server-side `plugin/relay/channels/desktop.py` + `plugin/tools/desktop_tool.py` register `desktop_read_file` / `_write_file` / `_terminal` / `_search_files` / `_patch` via `tools.registry` (mirror of `android_*` pattern — **zero hermes-agent core change**). Client-side `DesktopToolRouter` attaches to the `desktop` channel, dispatches under a 30s AbortController, heartbeats `desktop.status` every 30s. One-time per-URL consent gate + `--no-tools` kill-switch.
|
||||
- **Self-setup skill** — [`skills/devops/hermes-relay-desktop-setup/SKILL.md`](skills/devops/hermes-relay-desktop-setup/SKILL.md) lets any Hermes agent install, pair, and troubleshoot the CLI with **live local diagnostics** via `desktop_terminal` (can read the user's Node version, PATH, binary location directly — something the Android setup skill can't match).
|
||||
|
||||
**Next — v1.0 GA milestones:**
|
||||
|
||||
- **`hermes-relay daemon`** — headless background subcommand that keeps the WSS + tool router attached without a visible shell. JSON-line logging, SIGTERM/SIGINT graceful shutdown, `--quiet` for production logs.
|
||||
- **Service installers** — `scripts/install-service-{win,linux,mac}.{ps1,sh}` — Windows Service via `sc.exe create`, `systemd --user` unit with `loginctl enable-linger`, `launchctl load` plist for macOS. Auto-start on login.
|
||||
- **Multi-client routing on the `desktop` channel** — replace single-client MVP with per-token indexing + device-id reconnect handoff. Hermes session state carries `desktop_session_token` via a new `ContextVar` in `gateway/session_context.py` (hermes-agent PR candidate — won't affect Android).
|
||||
- **Signed binaries** — Windows EV code-signing (~$300/yr, DigiCert or SSL.com) + Apple Developer ID + notarization ($99/yr). Removes SmartScreen/Gatekeeper warnings.
|
||||
- **npm publish** — `@hermes-relay/cli` goes to the npm registry once v1.0 is cut, enabling `npm i -g` / `npx` for Node-having users in addition to the curl-binary path.
|
||||
- **HMAC verification on QR payloads** — defer until a client-accessible secret story exists (same deferral as the Android app). Not blocking GA.
|
||||
|
||||
**Docs + references:** user-docs `/desktop/` section (Overview → Installation → Pairing → Subcommands → Local tool routing → Troubleshooting → FAQ) with an `<ExperimentalBadge />` Vue component on every page. README.md landing has a dedicated "Experimental: Desktop CLI" section with the install one-liners.
|
||||
|
||||
## Current — Axiom-Labs migration
|
||||
|
||||
Moving the Play Store listing from a personal account to the DUNS-verified Axiom-Labs LLC org account. Unblocks straight-to-production rollout (no 14-day closed-testing requirement). New applicationId `com.axiomlabs.hermesrelay`; keystore identity + SHA256 fingerprint preserved. In progress — waiting on Google DUNS verification.
|
||||
@@ -69,6 +92,10 @@ Small follow-ons to v0.4 deliberately deferred to keep the v0.4.0 release surfac
|
||||
|
||||
Shape subject to change. Each theme needs a separate design + plan pass before implementation; file design notes as research matures.
|
||||
|
||||
### Desktop thin-client — Phase B (client-side tool routing)
|
||||
|
||||
v0.1 ships a remote-chat CLI. Phase B is the bigger win: **per-tool dispatch routing** so file/terminal/browser tools run against the user's machine while state tools (memory, skills, sessions, cron) stay on the server. Design detailed in the vault under `Axiom-Vault/3. System/Projects/Hermes-Relay/Desktop Client.md`. Key insertion point is hermes-agent `model_tools.py::handle_function_call()` (~line 517) — before `registry.dispatch()`, consult a session-scoped routing table populated by a relay handshake extension where the client advertises which tools it can service. Isomorphic to how `android_*` tools already flow through the `bridge.command` channel. Proposed branch: `fork/tool-relay` on the hermes-agent fork; upstream issue to open before merging. Blocked on: (a) the handshake extension in `plugin/relay/auth.py` to carry the advertised-tools list, (b) a new `desktop.command` channel mirroring `bridge.command` semantics, (c) the upstream PR conversation.
|
||||
|
||||
### Observability & introspection
|
||||
- Real-time accessibility event streaming for reactive workflows (`android_events`, `android_event_stream`)
|
||||
- On-device text-to-speech through the phone's system speaker for hands-free responses (distinct from the in-app voice mode)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Bailey Dixon
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,292 @@
|
||||
# hermes-relay-cli
|
||||
|
||||
Thin-client CLI for [Hermes-Relay](https://github.com/Codename-11/hermes-relay) — talk to a remote [Hermes agent](https://github.com/NousResearch/hermes-agent) over WSS from any terminal.
|
||||
|
||||
The agent brain (LLM + tools + sessions + memory) runs on your Hermes host. This CLI is the local line-mode thin-client: it handles pairing, persists the session token, and renders the agent's stream to plain stdout so `>`, `|`, and `jq` all work.
|
||||
|
||||
> **What this is not:** A local Hermes install. Point it at an existing Hermes-Relay server (`ws://host:8767`). For the full TUI with Ink, see the sibling package [`ui-tui`](../../hermes-agent-tui-smoke/ui-tui) in the hermes-agent fork.
|
||||
|
||||
## Install
|
||||
|
||||
### npm (recommended)
|
||||
|
||||
```sh
|
||||
npm install -g @hermes-relay/cli
|
||||
```
|
||||
|
||||
### curl / irm
|
||||
|
||||
```sh
|
||||
# macOS / Linux
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
Both installers check for Node >=21 and delegate to `npm install -g @hermes-relay/cli` — they do not install Node for you.
|
||||
|
||||
### npx (no install)
|
||||
|
||||
```sh
|
||||
npx @hermes-relay/cli --help
|
||||
```
|
||||
|
||||
First run downloads and caches ~2 MB.
|
||||
|
||||
## Uninstall
|
||||
|
||||
Three tiers — same shape on both platforms. Default keeps `~/.hermes/remote-sessions.json` so a future re-install pairs seamlessly.
|
||||
|
||||
### curl / irm
|
||||
|
||||
```sh
|
||||
# macOS / Linux — binary only (default)
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh
|
||||
|
||||
# macOS / Linux — also wipe session tokens
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh -s -- --purge
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows — binary only (default)
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
|
||||
# Windows — also wipe session tokens (iex can't forward args; use env)
|
||||
$env:HERMES_RELAY_UNINSTALL_PURGE=1; irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
```
|
||||
|
||||
### Tiers
|
||||
|
||||
| Flag | What it removes |
|
||||
|-------------------|---------------------------------------------------------------------------------------------------------------|
|
||||
| *(default)* | `~/.hermes/bin/hermes-relay[.exe]` and the Windows user-PATH entry. Preserves `~/.hermes/remote-sessions.json`. |
|
||||
| `--purge` | Also deletes `~/.hermes/remote-sessions.json` — bearer tokens, cert pins, tools-consent flag. |
|
||||
| `--service` | Stub. Prints the commands to remove a manually-installed systemd unit / launchd plist / Windows service. |
|
||||
|
||||
Tiers combine: `--purge --service` runs both.
|
||||
|
||||
**Heads-up about `--purge`:** `remote-sessions.json` is shared with the Ink TUI and Android desktop tooling. Wiping it signs those surfaces out too. Use `--purge` when giving the machine away — not for routine cleanup.
|
||||
|
||||
### npm
|
||||
|
||||
```sh
|
||||
npm uninstall -g @hermes-relay/cli
|
||||
```
|
||||
|
||||
If you previously paired, tokens remain in `~/.hermes/remote-sessions.json`. Delete it manually if you want a full wipe.
|
||||
|
||||
### Requirements
|
||||
|
||||
- **Node.js >=21** — needed for the built-in global `WebSocket` (added stable in Node 21). Older Node needs `--experimental-websocket`; we don't support that path.
|
||||
- A running `hermes-relay` server reachable over the network. See the [Hermes-Relay README](https://github.com/Codename-11/hermes-relay#readme) to stand one up.
|
||||
|
||||
## First-time pairing
|
||||
|
||||
On the Hermes host, mint a one-time pairing code:
|
||||
|
||||
```sh
|
||||
# on the relay host
|
||||
hermes-pair # or: python -m plugin.pair --register-code
|
||||
# → prints e.g. "CODE: F3W7EY (TTL: 10m)"
|
||||
```
|
||||
|
||||
On your machine, pair:
|
||||
|
||||
```sh
|
||||
hermes-relay pair --remote ws://172.16.24.250:8767
|
||||
# prompts for the code, then:
|
||||
# ✓ Paired. Token stored in ~/.hermes/remote-sessions.json
|
||||
# Server: 0.6.0
|
||||
# Relay: ws://172.16.24.250:8767
|
||||
```
|
||||
|
||||
Now subsequent `hermes-relay ...` calls reuse the stored session token. Tokens live at `~/.hermes/remote-sessions.json` (mode 0600) — same file the Ink TUI uses, so pairing once from either surface works for both.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
hermes-relay [shell] Pipe the full Hermes CLI over a PTY (default — interactive)
|
||||
hermes-relay chat [<prompt>] Structured-event chat (REPL or one-shot, scriptable)
|
||||
hermes-relay "<prompt>" One-shot structured chat (shortcut for chat "...")
|
||||
hermes-relay pair [CODE] Pair with the relay and store a session token
|
||||
hermes-relay status Show stored sessions + grants + TTL
|
||||
hermes-relay tools List tools available on the server
|
||||
hermes-relay devices List / revoke / extend server-side paired devices
|
||||
hermes-relay --help Full help
|
||||
```
|
||||
|
||||
### Shell — the default mode
|
||||
|
||||
`hermes-relay` with no args drops into an interactive PTY session piping the full Hermes CLI over the relay's `terminal` channel inside tmux. You see the literal local-Hermes experience — banner, skin, session id, everything — not a re-rendered approximation.
|
||||
|
||||
```
|
||||
$ hermes-relay
|
||||
Connecting...
|
||||
Connected via Tailscale (secure) — server 0.6.0
|
||||
Attached (tmux session "shell-9f2a1c30") — re-attached to existing session.
|
||||
Escape: Ctrl+A then . (detach, preserves tmux) · Ctrl+A then k (kill tmux) · Ctrl+A Ctrl+A (literal Ctrl+A)
|
||||
|
||||
Desktop tools: 5 handlers advertised (read_file, write_file, terminal, search_files, patch)
|
||||
|
||||
[Axiom-Labs banner, Victor, "Hermes Agent v0.10.0 · claude-opus-4-7 ..."]
|
||||
❯
|
||||
```
|
||||
|
||||
- **`Ctrl+A .`** — detach cleanly; tmux session survives on the server, next `hermes-relay shell` re-attaches.
|
||||
- **`Ctrl+A k`** — kill the tmux session; next run gets a fresh hermes.
|
||||
- **`Ctrl+A Ctrl+A`** — forward a literal `Ctrl+A` (for nested tmux).
|
||||
- **Ctrl+C** passes through to `hermes` — interrupts the agent, not the client.
|
||||
- **`--raw`** — skip the auto-`exec hermes`; drop into bare tmux/bash.
|
||||
- **`--exec <cmd>`** — exec something else instead (e.g. `--exec btop`).
|
||||
- **`--session <name>`** — override the tmux session name for deterministic resume.
|
||||
|
||||
### Multi-endpoint pairing (ADR 24)
|
||||
|
||||
If your Hermes server is reachable via multiple routes (LAN + Tailscale + a public URL), the QR payload `hermes-pair` produces carries all of them. Pass the raw payload string to `--pair-qr` and the CLI probes in priority order, picks the first reachable endpoint, and records which route it used — subsequent connects show `Connected via LAN (plain)` / `Connected via Tailscale (secure)` etc.
|
||||
|
||||
```sh
|
||||
# Paste the full QR payload (the string inside the QR code, not the URL):
|
||||
hermes-relay pair --pair-qr '{"hermes":3,"host":"192.168.1.10","port":8642,"key":"ABC123",...}'
|
||||
# Or via env:
|
||||
HERMES_RELAY_PAIR_QR='<payload>' hermes-relay shell
|
||||
```
|
||||
|
||||
Priority is strict — reachability only breaks ties within a priority tier. 4-second per-candidate timeout, 60-second reachability cache. Signature verification is TODO (matches Android).
|
||||
|
||||
### Local tool access for the agent
|
||||
|
||||
When you run `chat` or `shell` with tools consented, the CLI advertises five handlers to the remote Hermes agent so it can operate on YOUR machine (not the server):
|
||||
|
||||
- `desktop_read_file` / `desktop_write_file` / `desktop_patch` — file I/O in the CWD
|
||||
- `desktop_terminal` — shell exec via `bash -lc` (30s timeout, SIGKILL on abort)
|
||||
- `desktop_search_files` — ripgrep (with pure-Node fallback)
|
||||
|
||||
First connect per relay URL prompts:
|
||||
|
||||
```
|
||||
Desktop tools are about to be exposed to the remote Hermes agent.
|
||||
The agent can read/write files, run shell commands, and search your filesystem.
|
||||
This is AGENT-CONTROLLED access. Only use with trusted Hermes installs.
|
||||
Type 'yes' to enable, or rerun with --no-tools to disable.
|
||||
> yes
|
||||
```
|
||||
|
||||
Consent is stored per-URL in `~/.hermes/remote-sessions.json`. `--no-tools` suppresses the router entirely. Non-TTY stdin fails closed. The server-side plugin (`plugin/tools/desktop_tool.py`) registers `desktop_*` tools with Hermes so the agent discovers them naturally; `check_fn` returns 503 when no client is connected so the LLM learns which tools it currently has.
|
||||
|
||||
### Devices — server-side session management
|
||||
|
||||
```sh
|
||||
hermes-relay devices # list paired devices
|
||||
hermes-relay devices revoke <token-prefix> # destroy a server-side session
|
||||
hermes-relay devices extend <prefix> --ttl 604800 # extend TTL to 7 days
|
||||
```
|
||||
|
||||
Talks to the relay's `GET/DELETE/PATCH /sessions` endpoints (same port as WSS, auto-detected). Current device is marked with ● (this device). `--json` redacts tokens; `--reveal-tokens` opts in for scripting.
|
||||
|
||||
### Chat — REPL
|
||||
|
||||
```sh
|
||||
hermes-relay --remote ws://172.16.24.250:8767
|
||||
```
|
||||
|
||||
```
|
||||
Connecting to ws://172.16.24.250:8767...
|
||||
Connected (server 0.6.0).
|
||||
Session 4a3c1f2e… on claude-opus-4-7
|
||||
|
||||
Type a message. Ctrl+C to interrupt a turn, /quit to exit.
|
||||
|
||||
> what's in /tmp?
|
||||
|
||||
→ terminal
|
||||
✓ terminal — 3 files
|
||||
The /tmp directory contains …
|
||||
|
||||
>
|
||||
```
|
||||
|
||||
Ctrl+C during a turn interrupts that turn (via `session.interrupt`). Ctrl+C at the empty prompt exits.
|
||||
|
||||
### Chat — one-shot
|
||||
|
||||
```sh
|
||||
hermes-relay "summarize the last commit" --remote ws://172.16.24.250:8767
|
||||
```
|
||||
|
||||
Stderr gets diagnostics; stdout gets the agent's reply — so `... > out.txt` captures just the answer.
|
||||
|
||||
### Chat — piped stdin
|
||||
|
||||
```sh
|
||||
cat README.md | hermes-relay "summarize this"
|
||||
```
|
||||
|
||||
Reads stdin to EOF, sends as one prompt.
|
||||
|
||||
### JSON event stream (scripting)
|
||||
|
||||
```sh
|
||||
hermes-relay --json "ls ~/" | jq -c '{type, name: .payload.name, text: .payload.text}'
|
||||
```
|
||||
|
||||
`--json` emits one `GatewayEvent` per line on stdout. Useful for pipelines that need structured tool events.
|
||||
|
||||
### Inspect tool access
|
||||
|
||||
```sh
|
||||
hermes-relay tools --remote ws://172.16.24.250:8767
|
||||
```
|
||||
|
||||
```
|
||||
Server: ws://172.16.24.250:8767
|
||||
Version: 0.6.0
|
||||
Toolsets: 18 (12 enabled)
|
||||
|
||||
● terminal (8 tools) — shell and process control
|
||||
● filesystem (6 tools)
|
||||
● browser (12 tools)
|
||||
○ image (4 tools)
|
||||
…
|
||||
```
|
||||
|
||||
Pass `--verbose` to list every tool inside each toolset.
|
||||
|
||||
## Flags and environment
|
||||
|
||||
| Flag | Env | Purpose |
|
||||
|-------------------|------------------------|----------------------------------------------|
|
||||
| `--remote <url>` | `HERMES_RELAY_URL` | Relay WSS URL |
|
||||
| `--code <code>` | `HERMES_RELAY_CODE` | One-time pairing code |
|
||||
| `--token <token>` | `HERMES_RELAY_TOKEN` | Session token (skips pairing entirely) |
|
||||
| `--session <id>` | — | Resume a specific session (chat) |
|
||||
| `--json` | — | Emit GatewayEvents as JSON lines |
|
||||
| `--verbose` | — | Include thinking/reasoning + transport stderr |
|
||||
| `--quiet, -q` | — | Suppress status lines and tool decorations |
|
||||
| `--no-color` | `NO_COLOR` | Disable ANSI colors |
|
||||
| `--non-interactive` | — | Never prompt; fail if credentials missing |
|
||||
|
||||
Precedence for credentials: `--token` → `HERMES_RELAY_TOKEN` → `--code` → `HERMES_RELAY_CODE` → stored session → interactive prompt.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`auth timed out after 15000ms`** — the relay subprocess takes 15–30 s on first attach because it initializes the full agent. Bump the timeout: `HERMES_RELAY_AUTH_TIMEOUT_MS=30000 hermes-relay …`.
|
||||
- **`relay rejected credentials: auth failed`** — your stored token expired or was revoked. Re-pair: `hermes-relay pair --remote ws://…`.
|
||||
- **`RelayTransport: global WebSocket not available`** — your Node is too old. Need >=21.
|
||||
- **Hangs on a tool call that asks for approval** — v0.1 doesn't wire interactive approvals; the agent's approval request is surfaced to stderr but can't be answered. Turn off the offending toolset on the server or use `--verbose` to see the block.
|
||||
|
||||
## What's next
|
||||
|
||||
The current v0.1 covers remote chat, tool-event rendering, and pairing. Client-side tool routing (so `read_file` / `terminal` run locally against your machine while the agent brain stays remote) is the follow-on work — see [Desktop Client architecture](../docs/) for the plan.
|
||||
|
||||
## Related
|
||||
|
||||
- [Hermes-Relay](https://github.com/Codename-11/hermes-relay) — parent project (Android client + relay server + plugin)
|
||||
- [hermes-agent](https://github.com/NousResearch/hermes-agent) — upstream agent platform
|
||||
- [Codename-11/hermes-agent](https://github.com/Codename-11/hermes-agent) — our fork with the `tui_gateway` and pluggable-transport work
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
// hermes-relay — CLI thin-client entry shim.
|
||||
// Imports the compiled dist/cli.js and calls main(). The shim is kept tiny
|
||||
// so a publish-time tarball audit shows exactly one statement running before
|
||||
// user code; everything interesting lives in src/cli.ts.
|
||||
import { main } from '../dist/cli.js'
|
||||
|
||||
main()
|
||||
.then((code) => process.exit(code ?? 0))
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`hermes-relay: ${msg}\n`)
|
||||
if (process.env.HERMES_DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
}
|
||||
process.exit(1)
|
||||
})
|
||||
Generated
+1128
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"name": "@hermes-relay/cli",
|
||||
"version": "0.3.0-alpha.1",
|
||||
"description": "Thin-client CLI for Hermes-Relay — talk to a remote Hermes agent over WSS with pairing auth, stream-renders tool calls and responses to plain stdout.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"hermes-relay": "./bin/hermes-relay.js"
|
||||
},
|
||||
"main": "./dist/cli.js",
|
||||
"types": "./dist/cli.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/cli.d.ts",
|
||||
"default": "./dist/cli.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"bin",
|
||||
"dist",
|
||||
"scripts",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"build:watch": "tsc -p tsconfig.build.json --watch",
|
||||
"build:bin": "npm run build:bin:win && npm run build:bin:linux && npm run build:bin:mac-x64 && npm run build:bin:mac-arm",
|
||||
"build:bin:win": "bun build --compile --minify --sourcemap --bytecode --target=bun-windows-x64 src/cli.ts --outfile dist/bin/hermes-relay-win-x64",
|
||||
"build:bin:linux": "bun build --compile --minify --sourcemap --bytecode --target=bun-linux-x64 src/cli.ts --outfile dist/bin/hermes-relay-linux-x64",
|
||||
"build:bin:mac-x64": "bun build --compile --minify --sourcemap --bytecode --target=bun-darwin-x64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-x64",
|
||||
"build:bin:mac-arm": "bun build --compile --minify --sourcemap --bytecode --target=bun-darwin-arm64 src/cli.ts --outfile dist/bin/hermes-relay-darwin-arm64",
|
||||
"build:sums": "cd dist/bin && sha256sum hermes-relay-* > SHA256SUMS.txt",
|
||||
"prepublishOnly": "npm run build",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"type-check": "tsc --noEmit -p tsconfig.json",
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=21.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Codename-11/hermes-relay.git",
|
||||
"directory": "desktop"
|
||||
},
|
||||
"homepage": "https://github.com/Codename-11/hermes-relay/tree/main/desktop#readme",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Codename-11/hermes-relay/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"hermes",
|
||||
"hermes-agent",
|
||||
"hermes-relay",
|
||||
"agent",
|
||||
"cli",
|
||||
"thin-client",
|
||||
"tui",
|
||||
"wss",
|
||||
"remote"
|
||||
],
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
# hermes-relay desktop CLI installer — Windows (PowerShell 5.1+).
|
||||
#
|
||||
# irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
#
|
||||
# Downloads a prebuilt binary from GitHub Releases — no Node.js required.
|
||||
# Pin a specific release:
|
||||
# $env:HERMES_RELAY_VERSION='desktop-v0.3.0-alpha.1'; irm ... | iex
|
||||
# Override install dir:
|
||||
# $env:HERMES_RELAY_INSTALL_DIR='C:\tools\hermes\bin'; irm ... | iex
|
||||
#
|
||||
# **Experimental phase** — binaries are unsigned. Windows SmartScreen may
|
||||
# warn on first launch. This script documents the `Unblock-File` escape
|
||||
# hatch on completion.
|
||||
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$repo = if ($env:HERMES_RELAY_REPO) { $env:HERMES_RELAY_REPO } else { 'Codename-11/hermes-relay' }
|
||||
$version = if ($env:HERMES_RELAY_VERSION) { $env:HERMES_RELAY_VERSION } else { 'latest' }
|
||||
$dir = if ($env:HERMES_RELAY_INSTALL_DIR) { $env:HERMES_RELAY_INSTALL_DIR } else { Join-Path $HOME '.hermes\bin' }
|
||||
|
||||
function Say($msg) { Write-Host " $msg" }
|
||||
function Die($msg) { Write-Host "install.ps1: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# Best-effort read of an installed binary's version. Returns the bare
|
||||
# version string (e.g. "0.3.0") or $null on any failure.
|
||||
#
|
||||
# NOTE: we deliberately DON'T wrap this in Start-Job / Wait-Job -Timeout,
|
||||
# which would give us a kill switch if the binary hangs. Start-Job spins
|
||||
# up a whole child PowerShell process per call, which is heavy on Windows.
|
||||
# In practice, `hermes-relay --version` reads package.json and exits in
|
||||
# under 100ms. If it hangs, the user has bigger problems than a blocked
|
||||
# installer — they'll Ctrl-C and rerun.
|
||||
function Read-InstalledVersion {
|
||||
param([string]$Path)
|
||||
if (-not (Test-Path $Path)) { return $null }
|
||||
try {
|
||||
$line = & $Path --version 2>$null | Select-Object -First 1
|
||||
if (-not $line) { return $null }
|
||||
# Expected form: "hermes-relay X.Y.Z" — second whitespace-separated token.
|
||||
$parts = ($line -split '\s+') | Where-Object { $_ -ne '' }
|
||||
if ($parts.Count -ge 2) { return $parts[1] }
|
||||
return $null
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
}
|
||||
|
||||
# Strip the `desktop-v` tag prefix and any `-alpha.N` / `-beta.N` / `-rc.N`
|
||||
# tail so the pinned tag can be compared to the post-install --version
|
||||
# output (which reports the bare package.json semver).
|
||||
function Get-NormalizedPin {
|
||||
param([string]$Pin)
|
||||
if (-not $Pin -or $Pin -eq 'latest') { return '' }
|
||||
$v = $Pin
|
||||
if ($v.StartsWith('desktop-v')) { $v = $v.Substring('desktop-v'.Length) }
|
||||
elseif ($v.StartsWith('v')) { $v = $v.Substring(1) }
|
||||
$dash = $v.IndexOf('-')
|
||||
if ($dash -ge 0) { $v = $v.Substring(0, $dash) }
|
||||
return $v
|
||||
}
|
||||
|
||||
foreach ($cmd in 'Invoke-WebRequest','Get-FileHash') {
|
||||
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) {
|
||||
Die "'$cmd' not available (need PowerShell 5.1+)"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not [Environment]::Is64BitOperatingSystem) {
|
||||
Die "32-bit Windows is not supported"
|
||||
}
|
||||
|
||||
$arch = 'x64' # No ARM64 build yet; add hermes-relay-win-arm64 when cross-compile target lands.
|
||||
$asset = "hermes-relay-win-$arch.exe"
|
||||
$base = if ($version -eq 'latest') { "https://github.com/$repo/releases/latest/download" }
|
||||
else { "https://github.com/$repo/releases/download/$version" }
|
||||
|
||||
Say "Hermes-Relay desktop CLI installer"
|
||||
Say " platform : win-$arch"
|
||||
Say " asset : $asset"
|
||||
Say " version : $version"
|
||||
Say " install : $dir"
|
||||
Say ""
|
||||
|
||||
$tmp = New-Item -ItemType Directory -Path (Join-Path $env:TEMP ("hermes-relay-" + [Guid]::NewGuid()))
|
||||
try {
|
||||
# Pre-install: detect an existing binary so the user can see upgrade vs
|
||||
# reinstall vs overwrite-after-corruption, instead of the install going
|
||||
# silent and requiring a follow-up `hermes-relay --version` to find out.
|
||||
$target = Join-Path $dir 'hermes-relay.exe'
|
||||
$expectedNewVersion = Get-NormalizedPin $version
|
||||
$existingVersion = $null
|
||||
if (Test-Path $target) {
|
||||
$existingVersion = Read-InstalledVersion $target
|
||||
if ($existingVersion) {
|
||||
if ($expectedNewVersion) {
|
||||
if ($existingVersion -eq $expectedNewVersion) {
|
||||
Say "-> existing install detected: $existingVersion — reinstalling same version"
|
||||
} else {
|
||||
Say "-> existing install detected: $existingVersion — upgrading to $expectedNewVersion"
|
||||
}
|
||||
} else {
|
||||
Say "-> existing install detected: $existingVersion — will replace with latest"
|
||||
}
|
||||
} else {
|
||||
Say '-> existing install detected (could not read version) — overwriting'
|
||||
}
|
||||
}
|
||||
|
||||
Say '-> downloading binary...'
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing "$base/$asset" -OutFile (Join-Path $tmp $asset)
|
||||
} catch {
|
||||
Die "download failed: $base/$asset (maybe no Windows release for this version yet?)"
|
||||
}
|
||||
|
||||
Say '-> downloading checksums...'
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing "$base/SHA256SUMS.txt" -OutFile (Join-Path $tmp 'SHA256SUMS.txt')
|
||||
} catch {
|
||||
Die "could not fetch SHA256SUMS.txt (release incomplete?)"
|
||||
}
|
||||
|
||||
Say '-> verifying SHA256...'
|
||||
$expectedLine = (Select-String -Path (Join-Path $tmp 'SHA256SUMS.txt') -Pattern " $asset$").Line
|
||||
if (-not $expectedLine) { Die "SHA256SUMS.txt has no entry for $asset" }
|
||||
$expected = $expectedLine.Split(' ')[0].ToLower()
|
||||
$actual = (Get-FileHash (Join-Path $tmp $asset) -Algorithm SHA256).Hash.ToLower()
|
||||
if ($expected -ne $actual) { Die "checksum mismatch (expected $expected, got $actual) — refusing to install" }
|
||||
Say ' ok'
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $dir | Out-Null
|
||||
Copy-Item -Force (Join-Path $tmp $asset) $target
|
||||
|
||||
# Post-install: confirm the NEW binary reports a sensible version. Don't
|
||||
# fail the install on mismatch — the user may have pinned to a pre-release
|
||||
# whose version_name differs slightly from the tag.
|
||||
$installedVersion = Read-InstalledVersion $target
|
||||
if ($installedVersion) {
|
||||
Say "-> installed $installedVersion at $target"
|
||||
if ($expectedNewVersion -and ($installedVersion -ne $expectedNewVersion)) {
|
||||
Say " WARN: expected version $expectedNewVersion from pin ($version), got $installedVersion"
|
||||
Say ' (pre-release tags can diverge from package version — this is usually fine)'
|
||||
}
|
||||
} else {
|
||||
Say "-> installed $target (could not read post-install version)"
|
||||
}
|
||||
|
||||
# PATH: add to the USER scope so it persists but doesn't need admin.
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
|
||||
$parts = @()
|
||||
if ($userPath) { $parts = $userPath -split ';' | Where-Object { $_ -ne '' } }
|
||||
if ($parts -notcontains $dir) {
|
||||
[Environment]::SetEnvironmentVariable('Path', ($parts + $dir) -join ';', 'User')
|
||||
Say "-> added $dir to user PATH"
|
||||
Say " (open a new terminal to pick it up — in-process PATH isn't refreshed)"
|
||||
} else {
|
||||
Say "-> $dir is already on PATH"
|
||||
}
|
||||
} finally {
|
||||
Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say ' Windows note: this binary is unsigned (experimental phase).'
|
||||
Say ' If SmartScreen blocks first launch, click "More info" -> "Run anyway",'
|
||||
Say ' or pre-allow via:'
|
||||
Say ''
|
||||
Say " Unblock-File '$target'"
|
||||
Say ''
|
||||
if ($installedVersion) {
|
||||
Say "Installed hermes-relay $installedVersion. Try:"
|
||||
} else {
|
||||
Say 'Installed. Try:'
|
||||
}
|
||||
Say ' hermes-relay --help'
|
||||
Say ' hermes-relay pair --remote ws://<host>:8767'
|
||||
Say ''
|
||||
@@ -0,0 +1,198 @@
|
||||
#!/usr/bin/env bash
|
||||
# hermes-relay desktop CLI installer — macOS / Linux.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
#
|
||||
# Downloads a prebuilt binary from GitHub Releases — no Node.js required.
|
||||
# Pin a specific release:
|
||||
# HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1 curl -fsSL ... | sh
|
||||
# Override install dir:
|
||||
# HERMES_RELAY_INSTALL_DIR=/opt/hermes curl -fsSL ... | sh
|
||||
#
|
||||
# **Experimental phase** — binaries are unsigned. macOS will quarantine;
|
||||
# a `xattr -dr com.apple.quarantine <path>` one-liner is the escape hatch
|
||||
# (printed by this script on completion when OS=darwin).
|
||||
|
||||
set -eu
|
||||
|
||||
REPO="${HERMES_RELAY_REPO:-Codename-11/hermes-relay}"
|
||||
VERSION="${HERMES_RELAY_VERSION:-latest}"
|
||||
INSTALL_DIR="${HERMES_RELAY_INSTALL_DIR:-$HOME/.hermes/bin}"
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# Best-effort read of an installed binary's version. Prints the bare
|
||||
# version string (e.g. "0.3.0") on stdout and returns 0 on success; returns
|
||||
# non-zero on any failure (not executable, --version errors, timeout).
|
||||
# The binary prints `hermes-relay X.Y.Z\n` — we strip the prefix.
|
||||
read_installed_version() {
|
||||
local path="$1"
|
||||
local line=""
|
||||
[ -x "$path" ] || return 1
|
||||
if have timeout; then
|
||||
line="$(timeout 5 "$path" --version 2>/dev/null | head -1)" || return 1
|
||||
else
|
||||
line="$("$path" --version 2>/dev/null | head -1)" || return 1
|
||||
fi
|
||||
[ -n "$line" ] || return 1
|
||||
# Expected form: "hermes-relay X.Y.Z" — third field by whitespace.
|
||||
printf '%s' "$line" | awk '{print $2}'
|
||||
}
|
||||
|
||||
# Strip the `desktop-v` tag prefix and any `-alpha.N` / `-beta.N` / `-rc.N`
|
||||
# suffix so we can compare against the post-install --version output (which
|
||||
# reports the bare package.json semver).
|
||||
normalize_pinned_version() {
|
||||
local v="$1"
|
||||
# Empty or "latest" → unknown; caller decides.
|
||||
[ -z "$v" ] || [ "$v" = "latest" ] && { printf ''; return 0; }
|
||||
# Strip leading `desktop-v` (our release tag convention).
|
||||
v="${v#desktop-v}"
|
||||
# Strip leading `v` just in case someone pinned `v0.3.0`.
|
||||
v="${v#v}"
|
||||
# Strip -alpha.N / -beta.N / -rc.N / any other pre-release tail.
|
||||
v="${v%%-*}"
|
||||
printf '%s' "$v"
|
||||
}
|
||||
|
||||
have curl || die "curl is required"
|
||||
have uname || die "uname is required"
|
||||
have install || die "install(1) is required"
|
||||
|
||||
# sha256sum on Linux, shasum on macOS — provide a shim.
|
||||
if have sha256sum; then
|
||||
sha_check() { grep " $1\$" SHA256SUMS.txt | sha256sum -c -; }
|
||||
elif have shasum; then
|
||||
sha_check() {
|
||||
expected=$(grep " $1\$" SHA256SUMS.txt | awk '{print $1}')
|
||||
actual=$(shasum -a 256 "$1" | awk '{print $1}')
|
||||
[ "$expected" = "$actual" ]
|
||||
}
|
||||
else
|
||||
die "need sha256sum or shasum for checksum verification"
|
||||
fi
|
||||
|
||||
os="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
arch="$(uname -m)"
|
||||
case "$os-$arch" in
|
||||
linux-x86_64) asset="hermes-relay-linux-x64" ;;
|
||||
linux-aarch64) asset="hermes-relay-linux-arm64" ;;
|
||||
linux-arm64) asset="hermes-relay-linux-arm64" ;;
|
||||
darwin-x86_64) asset="hermes-relay-darwin-x64" ;;
|
||||
darwin-arm64) asset="hermes-relay-darwin-arm64" ;;
|
||||
*) die "unsupported platform: $os/$arch (supported: linux-x64/arm64, darwin-x64/arm64)" ;;
|
||||
esac
|
||||
|
||||
if [ "$VERSION" = "latest" ]; then
|
||||
base="https://github.com/$REPO/releases/latest/download"
|
||||
else
|
||||
base="https://github.com/$REPO/releases/download/$VERSION"
|
||||
fi
|
||||
|
||||
say "Hermes-Relay desktop CLI installer"
|
||||
say " platform : $os/$arch"
|
||||
say " asset : $asset"
|
||||
say " version : $VERSION"
|
||||
say " install : $INSTALL_DIR"
|
||||
say ""
|
||||
|
||||
tmp="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp"' EXIT
|
||||
cd "$tmp"
|
||||
|
||||
# Pre-install: detect an existing binary so the user can see upgrade vs
|
||||
# reinstall vs overwrite-after-corruption, instead of the install going
|
||||
# silent and requiring a follow-up `hermes-relay --version` to find out.
|
||||
target="$INSTALL_DIR/hermes-relay"
|
||||
expected_new_version="$(normalize_pinned_version "$VERSION")"
|
||||
existing_version=""
|
||||
if [ -e "$target" ]; then
|
||||
if existing_version="$(read_installed_version "$target")" && [ -n "$existing_version" ]; then
|
||||
if [ -n "$expected_new_version" ]; then
|
||||
if [ "$existing_version" = "$expected_new_version" ]; then
|
||||
say "-> existing install detected: $existing_version — reinstalling same version"
|
||||
else
|
||||
say "-> existing install detected: $existing_version — upgrading to $expected_new_version"
|
||||
fi
|
||||
else
|
||||
# VERSION=latest — we don't know the target version yet.
|
||||
say "-> existing install detected: $existing_version — will replace with latest"
|
||||
fi
|
||||
else
|
||||
existing_version=""
|
||||
say "-> existing install detected (could not read version) — overwriting"
|
||||
fi
|
||||
fi
|
||||
|
||||
say "-> downloading binary..."
|
||||
curl -fsSL -o "$asset" "$base/$asset" \
|
||||
|| die "download failed: $base/$asset (maybe no release for this platform yet?)"
|
||||
|
||||
say "-> downloading checksums..."
|
||||
curl -fsSL -o SHA256SUMS.txt "$base/SHA256SUMS.txt" \
|
||||
|| die "could not fetch SHA256SUMS.txt (release incomplete?)"
|
||||
|
||||
say "-> verifying SHA256..."
|
||||
sha_check "$asset" || die "checksum mismatch — refusing to install"
|
||||
say " ok"
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
install -m 0755 "$asset" "$target"
|
||||
|
||||
# Post-install: confirm the NEW binary reports a sensible version. Don't
|
||||
# fail the install on mismatch — the user may have pinned to a pre-release
|
||||
# whose version_name differs slightly from the tag.
|
||||
installed_version="$(read_installed_version "$target" || true)"
|
||||
if [ -n "$installed_version" ]; then
|
||||
say "-> installed $installed_version at $target"
|
||||
if [ -n "$expected_new_version" ] && [ "$installed_version" != "$expected_new_version" ]; then
|
||||
say " WARN: expected version $expected_new_version from pin ($VERSION), got $installed_version"
|
||||
say " (pre-release tags can diverge from package version — this is usually fine)"
|
||||
fi
|
||||
else
|
||||
say "-> installed $target (could not read post-install version)"
|
||||
fi
|
||||
|
||||
# PATH hint — don't mutate the user's rc silently without a prompt; just
|
||||
# tell them clearly what to do if the binary isn't reachable yet.
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*)
|
||||
say "-> $INSTALL_DIR is already in PATH"
|
||||
;;
|
||||
*)
|
||||
shell_rc=""
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) shell_rc="$HOME/.zshrc" ;;
|
||||
*/bash) shell_rc="$HOME/.bashrc" ;;
|
||||
*/fish) shell_rc="$HOME/.config/fish/config.fish" ;;
|
||||
esac
|
||||
say ""
|
||||
say "!! $INSTALL_DIR is not in your PATH."
|
||||
say " Add this line to ${shell_rc:-your shell rc file}:"
|
||||
say ""
|
||||
say " export PATH=\"$INSTALL_DIR:\$PATH\""
|
||||
say ""
|
||||
say " Then restart your shell (or \`source ${shell_rc:-~/.bashrc}\`)."
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ "$os" = "darwin" ]; then
|
||||
say ""
|
||||
say " macOS note: this binary is unsigned (experimental phase)."
|
||||
say " If macOS blocks first launch, clear the quarantine flag:"
|
||||
say ""
|
||||
say " xattr -dr com.apple.quarantine $target"
|
||||
say ""
|
||||
fi
|
||||
|
||||
say ""
|
||||
if [ -n "$installed_version" ]; then
|
||||
say "Installed hermes-relay $installed_version. Try:"
|
||||
else
|
||||
say "Installed. Try:"
|
||||
fi
|
||||
say " hermes-relay --help"
|
||||
say " hermes-relay pair --remote ws://<host>:8767"
|
||||
say ""
|
||||
@@ -0,0 +1,140 @@
|
||||
# hermes-relay desktop CLI uninstaller — Windows (PowerShell 5.1+).
|
||||
#
|
||||
# irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
#
|
||||
# Reverses install.ps1. Three tiers:
|
||||
#
|
||||
# (default) Remove the binary and the user-PATH entry for $INSTALL_DIR.
|
||||
# Preserves $HOME\.hermes\remote-sessions.json so a future
|
||||
# re-install pairs seamlessly.
|
||||
# --purge Also delete $HOME\.hermes\remote-sessions.json (bearer tokens,
|
||||
# cert pins, tools-consent flag). This file is SHARED with the
|
||||
# Ink TUI and Android tooling — wiping it affects those too.
|
||||
# --service Stub: daemon service installers aren't shipped yet. Prints
|
||||
# the `sc.exe delete` invocation a future install would need,
|
||||
# so you can remove a manually crafted service yourself.
|
||||
#
|
||||
# Tiers combine: `--purge --service` runs both.
|
||||
#
|
||||
# Override install dir (matches install.ps1):
|
||||
# $env:HERMES_RELAY_INSTALL_DIR='C:\tools\hermes\bin'; irm ... | iex
|
||||
#
|
||||
# Piped into iex, argument passing is awkward — set these env vars instead:
|
||||
# $env:HERMES_RELAY_UNINSTALL_PURGE=1
|
||||
# $env:HERMES_RELAY_UNINSTALL_SERVICE=1
|
||||
|
||||
#Requires -Version 5.1
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$dir = if ($env:HERMES_RELAY_INSTALL_DIR) { $env:HERMES_RELAY_INSTALL_DIR } else { Join-Path $HOME '.hermes\bin' }
|
||||
$sessionsFile = Join-Path $HOME '.hermes\remote-sessions.json'
|
||||
|
||||
# Parse from $args when invoked directly; fall back to env for iex pipelines.
|
||||
$purge = $false
|
||||
$service = $false
|
||||
foreach ($a in $args) {
|
||||
switch ($a) {
|
||||
'--purge' { $purge = $true }
|
||||
'--service' { $service = $true }
|
||||
'--binary-only' { } # default; accept for symmetry with uninstall.sh
|
||||
'-h' { Get-Content $PSCommandPath | Select-Object -First 22 | Where-Object { $_ -like '#*' } | ForEach-Object { $_ -replace '^# ?','' }; exit 0 }
|
||||
'--help' { Get-Content $PSCommandPath | Select-Object -First 22 | Where-Object { $_ -like '#*' } | ForEach-Object { $_ -replace '^# ?','' }; exit 0 }
|
||||
default { Write-Host "uninstall.ps1: unknown argument: $a" -ForegroundColor Red; exit 1 }
|
||||
}
|
||||
}
|
||||
if ($env:HERMES_RELAY_UNINSTALL_PURGE) { $purge = $true }
|
||||
if ($env:HERMES_RELAY_UNINSTALL_SERVICE) { $service = $true }
|
||||
|
||||
function Say($msg) { Write-Host " $msg" }
|
||||
function Die($msg) { Write-Host "uninstall.ps1: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
Say 'Hermes-Relay desktop CLI uninstaller'
|
||||
Say " install : $dir"
|
||||
if ($purge) { Say " mode : --purge (binary + session data)" }
|
||||
elseif ($service) { Say " mode : --service (binary + service stub)" }
|
||||
else { Say " mode : binary-only (preserves remote-sessions.json)" }
|
||||
Say ''
|
||||
|
||||
# -- Tier 1: binary + PATH --------------------------------------------------
|
||||
|
||||
$target = Join-Path $dir 'hermes-relay.exe'
|
||||
if (Test-Path -LiteralPath $target) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $target -Force
|
||||
Say "-> removed $target"
|
||||
} catch {
|
||||
Die "failed to remove ${target}: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
Say "-> no binary at $target (already removed?)"
|
||||
}
|
||||
|
||||
# Remove install dir if empty — never touch user-created content.
|
||||
if (Test-Path -LiteralPath $dir) {
|
||||
$leftover = @(Get-ChildItem -LiteralPath $dir -Force -ErrorAction SilentlyContinue)
|
||||
if ($leftover.Count -eq 0) {
|
||||
try {
|
||||
Remove-Item -LiteralPath $dir -Force
|
||||
Say "-> removed empty $dir"
|
||||
} catch {
|
||||
# Non-fatal; directory may be locked.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# PATH: mirror install.ps1's approach — read user scope, split on ;, filter
|
||||
# out our dir, write back. Only rewrite if we actually changed something.
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path','User')
|
||||
if ($userPath) {
|
||||
$parts = $userPath -split ';' | Where-Object { $_ -ne '' }
|
||||
$filtered = $parts | Where-Object { $_ -ne $dir }
|
||||
if ($parts.Count -ne $filtered.Count) {
|
||||
[Environment]::SetEnvironmentVariable('Path', ($filtered -join ';'), 'User')
|
||||
Say "-> removed $dir from user PATH"
|
||||
Say ' (open a new terminal to pick up the change)'
|
||||
} else {
|
||||
Say "-> $dir was not on user PATH"
|
||||
}
|
||||
} else {
|
||||
Say '-> user PATH is empty; nothing to remove'
|
||||
}
|
||||
|
||||
# -- Tier 2: purge session data --------------------------------------------
|
||||
|
||||
if ($purge) {
|
||||
Say ''
|
||||
if (Test-Path -LiteralPath $sessionsFile) {
|
||||
$size = (Get-Item -LiteralPath $sessionsFile).Length
|
||||
Say "!! --purge: wiping $sessionsFile ($size bytes)"
|
||||
Say ' This file is SHARED with:'
|
||||
Say ' - the Ink TUI (hermes-agent-tui-smoke)'
|
||||
Say ' - the Hermes Android desktop tooling'
|
||||
Say ' Those surfaces will lose their stored session tokens + cert pins too.'
|
||||
try {
|
||||
Remove-Item -LiteralPath $sessionsFile -Force
|
||||
Say "-> removed $sessionsFile"
|
||||
} catch {
|
||||
Die "failed to remove ${sessionsFile}: $($_.Exception.Message)"
|
||||
}
|
||||
} else {
|
||||
Say "-> --purge: no session file at $sessionsFile (already clean)"
|
||||
}
|
||||
}
|
||||
|
||||
# -- Tier 3: service stub ---------------------------------------------------
|
||||
|
||||
if ($service) {
|
||||
Say ''
|
||||
Say ' --service: daemon service installers are not yet shipped.'
|
||||
Say ' If you manually installed a Windows service, remove it yourself:'
|
||||
Say ''
|
||||
Say ' sc.exe stop HermesRelayDaemon'
|
||||
Say ' sc.exe delete HermesRelayDaemon'
|
||||
Say ''
|
||||
Say ' (requires an elevated PowerShell / cmd prompt)'
|
||||
}
|
||||
|
||||
Say ''
|
||||
Say 'Removed. hermes-relay is gone. To reinstall:'
|
||||
Say ' irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex'
|
||||
Say ''
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env bash
|
||||
# hermes-relay desktop CLI uninstaller — macOS / Linux.
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh
|
||||
#
|
||||
# Reverses install.sh. Three tiers:
|
||||
#
|
||||
# (default) Remove the binary only. Preserves ~/.hermes/remote-sessions.json
|
||||
# so a future re-install pairs seamlessly.
|
||||
# --purge Also delete ~/.hermes/remote-sessions.json (bearer tokens, cert
|
||||
# pins, tools-consent flag). This file is SHARED with the Ink TUI
|
||||
# and Android tooling — wiping it affects those surfaces too.
|
||||
# --service Stub: daemon service installers aren't shipped yet. Prints the
|
||||
# paths a future install would use so you can remove a manually
|
||||
# crafted unit yourself.
|
||||
#
|
||||
# Tiers combine: `--purge --service` runs both.
|
||||
#
|
||||
# Override install dir (matches install.sh):
|
||||
# HERMES_RELAY_INSTALL_DIR=/opt/hermes curl -fsSL ... | sh -s -- --purge
|
||||
#
|
||||
# install.sh never modifies your shell rc; neither does this. If you added
|
||||
# $INSTALL_DIR to your PATH manually, remove that line from your shell rc.
|
||||
|
||||
set -eu
|
||||
|
||||
INSTALL_DIR="${HERMES_RELAY_INSTALL_DIR:-$HOME/.hermes/bin}"
|
||||
SESSIONS_FILE="$HOME/.hermes/remote-sessions.json"
|
||||
|
||||
PURGE=0
|
||||
SERVICE=0
|
||||
BINARY_ONLY=0
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--purge) PURGE=1 ;;
|
||||
--service) SERVICE=1 ;;
|
||||
--binary-only) BINARY_ONLY=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'uninstall.sh: unknown argument: %s\n' "$arg" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
die() { printf 'uninstall.sh: %s\n' "$*" >&2; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
say "Hermes-Relay desktop CLI uninstaller"
|
||||
say " install : $INSTALL_DIR"
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
say " mode : --purge (binary + session data)"
|
||||
elif [ "$SERVICE" -eq 1 ] && [ "$BINARY_ONLY" -eq 0 ]; then
|
||||
say " mode : --service (binary + service stub)"
|
||||
else
|
||||
say " mode : binary-only (preserves ~/.hermes/remote-sessions.json)"
|
||||
fi
|
||||
say ""
|
||||
|
||||
# -- Tier 1: binary ---------------------------------------------------------
|
||||
|
||||
target="$INSTALL_DIR/hermes-relay"
|
||||
if [ -f "$target" ] || [ -L "$target" ]; then
|
||||
rm -f "$target" || die "failed to remove $target (permission denied?)"
|
||||
say "-> removed $target"
|
||||
else
|
||||
say "-> no binary at $target (already removed?)"
|
||||
fi
|
||||
|
||||
# Clean up an empty install dir we created, but never touch user-created content.
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
if [ -z "$(ls -A "$INSTALL_DIR" 2>/dev/null)" ]; then
|
||||
rmdir "$INSTALL_DIR" 2>/dev/null && say "-> removed empty $INSTALL_DIR" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# PATH note — install.sh never mutated shell rc, so neither do we.
|
||||
case ":$PATH:" in
|
||||
*":$INSTALL_DIR:"*)
|
||||
say ""
|
||||
say " Note: $INSTALL_DIR is still on your PATH for this shell session."
|
||||
say " If you added it manually to your shell rc, remove that line from:"
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) say " ~/.zshrc" ;;
|
||||
*/bash) say " ~/.bashrc" ;;
|
||||
*/fish) say " ~/.config/fish/config.fish" ;;
|
||||
*) say " your shell rc file" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
# -- Tier 2: purge session data --------------------------------------------
|
||||
|
||||
if [ "$PURGE" -eq 1 ]; then
|
||||
say ""
|
||||
if [ -f "$SESSIONS_FILE" ]; then
|
||||
size=""
|
||||
if have wc; then
|
||||
size=" ($(wc -c < "$SESSIONS_FILE" | tr -d ' ') bytes)"
|
||||
fi
|
||||
say "!! --purge: wiping $SESSIONS_FILE$size"
|
||||
say " This file is SHARED with:"
|
||||
say " - the Ink TUI (hermes-agent-tui-smoke)"
|
||||
say " - the Hermes Android desktop tooling"
|
||||
say " Those surfaces will lose their stored session tokens + cert pins too."
|
||||
rm -f "$SESSIONS_FILE" || die "failed to remove $SESSIONS_FILE"
|
||||
say "-> removed $SESSIONS_FILE"
|
||||
else
|
||||
say "-> --purge: no session file at $SESSIONS_FILE (already clean)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# -- Tier 3: service stub ---------------------------------------------------
|
||||
|
||||
if [ "$SERVICE" -eq 1 ]; then
|
||||
say ""
|
||||
say " --service: daemon service installers are not yet shipped."
|
||||
say " If you manually installed a service unit, remove it yourself:"
|
||||
say ""
|
||||
case "$(uname -s)" in
|
||||
Linux)
|
||||
say " systemctl --user stop hermes-relay-daemon.service 2>/dev/null || true"
|
||||
say " systemctl --user disable hermes-relay-daemon.service 2>/dev/null || true"
|
||||
say " rm -f ~/.config/systemd/user/hermes-relay-daemon.service"
|
||||
say " systemctl --user daemon-reload"
|
||||
;;
|
||||
Darwin)
|
||||
say " launchctl unload ~/Library/LaunchAgents/com.hermes.relay.daemon.plist 2>/dev/null || true"
|
||||
say " rm -f ~/Library/LaunchAgents/com.hermes.relay.daemon.plist"
|
||||
;;
|
||||
*)
|
||||
say " ~/.config/systemd/user/hermes-relay-daemon.service (Linux)"
|
||||
say " ~/Library/LaunchAgents/com.hermes.relay.daemon.plist (macOS)"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
say ""
|
||||
say "Removed. hermes-relay is gone. To reinstall:"
|
||||
say " curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh"
|
||||
say ""
|
||||
@@ -0,0 +1,133 @@
|
||||
// Contextual connect banner — mirrors the Android "Route / Plain / Secure /
|
||||
// LAN / Tailscale / Public" vocabulary landed in DEVLOG 2026-04-22. The
|
||||
// banner lands ONCE on connect (chat + shell both use it) so the user sees
|
||||
// at a glance which network path they're on and whether it's TLS-encrypted.
|
||||
//
|
||||
// Inputs are all optional — on older relays that don't send `transport_hint`
|
||||
// or when we don't know the endpoint role, we degrade to a neutral "Plain
|
||||
// (no TLS)" or just the server version.
|
||||
|
||||
import type { AuthMeta } from './transport/RelayTransport.js'
|
||||
|
||||
export type EndpointRole = 'lan' | 'tailscale' | 'public' | 'custom'
|
||||
|
||||
/**
|
||||
* Normalize the server-reported endpoint role string. Returns null if we
|
||||
* don't recognise the input — caller decides whether to fall back to url
|
||||
* scheme or leave the role blank.
|
||||
*/
|
||||
export function parseRole(raw: string | null | undefined): EndpointRole | null {
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
const lower = raw.toLowerCase()
|
||||
if (lower === 'lan' || lower === 'tailscale' || lower === 'public' || lower === 'custom') {
|
||||
return lower
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Human-friendly role label. Matches Android's `displayLabel()`. */
|
||||
export function roleLabel(role: EndpointRole | null): string {
|
||||
switch (role) {
|
||||
case 'lan':
|
||||
return 'LAN'
|
||||
case 'tailscale':
|
||||
return 'Tailscale'
|
||||
case 'public':
|
||||
return 'Public'
|
||||
case 'custom':
|
||||
return 'Custom VPN'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** Is this URL TLS-encrypted at the transport level? */
|
||||
export function urlIsSecure(url: string): boolean {
|
||||
const s = url.trim().toLowerCase()
|
||||
return s.startsWith('wss://') || s.startsWith('https://')
|
||||
}
|
||||
|
||||
/**
|
||||
* Transport security label. Prefers the server's `transport_hint` over the
|
||||
* URL scheme because the hint survives proxies — e.g. a wss:// URL fronting
|
||||
* a Tailscale HTTPS-terminated relay vs. a raw tailnet connection.
|
||||
*
|
||||
* secure → `wss://` / tailnet-signed / `tailscale serve`
|
||||
* plain → `ws://` (no TLS on the wire)
|
||||
*/
|
||||
export function transportLabel(
|
||||
url: string,
|
||||
transportHint: string | null | undefined
|
||||
): 'Secure' | 'Plain' {
|
||||
const hint = (transportHint ?? '').toLowerCase()
|
||||
if (hint === 'wss' || hint === 'tls' || hint === 'secure' || hint === 'https') {
|
||||
return 'Secure'
|
||||
}
|
||||
if (hint === 'ws' || hint === 'plain' || hint === 'insecure' || hint === 'http') {
|
||||
return 'Plain'
|
||||
}
|
||||
// Fall back to URL scheme when hint is absent / unknown.
|
||||
return urlIsSecure(url) ? 'Secure' : 'Plain'
|
||||
}
|
||||
|
||||
export interface ConnectBannerOpts {
|
||||
url: string
|
||||
serverVersion: string | null
|
||||
meta?: AuthMeta | null
|
||||
endpointRole?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the one-line connect banner. Examples:
|
||||
* "Connected via LAN (plain) — server 0.6.0"
|
||||
* "Connected via Tailscale (secure) — server 0.6.0"
|
||||
* "Connected via Public (secure) — server 0.7.2"
|
||||
* "Connected (plain ws://) — server 0.6.0" [no role — degrade gracefully]
|
||||
*
|
||||
* Does NOT print itself — returns the string so the caller can style it
|
||||
* (stderr vs stdout, color or not) and the tests can assert on the shape.
|
||||
*/
|
||||
export function buildConnectBanner(opts: ConnectBannerOpts): string {
|
||||
const role = parseRole(opts.endpointRole)
|
||||
const transport = transportLabel(opts.url, opts.meta?.transportHint ?? null)
|
||||
const v = opts.serverVersion ?? '?'
|
||||
|
||||
if (role) {
|
||||
return `Connected via ${roleLabel(role)} (${transport.toLowerCase()}) — server ${v}`
|
||||
}
|
||||
// No role info — show the bare scheme instead so the user still sees Plain vs Secure.
|
||||
const scheme = urlIsSecure(opts.url) ? 'wss://' : 'ws://'
|
||||
return `Connected (${transport.toLowerCase()} ${scheme}) — server ${v}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Epoch-second → human-friendly relative expiry string.
|
||||
* null → "never"
|
||||
* past → "expired"
|
||||
* under 60s → "in Ns"
|
||||
* under 1h → "in Nm"
|
||||
* under 24h → "in Nh"
|
||||
* over 24h → "in Nd"
|
||||
*/
|
||||
export function humanExpiry(ttlExpiresAt: number | null | undefined): string {
|
||||
if (ttlExpiresAt === null || ttlExpiresAt === undefined) {
|
||||
return 'never'
|
||||
}
|
||||
const now = Date.now() / 1000
|
||||
const delta = ttlExpiresAt - now
|
||||
if (delta <= 0) {
|
||||
return 'expired'
|
||||
}
|
||||
if (delta < 60) {
|
||||
return `in ${Math.floor(delta)}s`
|
||||
}
|
||||
if (delta < 3600) {
|
||||
return `in ${Math.floor(delta / 60)}m`
|
||||
}
|
||||
if (delta < 86_400) {
|
||||
return `in ${Math.floor(delta / 3600)}h`
|
||||
}
|
||||
return `in ${Math.floor(delta / 86_400)}d`
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// TOFU cert pinning helpers for the desktop CLI. Mirrors the Android
|
||||
// `CertPinStore` (app/.../auth/CertPinStore.kt):
|
||||
// - pin key: lowercase `host:port`
|
||||
// - pin value: `sha256/<base64-of-SPKI-sha256>` — same shape OkHttp's
|
||||
// CertificatePinner prints, so the format is portable across
|
||||
// our Kotlin and Node clients.
|
||||
//
|
||||
// This module is intentionally pure helpers — reading/writing the session
|
||||
// file (remoteSessions.ts) stays in RelayTransport so storage concerns don't
|
||||
// leak here. Node ≥21 only: uses built-in `node:crypto` X509Certificate and
|
||||
// `node:tls` for the pre-WS probe.
|
||||
//
|
||||
// Scope note: SPKI sha256 is always computed over the *leaf* peer certificate.
|
||||
// Intermediate CAs in the chain can (and do) rotate; pinning an intermediate
|
||||
// would cause false mismatches. Since the leaf is what the server actually
|
||||
// presents on each TLS handshake, pinning there matches the Android behavior
|
||||
// and avoids flapping on CA renewal.
|
||||
|
||||
import { createHash, X509Certificate } from 'node:crypto'
|
||||
|
||||
/** Format version: OkHttp-compatible `sha256/<base64>`. Change only if the
|
||||
* Android store format changes too. */
|
||||
const PIN_PREFIX = 'sha256/'
|
||||
|
||||
/**
|
||||
* Compute `sha256/<base64>` over the SubjectPublicKeyInfo of a DER-encoded
|
||||
* X.509 certificate. The input is the raw `rawCert` buffer returned by
|
||||
* `tls.TLSSocket.getPeerCertificate(false)`.
|
||||
*
|
||||
* Throws if the buffer isn't a parseable cert — callers should treat that
|
||||
* as a TOFU failure and refuse to connect.
|
||||
*/
|
||||
export const extractSpkiSha256 = (peerCertDer: Buffer): string => {
|
||||
const cert = new X509Certificate(peerCertDer)
|
||||
// `publicKey.export({type: 'spki', format: 'der'})` returns the exact
|
||||
// SubjectPublicKeyInfo bytes — same thing OkHttp's `sha256` pin hashes.
|
||||
const spkiDer = cert.publicKey.export({ type: 'spki', format: 'der' }) as Buffer
|
||||
const digest = createHash('sha256').update(spkiDer).digest('base64')
|
||||
|
||||
return `${PIN_PREFIX}${digest}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical pin-store key for a URL — lowercase `host:port`. Explicit port
|
||||
* is required (no implicit 443/80) so `wss://host/` and `wss://host:443/`
|
||||
* resolve to the same key.
|
||||
*/
|
||||
export const pinKey = (url: string): string => {
|
||||
const u = new URL(url)
|
||||
const host = u.hostname.toLowerCase()
|
||||
const port = u.port || (u.protocol === 'wss:' || u.protocol === 'https:' ? '443' : '80')
|
||||
|
||||
return `${host}:${port}`
|
||||
}
|
||||
|
||||
/** True if the URL uses TLS (`wss:` or `https:`). Plain ws:// short-circuits
|
||||
* all TOFU logic — no cert to pin, no probe to run. */
|
||||
export const isSecureUrl = (url: string): boolean => {
|
||||
try {
|
||||
const { protocol } = new URL(url)
|
||||
|
||||
return protocol === 'wss:' || protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time comparison of two pins. Both sides must share the
|
||||
* `sha256/<base64>` shape; a length mismatch returns false immediately
|
||||
* but that leak is fine — the prefix is fixed and the base64 body is a
|
||||
* fixed 44 chars for sha256.
|
||||
*/
|
||||
export const comparePins = (expected: string, actual: string): boolean => {
|
||||
if (expected.length !== actual.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let mismatch = 0
|
||||
for (let i = 0; i < expected.length; i++) {
|
||||
mismatch |= expected.charCodeAt(i) ^ actual.charCodeAt(i)
|
||||
}
|
||||
|
||||
return mismatch === 0
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// hermes-relay CLI — argv parser + subcommand dispatcher.
|
||||
// Deliberately tiny: mirrors the TUI entry's stance ("anything more elaborate
|
||||
// lands in hermes_cli/main.py — the proper home for a full CLI") but with
|
||||
// subcommands because a thin client has actual verbs (pair, status, tools).
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { chatCommand } from './commands/chat.js'
|
||||
import { daemonCommand } from './commands/daemon.js'
|
||||
import { devicesCommand } from './commands/devices.js'
|
||||
import { doctorCommand } from './commands/doctor.js'
|
||||
import { pairCommand } from './commands/pair.js'
|
||||
import { shellCommand } from './commands/shell.js'
|
||||
import { statusCommand } from './commands/status.js'
|
||||
import { toolsCommand } from './commands/tools.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readVersion(): string {
|
||||
// dist/cli.js lives at <pkg>/dist/cli.js; package.json is one dir up.
|
||||
try {
|
||||
const pkgPath = join(__dirname, '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }
|
||||
return pkg.version ?? '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
export interface ParsedArgs {
|
||||
command: string
|
||||
flags: Record<string, string | true>
|
||||
positional: string[]
|
||||
}
|
||||
|
||||
const SHORT_FLAGS: Record<string, string> = {
|
||||
h: 'help',
|
||||
v: 'version',
|
||||
q: 'quiet'
|
||||
}
|
||||
|
||||
const BOOLEAN_FLAGS = new Set([
|
||||
'help',
|
||||
'version',
|
||||
'quiet',
|
||||
'verbose',
|
||||
'json',
|
||||
'no-color',
|
||||
'non-interactive',
|
||||
'reveal-tokens',
|
||||
'raw',
|
||||
'no-tools',
|
||||
'log-human',
|
||||
'log-json',
|
||||
'allow-tools'
|
||||
])
|
||||
|
||||
function parseArgs(argv: string[]): ParsedArgs {
|
||||
const args = argv.slice(2)
|
||||
const positional: string[] = []
|
||||
const flags: Record<string, string | true> = {}
|
||||
let command = ''
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]!
|
||||
|
||||
if (a === '--') {
|
||||
// Rest is positional.
|
||||
for (let j = i + 1; j < args.length; j++) {
|
||||
positional.push(args[j]!)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (a.startsWith('--')) {
|
||||
const eq = a.indexOf('=')
|
||||
if (eq >= 0) {
|
||||
flags[a.slice(2, eq)] = a.slice(eq + 1)
|
||||
continue
|
||||
}
|
||||
const name = a.slice(2)
|
||||
if (BOOLEAN_FLAGS.has(name)) {
|
||||
flags[name] = true
|
||||
continue
|
||||
}
|
||||
// Value-taking flag: consume next arg if present and not a flag.
|
||||
const next = args[i + 1]
|
||||
if (next !== undefined && !next.startsWith('-')) {
|
||||
flags[name] = next
|
||||
i++
|
||||
} else {
|
||||
flags[name] = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (a.startsWith('-') && a.length === 2) {
|
||||
const k = a[1]!
|
||||
flags[SHORT_FLAGS[k] ?? k] = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!command) {
|
||||
command = a
|
||||
} else {
|
||||
positional.push(a)
|
||||
}
|
||||
}
|
||||
|
||||
return { command, flags, positional }
|
||||
}
|
||||
|
||||
const KNOWN_COMMANDS = new Set([
|
||||
'chat',
|
||||
'daemon',
|
||||
'devices',
|
||||
'doctor',
|
||||
'pair',
|
||||
'shell',
|
||||
'status',
|
||||
'tools',
|
||||
'help'
|
||||
])
|
||||
|
||||
const HELP = `hermes-relay — thin-client CLI for a remote Hermes agent over WSS
|
||||
|
||||
Usage:
|
||||
hermes-relay [shell] Pipe the full Hermes CLI over a PTY (default — interactive)
|
||||
hermes-relay chat [<prompt>] Structured-event chat (REPL or one-shot, scriptable)
|
||||
hermes-relay "<prompt>" One-shot structured chat (shortcut for chat "...")
|
||||
hermes-relay pair [CODE] Pair with the relay and store a session token
|
||||
hermes-relay status Show stored sessions + grants + TTL
|
||||
hermes-relay tools List tools available on the server
|
||||
hermes-relay devices List / revoke / extend server-side paired devices
|
||||
hermes-relay daemon Run headless — expose desktop tools even when no shell is open
|
||||
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
|
||||
hermes-relay help Show this help
|
||||
hermes-relay --version Print version and exit
|
||||
|
||||
Flags:
|
||||
--remote <url> Relay WSS URL (env: HERMES_RELAY_URL)
|
||||
--code <code> Pairing code (6 chars) (env: HERMES_RELAY_CODE)
|
||||
--token <token> Session token (skips pairing) (env: HERMES_RELAY_TOKEN)
|
||||
--pair-qr <payload> Full QR payload (multi-endpoint pairing, ADR 24;
|
||||
probes endpoints and picks highest-priority reachable)
|
||||
(env: HERMES_RELAY_PAIR_QR)
|
||||
--session <id> chat: resume session; shell: tmux session name
|
||||
--exec <cmd> shell: command to exec inside tmux (default: hermes)
|
||||
--raw shell: skip auto-exec; drop into bare tmux/bash
|
||||
--no-tools chat/shell: disable local tool handlers (fs, exec, search)
|
||||
--log-human daemon: human-readable log lines (default: auto on TTY)
|
||||
--log-json daemon: force JSON-line logs even on a TTY
|
||||
--allow-tools daemon: skip stored-consent gate (use only with --token; implies trust)
|
||||
--json chat: emit events as JSON lines (scripting)
|
||||
--verbose Include thinking/reasoning + transport stderr
|
||||
--quiet, -q Suppress status lines and tool decorations
|
||||
--no-color Disable ANSI colors (env: NO_COLOR)
|
||||
--non-interactive Never prompt; fail if creds missing
|
||||
--help, -h Show this help
|
||||
--version, -v Print version
|
||||
|
||||
Examples:
|
||||
# First time: pair with the relay (one-time code from \`hermes-pair\` on the server)
|
||||
hermes-relay pair --remote ws://172.16.24.250:8767
|
||||
# ...prompts for code, stores a token in ~/.hermes/remote-sessions.json
|
||||
|
||||
# REPL — reuses the stored token
|
||||
hermes-relay --remote ws://172.16.24.250:8767
|
||||
|
||||
# One-shot
|
||||
hermes-relay "what files are in ~/.hermes?" --remote ws://172.16.24.250:8767
|
||||
|
||||
# Pipe JSON events for scripting
|
||||
hermes-relay --json "summarize the last commit" | jq -c '.type'
|
||||
|
||||
# Inspect what tools the server will give the agent
|
||||
hermes-relay tools --verbose
|
||||
|
||||
# Run the tool router headless so the agent can reach you without an open shell
|
||||
hermes-relay daemon --remote ws://172.16.24.250:8767
|
||||
# ...writes JSON-line lifecycle events to stderr; redirect or pipe to jq
|
||||
|
||||
Config files:
|
||||
~/.hermes/remote-sessions.json session tokens (mode 0600)
|
||||
`
|
||||
|
||||
export async function main(argv = process.argv): Promise<number> {
|
||||
const args = parseArgs(argv)
|
||||
|
||||
if (args.flags.version) {
|
||||
process.stdout.write(`hermes-relay ${readVersion()}\n`)
|
||||
return 0
|
||||
}
|
||||
|
||||
if (args.flags.help || args.command === 'help') {
|
||||
process.stdout.write(HELP)
|
||||
return 0
|
||||
}
|
||||
|
||||
// Bare `hermes-relay` (no verb, no positional) → drop into the PTY shell,
|
||||
// which gives the user the full local-Hermes experience (banner, Victor,
|
||||
// skin, all of it verbatim). That matches what a hermes user expects.
|
||||
//
|
||||
// `hermes-relay "prompt"` (positional but no verb) still falls through to
|
||||
// structured chat — one-shot scripting use doesn't want a PTY.
|
||||
if (!args.command) {
|
||||
if (args.positional.length === 0) {
|
||||
return shellCommand(args)
|
||||
}
|
||||
return chatCommand(args)
|
||||
}
|
||||
|
||||
if (!KNOWN_COMMANDS.has(args.command)) {
|
||||
args.positional.unshift(args.command)
|
||||
args.command = 'chat'
|
||||
return chatCommand(args)
|
||||
}
|
||||
|
||||
switch (args.command) {
|
||||
case 'chat':
|
||||
return chatCommand(args)
|
||||
case 'daemon':
|
||||
return daemonCommand(args)
|
||||
case 'devices':
|
||||
return devicesCommand(args)
|
||||
case 'doctor':
|
||||
return doctorCommand(args)
|
||||
case 'pair':
|
||||
return pairCommand(args)
|
||||
case 'shell':
|
||||
return shellCommand(args)
|
||||
case 'status':
|
||||
return statusCommand(args)
|
||||
case 'tools':
|
||||
return toolsCommand(args)
|
||||
default:
|
||||
process.stderr.write(`unknown command: ${args.command}\n`)
|
||||
process.stderr.write(HELP)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-invoke when this module is the process entry point — covers
|
||||
// `tsx src/cli.ts <args>`, `npm run dev -- <args>`, and `node dist/cli.js`.
|
||||
// The bin shim at `bin/hermes-relay.js` imports { main } explicitly and
|
||||
// runs it itself; the guard prevents a double invocation there because
|
||||
// process.argv[1] points at the shim, not at this file. Tests that do
|
||||
// `import { main } from '../src/cli.ts'` also escape this branch because
|
||||
// argv[1] points at the test runner.
|
||||
const entryPath = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false
|
||||
if (entryPath) {
|
||||
main()
|
||||
.then((code) => process.exit(code ?? 0))
|
||||
.catch((err) => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
process.stderr.write(`hermes-relay: ${msg}\n`)
|
||||
if (process.env.HERMES_DEBUG) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err)
|
||||
}
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
// chat — the default subcommand. One-shot when a prompt is given as
|
||||
// positional args; REPL when stdin is a TTY; read-all-stdin-and-reply when
|
||||
// stdin is piped. Re-uses the TUI's pairing flow patterns exactly (see
|
||||
// docs/relay-protocol.md §3.7 for the handshake).
|
||||
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
import { buildConnectBanner } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { resolveCredentials } from '../credentials.js'
|
||||
import { GatewayClient } from '../gatewayClient.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { getSession } from '../remoteSessions.js'
|
||||
import type {
|
||||
GatewayEvent,
|
||||
PromptSubmitResponse,
|
||||
SessionCreateResponse,
|
||||
SessionInterruptResponse,
|
||||
SessionResumeResponse
|
||||
} from '../gatewayTypes.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { deleteSession, saveSession } from '../remoteSessions.js'
|
||||
import { CliRenderer } from '../renderer.js'
|
||||
import { ensureToolsConsent } from '../tools/consent.js'
|
||||
import { readFileHandler, writeFileHandler, patchHandler } from '../tools/handlers/fs.js'
|
||||
import { searchFilesHandler } from '../tools/handlers/search.js'
|
||||
import { terminalHandler } from '../tools/handlers/terminal.js'
|
||||
import { DesktopToolRouter } from '../tools/router.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
// (getSession is imported above with the other remoteSessions exports so we
|
||||
// can render the endpoint-role banner without changing the saveSession/auth
|
||||
// persistence path.)
|
||||
|
||||
const READY_TIMEOUT_MS = 60_000
|
||||
const TURN_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
function flag(args: ParsedArgs, name: string): string | null {
|
||||
const v = args.flags[name]
|
||||
return typeof v === 'string' ? v : null
|
||||
}
|
||||
|
||||
function resolveRemoteOrNull(args: ParsedArgs): string | null {
|
||||
const url = flag(args, 'remote') ?? process.env.HERMES_RELAY_URL ?? null
|
||||
return url ? url.trim() : null
|
||||
}
|
||||
|
||||
interface AuthedRelay {
|
||||
relay: RelayTransport
|
||||
/** Final URL actually connected to — from --remote OR from the --pair-qr
|
||||
* probe's winning endpoint. This is what the banner + saveSession use. */
|
||||
url: string
|
||||
/** Active endpoint role ("lan" / "tailscale" / "public") if the connection
|
||||
* was resolved via a multi-endpoint QR probe. Null for single-URL auth. */
|
||||
endpointRole: string | null
|
||||
}
|
||||
|
||||
async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
|
||||
let urlFlag = resolveRemoteOrNull(args)
|
||||
const argCode = flag(args, 'code') ?? undefined
|
||||
const argToken = flag(args, 'token') ?? undefined
|
||||
const argPairQr = flag(args, 'pair-qr') ?? process.env.HERMES_RELAY_PAIR_QR
|
||||
const nonInteractive = !!args.flags['non-interactive']
|
||||
|
||||
// First-run fallback: no --remote, no env var, no QR payload. Auto-pick
|
||||
// a single stored session, pick from a list, or walk a new user through
|
||||
// URL entry. resolveFirstRunUrl throws on non-interactive + ambiguous.
|
||||
if (!urlFlag && !argPairQr) {
|
||||
urlFlag = await resolveFirstRunUrl({ nonInteractive })
|
||||
}
|
||||
|
||||
// When --pair-qr supplies the URL, we don't yet know it at credential-
|
||||
// resolution time. Use the --remote value if present, else a placeholder
|
||||
// (harmless — the credentials module only uses it for the stored-session
|
||||
// lookup, which falls back to the QR path anyway).
|
||||
const probeUrl = urlFlag ?? 'ws://pair-qr-pending'
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const creds = await resolveCredentials(probeUrl, {
|
||||
argCode,
|
||||
argToken,
|
||||
argPairQr,
|
||||
nonInteractive
|
||||
})
|
||||
|
||||
const url = (creds.resolvedEndpoint?.relay.url ?? urlFlag)!.trim()
|
||||
const endpointRole = creds.resolvedEndpoint?.role ?? null
|
||||
|
||||
const cfg: ConstructorParameters<typeof RelayTransport>[0] = {
|
||||
url,
|
||||
deviceName: `hermes-relay-cli (${process.platform})`
|
||||
}
|
||||
if (creds.pairingCode) {
|
||||
cfg.pairingCode = creds.pairingCode
|
||||
}
|
||||
if (creds.sessionToken) {
|
||||
cfg.sessionToken = creds.sessionToken
|
||||
}
|
||||
|
||||
const relay = new RelayTransport(cfg)
|
||||
|
||||
relay.onAuthSuccess((token, ver, meta) => {
|
||||
void saveSession(url, token, ver, {
|
||||
grants: meta.grants,
|
||||
ttlExpiresAt: meta.ttlExpiresAt,
|
||||
endpointRole
|
||||
})
|
||||
})
|
||||
|
||||
relay.start()
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
|
||||
if (outcome.ok) {
|
||||
return { relay, url, endpointRole }
|
||||
}
|
||||
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (creds.sessionToken) {
|
||||
// Stored token was rejected — purge so next run starts clean.
|
||||
await deleteSession(url)
|
||||
}
|
||||
|
||||
if (attempt === 1 || nonInteractive) {
|
||||
throw new Error(`relay rejected credentials: ${outcome.reason}`)
|
||||
}
|
||||
|
||||
process.stderr.write(`\nRelay rejected credentials: ${outcome.reason}\n`)
|
||||
}
|
||||
|
||||
throw new Error('unreachable: connectAndAuth exhausted loop')
|
||||
}
|
||||
|
||||
function waitForReady(gw: GatewayClient, timeoutMs = READY_TIMEOUT_MS): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
gw.off('event', handler)
|
||||
reject(new Error(`gateway.ready timeout after ${timeoutMs}ms`))
|
||||
}, timeoutMs)
|
||||
|
||||
const handler = (ev: GatewayEvent) => {
|
||||
if (ev.type === 'gateway.ready') {
|
||||
clearTimeout(timer)
|
||||
gw.off('event', handler)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
gw.on('event', handler)
|
||||
})
|
||||
}
|
||||
|
||||
interface TurnHandle {
|
||||
/** Resolves on `message.complete` or when `cancel()` was called before an
|
||||
* `error` arrives. Rejects on `error` (when not cancelled) or turn timeout. */
|
||||
promise: Promise<void>
|
||||
/** Mark this turn as cancelled and fire `session.interrupt` at the server.
|
||||
* Must be called on the handle returned for *this* turn — the cancelled flag
|
||||
* is closed over locally so it can't be stomped by a later turn's SIGINT. */
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
/** Start one prompt→response turn. Listener is attached BEFORE `prompt.submit`
|
||||
* so we can't miss early events. Returns a handle instead of a bare Promise so
|
||||
* the caller (REPL) can interrupt just this turn without mutating shared state
|
||||
* — a shared `{ interrupted }` box was racey because `finally { reset = false }`
|
||||
* could fire while the old handler was still in the queue. */
|
||||
function runOneTurn(
|
||||
gw: GatewayClient,
|
||||
sessionId: string,
|
||||
prompt: string,
|
||||
renderer: CliRenderer
|
||||
): TurnHandle {
|
||||
let cancelled = false
|
||||
let settled = false
|
||||
let detach: (() => void) | null = null
|
||||
|
||||
const promise = new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
detach?.()
|
||||
reject(new Error(`turn timeout after ${TURN_TIMEOUT_MS}ms`))
|
||||
}, TURN_TIMEOUT_MS)
|
||||
timer.unref?.()
|
||||
|
||||
const handler = (ev: GatewayEvent) => {
|
||||
renderer.handle(ev)
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (ev.type === 'message.complete') {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
detach?.()
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
if (ev.type === 'error') {
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
detach?.()
|
||||
if (cancelled) {
|
||||
resolve()
|
||||
} else {
|
||||
reject(new Error(ev.payload?.message ?? 'agent error'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
detach = () => gw.off('event', handler)
|
||||
gw.on('event', handler)
|
||||
|
||||
gw.request<PromptSubmitResponse>('prompt.submit', { session_id: sessionId, text: prompt }).catch((e: unknown) => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
detach?.()
|
||||
reject(new Error(rpcErrorMessage(e)))
|
||||
})
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
cancelled = true
|
||||
gw.request<SessionInterruptResponse>('session.interrupt', { session_id: sessionId }).catch(() => {
|
||||
/* best-effort; the error event we're about to receive will settle the turn */
|
||||
})
|
||||
}
|
||||
|
||||
return { promise, cancel }
|
||||
}
|
||||
|
||||
async function createOrResumeSession(gw: GatewayClient, args: ParsedArgs): Promise<{ sessionId: string; model: string | null }> {
|
||||
const cols = process.stdout.columns ?? 80
|
||||
const resumeId = flag(args, 'session')
|
||||
|
||||
if (resumeId) {
|
||||
const raw = await gw.request<SessionResumeResponse>('session.resume', { session_id: resumeId, cols })
|
||||
const r = asRpcResult<SessionResumeResponse>(raw)
|
||||
if (!r?.session_id) {
|
||||
throw new Error(`failed to resume session ${resumeId}`)
|
||||
}
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
}
|
||||
|
||||
const raw = await gw.request<SessionCreateResponse>('session.create', { cols })
|
||||
const r = asRpcResult<SessionCreateResponse>(raw)
|
||||
if (!r?.session_id) {
|
||||
throw new Error('failed to create session')
|
||||
}
|
||||
return { sessionId: r.session_id, model: r.info?.model ?? null }
|
||||
}
|
||||
|
||||
async function readAllStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(chunk as Buffer)
|
||||
}
|
||||
return Buffer.concat(chunks).toString('utf8').trim()
|
||||
}
|
||||
|
||||
export async function chatCommand(args: ParsedArgs): Promise<number> {
|
||||
const renderer = new CliRenderer({
|
||||
json: !!args.flags.json,
|
||||
verbose: !!args.flags.verbose,
|
||||
quiet: !!args.flags.quiet,
|
||||
noColor: !!args.flags['no-color']
|
||||
})
|
||||
|
||||
process.stderr.write(`Connecting...\n`)
|
||||
|
||||
let authed: AuthedRelay
|
||||
try {
|
||||
authed = await connectAndAuth(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
const { relay, url, endpointRole: resolvedRole } = authed
|
||||
|
||||
const gw = new GatewayClient(relay)
|
||||
|
||||
// Wire desktop tool handlers (file read/write, shell, search) onto the
|
||||
// relay's `desktop` channel. Suppressed by `--no-tools`, and gated
|
||||
// behind a one-time consent prompt stored per-URL in remote-sessions.json.
|
||||
let toolRouter: DesktopToolRouter | null = null
|
||||
const toolsDisabled = !!args.flags['no-tools']
|
||||
if (!toolsDisabled) {
|
||||
const consent = await ensureToolsConsent(url)
|
||||
if (consent.consented) {
|
||||
toolRouter = new DesktopToolRouter({
|
||||
consentGranted: true,
|
||||
handlers: {
|
||||
desktop_read_file: readFileHandler,
|
||||
desktop_write_file: writeFileHandler,
|
||||
desktop_patch: patchHandler,
|
||||
desktop_terminal: terminalHandler,
|
||||
desktop_search_files: searchFilesHandler
|
||||
}
|
||||
})
|
||||
toolRouter.attach(relay)
|
||||
process.stderr.write(
|
||||
'Desktop tools: 5 handlers advertised (read_file, write_file, terminal, search_files, patch)\n'
|
||||
)
|
||||
} else if (consent.reason) {
|
||||
process.stderr.write(`Desktop tools: disabled (${consent.reason})\n`)
|
||||
}
|
||||
}
|
||||
|
||||
// Single teardown path — detach the tool router first so in-flight tool
|
||||
// calls get "aborted" responses rather than blocking the gw.kill() close.
|
||||
// Previously this recursed into itself; now it does the right thing.
|
||||
const tearDown = () => {
|
||||
try {
|
||||
toolRouter?.detach()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
gw.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
setupGracefulExit({ cleanups: [tearDown] })
|
||||
|
||||
gw.start()
|
||||
gw.drain()
|
||||
|
||||
try {
|
||||
await waitForReady(gw)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
|
||||
// Prefer the role we resolved from --pair-qr this invocation; fall back to
|
||||
// whatever was stored at pair time for older flows.
|
||||
const storedForBanner = await getSession(url)
|
||||
const bannerRole = resolvedRole ?? storedForBanner?.endpointRole ?? null
|
||||
process.stderr.write(
|
||||
buildConnectBanner({
|
||||
url,
|
||||
serverVersion: relay.serverVersion,
|
||||
meta: relay.authMeta,
|
||||
endpointRole: bannerRole
|
||||
}) + '\n'
|
||||
)
|
||||
|
||||
let session: { sessionId: string; model: string | null }
|
||||
try {
|
||||
session = await createOrResumeSession(gw, args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
|
||||
if (session.model) {
|
||||
process.stderr.write(`Session ${session.sessionId.slice(0, 8)}… on ${session.model}\n`)
|
||||
}
|
||||
|
||||
// Mode detection — one-shot vs piped vs REPL.
|
||||
|
||||
const oneShotPrompt = args.positional.join(' ').trim()
|
||||
if (oneShotPrompt) {
|
||||
try {
|
||||
await runOneTurn(gw, session.sessionId, oneShotPrompt, renderer).promise
|
||||
tearDown()
|
||||
return 0
|
||||
} catch (e) {
|
||||
process.stderr.write(`\nerror: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
const piped = (await readAllStdin()).trim()
|
||||
if (!piped) {
|
||||
process.stderr.write('no input on stdin; exiting.\n')
|
||||
tearDown()
|
||||
return 0
|
||||
}
|
||||
try {
|
||||
await runOneTurn(gw, session.sessionId, piped, renderer).promise
|
||||
tearDown()
|
||||
return 0
|
||||
} catch (e) {
|
||||
process.stderr.write(`\nerror: ${rpcErrorMessage(e)}\n`)
|
||||
tearDown()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// REPL mode.
|
||||
process.stderr.write('\nType a message. Ctrl+C to interrupt a turn, /quit to exit.\n')
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true })
|
||||
|
||||
// SIGINT target is the currently-running turn's handle. Keeping it per-turn
|
||||
// (instead of a shared mutable flag) means a late-arriving `error` event for
|
||||
// a cancelled turn can't be misread by the NEXT turn's handler — the flag
|
||||
// lives inside runOneTurn's closure and dies with the turn.
|
||||
let currentTurn: TurnHandle | null = null
|
||||
|
||||
rl.on('SIGINT', () => {
|
||||
if (currentTurn) {
|
||||
currentTurn.cancel()
|
||||
process.stderr.write('\n[interrupted]\n')
|
||||
} else {
|
||||
process.stderr.write('\nbye\n')
|
||||
rl.close()
|
||||
}
|
||||
})
|
||||
|
||||
let exitCode = 0
|
||||
while (true) {
|
||||
let line: string
|
||||
try {
|
||||
line = await rl.question('\n> ')
|
||||
} catch {
|
||||
break // readline closed
|
||||
}
|
||||
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
if (trimmed === '/quit' || trimmed === '/exit' || trimmed === ':q') {
|
||||
break
|
||||
}
|
||||
|
||||
const turn = runOneTurn(gw, session.sessionId, trimmed, renderer)
|
||||
currentTurn = turn
|
||||
try {
|
||||
await turn.promise
|
||||
} catch (e) {
|
||||
process.stderr.write(`\nerror: ${rpcErrorMessage(e)}\n`)
|
||||
exitCode = 1
|
||||
} finally {
|
||||
currentTurn = null
|
||||
}
|
||||
}
|
||||
|
||||
rl.close()
|
||||
tearDown()
|
||||
return exitCode
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// daemon — headless WSS + DesktopToolRouter, runs forever.
|
||||
//
|
||||
// The missing piece between "it works" and "feels local": the interactive
|
||||
// `shell` / `chat` commands only serve desktop tools while a terminal is
|
||||
// open. The daemon closes that gap — install it once (see
|
||||
// scripts/install-service-*) and the agent can reach the user's machine
|
||||
// any time of day, not just when they have a shell attached.
|
||||
//
|
||||
// Design contract:
|
||||
// - Fails closed if no stored session or consent isn't already true.
|
||||
// A headless binary must never be the thing that grants tool access;
|
||||
// the user must have previously consented via interactive `shell`.
|
||||
// - Inherits RelayTransport's reconnect state machine as-is. No custom
|
||||
// retry loop here — the transport's exp-backoff-to-30s + auth-resolve
|
||||
// semantics are already daemon-appropriate. Terminal auth failures
|
||||
// (auth.fail) exit non-zero so the service manager restarts us fresh
|
||||
// after the user re-pairs; the transient failure case is handled
|
||||
// inside the transport.
|
||||
// - `onChannel('desktop', ...)` listeners survive reconnects (the
|
||||
// transport's channelListeners Map is persistent), so the router's
|
||||
// attach only fires once at startup — no re-attach on every 'reconnected'.
|
||||
// - Logs are JSON-line by default (parseable by journald / log shippers);
|
||||
// --log-human opts into a pretty rendering, and it's auto-enabled when
|
||||
// stderr is a TTY so `hermes-relay daemon` in a terminal looks sane.
|
||||
//
|
||||
// Deferred to follow-ups:
|
||||
// - Service installer scripts (scripts/install-service-{win,linux,mac}).
|
||||
// - Foreground/daemon coordination when a shell attaches (server-side
|
||||
// single-client policy currently handles this — daemon reconnects
|
||||
// after the shell detaches; see roadmap for pause-while-interactive).
|
||||
// - --log-file <path>: for now, redirect stderr if you need a file.
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { getSession } from '../remoteSessions.js'
|
||||
import { readFileHandler, writeFileHandler, patchHandler } from '../tools/handlers/fs.js'
|
||||
import { searchFilesHandler } from '../tools/handlers/search.js'
|
||||
import { terminalHandler } from '../tools/handlers/terminal.js'
|
||||
import { DesktopToolRouter } from '../tools/router.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
interface LogFields {
|
||||
event: string
|
||||
[k: string]: unknown
|
||||
}
|
||||
|
||||
/** Structured logger — writes one JSON object per line to stderr (the
|
||||
* daemon convention that lets journald / logrotate / jq interoperate), or
|
||||
* a human-readable line when --log-human is on (or stderr is a TTY). */
|
||||
function makeLogger(human: boolean): {
|
||||
info: (fields: LogFields) => void
|
||||
warn: (fields: LogFields) => void
|
||||
error: (fields: LogFields) => void
|
||||
} {
|
||||
const write = (level: LogLevel, fields: LogFields) => {
|
||||
const ts = new Date().toISOString()
|
||||
if (human) {
|
||||
const { event, ...rest } = fields
|
||||
const extras = Object.keys(rest).length
|
||||
? ' ' + Object.entries(rest).map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(' ')
|
||||
: ''
|
||||
const pad = level === 'info' ? 'INFO ' : level === 'warn' ? 'WARN ' : 'ERROR'
|
||||
process.stderr.write(`${ts} ${pad} ${event}${extras}\n`)
|
||||
return
|
||||
}
|
||||
process.stderr.write(JSON.stringify({ ts, level, ...fields }) + '\n')
|
||||
}
|
||||
return {
|
||||
info: (fields) => write('info', fields),
|
||||
warn: (fields) => write('warn', fields),
|
||||
error: (fields) => write('error', fields)
|
||||
}
|
||||
}
|
||||
|
||||
function resolveRemoteOrNull(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
const url = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
return url ? url.trim() : null
|
||||
}
|
||||
|
||||
export async function daemonCommand(args: ParsedArgs): Promise<number> {
|
||||
// Default log shape: JSON-line for service-manager deploys, human if a
|
||||
// human is watching (TTY stderr) or asked for it explicitly.
|
||||
const humanFlag = !!args.flags['log-human']
|
||||
const human = humanFlag || (!args.flags['log-json'] && !!process.stderr.isTTY)
|
||||
const log = makeLogger(human)
|
||||
|
||||
const url = resolveRemoteOrNull(args)
|
||||
if (!url) {
|
||||
log.error({
|
||||
event: 'config_missing',
|
||||
message: 'daemon requires --remote <url> or HERMES_RELAY_URL'
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
// Resolve credentials: daemon takes ONLY --token or stored session. No
|
||||
// pairing code path (the daemon can't do the one-time code → token
|
||||
// trade safely — the token should already be stored). No interactive
|
||||
// fallback (headless).
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token : undefined
|
||||
const envToken = process.env.HERMES_RELAY_TOKEN
|
||||
const stored = await getSession(url)
|
||||
const token = argToken ?? envToken ?? stored?.token
|
||||
|
||||
if (!token) {
|
||||
log.error({
|
||||
event: 'no_credentials',
|
||||
url,
|
||||
message: 'no session token. Run `hermes-relay pair --remote <url>` once, then start the daemon.'
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
// Consent gate: the daemon must not grant tool access on its own. The
|
||||
// interactive `shell` command is the canonical place consent is captured,
|
||||
// and it writes `toolsConsented: true` onto the stored session. If the
|
||||
// token came from --token but we have no stored record, assume the user
|
||||
// knows what they're doing ONLY if they also pass --allow-tools; otherwise
|
||||
// bail.
|
||||
const tokenFromStored = !argToken && !envToken
|
||||
const consented = stored?.toolsConsented === true
|
||||
const allowToolsFlag = !!args.flags['allow-tools']
|
||||
|
||||
if (tokenFromStored && !consented) {
|
||||
log.error({
|
||||
event: 'consent_missing',
|
||||
url,
|
||||
message:
|
||||
'tools not consented for this URL. Run `hermes-relay shell --remote <url>` once to grant, or pass --allow-tools to override.'
|
||||
})
|
||||
return 1
|
||||
}
|
||||
if (!tokenFromStored && !consented && !allowToolsFlag) {
|
||||
log.error({
|
||||
event: 'consent_missing',
|
||||
url,
|
||||
message:
|
||||
'no stored consent for this URL. Pass --allow-tools to override, or pair interactively first.'
|
||||
})
|
||||
return 1
|
||||
}
|
||||
|
||||
log.info({
|
||||
event: 'starting',
|
||||
url,
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
node: process.version
|
||||
})
|
||||
|
||||
const relay = new RelayTransport({
|
||||
url,
|
||||
sessionToken: token,
|
||||
deviceName: `hermes-relay-cli daemon (${process.platform})`
|
||||
})
|
||||
|
||||
// Lifecycle wiring — every event the transport emits that a daemon
|
||||
// should log. These install before `start()` so we don't race the
|
||||
// connect-completes-before-listener-attached window.
|
||||
relay.on('reconnecting', (info: unknown) => {
|
||||
const { attempt, delayMs } =
|
||||
info && typeof info === 'object'
|
||||
? (info as { attempt?: number; delayMs?: number })
|
||||
: {}
|
||||
log.warn({ event: 'reconnecting', attempt: attempt ?? null, delay_ms: delayMs ?? null })
|
||||
})
|
||||
relay.on('reconnected', () => {
|
||||
log.info({ event: 'reconnected' })
|
||||
})
|
||||
relay.on('exit', (code: unknown) => {
|
||||
// Transport gave up (auth.fail, reconnect gate returned false, or
|
||||
// reconnect attempts exhausted). Daemon exits non-zero so the
|
||||
// service manager decides whether to restart.
|
||||
log.error({ event: 'transport_exited', code: typeof code === 'number' ? code : null })
|
||||
// Defer exit so the log line flushes before the process dies.
|
||||
setImmediate(() => process.exit(1))
|
||||
})
|
||||
|
||||
relay.start()
|
||||
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
if (!outcome.ok) {
|
||||
log.error({ event: 'auth_failed', reason: outcome.reason })
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
log.info({
|
||||
event: 'authed',
|
||||
server_version: relay.serverVersion ?? null,
|
||||
transport: relay.authMeta?.transportHint ?? null
|
||||
})
|
||||
|
||||
// Wire the desktop tool router. consentGranted is true by this point —
|
||||
// we gated on stored consent (or --allow-tools override) above.
|
||||
const router = new DesktopToolRouter({
|
||||
consentGranted: true,
|
||||
handlers: {
|
||||
desktop_read_file: readFileHandler,
|
||||
desktop_write_file: writeFileHandler,
|
||||
desktop_patch: patchHandler,
|
||||
desktop_terminal: terminalHandler,
|
||||
desktop_search_files: searchFilesHandler
|
||||
}
|
||||
})
|
||||
router.attach(relay)
|
||||
|
||||
log.info({
|
||||
event: 'ready',
|
||||
advertised_tools: [
|
||||
'desktop_read_file',
|
||||
'desktop_write_file',
|
||||
'desktop_patch',
|
||||
'desktop_terminal',
|
||||
'desktop_search_files'
|
||||
]
|
||||
})
|
||||
|
||||
// Graceful shutdown: detach router (stops heartbeats), kill transport
|
||||
// (closes the WSS), then let setupGracefulExit's failsafe exit us.
|
||||
const cleanup = () => {
|
||||
log.info({ event: 'shutdown' })
|
||||
try {
|
||||
router.detach()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
setupGracefulExit({ cleanups: [cleanup] })
|
||||
|
||||
// Park forever — all work happens through event handlers and the router.
|
||||
// Resolve only when process.exit fires from 'exit' handler or a signal.
|
||||
return new Promise<number>(() => {
|
||||
/* never resolves; lifecycle is driven by signals + transport events */
|
||||
})
|
||||
}
|
||||
|
||||
// Module-default: used only when the file is imported directly (tests).
|
||||
// The CLI dispatches via `cli.ts` → `daemonCommand`. The try/catch at the
|
||||
// shim handles unexpected throws.
|
||||
export default daemonCommand
|
||||
|
||||
// Small utility function re-exported for tests that need to stub the logger.
|
||||
export type { LogFields }
|
||||
export { makeLogger as __makeLoggerForTests, rpcErrorMessage as __rpcErrorMessageForTests }
|
||||
@@ -0,0 +1,289 @@
|
||||
// devices — server-side paired-device management.
|
||||
//
|
||||
// The relay exposes three HTTP endpoints (not WSS) for session management:
|
||||
// GET /sessions list all paired devices
|
||||
// DELETE /sessions/{token_prefix} revoke one
|
||||
// PATCH /sessions/{token_prefix} extend TTL / change grants
|
||||
//
|
||||
// These live on the same port as the WSS endpoint (default 8767). Auth is
|
||||
// `Authorization: Bearer <session_token>`. For local dashboards the server
|
||||
// accepts loopback traffic without a bearer (plugin/relay/server.py:646+),
|
||||
// but from a remote CLI we always need the token — pulled from the local
|
||||
// session store.
|
||||
//
|
||||
// Subcommand shape (mirrors Android's "Relay Sessions" screen):
|
||||
// hermes-relay devices list
|
||||
// hermes-relay devices list --json list as JSON for scripting
|
||||
// hermes-relay devices revoke <prefix> DELETE — destroys the token
|
||||
// hermes-relay devices extend <prefix> [--ttl <seconds>] PATCH — defaults to 24h
|
||||
//
|
||||
// If no `--remote` is passed we default to the single stored relay (if
|
||||
// exactly one exists). Otherwise --remote is required.
|
||||
|
||||
import { humanExpiry } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { getSession, listSessions } from '../remoteSessions.js'
|
||||
|
||||
const DEFAULT_EXTEND_TTL_SECONDS = 24 * 3600
|
||||
|
||||
interface ServerSession {
|
||||
token_prefix: string
|
||||
device_name?: string
|
||||
device_id?: string
|
||||
created_at?: number
|
||||
first_seen?: number
|
||||
last_seen?: number
|
||||
expires_at?: number | null
|
||||
grants?: Record<string, number | null>
|
||||
transport_hint?: string
|
||||
is_current?: boolean
|
||||
}
|
||||
|
||||
interface SessionsListResponse {
|
||||
sessions?: ServerSession[]
|
||||
}
|
||||
|
||||
/** Convert a ws://host:port URL to http://host:port for the sessions HTTP API.
|
||||
* The relay serves both on the same port, so the host:port swap is the whole
|
||||
* transform. `wss://` → `https://`, `ws://` → `http://`. */
|
||||
function wsToHttp(url: string): string {
|
||||
const trimmed = url.trim()
|
||||
if (trimmed.startsWith('wss://')) {
|
||||
return 'https://' + trimmed.slice('wss://'.length)
|
||||
}
|
||||
if (trimmed.startsWith('ws://')) {
|
||||
return 'http://' + trimmed.slice('ws://'.length)
|
||||
}
|
||||
// Already http(s) — leave alone.
|
||||
return trimmed
|
||||
}
|
||||
|
||||
async function resolveRemoteAndToken(
|
||||
args: ParsedArgs
|
||||
): Promise<{ url: string; token: string }> {
|
||||
const argUrl = typeof args.flags.remote === 'string' ? args.flags.remote.trim() : null
|
||||
const envUrl = process.env.HERMES_RELAY_URL?.trim()
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token.trim() : null
|
||||
const envToken = process.env.HERMES_RELAY_TOKEN?.trim()
|
||||
|
||||
// Token direct from flag / env short-circuits the stored-session lookup.
|
||||
if (argToken || envToken) {
|
||||
const url = argUrl ?? envUrl
|
||||
if (!url) {
|
||||
throw new Error('--token supplied without --remote. Pass both, or set HERMES_RELAY_URL.')
|
||||
}
|
||||
return { url, token: (argToken ?? envToken)! }
|
||||
}
|
||||
|
||||
const stored = await listSessions()
|
||||
const urls = Object.keys(stored)
|
||||
|
||||
let url: string
|
||||
if (argUrl || envUrl) {
|
||||
url = argUrl ?? envUrl!
|
||||
} else if (urls.length === 1) {
|
||||
url = urls[0]!
|
||||
} else if (urls.length === 0) {
|
||||
throw new Error('No paired relays. Run `hermes-relay pair --remote ws://host:port` first.')
|
||||
} else {
|
||||
throw new Error(
|
||||
`Multiple paired relays; pass --remote to pick one (${urls.join(', ')}).`
|
||||
)
|
||||
}
|
||||
|
||||
const rec = await getSession(url)
|
||||
if (!rec) {
|
||||
throw new Error(`No stored session for ${url}. Run \`hermes-relay pair --remote ${url}\` first.`)
|
||||
}
|
||||
return { url, token: rec.token }
|
||||
}
|
||||
|
||||
async function jsonFetch(
|
||||
url: string,
|
||||
token: string,
|
||||
init: { method: 'GET' | 'DELETE' | 'PATCH'; body?: unknown } = { method: 'GET' }
|
||||
): Promise<{ status: number; body: unknown }> {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json'
|
||||
}
|
||||
if (init.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: init.method,
|
||||
headers,
|
||||
body: init.body !== undefined ? JSON.stringify(init.body) : undefined
|
||||
})
|
||||
// Parse body opportunistically — not all responses are JSON (e.g. 204).
|
||||
const text = await res.text()
|
||||
let body: unknown
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
body = JSON.parse(text)
|
||||
} catch {
|
||||
body = text
|
||||
}
|
||||
}
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
async function listDevices(args: ParsedArgs): Promise<number> {
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const httpBase = wsToHttp(url)
|
||||
const { status, body } = await jsonFetch(`${httpBase}/sessions`, token)
|
||||
|
||||
if (status !== 200) {
|
||||
process.stderr.write(`error: GET /sessions returned ${status}: ${JSON.stringify(body)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
const sessions = (body as SessionsListResponse)?.sessions ?? []
|
||||
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(sessions, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
process.stdout.write(`(no paired devices on ${url})\n`)
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(`Devices paired with ${url} (${sessions.length}):\n\n`)
|
||||
for (const s of sessions) {
|
||||
const tag = s.is_current ? ' ● (this device)' : ''
|
||||
const name = s.device_name ?? '(unnamed)'
|
||||
process.stdout.write(` ${s.token_prefix} ${name}${tag}\n`)
|
||||
if (s.last_seen) {
|
||||
const ageSec = Math.floor(Date.now() / 1000) - s.last_seen
|
||||
const ageHuman =
|
||||
ageSec < 60
|
||||
? `${ageSec}s`
|
||||
: ageSec < 3600
|
||||
? `${Math.floor(ageSec / 60)}m`
|
||||
: ageSec < 86_400
|
||||
? `${Math.floor(ageSec / 3600)}h`
|
||||
: `${Math.floor(ageSec / 86_400)}d`
|
||||
process.stdout.write(` last seen: ${ageHuman} ago\n`)
|
||||
}
|
||||
process.stdout.write(` expires: ${humanExpiry(s.expires_at ?? null)}\n`)
|
||||
if (s.transport_hint) {
|
||||
process.stdout.write(` transport: ${s.transport_hint}\n`)
|
||||
}
|
||||
if (s.grants && Object.keys(s.grants).length > 0) {
|
||||
const formatted = Object.entries(s.grants)
|
||||
.map(([k, v]) => `${k}=${v === null ? 'never' : humanExpiry(v)}`)
|
||||
.sort()
|
||||
.join(', ')
|
||||
process.stdout.write(` grants: ${formatted}\n`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
process.stdout.write(
|
||||
` Use \`hermes-relay devices revoke <prefix>\` to delete a session, or\n` +
|
||||
` \`hermes-relay devices extend <prefix> --ttl <seconds>\` to push the expiry.\n`
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
async function revokeDevice(args: ParsedArgs): Promise<number> {
|
||||
const prefix = args.positional[0]
|
||||
if (!prefix) {
|
||||
process.stderr.write('error: `devices revoke` needs a token prefix. Run `devices` to see them.\n')
|
||||
return 2
|
||||
}
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const httpBase = wsToHttp(url)
|
||||
const { status, body } = await jsonFetch(`${httpBase}/sessions/${encodeURIComponent(prefix)}`, token, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
if (status === 200 || status === 204) {
|
||||
const revokedSelf = typeof body === 'object' && body !== null && (body as Record<string, unknown>).revoked_self === true
|
||||
process.stdout.write(`✓ revoked ${prefix}${revokedSelf ? ' (this device — subsequent commands will re-pair)' : ''}\n`)
|
||||
return 0
|
||||
}
|
||||
if (status === 404) {
|
||||
process.stderr.write(`error: no session matches prefix "${prefix}"\n`)
|
||||
return 1
|
||||
}
|
||||
if (status === 409) {
|
||||
process.stderr.write(`error: multiple sessions match "${prefix}". Use a longer prefix.\n`)
|
||||
return 1
|
||||
}
|
||||
process.stderr.write(`error: DELETE /sessions/${prefix} returned ${status}: ${JSON.stringify(body)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
async function extendDevice(args: ParsedArgs): Promise<number> {
|
||||
const prefix = args.positional[0]
|
||||
if (!prefix) {
|
||||
process.stderr.write('error: `devices extend` needs a token prefix. Run `devices` to see them.\n')
|
||||
return 2
|
||||
}
|
||||
const rawTtl = typeof args.flags.ttl === 'string' ? args.flags.ttl : null
|
||||
const ttlSeconds = rawTtl === null ? DEFAULT_EXTEND_TTL_SECONDS : parseInt(rawTtl, 10)
|
||||
if (!Number.isFinite(ttlSeconds) || ttlSeconds < 0) {
|
||||
process.stderr.write(`error: --ttl must be a non-negative integer (got "${rawTtl}")\n`)
|
||||
return 2
|
||||
}
|
||||
const { url, token } = await resolveRemoteAndToken(args)
|
||||
const httpBase = wsToHttp(url)
|
||||
const { status, body } = await jsonFetch(`${httpBase}/sessions/${encodeURIComponent(prefix)}`, token, {
|
||||
method: 'PATCH',
|
||||
body: { ttl_seconds: ttlSeconds }
|
||||
})
|
||||
if (status === 200) {
|
||||
const expiresAt = (body as { expires_at?: number | null })?.expires_at ?? null
|
||||
process.stdout.write(
|
||||
`✓ extended ${prefix} — now expires ${humanExpiry(expiresAt)}` +
|
||||
(expiresAt === null ? ' (never)' : '') +
|
||||
'\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
process.stderr.write(`error: PATCH /sessions/${prefix} returned ${status}: ${JSON.stringify(body)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
export async function devicesCommand(args: ParsedArgs): Promise<number> {
|
||||
// The first positional after `devices` is the sub-verb: list (default) /
|
||||
// revoke / extend. Shift it out so the remaining positionals are available
|
||||
// to the sub-handler (which uses positional[0] for the token prefix).
|
||||
const sub = args.positional[0] ?? 'list'
|
||||
|
||||
if (sub === 'list') {
|
||||
if (args.positional.length > 0 && args.positional[0] === 'list') {
|
||||
args.positional.shift()
|
||||
}
|
||||
try {
|
||||
return await listDevices(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'revoke') {
|
||||
args.positional.shift()
|
||||
try {
|
||||
return await revokeDevice(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
if (sub === 'extend') {
|
||||
args.positional.shift()
|
||||
try {
|
||||
return await extendDevice(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
process.stderr.write(`unknown devices sub-verb "${sub}". Try: list | revoke <prefix> | extend <prefix>\n`)
|
||||
return 2
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
// doctor — local diagnostic report. Zero network, zero deps.
|
||||
//
|
||||
// Designed for two audiences:
|
||||
// 1. Users sanity-checking an install ("is hermes-relay actually on my
|
||||
// PATH? did my session store survive the upgrade?").
|
||||
// 2. Support triage ("paste `hermes-relay doctor --json` so we can see
|
||||
// what you've got") — hence the --json mode with redacted tokens.
|
||||
//
|
||||
// Everything here is a local read: package.json for version, process.*
|
||||
// for runtime, fs.stat for files, and a plain listSessions() for stored
|
||||
// pairings. No WSS, no HTTP, no daemon probe beyond "is the service
|
||||
// file on disk?" — doctor should complete in milliseconds regardless of
|
||||
// network state.
|
||||
|
||||
import { readFileSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { delimiter, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { humanExpiry } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { listSessions } from '../remoteSessions.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function readVersion(): string {
|
||||
// Same trick as cli.ts — dist/commands/doctor.js → dist → pkg root.
|
||||
try {
|
||||
const pkgPath = join(__dirname, '..', '..', 'package.json')
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { version?: string }
|
||||
return pkg.version ?? '0.0.0'
|
||||
} catch {
|
||||
return '0.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBinaryPath(): string {
|
||||
// argv[1] is the invoked script (the bin shim under normal `hermes-relay`
|
||||
// usage, or dist/cli.js under `node dist/cli.js`, or the tsx entry under
|
||||
// `tsx src/cli.ts`). It's what the user actually ran, so it's the right
|
||||
// answer for "where is my binary?"
|
||||
return process.argv[1] ?? fileURLToPath(import.meta.url)
|
||||
}
|
||||
|
||||
function isOnPath(installDir: string): boolean {
|
||||
const path = process.env.PATH ?? ''
|
||||
if (!path) {
|
||||
return false
|
||||
}
|
||||
const entries = path.split(delimiter).map((p) => p.trim()).filter(Boolean)
|
||||
// Case-insensitive compare on Windows, exact elsewhere. We don't try to
|
||||
// canonicalize symlinks — false negatives here are acceptable ("it's
|
||||
// working but doctor says it's not" is annoying but recoverable).
|
||||
if (process.platform === 'win32') {
|
||||
const target = installDir.toLowerCase()
|
||||
return entries.some((p) => p.toLowerCase() === target)
|
||||
}
|
||||
return entries.includes(installDir)
|
||||
}
|
||||
|
||||
interface DaemonDetection {
|
||||
detected: boolean
|
||||
note: string | null
|
||||
}
|
||||
|
||||
function detectDaemon(): DaemonDetection {
|
||||
// Service installers aren't shipped yet — report truthfully but have the
|
||||
// detection code ready. When the installers land we just drop the note.
|
||||
const home = homedir()
|
||||
try {
|
||||
if (process.platform === 'linux') {
|
||||
const svc = join(home, '.config', 'systemd', 'user', 'hermes-relay-daemon.service')
|
||||
try {
|
||||
statSync(svc)
|
||||
return { detected: true, note: null }
|
||||
} catch {
|
||||
return { detected: false, note: 'service installers not yet shipped' }
|
||||
}
|
||||
}
|
||||
if (process.platform === 'darwin') {
|
||||
const plist = join(home, 'Library', 'LaunchAgents', 'com.hermes.relay.daemon.plist')
|
||||
try {
|
||||
statSync(plist)
|
||||
return { detected: true, note: null }
|
||||
} catch {
|
||||
return { detected: false, note: 'service installers not yet shipped' }
|
||||
}
|
||||
}
|
||||
// Windows service installer not shipped; avoid shelling out to sc.exe.
|
||||
return { detected: false, note: 'service installers not yet shipped' }
|
||||
} catch {
|
||||
return { detected: false, note: 'service installers not yet shipped' }
|
||||
}
|
||||
}
|
||||
|
||||
function sessionsFilePath(): string {
|
||||
return join(homedir(), '.hermes', 'remote-sessions.json')
|
||||
}
|
||||
|
||||
function humanAge(seconds: number): string {
|
||||
if (seconds < 60) return `${seconds}s`
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
||||
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
|
||||
return `${Math.floor(seconds / 86_400)}d`
|
||||
}
|
||||
|
||||
function humanSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
interface SessionJson {
|
||||
url: string
|
||||
paired_at_iso: string
|
||||
server_version: string | null
|
||||
tools_consented: boolean
|
||||
ttl_expires_at_iso: string | null
|
||||
}
|
||||
|
||||
interface DoctorReport {
|
||||
version: string
|
||||
binary_path: string
|
||||
node_version: string
|
||||
platform: string
|
||||
arch: string
|
||||
install_dir: string
|
||||
on_path: boolean
|
||||
sessions_file: string
|
||||
sessions_file_exists: boolean
|
||||
sessions_file_size: number | null
|
||||
sessions: SessionJson[]
|
||||
sessions_count: number
|
||||
daemon_detected: boolean
|
||||
daemon_note: string | null
|
||||
}
|
||||
|
||||
async function gather(): Promise<DoctorReport> {
|
||||
const binary = resolveBinaryPath()
|
||||
const installDir = dirname(binary)
|
||||
const onPath = isOnPath(installDir)
|
||||
|
||||
const sessionsFile = sessionsFilePath()
|
||||
let sessionsFileExists = false
|
||||
let sessionsFileSize: number | null = null
|
||||
try {
|
||||
const st = statSync(sessionsFile)
|
||||
sessionsFileExists = true
|
||||
sessionsFileSize = st.size
|
||||
} catch {
|
||||
/* file missing or unreadable — report as does-not-exist */
|
||||
}
|
||||
|
||||
const storedSessions = await listSessions()
|
||||
const sessions: SessionJson[] = Object.entries(storedSessions).map(([url, rec]) => ({
|
||||
url,
|
||||
paired_at_iso: new Date(rec.pairedAt * 1000).toISOString(),
|
||||
server_version: rec.serverVersion,
|
||||
tools_consented: rec.toolsConsented === true,
|
||||
ttl_expires_at_iso:
|
||||
rec.ttlExpiresAt === null || rec.ttlExpiresAt === undefined
|
||||
? null
|
||||
: new Date(rec.ttlExpiresAt * 1000).toISOString()
|
||||
}))
|
||||
|
||||
const daemon = detectDaemon()
|
||||
|
||||
return {
|
||||
version: readVersion(),
|
||||
binary_path: binary,
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
install_dir: installDir,
|
||||
on_path: onPath,
|
||||
sessions_file: sessionsFile,
|
||||
sessions_file_exists: sessionsFileExists,
|
||||
sessions_file_size: sessionsFileSize,
|
||||
sessions,
|
||||
sessions_count: sessions.length,
|
||||
daemon_detected: daemon.detected,
|
||||
daemon_note: daemon.note
|
||||
}
|
||||
}
|
||||
|
||||
function renderHuman(report: DoctorReport): string {
|
||||
const lines: string[] = []
|
||||
const hints: string[] = []
|
||||
|
||||
lines.push('hermes-relay doctor')
|
||||
lines.push(` version: ${report.version}`)
|
||||
lines.push(` binary: ${report.binary_path}`)
|
||||
lines.push(` node: ${report.node_version} (${report.platform}/${report.arch})`)
|
||||
|
||||
if (report.on_path) {
|
||||
lines.push(` on PATH: yes`)
|
||||
} else {
|
||||
lines.push(`!! on PATH: no (install_dir: ${report.install_dir})`)
|
||||
hints.push(
|
||||
`add ${report.install_dir} to your PATH, or re-run the installer from desktop/scripts/`
|
||||
)
|
||||
}
|
||||
|
||||
if (report.sessions_file_exists) {
|
||||
const sz = report.sessions_file_size !== null ? ` (${humanSize(report.sessions_file_size)})` : ''
|
||||
lines.push(` sessions file: ${report.sessions_file}${sz}`)
|
||||
} else {
|
||||
lines.push(`!! sessions file: ${report.sessions_file} (missing)`)
|
||||
hints.push('run `hermes-relay pair --remote <url>` to create it')
|
||||
}
|
||||
|
||||
if (report.sessions_count === 0) {
|
||||
lines.push(` sessions: 0 stored`)
|
||||
} else {
|
||||
lines.push(` sessions: ${report.sessions_count} stored`)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
for (const s of report.sessions) {
|
||||
const pairedAtSec = Math.floor(new Date(s.paired_at_iso).getTime() / 1000)
|
||||
const age = humanAge(Math.max(0, now - pairedAtSec))
|
||||
const server = s.server_version ?? 'unknown'
|
||||
const consent = s.tools_consented ? 'tools consented' : 'tools not consented'
|
||||
let ttlPart = ''
|
||||
if (s.ttl_expires_at_iso !== null) {
|
||||
const ttlSec = Math.floor(new Date(s.ttl_expires_at_iso).getTime() / 1000)
|
||||
ttlPart = `, expires ${humanExpiry(ttlSec)}`
|
||||
} else {
|
||||
ttlPart = ', never expires'
|
||||
}
|
||||
lines.push(` - ${s.url} (server ${server}, paired ${age} ago, ${consent}${ttlPart})`)
|
||||
}
|
||||
}
|
||||
|
||||
if (report.daemon_detected) {
|
||||
lines.push(` daemon: installed`)
|
||||
} else if (report.daemon_note) {
|
||||
lines.push(` daemon: not installed (${report.daemon_note})`)
|
||||
} else {
|
||||
lines.push(` daemon: not installed`)
|
||||
}
|
||||
|
||||
if (hints.length > 0) {
|
||||
lines.push('')
|
||||
for (const hint of hints) {
|
||||
lines.push(`hint: ${hint}`)
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n'
|
||||
}
|
||||
|
||||
export async function doctorCommand(args: ParsedArgs): Promise<number> {
|
||||
const report = await gather()
|
||||
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(report, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(renderHuman(report))
|
||||
return 0
|
||||
}
|
||||
|
||||
export default doctorCommand
|
||||
@@ -0,0 +1,159 @@
|
||||
// pair — explicit pairing subcommand. Does nothing except the handshake:
|
||||
// connect, auth with a one-time code (or multi-endpoint QR payload), persist
|
||||
// the minted session token, exit. Useful as a first-time setup step so
|
||||
// subsequent chat/tools/status calls don't prompt for a code.
|
||||
//
|
||||
// Two auth paths:
|
||||
// (a) 6-char pairing code + --remote URL (single endpoint, legacy).
|
||||
// (b) --pair-qr <full-QR-payload> (ADR 24 multi-endpoint — decode, probe
|
||||
// the endpoints list in priority order, connect to the first reachable
|
||||
// one, record its role in the stored session so `status` / banner
|
||||
// render "Paired via LAN / Tailscale / Public" correctly).
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import {
|
||||
cleanCode,
|
||||
isValidCode,
|
||||
promptForPairingCode,
|
||||
validatePairingPayloadString
|
||||
} from '../pairing.js'
|
||||
import { payloadToCandidates, probeCandidatesByPriority } from '../pairingQr.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { saveSession } from '../remoteSessions.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
function resolveRemote(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
const url = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
return url ? url.trim() : null
|
||||
}
|
||||
|
||||
interface PairTarget {
|
||||
url: string
|
||||
code: string
|
||||
/** Active-endpoint role if this came from a multi-endpoint QR probe. */
|
||||
endpointRole: string | null
|
||||
}
|
||||
|
||||
async function resolvePairTarget(args: ParsedArgs): Promise<PairTarget | { error: string }> {
|
||||
// Path (b): --pair-qr / HERMES_RELAY_PAIR_QR — multi-endpoint payload. This
|
||||
// wins over --remote + --code because the QR carries both: the candidate
|
||||
// list AND the pairing key.
|
||||
const argPairQr = typeof args.flags['pair-qr'] === 'string' ? args.flags['pair-qr'] : null
|
||||
const envPairQr = process.env.HERMES_RELAY_PAIR_QR?.trim()
|
||||
const pairQr = argPairQr?.trim() || envPairQr
|
||||
|
||||
if (pairQr) {
|
||||
const validated = validatePairingPayloadString(pairQr)
|
||||
if (!validated.ok) {
|
||||
return { error: `invalid --pair-qr payload: ${validated.reason}` }
|
||||
}
|
||||
const candidates = payloadToCandidates(validated.payload)
|
||||
if (candidates.length === 0) {
|
||||
return { error: 'pairing payload had no endpoints to probe' }
|
||||
}
|
||||
process.stderr.write(`Probing ${candidates.length} endpoint(s)...\n`)
|
||||
let winner
|
||||
try {
|
||||
winner = await probeCandidatesByPriority(candidates)
|
||||
} catch (e) {
|
||||
return { error: `no endpoints reachable: ${e instanceof Error ? e.message : String(e)}` }
|
||||
}
|
||||
process.stderr.write(
|
||||
` → picked ${winner.role} endpoint ${winner.relay.url}\n`
|
||||
)
|
||||
return {
|
||||
url: winner.relay.url,
|
||||
code: validated.payload.key.toUpperCase(),
|
||||
endpointRole: winner.role
|
||||
}
|
||||
}
|
||||
|
||||
// Path (a): --remote + --code / positional code.
|
||||
let url = resolveRemote(args)
|
||||
const nonInteractive = !!args.flags['non-interactive']
|
||||
|
||||
// First-run fallback: no --remote and no env var. Either auto-pick a
|
||||
// single stored session, prompt from a numbered list of stored sessions,
|
||||
// or ask for a brand-new URL. resolveFirstRunUrl() throws if
|
||||
// non-interactive + ambiguous (multiple or zero stored sessions).
|
||||
if (!url) {
|
||||
try {
|
||||
url = await resolveFirstRunUrl({
|
||||
nonInteractive,
|
||||
banner:
|
||||
'Pair: no --remote given. Pick an existing session or enter a new relay URL.'
|
||||
})
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
|
||||
const argCode = args.positional[0] ?? (typeof args.flags.code === 'string' ? args.flags.code : null)
|
||||
const envCode = process.env.HERMES_RELAY_CODE
|
||||
const raw = argCode ?? envCode ?? null
|
||||
|
||||
let code: string
|
||||
if (raw) {
|
||||
const cleaned = cleanCode(raw)
|
||||
if (!isValidCode(cleaned)) {
|
||||
return { error: `invalid code format. Need 6 chars of A-Z or 0-9. Got "${raw}"` }
|
||||
}
|
||||
code = cleaned
|
||||
} else {
|
||||
try {
|
||||
code = await promptForPairingCode(url)
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : String(e) }
|
||||
}
|
||||
}
|
||||
return { url, code, endpointRole: null }
|
||||
}
|
||||
|
||||
export async function pairCommand(args: ParsedArgs): Promise<number> {
|
||||
const target = await resolvePairTarget(args)
|
||||
if ('error' in target) {
|
||||
process.stderr.write(`error: ${target.error}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
process.stderr.write(`Pairing with ${target.url}...\n`)
|
||||
|
||||
const relay = new RelayTransport({
|
||||
url: target.url,
|
||||
pairingCode: target.code,
|
||||
deviceName: `hermes-relay-cli (${process.platform})`
|
||||
})
|
||||
|
||||
relay.start()
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
|
||||
if (outcome.ok) {
|
||||
await saveSession(target.url, outcome.token, outcome.serverVersion, {
|
||||
grants: outcome.meta.grants,
|
||||
ttlExpiresAt: outcome.meta.ttlExpiresAt,
|
||||
endpointRole: target.endpointRole
|
||||
})
|
||||
process.stdout.write(`✓ Paired. Token stored in ~/.hermes/remote-sessions.json\n`)
|
||||
process.stdout.write(` Server: ${outcome.serverVersion ?? '?'}\n`)
|
||||
process.stdout.write(` Relay: ${target.url}\n`)
|
||||
if (target.endpointRole) {
|
||||
process.stdout.write(` Route: ${target.endpointRole}\n`)
|
||||
}
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stderr.write(`✗ Pairing failed: ${outcome.reason}\n`)
|
||||
process.stderr.write(` ${relay.getLogTail(5)}\n`)
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1
|
||||
}
|
||||
@@ -0,0 +1,500 @@
|
||||
// shell — pipe a PTY on the relay to the local terminal.
|
||||
//
|
||||
// Drives the existing `terminal` relay channel (docs/relay-protocol.md §3.4,
|
||||
// already used by the Android TerminalViewModel). After the attach handshake
|
||||
// we inject `clear; exec hermes\n` so the tmux-hosted login shell replaces
|
||||
// itself with the full `hermes` CLI — banner, skin, session ID, all of it,
|
||||
// verbatim, no re-rendering. Zero server changes.
|
||||
//
|
||||
// Why the post-attach `exec` dance and not a `shell: "hermes"` attach param?
|
||||
// The relay's terminal channel spawns `tmux new-session -A` when tmux is
|
||||
// present (always, on this deploy) and tmux always launches the user's
|
||||
// default login shell — the `shell` attach field is stored for display
|
||||
// only. `exec` replaces bash in-place so Ctrl+C / EOF map to hermes, not
|
||||
// to an outer shell that would keep running after hermes exits.
|
||||
//
|
||||
// Wire contract (`plugin/relay/channels/terminal.py`, verified 2026-04-23):
|
||||
// attach (c→s) { channel:'terminal', type:'terminal.attach',
|
||||
// payload:{ cols, rows, session_name? } }
|
||||
// attached (s→c) { channel:'terminal', type:'terminal.attached',
|
||||
// payload:{ session_name, pid, shell, cols, rows,
|
||||
// tmux_available, reattach } }
|
||||
// input (c→s) { channel:'terminal', type:'terminal.input',
|
||||
// payload:{ session_name?, data: <utf8 string> } }
|
||||
// output (s→c) { channel:'terminal', type:'terminal.output',
|
||||
// payload:{ session_name, data: <utf8 string> } }
|
||||
// — output is batched ≤16ms / ≤4KB; raw ANSI embedded.
|
||||
// resize (c→s) { channel:'terminal', type:'terminal.resize',
|
||||
// payload:{ session_name?, cols, rows } }
|
||||
// detached (s→c) { channel:'terminal', type:'terminal.detached',
|
||||
// payload:{ session_name, reason } }
|
||||
// error (s→c) { channel:'terminal', type:'terminal.error',
|
||||
// payload:{ message } }
|
||||
//
|
||||
// Client-side escape: Ctrl+A as a prefix (tmux-style). `Ctrl+A .` detaches
|
||||
// (closes the WSS cleanly but preserves the tmux session on the server, so
|
||||
// the next `hermes-relay shell` re-attaches to the same hermes instance).
|
||||
// `Ctrl+A Ctrl+A` sends a literal Ctrl+A. `Ctrl+A k` kills the tmux session
|
||||
// for real (destructive, bypasses tmux persistence). Anything else after
|
||||
// Ctrl+A is swallowed with a one-line hint so the user isn't guessing.
|
||||
// Ctrl+C is NOT intercepted — it passes through as byte 0x03 to interrupt
|
||||
// whatever's running on the remote side, which is what the user expects.
|
||||
|
||||
import { buildConnectBanner } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { resolveCredentials } from '../credentials.js'
|
||||
import { setupGracefulExit } from '../lib/gracefulExit.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { deleteSession, getSession, saveSession } from '../remoteSessions.js'
|
||||
import { ensureToolsConsent } from '../tools/consent.js'
|
||||
import { readFileHandler, writeFileHandler, patchHandler } from '../tools/handlers/fs.js'
|
||||
import { searchFilesHandler } from '../tools/handlers/search.js'
|
||||
import { terminalHandler } from '../tools/handlers/terminal.js'
|
||||
import { DesktopToolRouter } from '../tools/router.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
const ATTACH_TIMEOUT_MS = 30_000
|
||||
const CTRL_A = 0x01
|
||||
/** Time between `terminal.attached` and the auto-`exec` we inject. tmux
|
||||
* needs a beat to settle the new-session → login-shell prompt; if we
|
||||
* blast `exec hermes` in too fast, the shell sees it before the prompt
|
||||
* is drawn and bash eats the first keystroke of your subsequent input.
|
||||
* 350 ms is empirical — under 200 eats chars on a cold tmux session;
|
||||
* over 500 feels laggy. */
|
||||
const EXEC_SETTLE_MS = 350
|
||||
|
||||
function resolveRemoteOrNull(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
const url = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
return url ? url.trim() : null
|
||||
}
|
||||
|
||||
interface AuthedRelay {
|
||||
relay: RelayTransport
|
||||
url: string
|
||||
endpointRole: string | null
|
||||
}
|
||||
|
||||
async function connectAndAuth(args: ParsedArgs): Promise<AuthedRelay> {
|
||||
let urlFlag = resolveRemoteOrNull(args)
|
||||
const argCode = typeof args.flags.code === 'string' ? args.flags.code : undefined
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token : undefined
|
||||
const argPairQr =
|
||||
typeof args.flags['pair-qr'] === 'string'
|
||||
? args.flags['pair-qr']
|
||||
: process.env.HERMES_RELAY_PAIR_QR
|
||||
const nonInteractive = !!args.flags['non-interactive']
|
||||
|
||||
// First-run fallback: no --remote, no env var, no QR payload. Either
|
||||
// auto-pick a single stored session, pick from a list, or walk a brand-new
|
||||
// user through URL entry. resolveFirstRunUrl throws on non-interactive +
|
||||
// ambiguous. Once we have a URL, resolveCredentials handles the rest of
|
||||
// the chain (stored token → pairing-code prompt on TTY).
|
||||
if (!urlFlag && !argPairQr) {
|
||||
urlFlag = await resolveFirstRunUrl({ nonInteractive })
|
||||
}
|
||||
const probeUrl = urlFlag ?? 'ws://pair-qr-pending'
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const creds = await resolveCredentials(probeUrl, {
|
||||
argCode,
|
||||
argToken,
|
||||
argPairQr,
|
||||
nonInteractive
|
||||
})
|
||||
|
||||
const url = (creds.resolvedEndpoint?.relay.url ?? urlFlag)!.trim()
|
||||
const endpointRole = creds.resolvedEndpoint?.role ?? null
|
||||
|
||||
const cfg: ConstructorParameters<typeof RelayTransport>[0] = {
|
||||
url,
|
||||
deviceName: `hermes-relay-cli shell (${process.platform})`
|
||||
}
|
||||
if (creds.pairingCode) {
|
||||
cfg.pairingCode = creds.pairingCode
|
||||
}
|
||||
if (creds.sessionToken) {
|
||||
cfg.sessionToken = creds.sessionToken
|
||||
}
|
||||
const relay = new RelayTransport(cfg)
|
||||
relay.onAuthSuccess((token, ver, meta) => {
|
||||
void saveSession(url, token, ver, {
|
||||
grants: meta.grants,
|
||||
ttlExpiresAt: meta.ttlExpiresAt,
|
||||
endpointRole
|
||||
})
|
||||
})
|
||||
relay.start()
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
if (outcome.ok) {
|
||||
return { relay, url, endpointRole }
|
||||
}
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (creds.sessionToken) {
|
||||
await deleteSession(url)
|
||||
}
|
||||
if (attempt === 1 || nonInteractive) {
|
||||
throw new Error(`relay rejected credentials: ${outcome.reason}`)
|
||||
}
|
||||
process.stderr.write(`\nRelay rejected credentials: ${outcome.reason}\n`)
|
||||
}
|
||||
throw new Error('unreachable: connectAndAuth exhausted loop')
|
||||
}
|
||||
|
||||
interface AttachedInfo {
|
||||
sessionName: string
|
||||
pid?: number
|
||||
shell?: string
|
||||
tmuxAvailable?: boolean
|
||||
reattach?: boolean
|
||||
}
|
||||
|
||||
/** Wait for the server's `terminal.attached` ack (or error) after we've
|
||||
* sent `terminal.attach`. Rejects on timeout, on `terminal.error`, or if
|
||||
* the transport tears down before the ack arrives. */
|
||||
function waitForAttached(relay: RelayTransport): Promise<AttachedInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
relay.onChannel('terminal', null)
|
||||
reject(new Error(`terminal.attached timeout after ${ATTACH_TIMEOUT_MS}ms`))
|
||||
}, ATTACH_TIMEOUT_MS)
|
||||
|
||||
relay.onChannel('terminal', (type, payload) => {
|
||||
if (type === 'terminal.attached') {
|
||||
clearTimeout(timer)
|
||||
const sessionName = typeof payload.session_name === 'string' ? payload.session_name : ''
|
||||
if (!sessionName) {
|
||||
reject(new Error('terminal.attached ack missing session_name'))
|
||||
return
|
||||
}
|
||||
resolve({
|
||||
sessionName,
|
||||
pid: typeof payload.pid === 'number' ? payload.pid : undefined,
|
||||
shell: typeof payload.shell === 'string' ? payload.shell : undefined,
|
||||
tmuxAvailable: typeof payload.tmux_available === 'boolean' ? payload.tmux_available : undefined,
|
||||
reattach: typeof payload.reattach === 'boolean' ? payload.reattach : undefined
|
||||
})
|
||||
return
|
||||
}
|
||||
if (type === 'terminal.error') {
|
||||
clearTimeout(timer)
|
||||
const msg = typeof payload.message === 'string' ? payload.message : 'terminal error'
|
||||
reject(new Error(msg))
|
||||
return
|
||||
}
|
||||
// Drop any other envelopes that arrive before attached.
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Feature-detect raw mode — a CI environment or a non-TTY stdin won't
|
||||
* have it. We need raw mode to forward every keystroke (including
|
||||
* modifiers and escape sequences) to the remote PTY. */
|
||||
function canRawMode(): boolean {
|
||||
const anyStdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (v: boolean) => void }
|
||||
return typeof anyStdin.setRawMode === 'function' && !!process.stdin.isTTY
|
||||
}
|
||||
|
||||
export async function shellCommand(args: ParsedArgs): Promise<number> {
|
||||
const sessionNameArg = typeof args.flags.session === 'string' ? args.flags.session : undefined
|
||||
const raw = !!args.flags.raw
|
||||
const execOverride = typeof args.flags.exec === 'string' ? args.flags.exec : null
|
||||
// Default: exec the full hermes CLI after the tmux shell settles.
|
||||
// --raw disables this (plain bash/tmux). --exec overrides the command.
|
||||
const postAttachExec: string | null = raw ? null : (execOverride ?? 'hermes')
|
||||
|
||||
if (!canRawMode()) {
|
||||
process.stderr.write(
|
||||
'error: `shell` requires an interactive TTY on stdin. Pipe-mode is not supported ' +
|
||||
'(use a proper terminal: Windows Terminal, iTerm, etc.).\n'
|
||||
)
|
||||
return 1
|
||||
}
|
||||
|
||||
process.stderr.write(`Connecting...\n`)
|
||||
|
||||
let authed: AuthedRelay
|
||||
try {
|
||||
authed = await connectAndAuth(args)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
const { relay, url, endpointRole: resolvedRole } = authed
|
||||
|
||||
// Prefer this-invocation's resolved role over the stored one (the QR might
|
||||
// have been probed onto a different endpoint than the last session).
|
||||
const storedForBanner = await getSession(url)
|
||||
const bannerRole = resolvedRole ?? storedForBanner?.endpointRole ?? null
|
||||
process.stderr.write(
|
||||
buildConnectBanner({
|
||||
url,
|
||||
serverVersion: relay.serverVersion,
|
||||
meta: relay.authMeta,
|
||||
endpointRole: bannerRole
|
||||
}) + '\n'
|
||||
)
|
||||
|
||||
// Wire desktop tool handlers BEFORE the terminal attach hands the TTY
|
||||
// over to raw-mode PTY forwarding. The consent prompt (if shown) uses
|
||||
// readline on stdin/stderr and needs a cooked TTY.
|
||||
let toolRouter: DesktopToolRouter | null = null
|
||||
const toolsDisabled = !!args.flags['no-tools']
|
||||
if (!toolsDisabled) {
|
||||
const consent = await ensureToolsConsent(url)
|
||||
if (consent.consented) {
|
||||
toolRouter = new DesktopToolRouter({
|
||||
consentGranted: true,
|
||||
handlers: {
|
||||
desktop_read_file: readFileHandler,
|
||||
desktop_write_file: writeFileHandler,
|
||||
desktop_patch: patchHandler,
|
||||
desktop_terminal: terminalHandler,
|
||||
desktop_search_files: searchFilesHandler
|
||||
}
|
||||
})
|
||||
toolRouter.attach(relay)
|
||||
process.stderr.write(
|
||||
'Desktop tools: 5 handlers advertised (read_file, write_file, terminal, search_files, patch)\n'
|
||||
)
|
||||
} else if (consent.reason) {
|
||||
process.stderr.write(`Desktop tools: disabled (${consent.reason})\n`)
|
||||
}
|
||||
}
|
||||
|
||||
const cols = process.stdout.columns ?? 120
|
||||
const rows = process.stdout.rows ?? 30
|
||||
|
||||
// Kick off the attach handshake. Listener is installed INSIDE waitForAttached
|
||||
// so we can't miss the ack — `terminal.attached` can arrive before the
|
||||
// promise resolves this microtask.
|
||||
const attachPromise = waitForAttached(relay)
|
||||
|
||||
const attachPayload: Record<string, unknown> = { cols, rows }
|
||||
if (sessionNameArg) {
|
||||
attachPayload.session_name = sessionNameArg
|
||||
}
|
||||
relay.sendChannel('terminal', 'terminal.attach', attachPayload)
|
||||
|
||||
let attached: AttachedInfo
|
||||
try {
|
||||
attached = await attachPromise
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
try {
|
||||
toolRouter?.detach()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
relay.kill()
|
||||
return 1
|
||||
}
|
||||
|
||||
// This session_name is the authoritative key for every subsequent outgoing
|
||||
// envelope. The server accepts omitted session_name (falls back to most-
|
||||
// recent), but echoing it makes the wire self-describing and survives a
|
||||
// future multi-session client.
|
||||
const sessionName = attached.sessionName
|
||||
|
||||
const reattachMsg = attached.reattach ? ' — re-attached to existing session' : ''
|
||||
process.stderr.write(
|
||||
`Attached${attached.tmuxAvailable ? ` (tmux session "${sessionName}")` : ''}${reattachMsg}.\n` +
|
||||
`Escape: Ctrl+A then . (detach, preserves tmux) · Ctrl+A then k (kill tmux) · Ctrl+A Ctrl+A (literal Ctrl+A)\n\n`
|
||||
)
|
||||
|
||||
// Swap handler from attach-waiter to steady-state output pump. Re-registering
|
||||
// replaces the previous listener, so `terminal.output` frames now flow to
|
||||
// stdout instead of the attach-waiter's resolve path (which already settled).
|
||||
let bytesReceived = 0
|
||||
let exiting = false
|
||||
relay.onChannel('terminal', (type, payload) => {
|
||||
if (type === 'terminal.output') {
|
||||
const data = typeof payload.data === 'string' ? payload.data : null
|
||||
if (data) {
|
||||
process.stdout.write(data)
|
||||
bytesReceived += data.length
|
||||
}
|
||||
return
|
||||
}
|
||||
if (type === 'terminal.error') {
|
||||
const msg = typeof payload.message === 'string' ? payload.message : 'terminal error'
|
||||
process.stderr.write(`\n\x1b[31m[terminal] error: ${msg}\x1b[0m\n`)
|
||||
return
|
||||
}
|
||||
if (type === 'terminal.detached') {
|
||||
// Server-side PTY ended (or was explicitly detached). Exit with code
|
||||
// depending on reason: user-initiated "client detach" / "client kill"
|
||||
// are clean exits; anything else is treated as abnormal teardown.
|
||||
const reason = typeof payload.reason === 'string' ? payload.reason : 'detached'
|
||||
const clean = reason === 'client detach' || reason === 'client kill' || reason === 'eof'
|
||||
if (!exiting) {
|
||||
exiting = true
|
||||
process.stderr.write(`\n\x1b[90m[shell] ${reason}\x1b[0m\n`)
|
||||
cleanup()
|
||||
process.exit(clean ? 0 : 1)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Unknown types — ignore, don't pollute the terminal.
|
||||
})
|
||||
|
||||
// Raw mode: every keystroke goes to the PTY as a byte. No local line
|
||||
// buffering, no local Ctrl+C interception (the PTY sees 0x03 and interrupts
|
||||
// whatever's running remotely, which is what the user expects).
|
||||
process.stdin.setRawMode(true)
|
||||
process.stdin.resume()
|
||||
|
||||
let escapePending = false
|
||||
|
||||
const sendInput = (data: string) => {
|
||||
relay.sendChannel('terminal', 'terminal.input', { session_name: sessionName, data })
|
||||
}
|
||||
|
||||
const forwardInput = (chunk: Buffer) => {
|
||||
if (exiting) {
|
||||
return
|
||||
}
|
||||
// Escape filter — strip Ctrl+A verbs from the client-side byte stream.
|
||||
// All other bytes (including a literal Ctrl+A after Ctrl+A Ctrl+A) pass
|
||||
// through. We accumulate into a byte array and emit once per chunk so
|
||||
// sendInput fires with a coherent payload instead of one envelope per byte.
|
||||
const out: number[] = []
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
const b = chunk[i]!
|
||||
if (escapePending) {
|
||||
escapePending = false
|
||||
if (b === 0x2e /* '.' */) {
|
||||
// Ctrl+A . → clean detach (tmux preserved on server)
|
||||
exiting = true
|
||||
relay.sendChannel('terminal', 'terminal.detach', { session_name: sessionName })
|
||||
process.stderr.write('\n\x1b[90m[shell] detached (tmux preserved)\x1b[0m\n')
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
}
|
||||
if (b === 0x6b /* 'k' */) {
|
||||
// Ctrl+A k → destructive kill (tmux session destroyed)
|
||||
exiting = true
|
||||
relay.sendChannel('terminal', 'terminal.kill', { session_name: sessionName })
|
||||
process.stderr.write('\n\x1b[90m[shell] killed tmux session "' + sessionName + '"\x1b[0m\n')
|
||||
cleanup()
|
||||
process.exit(0)
|
||||
}
|
||||
if (b === CTRL_A) {
|
||||
// Ctrl+A Ctrl+A → forward a literal Ctrl+A
|
||||
out.push(CTRL_A)
|
||||
continue
|
||||
}
|
||||
process.stderr.write(
|
||||
`\n\x1b[90m[shell] escape: . detach · k kill · Ctrl+A literal\x1b[0m\n`
|
||||
)
|
||||
continue
|
||||
}
|
||||
if (b === CTRL_A) {
|
||||
escapePending = true
|
||||
continue
|
||||
}
|
||||
out.push(b)
|
||||
}
|
||||
if (out.length > 0) {
|
||||
sendInput(Buffer.from(out).toString('utf8'))
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', forwardInput)
|
||||
|
||||
// Forward SIGWINCH as a `terminal.resize` envelope. The relay TIOCSWINSZs
|
||||
// the master fd, so `hermes`'s Ink layer re-flows immediately.
|
||||
let lastCols = cols
|
||||
let lastRows = rows
|
||||
const onResize = () => {
|
||||
const c = process.stdout.columns ?? lastCols
|
||||
const r = process.stdout.rows ?? lastRows
|
||||
if (c === lastCols && r === lastRows) {
|
||||
return
|
||||
}
|
||||
lastCols = c
|
||||
lastRows = r
|
||||
relay.sendChannel('terminal', 'terminal.resize', { session_name: sessionName, cols: c, rows: r })
|
||||
}
|
||||
process.stdout.on('resize', onResize)
|
||||
|
||||
const cleanup = () => {
|
||||
try {
|
||||
process.stdin.off('data', forwardInput)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
process.stdout.off('resize', onResize)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
process.stdin.setRawMode(false)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
process.stdin.pause()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
relay.onChannel('terminal', null)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
toolRouter?.detach()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
setupGracefulExit({ cleanups: [cleanup] })
|
||||
|
||||
relay.on('exit', () => {
|
||||
if (exiting) {
|
||||
return
|
||||
}
|
||||
exiting = true
|
||||
process.stderr.write(
|
||||
`\n\x1b[90m[shell] transport closed (received ${bytesReceived} bytes)\x1b[0m\n`
|
||||
)
|
||||
cleanup()
|
||||
process.exit(1)
|
||||
})
|
||||
|
||||
// Auto-exec the target command (default: `hermes`) once tmux has settled.
|
||||
// On a fresh tmux attach the login shell takes ~250-350ms to paint its first
|
||||
// prompt; injecting `exec` too early means bash swallows the first keystroke
|
||||
// and the command never runs. Skipped entirely when --raw is set.
|
||||
if (postAttachExec) {
|
||||
setTimeout(() => {
|
||||
if (exiting) {
|
||||
return
|
||||
}
|
||||
// `clear` wipes the shell's welcome/prompt first so the hermes banner
|
||||
// starts from a clean viewport. `exec` replaces bash in place so Ctrl+C
|
||||
// and EOF signal hermes directly — no outer shell to catch them and
|
||||
// drop the user back to bash after hermes exits.
|
||||
sendInput(`clear; exec ${postAttachExec}\n`)
|
||||
}, EXEC_SETTLE_MS)
|
||||
}
|
||||
|
||||
// Park — everything flows through listeners now. Resolve only when
|
||||
// cleanup() → process.exit() fires.
|
||||
return new Promise<number>(() => {
|
||||
/* never resolves directly; process.exit paths above */
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
// status — list stored relay sessions from ~/.hermes/remote-sessions.json.
|
||||
// No network — purely a read of the local file. Mirrors `hermes-pair list`
|
||||
// on the server so a user can see which relays this machine is paired with,
|
||||
// plus the grants (which channels this token can access) and TTL (when the
|
||||
// token expires) captured from the auth.ok handshake.
|
||||
|
||||
import { humanExpiry, parseRole, roleLabel } from '../banner.js'
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { listSessions, type RemoteSessionRecord } from '../remoteSessions.js'
|
||||
|
||||
function humanAge(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
return `${Math.floor(seconds / 60)}m`
|
||||
}
|
||||
if (seconds < 86_400) {
|
||||
return `${Math.floor(seconds / 3600)}h`
|
||||
}
|
||||
return `${Math.floor(seconds / 86_400)}d`
|
||||
}
|
||||
|
||||
function redactToken(token: string): string {
|
||||
return token.length >= 12 ? `${token.slice(0, 8)}…${token.slice(-4)}` : '(redacted)'
|
||||
}
|
||||
|
||||
export async function statusCommand(args: ParsedArgs): Promise<number> {
|
||||
const sessions = await listSessions()
|
||||
const entries = Object.entries(sessions)
|
||||
const revealTokens = !!args.flags['reveal-tokens']
|
||||
|
||||
if (args.flags.json) {
|
||||
// Bearer tokens are secrets — redact by default so `status --json > file`
|
||||
// or `| tee` doesn't splash them into shell history, CI logs, or pastes.
|
||||
// Callers who actually need the token (scripting re-auth) must opt in.
|
||||
const out: Record<string, RemoteSessionRecord> = revealTokens
|
||||
? sessions
|
||||
: Object.fromEntries(
|
||||
Object.entries(sessions).map(([url, rec]) => [
|
||||
url,
|
||||
{ ...rec, token: redactToken(rec.token) }
|
||||
])
|
||||
)
|
||||
process.stdout.write(JSON.stringify(out, null, 2) + '\n')
|
||||
return 0
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
process.stdout.write(
|
||||
'No paired relays. Run `hermes-relay pair --remote ws://host:port` to pair.\n'
|
||||
)
|
||||
return 0
|
||||
}
|
||||
|
||||
process.stdout.write(`Paired relays (${entries.length}):\n\n`)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
for (const [url, rec] of entries) {
|
||||
const age = humanAge(Math.max(0, now - rec.pairedAt))
|
||||
const tokPreview =
|
||||
rec.token.length >= 12 ? `${rec.token.slice(0, 8)}…${rec.token.slice(-4)}` : '(short)'
|
||||
process.stdout.write(` ${url}\n`)
|
||||
process.stdout.write(` server: ${rec.serverVersion ?? '(unknown)'}\n`)
|
||||
process.stdout.write(` paired: ${age} ago\n`)
|
||||
process.stdout.write(` token: ${tokPreview}\n`)
|
||||
process.stdout.write(` expires: ${humanExpiry(rec.ttlExpiresAt)}\n`)
|
||||
const role = parseRole(rec.endpointRole)
|
||||
if (role) {
|
||||
process.stdout.write(` route: ${roleLabel(role)}\n`)
|
||||
}
|
||||
if (rec.grants && Object.keys(rec.grants).length > 0) {
|
||||
const formatted = Object.entries(rec.grants)
|
||||
.map(([channel, expiry]) => {
|
||||
const when = expiry === null ? 'never' : humanExpiry(expiry)
|
||||
return `${channel} (${when})`
|
||||
})
|
||||
.sort()
|
||||
process.stdout.write(` grants: ${formatted.join(', ')}\n`)
|
||||
}
|
||||
if (rec.certPinSha256) {
|
||||
process.stdout.write(` cert: sha256:${rec.certPinSha256.slice(0, 12)}…\n`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
// tools — ask the server what tool access the agent will have on this
|
||||
// connection. Calls the `tools.list` JSON-RPC (surfaced from hermes-agent
|
||||
// `run_agent.py::tools.list`, see note in CLAUDE.md) and prints a summary
|
||||
// so the user can see which toolsets are enabled before spending a prompt.
|
||||
|
||||
import type { ParsedArgs } from '../cli.js'
|
||||
import { resolveCredentials } from '../credentials.js'
|
||||
import { GatewayClient } from '../gatewayClient.js'
|
||||
import type { GatewayEvent, ToolsListResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
|
||||
import { deleteSession, saveSession } from '../remoteSessions.js'
|
||||
import { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
const READY_TIMEOUT_MS = 60_000
|
||||
|
||||
function resolveRemote(args: ParsedArgs): string | null {
|
||||
const v = args.flags.remote
|
||||
return (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
|
||||
}
|
||||
|
||||
function waitForReady(gw: GatewayClient): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
gw.off('event', handler)
|
||||
reject(new Error(`gateway.ready timeout after ${READY_TIMEOUT_MS}ms`))
|
||||
}, READY_TIMEOUT_MS)
|
||||
|
||||
const handler = (ev: GatewayEvent) => {
|
||||
if (ev.type === 'gateway.ready') {
|
||||
clearTimeout(timer)
|
||||
gw.off('event', handler)
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
gw.on('event', handler)
|
||||
})
|
||||
}
|
||||
|
||||
export async function toolsCommand(args: ParsedArgs): Promise<number> {
|
||||
let urlFlag = resolveRemote(args)
|
||||
const argCode = typeof args.flags.code === 'string' ? args.flags.code : undefined
|
||||
const argToken = typeof args.flags.token === 'string' ? args.flags.token : undefined
|
||||
const argPairQr =
|
||||
typeof args.flags['pair-qr'] === 'string'
|
||||
? args.flags['pair-qr']
|
||||
: process.env.HERMES_RELAY_PAIR_QR
|
||||
const nonInteractive = !!args.flags['non-interactive']
|
||||
|
||||
// First-run fallback: no --remote, no env var, no QR payload. Pick
|
||||
// from stored sessions or prompt for a URL. Throws on non-interactive
|
||||
// + ambiguous.
|
||||
if (!urlFlag && !argPairQr) {
|
||||
try {
|
||||
urlFlag = await resolveFirstRunUrl({ nonInteractive })
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// Placeholder — resolveCredentials needs SOMETHING non-empty so its
|
||||
// `getSession(url)` branch has a key to look up (harmless when we end
|
||||
// up routing through --pair-qr since we override the url below).
|
||||
const probeUrl = urlFlag ?? 'ws://pair-qr-pending'
|
||||
|
||||
let creds
|
||||
try {
|
||||
creds = await resolveCredentials(probeUrl, { argCode, argToken, argPairQr, nonInteractive })
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
return 1
|
||||
}
|
||||
|
||||
// Multi-endpoint path won — use the winning endpoint's relay.url. Otherwise
|
||||
// fall back to the caller-supplied --remote.
|
||||
const url = (creds.resolvedEndpoint?.relay.url ?? urlFlag)!.trim()
|
||||
const endpointRole = creds.resolvedEndpoint?.role ?? null
|
||||
|
||||
const relayCfg: ConstructorParameters<typeof RelayTransport>[0] = {
|
||||
url,
|
||||
deviceName: `hermes-relay-cli (${process.platform})`
|
||||
}
|
||||
if (creds.pairingCode) {
|
||||
relayCfg.pairingCode = creds.pairingCode
|
||||
}
|
||||
if (creds.sessionToken) {
|
||||
relayCfg.sessionToken = creds.sessionToken
|
||||
}
|
||||
|
||||
const relay = new RelayTransport(relayCfg)
|
||||
relay.onAuthSuccess((token, ver, meta) => {
|
||||
void saveSession(url, token, ver, {
|
||||
grants: meta.grants,
|
||||
ttlExpiresAt: meta.ttlExpiresAt,
|
||||
endpointRole
|
||||
})
|
||||
})
|
||||
|
||||
relay.start()
|
||||
const outcome = await relay.whenAuthResolved()
|
||||
|
||||
if (!outcome.ok) {
|
||||
if (creds.sessionToken) {
|
||||
await deleteSession(url)
|
||||
}
|
||||
process.stderr.write(`error: ${outcome.reason}\n`)
|
||||
try {
|
||||
relay.kill()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
const gw = new GatewayClient(relay)
|
||||
gw.start()
|
||||
gw.drain()
|
||||
|
||||
try {
|
||||
await waitForReady(gw)
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
gw.kill()
|
||||
return 1
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = await gw.request<ToolsListResponse>('tools.list', {})
|
||||
const result = asRpcResult<ToolsListResponse>(raw)
|
||||
const toolsets = result?.toolsets ?? []
|
||||
|
||||
if (args.flags.json) {
|
||||
process.stdout.write(JSON.stringify(toolsets, null, 2) + '\n')
|
||||
gw.kill()
|
||||
return 0
|
||||
}
|
||||
|
||||
if (toolsets.length === 0) {
|
||||
process.stdout.write('(server returned no toolsets)\n')
|
||||
gw.kill()
|
||||
return 0
|
||||
}
|
||||
|
||||
const enabled = toolsets.filter((t) => t.enabled).length
|
||||
process.stdout.write(
|
||||
`Server: ${url}\n` +
|
||||
`Version: ${relay.serverVersion ?? '?'}\n` +
|
||||
`Toolsets: ${toolsets.length} (${enabled} enabled)\n\n`
|
||||
)
|
||||
|
||||
for (const ts of toolsets) {
|
||||
const mark = ts.enabled ? '●' : '○'
|
||||
const count = typeof ts.tool_count === 'number' ? `${ts.tool_count} tools` : '?'
|
||||
process.stdout.write(` ${mark} ${ts.name} (${count})`)
|
||||
if (ts.description) {
|
||||
process.stdout.write(` — ${ts.description}`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
|
||||
if (args.flags.verbose && ts.tools && ts.tools.length > 0) {
|
||||
for (const t of ts.tools) {
|
||||
process.stdout.write(` • ${t.name}`)
|
||||
if (t.description) {
|
||||
process.stdout.write(` ${t.description}`)
|
||||
}
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write('\n ● = enabled for this session ○ = available but off\n')
|
||||
|
||||
gw.kill()
|
||||
return 0
|
||||
} catch (e) {
|
||||
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
|
||||
gw.kill()
|
||||
return 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Credential resolution with a strict precedence chain:
|
||||
// 1. --token / HERMES_RELAY_TOKEN (session token — skips pairing)
|
||||
// 2. --pair-qr / HERMES_RELAY_PAIR_QR (multi-endpoint QR payload; probe + pair)
|
||||
// 3. --code / HERMES_RELAY_CODE (pairing code — one-time)
|
||||
// 4. ~/.hermes/remote-sessions.json (previously-minted session token)
|
||||
// 5. interactive prompt (TTY only)
|
||||
//
|
||||
// The chain mirrors the TUI's resolveCredentials() in entry.tsx so users
|
||||
// paired via one surface can use the other without re-pairing. Kept separate
|
||||
// from commands/*.ts because pair.ts uses the prompt directly and should not
|
||||
// also fall through to stored-token reuse.
|
||||
//
|
||||
// The `--pair-qr` slot carries a full v3 QR payload (ADR 24). When present we
|
||||
// decode, run the priority-aware reachability probe against its `endpoints`
|
||||
// array, and hand back the winning candidate alongside the pairing code. The
|
||||
// caller is expected to inspect `resolvedEndpoint.relay.url` and override its
|
||||
// `--remote` argument — we don't reach into the caller's URL state from here.
|
||||
|
||||
import type { EndpointCandidate } from './endpoint.js'
|
||||
import { promptForPairingCode } from './pairing.js'
|
||||
import { decodePairingPayload, payloadToCandidates, probeCandidatesByPriority } from './pairingQr.js'
|
||||
import { getSession } from './remoteSessions.js'
|
||||
|
||||
export interface Credentials {
|
||||
sessionToken?: string
|
||||
pairingCode?: string
|
||||
/**
|
||||
* Populated when creds came from a multi-endpoint QR payload. Caller uses
|
||||
* `resolvedEndpoint.relay.url` as the WSS URL and `.role` for the banner.
|
||||
* Absent when credentials came from any other chain entry.
|
||||
*/
|
||||
resolvedEndpoint?: EndpointCandidate
|
||||
}
|
||||
|
||||
export interface ResolveOptions {
|
||||
/** --code from argv. */
|
||||
argCode?: string
|
||||
/** --token from argv. */
|
||||
argToken?: string
|
||||
/**
|
||||
* --pair-qr from argv. The raw pairing-QR payload (either compact JSON or
|
||||
* base64-wrapped compact JSON) — the same string the phone would scan.
|
||||
* When provided, credential resolution routes through the payload decoder
|
||||
* + reachability probe instead of the `--code` flow.
|
||||
*/
|
||||
argPairQr?: string
|
||||
/** Refuse to prompt even if stdin is a TTY — used by scripting callers. */
|
||||
nonInteractive?: boolean
|
||||
}
|
||||
|
||||
export async function resolveCredentials(
|
||||
url: string,
|
||||
opts: ResolveOptions = {}
|
||||
): Promise<Credentials> {
|
||||
const envToken = process.env.HERMES_RELAY_TOKEN?.trim()
|
||||
const token = opts.argToken?.trim() || envToken
|
||||
if (token) {
|
||||
return { sessionToken: token }
|
||||
}
|
||||
|
||||
const envPairQr = process.env.HERMES_RELAY_PAIR_QR?.trim()
|
||||
const pairQr = opts.argPairQr?.trim() || envPairQr
|
||||
if (pairQr) {
|
||||
const payload = decodePairingPayload(pairQr)
|
||||
const candidates = payloadToCandidates(payload)
|
||||
const winner = await probeCandidatesByPriority(candidates)
|
||||
return {
|
||||
pairingCode: payload.key.toUpperCase(),
|
||||
resolvedEndpoint: winner,
|
||||
}
|
||||
}
|
||||
|
||||
const envCode = process.env.HERMES_RELAY_CODE?.trim()
|
||||
const code = opts.argCode?.trim() || envCode
|
||||
if (code) {
|
||||
return { pairingCode: code.toUpperCase() }
|
||||
}
|
||||
|
||||
const stored = await getSession(url)
|
||||
if (stored) {
|
||||
return { sessionToken: stored.token }
|
||||
}
|
||||
|
||||
if (opts.nonInteractive) {
|
||||
throw new Error(
|
||||
'No credentials. Pass --code <CODE>, --token <TOKEN>, --pair-qr <PAYLOAD>, ' +
|
||||
'set HERMES_RELAY_CODE / HERMES_RELAY_TOKEN / HERMES_RELAY_PAIR_QR, or run ' +
|
||||
'`hermes-relay pair` first.'
|
||||
)
|
||||
}
|
||||
|
||||
const pairingCode = await promptForPairingCode(url)
|
||||
return { pairingCode }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
// Multi-endpoint pairing primitives — pure types + tiny helpers (ADR 24).
|
||||
//
|
||||
// Mirrors `app/src/main/kotlin/com/hermesandroid/relay/data/Endpoint.kt`.
|
||||
// The Kotlin data classes over there are the source of truth — any semantic
|
||||
// change must go through ADR 24 and land in both surfaces simultaneously.
|
||||
//
|
||||
// Intentionally pure: no I/O, no Node globals beyond TS. Probe semantics +
|
||||
// the parser live in `pairingQr.ts`; this file is safe to import from
|
||||
// anywhere, including tests that have no network access.
|
||||
|
||||
/**
|
||||
* Known endpoint roles. The wire format treats `role` as an open string —
|
||||
* operators can emit arbitrary labels — so anything not in this set
|
||||
* normalizes to `'custom'` for display. The **raw** string is preserved
|
||||
* verbatim in `EndpointCandidate.role` for HMAC canonicalization; this
|
||||
* narrowed type is display-only.
|
||||
*/
|
||||
export type EndpointRole = 'lan' | 'tailscale' | 'public' | 'custom'
|
||||
|
||||
/**
|
||||
* API-server half of an endpoint candidate. Points at the HTTP/SSE target
|
||||
* used for `/v1/runs`, `/v1/chat/completions`, `/api/sessions/*`, etc.
|
||||
*
|
||||
* `tls` defaults to `false` on the wire so a legacy v1/v2 payload without
|
||||
* the field synthesizes cleanly (see `payloadToCandidates`).
|
||||
*/
|
||||
export interface ApiEndpoint {
|
||||
host: string
|
||||
port: number
|
||||
tls: boolean
|
||||
}
|
||||
|
||||
/** Build the full API URL — mirrors Kotlin's `ApiEndpoint.url` getter. */
|
||||
export function apiUrl(e: ApiEndpoint): string {
|
||||
return `${e.tls ? 'https' : 'http'}://${e.host}:${e.port}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay-server half of an endpoint candidate. Carries only the URL and the
|
||||
* transport hint — the pairing `code`, `ttl_seconds`, and `grants` are
|
||||
* per-pair artifacts that stay on the top-level pairing payload.
|
||||
*/
|
||||
export interface RelayEndpoint {
|
||||
url: string
|
||||
/** `"wss"` | `"ws"` | undefined. UI hint only — `url` scheme is authoritative. */
|
||||
transportHint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in a v3 pairing payload's `endpoints` array. `priority` is
|
||||
* strict — `0 = highest`. Reachability is a tiebreaker **within** a
|
||||
* priority tier; it never promotes a lower tier over a higher one.
|
||||
*/
|
||||
export interface EndpointCandidate {
|
||||
/** Raw role string from the wire — preserved verbatim for HMAC canonicalization. */
|
||||
role: string
|
||||
priority: number
|
||||
api: ApiEndpoint
|
||||
relay: RelayEndpoint
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow a raw role string to one of the known UI roles. Case-insensitive.
|
||||
* Anything outside the built-in set (operator-defined roles like
|
||||
* `"wireguard"`, `"zerotier"`, `"netbird-eu"`) folds to `'custom'` so
|
||||
* callers can switch on a closed set.
|
||||
*
|
||||
* This is a display-time transform. The raw role on the candidate stays
|
||||
* in its emitted form — do NOT mutate `EndpointCandidate.role` based on
|
||||
* this narrowing or the HMAC canonicalization breaks.
|
||||
*/
|
||||
export function parseRawRole(raw: string): EndpointRole {
|
||||
switch (raw.toLowerCase()) {
|
||||
case 'lan':
|
||||
return 'lan'
|
||||
case 'tailscale':
|
||||
return 'tailscale'
|
||||
case 'public':
|
||||
return 'public'
|
||||
default:
|
||||
return 'custom'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `role` maps to a built-in styled role. Mirrors Kotlin's
|
||||
* `EndpointCandidate.isKnownRole()`.
|
||||
*/
|
||||
export function isKnownRole(role: string): boolean {
|
||||
return parseRawRole(role) !== 'custom'
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable label for the role. Mirrors Kotlin's `displayLabel()`.
|
||||
* Unknown roles render as `"Custom VPN (<raw>)"` so the operator sees
|
||||
* exactly what they labeled it.
|
||||
*/
|
||||
export function displayLabel(role: string): string {
|
||||
switch (parseRawRole(role)) {
|
||||
case 'lan':
|
||||
return 'LAN'
|
||||
case 'tailscale':
|
||||
return 'Tailscale'
|
||||
case 'public':
|
||||
return 'Public'
|
||||
default:
|
||||
return `Custom VPN (${role})`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard — is this value shaped like an `ApiEndpoint`? Used by the
|
||||
* pairing parser to filter malformed candidates without throwing.
|
||||
*/
|
||||
export function isApiEndpointShape(v: unknown): v is ApiEndpoint {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const o = v as Record<string, unknown>
|
||||
return (
|
||||
typeof o.host === 'string' &&
|
||||
typeof o.port === 'number' &&
|
||||
(typeof o.tls === 'boolean' || o.tls === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard — is this value shaped like a `RelayEndpoint`? `transport_hint`
|
||||
* is the wire name; `transportHint` is the JS-side name. The parser handles
|
||||
* the rename.
|
||||
*/
|
||||
export function isRelayEndpointShape(v: unknown): v is { url: string; transport_hint?: string } {
|
||||
if (typeof v !== 'object' || v === null) return false
|
||||
const o = v as Record<string, unknown>
|
||||
return (
|
||||
typeof o.url === 'string' &&
|
||||
(typeof o.transport_hint === 'string' || o.transport_hint === undefined)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
// Thin coordinator over `Transport` — mirror of the TUI's GatewayClient but
|
||||
// with the default LocalSubprocessTransport dependency dropped. Callers must
|
||||
// construct a transport explicitly (there's only one — RelayTransport — for
|
||||
// the CLI; a local `hermes chat` invocation is a separate Python binary).
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
import type { GatewayEvent } from './gatewayTypes.js'
|
||||
import type { Transport } from './transport/Transport.js'
|
||||
|
||||
export class GatewayClient extends EventEmitter {
|
||||
private transport: Transport
|
||||
|
||||
constructor(transport: Transport) {
|
||||
super()
|
||||
this.setMaxListeners(0)
|
||||
|
||||
this.transport = transport
|
||||
|
||||
// Re-emit events + exit from the underlying transport.
|
||||
this.transport.on('event', (ev: GatewayEvent) => this.emit('event', ev))
|
||||
this.transport.on('exit', (code: number | null) => this.emit('exit', code))
|
||||
}
|
||||
|
||||
start() {
|
||||
this.transport.start()
|
||||
}
|
||||
|
||||
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
return this.transport.request<T>(method, params)
|
||||
}
|
||||
|
||||
drain() {
|
||||
this.transport.drain()
|
||||
}
|
||||
|
||||
getLogTail(limit = 20): string {
|
||||
return this.transport.getLogTail(limit)
|
||||
}
|
||||
|
||||
kill() {
|
||||
this.transport.kill()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/gatewayTypes.ts (feat/tui-transport-pluggable)
|
||||
// on 2026-04-23. Kept verbatim so both clients speak identical JSON-RPC to the
|
||||
// `tui_gateway` subprocess. When the shape upstreams, collapse back into a
|
||||
// shared package and re-import.
|
||||
|
||||
import type { SessionInfo, SlashCategory, Usage } from './types.js'
|
||||
|
||||
export interface GatewaySkin {
|
||||
banner_hero?: string
|
||||
banner_logo?: string
|
||||
branding?: Record<string, string>
|
||||
colors?: Record<string, string>
|
||||
help_header?: string
|
||||
tool_prefix?: string
|
||||
}
|
||||
|
||||
export interface GatewayCompletionItem {
|
||||
display: string
|
||||
meta?: string
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface GatewayTranscriptMessage {
|
||||
context?: string
|
||||
name?: string
|
||||
role: 'assistant' | 'system' | 'tool' | 'user'
|
||||
text?: string
|
||||
}
|
||||
|
||||
// ── Commands / completion ────────────────────────────────────────────
|
||||
|
||||
export interface CommandsCatalogResponse {
|
||||
canon?: Record<string, string>
|
||||
categories?: SlashCategory[]
|
||||
pairs?: [string, string][]
|
||||
skill_count?: number
|
||||
sub?: Record<string, string[]>
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface CompletionResponse {
|
||||
items?: GatewayCompletionItem[]
|
||||
replace_from?: number
|
||||
}
|
||||
|
||||
export interface SlashExecResponse {
|
||||
output?: string
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export type CommandDispatchResponse =
|
||||
| { output?: string; type: 'exec' | 'plugin' }
|
||||
| { target: string; type: 'alias' }
|
||||
| { message?: string; name: string; type: 'skill' }
|
||||
| { message: string; type: 'send' }
|
||||
|
||||
// ── Session lifecycle ────────────────────────────────────────────────
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
info?: SessionInfo & { credential_warning?: string }
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionResumeResponse {
|
||||
info?: SessionInfo
|
||||
message_count?: number
|
||||
messages: GatewayTranscriptMessage[]
|
||||
resumed?: string
|
||||
session_id: string
|
||||
}
|
||||
|
||||
export interface SessionListItem {
|
||||
id: string
|
||||
message_count: number
|
||||
preview: string
|
||||
source?: string
|
||||
started_at: number
|
||||
title: string
|
||||
}
|
||||
|
||||
export interface SessionListResponse {
|
||||
sessions?: SessionListItem[]
|
||||
}
|
||||
|
||||
export interface SessionInterruptResponse {
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
// ── Prompt ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface PromptSubmitResponse {
|
||||
ok?: boolean
|
||||
}
|
||||
|
||||
// ── Tool listing (tools.list) ────────────────────────────────────────
|
||||
|
||||
export interface ToolsetTool {
|
||||
name: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ToolsetInfo {
|
||||
name: string
|
||||
description?: string
|
||||
tool_count?: number
|
||||
enabled?: boolean
|
||||
tools?: ToolsetTool[]
|
||||
}
|
||||
|
||||
export interface ToolsListResponse {
|
||||
toolsets?: ToolsetInfo[]
|
||||
}
|
||||
|
||||
// ── Setup status ─────────────────────────────────────────────────────
|
||||
|
||||
export interface SetupStatusResponse {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
// ── Subagent events ──────────────────────────────────────────────────
|
||||
|
||||
export interface SubagentEventPayload {
|
||||
duration_seconds?: number
|
||||
goal: string
|
||||
status?: 'completed' | 'failed' | 'interrupted' | 'running'
|
||||
summary?: string
|
||||
task_count?: number
|
||||
task_index: number
|
||||
text?: string
|
||||
tool_name?: string
|
||||
tool_preview?: string
|
||||
}
|
||||
|
||||
// ── Gateway event discriminated union ────────────────────────────────
|
||||
|
||||
export type GatewayEvent =
|
||||
| { payload?: { skin?: GatewaySkin }; session_id?: string; type: 'gateway.ready' }
|
||||
| { payload?: GatewaySkin; session_id?: string; type: 'skin.changed' }
|
||||
| { payload: SessionInfo; session_id?: string; type: 'session.info' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' }
|
||||
| { payload?: undefined; session_id?: string; type: 'message.start' }
|
||||
| { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' }
|
||||
| { payload: { line: string }; session_id?: string; type: 'gateway.stderr' }
|
||||
| { payload?: { cwd?: string; python?: string }; session_id?: string; type: 'gateway.start_timeout' }
|
||||
| { payload?: { preview?: string }; session_id?: string; type: 'gateway.protocol_error' }
|
||||
| { payload?: { text?: string }; session_id?: string; type: 'reasoning.delta' | 'reasoning.available' }
|
||||
| { payload: { name?: string; preview?: string }; session_id?: string; type: 'tool.progress' }
|
||||
| { payload: { name?: string }; session_id?: string; type: 'tool.generating' }
|
||||
| { payload: { context?: string; name?: string; tool_id: string }; session_id?: string; type: 'tool.start' }
|
||||
| {
|
||||
payload: { error?: string; inline_diff?: string; name?: string; summary?: string; tool_id: string }
|
||||
session_id?: string
|
||||
type: 'tool.complete'
|
||||
}
|
||||
| {
|
||||
payload: { choices: string[] | null; question: string; request_id: string }
|
||||
session_id?: string
|
||||
type: 'clarify.request'
|
||||
}
|
||||
| { payload: { command: string; description: string }; session_id?: string; type: 'approval.request' }
|
||||
| { payload: { request_id: string }; session_id?: string; type: 'sudo.request' }
|
||||
| { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' }
|
||||
| { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' }
|
||||
| { payload: { text: string }; session_id?: string; type: 'btw.complete' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.start' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.thinking' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.tool' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.progress' }
|
||||
| { payload: SubagentEventPayload; session_id?: string; type: 'subagent.complete' }
|
||||
| { payload: { rendered?: string; text?: string }; session_id?: string; type: 'message.delta' }
|
||||
| {
|
||||
payload?: { reasoning?: string; rendered?: string; text?: string; usage?: Usage }
|
||||
session_id?: string
|
||||
type: 'message.complete'
|
||||
}
|
||||
| { payload?: { message?: string }; session_id?: string; type: 'error' }
|
||||
@@ -0,0 +1,49 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/lib/circularBuffer.ts.
|
||||
export class CircularBuffer<T> {
|
||||
private buf: T[]
|
||||
private head = 0
|
||||
private len = 0
|
||||
|
||||
constructor(private capacity: number) {
|
||||
if (!Number.isInteger(capacity) || capacity <= 0) {
|
||||
throw new RangeError(`CircularBuffer capacity must be a positive integer, got ${capacity}`)
|
||||
}
|
||||
|
||||
this.buf = new Array<T>(capacity)
|
||||
}
|
||||
|
||||
push(item: T) {
|
||||
this.buf[this.head] = item
|
||||
this.head = (this.head + 1) % this.capacity
|
||||
|
||||
if (this.len < this.capacity) {
|
||||
this.len++
|
||||
}
|
||||
}
|
||||
|
||||
tail(n = this.len): T[] {
|
||||
const take = Math.min(Math.max(0, n), this.len)
|
||||
const start = this.len < this.capacity ? 0 : this.head
|
||||
const out: T[] = new Array<T>(take)
|
||||
|
||||
for (let i = 0; i < take; i++) {
|
||||
out[i] = this.buf[(start + this.len - take + i) % this.capacity]!
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
drain(): T[] {
|
||||
const out = this.tail()
|
||||
|
||||
this.clear()
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.buf = new Array<T>(this.capacity)
|
||||
this.head = 0
|
||||
this.len = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/lib/gracefulExit.ts.
|
||||
interface SetupOptions {
|
||||
cleanups?: (() => Promise<void> | void)[]
|
||||
failsafeMs?: number
|
||||
onError?: (scope: 'uncaughtException' | 'unhandledRejection', err: unknown) => void
|
||||
onSignal?: (signal: NodeJS.Signals) => void
|
||||
}
|
||||
|
||||
const SIGNAL_EXIT_CODE: Record<'SIGHUP' | 'SIGINT' | 'SIGTERM', number> = {
|
||||
SIGHUP: 129,
|
||||
SIGINT: 130,
|
||||
SIGTERM: 143
|
||||
}
|
||||
|
||||
let wired = false
|
||||
|
||||
export function setupGracefulExit({ cleanups = [], failsafeMs = 4000, onError, onSignal }: SetupOptions = {}) {
|
||||
if (wired) {
|
||||
return
|
||||
}
|
||||
|
||||
wired = true
|
||||
|
||||
let shuttingDown = false
|
||||
|
||||
const exit = (code: number, signal?: NodeJS.Signals) => {
|
||||
if (shuttingDown) {
|
||||
return
|
||||
}
|
||||
|
||||
shuttingDown = true
|
||||
|
||||
if (signal) {
|
||||
onSignal?.(signal)
|
||||
}
|
||||
|
||||
setTimeout(() => process.exit(code), failsafeMs).unref?.()
|
||||
|
||||
void Promise.allSettled(cleanups.map(fn => Promise.resolve().then(fn))).finally(() => process.exit(code))
|
||||
}
|
||||
|
||||
for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP'] as const) {
|
||||
process.on(sig, () => exit(SIGNAL_EXIT_CODE[sig], sig))
|
||||
}
|
||||
|
||||
process.on('uncaughtException', err => onError?.('uncaughtException', err))
|
||||
process.on('unhandledRejection', reason => onError?.('unhandledRejection', reason))
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Vendored subset of hermes-agent/ui-tui/src/lib/rpc.ts.
|
||||
// Only the shape-checking helpers the CLI needs (asRpcResult, rpcErrorMessage).
|
||||
// CommandDispatchResponse narrowing lives in the TUI and is not needed here.
|
||||
|
||||
// `any` (not `unknown`) inside the index type is deliberate — it lets the
|
||||
// generic be satisfied by interfaces with known keys (SessionCreateResponse
|
||||
// etc.) without forcing every caller to add an index signature. Matches the
|
||||
// TUI's helper shape (ui-tui/src/lib/rpc.ts).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type RpcResult = Record<string, any>
|
||||
|
||||
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
|
||||
!value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
|
||||
|
||||
export const rpcErrorMessage = (err: unknown): string =>
|
||||
err instanceof Error && err.message
|
||||
? err.message
|
||||
: typeof err === 'string' && err.trim()
|
||||
? err
|
||||
: 'request failed'
|
||||
@@ -0,0 +1,142 @@
|
||||
// Headless (readline) pairing-code prompt. Mirrors the TUI's Ink prompt in
|
||||
// validation + retry semantics, differs only in the rendering substrate —
|
||||
// the TUI uses Ink, we use `node:readline/promises`. Keep the regex + clean
|
||||
// rules identical across both so a user who pastes a code into either
|
||||
// surface sees the same feedback.
|
||||
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
import { decodePairingPayload, type PairingPayload } from './pairingQr.js'
|
||||
|
||||
const CODE_RE = /^[A-Z0-9]{6}$/
|
||||
|
||||
// Bracketed-paste mode control sequences. Windows Terminal / iTerm2 / etc.
|
||||
// wrap pasted content in `\x1b[200~...\x1b[201~` so apps can distinguish
|
||||
// typed vs. pasted input. `readline` in terminal mode captures those markers
|
||||
// into the input string, which mangles pasted pairing codes — the digits
|
||||
// `200` / `201` survive our alphanumeric filter and end up in the "cleaned"
|
||||
// code. We disable bracketed paste for the duration of the prompt and
|
||||
// re-enable on exit; belt-and-suspenders by also stripping escape
|
||||
// sequences inside cleanCode in case a terminal refuses to honor the flag.
|
||||
const DISABLE_BRACKETED_PASTE = '\x1b[?2004l'
|
||||
const ENABLE_BRACKETED_PASTE = '\x1b[?2004h'
|
||||
|
||||
/** Strip ANSI escape sequences (CSI/OSC) and stray control chars. Keeps
|
||||
* tab, LF, CR; drops everything else below 0x20 plus DEL (0x7f). */
|
||||
const stripAnsiAndControls = (s: string): string =>
|
||||
s
|
||||
// CSI: ESC [ ...params... finalByte (`~` included for bracketed paste)
|
||||
.replaceAll(/\x1b\[[?\d;]*[a-zA-Z~]/g, '')
|
||||
// OSC: ESC ] ...; ...BEL or ESC \
|
||||
.replaceAll(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
||||
// Bare ESC or leftover control chars (not TAB / LF / CR)
|
||||
.replaceAll(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
|
||||
/** Strip non-[A-Z0-9] and clamp to 6 chars. Defensively strips ANSI + control
|
||||
* bytes first so a bracketed-paste-wrapped input doesn't smuggle the `200`
|
||||
* from `\x1b[200~` into the final code. Idempotent. */
|
||||
export const cleanCode = (raw: string): string =>
|
||||
stripAnsiAndControls(raw).toUpperCase().replaceAll(/[^A-Z0-9]/g, '').slice(0, 6)
|
||||
|
||||
export const isValidCode = (raw: string): boolean => CODE_RE.test(raw)
|
||||
|
||||
export interface PromptOptions {
|
||||
retryReason?: string
|
||||
/** Max attempts before we give up. Default 3. */
|
||||
maxAttempts?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for a 6-char pairing code. Reads from stdin, writes to
|
||||
* stderr so that piping `hermes-relay chat "prompt" > out.txt` still shows
|
||||
* the prompt.
|
||||
*
|
||||
* Throws on EOF, no-TTY, or exhausted attempts. Callers decide whether to
|
||||
* exit(1) or fall through to a different credential source.
|
||||
*/
|
||||
export async function promptForPairingCode(
|
||||
relayUrl: string,
|
||||
opts: PromptOptions = {}
|
||||
): Promise<string> {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
'No TTY for interactive pairing. Pass --code <CODE>, set HERMES_RELAY_CODE, or ' +
|
||||
'run `hermes-relay pair <CODE>` first to store a session token.'
|
||||
)
|
||||
}
|
||||
|
||||
// Disable bracketed paste BEFORE readline opens so pasted content arrives
|
||||
// as plain keystrokes. Restore on exit regardless of which branch returns
|
||||
// (even if the user Ctrl+Cs out of the prompt).
|
||||
process.stderr.write(DISABLE_BRACKETED_PASTE)
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
})
|
||||
|
||||
try {
|
||||
if (opts.retryReason) {
|
||||
process.stderr.write(`\n${opts.retryReason}\n`)
|
||||
} else {
|
||||
process.stderr.write(`\nRelay: ${relayUrl}\n`)
|
||||
process.stderr.write(
|
||||
'Need a pairing code — run `/hermes-relay-pair` (or `hermes-pair`) on the relay host.\n'
|
||||
)
|
||||
process.stderr.write(
|
||||
'(Paste works; cleaned code shown before submit.)\n\n'
|
||||
)
|
||||
}
|
||||
|
||||
const maxAttempts = opts.maxAttempts ?? 3
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const raw = await rl.question('Pairing code (6 chars): ')
|
||||
const cleaned = cleanCode(raw)
|
||||
|
||||
if (isValidCode(cleaned)) {
|
||||
// Echo the cleaned code back so the user can sanity-check before
|
||||
// we commit to the handshake. Especially useful if the terminal
|
||||
// ignored our bracketed-paste disable and escape markers slipped in.
|
||||
process.stderr.write(` → using code: ${cleaned}\n`)
|
||||
return cleaned
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
` invalid — need 6 chars of A-Z or 0-9. ` +
|
||||
`Got "${raw.trim().slice(0, 40)}${raw.trim().length > 40 ? '…' : ''}" ` +
|
||||
`(${cleaned.length} valid chars after strip). Try typing it manually.\n`
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`no valid pairing code after ${maxAttempts} attempts`)
|
||||
} finally {
|
||||
rl.close()
|
||||
process.stderr.write(ENABLE_BRACKETED_PASTE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive wrapper around `decodePairingPayload` that returns a discriminated
|
||||
* union instead of throwing. Use this from flag parsers / help surfaces
|
||||
* where a friendly "bad QR, paste again" message is more useful than a
|
||||
* stack trace.
|
||||
*
|
||||
* The success branch exposes the parsed payload for callers that want to
|
||||
* peek at `hermes` version or `endpoints` before committing to the full
|
||||
* probe-and-pair flow.
|
||||
*/
|
||||
export type ValidatePairingResult =
|
||||
| { ok: true; payload: PairingPayload }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
export function validatePairingPayloadString(raw: string): ValidatePairingResult {
|
||||
try {
|
||||
const payload = decodePairingPayload(raw)
|
||||
return { ok: true, payload }
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err)
|
||||
return { ok: false, reason }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
// Pairing-QR payload parser + priority-aware reachability resolver.
|
||||
//
|
||||
// Mirrors (in intent, not in code) the Kotlin side at:
|
||||
// app/src/main/kotlin/com/hermesandroid/relay/ui/components/QrPairingScanner.kt
|
||||
// app/src/main/kotlin/com/hermesandroid/relay/network/EndpointResolver.kt
|
||||
//
|
||||
// Wire schema is ADR 24 `hermes: 3`. Older `hermes: 1|2` payloads are still
|
||||
// accepted — `payloadToCandidates` synthesizes a single priority-0 candidate
|
||||
// from the top-level fields so callers can always iterate a non-empty array.
|
||||
//
|
||||
// HMAC signature verification is deliberately NOT implemented here — Android
|
||||
// doesn't verify either (the phone has no way to fetch the server's secret
|
||||
// in-band). The `sig` field is parsed + carried for future use.
|
||||
|
||||
import {
|
||||
type ApiEndpoint,
|
||||
type EndpointCandidate,
|
||||
type RelayEndpoint,
|
||||
apiUrl,
|
||||
isApiEndpointShape,
|
||||
isRelayEndpointShape,
|
||||
} from './endpoint.js'
|
||||
|
||||
/**
|
||||
* Per-candidate HEAD `/health` probe timeout. Matches Kotlin
|
||||
* `EndpointResolver.PROBE_TIMEOUT_MS`. 4s was chosen over ADR 24's original
|
||||
* 2s because LTE hand-off + slow hotel Wi-Fi routinely blew past 2s on the
|
||||
* first packet and spuriously flagged real endpoints unreachable.
|
||||
*/
|
||||
export const PROBE_TIMEOUT_MS = 4_000
|
||||
|
||||
/**
|
||||
* In-memory probe-cache TTL. Matches Kotlin `EndpointResolver.CACHE_TTL_MS`.
|
||||
* Widened from ADR 24's 30s because NetworkCallback invalidation isn't
|
||||
* available on Node — we can't reactively flush on network change, so
|
||||
* a shorter TTL would burn extra probes without giving us anything back.
|
||||
*/
|
||||
export const PROBE_CACHE_TTL_MS = 60_000
|
||||
|
||||
/**
|
||||
* Raw relay block as it appears on the wire. `ttl_seconds` / `grants` /
|
||||
* `transport_hint` / `code` are all optional so v1 QRs with only `url`
|
||||
* still decode.
|
||||
*
|
||||
* `code` is not currently used by the CLI (the top-level `key` field
|
||||
* carries the pairing code) but is preserved so we can pivot later.
|
||||
*/
|
||||
export interface PairingRelay {
|
||||
url: string
|
||||
code?: string
|
||||
ttl_seconds?: number
|
||||
grants?: Record<string, number>
|
||||
transport_hint?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed pairing payload. Mirrors Kotlin's `HermesPairingPayload`. Fields
|
||||
* are kept in wire-case (snake where the server emits snake) for the
|
||||
* `endpoints` array only — everything else is a trivial rename.
|
||||
*
|
||||
* The `sig` field is parsed + carried but NOT verified. See
|
||||
* `verifyPairingSignature` for the (TODO) verification stub.
|
||||
*/
|
||||
export interface PairingPayload {
|
||||
hermes: number
|
||||
host: string
|
||||
port: number
|
||||
key: string
|
||||
tls: boolean
|
||||
relay?: PairingRelay
|
||||
endpoints?: EndpointCandidate[]
|
||||
sig?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an `endpoints[i]` object from the wire. Returns null on
|
||||
* malformed input so the outer parser can silently skip bad records
|
||||
* instead of rejecting the whole payload.
|
||||
*
|
||||
* The input comes from `JSON.parse` so all fields are `unknown`. We
|
||||
* validate structure via the `isApiEndpointShape` / `isRelayEndpointShape`
|
||||
* guards and then coerce into our TS shape.
|
||||
*/
|
||||
function parseCandidate(v: unknown): EndpointCandidate | null {
|
||||
if (typeof v !== 'object' || v === null) return null
|
||||
const o = v as Record<string, unknown>
|
||||
if (typeof o.role !== 'string') return null
|
||||
const priority = typeof o.priority === 'number' ? o.priority : 0
|
||||
if (!isApiEndpointShape(o.api)) return null
|
||||
if (!isRelayEndpointShape(o.relay)) return null
|
||||
const api: ApiEndpoint = {
|
||||
host: o.api.host,
|
||||
port: o.api.port,
|
||||
tls: typeof o.api.tls === 'boolean' ? o.api.tls : false,
|
||||
}
|
||||
const relay: RelayEndpoint = {
|
||||
url: o.relay.url,
|
||||
...(o.relay.transport_hint !== undefined ? { transportHint: o.relay.transport_hint } : {}),
|
||||
}
|
||||
return { role: o.role, priority, api, relay }
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to parse a pairing-QR payload. Accepts either the raw compact-JSON
|
||||
* string emitted by `plugin/pair.py:build_payload` OR a base64-encoded
|
||||
* wrapper of the same (some terminals auto-wrap pasted content, and some
|
||||
* downstream tools emit base64 for transport).
|
||||
*
|
||||
* Throws with a friendly message on any failure — the CLI surfaces this
|
||||
* directly to the user.
|
||||
*/
|
||||
export function decodePairingPayload(raw: string): PairingPayload {
|
||||
const trimmed = raw.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error('empty pairing payload')
|
||||
}
|
||||
|
||||
// Try raw JSON first — this is the canonical wire form.
|
||||
let text = trimmed
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
// Fallback: maybe it's base64-wrapped. Only attempt if the content
|
||||
// looks base64-shaped; guards against hex / other encodings masquerading.
|
||||
if (!/^[A-Za-z0-9+/=_-]+$/.test(trimmed)) {
|
||||
throw new Error('pairing payload is neither valid JSON nor base64')
|
||||
}
|
||||
try {
|
||||
// Accept both standard and URL-safe base64 variants.
|
||||
const normalized = trimmed.replaceAll('-', '+').replaceAll('_', '/')
|
||||
text = Buffer.from(normalized, 'base64').toString('utf8')
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
throw new Error('pairing payload failed base64→JSON decode')
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof parsed !== 'object' || parsed === null) {
|
||||
throw new Error('pairing payload is not a JSON object')
|
||||
}
|
||||
const o = parsed as Record<string, unknown>
|
||||
|
||||
const hermes = typeof o.hermes === 'number' ? o.hermes : 1
|
||||
if (hermes < 1) {
|
||||
throw new Error(`unsupported pairing schema version: ${hermes}`)
|
||||
}
|
||||
if (typeof o.host !== 'string' || o.host.length === 0) {
|
||||
throw new Error('pairing payload missing `host`')
|
||||
}
|
||||
if (typeof o.key !== 'string') {
|
||||
throw new Error('pairing payload missing `key`')
|
||||
}
|
||||
|
||||
const payload: PairingPayload = {
|
||||
hermes,
|
||||
host: o.host,
|
||||
port: typeof o.port === 'number' ? o.port : 8642,
|
||||
key: o.key,
|
||||
tls: typeof o.tls === 'boolean' ? o.tls : false,
|
||||
}
|
||||
|
||||
if (typeof o.relay === 'object' && o.relay !== null) {
|
||||
const r = o.relay as Record<string, unknown>
|
||||
if (typeof r.url === 'string') {
|
||||
const relay: PairingRelay = { url: r.url }
|
||||
if (typeof r.code === 'string') relay.code = r.code
|
||||
if (typeof r.ttl_seconds === 'number') relay.ttl_seconds = r.ttl_seconds
|
||||
if (typeof r.transport_hint === 'string') relay.transport_hint = r.transport_hint
|
||||
if (typeof r.grants === 'object' && r.grants !== null) {
|
||||
const grants: Record<string, number> = {}
|
||||
for (const [k, v] of Object.entries(r.grants as Record<string, unknown>)) {
|
||||
if (typeof v === 'number') grants[k] = v
|
||||
}
|
||||
relay.grants = grants
|
||||
}
|
||||
payload.relay = relay
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(o.endpoints)) {
|
||||
const candidates: EndpointCandidate[] = []
|
||||
for (const entry of o.endpoints) {
|
||||
const c = parseCandidate(entry)
|
||||
if (c !== null) candidates.push(c)
|
||||
}
|
||||
if (candidates.length > 0) payload.endpoints = candidates
|
||||
}
|
||||
|
||||
if (typeof o.sig === 'string') {
|
||||
payload.sig = o.sig
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic — is this host a Tailscale endpoint? Mirrors Kotlin's
|
||||
* `synthesizeLegacyEndpoint` detector: `.ts.net` suffix or `100.`
|
||||
* IPv4 prefix (broader than CGNAT's 100.64.0.0/10 but keeps us
|
||||
* tolerant of operator labeling).
|
||||
*/
|
||||
function looksLikeTailscale(host: string): boolean {
|
||||
return host.toLowerCase().endsWith('.ts.net') || host.startsWith('100.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a parsed pairing payload into a non-empty ordered list of
|
||||
* candidates. For `hermes: 3` payloads the `endpoints` array rides
|
||||
* through verbatim; for `hermes: 1|2` a single priority-0 candidate is
|
||||
* synthesized from the top-level fields.
|
||||
*
|
||||
* Mirrors Kotlin's `parseHermesPairingQr` + `synthesizeLegacyEndpoint`
|
||||
* path — callers downstream can always assume at least one candidate.
|
||||
*/
|
||||
export function payloadToCandidates(p: PairingPayload): EndpointCandidate[] {
|
||||
if (p.endpoints && p.endpoints.length > 0) {
|
||||
return p.endpoints
|
||||
}
|
||||
const role = looksLikeTailscale(p.host) ? 'tailscale' : 'lan'
|
||||
const relay: RelayEndpoint = {
|
||||
url: p.relay?.url ?? '',
|
||||
...(p.relay?.transport_hint !== undefined ? { transportHint: p.relay.transport_hint } : {}),
|
||||
}
|
||||
return [
|
||||
{
|
||||
role,
|
||||
priority: 0,
|
||||
api: { host: p.host, port: p.port, tls: p.tls },
|
||||
relay,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable cache key for a candidate: `"<role>|<api.host>:<api.port>"`.
|
||||
* Mirrors Kotlin's `EndpointResolver.cacheKey`. Role preserves case (HMAC
|
||||
* canonicalization) but host is lowercased — two roles pointing at the
|
||||
* same host:port share reachability state.
|
||||
*/
|
||||
function cacheKey(c: EndpointCandidate): string {
|
||||
return `${c.role}|${c.api.host.toLowerCase()}:${c.api.port}`
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
expiresAt: number
|
||||
reachable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory probe cache. Shared across all resolver invocations in the
|
||||
* same Node process. Keyed by `cacheKey`, TTL'd to `PROBE_CACHE_TTL_MS`.
|
||||
*
|
||||
* Not exported — callers should go through `probeCandidatesByPriority`.
|
||||
* Tests that need to flush should spawn a fresh process.
|
||||
*/
|
||||
const probeCache = new Map<string, CacheEntry>()
|
||||
|
||||
/** Probe a single candidate against `{api.url}/health`. Any 2xx wins. */
|
||||
export interface ProbeResult {
|
||||
candidate: EndpointCandidate
|
||||
reachable: boolean
|
||||
elapsedMs: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire one HEAD-equivalent probe. We use GET (not HEAD) because not every
|
||||
* relay flavor answers HEAD — Tailscale Serve in particular has been
|
||||
* spotty. Any 2xx counts as reachable.
|
||||
*
|
||||
* Uses the global Node `fetch` (Node ≥21) with the passed AbortSignal so
|
||||
* callers can cancel losers from the priority-group race.
|
||||
*/
|
||||
export async function probeCandidate(
|
||||
c: EndpointCandidate,
|
||||
signal: AbortSignal,
|
||||
): Promise<ProbeResult> {
|
||||
const started = Date.now()
|
||||
const url = `${apiUrl(c.api)}/health`
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal,
|
||||
headers: { Accept: '*/*' },
|
||||
})
|
||||
return {
|
||||
candidate: c,
|
||||
reachable: resp.ok,
|
||||
elapsedMs: Date.now() - started,
|
||||
}
|
||||
} catch (err) {
|
||||
return {
|
||||
candidate: c,
|
||||
reachable: false,
|
||||
elapsedMs: Date.now() - started,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the highest-priority reachable candidate from `candidates`.
|
||||
*
|
||||
* Algorithm (matches ADR 24 / Kotlin `EndpointResolver.resolve`):
|
||||
* 1. Group by `priority` ascending (0 = highest).
|
||||
* 2. Within each tier, race all candidates in parallel. First probe to
|
||||
* come back reachable wins; an `AbortController` cancels the losers.
|
||||
* 3. If the entire tier is unreachable, fall through to the next tier.
|
||||
* 4. Throws if nothing across any tier is reachable — callers decide
|
||||
* whether to fall back to a stored URL or surface the error.
|
||||
*
|
||||
* A 60-second in-memory cache short-circuits re-probes within the TTL.
|
||||
* The cache is process-local; the CLI is typically one-shot so this
|
||||
* mostly helps the REPL case where the user cycles through sessions.
|
||||
*/
|
||||
export async function probeCandidatesByPriority(
|
||||
candidates: EndpointCandidate[],
|
||||
): Promise<EndpointCandidate> {
|
||||
if (candidates.length === 0) {
|
||||
throw new Error('no endpoint candidates to probe')
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
// Bucket by priority ascending. Sort after the groupBy so cache-hit
|
||||
// fast-path and the live race both see tiers in the same order.
|
||||
const groups = new Map<number, EndpointCandidate[]>()
|
||||
for (const c of candidates) {
|
||||
const bucket = groups.get(c.priority) ?? []
|
||||
bucket.push(c)
|
||||
groups.set(c.priority, bucket)
|
||||
}
|
||||
const priorities = [...groups.keys()].sort((a, b) => a - b)
|
||||
|
||||
for (const priority of priorities) {
|
||||
const group = groups.get(priority) ?? []
|
||||
|
||||
// Fast path: any cached-reachable candidate wins without touching the
|
||||
// network. Matches Kotlin's pre-race cache scan.
|
||||
for (const c of group) {
|
||||
const cached = probeCache.get(cacheKey(c))
|
||||
if (cached && cached.expiresAt > now && cached.reachable) {
|
||||
return c
|
||||
}
|
||||
}
|
||||
|
||||
// Live race. Each probe gets its own AbortSignal linked to a shared
|
||||
// controller so a winner cancels outstanding losers. Per-probe
|
||||
// timeout via AbortSignal.timeout — composed with the group controller
|
||||
// so either can trigger abort.
|
||||
const groupController = new AbortController()
|
||||
const probes = group.map(async (c) => {
|
||||
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS)
|
||||
// AbortSignal.any is available on Node ≥20 for combining signals.
|
||||
const signal = AbortSignal.any([groupController.signal, timeout])
|
||||
const result = await probeCandidate(c, signal)
|
||||
probeCache.set(cacheKey(c), {
|
||||
expiresAt: Date.now() + PROBE_CACHE_TTL_MS,
|
||||
reachable: result.reachable,
|
||||
})
|
||||
if (!result.reachable) {
|
||||
throw new Error(result.error ?? `unreachable: ${c.role}`)
|
||||
}
|
||||
return c
|
||||
})
|
||||
|
||||
try {
|
||||
// Promise.any → first fulfilled wins. Losers continue in the
|
||||
// background until their probe resolves, but their fetches are
|
||||
// aborted via the controller below.
|
||||
const winner = await Promise.any(probes)
|
||||
groupController.abort()
|
||||
return winner
|
||||
} catch {
|
||||
// All probes in this tier rejected (unreachable). Fall through to
|
||||
// the next priority tier. Make sure we abort any in-flight probes
|
||||
// so the Node event loop doesn't hold the process open.
|
||||
groupController.abort()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`no reachable endpoint across ${candidates.length} candidate(s) — ` +
|
||||
'check relay is running and host is routable from this machine',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* HMAC-SHA256 verification against the server's pairing secret.
|
||||
*
|
||||
* TODO(hmac): Android currently does not verify either — the phone has
|
||||
* no in-band channel to fetch the server-side secret at
|
||||
* `~/.hermes/hermes-relay-qr-secret`. When that changes (e.g. a
|
||||
* provisioning endpoint ships), this stub becomes the verification
|
||||
* implementation: reconstruct the canonical form via
|
||||
* `JSON.stringify(payload_without_sig)` with sorted keys + compact
|
||||
* separators, HMAC-SHA256 against the fetched secret, compare to
|
||||
* `payload.sig` via constant-time equality.
|
||||
*
|
||||
* Until then this is an intentional no-op that returns `true` so the
|
||||
* CLI matches the phone's trust model (parse + carry, don't verify).
|
||||
*/
|
||||
export function verifyPairingSignature(_payload: PairingPayload): boolean {
|
||||
// TODO(hmac): secret not available client-side yet — mirror Android's
|
||||
// parse-but-don't-verify posture. See ADR 24, section on signature
|
||||
// handling.
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// First-run relay-URL prompt. Used by `pair` (when --remote is absent and the
|
||||
// user hasn't stored any sessions yet) and by the bare-invocation fallback in
|
||||
// chat/shell/tools (when a new user types `hermes-relay` on a fresh machine).
|
||||
//
|
||||
// Why a separate module from pairing.ts: the pairing-code prompt is called
|
||||
// AFTER we already know the URL (it needs the URL for the retry message and
|
||||
// for `getSession()` lookups). This prompt runs BEFORE the URL is known, so
|
||||
// sharing a readline instance across the two would mean holding stderr
|
||||
// bracketed-paste state open across an authentication boundary — clunky and
|
||||
// prone to leaking escape sequences on error paths.
|
||||
//
|
||||
// Validation: ws:// or wss:// scheme followed by at least one non-whitespace
|
||||
// char. We intentionally don't do stricter host/port parsing here — the
|
||||
// transport layer's TLS probe will catch malformed URLs with a clearer error
|
||||
// ("TOFU probe failed: ENOTFOUND host") than a regex rejection would.
|
||||
//
|
||||
// Output discipline: every prompt line goes to stderr, not stdout. Keeps
|
||||
// `hermes-relay "hi" > out.txt` clean — only the assistant's reply lands in
|
||||
// out.txt, prompts + spinner stream past to the user's terminal.
|
||||
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
import { listSessions } from './remoteSessions.js'
|
||||
|
||||
const URL_RE = /^wss?:\/\/\S+$/
|
||||
|
||||
export const isValidRelayUrl = (raw: string): boolean => URL_RE.test(raw.trim())
|
||||
|
||||
export interface PromptRelayUrlOptions {
|
||||
/** Max attempts before we give up. Default 3. */
|
||||
maxAttempts?: number
|
||||
/** First-line banner. Default: "Relay URL (ws:// or wss://)". */
|
||||
banner?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt the user for a relay WSS URL. Reads stdin, writes to stderr.
|
||||
* Throws on no-TTY, EOF, or exhausted attempts — callers decide whether to
|
||||
* exit(1) or fall back.
|
||||
*
|
||||
* stdin.isTTY is the one check we can't skip: a piped invocation
|
||||
* (`echo url | hermes-relay pair`) sets isTTY=false, which must fail closed
|
||||
* here — we don't accept pairing URLs from pipes (too easy to smuggle in via
|
||||
* a malicious `curl | sh` one-liner). The non-interactive branch has a more
|
||||
* actionable error message pointing back at --remote.
|
||||
*/
|
||||
export async function promptForRelayUrl(
|
||||
opts: PromptRelayUrlOptions = {}
|
||||
): Promise<string> {
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
'No TTY for interactive URL entry. Pass --remote ws://host:port, ' +
|
||||
'set HERMES_RELAY_URL, or use --pair-qr with a full QR payload.'
|
||||
)
|
||||
}
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
})
|
||||
|
||||
try {
|
||||
const banner = opts.banner ?? 'Relay URL (ws:// or wss://)'
|
||||
process.stderr.write(`\n${banner}\n`)
|
||||
|
||||
const maxAttempts = opts.maxAttempts ?? 3
|
||||
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
const raw = await rl.question('URL: ')
|
||||
const trimmed = raw.trim()
|
||||
|
||||
if (!trimmed) {
|
||||
process.stderr.write(' (empty — try again)\n')
|
||||
continue
|
||||
}
|
||||
|
||||
if (isValidRelayUrl(trimmed)) {
|
||||
return trimmed
|
||||
}
|
||||
|
||||
process.stderr.write(
|
||||
` invalid — need ws://host:port or wss://host:port. ` +
|
||||
`Got "${trimmed.slice(0, 60)}${trimmed.length > 60 ? '…' : ''}".\n`
|
||||
)
|
||||
}
|
||||
|
||||
throw new Error(`no valid relay URL after ${maxAttempts} attempts`)
|
||||
} finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
|
||||
export interface ResolveFirstRunUrlOptions {
|
||||
/** Refuse to prompt even on TTY — used by `--non-interactive`. */
|
||||
nonInteractive?: boolean
|
||||
/** Banner shown before the URL prompt. Customize per-command. */
|
||||
banner?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* When neither --remote nor HERMES_RELAY_URL nor --pair-qr is set, figure out
|
||||
* what URL the user wants to talk to:
|
||||
*
|
||||
* 1. If `~/.hermes/remote-sessions.json` has exactly one entry → return it
|
||||
* and note the pick to stderr (zero-friction re-use).
|
||||
* 2. If it has multiple entries → show a numbered list, let the user pick
|
||||
* or type "n" to enter a new URL.
|
||||
* 3. If it has zero entries → print the first-run banner and prompt for
|
||||
* a URL directly.
|
||||
*
|
||||
* Non-interactive callers (daemon, CI scripts with --non-interactive) never
|
||||
* reach the prompts — they hit the case (1) fast-path if there's a single
|
||||
* stored session, otherwise throw. This keeps scripted invocations deterministic.
|
||||
*/
|
||||
export async function resolveFirstRunUrl(
|
||||
opts: ResolveFirstRunUrlOptions = {}
|
||||
): Promise<string> {
|
||||
const sessions = await listSessions()
|
||||
const urls = Object.keys(sessions)
|
||||
|
||||
// Case (1): exactly one stored session — auto-pick. Both interactive and
|
||||
// non-interactive callers benefit (it's the happy path for repeat users).
|
||||
if (urls.length === 1) {
|
||||
const url = urls[0]!
|
||||
process.stderr.write(`Using stored session for ${url}\n`)
|
||||
return url
|
||||
}
|
||||
|
||||
// From here down we need a TTY. Fail fast for scripted callers so they
|
||||
// don't hang on stdin.
|
||||
if (opts.nonInteractive) {
|
||||
if (urls.length === 0) {
|
||||
throw new Error(
|
||||
'no relay URL and no stored sessions. Pass --remote ws://host:port or ' +
|
||||
're-run interactively to be prompted.'
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`no relay URL and ${urls.length} stored sessions. Pass --remote to pick one, ` +
|
||||
'or re-run interactively.'
|
||||
)
|
||||
}
|
||||
|
||||
if (!process.stdin.isTTY) {
|
||||
throw new Error(
|
||||
'no relay URL and no TTY. Pass --remote ws://host:port, set HERMES_RELAY_URL, ' +
|
||||
'or use --pair-qr.'
|
||||
)
|
||||
}
|
||||
|
||||
// Case (3): zero stored sessions — first-run path. Show a welcoming banner
|
||||
// before the URL prompt so a brand-new user knows they're in the right place.
|
||||
if (urls.length === 0) {
|
||||
const banner =
|
||||
opts.banner ??
|
||||
"Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server."
|
||||
process.stderr.write(`\n${banner}\n`)
|
||||
return promptForRelayUrl()
|
||||
}
|
||||
|
||||
// Case (2): multiple stored sessions — numbered picker with "n" for new URL.
|
||||
process.stderr.write('\nStored sessions:\n')
|
||||
urls.forEach((u, i) => {
|
||||
process.stderr.write(` ${i + 1}. ${u}\n`)
|
||||
})
|
||||
process.stderr.write(` n. (new relay URL)\n`)
|
||||
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
})
|
||||
|
||||
try {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const raw = (await rl.question(`Pick 1-${urls.length} or n: `)).trim().toLowerCase()
|
||||
|
||||
if (raw === 'n' || raw === 'new') {
|
||||
rl.close()
|
||||
return promptForRelayUrl({ banner: 'New relay URL (ws:// or wss://)' })
|
||||
}
|
||||
|
||||
const idx = Number.parseInt(raw, 10)
|
||||
if (Number.isInteger(idx) && idx >= 1 && idx <= urls.length) {
|
||||
return urls[idx - 1]!
|
||||
}
|
||||
|
||||
process.stderr.write(` invalid — type 1-${urls.length} or n.\n`)
|
||||
}
|
||||
|
||||
throw new Error('no valid selection after 3 attempts')
|
||||
} finally {
|
||||
rl.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/remoteSessions.ts — identical format
|
||||
// so a user who paired once via the TUI can run the CLI (and vice-versa)
|
||||
// without re-pairing. Storage lives at `~/.hermes/remote-sessions.json`,
|
||||
// mode 0600, atomic tempfile→rename. Fails closed to null on any error.
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
export interface RemoteSessionRecord {
|
||||
token: string
|
||||
serverVersion: string | null
|
||||
pairedAt: number // epoch seconds
|
||||
certPinSha256: string | null
|
||||
/** Per-channel grant expiry (epoch seconds; `null` = never expires). Captured
|
||||
* from `auth.ok.grants`. Used by `hermes-relay status` to tell the user which
|
||||
* channels the token can access. Shape mirrors what the server persists on
|
||||
* `Session.grants` (plugin/relay/auth.py). */
|
||||
grants?: Record<string, number | null> | null
|
||||
/** Epoch seconds when the session token itself expires (not a specific grant);
|
||||
* `null` = never. Captured from `auth.ok.expires_at`. */
|
||||
ttlExpiresAt?: number | null
|
||||
/** Active endpoint role at pair time — one of "lan", "tailscale", "public",
|
||||
* "custom", or null if unknown. Drives the contextual connect banner and
|
||||
* the "Plain (on LAN)" style labels copied from the Android app. */
|
||||
endpointRole?: string | null
|
||||
/** Per-URL consent for exposing desktop tool handlers (file read/write,
|
||||
* shell exec, search) to the remote agent. Granted explicitly on first
|
||||
* chat/shell connect when tools would be wired. Missing → prompt; true
|
||||
* → tools attached silently; false → tools suppressed even if set via
|
||||
* `--no-tools` override later. See desktop/src/tools/router.ts. */
|
||||
toolsConsented?: boolean
|
||||
}
|
||||
|
||||
interface StoredRecord {
|
||||
token: string
|
||||
server_version?: string | null
|
||||
paired_at: number
|
||||
cert_pin_sha256?: string | null
|
||||
/** Serialized as a plain object. `null` values mean never-expires; missing
|
||||
* means the channel isn't granted. See RemoteSessionRecord.grants. */
|
||||
grants?: Record<string, number | null> | null
|
||||
ttl_expires_at?: number | null
|
||||
endpoint_role?: string | null
|
||||
tools_consented?: boolean
|
||||
}
|
||||
|
||||
interface StoredFile {
|
||||
version: number
|
||||
sessions: Record<string, StoredRecord>
|
||||
}
|
||||
|
||||
const STORE_VERSION = 1
|
||||
|
||||
const defaultPath = () => join(homedir(), '.hermes', 'remote-sessions.json')
|
||||
|
||||
/** Override for tests — restore with `setStorePath(null)`. */
|
||||
let pathOverride: string | null = null
|
||||
|
||||
export const setStorePath = (p: string | null) => {
|
||||
pathOverride = p
|
||||
}
|
||||
|
||||
const storePath = () => pathOverride ?? defaultPath()
|
||||
|
||||
const emptyFile = (): StoredFile => ({ version: STORE_VERSION, sessions: {} })
|
||||
|
||||
const toRecord = (raw: StoredRecord): RemoteSessionRecord => ({
|
||||
token: raw.token,
|
||||
serverVersion: raw.server_version ?? null,
|
||||
pairedAt: raw.paired_at,
|
||||
certPinSha256: raw.cert_pin_sha256 ?? null,
|
||||
grants: raw.grants ?? null,
|
||||
ttlExpiresAt: raw.ttl_expires_at ?? null,
|
||||
endpointRole: raw.endpoint_role ?? null,
|
||||
toolsConsented: raw.tools_consented ?? false
|
||||
})
|
||||
|
||||
const fromRecord = (r: RemoteSessionRecord): StoredRecord => ({
|
||||
token: r.token,
|
||||
server_version: r.serverVersion,
|
||||
paired_at: r.pairedAt,
|
||||
cert_pin_sha256: r.certPinSha256,
|
||||
grants: r.grants ?? null,
|
||||
ttl_expires_at: r.ttlExpiresAt ?? null,
|
||||
endpoint_role: r.endpointRole ?? null,
|
||||
tools_consented: r.toolsConsented ?? false
|
||||
})
|
||||
|
||||
const readFile = async (): Promise<StoredFile> => {
|
||||
try {
|
||||
const raw = await fs.readFile(storePath(), 'utf8')
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return emptyFile()
|
||||
}
|
||||
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const sessions = obj.sessions
|
||||
|
||||
if (!sessions || typeof sessions !== 'object' || Array.isArray(sessions)) {
|
||||
return emptyFile()
|
||||
}
|
||||
|
||||
return { version: STORE_VERSION, sessions: sessions as Record<string, StoredRecord> }
|
||||
} catch {
|
||||
return emptyFile()
|
||||
}
|
||||
}
|
||||
|
||||
const writeFile = async (file: StoredFile): Promise<void> => {
|
||||
const path = storePath()
|
||||
const dir = dirname(path)
|
||||
await fs.mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`
|
||||
await fs.writeFile(tmp, JSON.stringify(file, null, 2), { mode: 0o600 })
|
||||
await fs.rename(tmp, path)
|
||||
}
|
||||
|
||||
export const getSession = async (url: string): Promise<RemoteSessionRecord | null> => {
|
||||
try {
|
||||
const file = await readFile()
|
||||
const raw = file.sessions[url]
|
||||
|
||||
if (!raw || typeof raw.token !== 'string' || !raw.token) {
|
||||
return null
|
||||
}
|
||||
|
||||
return toRecord(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export interface SaveSessionOptions {
|
||||
certPin?: string | null
|
||||
grants?: Record<string, number | null> | null
|
||||
ttlExpiresAt?: number | null
|
||||
endpointRole?: string | null
|
||||
toolsConsented?: boolean
|
||||
}
|
||||
|
||||
export const saveSession = async (
|
||||
url: string,
|
||||
token: string,
|
||||
serverVersion: string | null,
|
||||
opts: SaveSessionOptions | string | null = null
|
||||
): Promise<void> => {
|
||||
// Back-compat: the original signature took `certPin` as the fourth param.
|
||||
// Older callers still pass a string or null. Detect and route.
|
||||
const options: SaveSessionOptions =
|
||||
typeof opts === 'string' || opts === null || opts === undefined
|
||||
? { certPin: (opts as string | null) ?? undefined }
|
||||
: opts
|
||||
|
||||
try {
|
||||
const file = await readFile()
|
||||
const prev = file.sessions[url]
|
||||
file.sessions[url] = fromRecord({
|
||||
token,
|
||||
serverVersion,
|
||||
pairedAt: Math.floor(Date.now() / 1000),
|
||||
certPinSha256: options.certPin ?? prev?.cert_pin_sha256 ?? null,
|
||||
grants: options.grants ?? prev?.grants ?? null,
|
||||
ttlExpiresAt: options.ttlExpiresAt ?? prev?.ttl_expires_at ?? null,
|
||||
endpointRole: options.endpointRole ?? prev?.endpoint_role ?? null,
|
||||
toolsConsented:
|
||||
options.toolsConsented !== undefined
|
||||
? options.toolsConsented
|
||||
: (prev?.tools_consented ?? false)
|
||||
})
|
||||
await writeFile(file)
|
||||
} catch {
|
||||
// Persistence failures are non-fatal — next run just re-pairs.
|
||||
}
|
||||
}
|
||||
|
||||
export const deleteSession = async (url: string): Promise<void> => {
|
||||
try {
|
||||
const file = await readFile()
|
||||
|
||||
if (!(url in file.sessions)) {
|
||||
return
|
||||
}
|
||||
|
||||
delete file.sessions[url]
|
||||
await writeFile(file)
|
||||
} catch {
|
||||
/* fail-closed */
|
||||
}
|
||||
}
|
||||
|
||||
export const listSessions = async (): Promise<Record<string, RemoteSessionRecord>> => {
|
||||
try {
|
||||
const file = await readFile()
|
||||
const out: Record<string, RemoteSessionRecord> = {}
|
||||
|
||||
for (const [url, raw] of Object.entries(file.sessions)) {
|
||||
if (raw && typeof raw.token === 'string' && raw.token) {
|
||||
out[url] = toRecord(raw)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// CLI event renderer — turns GatewayEvents into plain-line stdout/stderr.
|
||||
//
|
||||
// Design:
|
||||
// - Assistant message text (message.delta) → stdout, streamed as it arrives.
|
||||
// - Tool calls/results → stdout inline, decorated with arrow marks so a
|
||||
// piped transcript still reads naturally.
|
||||
// - Diagnostics (status, errors, timeouts) → stderr, so `> out.txt` only
|
||||
// captures the agent's reply.
|
||||
// - --json → one event per line on stdout, for scripting; everything else
|
||||
// stays quiet.
|
||||
//
|
||||
// We deliberately do NOT handle clarify / approval / sudo / secret requests
|
||||
// here — those belong at the REPL layer because they need synchronous
|
||||
// keyboard input. The renderer just surfaces a warning so the user knows
|
||||
// the turn is blocked on input it can't provide.
|
||||
|
||||
import type { GatewayEvent } from './gatewayTypes.js'
|
||||
|
||||
const ANSI = {
|
||||
reset: '\x1b[0m',
|
||||
dim: '\x1b[2m',
|
||||
bold: '\x1b[1m',
|
||||
red: '\x1b[31m',
|
||||
green: '\x1b[32m',
|
||||
yellow: '\x1b[33m',
|
||||
cyan: '\x1b[36m',
|
||||
gray: '\x1b[90m'
|
||||
} as const
|
||||
|
||||
function colorEnabled(): boolean {
|
||||
if (process.env.NO_COLOR) {
|
||||
return false
|
||||
}
|
||||
if (process.env.FORCE_COLOR === '0') {
|
||||
return false
|
||||
}
|
||||
if (process.env.FORCE_COLOR && process.env.FORCE_COLOR !== '0') {
|
||||
return true
|
||||
}
|
||||
return !!process.stdout.isTTY
|
||||
}
|
||||
|
||||
export interface RendererOptions {
|
||||
/** Emit each event as a JSON line on stdout — useful for scripting. */
|
||||
json?: boolean
|
||||
/** Include thinking / reasoning deltas. */
|
||||
verbose?: boolean
|
||||
/** Suppress status lines and tool decorations. */
|
||||
quiet?: boolean
|
||||
/** Force-disable colors (overrides auto-detect). */
|
||||
noColor?: boolean
|
||||
}
|
||||
|
||||
export class CliRenderer {
|
||||
private inAssistantMessage = false
|
||||
private lastDeltaEndedWithNewline = true
|
||||
private readonly useColor: boolean
|
||||
|
||||
constructor(private opts: RendererOptions = {}) {
|
||||
this.useColor = opts.noColor ? false : colorEnabled()
|
||||
}
|
||||
|
||||
private c(code: string, text: string): string {
|
||||
return this.useColor ? `${code}${text}${ANSI.reset}` : text
|
||||
}
|
||||
|
||||
/** Force a newline on stdout if the last assistant delta didn't end with one.
|
||||
* Call this before printing tool decorations / prompts that should start on
|
||||
* a fresh line regardless of what the model just emitted. */
|
||||
private breakLine(): void {
|
||||
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
|
||||
process.stdout.write('\n')
|
||||
this.lastDeltaEndedWithNewline = true
|
||||
}
|
||||
}
|
||||
|
||||
handle(ev: GatewayEvent): void {
|
||||
if (this.opts.json) {
|
||||
process.stdout.write(JSON.stringify(ev) + '\n')
|
||||
return
|
||||
}
|
||||
|
||||
switch (ev.type) {
|
||||
case 'message.start': {
|
||||
if (!this.inAssistantMessage) {
|
||||
process.stdout.write('\n')
|
||||
this.inAssistantMessage = true
|
||||
this.lastDeltaEndedWithNewline = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'message.delta': {
|
||||
const text = ev.payload?.text
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
if (!this.inAssistantMessage) {
|
||||
process.stdout.write('\n')
|
||||
this.inAssistantMessage = true
|
||||
}
|
||||
process.stdout.write(text)
|
||||
this.lastDeltaEndedWithNewline = text.endsWith('\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'message.complete': {
|
||||
if (this.inAssistantMessage && !this.lastDeltaEndedWithNewline) {
|
||||
process.stdout.write('\n')
|
||||
}
|
||||
this.inAssistantMessage = false
|
||||
this.lastDeltaEndedWithNewline = true
|
||||
return
|
||||
}
|
||||
|
||||
case 'thinking.delta':
|
||||
case 'reasoning.delta': {
|
||||
if (this.opts.verbose) {
|
||||
const text = ev.payload?.text
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
process.stderr.write(this.c(ANSI.gray, text))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'tool.start': {
|
||||
if (this.opts.quiet) {
|
||||
return
|
||||
}
|
||||
this.breakLine()
|
||||
const name = ev.payload.name ?? ev.payload.tool_id
|
||||
const context = ev.payload.context ? ` ${this.c(ANSI.dim, `(${ev.payload.context})`)}` : ''
|
||||
process.stdout.write(`\n${this.c(ANSI.cyan, '→')} ${this.c(ANSI.bold, name)}${context}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
case 'tool.progress':
|
||||
// Suppress per-token progress — too noisy for line-mode.
|
||||
return
|
||||
|
||||
case 'tool.generating':
|
||||
// Internal "the model is filling in args" — silent.
|
||||
return
|
||||
|
||||
case 'tool.complete': {
|
||||
if (this.opts.quiet) {
|
||||
return
|
||||
}
|
||||
const name = ev.payload.name ?? ev.payload.tool_id
|
||||
if (ev.payload.error) {
|
||||
process.stdout.write(
|
||||
`${this.c(ANSI.red, '✗')} ${this.c(ANSI.bold, name)} — ${ev.payload.error}\n`
|
||||
)
|
||||
} else {
|
||||
const summary = ev.payload.summary
|
||||
const suffix = summary ? ` ${this.c(ANSI.dim, '— ' + summary.replace(/\n+/g, ' '))}` : ''
|
||||
process.stdout.write(`${this.c(ANSI.green, '✓')} ${this.c(ANSI.bold, name)}${suffix}\n`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'status.update': {
|
||||
if (this.opts.quiet) {
|
||||
return
|
||||
}
|
||||
const text = ev.payload?.text
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
process.stderr.write(this.c(ANSI.dim, `[${text}]`) + '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'error': {
|
||||
this.breakLine()
|
||||
const msg = ev.payload?.message ?? 'unknown error'
|
||||
process.stderr.write(this.c(ANSI.red, `error: ${msg}`) + '\n')
|
||||
return
|
||||
}
|
||||
|
||||
case 'approval.request': {
|
||||
this.breakLine()
|
||||
process.stderr.write(
|
||||
this.c(ANSI.yellow, `⚠ approval requested: ${ev.payload.command}`) + '\n'
|
||||
)
|
||||
process.stderr.write(this.c(ANSI.dim, ` ${ev.payload.description}`) + '\n')
|
||||
process.stderr.write(
|
||||
this.c(ANSI.dim, ' (interactive approval is not wired in v0.1 — ignore or cancel)') + '\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
case 'clarify.request': {
|
||||
this.breakLine()
|
||||
process.stderr.write(
|
||||
this.c(ANSI.yellow, `⚠ clarification requested: ${ev.payload.question}`) + '\n'
|
||||
)
|
||||
process.stderr.write(
|
||||
this.c(ANSI.dim, ' (interactive clarify is not wired in v0.1 — ignore or cancel)') + '\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
case 'sudo.request':
|
||||
case 'secret.request': {
|
||||
this.breakLine()
|
||||
process.stderr.write(
|
||||
this.c(ANSI.yellow, `⚠ server requested ${ev.type} — not wired in v0.1`) + '\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
case 'gateway.stderr': {
|
||||
if (this.opts.verbose) {
|
||||
process.stderr.write(this.c(ANSI.dim, ev.payload.line) + '\n')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
case 'gateway.start_timeout': {
|
||||
process.stderr.write(
|
||||
this.c(ANSI.yellow, 'warning: gateway subprocess slow to start (>15s)') + '\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
case 'gateway.protocol_error': {
|
||||
process.stderr.write(
|
||||
this.c(ANSI.red, `protocol error: ${ev.payload?.preview ?? '(no preview)'}`) + '\n'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// Subagent events, skin changes, session.info, background/btw, reasoning.available —
|
||||
// silent by default in v0.1. When we wire these into the CLI they'll get their own cases.
|
||||
case 'session.info':
|
||||
case 'gateway.ready':
|
||||
case 'skin.changed':
|
||||
case 'reasoning.available':
|
||||
case 'background.complete':
|
||||
case 'btw.complete':
|
||||
case 'subagent.start':
|
||||
case 'subagent.thinking':
|
||||
case 'subagent.tool':
|
||||
case 'subagent.progress':
|
||||
case 'subagent.complete':
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// One-time consent prompt for desktop tool exposure.
|
||||
//
|
||||
// The tool handlers run IN-PROCESS with full filesystem + shell access.
|
||||
// A compromised relay could ask us to `rm -rf /`. To keep the blast radius
|
||||
// bounded, we gate the router behind an explicit per-URL consent toggle
|
||||
// stored in `~/.hermes/remote-sessions.json`.
|
||||
//
|
||||
// Flow (driven from chat.ts / shell.ts):
|
||||
// 1. Check stored `toolsConsented` for this URL.
|
||||
// 2. If granted → return true, wire the router silently.
|
||||
// 3. If missing + TTY stdin → print prompt, read "yes" / anything-else.
|
||||
// Persist grant on "yes"; return false otherwise (tools not wired).
|
||||
// 4. If missing + non-TTY stdin → fail closed. Stderr error telling
|
||||
// the user to rerun with --no-tools or a TTY to consent.
|
||||
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
|
||||
import { getSession, saveSession } from '../remoteSessions.js'
|
||||
|
||||
export const CONSENT_PROMPT = `
|
||||
Desktop tools are about to be exposed to the remote Hermes agent.
|
||||
The agent can read/write files, run shell commands, and search your filesystem.
|
||||
This is AGENT-CONTROLLED access. Only use with trusted Hermes installs.
|
||||
Type 'yes' to enable, or rerun with --no-tools to disable.
|
||||
`
|
||||
|
||||
export interface EnsureConsentResult {
|
||||
consented: boolean
|
||||
/** Reason the router was not wired. Only set when consented=false. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
/** Check stored consent for `url`, prompt the user if missing, persist
|
||||
* the decision. Never throws — persistence failures fall through as
|
||||
* "consented this session only". */
|
||||
export async function ensureToolsConsent(url: string): Promise<EnsureConsentResult> {
|
||||
const existing = await getSession(url)
|
||||
if (existing?.toolsConsented === true) {
|
||||
return { consented: true }
|
||||
}
|
||||
|
||||
// Must have a TTY on stdin AND stderr to render the prompt — writing to
|
||||
// stderr avoids stepping on piped-JSON stdout in one-shot chat mode.
|
||||
if (!process.stdin.isTTY) {
|
||||
return {
|
||||
consented: false,
|
||||
reason:
|
||||
'desktop tools require one-time consent; rerun on an interactive TTY, or pass --no-tools to skip'
|
||||
}
|
||||
}
|
||||
|
||||
// Same bracketed-paste suppression as the pairing-code prompt — keeps
|
||||
// `yes`/`no` clean on Windows Terminal (ConPTY otherwise wraps pasted
|
||||
// content in `\x1b[200~...\x1b[201~` escape markers that readline
|
||||
// captures into the input string).
|
||||
process.stderr.write(CONSENT_PROMPT + '\x1b[?2004l')
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stderr,
|
||||
terminal: true
|
||||
})
|
||||
let answer = ''
|
||||
try {
|
||||
const raw = await rl.question('> ')
|
||||
// Strip ANSI + control chars defensively, then lower + trim.
|
||||
answer = raw
|
||||
.replaceAll(/\x1b\[[?\d;]*[a-zA-Z~]/g, '')
|
||||
.replaceAll(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
} catch {
|
||||
answer = ''
|
||||
} finally {
|
||||
rl.close()
|
||||
process.stderr.write('\x1b[?2004h')
|
||||
}
|
||||
|
||||
if (answer !== 'yes') {
|
||||
return { consented: false, reason: 'user declined tool consent' }
|
||||
}
|
||||
|
||||
// Persist on the existing session record. We pass the existing token /
|
||||
// server version unchanged — saveSession merges onto `prev` so we don't
|
||||
// clobber grants or certPin.
|
||||
if (existing) {
|
||||
try {
|
||||
await saveSession(url, existing.token, existing.serverVersion, {
|
||||
toolsConsented: true
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal — consent still holds for this process.
|
||||
}
|
||||
}
|
||||
|
||||
return { consented: true }
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
// Filesystem tool handlers for the `desktop` relay channel.
|
||||
//
|
||||
// desktop_read_file — bounded file read, truncates at max_bytes
|
||||
// desktop_write_file — optionally mkdir -p the parent, writes content
|
||||
// desktop_patch — apply a unified diff; fails loud rather than fuzz
|
||||
//
|
||||
// All handlers resolve paths against `ctx.cwd`. Absolute paths pass
|
||||
// through unchanged. ENOENT / EACCES surface verbatim in the error
|
||||
// string so the remote agent can branch on them.
|
||||
|
||||
import { promises as fs } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { ToolContext, ToolHandler } from '../router.js'
|
||||
|
||||
const DEFAULT_MAX_BYTES = 1_000_000
|
||||
|
||||
function resolvePath(ctx: ToolContext, p: unknown): string {
|
||||
if (typeof p !== 'string' || p.length === 0) {
|
||||
throw new Error('missing or invalid "path" argument')
|
||||
}
|
||||
return path.resolve(ctx.cwd, p)
|
||||
}
|
||||
|
||||
function argNumber(v: unknown, fallback: number): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v >= 0) {
|
||||
return Math.floor(v)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function argString(v: unknown, name: string): string {
|
||||
if (typeof v !== 'string') {
|
||||
throw new Error(`missing or invalid "${name}" argument`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function argBool(v: unknown, fallback: boolean): boolean {
|
||||
if (typeof v === 'boolean') {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
/** desktop_read_file
|
||||
* args: { path: string, max_bytes?: number (default 1_000_000) }
|
||||
* returns: string — file contents, possibly truncated
|
||||
* Truncation appends a marker line so the agent can detect it. */
|
||||
export const readFileHandler: ToolHandler = async (args, ctx) => {
|
||||
const abs = resolvePath(ctx, args.path)
|
||||
const maxBytes = argNumber(args.max_bytes, DEFAULT_MAX_BYTES)
|
||||
|
||||
// Stat first so we can detect oversize without reading the whole file
|
||||
// into memory. For normal files readFile is fine, but a multi-GB log
|
||||
// would OOM if we blindly consumed it.
|
||||
const stat = await fs.stat(abs)
|
||||
if (!stat.isFile()) {
|
||||
throw new Error(`not a regular file: ${abs}`)
|
||||
}
|
||||
|
||||
if (stat.size <= maxBytes) {
|
||||
const buf = await fs.readFile(abs)
|
||||
return buf.toString('utf8')
|
||||
}
|
||||
|
||||
// Oversize: read only the first maxBytes + truncation marker.
|
||||
const handle = await fs.open(abs, 'r')
|
||||
try {
|
||||
const buf = Buffer.alloc(maxBytes)
|
||||
await handle.read(buf, 0, maxBytes, 0)
|
||||
return `${buf.toString('utf8')}\n[... truncated at ${maxBytes} bytes]`
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** desktop_write_file
|
||||
* args: { path: string, content: string, create_dirs?: boolean }
|
||||
* returns: { ok: true, bytes_written: number, path: string } */
|
||||
export const writeFileHandler: ToolHandler = async (args, ctx) => {
|
||||
const abs = resolvePath(ctx, args.path)
|
||||
const content = argString(args.content, 'content')
|
||||
const createDirs = argBool(args.create_dirs, false)
|
||||
|
||||
if (createDirs) {
|
||||
await fs.mkdir(path.dirname(abs), { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFile(abs, content, 'utf8')
|
||||
const bytes = Buffer.byteLength(content, 'utf8')
|
||||
|
||||
return { ok: true, bytes_written: bytes, path: abs }
|
||||
}
|
||||
|
||||
// -- Patch application --------------------------------------------------
|
||||
// We implement a deliberately strict unified-diff applier. No fuzz, no
|
||||
// context-shifting — if the hunk's pre-image doesn't match the file at
|
||||
// the claimed line range, we refuse and the agent gets a clear error.
|
||||
// Better to punt back to the model than silently corrupt a file.
|
||||
|
||||
interface Hunk {
|
||||
oldStart: number // 1-based; 0 if file creation
|
||||
oldLen: number
|
||||
// newStart / newLen aren't strictly needed for application — we use the
|
||||
// hunk body to derive them — but we parse them so a malformed header
|
||||
// surfaces as a parse error rather than a weird mid-apply failure.
|
||||
newStart: number
|
||||
newLen: number
|
||||
lines: string[] // each line starts with ' ', '+', or '-'
|
||||
}
|
||||
|
||||
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/
|
||||
|
||||
function parseUnifiedDiff(patch: string): Hunk[] {
|
||||
const lines = patch.split('\n')
|
||||
const hunks: Hunk[] = []
|
||||
let i = 0
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
const m = HUNK_HEADER.exec(line)
|
||||
if (!m) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const oldStart = parseInt(m[1]!, 10)
|
||||
const oldLen = m[2] ? parseInt(m[2], 10) : 1
|
||||
const newStart = parseInt(m[3]!, 10)
|
||||
const newLen = m[4] ? parseInt(m[4], 10) : 1
|
||||
|
||||
i++
|
||||
const body: string[] = []
|
||||
while (i < lines.length) {
|
||||
const l = lines[i]!
|
||||
if (HUNK_HEADER.test(l)) {
|
||||
break
|
||||
}
|
||||
// Unified-diff file headers (---, +++) shouldn't appear inside a
|
||||
// hunk. If we see one, assume we've fallen out of the hunk body.
|
||||
if (l.startsWith('--- ') || l.startsWith('+++ ')) {
|
||||
break
|
||||
}
|
||||
if (l.length === 0) {
|
||||
// A bare empty line inside a hunk represents a context line
|
||||
// containing nothing — some diff generators emit it without the
|
||||
// leading space. Normalize.
|
||||
body.push(' ')
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const first = l[0]
|
||||
if (first !== ' ' && first !== '+' && first !== '-' && first !== '\\') {
|
||||
// Outside the recognized prefix set → not a diff line; end of hunk.
|
||||
break
|
||||
}
|
||||
if (first === '\\') {
|
||||
// "\ No newline at end of file" — ignore, we handle trailing
|
||||
// newlines at file-write time.
|
||||
i++
|
||||
continue
|
||||
}
|
||||
body.push(l)
|
||||
i++
|
||||
}
|
||||
|
||||
hunks.push({ oldStart, oldLen, newStart, newLen, lines: body })
|
||||
}
|
||||
|
||||
if (hunks.length === 0) {
|
||||
throw new Error('no hunks found in patch')
|
||||
}
|
||||
return hunks
|
||||
}
|
||||
|
||||
function applyHunk(srcLines: string[], hunk: Hunk): string[] {
|
||||
// Build pre-image (context + deletions) and post-image (context + additions)
|
||||
// from the hunk body.
|
||||
const pre: string[] = []
|
||||
const post: string[] = []
|
||||
for (const l of hunk.lines) {
|
||||
const tag = l[0]
|
||||
const content = l.slice(1)
|
||||
if (tag === ' ') {
|
||||
pre.push(content)
|
||||
post.push(content)
|
||||
} else if (tag === '-') {
|
||||
pre.push(content)
|
||||
} else if (tag === '+') {
|
||||
post.push(content)
|
||||
}
|
||||
}
|
||||
|
||||
// Locate pre-image in srcLines at the expected 1-based oldStart.
|
||||
const startIdx = hunk.oldStart - 1
|
||||
if (startIdx < 0 || startIdx + pre.length > srcLines.length) {
|
||||
throw new Error(
|
||||
`hunk @@ -${hunk.oldStart},${hunk.oldLen} @@ out of range (file has ${srcLines.length} lines)`
|
||||
)
|
||||
}
|
||||
for (let j = 0; j < pre.length; j++) {
|
||||
if (srcLines[startIdx + j] !== pre[j]) {
|
||||
throw new Error(
|
||||
`hunk @@ -${hunk.oldStart},${hunk.oldLen} @@ context mismatch at line ${startIdx + j + 1}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
...srcLines.slice(0, startIdx),
|
||||
...post,
|
||||
...srcLines.slice(startIdx + pre.length)
|
||||
]
|
||||
}
|
||||
|
||||
/** desktop_patch
|
||||
* args: { path: string, patch: string }
|
||||
* returns: { ok: true, hunks_applied: number, path: string }
|
||||
* Strict: any context mismatch aborts the whole patch — no partial writes. */
|
||||
export const patchHandler: ToolHandler = async (args, ctx) => {
|
||||
const abs = resolvePath(ctx, args.path)
|
||||
const patchText = argString(args.patch, 'patch')
|
||||
|
||||
let hunks: Hunk[]
|
||||
try {
|
||||
hunks = parseUnifiedDiff(patchText)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
throw new Error(`patch did not apply cleanly: ${msg}`)
|
||||
}
|
||||
|
||||
const original = await fs.readFile(abs, 'utf8')
|
||||
// Preserve the original trailing-newline state so we can re-apply it
|
||||
// after splitting. split('\n') on "a\nb\n" → ["a","b",""] which is
|
||||
// fine — we'll filter the trailing '' back out on join if it was there.
|
||||
const hadTrailingNewline = original.endsWith('\n')
|
||||
const srcLines = original.split('\n')
|
||||
if (hadTrailingNewline) {
|
||||
// Drop the phantom empty string produced by the trailing '\n' split.
|
||||
srcLines.pop()
|
||||
}
|
||||
|
||||
// Apply hunks bottom-up so earlier-hunk deletions don't shift later-hunk
|
||||
// indices. The diff format already orders them top-down in the file,
|
||||
// so reversing in-place is safe.
|
||||
let working = srcLines
|
||||
const ordered = [...hunks].sort((a, b) => b.oldStart - a.oldStart)
|
||||
for (const h of ordered) {
|
||||
try {
|
||||
working = applyHunk(working, h)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
throw new Error(`patch did not apply cleanly: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
const joined = working.join('\n') + (hadTrailingNewline ? '\n' : '')
|
||||
await fs.writeFile(abs, joined, 'utf8')
|
||||
|
||||
return { ok: true, hunks_applied: hunks.length, path: abs }
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// File/content search handler for the `desktop` relay channel.
|
||||
//
|
||||
// args: { pattern: string, cwd?: string, max_results?: number,
|
||||
// content?: boolean }
|
||||
// returns: { matches: string[], truncated: boolean }
|
||||
//
|
||||
// content:true — search file *contents* for `pattern`. Prefers ripgrep
|
||||
// if it's on PATH; falls back to a pure-Node walk+read
|
||||
// (slower, but keeps the handler dep-free).
|
||||
// content:false — search file *paths* for `pattern` (glob-ish: supports
|
||||
// `*`, `?`, `**` with forward-slash segments).
|
||||
//
|
||||
// Skips `.git`, `node_modules`, `dist`, `.next`, `.cache` under all
|
||||
// modes so a naïve `*.ts` doesn't drown in vendored code.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import type { ToolContext, ToolHandler } from '../router.js'
|
||||
|
||||
const DEFAULT_MAX = 100
|
||||
const SKIP_DIRS = new Set(['.git', 'node_modules', 'dist', '.next', '.cache'])
|
||||
|
||||
function argString(v: unknown, name: string): string {
|
||||
if (typeof v !== 'string' || v.length === 0) {
|
||||
throw new Error(`missing or invalid "${name}" argument`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function argNumber(v: unknown, fallback: number): number {
|
||||
if (typeof v === 'number' && Number.isFinite(v) && v > 0) {
|
||||
return Math.floor(v)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function argBool(v: unknown, fallback: boolean): boolean {
|
||||
if (typeof v === 'boolean') {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// Compile a glob pattern (`*`, `?`, `**`) to a RegExp that matches full
|
||||
// relative paths. Not fully POSIX — enough for the common agent cases
|
||||
// (`**/*.ts`, `src/**/*.py`, `*.md`).
|
||||
function globToRegExp(glob: string): RegExp {
|
||||
let out = '^'
|
||||
let i = 0
|
||||
while (i < glob.length) {
|
||||
const c = glob[i]!
|
||||
if (c === '*') {
|
||||
if (glob[i + 1] === '*') {
|
||||
// `**` — match any sequence including path separators
|
||||
out += '.*'
|
||||
i += 2
|
||||
// Consume a trailing slash after `**` so `**/foo` doesn't require a
|
||||
// leading directory: treat `**/foo` equivalent to `(?:.*\/)?foo`.
|
||||
if (glob[i] === '/') {
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
// Single `*` — match anything except a path separator
|
||||
out += '[^/]*'
|
||||
i++
|
||||
}
|
||||
} else if (c === '?') {
|
||||
out += '[^/]'
|
||||
i++
|
||||
} else if ('.+^$()|{}[]\\'.indexOf(c) >= 0) {
|
||||
out += '\\' + c
|
||||
i++
|
||||
} else {
|
||||
out += c
|
||||
i++
|
||||
}
|
||||
}
|
||||
out += '$'
|
||||
return new RegExp(out)
|
||||
}
|
||||
|
||||
async function* walk(root: string, ctx: ToolContext): AsyncGenerator<string> {
|
||||
const queue: string[] = [root]
|
||||
while (queue.length > 0) {
|
||||
if (ctx.abortSignal.aborted) {
|
||||
return
|
||||
}
|
||||
const dir = queue.shift()!
|
||||
let entries: Array<{ name: string; isDirectory(): boolean; isFile(): boolean }> = []
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue // unreadable dir — skip silently
|
||||
}
|
||||
for (const ent of entries) {
|
||||
const full = path.join(dir, ent.name)
|
||||
if (ent.isDirectory()) {
|
||||
if (SKIP_DIRS.has(ent.name)) {
|
||||
continue
|
||||
}
|
||||
queue.push(full)
|
||||
} else if (ent.isFile()) {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function pathSearch(
|
||||
pattern: string,
|
||||
root: string,
|
||||
max: number,
|
||||
ctx: ToolContext
|
||||
): Promise<{ matches: string[]; truncated: boolean }> {
|
||||
// Normalize pattern to forward-slash for cross-platform glob matching.
|
||||
const re = globToRegExp(pattern.replace(/\\/g, '/'))
|
||||
const matches: string[] = []
|
||||
let truncated = false
|
||||
|
||||
for await (const file of walk(root, ctx)) {
|
||||
const rel = path.relative(root, file).replace(/\\/g, '/')
|
||||
if (re.test(rel) || re.test(path.basename(file))) {
|
||||
matches.push(file)
|
||||
if (matches.length >= max) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, truncated }
|
||||
}
|
||||
|
||||
function ripgrepContentSearch(
|
||||
pattern: string,
|
||||
root: string,
|
||||
max: number,
|
||||
ctx: ToolContext
|
||||
): Promise<{ matches: string[]; truncated: boolean; ok: boolean }> {
|
||||
return new Promise(resolve => {
|
||||
const rgArgs = [
|
||||
'--files-with-matches',
|
||||
'--no-messages',
|
||||
'--max-count', '1',
|
||||
...Array.from(SKIP_DIRS).flatMap(d => ['--glob', `!${d}`]),
|
||||
'-e',
|
||||
pattern,
|
||||
root
|
||||
]
|
||||
let child
|
||||
try {
|
||||
child = spawn('rg', rgArgs, { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
} catch {
|
||||
resolve({ matches: [], truncated: false, ok: false })
|
||||
return
|
||||
}
|
||||
|
||||
let stdout = ''
|
||||
child.stdout?.on('data', (c: Buffer) => {
|
||||
stdout += c.toString('utf8')
|
||||
})
|
||||
child.on('error', () => resolve({ matches: [], truncated: false, ok: false }))
|
||||
|
||||
const onAbort = () => {
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ctx.abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
child.on('close', () => {
|
||||
ctx.abortSignal.removeEventListener('abort', onAbort)
|
||||
const lines = stdout.split('\n').filter(Boolean)
|
||||
const matches = lines.slice(0, max)
|
||||
resolve({ matches, truncated: lines.length > max, ok: true })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function fallbackContentSearch(
|
||||
pattern: string,
|
||||
root: string,
|
||||
max: number,
|
||||
ctx: ToolContext
|
||||
): Promise<{ matches: string[]; truncated: boolean }> {
|
||||
// Literal substring match — we don't try to be a regex engine. If the
|
||||
// agent wants regex, it can use ripgrep when available or ask for
|
||||
// path search + read-file combo.
|
||||
const matches: string[] = []
|
||||
let truncated = false
|
||||
|
||||
for await (const file of walk(root, ctx)) {
|
||||
if (ctx.abortSignal.aborted) {
|
||||
break
|
||||
}
|
||||
try {
|
||||
const stat = await fs.stat(file)
|
||||
if (stat.size > 5_000_000) {
|
||||
continue // skip huge files in the fallback path; rg can handle them
|
||||
}
|
||||
const buf = await fs.readFile(file)
|
||||
if (buf.includes(pattern)) {
|
||||
matches.push(file)
|
||||
if (matches.length >= max) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// unreadable — skip
|
||||
}
|
||||
}
|
||||
|
||||
return { matches, truncated }
|
||||
}
|
||||
|
||||
export const searchFilesHandler: ToolHandler = async (args, ctx) => {
|
||||
const pattern = argString(args.pattern, 'pattern')
|
||||
const cwd =
|
||||
typeof args.cwd === 'string' && args.cwd.length > 0
|
||||
? path.resolve(ctx.cwd, args.cwd)
|
||||
: ctx.cwd
|
||||
const max = argNumber(args.max_results, DEFAULT_MAX)
|
||||
const content = argBool(args.content, false)
|
||||
|
||||
if (content) {
|
||||
const rg = await ripgrepContentSearch(pattern, cwd, max, ctx)
|
||||
if (rg.ok) {
|
||||
return { matches: rg.matches, truncated: rg.truncated }
|
||||
}
|
||||
return await fallbackContentSearch(pattern, cwd, max, ctx)
|
||||
}
|
||||
|
||||
return await pathSearch(pattern, cwd, max, ctx)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
// Shell-exec handler for the `desktop` channel. Spawns a child under the
|
||||
// user's login-ish shell, captures stdout/stderr, kills on abort/timeout,
|
||||
// returns a structured exit envelope.
|
||||
//
|
||||
// Platform split:
|
||||
// - POSIX: `bash -lc '<command>'` — -l loads login files (aliases, PATH).
|
||||
// - Windows: `cmd /c <command>` — closest equivalent that accepts a
|
||||
// single string. A future revision could prefer PowerShell; cmd was
|
||||
// chosen for zero-config parity with git-bash users.
|
||||
//
|
||||
// The router caps the whole handler at 30s via its AbortController, but
|
||||
// callers can ALSO pass a per-call `timeout` in SECONDS — the wire spec
|
||||
// in `plugin/tools/desktop_tool.py` passes `timeout: int(timeout)` where
|
||||
// `timeout` is the seconds value from the tool schema (default 30s). We
|
||||
// convert to ms internally. Whichever fires first (per-call timeout OR
|
||||
// router AbortController) triggers SIGKILL on the child.
|
||||
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
import type { ToolHandler } from '../router.js'
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
// Absolute ceiling so a malicious / confused caller can't ask for a
|
||||
// 1-hour shell command on our machine. Bounded at 10 minutes — above
|
||||
// that, split the work into multiple calls.
|
||||
const MAX_TIMEOUT_MS = 10 * 60_000
|
||||
|
||||
function argString(v: unknown, name: string): string {
|
||||
if (typeof v !== 'string' || v.length === 0) {
|
||||
throw new Error(`missing or invalid "${name}" argument`)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
function argOptionalString(v: unknown): string | null {
|
||||
if (typeof v === 'string' && v.length > 0) {
|
||||
return v
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an incoming `timeout` (SECONDS per wire spec) into milliseconds,
|
||||
* with defensive clamping. Accepts the value from either `timeout` (seconds,
|
||||
* canonical) or `timeout_ms` (milliseconds, opt-in override). This matches
|
||||
* Python's idiomatic `time.sleep(seconds)` convention while still letting
|
||||
* Node-native callers send precise ms values when they need to.
|
||||
*/
|
||||
function resolveTimeoutMs(args: Record<string, unknown>): number {
|
||||
// timeout_ms wins if both provided — more specific.
|
||||
const rawMs = args.timeout_ms
|
||||
if (typeof rawMs === 'number' && Number.isFinite(rawMs) && rawMs > 0) {
|
||||
return Math.min(Math.floor(rawMs), MAX_TIMEOUT_MS)
|
||||
}
|
||||
const rawSec = args.timeout
|
||||
if (typeof rawSec === 'number' && Number.isFinite(rawSec) && rawSec > 0) {
|
||||
return Math.min(Math.floor(rawSec * 1000), MAX_TIMEOUT_MS)
|
||||
}
|
||||
return DEFAULT_TIMEOUT_MS
|
||||
}
|
||||
|
||||
export const terminalHandler: ToolHandler = async (args, ctx) => {
|
||||
const command = argString(args.command, 'command')
|
||||
const cwd = argOptionalString(args.cwd) ?? ctx.cwd
|
||||
const timeoutMs = resolveTimeoutMs(args)
|
||||
|
||||
const isWin = process.platform === 'win32'
|
||||
const cmd = isWin ? 'cmd' : 'bash'
|
||||
const shellArgs = isWin ? ['/c', command] : ['-lc', command]
|
||||
|
||||
const start = Date.now()
|
||||
const child = spawn(cmd, shellArgs, {
|
||||
cwd,
|
||||
env: process.env,
|
||||
// Don't inherit stdin — the remote agent has no way to type into it
|
||||
// and a command that reads stdin would otherwise hang until timeout.
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true
|
||||
})
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
let killedBy: 'timeout' | 'abort' | null = null
|
||||
|
||||
child.stdout?.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
child.stderr?.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
// Per-call timeout (bounded above by the router's AbortController).
|
||||
const timer = setTimeout(() => {
|
||||
killedBy = 'timeout'
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, timeoutMs)
|
||||
timer.unref?.()
|
||||
|
||||
// Honor transport-level abort (router timeout or relay teardown).
|
||||
const onAbort = () => {
|
||||
if (!killedBy) {
|
||||
killedBy = 'abort'
|
||||
}
|
||||
try {
|
||||
child.kill('SIGKILL')
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ctx.abortSignal.addEventListener('abort', onAbort, { once: true })
|
||||
|
||||
try {
|
||||
const exitCode = await new Promise<number>((resolve, reject) => {
|
||||
child.on('error', e => reject(e))
|
||||
child.on('close', (code, signal) => {
|
||||
if (killedBy) {
|
||||
reject(new Error(killedBy === 'timeout' ? `timed out after ${timeoutMs}ms` : 'aborted'))
|
||||
return
|
||||
}
|
||||
if (code !== null) {
|
||||
resolve(code)
|
||||
return
|
||||
}
|
||||
// Killed by signal (not our kill) — surface as non-zero exit for the agent.
|
||||
resolve(128 + (typeof signal === 'string' ? 15 : 1))
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
stdout,
|
||||
stderr,
|
||||
exit_code: exitCode,
|
||||
duration_ms: Date.now() - start
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
ctx.abortSignal.removeEventListener('abort', onAbort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// DesktopToolRouter — Node mirror of Android's BridgeCommandHandler for the
|
||||
// `desktop` relay channel. Server sends `desktop.command` envelopes; we look
|
||||
// up the named tool, run it under an AbortController, and send a matching
|
||||
// `desktop.response`. Periodic `desktop.status` heartbeats advertise which
|
||||
// tools we handle so the server's Python-side `DesktopHandler.advertised`
|
||||
// set knows which clients can service which calls.
|
||||
//
|
||||
// Wire contract (agreed with server-side agent, see commit 2026-04-23):
|
||||
// server → client:
|
||||
// { channel:'desktop', type:'desktop.command', id:'<uuid>',
|
||||
// payload:{ request_id, tool, args } }
|
||||
// client → server:
|
||||
// { channel:'desktop', type:'desktop.response',
|
||||
// payload:{ request_id, ok:true|false, result?, error? } }
|
||||
// client → server (periodic, every 30s + on attach):
|
||||
// { channel:'desktop', type:'desktop.status',
|
||||
// payload:{ advertised_tools: string[] } }
|
||||
//
|
||||
// Safety: the router is a no-op unless the caller has explicitly flagged
|
||||
// `consentGranted: true`. Consent is a per-URL toggle stored in
|
||||
// `~/.hermes/remote-sessions.json` and driven by chat.ts / shell.ts —
|
||||
// this module just honors the flag.
|
||||
|
||||
import type { RelayTransport } from '../transport/RelayTransport.js'
|
||||
|
||||
/** The payload shape server → client for a single tool invocation. */
|
||||
export interface ToolCallPayload {
|
||||
request_id: string
|
||||
tool: string
|
||||
args: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Either a success with a free-form result, or a failure with an error
|
||||
* message. The router serializes handler throws into the failure shape
|
||||
* automatically — handlers should just throw. */
|
||||
export type ToolResponsePayload =
|
||||
| { request_id: string; ok: true; result: unknown }
|
||||
| { request_id: string; ok: false; error: string }
|
||||
|
||||
/** Context passed to every handler. `cwd` defaults to `process.cwd()` but
|
||||
* per-call overrides (e.g. terminalHandler's own `cwd` arg) still apply
|
||||
* inside the handler — this is just the router-level default. `abortSignal`
|
||||
* fires on transport teardown or 30s handler timeout; handlers should
|
||||
* honor it wherever they spawn children / do long I/O. */
|
||||
export interface ToolContext {
|
||||
cwd: string
|
||||
abortSignal: AbortSignal
|
||||
}
|
||||
|
||||
/** A tool handler. Throws → router responds with `{ok:false, error}`. */
|
||||
export type ToolHandler = (
|
||||
args: Record<string, unknown>,
|
||||
ctx: ToolContext
|
||||
) => Promise<unknown>
|
||||
|
||||
export interface DesktopToolRouterOpts {
|
||||
handlers: Record<string, ToolHandler>
|
||||
/** Optional override for the heartbeat `advertised_tools` list — default
|
||||
* is `Object.keys(handlers)`. Useful when some handlers are stubs that
|
||||
* should not be advertised. */
|
||||
advertisedTools?: string[]
|
||||
/** If false, `attach()` logs a warning and does nothing. Wire chat.ts /
|
||||
* shell.ts to this so --no-tools or missing consent refuses cleanly. */
|
||||
consentGranted?: boolean
|
||||
}
|
||||
|
||||
/** Heartbeat cadence — matches server-side `DesktopHandler` expectation.
|
||||
* Server stamps `last_status` on receipt and clears advertised tools if
|
||||
* a client goes silent longer than ~90s. */
|
||||
const HEARTBEAT_MS = 30_000
|
||||
|
||||
/** Per-handler timeout — if the handler hasn't resolved by then, the abort
|
||||
* signal fires and we send `{ok:false, error:'aborted'}`. 30s matches the
|
||||
* Android bridge's timeout for the same class of request/response RPCs. */
|
||||
const HANDLER_TIMEOUT_MS = 30_000
|
||||
|
||||
function isToolCallPayload(x: unknown): x is ToolCallPayload {
|
||||
if (!x || typeof x !== 'object') {
|
||||
return false
|
||||
}
|
||||
const r = x as Record<string, unknown>
|
||||
return (
|
||||
typeof r.request_id === 'string' &&
|
||||
typeof r.tool === 'string' &&
|
||||
!!r.args &&
|
||||
typeof r.args === 'object' &&
|
||||
!Array.isArray(r.args)
|
||||
)
|
||||
}
|
||||
|
||||
export class DesktopToolRouter {
|
||||
private readonly handlers: Record<string, ToolHandler>
|
||||
private readonly advertisedTools: string[]
|
||||
private readonly consentGranted: boolean
|
||||
private relay: RelayTransport | null = null
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
private attached = false
|
||||
|
||||
constructor(opts: DesktopToolRouterOpts) {
|
||||
this.handlers = opts.handlers
|
||||
this.advertisedTools = opts.advertisedTools ?? Object.keys(opts.handlers)
|
||||
this.consentGranted = opts.consentGranted ?? false
|
||||
}
|
||||
|
||||
/** Install the `onChannel('desktop')` listener and start heartbeats.
|
||||
* Idempotent — calling twice is a no-op. Refuses silently if consent
|
||||
* wasn't granted; upstream code is expected to skip the whole router
|
||||
* in that case, this is just a defense-in-depth guard. */
|
||||
attach(relay: RelayTransport): void {
|
||||
if (this.attached) {
|
||||
return
|
||||
}
|
||||
if (!this.consentGranted) {
|
||||
// Defensive: caller should not have constructed us, but if they did,
|
||||
// don't silently serve commands.
|
||||
return
|
||||
}
|
||||
this.attached = true
|
||||
this.relay = relay
|
||||
|
||||
relay.onChannel('desktop', (type, payload) => {
|
||||
if (type === 'desktop.command') {
|
||||
if (!isToolCallPayload(payload)) {
|
||||
// Malformed envelope — no request_id to echo back, so just drop.
|
||||
return
|
||||
}
|
||||
void this.dispatch(payload)
|
||||
return
|
||||
}
|
||||
// Unknown desktop.* types — ignore; server may extend later.
|
||||
})
|
||||
|
||||
// Fire one heartbeat immediately so the server learns about us on attach
|
||||
// without waiting a full 30s cycle.
|
||||
this.sendHeartbeat()
|
||||
this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), HEARTBEAT_MS)
|
||||
// Don't keep the event loop alive just for the heartbeat.
|
||||
this.heartbeatTimer.unref?.()
|
||||
}
|
||||
|
||||
/** Remove the channel listener and stop heartbeats. Idempotent. */
|
||||
detach(): void {
|
||||
if (!this.attached) {
|
||||
return
|
||||
}
|
||||
this.attached = false
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer)
|
||||
this.heartbeatTimer = null
|
||||
}
|
||||
try {
|
||||
this.relay?.onChannel('desktop', null)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.relay = null
|
||||
}
|
||||
|
||||
/** Broadcast the advertised-tools heartbeat. Safe to call when detached
|
||||
* — just no-ops. */
|
||||
private sendHeartbeat(): void {
|
||||
if (!this.relay) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.relay.sendChannel('desktop', 'desktop.status', {
|
||||
advertised_tools: this.advertisedTools
|
||||
})
|
||||
} catch {
|
||||
// Heartbeat failures are non-fatal; next cycle retries.
|
||||
}
|
||||
}
|
||||
|
||||
/** Look up the handler, run it under a 30s AbortController, and reply.
|
||||
* Always sends a response — even unknown-tool, timeout, and thrown-error
|
||||
* paths — so the server's pending-request map never hangs. */
|
||||
private async dispatch(cmd: ToolCallPayload): Promise<void> {
|
||||
const { request_id, tool, args } = cmd
|
||||
const handler = this.handlers[tool]
|
||||
if (!handler) {
|
||||
this.sendResponse({
|
||||
request_id,
|
||||
ok: false,
|
||||
error: `unknown tool: ${tool}`
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, HANDLER_TIMEOUT_MS)
|
||||
// Timeout fires abort, which the handler should honor. Timer itself
|
||||
// is unref'd so it doesn't keep the process alive.
|
||||
timeoutTimer.unref?.()
|
||||
|
||||
const ctx: ToolContext = {
|
||||
cwd: process.cwd(),
|
||||
abortSignal: controller.signal
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handler(args, ctx)
|
||||
clearTimeout(timeoutTimer)
|
||||
// If the abort fired but the handler returned anyway, still treat
|
||||
// the outcome as the canonical result — the handler decided the
|
||||
// work was completable.
|
||||
this.sendResponse({ request_id, ok: true, result })
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutTimer)
|
||||
// Distinguish aborts (timeout or transport teardown) from genuine
|
||||
// handler errors so the user-visible error message is accurate.
|
||||
const aborted = controller.signal.aborted
|
||||
const message = aborted
|
||||
? 'aborted'
|
||||
: e instanceof Error
|
||||
? e.message
|
||||
: String(e)
|
||||
this.sendResponse({ request_id, ok: false, error: message })
|
||||
}
|
||||
}
|
||||
|
||||
private sendResponse(payload: ToolResponsePayload): void {
|
||||
if (!this.relay) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
this.relay.sendChannel(
|
||||
'desktop',
|
||||
'desktop.response',
|
||||
payload as unknown as Record<string, unknown>
|
||||
)
|
||||
} catch {
|
||||
// If send fails, the server will time out the pending request; no
|
||||
// local recovery possible.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/transport/RelayTransport.ts
|
||||
// (feat/tui-transport-pluggable, 2026-04-23). Changes from source:
|
||||
// - Dropped `deviceId` auto-generation — CLI leaves it to the caller.
|
||||
// - `sendResize` kept even though CLI doesn't currently wire SIGWINCH;
|
||||
// leaving it hot avoids a surprise when we add streaming REPL later.
|
||||
// - Added reconnect-on-drop state machine + TOFU cert pinning. The
|
||||
// TUI upstream stays one-shot; this divergence is local-only.
|
||||
// Envelope protocol (docs/relay-protocol.md §3.7) stays in lockstep with
|
||||
// the TUI — only the lifecycle around `open`/`close` changed.
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { connect as tlsConnect } from 'node:tls'
|
||||
|
||||
import { comparePins, extractSpkiSha256, isSecureUrl, pinKey } from '../certPin.js'
|
||||
import type { GatewayEvent } from '../gatewayTypes.js'
|
||||
import { CircularBuffer } from '../lib/circularBuffer.js'
|
||||
import { getSession, saveSession } from '../remoteSessions.js'
|
||||
|
||||
import type { Transport } from './Transport.js'
|
||||
|
||||
const MAX_LOG_LINES = 200
|
||||
const MAX_LOG_LINE_BYTES = 4096
|
||||
const MAX_BUFFERED_EVENTS = 2000
|
||||
const REQUEST_TIMEOUT_MS = Math.max(30000, parseInt(process.env.HERMES_RELAY_RPC_TIMEOUT_MS ?? '120000', 10) || 120000)
|
||||
const AUTH_TIMEOUT_MS = Math.max(5000, parseInt(process.env.HERMES_RELAY_AUTH_TIMEOUT_MS ?? '15000', 10) || 15000)
|
||||
|
||||
// Reconnect knobs — mirrored from Android ConnectionManager.kt.
|
||||
const RECONNECT_BASE_MS = 1000
|
||||
const RECONNECT_MAX_MS = 30_000
|
||||
const RECONNECT_BACKOFF_CEIL = 4 // cap the exponent so 2^n doesn't overflow past MAX
|
||||
const RECONNECT_RATE_LIMITED_MS = 5 * 60 * 1000
|
||||
const TLS_PROBE_TIMEOUT_MS = 10_000
|
||||
|
||||
const truncateLine = (line: string) =>
|
||||
line.length > MAX_LOG_LINE_BYTES ? `${line.slice(0, MAX_LOG_LINE_BYTES)}… [truncated ${line.length} bytes]` : line
|
||||
|
||||
const asGatewayEvent = (value: unknown): GatewayEvent | null =>
|
||||
value && typeof value === 'object' && !Array.isArray(value) && typeof (value as { type?: unknown }).type === 'string'
|
||||
? (value as GatewayEvent)
|
||||
: null
|
||||
|
||||
interface Pending {
|
||||
id: string
|
||||
method: string
|
||||
reject: (e: Error) => void
|
||||
resolve: (v: unknown) => void
|
||||
timeout: ReturnType<typeof setTimeout>
|
||||
}
|
||||
|
||||
interface WSMessageEvent { data: unknown }
|
||||
interface WSCloseEvent { code: number; reason: string }
|
||||
interface WSErrorEvent { message?: string }
|
||||
|
||||
interface WSLike {
|
||||
readyState: number
|
||||
send(data: string): void
|
||||
close(code?: number, reason?: string): void
|
||||
addEventListener(type: 'open', listener: () => void): void
|
||||
addEventListener(type: 'message', listener: (ev: WSMessageEvent) => void): void
|
||||
addEventListener(type: 'close', listener: (ev: WSCloseEvent) => void): void
|
||||
addEventListener(type: 'error', listener: (ev: WSErrorEvent) => void): void
|
||||
}
|
||||
|
||||
type WSFactory = (url: string) => WSLike
|
||||
|
||||
export interface AuthMeta {
|
||||
/** Per-channel grant expiry (epoch seconds; `null` = never). Shape matches
|
||||
* `auth.ok.grants` — typical keys: `chat`, `terminal`, `bridge`, `tui`. */
|
||||
grants: Record<string, number | null> | null
|
||||
/** Session token expiry (epoch seconds; `null` = never). */
|
||||
ttlExpiresAt: number | null
|
||||
/** Server's hint about the transport it's running on — `"wss"` / `"ws"` / `"unknown"`.
|
||||
* Used by the contextual connect banner so we can tell the user what they're on. */
|
||||
transportHint: string | null
|
||||
}
|
||||
|
||||
export type AuthOutcome =
|
||||
| { ok: true; serverVersion: null | string; token: string; meta: AuthMeta }
|
||||
| { ok: false; reason: string }
|
||||
|
||||
/** Internal state for the reconnect machine. `idle` = pre-start or fully
|
||||
* stopped; `connecting` = initial connect in flight; `connected` =
|
||||
* auth.ok seen; `reconnecting` = socket dropped, backoff timer armed. */
|
||||
type ReconnectState = 'idle' | 'connecting' | 'connected' | 'reconnecting'
|
||||
|
||||
const defaultWSFactory: WSFactory = url => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Ctor = (globalThis as any).WebSocket
|
||||
|
||||
if (typeof Ctor !== 'function') {
|
||||
throw new Error('RelayTransport: global WebSocket not available. Need Node >=21.')
|
||||
}
|
||||
|
||||
return new Ctor(url) as WSLike
|
||||
}
|
||||
|
||||
export interface RelayTransportConfig {
|
||||
url: string
|
||||
/** One-time pairing code. Mutually exclusive with sessionToken. */
|
||||
pairingCode?: string
|
||||
/** Previously-minted session token for reconnection. */
|
||||
sessionToken?: string
|
||||
/** Human-readable label for the "Paired Devices" list. */
|
||||
deviceName?: string
|
||||
/** Stable per-install identifier. */
|
||||
deviceId?: string
|
||||
/** Requested session lifetime in seconds (0 = never expire). */
|
||||
ttlSeconds?: number
|
||||
/** Test hook. */
|
||||
wsFactory?: WSFactory
|
||||
/** Auto-reconnect on WSS close. Default: true. Set false for one-shot
|
||||
* commands (pair, tools list). */
|
||||
autoReconnect?: boolean
|
||||
/** Max reconnect attempts before giving up and emitting 'exit'. 0 =
|
||||
* unlimited. Default: 0. */
|
||||
maxReconnectAttempts?: number
|
||||
/** Fires after a successful reconnect — caller should re-send whatever
|
||||
* attach envelope it depends on (tui.attach / terminal.attach) since the
|
||||
* server-side subprocess was lost when the socket closed. */
|
||||
onReconnect?: () => void
|
||||
/** Predicate checked both at schedule time and after backoff expires.
|
||||
* Returning false aborts reconnect — used for credential-purge races
|
||||
* (e.g. user ran `hermes-relay pair --reset` mid-session). */
|
||||
reconnectGate?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* RelayTransport pipes JSON-RPC to a remote `tui_gateway` subprocess via the
|
||||
* hermes-relay `tui` channel (docs/relay-protocol.md §3.7). Outbound JSON-RPC
|
||||
* is wrapped in `tui.rpc.request` envelopes; inbound `tui.rpc.response` and
|
||||
* `tui.rpc.event` envelopes are unwrapped back to the flat JSON-RPC shape.
|
||||
*
|
||||
* Reconnect semantics:
|
||||
* - First `start()` opens the socket, runs TOFU (if wss://), then auths.
|
||||
* - On unexpected close AFTER auth.ok, schedules a reconnect with exponential
|
||||
* backoff (1s, 2s, 4s, 8s, 16s, 30s, 30s, …). Rate-limit closes get 5min.
|
||||
* - `'reconnecting'` fires with `{attempt, delayMs}` before each backoff.
|
||||
* `'reconnected'` fires once auth.ok lands on the new socket.
|
||||
* - The original `whenAuthResolved()` promise settles on the FIRST connect
|
||||
* only — subsequent reconnects do not re-resolve it. Callers that care
|
||||
* listen for the `'reconnected'` event.
|
||||
*/
|
||||
export class RelayTransport extends EventEmitter implements Transport {
|
||||
private ws: WSLike | null = null
|
||||
private wsFactory: WSFactory
|
||||
private cfg: RelayTransportConfig
|
||||
private reqId = 0
|
||||
private logs = new CircularBuffer<string>(MAX_LOG_LINES)
|
||||
private pending = new Map<string, Pending>()
|
||||
private bufferedEvents = new CircularBuffer<GatewayEvent>(MAX_BUFFERED_EVENTS)
|
||||
private pendingExit: number | null | undefined
|
||||
private subscribed = false
|
||||
private authResolved = false
|
||||
private authTimer: ReturnType<typeof setTimeout> | null = null
|
||||
sessionToken: string | null = null
|
||||
serverVersion: string | null = null
|
||||
/** Auth.ok metadata captured on handshake — surfaces grants / ttl / transport
|
||||
* hint to the CLI so `hermes-relay status`, the connect banner, and future
|
||||
* TTL-aware flows don't have to re-RPC for data the handshake already carried. */
|
||||
authMeta: AuthMeta = { grants: null, ttlExpiresAt: null, transportHint: null }
|
||||
private authSuccessObservers: Array<(token: string, serverVersion: string | null, meta: AuthMeta) => void> = []
|
||||
private authSettlers: Array<(r: AuthOutcome) => void> = []
|
||||
private authFailReason: null | string = null
|
||||
private started = false
|
||||
private tornDown = false
|
||||
/** Listeners keyed by channel name. Lets the `shell` subcommand receive raw
|
||||
* `terminal.*` envelopes without routing through the JSON-RPC wrapper the
|
||||
* `tui` channel uses. If a channel has no listener, frames fall through to
|
||||
* the existing log-and-drop path so unknown channels stay non-fatal. */
|
||||
private channelListeners = new Map<string, (type: string, payload: Record<string, unknown>) => void>()
|
||||
|
||||
// Reconnect state ------------------------------------------------------
|
||||
private state: ReconnectState = 'idle'
|
||||
/** Count of reconnect ATTEMPTS (not successes). Resets to 0 on each
|
||||
* successful auth.ok. */
|
||||
private reconnectAttempt = 0
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
/** True once a reconnect has succeeded (auth.ok on a new socket). Used to
|
||||
* pick which auth handler path runs — subsequent auth.ok should fire
|
||||
* `'reconnected'`, not settle the initial `whenAuthResolved()` promise. */
|
||||
private reconnectInFlight = false
|
||||
|
||||
constructor(cfg: RelayTransportConfig) {
|
||||
super()
|
||||
this.setMaxListeners(0)
|
||||
this.cfg = cfg
|
||||
this.wsFactory = cfg.wsFactory ?? defaultWSFactory
|
||||
this.sessionToken = cfg.sessionToken ?? null
|
||||
}
|
||||
|
||||
onAuthSuccess(cb: (token: string, serverVersion: string | null, meta: AuthMeta) => void): void {
|
||||
this.authSuccessObservers.push(cb)
|
||||
}
|
||||
|
||||
/** Current reconnect-machine state — exposed for diagnostics and tests.
|
||||
* `idle` before `start()` / after terminal teardown; `connecting` during
|
||||
* initial connect; `connected` after auth.ok; `reconnecting` while a
|
||||
* backoff timer is armed. */
|
||||
getState(): ReconnectState {
|
||||
return this.state
|
||||
}
|
||||
|
||||
whenAuthResolved(): Promise<AuthOutcome> {
|
||||
if (this.authResolved && this.sessionToken) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
serverVersion: this.serverVersion,
|
||||
token: this.sessionToken,
|
||||
meta: this.authMeta
|
||||
})
|
||||
}
|
||||
if (this.tornDown) {
|
||||
return Promise.resolve({ ok: false, reason: this.authFailReason ?? 'disconnected before auth' })
|
||||
}
|
||||
|
||||
return new Promise<AuthOutcome>(resolve => {
|
||||
this.authSettlers.push(resolve)
|
||||
})
|
||||
}
|
||||
|
||||
private settleAuth(outcome: AuthOutcome): void {
|
||||
const settlers = this.authSettlers
|
||||
this.authSettlers = []
|
||||
for (const s of settlers) {
|
||||
try {
|
||||
s(outcome)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendResize(cols: number, rows: number): void {
|
||||
if (!this.ws || !this.authResolved) {
|
||||
return
|
||||
}
|
||||
this.sendEnvelope('tui', 'tui.resize', { cols, rows })
|
||||
}
|
||||
|
||||
getAuthInfo(): { serverVersion: string | null; token: string } | null {
|
||||
if (!this.authResolved || !this.sessionToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { serverVersion: this.serverVersion, token: this.sessionToken }
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.started) {
|
||||
return
|
||||
}
|
||||
this.started = true
|
||||
this.pendingExit = undefined
|
||||
this.authResolved = false
|
||||
this.tornDown = false
|
||||
this.authFailReason = null
|
||||
this.state = 'connecting'
|
||||
void this.connectOnce()
|
||||
}
|
||||
|
||||
/** Shared first-connect + reconnect path. Runs TOFU (wss://-only), opens
|
||||
* the WS, and wires lifecycle listeners. Any failure tears the transport
|
||||
* down; the close handler decides whether to re-schedule. */
|
||||
private async connectOnce(): Promise<void> {
|
||||
// Arm auth timeout per-attempt. Cancelled by auth.ok or by teardown.
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer)
|
||||
}
|
||||
this.authTimer = setTimeout(() => {
|
||||
if (this.authResolved) {
|
||||
return
|
||||
}
|
||||
const msg = `auth timed out after ${AUTH_TIMEOUT_MS}ms`
|
||||
this.pushLog(`[auth] ${msg} (url=${this.cfg.url})`)
|
||||
this.publish({ type: 'gateway.start_timeout', payload: {} })
|
||||
this.teardownSocket(-1, msg)
|
||||
}, AUTH_TIMEOUT_MS)
|
||||
|
||||
// TOFU: probe the TLS peer cert BEFORE the WebSocket handshake so we can
|
||||
// refuse to open the WS on a pin mismatch. No-op for ws://.
|
||||
if (isSecureUrl(this.cfg.url)) {
|
||||
try {
|
||||
await this.verifyOrCapturePin()
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
this.pushLog(`[tofu] ${msg}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[tofu] ${msg}` } })
|
||||
this.authFailReason = msg
|
||||
this.teardownSocket(-1, msg)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let ws: WSLike
|
||||
|
||||
try {
|
||||
ws = this.wsFactory(this.cfg.url)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
this.pushLog(`[ws] factory failed: ${msg}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[ws] ${msg}` } })
|
||||
this.teardownSocket(-1, msg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.ws = ws
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
this.pushLog(`[ws] open → ${this.cfg.url}`)
|
||||
this.sendAuth()
|
||||
})
|
||||
|
||||
ws.addEventListener('message', ev => {
|
||||
const raw = typeof ev.data === 'string' ? ev.data : String(ev.data ?? '')
|
||||
this.handleFrame(raw)
|
||||
})
|
||||
|
||||
ws.addEventListener('close', ev => {
|
||||
this.pushLog(`[ws] close code=${ev.code} reason=${ev.reason || ''}`)
|
||||
this.handleClose(ev.code, ev.reason)
|
||||
})
|
||||
|
||||
ws.addEventListener('error', ev => {
|
||||
const msg = ev?.message ?? 'WebSocket error'
|
||||
this.pushLog(`[ws] error: ${msg}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[ws] ${msg}` } })
|
||||
})
|
||||
}
|
||||
|
||||
/** Runs a short TLS probe to the target host:port, extracts the leaf
|
||||
* cert's SPKI sha256, and either captures it (first-time) or compares
|
||||
* against the stored pin. Rejects on mismatch or probe failure.
|
||||
*
|
||||
* Why a pre-WS probe? Node's built-in WebSocket (from undici) hides the
|
||||
* underlying TLSSocket, so we can't call `getPeerCertificate()` on the
|
||||
* live WS. A throwaway `tls.connect()` to the same host:port runs the
|
||||
* exact same TLS handshake (same SNI, same cert selection), costs
|
||||
* ~10–30ms once per connect, and surfaces `rawCert` directly. */
|
||||
private async verifyOrCapturePin(): Promise<void> {
|
||||
const u = new URL(this.cfg.url)
|
||||
const host = u.hostname
|
||||
const port = parseInt(u.port || '443', 10)
|
||||
const key = pinKey(this.cfg.url)
|
||||
const stored = await getSession(this.cfg.url)
|
||||
const expectedPin = stored?.certPinSha256 ?? null
|
||||
|
||||
const actualPin = await new Promise<string>((resolve, reject) => {
|
||||
const socket = tlsConnect({
|
||||
host,
|
||||
port,
|
||||
servername: host,
|
||||
// Let Node's default CA store run. Self-signed servers still TOFU-
|
||||
// pin on subsequent connects, but the first probe requires a valid
|
||||
// chain. If users need a self-signed flow, they can pre-seed a pin
|
||||
// via the Android app or a future `--trust-self-signed` flag.
|
||||
rejectUnauthorized: true
|
||||
})
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
socket.destroy(new Error(`TLS probe to ${key} timed out after ${TLS_PROBE_TIMEOUT_MS}ms`))
|
||||
}, TLS_PROBE_TIMEOUT_MS)
|
||||
timer.unref?.()
|
||||
|
||||
socket.once('secureConnect', () => {
|
||||
clearTimeout(timer)
|
||||
try {
|
||||
// `detailed=false` here; we only need `raw` (DER). We always pin
|
||||
// the leaf — intermediates rotate on CA renewal and would cause
|
||||
// spurious mismatches.
|
||||
const peer = socket.getPeerCertificate(false)
|
||||
const raw = (peer as unknown as { raw?: Buffer })?.raw
|
||||
|
||||
if (!raw || !Buffer.isBuffer(raw) || raw.length === 0) {
|
||||
socket.destroy()
|
||||
reject(new Error(`TLS probe to ${key} returned no peer cert`))
|
||||
|
||||
return
|
||||
}
|
||||
const pin = extractSpkiSha256(raw)
|
||||
socket.end()
|
||||
resolve(pin)
|
||||
} catch (e) {
|
||||
socket.destroy()
|
||||
reject(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
})
|
||||
|
||||
socket.once('error', err => {
|
||||
clearTimeout(timer)
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
|
||||
if (expectedPin) {
|
||||
if (!comparePins(expectedPin, actualPin)) {
|
||||
// Surface a user-friendly remediation path. The session file carries
|
||||
// the pin so a re-pair clears it; `saveSession(..., {certPin: null})`
|
||||
// also wipes it if a `--reset-pin` flag lands.
|
||||
throw new Error(
|
||||
`cert pin mismatch for ${key}: expected ${expectedPin}, got ${actualPin}. ` +
|
||||
`If this server was legitimately rotated, re-pair with \`hermes-relay pair\` ` +
|
||||
`to capture the new pin.`
|
||||
)
|
||||
}
|
||||
this.pushLog(`[tofu] pin match for ${key}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// First-time capture. Don't overwrite a token we don't have — merge via
|
||||
// saveSession (which preserves token/version if omitted isn't safe; we
|
||||
// need both, so capture only when we have a stored record to merge
|
||||
// into). If there's no stored session yet (initial pair path), the
|
||||
// pair flow itself will call saveSession with the pin.
|
||||
if (stored) {
|
||||
await saveSession(this.cfg.url, stored.token, stored.serverVersion, { certPin: actualPin })
|
||||
this.pushLog(`[tofu] captured pin for ${key}: ${actualPin}`)
|
||||
} else {
|
||||
// No stored session; we're on the initial pair path. The pair command
|
||||
// doesn't call verifyOrCapturePin directly (it runs with autoReconnect
|
||||
// off and saves the pin itself), so this branch is mostly defensive.
|
||||
this.pushLog(`[tofu] observed pin for ${key} (no stored session to merge into): ${actualPin}`)
|
||||
}
|
||||
}
|
||||
|
||||
private sendAuth() {
|
||||
if (!this.ws) {
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {}
|
||||
|
||||
if (this.cfg.pairingCode) {
|
||||
payload.pairing_code = this.cfg.pairingCode
|
||||
} else if (this.sessionToken) {
|
||||
// Prefer the live token over the initial cfg — after a reconnect,
|
||||
// the stored token is what we must present.
|
||||
payload.session_token = this.sessionToken
|
||||
} else if (this.cfg.sessionToken) {
|
||||
payload.session_token = this.cfg.sessionToken
|
||||
} else {
|
||||
const msg = 'RelayTransport: neither pairingCode nor sessionToken provided'
|
||||
this.pushLog(`[auth] ${msg}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: msg } })
|
||||
this.teardownSocket(-1, msg)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.cfg.deviceName) {
|
||||
payload.device_name = this.cfg.deviceName
|
||||
}
|
||||
|
||||
if (this.cfg.deviceId) {
|
||||
payload.device_id = this.cfg.deviceId
|
||||
}
|
||||
|
||||
if (typeof this.cfg.ttlSeconds === 'number') {
|
||||
payload.ttl_seconds = this.cfg.ttlSeconds
|
||||
}
|
||||
|
||||
this.sendEnvelope('system', 'auth', payload)
|
||||
}
|
||||
|
||||
private sendEnvelope(channel: string, type: string, payload: Record<string, unknown>, id?: string) {
|
||||
if (!this.ws) {
|
||||
return
|
||||
}
|
||||
const envelope = { channel, type, id: id ?? randomUUID(), payload }
|
||||
|
||||
try {
|
||||
this.ws.send(JSON.stringify(envelope))
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
this.pushLog(`[ws] send failed: ${msg}`)
|
||||
}
|
||||
}
|
||||
|
||||
private handleFrame(raw: string) {
|
||||
let msg: Record<string, unknown>
|
||||
|
||||
try {
|
||||
msg = JSON.parse(raw) as Record<string, unknown>
|
||||
} catch {
|
||||
this.pushLog(`[protocol] malformed frame: ${raw.slice(0, 240)}`)
|
||||
this.publish({ type: 'gateway.protocol_error', payload: { preview: raw.slice(0, 240) } })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const channel = typeof msg.channel === 'string' ? msg.channel : ''
|
||||
const type = typeof msg.type === 'string' ? msg.type : ''
|
||||
const payload = (msg.payload ?? {}) as Record<string, unknown>
|
||||
|
||||
if (channel === 'system') {
|
||||
this.handleSystem(type, payload)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (channel === 'tui') {
|
||||
this.handleTui(type, payload)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const listener = this.channelListeners.get(channel)
|
||||
if (listener) {
|
||||
try {
|
||||
listener(type, payload)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
this.pushLog(`[ws] ${channel} listener threw: ${msg}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.pushLog(`[ws] ignoring ${channel}:${type}`)
|
||||
}
|
||||
|
||||
/** Register a listener for raw envelopes on `channel`. Used by non-RPC
|
||||
* channels like `terminal` where payloads aren't wrapped in JSON-RPC.
|
||||
* Only one listener per channel — subsequent calls replace. Pass `null`
|
||||
* to unregister. */
|
||||
onChannel(
|
||||
channel: string,
|
||||
listener: ((type: string, payload: Record<string, unknown>) => void) | null
|
||||
): void {
|
||||
if (listener) {
|
||||
this.channelListeners.set(channel, listener)
|
||||
} else {
|
||||
this.channelListeners.delete(channel)
|
||||
}
|
||||
}
|
||||
|
||||
/** Send a raw envelope on an arbitrary channel. Caller owns the `type` +
|
||||
* `payload` shape — no RPC wrapping, no request/response tracking. Used by
|
||||
* the `shell` subcommand to pipe `terminal.input` / `terminal.resize`. */
|
||||
sendChannel(channel: string, type: string, payload: Record<string, unknown>): void {
|
||||
this.sendEnvelope(channel, type, payload)
|
||||
}
|
||||
|
||||
private handleSystem(type: string, payload: Record<string, unknown>) {
|
||||
if (type === 'auth.ok') {
|
||||
const isReconnect = this.reconnectInFlight
|
||||
this.authResolved = true
|
||||
this.state = 'connected'
|
||||
this.reconnectAttempt = 0
|
||||
this.reconnectInFlight = false
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer)
|
||||
this.authTimer = null
|
||||
}
|
||||
|
||||
const token = payload.session_token
|
||||
|
||||
if (typeof token === 'string') {
|
||||
this.sessionToken = token
|
||||
}
|
||||
const ver = payload.server_version
|
||||
|
||||
if (typeof ver === 'string') {
|
||||
this.serverVersion = ver
|
||||
}
|
||||
|
||||
// Capture the metadata fields the server added in v0.6.x+. All are
|
||||
// optional — older relays send only {session_token, server_version}.
|
||||
// Grants: Record<channel, epoch|null> per plugin/relay/server.py:2745.
|
||||
const rawGrants = payload.grants
|
||||
let grants: Record<string, number | null> | null = null
|
||||
if (rawGrants && typeof rawGrants === 'object' && !Array.isArray(rawGrants)) {
|
||||
const norm: Record<string, number | null> = {}
|
||||
for (const [k, v] of Object.entries(rawGrants as Record<string, unknown>)) {
|
||||
if (v === null) {
|
||||
norm[k] = null
|
||||
} else if (typeof v === 'number' && Number.isFinite(v)) {
|
||||
norm[k] = v
|
||||
}
|
||||
// Non-null non-number values (bools, strings) are silently dropped —
|
||||
// defensive against schema drift.
|
||||
}
|
||||
grants = norm
|
||||
}
|
||||
const rawTtl = payload.expires_at
|
||||
const ttlExpiresAt =
|
||||
rawTtl === null
|
||||
? null
|
||||
: typeof rawTtl === 'number' && Number.isFinite(rawTtl)
|
||||
? rawTtl
|
||||
: null
|
||||
const rawHint = payload.transport_hint
|
||||
const transportHint = typeof rawHint === 'string' ? rawHint : null
|
||||
this.authMeta = { grants, ttlExpiresAt, transportHint }
|
||||
|
||||
this.pushLog(
|
||||
`[auth] ok (server ${this.serverVersion ?? '?'}, transport=${transportHint ?? '?'}, ttl=${
|
||||
ttlExpiresAt === null ? 'never' : new Date(ttlExpiresAt * 1000).toISOString()
|
||||
})`
|
||||
)
|
||||
|
||||
if (this.sessionToken) {
|
||||
for (const cb of this.authSuccessObservers) {
|
||||
try {
|
||||
cb(this.sessionToken, this.serverVersion, this.authMeta)
|
||||
} catch {
|
||||
/* persistence failures must not take down the transport */
|
||||
}
|
||||
}
|
||||
|
||||
if (!isReconnect) {
|
||||
// First connect settles the original promise. Reconnects don't —
|
||||
// whenAuthResolved() is documented as first-settlement-only;
|
||||
// callers use the 'reconnected' event to re-attach.
|
||||
this.settleAuth({
|
||||
ok: true,
|
||||
serverVersion: this.serverVersion,
|
||||
token: this.sessionToken,
|
||||
meta: this.authMeta
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.sendEnvelope('tui', 'tui.attach', {
|
||||
cols: process.stdout.columns ?? 80,
|
||||
rows: process.stdout.rows ?? 24
|
||||
})
|
||||
|
||||
if (isReconnect) {
|
||||
// Clear any stale buffered events from the pre-drop socket — they'd
|
||||
// confuse the caller post-reconnect (e.g. a dangling tool.started
|
||||
// whose tool.completed never arrives).
|
||||
this.bufferedEvents = new CircularBuffer<GatewayEvent>(MAX_BUFFERED_EVENTS)
|
||||
try {
|
||||
this.cfg.onReconnect?.()
|
||||
} catch (e) {
|
||||
const m = e instanceof Error ? e.message : String(e)
|
||||
this.pushLog(`[reconnect] onReconnect threw: ${m}`)
|
||||
}
|
||||
this.emit('reconnected')
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'auth.fail') {
|
||||
const reason = typeof payload.reason === 'string' ? payload.reason : 'auth failed'
|
||||
this.authFailReason = reason
|
||||
this.pushLog(`[auth] fail: ${reason}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[auth] ${reason}` } })
|
||||
// Auth failures are terminal — server rejected credentials, so
|
||||
// reconnecting with the same token would just fail again.
|
||||
this.teardownFinal(-1, reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'ping') {
|
||||
this.sendEnvelope('system', 'pong', { ts: typeof payload.ts === 'number' ? payload.ts : Date.now() })
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private handleTui(type: string, payload: Record<string, unknown>) {
|
||||
if (type === 'tui.attached') {
|
||||
this.pushLog(`[tui] attached pid=${payload.pid ?? '?'} server=${payload.server_version ?? '?'}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'tui.rpc.response') {
|
||||
this.dispatchRpc(payload)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'tui.rpc.event') {
|
||||
const params = payload.params
|
||||
|
||||
const ev = asGatewayEvent(
|
||||
params && typeof params === 'object' && !Array.isArray(params)
|
||||
? (params as Record<string, unknown>)
|
||||
: null
|
||||
)
|
||||
|
||||
if (ev) {
|
||||
this.publish(ev)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (type === 'tui.error') {
|
||||
const message = typeof payload.message === 'string' ? payload.message : 'tui channel error'
|
||||
this.pushLog(`[tui] error: ${message}`)
|
||||
this.publish({ type: 'gateway.stderr', payload: { line: `[tui] ${message}` } })
|
||||
// Channel-level error — socket is probably unusable. Treat like a
|
||||
// drop; reconnect policy decides whether to retry.
|
||||
this.teardownSocket(-1, message)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
private dispatchRpc(msg: Record<string, unknown>) {
|
||||
const id = msg.id as string | undefined
|
||||
const p = id ? this.pending.get(id) : undefined
|
||||
|
||||
if (!p) {
|
||||
return
|
||||
}
|
||||
this.settle(p, msg.error ? this.toError(msg.error) : null, msg.result)
|
||||
}
|
||||
|
||||
private toError(raw: unknown): Error {
|
||||
const err = raw as { message?: unknown } | null | undefined
|
||||
|
||||
return new Error(typeof err?.message === 'string' ? err.message : 'request failed')
|
||||
}
|
||||
|
||||
private settle(p: Pending, err: Error | null, result: unknown) {
|
||||
clearTimeout(p.timeout)
|
||||
this.pending.delete(p.id)
|
||||
|
||||
if (err) {
|
||||
p.reject(err)
|
||||
} else {
|
||||
p.resolve(result)
|
||||
}
|
||||
}
|
||||
|
||||
private publish(ev: GatewayEvent) {
|
||||
if (this.subscribed) {
|
||||
this.emit('event', ev)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.bufferedEvents.push(ev)
|
||||
}
|
||||
|
||||
private pushLog(line: string) {
|
||||
this.logs.push(truncateLine(line))
|
||||
}
|
||||
|
||||
private rejectPending(err: Error) {
|
||||
for (const p of this.pending.values()) {
|
||||
clearTimeout(p.timeout)
|
||||
p.reject(err)
|
||||
}
|
||||
|
||||
this.pending.clear()
|
||||
}
|
||||
|
||||
private onTimeout = (id: string) => {
|
||||
const p = this.pending.get(id)
|
||||
|
||||
if (p) {
|
||||
this.pending.delete(id)
|
||||
p.reject(new Error(`timeout: ${p.method}`))
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the socket, rejects pending, but does NOT emit `'exit'`. Routes
|
||||
* the close through `handleClose` so the reconnect policy runs. Used when
|
||||
* the transport itself decides the current socket is dead (auth timeout,
|
||||
* tui.error, TOFU failure). */
|
||||
private teardownSocket(code: number | null, reason: string): void {
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer)
|
||||
this.authTimer = null
|
||||
}
|
||||
this.rejectPending(new Error(`relay disconnected: ${reason || 'unknown'}`))
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
this.handleClose(code ?? -1, reason)
|
||||
}
|
||||
|
||||
/** Unconditionally tears the transport down and emits `'exit'`. Used for
|
||||
* auth failures, exhausted reconnect attempts, and `kill()`. */
|
||||
private teardownFinal(code: number | null, reason: string): void {
|
||||
if (this.tornDown) {
|
||||
return
|
||||
}
|
||||
this.tornDown = true
|
||||
this.state = 'idle'
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer)
|
||||
this.authTimer = null
|
||||
}
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
this.reconnectTimer = null
|
||||
}
|
||||
|
||||
if (!this.authResolved) {
|
||||
this.settleAuth({ ok: false, reason: this.authFailReason ?? (reason || 'disconnected before auth') })
|
||||
}
|
||||
|
||||
this.rejectPending(new Error(`relay disconnected: ${reason || 'unknown'}`))
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (this.subscribed) {
|
||||
this.emit('exit', code)
|
||||
} else {
|
||||
this.pendingExit = code
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes a socket close through reconnect policy. Either schedules a
|
||||
* reconnect or escalates to `teardownFinal`. */
|
||||
private handleClose(code: number | null, reason: string): void {
|
||||
if (this.tornDown) {
|
||||
return
|
||||
}
|
||||
|
||||
// Drop in-flight pending — callers see the disconnect immediately.
|
||||
// They'll retry their own requests once they hear 'reconnected'.
|
||||
this.rejectPending(new Error(`relay disconnected: ${reason || 'unknown'}`))
|
||||
const ws = this.ws
|
||||
this.ws = null
|
||||
try {
|
||||
ws?.close()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
if (this.authTimer) {
|
||||
clearTimeout(this.authTimer)
|
||||
this.authTimer = null
|
||||
}
|
||||
|
||||
const canReconnect =
|
||||
this.cfg.autoReconnect === true &&
|
||||
this.authResolved && // only reconnect sessions that were once healthy
|
||||
!this.authFailReason && // terminal auth failure blocks reconnect
|
||||
(this.cfg.reconnectGate?.() ?? true) &&
|
||||
this.withinAttemptLimit()
|
||||
|
||||
if (!canReconnect) {
|
||||
this.teardownFinal(code, reason)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.scheduleReconnect(code, reason)
|
||||
}
|
||||
|
||||
private withinAttemptLimit(): boolean {
|
||||
const max = this.cfg.maxReconnectAttempts ?? 0
|
||||
if (max <= 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
return this.reconnectAttempt < max
|
||||
}
|
||||
|
||||
private backoffFor(attempt: number, reason: string): number {
|
||||
if (this.isRateLimited(reason)) {
|
||||
return RECONNECT_RATE_LIMITED_MS
|
||||
}
|
||||
const exp = Math.min(Math.max(attempt - 1, 0), RECONNECT_BACKOFF_CEIL)
|
||||
|
||||
return Math.min(RECONNECT_BASE_MS * 2 ** exp, RECONNECT_MAX_MS)
|
||||
}
|
||||
|
||||
private isRateLimited(reason: string): boolean {
|
||||
if (!reason) {
|
||||
return false
|
||||
}
|
||||
const lower = reason.toLowerCase()
|
||||
|
||||
return lower.includes('429') || lower.includes('rate-limited') || lower.includes('rate limited')
|
||||
}
|
||||
|
||||
private scheduleReconnect(_code: number | null, reason: string): void {
|
||||
this.state = 'reconnecting'
|
||||
this.reconnectAttempt += 1
|
||||
const delayMs = this.backoffFor(this.reconnectAttempt, reason)
|
||||
|
||||
this.pushLog(
|
||||
`[reconnect] attempt ${this.reconnectAttempt} in ${delayMs}ms (reason=${reason || 'unknown'})`
|
||||
)
|
||||
this.emit('reconnecting', { attempt: this.reconnectAttempt, delayMs })
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer)
|
||||
}
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null
|
||||
|
||||
if (this.tornDown) {
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check gate post-backoff — the token could have been purged
|
||||
// while we slept.
|
||||
if (this.cfg.reconnectGate && !this.cfg.reconnectGate()) {
|
||||
this.teardownFinal(-1, 'reconnect gate rejected')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.withinAttemptLimit()) {
|
||||
this.teardownFinal(-1, 'reconnect attempts exhausted')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectInFlight = true
|
||||
this.authResolved = false // gate RPC until the new socket auths
|
||||
this.state = 'connecting'
|
||||
void this.connectOnce()
|
||||
}, delayMs)
|
||||
|
||||
this.reconnectTimer.unref?.()
|
||||
}
|
||||
|
||||
drain() {
|
||||
this.subscribed = true
|
||||
|
||||
for (const ev of this.bufferedEvents.drain()) {
|
||||
this.emit('event', ev)
|
||||
}
|
||||
|
||||
if (this.pendingExit !== undefined) {
|
||||
const code = this.pendingExit
|
||||
this.pendingExit = undefined
|
||||
this.emit('exit', code)
|
||||
}
|
||||
}
|
||||
|
||||
getLogTail(limit = 20): string {
|
||||
return this.logs.tail(Math.max(1, limit)).join('\n')
|
||||
}
|
||||
|
||||
request<T = unknown>(method: string, params: Record<string, unknown> = {}): Promise<T> {
|
||||
if (!this.ws) {
|
||||
return Promise.reject(new Error('relay transport not connected'))
|
||||
}
|
||||
|
||||
if (!this.authResolved) {
|
||||
return Promise.reject(new Error('relay not authenticated yet'))
|
||||
}
|
||||
|
||||
const id = `r${++this.reqId}`
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timeout = setTimeout(this.onTimeout, REQUEST_TIMEOUT_MS, id)
|
||||
timeout.unref?.()
|
||||
|
||||
this.pending.set(id, {
|
||||
id,
|
||||
method,
|
||||
reject,
|
||||
resolve: v => resolve(v as T),
|
||||
timeout
|
||||
})
|
||||
|
||||
try {
|
||||
this.sendEnvelope('tui', 'tui.rpc.request', { id, jsonrpc: '2.0', method, params })
|
||||
} catch (e) {
|
||||
const pending = this.pending.get(id)
|
||||
|
||||
if (pending) {
|
||||
clearTimeout(pending.timeout)
|
||||
this.pending.delete(id)
|
||||
}
|
||||
|
||||
reject(e instanceof Error ? e : new Error(String(e)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
kill() {
|
||||
this.teardownFinal(null, 'kill')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/transport/Transport.ts. The CLI only
|
||||
// ships a RelayTransport (no LocalSubprocessTransport) — a CLI binary running
|
||||
// directly on the Hermes server is a niche case and would just be the Python
|
||||
// `hermes chat` command.
|
||||
|
||||
import type { GatewayEvent } from '../gatewayTypes.js'
|
||||
|
||||
/**
|
||||
* Abstract transport for the CLI/TUI ↔ `tui_gateway` JSON-RPC stream.
|
||||
* The Python server is endpoint-agnostic; all transports speak the same
|
||||
* line-delimited JSON-RPC 2.0 wire format. Only the carrier changes.
|
||||
*/
|
||||
export interface Transport {
|
||||
/** Drop in-flight state and start the carrier (open socket). */
|
||||
start(): void
|
||||
/** Send a JSON-RPC request; resolves with `result` or rejects with the server's error. */
|
||||
request<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>
|
||||
/** Attach event/exit listeners. */
|
||||
on(event: 'event', handler: (ev: GatewayEvent) => void): void
|
||||
on(event: 'exit', handler: (code: number | null) => void): void
|
||||
/** Detach. */
|
||||
off(event: 'event', handler: (ev: GatewayEvent) => void): void
|
||||
off(event: 'exit', handler: (code: number | null) => void): void
|
||||
/** Flush buffered events to listeners — call once after attaching. */
|
||||
drain(): void
|
||||
/** Tail of captured stderr / transport log for diagnostics. */
|
||||
getLogTail(limit?: number): string
|
||||
/** Stop the carrier. Safe to call multiple times. */
|
||||
kill(): void
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Vendored from hermes-agent/ui-tui/src/types.ts (feat/tui-transport-pluggable).
|
||||
// TUI-only view types (Msg, PanelData, DetailsMode, etc.) are omitted — this
|
||||
// CLI doesn't render panels or manage composer state.
|
||||
|
||||
export interface McpServerStatus {
|
||||
connected: boolean
|
||||
name: string
|
||||
tools: number
|
||||
transport: string
|
||||
}
|
||||
|
||||
export interface SessionInfo {
|
||||
cwd?: string
|
||||
mcp_servers?: McpServerStatus[]
|
||||
model: string
|
||||
release_date?: string
|
||||
skills: Record<string, string[]>
|
||||
tools: Record<string, string[]>
|
||||
update_behind?: number | null
|
||||
update_command?: string
|
||||
usage?: Usage
|
||||
version?: string
|
||||
}
|
||||
|
||||
export interface Usage {
|
||||
calls: number
|
||||
context_max?: number
|
||||
context_percent?: number
|
||||
context_used?: number
|
||||
cost_usd?: number
|
||||
input: number
|
||||
output: number
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface SlashCategory {
|
||||
name: string
|
||||
pairs: [string, string][]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
// `src/` is not shipped in the published tarball (see package.json `files`),
|
||||
// so .d.ts.map and .js.map files would reference paths that don't exist
|
||||
// for consumers. Turn them off for publish builds.
|
||||
"declarationMap": false,
|
||||
"sourceMap": false
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "tests"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
---
|
||||
name: hermes-relay-desktop-setup
|
||||
description: Install, pair, and troubleshoot the Hermes-Relay desktop CLI on Windows, macOS, or Linux. Agent-readable recipe with live local-machine diagnostics via the desktop_terminal tool.
|
||||
version: 0.1.0-experimental
|
||||
author: Axiom Labs
|
||||
license: MIT
|
||||
platforms: [windows, macos, linux]
|
||||
metadata:
|
||||
hermes:
|
||||
tags: [setup, install, hermes-relay, desktop, cli, thin-client, devops, experimental]
|
||||
category: devops
|
||||
homepage: https://github.com/Codename-11/hermes-relay
|
||||
related_skills: [hermes-relay-pair, hermes-relay-self-setup, hermes-relay-doctor]
|
||||
---
|
||||
|
||||
# Hermes-Relay Desktop CLI Setup
|
||||
|
||||
> **Experimental.** The desktop CLI at `desktop/` is a preview-grade thin client. Pairing, chat, shell (PTY pipe to the host), and local tool routing (`desktop_terminal` / `desktop_read_file` / `desktop_write_file` / `desktop_search_files` / `desktop_patch`) all work end-to-end. Daemon mode, multi-client routing, and code-signed binaries are the v1.0 polish — see the [ROADMAP](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track).
|
||||
|
||||
The [Hermes-Relay](https://github.com/Codename-11/hermes-relay) desktop CLI (`hermes-relay`) is a thin client that gives you remote access to a Hermes agent running on another machine. It pipes a full PTY shell (with the agent's native Ink TUI), streams structured chat events for scripting, and — uniquely — lets the remote agent execute tools **on your local machine** (read files, run shell commands, search the filesystem) through a round-trip over the same WSS relay the Android client uses. The agent brain stays on the host; your laptop is the hands.
|
||||
|
||||
## When to Use
|
||||
|
||||
Invoke this skill when any of the following happens:
|
||||
|
||||
- User runs the `/hermes-relay-desktop-setup` slash command.
|
||||
- User asks to "install the Hermes-Relay CLI", "set up hermes-relay on my laptop", "pair this desktop with my Hermes server", "hermes-relay shell isn't working", or anything equivalent.
|
||||
- User is on a fresh machine and wants to connect to an existing Hermes-Relay host.
|
||||
- User reports any of: `hermes-relay` command not found, Node version mismatch, auth timeouts, "No desktop client connected" errors, pasted-pairing-code mangled by bracketed paste, or desktop tools that "time out immediately" (seconds↔ms unit bug in pre-0.6 builds).
|
||||
|
||||
Do NOT use this skill to troubleshoot the relay **server** itself (that's `hermes-relay-self-setup`) or the Android client (that's `hermes-relay-self-setup` too). This skill is desktop-CLI-specific, running on the user's laptop/workstation.
|
||||
|
||||
## Unique Capability: Live Local Diagnostics
|
||||
|
||||
**This skill can invoke `desktop_terminal` to run commands on the user's own machine** — a capability the Android skill can't match. Use it aggressively during install / pairing / troubleshooting:
|
||||
|
||||
```
|
||||
User: "Install the hermes-relay CLI"
|
||||
→ [desktop_terminal] node --version
|
||||
Result: v20.10.0 → Too old
|
||||
→ "Node 21+ required. Upgrade via https://nodejs.org, then I'll retry."
|
||||
→ User upgrades
|
||||
→ [desktop_terminal] node --version → v22.0.1 OK
|
||||
→ [desktop_terminal] npm install -g @hermes-relay/cli
|
||||
→ [desktop_terminal] hermes-relay --version → 0.2.0 Success
|
||||
```
|
||||
|
||||
**Never guess the user's state — measure it.** `desktop_terminal` is cheap; one call per check is the right cadence.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **The Hermes-Relay server is running on the host** and reachable from this machine. Verify:
|
||||
```bash
|
||||
curl -s http://<host>:8767/health
|
||||
```
|
||||
Expect `{"status":"ok","version":"0.6.0"}` or later. If it fails, the user's server is down — stop here and run `/hermes-relay-self-setup` or `/hermes-relay-doctor` on the host instead.
|
||||
|
||||
2. **One of:**
|
||||
- Node.js **≥21** on this machine (for the built-in global `WebSocket`). Verify `node --version` ≥ v21. Older Node refuses to run.
|
||||
- A prebuilt `hermes-relay` binary from GitHub Releases (no Node required). Use this path on Windows desktops where Node isn't already installed.
|
||||
|
||||
3. **Network path from here to `<host>:8767`.** If the user is roaming (work + home + coffee shop), multi-endpoint pairing (ADR 24) handles it automatically — no prerequisite here.
|
||||
|
||||
## Procedure
|
||||
|
||||
### A. Install
|
||||
|
||||
Three install methods in decreasing order of Node-flexibility. Pick the first the user can do.
|
||||
|
||||
#### A1. Binary (Windows — recommended, no Node required)
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
- Downloads `hermes-relay-win-x64.exe` from the latest GitHub Release.
|
||||
- Verifies SHA256 against the published `SHA256SUMS.txt`.
|
||||
- Installs to `%USERPROFILE%\.hermes\bin\hermes-relay.exe` and adds that directory to the **user** PATH.
|
||||
- The binary is currently **unsigned** (experimental phase). Windows SmartScreen may show "Windows protected your PC" on first launch. Click "More info" → "Run anyway", or pre-unblock via `Unblock-File`.
|
||||
|
||||
Pin a version: `$env:HERMES_RELAY_VERSION='desktop-v0.3.0-alpha.1'; irm ... | iex`.
|
||||
|
||||
Uninstall: delete `%USERPROFILE%\.hermes\bin\hermes-relay.exe` and remove the PATH entry from **System Properties → Environment Variables → User → Path**.
|
||||
|
||||
#### A2. Binary (macOS / Linux — recommended, no Node required)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
- Detects OS/arch (`linux-x64`, `darwin-x64`, `darwin-arm64`).
|
||||
- Downloads the matching binary + SHA256 checksum from the latest release.
|
||||
- Installs to `$HOME/.hermes/bin/hermes-relay` and appends that path to `~/.bashrc` or `~/.zshrc` if not already present.
|
||||
- **macOS Gatekeeper** will quarantine the unsigned binary on first launch. If the user sees "cannot be opened because the developer cannot be verified", run once:
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine ~/.hermes/bin/hermes-relay
|
||||
```
|
||||
|
||||
Pin a version: `HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1 curl -fsSL ... | sh`.
|
||||
|
||||
Uninstall: `rm ~/.hermes/bin/hermes-relay` and remove the PATH line from the shell rc.
|
||||
|
||||
#### A3. npm / npx (any platform with Node ≥21)
|
||||
|
||||
Once we publish to npm (not yet — experimental phase), this will be:
|
||||
```bash
|
||||
npm install -g @hermes-relay/cli
|
||||
```
|
||||
|
||||
For now, install from a clone:
|
||||
```bash
|
||||
git clone https://github.com/Codename-11/hermes-relay
|
||||
cd hermes-relay/desktop
|
||||
npm install
|
||||
npm run build
|
||||
npm link
|
||||
```
|
||||
|
||||
The `npm link` step drops `hermes-relay` into your npm global bin directory. Add that to your PATH if it isn't already (`npm config get prefix` shows where).
|
||||
|
||||
### B. Verify the install
|
||||
|
||||
Run a one-off `--version` check:
|
||||
|
||||
```bash
|
||||
hermes-relay --version
|
||||
```
|
||||
|
||||
Expect `0.2.0` or later (depending on which release is current).
|
||||
|
||||
If the command is not found:
|
||||
- **Windows**: open a **new** PowerShell (PATH updates don't apply retroactively), or run `$env:Path += ';C:\Users\<you>\.hermes\bin'` temporarily.
|
||||
- **macOS / Linux**: source the rc file (`source ~/.zshrc`) or open a new terminal.
|
||||
- **npm path missing**: `export PATH="$(npm config get prefix)/bin:$PATH"`, then add permanently.
|
||||
|
||||
### C. Pair
|
||||
|
||||
On the **relay host**, mint a fresh 6-character pairing code:
|
||||
|
||||
```bash
|
||||
# On the host (SSH in first if remote)
|
||||
hermes-pair --ttl 600
|
||||
```
|
||||
|
||||
Or, from any Hermes chat session on the host: `/hermes-relay-pair`.
|
||||
|
||||
You'll get a line like `Code: F3W7EY (valid 10 min)`.
|
||||
|
||||
On **this machine** (your laptop):
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
The CLI prompts:
|
||||
```
|
||||
Pairing code (6 chars): _
|
||||
```
|
||||
|
||||
Type or paste `F3W7EY`. The CLI will:
|
||||
1. Disable bracketed-paste mode during the prompt (so pasted codes aren't wrapped in `\x1b[200~...`).
|
||||
2. Strip ANSI + control chars defensively, clean to `A-Z0-9`, clamp to 6 chars.
|
||||
3. Echo `→ using code: F3W7EY` as a sanity check.
|
||||
4. Connect to the relay, exchange the code for a session token, persist to `~/.hermes/remote-sessions.json` (mode 0600).
|
||||
|
||||
On success:
|
||||
```
|
||||
✓ Paired. Token stored in ~/.hermes/remote-sessions.json
|
||||
Server: 0.6.0
|
||||
Relay: ws://<host>:8767
|
||||
Route: lan
|
||||
```
|
||||
|
||||
Subsequent `hermes-relay` invocations reuse the stored token — no re-pair needed.
|
||||
|
||||
### D. First smoke — structured event chat
|
||||
|
||||
Quickest sanity check:
|
||||
|
||||
```bash
|
||||
hermes-relay chat "what time is it?"
|
||||
```
|
||||
|
||||
The CLI streams back the agent's reply. Stderr gets `Connecting... / Connected via LAN (plain) — server 0.6.0`; stdout gets the answer. Redirecting stdout to a file (`... > out.txt`) captures **only** the reply.
|
||||
|
||||
### E. Full Hermes TUI — shell mode
|
||||
|
||||
For the complete local-Hermes experience (the Axiom-Labs banner, Victor, the Ink status bar, slash commands, everything):
|
||||
|
||||
```bash
|
||||
hermes-relay
|
||||
```
|
||||
|
||||
(Bare invocation is shorthand for `hermes-relay shell`.)
|
||||
|
||||
The CLI:
|
||||
1. Connects over WSS.
|
||||
2. Prints `Connected via LAN (plain) — server 0.6.0`.
|
||||
3. Prompts for **one-time local-tool consent** (see §F).
|
||||
4. Attaches to the relay's `terminal` channel, which spawns (or re-attaches) a tmux session on the host.
|
||||
5. Sends `clear; exec hermes\n` after a 350 ms settle — tmux's login shell replaces itself with `hermes`, which renders its native Ink TUI straight through to your terminal.
|
||||
|
||||
**Escape keys:**
|
||||
- `Ctrl+A .` — detach (tmux preserved on the host — next `hermes-relay` re-attaches with full state)
|
||||
- `Ctrl+A k` — kill the tmux session (destructive; fresh hermes on next run)
|
||||
- `Ctrl+A Ctrl+A` — forward a literal `Ctrl+A` (for nested tmux)
|
||||
- `Ctrl+C` — passes through to `hermes` (interrupts the agent, not the client)
|
||||
|
||||
### F. Desktop tool consent — **one-time per relay URL**
|
||||
|
||||
On first `shell` or `chat` with tools enabled, the CLI shows:
|
||||
|
||||
```
|
||||
Desktop tools are about to be exposed to the remote Hermes agent.
|
||||
The agent can read/write files, run shell commands, and search your filesystem.
|
||||
This is AGENT-CONTROLLED access. Only use with trusted Hermes installs.
|
||||
Type 'yes' to enable, or rerun with --no-tools to disable.
|
||||
> _
|
||||
```
|
||||
|
||||
Only `yes` (case-insensitive) enables. Anything else (`y`, `no`, Enter, Ctrl+C) denies. Consent is stored per-URL in `~/.hermes/remote-sessions.json` as `toolsConsented: true` and sticks across sessions.
|
||||
|
||||
**Kill-switches:**
|
||||
- `--no-tools` on any subcommand suppresses the router entirely — the agent sees "no desktop client for tool X".
|
||||
- `hermes-relay pair --reset-pin` (coming) wipes consent + cert pin; re-pair re-prompts.
|
||||
|
||||
### G. Verify end-to-end tool routing
|
||||
|
||||
Inside `hermes-relay shell`, at the agent prompt:
|
||||
|
||||
```
|
||||
use desktop_terminal to run "hostname" and show me the raw JSON output
|
||||
```
|
||||
|
||||
Expect the agent to render:
|
||||
```json
|
||||
{
|
||||
"stdout": "<YOUR-LOCAL-HOSTNAME>\r\n",
|
||||
"stderr": "",
|
||||
"exit_code": 0,
|
||||
"duration_ms": 70
|
||||
}
|
||||
```
|
||||
|
||||
The key signal: `stdout` contains **your local hostname**, not the server's. That proves the call routed from hermes → relay → WSS → this machine → shell exec → response back. If the agent instead sees the server's hostname (e.g., `Docker-Server`), the tool is running on the server — check consent and `/desktop/_ping` from the host:
|
||||
|
||||
```bash
|
||||
# On the host
|
||||
curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
|
||||
```
|
||||
|
||||
`{"connected": true, "advertised_tools": [...]}` means routing works; the issue is upstream in Hermes. `{"connected": false}` means the client isn't attached or consent was denied.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### `hermes-relay: command not found`
|
||||
Your shell hasn't picked up the PATH change. Options:
|
||||
- Windows: open a **new** terminal window.
|
||||
- macOS/Linux: `source ~/.bashrc` (or `~/.zshrc`) in the current session.
|
||||
- Or run with the full path one time: `~/.hermes/bin/hermes-relay --version`.
|
||||
|
||||
### `Node version 20 too old. Need >=21`
|
||||
You're on the npm/source install path with old Node. Either:
|
||||
- Upgrade Node: https://nodejs.org → LTS or current, whichever is ≥21.
|
||||
- Switch to the **binary** install (A1 / A2) which needs no Node.
|
||||
|
||||
### `auth timed out after 15000ms`
|
||||
The relay subprocess takes 15–30 s on first attach (hermes agent full-init). Bump the timeout for slow first connects:
|
||||
```bash
|
||||
HERMES_RELAY_AUTH_TIMEOUT_MS=30000 hermes-relay shell
|
||||
```
|
||||
|
||||
### `relay rejected credentials: auth failed` / `disconnected before auth`
|
||||
Stored token expired, was revoked on the host, or was typo'd during pairing. Re-pair:
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
### Pasting the pairing code produces weird chars (`[200~F3W7EY[201~`)
|
||||
Your terminal's bracketed-paste mode wasn't respected by readline. Two workarounds:
|
||||
- Type the 6 chars manually (fastest).
|
||||
- Pass as a positional: `hermes-relay pair F3W7EY --remote ws://...` — skips the prompt entirely.
|
||||
|
||||
The CLI already disables bracketed paste during the prompt (+ strips escapes defensively), but some terminals ignore the disable flag.
|
||||
|
||||
### `No desktop client connected` when the agent tries a tool
|
||||
The CLI isn't actively connected (or `--no-tools` is active). Start a session:
|
||||
```bash
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Leave it running in one terminal. The agent can now fire tools into it. If you want the CLI connected **without** a visible shell, the forthcoming `hermes-relay daemon` subcommand will be the answer (v1.0 target).
|
||||
|
||||
### `timed out after 30ms` on any desktop tool call
|
||||
You're running a pre-fix build. The unit-mismatch bug (Python sent `timeout` as seconds, Node treated as ms) was fixed in the commit that landed same-day as the initial Phase B release. Upgrade:
|
||||
- Binary: re-run the install one-liner (picks up latest).
|
||||
- npm/source: `cd hermes-relay/desktop && git pull && npm run build`.
|
||||
|
||||
### Windows SmartScreen warning on first launch
|
||||
Unsigned binary (expected during experimental phase). Click "More info" → "Run anyway", or pre-allow via PowerShell:
|
||||
```powershell
|
||||
Unblock-File "$env:USERPROFILE\.hermes\bin\hermes-relay.exe"
|
||||
```
|
||||
|
||||
### macOS "can't be opened because the developer cannot be verified"
|
||||
Quarantine xattr. Clear once:
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine ~/.hermes/bin/hermes-relay
|
||||
```
|
||||
|
||||
### `hermes-relay shell` drops me into bash, not hermes
|
||||
The post-attach `exec hermes` injection didn't happen. Usually: `--raw` was set, or the previous tmux session still has a different shell. Try:
|
||||
```bash
|
||||
hermes-relay shell --exec hermes
|
||||
# or kill the old session:
|
||||
# (inside shell) Ctrl+A k
|
||||
# then retry
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
### Tool calls hang, then fail with `aborted`
|
||||
The 30-second router ceiling fired. Most likely the handler (especially `desktop_terminal`) is running a command that blocks on stdin or doesn't terminate. Kill the shell command from inside Hermes, or explicitly set a shorter `timeout` in the tool args: `desktop_terminal("slow_cmd", timeout=5)`.
|
||||
|
||||
## Verification (final)
|
||||
|
||||
Before declaring the install healthy, confirm:
|
||||
|
||||
1. **Install**: `hermes-relay --version` returns a version string (via `desktop_terminal` if the agent is already paired on another machine, else ask the user).
|
||||
2. **Pairing**: `hermes-relay status` shows one entry for the relay URL with `expires` in the future and `grants` populated (`chat`, `terminal`, `tui`).
|
||||
3. **Chat**: `hermes-relay "what time is it?"` returns a streamed reply.
|
||||
4. **Shell**: `hermes-relay` drops into the full Hermes TUI. The Axiom-Labs banner appears; the bottom-right clock shows the **server's** time (confirms the shell is on the host).
|
||||
5. **Tool routing** (if consent granted): ask the agent `use desktop_terminal to run hostname` — result must contain the **client's** hostname, not the server's.
|
||||
|
||||
If all five pass: the client is fully operational and the user can start a real work session.
|
||||
|
||||
## Agent Flow — Live Diagnostic Recipe
|
||||
|
||||
When the user reports a problem, follow this sequence using `desktop_terminal`:
|
||||
|
||||
```
|
||||
1. [desktop_terminal] hermes-relay --version
|
||||
→ if not found: PATH issue → walk user through PATH fix
|
||||
→ if found but old: upgrade via install.sh/ps1
|
||||
|
||||
2. [desktop_terminal] node --version (only on npm/source installs)
|
||||
→ if < 21: upgrade Node OR switch to binary install
|
||||
|
||||
3. [desktop_terminal] hermes-relay status
|
||||
→ if no entry for expected URL: never paired / token purged
|
||||
→ if expired: re-pair
|
||||
|
||||
4. [desktop_terminal] curl -s http://<host>:8767/health
|
||||
→ 200 + "ok": server healthy
|
||||
→ timeout / refused: server down OR firewall OR wrong URL
|
||||
|
||||
5. [desktop_terminal] hermes-relay tools --remote ws://<host>:8767 --non-interactive
|
||||
→ 46+ toolsets, 17 enabled: tool plumbing intact
|
||||
→ 0 toolsets: hermes-gateway not registering plugin — run /hermes-relay-self-setup on host
|
||||
|
||||
6. [desktop_terminal] cat ~/.hermes/remote-sessions.json
|
||||
→ redact the token field before echoing to the user
|
||||
→ confirm toolsConsented: true if routing is expected
|
||||
```
|
||||
|
||||
Each step is ~1 second; the user gets real answers based on real state, not guesses.
|
||||
|
||||
## Safety
|
||||
|
||||
- **Grant tool consent only to trusted Hermes servers.** Once consented, the agent on that URL can read/write/search your filesystem and run shell commands. Review the URL carefully before typing `yes`.
|
||||
- **Never pipe `curl | sh` from URLs you don't recognize.** The install scripts live at `github.com/Codename-11/hermes-relay` — verify before piping.
|
||||
- **Binaries are unsigned during experimental phase.** SmartScreen (Windows) and Gatekeeper (macOS) warnings are expected. Signing comes with the v1.0 release.
|
||||
- **Session tokens are secrets.** `~/.hermes/remote-sessions.json` is mode 0600 for a reason. Don't commit it, don't share it, don't paste it into chat.
|
||||
- **`--reveal-tokens` on `hermes-relay status --json`** prints full tokens — use only when you need them for scripted re-auth, never in a streamed session or shared terminal.
|
||||
- **Never restart `hermes-gateway` on the host without asking the user.** It interrupts every active chat across every client.
|
||||
- **Destructive tool calls** (`desktop_write_file` overwriting, `desktop_terminal` running `rm -rf`, `desktop_patch` rewriting files) are agent-initiated — if you (the agent) are about to request one, confirm with the user first. There's no client-side confirmation modal yet.
|
||||
@@ -49,6 +49,7 @@ export default defineConfig({
|
||||
nav: [
|
||||
{ text: 'Guide', link: '/guide/' },
|
||||
{ text: 'Features', link: '/features/' },
|
||||
{ text: 'Desktop CLI', link: '/desktop/' },
|
||||
{ text: 'Architecture', link: '/architecture/' },
|
||||
{ text: 'Reference', link: '/reference/api' },
|
||||
{ text: 'GitHub', link: 'https://github.com/Codename-11/hermes-relay' },
|
||||
@@ -106,6 +107,22 @@ export default defineConfig({
|
||||
],
|
||||
},
|
||||
],
|
||||
'/desktop/': [
|
||||
{
|
||||
// Sidebar header carries the experimental marker so it's visible
|
||||
// on every page in the section, not just the overview.
|
||||
text: 'Desktop CLI · Experimental',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/desktop/' },
|
||||
{ text: 'Installation', link: '/desktop/installation' },
|
||||
{ text: 'Pairing', link: '/desktop/pairing' },
|
||||
{ text: 'Subcommands', link: '/desktop/subcommands' },
|
||||
{ text: 'Local tool routing', link: '/desktop/tools' },
|
||||
{ text: 'Troubleshooting', link: '/desktop/troubleshooting' },
|
||||
{ text: 'FAQ', link: '/desktop/faq' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
socialLinks: [
|
||||
|
||||
@@ -7,12 +7,14 @@ import InstallSection from './components/InstallSection.vue';
|
||||
import HeroDemo from './components/HeroDemo.vue';
|
||||
import FeatureMatrix from './components/FeatureMatrix.vue';
|
||||
import SphereMark from './components/SphereMark.vue';
|
||||
import ExperimentalBadge from './components/ExperimentalBadge.vue';
|
||||
|
||||
export default {
|
||||
extends: DefaultTheme,
|
||||
enhanceApp({ app }: { app: any }) {
|
||||
app.component('HermesFlow', HermesFlow);
|
||||
app.component('FeatureMatrix', FeatureMatrix);
|
||||
app.component('ExperimentalBadge', ExperimentalBadge);
|
||||
},
|
||||
Layout() {
|
||||
return h(DefaultTheme.Layout, null, {
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# FAQ <ExperimentalBadge />
|
||||
|
||||
## Is this just SSH?
|
||||
|
||||
No. It's closer to **"give the agent on my server a set of hands on my local machine"**. Three differences from SSH:
|
||||
|
||||
1. **The agent drives the tools, not you.** Hermes picks when to call `desktop_read_file` or `desktop_terminal` based on what you asked it to do. SSH hands the keyboard to a human; this hands it to the agent.
|
||||
2. **The connection carries chat + shell + tools simultaneously.** SSH does shell; this does shell *and* a structured JSON-RPC event stream *and* a reverse tool channel, on one socket.
|
||||
3. **Pairing is one-time and tokenized.** No passwords, no keys to distribute, no SSH-config to maintain. The pair exchange is a 6-char code valid for 10 minutes, and the resulting session token is revocable from the server.
|
||||
|
||||
You'd use SSH *instead* of this if you want to type commands yourself. You'd use this *instead* of SSH if you want an LLM to drive and coordinate multi-step work where some steps happen remotely and some happen locally.
|
||||
|
||||
## Can I use it offline?
|
||||
|
||||
No. The whole point is the agent lives on a server reachable over the network. If your laptop is offline, you can't reach the Hermes host. (You can of course run Hermes itself on your laptop and skip the relay — but then the desktop CLI is redundant.)
|
||||
|
||||
## How do I revoke access from one of my machines?
|
||||
|
||||
Two ways:
|
||||
|
||||
1. **From the device**: delete the entry in `~/.hermes/remote-sessions.json`, or remove the whole file.
|
||||
2. **From the server** (destroys the session so even a compromised device can't reuse the token):
|
||||
```bash
|
||||
hermes-relay devices revoke <token-prefix>
|
||||
```
|
||||
The prefix is shown in `hermes-relay devices`. Use at least 8 chars to be unambiguous.
|
||||
|
||||
## Does it work over Tailscale?
|
||||
|
||||
Yes. Pair against your tailnet hostname: `hermes-relay pair --remote wss://hermes.<tailnet>.ts.net:8767`. Use `wss://` if you've enabled `tailscale serve` for managed TLS + ACL-based identity.
|
||||
|
||||
If your Hermes host is reachable via **multiple** routes (LAN + Tailscale + public), use the [multi-endpoint QR flow](./pairing.md#multi-endpoint-pairing-adr-24) so the CLI auto-picks the best reachable endpoint as you move between networks.
|
||||
|
||||
## What happens when my laptop goes to sleep / network drops?
|
||||
|
||||
- **In `shell` mode**: the tmux session persists on the server. The WSS disconnects when the network goes away. When you wake up and re-run `hermes-relay shell`, it re-attaches to the same tmux session with the hermes process still running.
|
||||
- **In `chat` mode**: the in-flight turn gets torn down; the server's session state persists. Next `hermes-relay chat` resumes cleanly.
|
||||
- **Tool calls that were in-flight when the drop happened**: the handler's AbortController fires, the child process is SIGKILL'd, and the relay-side Python handler sees a disconnect error.
|
||||
|
||||
Auto-reconnect with exponential backoff (1 s → 30 s, 5 min on 429) is built into the transport — short drops heal transparently. Longer drops you'll want to re-run the command.
|
||||
|
||||
## Can I pipe stdin?
|
||||
|
||||
Yes, in `chat` mode:
|
||||
|
||||
```bash
|
||||
cat README.md | hermes-relay "summarize this"
|
||||
```
|
||||
|
||||
The CLI reads stdin to EOF and sends it as a single prompt. Good for CI / scripts / one-shot queries. `shell` mode requires an interactive TTY (it needs raw-mode stdin to forward every keystroke through the PTY).
|
||||
|
||||
## What's the maximum tool execution time?
|
||||
|
||||
30 seconds, enforced by the router's `AbortController`. Per-call overrides are allowed via the `timeout` arg (seconds):
|
||||
|
||||
```
|
||||
use desktop_terminal to run "long_cmd", timeout=60
|
||||
```
|
||||
|
||||
But clamped to a 10-minute absolute ceiling. Anything longer should be broken into multiple calls or run as a background job (with the agent polling).
|
||||
|
||||
## How do I share a session between two machines?
|
||||
|
||||
You can't — each `(URL, device)` gets its own stored session token. Pair each machine separately. Same agent server can see both as distinct paired devices (`hermes-relay devices` lists all).
|
||||
|
||||
## What's the difference between `shell` and `chat` modes?
|
||||
|
||||
| Mode | Rendering | Stdin | Best for |
|
||||
|------|-----------|-------|----------|
|
||||
| `shell` | PTY pipe of the host's literal `hermes` CLI (Ink TUI) | Raw-mode forward | Interactive conversation with agent, slash commands, rich rendering |
|
||||
| `chat` | CLI's own structured-event renderer (plain lines, optional `--json`) | One-shot / REPL / piped | Scripting, CI, automation, machine-readable transcripts |
|
||||
|
||||
## Why the experimental badge?
|
||||
|
||||
Because:
|
||||
|
||||
- Binaries are unsigned (SmartScreen/Gatekeeper warnings).
|
||||
- `hermes-relay daemon` mode hasn't shipped yet (currently tools only work while a shell/chat is open).
|
||||
- Multi-client routing is single-client MVP (one desktop per relay session).
|
||||
- Wire protocol may change between releases.
|
||||
- No npm publish yet — installation is binary-via-curl or source clone.
|
||||
|
||||
Everything currently shipped works — pairing, shell, chat, tools, devices, status. It's "experimental" in the sense of "the stability contract isn't promised yet," not "expect it to break."
|
||||
|
||||
## Does it log my tool calls?
|
||||
|
||||
Server-side: yes. The relay's `desktop` channel keeps a rolling 100-command audit buffer (`/desktop/activity`) with tool name, request ID, latency. The contents of `desktop_terminal` commands and `desktop_read_file` paths are in that buffer.
|
||||
|
||||
Client-side: no. The CLI doesn't write a separate audit log — just stdout/stderr of the current session.
|
||||
|
||||
If you care about this (you probably should), pair only with Hermes hosts you control.
|
||||
|
||||
## How is this different from MCP?
|
||||
|
||||
[MCP](https://modelcontextprotocol.io/) is a protocol for exposing tools to LLMs over stdio or SSE. It works — but each tool needs its own MCP server process running somewhere the agent can reach.
|
||||
|
||||
Hermes's desktop tools are:
|
||||
- **Zero-config** for the end user — pair once, tools are there.
|
||||
- **Bidirectional with the existing session transport** — same WSS that carries chat, not a separate port.
|
||||
- **Scoped by pairing** — the agent only has tools for the specific machines you've explicitly paired.
|
||||
|
||||
You can totally use MCP alongside Hermes-Relay — the agent sees MCP tools (under the `hermes-acp` / `hermes-api-server` toolsets etc.) and `desktop_*` tools in the same registry.
|
||||
|
||||
## When will there be a daemon mode?
|
||||
|
||||
v1.0. Tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track). The daemon will:
|
||||
|
||||
- Run in the background with no visible shell.
|
||||
- Advertise desktop tools so the agent can reach you anytime you're on the machine.
|
||||
- Install as a Windows service / systemd user unit / launchd plist so it auto-starts on login.
|
||||
|
||||
Until then: keep a `hermes-relay shell` open in a spare terminal tab for the agent to dispatch tool calls into.
|
||||
|
||||
## Can multiple people use the same Hermes host from different desktop CLIs?
|
||||
|
||||
Right now the server tracks a single "active" desktop client per relay — if you pair from two machines, the most recently connected wins routing. v1.0 adds per-session-token routing (each hermes session binds to a specific desktop client) so multi-client is clean.
|
||||
|
||||
For now: one desktop attached at a time. Or two if you pair them with different tokens and only one is connected.
|
||||
|
||||
## Is there voice mode?
|
||||
|
||||
Not in the desktop CLI. The Android client has voice mode. The CLI is text-first.
|
||||
|
||||
## Is there a Windows-on-ARM build?
|
||||
|
||||
Not yet — Bun's `bun-windows-arm64` target is still experimental as of this writing. When it stabilizes we'll add it to the release matrix. In the meantime, ARM64 Windows users can run the x64 build under emulation.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Desktop CLI <ExperimentalBadge />
|
||||
|
||||
The **Hermes-Relay Desktop CLI** (`hermes-relay`) is a thin command-line client for remote Hermes agent access. One install gets you three ways to talk to a Hermes server running anywhere else on your network — locally, on a Tailscale-tailed box, or behind a public URL.
|
||||
|
||||
::: warning Experimental phase
|
||||
Binaries are unsigned (SmartScreen/Gatekeeper warnings are expected). Daemon mode, multi-client routing, and code-signed releases land with v1.0. Safe to use — just expect occasional friction and [file an issue](https://github.com/Codename-11/hermes-relay/issues) when you hit one.
|
||||
:::
|
||||
|
||||
## What it does
|
||||
|
||||
| Mode | Command | Best for |
|
||||
|------|---------|----------|
|
||||
| **Shell** (default) | `hermes-relay` | Full Hermes Ink TUI over a PTY — banner, Victor, slash commands, the whole experience. Uses tmux on the host so disconnects preserve state. |
|
||||
| **Chat (structured)** | `hermes-relay chat "<prompt>"` / `hermes-relay "<prompt>"` | Scriptable, one-shot, pipes stdin. `--json` emits `GatewayEvent`s per line for `jq` / automation. |
|
||||
| **Tools** | Automatic, in-session | The remote agent can call `desktop_read_file`, `desktop_write_file`, `desktop_terminal`, `desktop_search_files`, `desktop_patch` — **executed on your machine**, not the server. One-time per-URL consent gate. |
|
||||
| **Pair / Status / Tools / Devices** | `hermes-relay pair` / `status` / `tools` / `devices` | First-time setup, session inventory, server-side toolset introspection, paired-device management. |
|
||||
|
||||
## Quick start
|
||||
|
||||
::: code-group
|
||||
|
||||
```powershell [Windows]
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay
|
||||
```
|
||||
|
||||
```bash [macOS / Linux]
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
hermes-relay
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
See **[Installation](./installation.md)** for the full walkthrough and **[Pairing](./pairing.md)** for minting a 6-char code on the server.
|
||||
|
||||
## Why both shell AND chat modes?
|
||||
|
||||
They're not the same thing:
|
||||
|
||||
- **`shell`** pipes the host's actual `hermes` CLI through a PTY. You see exactly what `ssh bailey@hermes-host hermes` would show — same banner, same skin, same slash commands. Best for interactive use.
|
||||
- **`chat`** speaks the relay's structured `tui` channel (JSON-RPC-over-WSS), renders events as plain lines. Scriptable, pipeable, survives non-TTY environments. Best for automation / CI / one-shot queries.
|
||||
|
||||
Most users want `shell`. If you're writing a script, use `chat --json`.
|
||||
|
||||
## Local tool routing (the big deal)
|
||||
|
||||
The agent on the server can reach through the relay and run tools on **your** machine — read your notes, grep your codebase, run a build, edit a file — while the agent's brain + conversation state stay on the host. [Read how](./tools.md).
|
||||
|
||||
This mirrors how the Android client exposes `android_tap` / `android_screenshot` to the agent. Zero hermes-agent core changes — the `desktop_*` tools are registered via the standard plugin system, same pattern as `android_*`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Hermes-Relay Android client](/guide/) — parent project, same relay, different device.
|
||||
- [Hermes Agent](https://github.com/NousResearch/hermes-agent) — the agent platform the CLI talks to.
|
||||
- [Desktop CLI GitHub source](https://github.com/Codename-11/hermes-relay/tree/main/desktop) — `@hermes-relay/cli` package.
|
||||
- [Release notes](https://github.com/Codename-11/hermes-relay/releases?q=desktop) — tagged `desktop-v*` (separate track from Android).
|
||||
@@ -0,0 +1,196 @@
|
||||
# Installing the Desktop CLI <ExperimentalBadge />
|
||||
|
||||
Three install paths — binary (recommended, no Node required), npm (when we publish), or source clone. Windows is covered first because the binaries are ready; Mac/Linux binaries ship in the same release.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running Hermes-Relay server reachable from this machine (`curl -s http://<host>:8767/health` should return `{"status":"ok"}`).
|
||||
- One of:
|
||||
- A prebuilt `hermes-relay` binary from [GitHub Releases](https://github.com/Codename-11/hermes-relay/releases?q=desktop) — no Node.js needed. **Recommended.**
|
||||
- Node.js ≥21 if you want to install via npm or run from source.
|
||||
|
||||
## Windows — PowerShell one-liner
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Detects architecture (x64; ARM64 lands after the Bun cross-compile target stabilizes).
|
||||
2. Downloads `hermes-relay-win-x64.exe` from the latest `desktop-v*` GitHub Release.
|
||||
3. Verifies the SHA256 checksum against the published `SHA256SUMS.txt` from the same release.
|
||||
4. Installs to `%USERPROFILE%\.hermes\bin\hermes-relay.exe`.
|
||||
5. Adds `%USERPROFILE%\.hermes\bin` to your **user** PATH (no admin needed).
|
||||
|
||||
Open a **new** terminal (PATH updates don't retroactively apply), then verify:
|
||||
|
||||
```powershell
|
||||
hermes-relay --version
|
||||
```
|
||||
|
||||
### SmartScreen warning on first launch
|
||||
|
||||
The binary is unsigned during the experimental phase. Windows will show "Windows protected your PC" the first time you run it. Click **More info → Run anyway**, or pre-allow from PowerShell:
|
||||
|
||||
```powershell
|
||||
Unblock-File "$env:USERPROFILE\.hermes\bin\hermes-relay.exe"
|
||||
```
|
||||
|
||||
Code signing (EV cert) is a v1.0 milestone — the experimental phase doesn't justify the $300/yr.
|
||||
|
||||
### Pin a specific version
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_VERSION = 'desktop-v0.3.0-alpha.1'
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
See [Uninstall](#uninstall) below — the PowerShell one-liner reverses everything install.ps1 did (binary + user-PATH entry), with optional tiers for session-data purge and service cleanup.
|
||||
|
||||
## macOS / Linux — curl one-liner
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
The script:
|
||||
|
||||
1. Detects OS/arch (supports `linux-x64`, `linux-arm64`, `darwin-x64`, `darwin-arm64`).
|
||||
2. Downloads the matching binary + `SHA256SUMS.txt` from the latest `desktop-v*` release.
|
||||
3. Verifies SHA256 (`sha256sum` on Linux, `shasum -a 256` on macOS).
|
||||
4. Installs to `$HOME/.hermes/bin/hermes-relay` (mode 0755).
|
||||
5. Hints how to add `$HOME/.hermes/bin` to your PATH if it isn't already — **does not mutate your shell rc silently**. Add the line yourself:
|
||||
|
||||
```bash
|
||||
export PATH="$HOME/.hermes/bin:$PATH"
|
||||
```
|
||||
|
||||
Put it in `~/.bashrc` / `~/.zshrc` / `~/.config/fish/config.fish` depending on your shell.
|
||||
|
||||
Verify in a fresh shell:
|
||||
|
||||
```bash
|
||||
hermes-relay --version
|
||||
```
|
||||
|
||||
### macOS quarantine
|
||||
|
||||
Unsigned binaries get quarantined by Gatekeeper on first run. If macOS refuses to open the binary, clear the xattr:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine ~/.hermes/bin/hermes-relay
|
||||
```
|
||||
|
||||
Apple Developer ID signing + notarization is a v1.0 milestone.
|
||||
|
||||
### Pin a specific version
|
||||
|
||||
```bash
|
||||
HERMES_RELAY_VERSION=desktop-v0.3.0-alpha.1 \
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
### Uninstall
|
||||
|
||||
See [Uninstall](#uninstall) below — the curl one-liner reverses install.sh, with optional tiers for session-data purge and service cleanup.
|
||||
|
||||
## Install from source (Node ≥21)
|
||||
|
||||
For dev / contributors / custom builds:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Codename-11/hermes-relay
|
||||
cd hermes-relay/desktop
|
||||
npm install
|
||||
npm run build
|
||||
npm link # puts `hermes-relay` on your PATH via the npm global bin dir
|
||||
```
|
||||
|
||||
Dev loop — skip the tsc build, run TypeScript directly:
|
||||
|
||||
```bash
|
||||
npx tsx src/cli.ts --help
|
||||
npx tsx src/cli.ts pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
## Install via npm (coming soon)
|
||||
|
||||
Once we publish to npm (after experimental phase wraps), it'll be:
|
||||
|
||||
```bash
|
||||
npm install -g @hermes-relay/cli
|
||||
```
|
||||
|
||||
For now, the binary or source paths are the way.
|
||||
|
||||
## Uninstall
|
||||
|
||||
The uninstallers mirror the installers — one-liners on both platforms, three removal tiers.
|
||||
|
||||
### Tiers
|
||||
|
||||
| Flag | What it removes |
|
||||
|-------------------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| *(default)* | `$HOME/.hermes/bin/hermes-relay[.exe]` + the Windows user-PATH entry install.ps1 added. Preserves `~/.hermes/remote-sessions.json`. |
|
||||
| `--purge` | Also deletes `~/.hermes/remote-sessions.json` — bearer tokens, cert pins, and the tools-consent flag. |
|
||||
| `--service` | Stub. Prints the commands to remove a manually-installed systemd unit, launchd plist, or Windows service. No service installers ship yet. |
|
||||
|
||||
Tiers combine: `--purge --service` runs both.
|
||||
|
||||
**`--purge` warning:** `remote-sessions.json` is shared with the Ink TUI and the Hermes Android desktop tooling. Wiping it signs those surfaces out too. Use `--purge` when giving a machine away — not for routine cleanup.
|
||||
|
||||
### Windows
|
||||
|
||||
```powershell
|
||||
# Binary + user-PATH entry only (default)
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
|
||||
# Also purge session tokens — iex can't forward args, so set env first
|
||||
$env:HERMES_RELAY_UNINSTALL_PURGE = 1
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
```
|
||||
|
||||
The script removes `%USERPROFILE%\.hermes\bin\hermes-relay.exe`, strips that directory from your **user** PATH (not system — no admin needed), and removes the install dir if it's empty.
|
||||
|
||||
Open a new terminal afterward so shells pick up the PATH change.
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
```bash
|
||||
# Binary only (default)
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh
|
||||
|
||||
# Also purge session tokens
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh -s -- --purge
|
||||
```
|
||||
|
||||
`install.sh` never touches your shell rc, so neither does `uninstall.sh`. If you added `$HOME/.hermes/bin` to your PATH manually, remove that line from your rc yourself — the script prints a reminder.
|
||||
|
||||
### Override install dir
|
||||
|
||||
Both scripts honor the same env var as the installers:
|
||||
|
||||
```bash
|
||||
HERMES_RELAY_INSTALL_DIR=/opt/hermes \
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.sh | sh
|
||||
```
|
||||
|
||||
```powershell
|
||||
$env:HERMES_RELAY_INSTALL_DIR = 'C:\tools\hermes\bin'
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/uninstall.ps1 | iex
|
||||
```
|
||||
|
||||
## Verify
|
||||
|
||||
After install, all three of these should succeed:
|
||||
|
||||
```bash
|
||||
hermes-relay --version # 0.x.x (matches release tag)
|
||||
hermes-relay --help # Full help text
|
||||
hermes-relay status # Local view — no sessions stored yet
|
||||
```
|
||||
|
||||
Next step: **[Pairing](./pairing.md)** — mint a code on the server and exchange it for a stored session token.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Pairing <ExperimentalBadge />
|
||||
|
||||
Pairing exchanges a one-time 6-character code for a long-lived session token, stored at `~/.hermes/remote-sessions.json` (mode 0600). This is the same file the [Android client](../guide/getting-started.md) uses — **pair once from either, both work**.
|
||||
|
||||
## Step 1 — mint a code on the server
|
||||
|
||||
SSH into your Hermes host (or use any terminal already on it):
|
||||
|
||||
```bash
|
||||
hermes-pair --ttl 600
|
||||
```
|
||||
|
||||
Output:
|
||||
```
|
||||
Code : F3W7EY
|
||||
Relay : ws://127.0.0.1:8767
|
||||
Session TTL : 600 seconds
|
||||
```
|
||||
|
||||
The code is valid for **10 minutes** (the default) and **single-use**. After first successful pair it's consumed. Adjust TTL (how long the minted session token stays valid) with `--ttl 86400` (1 day), `--ttl 2592000` (30 days), etc. — `0` means never expire (not recommended outside LAN).
|
||||
|
||||
If you don't have shell access to the host, run this from a Hermes chat session (any client, including Android): `/hermes-relay-pair`.
|
||||
|
||||
## Step 2 — pair on the client
|
||||
|
||||
On your laptop/workstation:
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
Replace `<host>` with:
|
||||
- A LAN IP (`192.168.1.100`)
|
||||
- A Tailscale tailnet hostname (`hermes.tail1234.ts.net`) — use `wss://` if tailscale serve is on
|
||||
- A public URL (`wss://hermes.example.com`) — Cloudflare Tunnel, Caddy, nginx, etc.
|
||||
|
||||
The CLI prompts:
|
||||
|
||||
```
|
||||
Relay: ws://<host>:8767
|
||||
Need a pairing code — run `/hermes-relay-pair` (or `hermes-pair`) on the relay host.
|
||||
(Paste works; cleaned code shown before submit.)
|
||||
|
||||
Pairing code (6 chars): _
|
||||
```
|
||||
|
||||
Type or paste `F3W7EY`. On success:
|
||||
|
||||
```
|
||||
→ using code: F3W7EY
|
||||
Pairing with ws://<host>:8767...
|
||||
✓ Paired. Token stored in ~/.hermes/remote-sessions.json
|
||||
Server: 0.6.0
|
||||
Relay: ws://<host>:8767
|
||||
Route: lan
|
||||
```
|
||||
|
||||
Subsequent `hermes-relay` commands reuse the stored token.
|
||||
|
||||
## Paste safety — what if the code comes out garbled?
|
||||
|
||||
Some terminals (Windows Terminal, WezTerm, older iTerm2) wrap pasted content in **bracketed paste** escape markers (`\x1b[200~...\x1b[201~`). The CLI disables bracketed paste before the prompt and defensively strips ANSI + control chars, but a few terminals ignore the disable flag. The `→ using code: F3W7EY` confirmation line is your sanity check — if the echoed code doesn't match what you pasted, type it manually instead.
|
||||
|
||||
You can also skip the prompt entirely by passing the code positionally:
|
||||
|
||||
```bash
|
||||
hermes-relay pair F3W7EY --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
## Multi-endpoint pairing (ADR 24)
|
||||
|
||||
If your Hermes server is reachable from multiple routes — LAN + Tailscale + public URL — the host can mint a **single QR payload** containing all of them. The CLI probes endpoints in priority order, picks the first reachable one, and records which route it picked so the banner shows "Connected via LAN (plain)" or "Connected via Tailscale (secure)" on reconnect.
|
||||
|
||||
On the server:
|
||||
|
||||
```bash
|
||||
# All three routes
|
||||
hermes-pair --mode auto --public-url https://hermes.example.com
|
||||
|
||||
# Or specific:
|
||||
hermes-pair --mode lan
|
||||
hermes-pair --mode tailscale
|
||||
hermes-pair --mode public --public-url https://hermes.example.com
|
||||
```
|
||||
|
||||
The output is a JSON blob (printed alongside the QR). Copy it verbatim and paste to the CLI:
|
||||
|
||||
```bash
|
||||
hermes-relay pair --pair-qr '{"hermes":3,"host":"192.168.1.10","port":8642,"key":"ABC123","endpoints":[...]}'
|
||||
```
|
||||
|
||||
Or via env:
|
||||
|
||||
```bash
|
||||
HERMES_RELAY_PAIR_QR='<payload>' hermes-relay shell
|
||||
```
|
||||
|
||||
The CLI races candidates within the same priority tier (`Promise.any` with 4s per-candidate timeout, 60s reachability cache) and picks the winner. Priority is strict — reachability only breaks ties *within* a tier, never promotes a lower-priority candidate.
|
||||
|
||||
> **HMAC signature.** The QR payload carries an optional HMAC-SHA256 signature (`sig` field). The current CLI parses it but doesn't verify — the server's HMAC secret isn't client-accessible yet. Matches the Android app's current behavior. Verification lands with v1.0.
|
||||
|
||||
## Re-pair (reset)
|
||||
|
||||
If your stored token expires, was revoked, or you want a fresh start:
|
||||
|
||||
```bash
|
||||
# purge stored session for this URL
|
||||
rm ~/.hermes/remote-sessions.json # or delete just this URL's entry
|
||||
|
||||
# mint a fresh code on the server, then:
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
## Inspect stored sessions
|
||||
|
||||
```bash
|
||||
hermes-relay status
|
||||
```
|
||||
|
||||
Shows per-URL: server version, pair age, token prefix, TTL expiry, grants (per-channel access), endpoint role, cert pin (wss only), tool consent state. Pass `--json` for a machine-readable redacted dump, or `--json --reveal-tokens` to include full tokens (for scripted re-auth — never paste into a shared terminal).
|
||||
|
||||
## Paired devices on the server (revoke remotely)
|
||||
|
||||
See what the server thinks is paired — and revoke / extend:
|
||||
|
||||
```bash
|
||||
hermes-relay devices # list all paired devices on this server
|
||||
hermes-relay devices revoke abc12345 # revoke by token prefix
|
||||
hermes-relay devices extend abc12345 --ttl 604800 # extend to 7 days
|
||||
```
|
||||
|
||||
Talks to the relay's `GET /sessions` HTTP endpoint using your stored bearer. The current device is marked with `●`.
|
||||
|
||||
## Related
|
||||
|
||||
- [Installation](./installation.md) — get the binary on your machine first.
|
||||
- [Subcommands](./subcommands.md) — full reference for `pair`, `status`, `devices`, `shell`, `chat`, `tools`.
|
||||
- [Troubleshooting](./troubleshooting.md) — `auth timed out`, `relay rejected`, `disconnected before auth`, etc.
|
||||
@@ -0,0 +1,168 @@
|
||||
# Subcommands <ExperimentalBadge />
|
||||
|
||||
Full reference for every `hermes-relay` verb. Flags map one-to-one with env vars where noted.
|
||||
|
||||
## `hermes-relay` (bare — defaults to `shell`)
|
||||
|
||||
```bash
|
||||
hermes-relay # interactive shell (full Hermes TUI over PTY)
|
||||
hermes-relay "what time is it?" # one-shot chat (structured events)
|
||||
```
|
||||
|
||||
Bare invocation opens `shell` mode if no positional arg is given, `chat` mode if a positional is provided. This matches user expectation — "I want to talk to Hermes" drops into the rich interactive experience by default.
|
||||
|
||||
## `hermes-relay shell`
|
||||
|
||||
Pipes a PTY from the server's tmux (+ `exec hermes` post-attach) directly to your local terminal. You see the literal Hermes CLI — banner, Victor, Ink status bar, all of it.
|
||||
|
||||
```bash
|
||||
hermes-relay shell # default: exec hermes
|
||||
hermes-relay shell --exec btop # run something else in tmux
|
||||
hermes-relay shell --raw # drop into bare tmux/bash (no auto-exec)
|
||||
hermes-relay shell --session my-work # pin tmux session name for deterministic resume
|
||||
```
|
||||
|
||||
**Escape keys:**
|
||||
- `Ctrl+A .` — detach cleanly. tmux session persists on the server; next `hermes-relay shell` re-attaches with full state.
|
||||
- `Ctrl+A k` — destroy the tmux session. Fresh hermes on next run.
|
||||
- `Ctrl+A Ctrl+A` — forward a literal `Ctrl+A` (for nested tmux).
|
||||
- `Ctrl+C` — passes through to the remote process (interrupts hermes, not the client).
|
||||
|
||||
## `hermes-relay chat`
|
||||
|
||||
Structured-event chat. Renders `message.delta` → stdout, tool events → decorated stderr lines, optional JSON firehose for scripting.
|
||||
|
||||
```bash
|
||||
hermes-relay chat # interactive REPL
|
||||
hermes-relay chat "<prompt>" # one-shot
|
||||
echo "<prompt>" | hermes-relay chat # pipe stdin
|
||||
hermes-relay chat --json "<prompt>" | jq -c '.type' # structured event stream
|
||||
hermes-relay chat --verbose # include thinking/reasoning + transport stderr
|
||||
hermes-relay chat --quiet # suppress tool decorations + status lines
|
||||
hermes-relay chat --session <id> # resume a specific hermes session
|
||||
hermes-relay chat --no-tools # skip desktop tool handlers for this invocation
|
||||
```
|
||||
|
||||
**Ctrl+C** during a turn fires `session.interrupt` on the relay — the in-flight turn is cancelled, the REPL prompt returns. Ctrl+C at the empty prompt exits.
|
||||
|
||||
## `hermes-relay pair`
|
||||
|
||||
Mint a pairing code on the server, exchange it here.
|
||||
|
||||
```bash
|
||||
hermes-relay pair --remote ws://<host>:8767 # interactive (prompts for code)
|
||||
hermes-relay pair <CODE> --remote ws://<host>:8767 # positional code (avoids paste issues)
|
||||
hermes-relay pair --code <CODE> --remote ws://<host>:8767
|
||||
hermes-relay pair --pair-qr '<v3-QR-payload>' # multi-endpoint ADR 24 flow
|
||||
```
|
||||
|
||||
After success, the session token is stored in `~/.hermes/remote-sessions.json` (mode 0600) and subsequent commands reuse it.
|
||||
|
||||
See **[Pairing](./pairing.md)** for the full walkthrough.
|
||||
|
||||
## `hermes-relay status`
|
||||
|
||||
Local inventory — no network. Reads `~/.hermes/remote-sessions.json`.
|
||||
|
||||
```bash
|
||||
hermes-relay status # human-readable
|
||||
hermes-relay status --json # redacted JSON (tokens truncated)
|
||||
hermes-relay status --json --reveal-tokens # full tokens (careful — don't paste this anywhere)
|
||||
```
|
||||
|
||||
Output per URL:
|
||||
```
|
||||
ws://172.16.24.250:8767
|
||||
server: 0.6.0
|
||||
paired: 2h ago
|
||||
token: 79d2cf41…8d8c
|
||||
expires: in 29d
|
||||
route: LAN
|
||||
grants: bridge (in 6d), chat (in 29d), terminal (in 29d), tui (in 29d)
|
||||
cert: sha256:a1b2c3d4e5f6… (wss only)
|
||||
```
|
||||
|
||||
## `hermes-relay tools`
|
||||
|
||||
Ask the server what tool access the agent will have on this connection. Hits `tools.list` RPC, prints the toolset taxonomy.
|
||||
|
||||
```bash
|
||||
hermes-relay tools --remote ws://<host>:8767 # summary
|
||||
hermes-relay tools --remote <url> --verbose # per-tool detail per toolset
|
||||
hermes-relay tools --remote <url> --json # machine-readable
|
||||
```
|
||||
|
||||
`●` = enabled for this session, `○` = available but off. Useful to sanity-check that `desktop` is in the list and enabled before starting a shell that will use local tools.
|
||||
|
||||
## `hermes-relay devices`
|
||||
|
||||
Server-side paired-device management (the relay's `GET /sessions` HTTP API). Shows all paired devices across all your clients — Android phones, desktop CLIs, Ink TUIs — and lets you revoke or extend them.
|
||||
|
||||
```bash
|
||||
hermes-relay devices # list (defaults to the one stored relay)
|
||||
hermes-relay devices --remote ws://<host>:8767 # list specific relay
|
||||
hermes-relay devices revoke abc12345 # destroy a session by token prefix
|
||||
hermes-relay devices extend abc12345 --ttl 604800 # extend TTL (seconds)
|
||||
hermes-relay devices --json # machine-readable (redacted)
|
||||
```
|
||||
|
||||
The current device is marked `●`. Prefix must be unambiguous — if multiple sessions share a prefix, you'll get a 409 with the conflicting list; use a longer prefix.
|
||||
|
||||
## Global flags
|
||||
|
||||
Available on every subcommand.
|
||||
|
||||
| Flag | Env | Purpose |
|
||||
|------|-----|---------|
|
||||
| `--remote <url>` | `HERMES_RELAY_URL` | Relay WSS URL |
|
||||
| `--code <CODE>` | `HERMES_RELAY_CODE` | One-time pairing code (pair only) |
|
||||
| `--token <token>` | `HERMES_RELAY_TOKEN` | Session token, skips pairing entirely |
|
||||
| `--pair-qr <payload>` | `HERMES_RELAY_PAIR_QR` | Multi-endpoint QR (ADR 24) |
|
||||
| `--session <id>` | — | `chat`: resume session. `shell`: tmux session name. |
|
||||
| `--exec <cmd>` | — | `shell` only: override `hermes` auto-exec |
|
||||
| `--raw` | — | `shell` only: skip auto-exec, bare tmux/bash |
|
||||
| `--no-tools` | — | Don't wire desktop tool handlers for this invocation |
|
||||
| `--json` | — | `chat` / `status` / `tools` / `devices`: JSON output |
|
||||
| `--verbose` | — | `chat` / `tools`: include thinking/reasoning + transport stderr |
|
||||
| `--quiet`, `-q` | — | Suppress status lines + tool decorations |
|
||||
| `--no-color` | `NO_COLOR` | Disable ANSI colors |
|
||||
| `--non-interactive` | — | Never prompt; fail fast if creds missing |
|
||||
| `--reveal-tokens` | — | `status` / `devices`: print full tokens in `--json` output |
|
||||
| `--help`, `-h` | — | Print help |
|
||||
| `--version`, `-v` | — | Print version |
|
||||
|
||||
## Environment variables (summary)
|
||||
|
||||
| Var | Effect |
|
||||
|-----|--------|
|
||||
| `HERMES_RELAY_URL` | Default `--remote` |
|
||||
| `HERMES_RELAY_CODE` | Default `--code` |
|
||||
| `HERMES_RELAY_TOKEN` | Default `--token` |
|
||||
| `HERMES_RELAY_PAIR_QR` | Default `--pair-qr` |
|
||||
| `HERMES_RELAY_AUTH_TIMEOUT_MS` | Override 15s auth timeout (default `15000`) — useful on slow first connects while tui_gateway spawns |
|
||||
| `HERMES_RELAY_RPC_TIMEOUT_MS` | Override 120s RPC timeout (default `120000`) |
|
||||
| `HERMES_RELAY_INSTALL_DIR` | Install script override (default `~/.hermes/bin`) |
|
||||
| `HERMES_RELAY_VERSION` | Install script pin (default `latest`) |
|
||||
| `NO_COLOR` | Disable ANSI output |
|
||||
| `FORCE_COLOR=1` | Force ANSI even on non-TTY |
|
||||
|
||||
## Exit codes
|
||||
|
||||
| Code | Meaning |
|
||||
|------|---------|
|
||||
| `0` | Success |
|
||||
| `1` | General error (auth failed, relay rejected, tool call errored) |
|
||||
| `2` | Argument / flag parsing error |
|
||||
|
||||
## Config files
|
||||
|
||||
| Path | Purpose |
|
||||
|------|---------|
|
||||
| `~/.hermes/remote-sessions.json` | Session tokens, grants, TTLs, cert pins, tool consent (mode 0600, atomic tempfile+rename) |
|
||||
| `~/.hermes/bin/hermes-relay` (`.exe` on Windows) | Installed binary path |
|
||||
|
||||
## Related
|
||||
|
||||
- [Pairing](./pairing.md) — how to get a session token in the first place.
|
||||
- [Local tool routing](./tools.md) — `desktop_*` tools and consent.
|
||||
- [Troubleshooting](./troubleshooting.md) — common errors and fixes.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Local tool routing <ExperimentalBadge />
|
||||
|
||||
The big feature. The remote Hermes agent can read, write, search, and execute on **your machine** — not the server — through the same WSS relay it uses for chat. The agent's brain and conversation state stay on the host; your laptop is the hands.
|
||||
|
||||
## What the agent can do
|
||||
|
||||
Five tools are registered in the `desktop` toolset. The agent sees them as normal tools alongside its usual ones — no special syntax needed, just "read my notes" or "run `tsc --noEmit`".
|
||||
|
||||
| Tool | Signature | Example use |
|
||||
|------|-----------|-------------|
|
||||
| `desktop_read_file` | `(path: string, max_bytes?: number)` | "Read my notes.md and summarize." |
|
||||
| `desktop_write_file` | `(path: string, content: string, create_dirs?: boolean)` | "Write a quick-start guide to `~/Desktop/quickstart.md`." |
|
||||
| `desktop_patch` | `(path: string, patch: string)` | Apply a unified diff. Strict — no fuzzy matching. |
|
||||
| `desktop_terminal` | `(command: string, cwd?: string, timeout?: number)` | "Run `tsc --noEmit` and tell me what's broken." |
|
||||
| `desktop_search_files` | `(pattern: string, cwd?: string, max_results?: number, content?: boolean)` | "Find every file mentioning `DesktopToolRouter`." |
|
||||
|
||||
All run under a **30-second AbortController** ceiling enforced by the router. `desktop_terminal` accepts a per-call `timeout` (seconds, per the wire spec — converted to ms internally) that's clamped to a 10-minute maximum.
|
||||
|
||||
## How it works
|
||||
|
||||
1. You pair + connect via `hermes-relay shell` or `hermes-relay chat`.
|
||||
2. On connect, the CLI's `DesktopToolRouter` attaches to the relay's `desktop` channel and heartbeats every 30s with the list of advertised tools.
|
||||
3. Hermes's Python-side `desktop_tool.py` handlers register with `tools.registry` (same pattern as `android_tool.py`) — the agent sees `desktop_read_file` as just another tool.
|
||||
4. When the agent calls a `desktop_*` tool, the Python handler HTTP-POSTs to `localhost:8767/desktop/<tool_name>` on the host.
|
||||
5. The relay's `desktop` channel forwards the call over WSS to the connected CLI.
|
||||
6. The CLI's `DesktopToolRouter` dispatches to an in-process handler (`fs.ts`, `terminal.ts`, `search.ts`).
|
||||
7. The handler runs on **your** machine, returns the result, and the response bubbles back: CLI → relay → Python → Hermes → agent.
|
||||
8. Typical round-trip: 60–100 ms for a simple command.
|
||||
|
||||
No hermes-agent core changes. It's the same pattern the Android client uses for `android_tap` / `android_screenshot` / etc. — just swapping the bridge endpoint for a desktop one.
|
||||
|
||||
## Consent gate
|
||||
|
||||
On your first `shell` or `chat` session per relay URL with tools enabled, you'll see a prompt:
|
||||
|
||||
```
|
||||
Desktop tools are about to be exposed to the remote Hermes agent.
|
||||
The agent can read/write files, run shell commands, and search your filesystem.
|
||||
This is AGENT-CONTROLLED access. Only use with trusted Hermes installs.
|
||||
Type 'yes' to enable, or rerun with --no-tools to disable.
|
||||
>
|
||||
```
|
||||
|
||||
Only `yes` (case-insensitive) enables. Anything else (`y`, `no`, `Enter`, `Ctrl+C`) denies.
|
||||
|
||||
Consent is stored per-URL in `~/.hermes/remote-sessions.json` as `toolsConsented: true` and sticks across sessions. You won't be asked again for this relay until the URL changes or you wipe the session.
|
||||
|
||||
**Kill-switches:**
|
||||
- `--no-tools` on any subcommand suppresses the router entirely for that invocation.
|
||||
- Non-TTY stdin (e.g. piped invocations) fails closed — never auto-consents.
|
||||
- Delete the session record (or set `toolsConsented: false` in the file) to force re-prompt.
|
||||
|
||||
## Safety walls
|
||||
|
||||
The desktop tools run **in-process on your machine** with your full user privileges. That's a real risk — a compromised relay or a misaligned agent could ask to `rm -rf /`, exfiltrate tokens, or rewrite your `.ssh/config`. The walls:
|
||||
|
||||
1. **Consent per-URL, not per-run.** Once you say yes to `ws://hermes.example.com`, the agent on THAT server has persistent tool access. A different URL re-prompts.
|
||||
2. **No sudo / privilege escalation.** All tools inherit your shell's environment. `desktop_terminal "sudo rm -rf /"` requires a passwordless sudo configuration to succeed — we're not adding it.
|
||||
3. **Per-call AbortController ceiling.** 30 seconds per tool call hard stop. A long-running compromise would trip this.
|
||||
4. **Handler implementations are defensive:**
|
||||
- `desktop_read_file` caps at `max_bytes` (default 1 MB) and truncates with a marker.
|
||||
- `desktop_write_file` refuses to create parent dirs unless `create_dirs: true` is set.
|
||||
- `desktop_patch` is strict — any hunk mismatch aborts the whole patch. No fuzzy matching. Better to fail than to corrupt.
|
||||
- `desktop_terminal` uses `bash -lc` on POSIX, `cmd /c` on Windows — no shell injection beyond what the command itself carries (it IS the command).
|
||||
- `desktop_search_files` skips `.git` / `node_modules` / `dist` / `.next` / `.cache` by default.
|
||||
5. **No stdin.** `desktop_terminal` pipes `/dev/null` to the child — a command that reads stdin hangs up immediately rather than blocking the handler.
|
||||
6. **SIGKILL on abort/timeout.** No chance for a signal handler to trap and keep running.
|
||||
|
||||
**What we DON'T have yet (v1.0 targets):**
|
||||
- Command allowlist / blocklist per session.
|
||||
- Destructive-verb confirmation modal (like the Android bridge's `send_sms`/`call` prompts).
|
||||
- Per-tool sandbox (e.g., restrict `desktop_read_file` to a project root).
|
||||
- Code signing (`hermes-relay` binary is currently unsigned).
|
||||
|
||||
## Diagnosing routing
|
||||
|
||||
If the agent says "desktop_terminal is not available" or calls time out immediately:
|
||||
|
||||
```bash
|
||||
# On the server, verify the channel sees your client
|
||||
ssh bailey@<host> curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
|
||||
```
|
||||
|
||||
Expected:
|
||||
```json
|
||||
{
|
||||
"connected": true,
|
||||
"advertised_tools": ["desktop_patch", "desktop_read_file", "desktop_search_files", "desktop_terminal", "desktop_write_file"],
|
||||
"client_status": { ... },
|
||||
"last_seen_at": 1776964298.02,
|
||||
"pending_commands": 0
|
||||
}
|
||||
```
|
||||
|
||||
If `connected: false`:
|
||||
- No active shell/chat session is connected. Start one.
|
||||
- `--no-tools` was used. Retry without it.
|
||||
- Consent was denied. Delete the session record or re-pair.
|
||||
|
||||
If `connected: true` but the agent still says the tool is missing:
|
||||
- The toolset isn't enabled for this Hermes session. Inside the shell, ask Hermes: "enable the `desktop` toolset for this session." Or add it to your Hermes config's default enabled toolsets.
|
||||
- The plugin wasn't loaded on the gateway. See `hermes-relay-self-setup` skill — `plugins.enabled` in `~/.hermes/config.yaml` must include `hermes-relay`.
|
||||
|
||||
## Future — daemon mode
|
||||
|
||||
Currently the tools only work while a `shell` or `chat` session is open. The forthcoming `hermes-relay daemon` subcommand will run the CLI headless in the background, attaching the tool router without a visible shell. That lets the agent reach you anytime you're on the machine, not just when you're in an active session.
|
||||
|
||||
Daemon mode + multi-client routing + Windows Service / systemd / launchd integration is the v1.0 work. Tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track).
|
||||
|
||||
## Related
|
||||
|
||||
- [Pairing](./pairing.md) — must pair before tools work.
|
||||
- [Subcommands](./subcommands.md) — `--no-tools` flag.
|
||||
- [Troubleshooting](./troubleshooting.md) — common tool routing errors.
|
||||
@@ -0,0 +1,221 @@
|
||||
# Troubleshooting <ExperimentalBadge />
|
||||
|
||||
Common errors in the desktop CLI, indexed by exact message. If your problem isn't here, [open an issue](https://github.com/Codename-11/hermes-relay/issues/new).
|
||||
|
||||
## `hermes-relay: command not found` / `is not recognized`
|
||||
|
||||
Your PATH doesn't include the install directory.
|
||||
|
||||
**Windows**: open a **new** PowerShell (PATH updates don't retroactively apply to in-process shells).
|
||||
|
||||
```powershell
|
||||
# Confirm the binary exists:
|
||||
Test-Path "$env:USERPROFILE\.hermes\bin\hermes-relay.exe"
|
||||
# Re-add to user PATH if missing:
|
||||
[Environment]::SetEnvironmentVariable('Path', "$([Environment]::GetEnvironmentVariable('Path','User'));$env:USERPROFILE\.hermes\bin", 'User')
|
||||
```
|
||||
|
||||
**macOS / Linux**: add to your shell rc.
|
||||
|
||||
```bash
|
||||
echo 'export PATH="$HOME/.hermes/bin:$PATH"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
Or run with full path: `~/.hermes/bin/hermes-relay --version`.
|
||||
|
||||
## `auth timed out after 15000ms`
|
||||
|
||||
The relay subprocess takes 15–30 seconds on first attach because Hermes initializes the full agent. Bump the timeout for slow first connects:
|
||||
|
||||
```bash
|
||||
HERMES_RELAY_AUTH_TIMEOUT_MS=30000 hermes-relay shell
|
||||
```
|
||||
|
||||
If it still times out at 30 s, the relay itself is stuck. Check:
|
||||
```bash
|
||||
# On the host
|
||||
systemctl --user status hermes-relay --no-pager
|
||||
journalctl --user -u hermes-relay --since '5 minutes ago' --no-pager | tail -20
|
||||
```
|
||||
|
||||
## `relay rejected credentials: auth failed`
|
||||
|
||||
Your stored token is expired, was revoked on the host, or you mistyped the pairing code.
|
||||
|
||||
```bash
|
||||
hermes-relay status # see what's stored
|
||||
# If the relay is in the list: the token is stale. Purge and re-pair:
|
||||
rm ~/.hermes/remote-sessions.json # or delete just this URL's entry
|
||||
hermes-relay pair --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
Mint a fresh code on the server first: `hermes-pair --ttl 600`.
|
||||
|
||||
## `disconnected before auth`
|
||||
|
||||
The WebSocket closed before the server sent `auth.ok` or `auth.fail`. Usually means:
|
||||
|
||||
1. The pairing code was rejected and the server closed the socket (this is the most common case — re-pair).
|
||||
2. Network path broke mid-handshake (check connectivity).
|
||||
3. The relay crashed. Check `journalctl --user -u hermes-relay`.
|
||||
|
||||
Follow the re-pair steps above.
|
||||
|
||||
## `No relay URL. Pass --remote ws://host:port or set HERMES_RELAY_URL`
|
||||
|
||||
Exactly what it says. The `remote` URL is required for any network command. Either:
|
||||
|
||||
```bash
|
||||
hermes-relay --remote ws://<host>:8767 ...
|
||||
# or
|
||||
export HERMES_RELAY_URL=ws://<host>:8767
|
||||
hermes-relay ...
|
||||
```
|
||||
|
||||
The stored session is keyed by URL, so once paired against a specific URL, that URL is the one to reuse.
|
||||
|
||||
## Pairing code pastes as `[200~F3W7EY[201~`
|
||||
|
||||
Your terminal's bracketed paste mode wasn't respected by readline. Options:
|
||||
|
||||
1. **Type the 6 chars manually.** Most reliable.
|
||||
2. **Pass positionally** to skip the prompt:
|
||||
```bash
|
||||
hermes-relay pair F3W7EY --remote ws://<host>:8767
|
||||
```
|
||||
|
||||
The CLI disables bracketed paste before the readline prompt and strips ANSI defensively — but some terminals (older WezTerm, certain PowerShell+Windows-Terminal combos, Claude Desktop's embedded terminal) ignore the disable flag.
|
||||
|
||||
## `timed out after 30ms` (or any millisecond-range timeout on a desktop tool)
|
||||
|
||||
You're running a pre-fix desktop CLI build. The Python side sends `timeout` in seconds; early Node builds treated it as milliseconds — `30` seconds became 30 ms, and every shell command SIGKILL'd instantly.
|
||||
|
||||
Fixed in releases after 2026-04-23. Upgrade:
|
||||
|
||||
::: code-group
|
||||
|
||||
```powershell [Windows]
|
||||
irm https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
```bash [macOS / Linux]
|
||||
curl -fsSL https://raw.githubusercontent.com/Codename-11/hermes-relay/main/desktop/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
```bash [from source]
|
||||
cd hermes-relay/desktop && git pull && npm run build
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## Agent reports `desktop_*` tools are not available
|
||||
|
||||
Two layers — check them in order:
|
||||
|
||||
```bash
|
||||
# On the server
|
||||
curl -s "http://127.0.0.1:8767/desktop/_ping?tool=desktop_terminal"
|
||||
```
|
||||
|
||||
**If `connected: false`**: no desktop CLI is attached. Start `hermes-relay shell` or `chat`; make sure you didn't pass `--no-tools`; make sure you consented on the first-run prompt.
|
||||
|
||||
**If `connected: true`** but Hermes still can't see the tools: the plugin isn't loaded by the gateway, or the `desktop` toolset isn't enabled for your session.
|
||||
|
||||
Check `~/.hermes/config.yaml` on the server:
|
||||
```yaml
|
||||
plugins:
|
||||
enabled:
|
||||
- model-router
|
||||
- hermes-relay # ← must be here
|
||||
```
|
||||
|
||||
If missing, add it and restart:
|
||||
```bash
|
||||
systemctl --user restart hermes-gateway
|
||||
```
|
||||
|
||||
Enable the toolset for your current session — inside `hermes-relay shell`, ask Victor: "enable the `desktop` toolset for this session." Or add it to the default enabled toolsets in `config.yaml`.
|
||||
|
||||
## Agent calls a desktop tool but response comes back with the **server's** hostname instead of mine
|
||||
|
||||
The Python handler is running the command locally on the server instead of routing to your client. Two causes:
|
||||
|
||||
1. **No client is connected to the `desktop` channel** — the Python handler falls back to returning an error. Verify via `/desktop/_ping` (see above). If `connected: false`, start a shell session.
|
||||
2. **The wrong tool was called.** Hermes has a `terminal` toolset (server-side shell) AND a `desktop` toolset (client-side). If Hermes picked `terminal` instead of `desktop_terminal`, it ran on the server. Ask explicitly: "use **desktop_terminal** to run ...".
|
||||
|
||||
## Windows SmartScreen: "Windows protected your PC"
|
||||
|
||||
Unsigned binary (expected during experimental phase). Click **More info → Run anyway**, or pre-allow:
|
||||
|
||||
```powershell
|
||||
Unblock-File "$env:USERPROFILE\.hermes\bin\hermes-relay.exe"
|
||||
```
|
||||
|
||||
Signed EV binaries land with v1.0.
|
||||
|
||||
## macOS: "hermes-relay can't be opened because the developer cannot be verified"
|
||||
|
||||
Quarantine xattr. Clear once:
|
||||
|
||||
```bash
|
||||
xattr -dr com.apple.quarantine ~/.hermes/bin/hermes-relay
|
||||
```
|
||||
|
||||
Apple Developer ID signing + notarization lands with v1.0.
|
||||
|
||||
## `shell` drops me into bash / tmux, not hermes
|
||||
|
||||
The post-attach `exec hermes` injection didn't happen. Usually: `--raw` was set, or the previous tmux session already has a different shell running that captures the injection.
|
||||
|
||||
```bash
|
||||
# Kill the existing tmux session (inside shell)
|
||||
Ctrl+A k
|
||||
# Re-enter — fresh tmux will exec hermes on attach
|
||||
hermes-relay shell
|
||||
```
|
||||
|
||||
Or explicitly:
|
||||
```bash
|
||||
hermes-relay shell --exec hermes
|
||||
```
|
||||
|
||||
## `Ctrl+A .` doesn't detach — it types a period
|
||||
|
||||
You're probably in hermes's TUI input field, not at the escape-key handler. Make sure hermes isn't capturing Ctrl+A as "select all" in an input — some Ink apps do.
|
||||
|
||||
Fallback: close the terminal window (tmux preserves state on disconnect — next run re-attaches).
|
||||
|
||||
## Tool calls hang, then fail with `aborted`
|
||||
|
||||
The 30-second router ceiling fired. The handler is stuck — usually because `desktop_terminal` is running a command that reads stdin, or a command that doesn't terminate.
|
||||
|
||||
- Confirm the command terminates when run manually.
|
||||
- Pass a short explicit timeout in the tool call args: `desktop_terminal("command", timeout=5)`.
|
||||
- If the command genuinely needs >30 s, it's too long for tool-use — break it into smaller steps, or use background + polling.
|
||||
|
||||
## `certificate pin mismatch` (wss only)
|
||||
|
||||
The TLS peer cert SHA256 differs from the one stored at pair time. Either the relay rotated its cert, or someone is MITMing the connection.
|
||||
|
||||
Legitimate rotation: re-pair (which wipes the old pin + stores the new one):
|
||||
```bash
|
||||
hermes-relay pair --remote wss://<host>:8767
|
||||
```
|
||||
|
||||
If you DID NOT rotate and the pin mismatches, **DO NOT CONTINUE**. Check your network path — VPN/DNS hijack is a real possibility on public networks.
|
||||
|
||||
## Still stuck?
|
||||
|
||||
Run the self-diagnostic skill from any Hermes chat:
|
||||
|
||||
```
|
||||
/hermes-relay-desktop-setup
|
||||
```
|
||||
|
||||
The skill can invoke `desktop_terminal` on **your machine** to read your config, check versions, trace PATH issues — without you having to paste console output. It's the fastest triage path.
|
||||
|
||||
Or open an issue with:
|
||||
1. The full CLI output (run with `--verbose` and redact tokens).
|
||||
2. `hermes-relay --version` + `hermes-relay status` output.
|
||||
3. `/desktop/_ping` output from the server.
|
||||
Reference in New Issue
Block a user