Compare commits

..
Author SHA1 Message Date
Bailey Dixon 26e4a054d2 Merge pull request #100 from Codename-11/dev
fix(ci): unblock cli-v release (tray smoke $home bug)
2026-06-21 21:18:57 -04:00
Bailey DixonandClaude Opus 4.8 9f568e12cb fix(ci): tray smoke uses $smokeHome, not read-only $home (unblocks cli-v release)
The tray smoke step in release-cli.yml assigned `$home = ...`, but $HOME is a
read-only automatic variable in PowerShell (names are case-insensitive), so it
threw "Cannot overwrite variable HOME because it is read-only or constant",
failing the tray job and skipping Publish. First cli-v* tag surfaced it — the
CLI binaries themselves built fine. Use a distinct scratch variable; the
$env:HOME / $env:USERPROFILE environment vars stay writable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 21:17:39 -04:00
Bailey Dixon a0b4d3715c Merge pull request #99 from Codename-11/dev
release(cli): cli-v0.4.0-alpha.1
2026-06-21 21:03:24 -04:00
Bailey DixonandClaude Opus 4.8 e0a2a59957 release(cli): cli-v0.4.0-alpha.1
Bumps desktop/package.json 0.3.0-alpha.18 -> 0.4.0-alpha.1 (a new minor for the
command-surface uplift; stays in the experimental alpha track) and fills
CLI_RELEASE_NOTES.md for the GitHub Release body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:59:47 -04:00
Bailey DixonandClaude Opus 4.8 738256238f feat(desktop): CLI first-class pass — audit/relay/logo, background daemon, visual layer
Brings the CLI up to the relay's v1.2.0 capabilities and gives it a consistent,
discoverable interface. New commands: `audit` (what the agent ran on this
machine, from a local log), `relay info/security/context` (inspect the relay
server and audit the system-prompt context it injects into the agent), `logo`,
and `daemon start/stop/status` for running the tool router in the background
(no console window, survives closing the terminal).

Every subcommand now answers `--help`; list output (devices/sessions) renders
as aligned tables with status dots; slow operations show a spinner; errors
suggest the fix; and pairing reports per-endpoint probe progress and warns
before a stored session expires. `voice` surfaces the enhanced-voice
(Gemini/xAI) block, and the desktop-tool consent prompt points at `audit`.

Adds a shared zero-dep lib/ (theme/table/spinner/hints/usage/logo/auditLog/
daemonStatus), an `npm run dev:install` local-binary helper, and refreshed
desktop user-docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:59:42 -04:00
Bailey DixonandClaude Opus 4.8 d1820fb606 fix(relay): keep realtime voice heartbeat alive during long Hermes runs
The realtime voice agent killed a turn after ~90s of websocket silence
(client idle watchdog). The relay heartbeat stopped the moment
hermes_run_status left {running, waiting_for_confirmation}, so a long or
background Hermes run could starve it and trip the stall. The heartbeat
now continues while session.hermes_task is unfinished, and the spoken
progress repeat is raised 30s->90s and gated on a coarse status change so
tool-message churn no longer re-narrates.

Adds plugin/tests/test_realtime_heartbeat.py (11 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 20:23:46 -04:00
Bailey DixonandClaude Opus 4.8 11274ce51b ci(android): add release-build smoke to catch tag-time breakage early
The android-v* release builds the release variant (bundleRelease
assembleRelease, both flavors); PR CI only built debug, so release-only
failures (R8/minify, resource shrinking, bundletool OOM) surfaced at the tag
— e.g. the v1.2.0 OOM at -Xmx2048m. Adds a debug-signed release-build smoke
(no secrets) on dev/main pushes and the dev->main release PR, so the same
build that the tag runs is exercised before tagging.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:22:55 -04:00
Bailey Dixon 6fb15ddc9c Merge: main (v1.2.0 release + CI fixes) back into dev 2026-06-21 18:08:20 -04:00
Bailey Dixon 15dcd6d637 fix(docs): pin search-insights for deterministic npm ci (#98)
Unblocks Deploy Docs.
2026-06-21 18:07:18 -04:00
Bailey DixonandClaude Opus 4.8 42d262bc79 fix(docs): pin search-insights so npm ci is deterministic across npm versions
The bundled docsearch declares search-insights as an OPTIONAL peer dep with
no resolved lock entry. npm 11.9 (local) treats it as satisfiable and passes;
CI's npm rejects it ("Missing: search-insights@2.17.3 from lock file").
Pinning it as a direct devDependency gives it a resolved node_modules entry,
so `npm ci` agrees on every npm version. Validated with a clean local npm ci.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:06:20 -04:00
Bailey Dixon b977b6b02a fix(ci): docs build on Node 24 to match lockfile (#97)
Unblocks Deploy Docs.
2026-06-21 18:02:02 -04:00
Bailey DixonandClaude Opus 4.8 d411764935 fix(ci): build docs on Node 24 (npm 11) to match the lockfile
Deploy Docs failed `npm ci` with "Missing: search-insights@2.17.3 from lock
file". user-docs/package-lock.json is generated by npm 11, which omits the
resolved entry for the optional `search-insights` peer dep of bundled
docsearch; CI's Node 20 / npm 10 demands it. Align CI to npm 11.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 18:01:26 -04:00
48 changed files with 2648 additions and 260 deletions
+40
View File
@@ -6,6 +6,11 @@
# Pipeline: lint, build, and focused tests run concurrently. PRs build debug
# APKs before merge; dev pushes keep lint/tests only to avoid duplicate
# post-merge packaging. Main pushes keep APK artifacts.
#
# A release-build smoke (bundleRelease assembleRelease) runs on dev/main pushes
# and on the dev→main release PR so release-only breakage (R8/minify rules,
# resource shrinking, bundletool OOM) is caught BEFORE the android-v* tag,
# instead of mid-release. It is debug-signed, so it needs no signing secrets.
name: CI — Android
@@ -152,3 +157,38 @@ jobs:
name: test-reports
path: app/build/reports/tests/
retention-days: 7
# ──────────────────────────────────────────────
# Release build smoke — exercises the release variant the android-v* tag
# build runs (./gradlew bundleRelease assembleRelease, both flavors), so
# release-only breakage (R8/minify, resource shrinking, bundletool OOM) is
# caught BEFORE the tag instead of mid-release. Debug-signed — no secrets,
# so it also runs on fork PRs. Runs on dev/main pushes (early signal after
# each merge) and on the dev→main release PR (hard pre-tag gate); skipped on
# dev-targeted feature PRs to avoid re-running a ~12-min build per iteration.
# ──────────────────────────────────────────────
release-smoke:
name: Release build smoke (Android)
if: ${{ github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || (github.event_name == 'pull_request' && github.base_ref == 'main') }}
runs-on: ubuntu-latest
timeout-minutes: 35
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up JDK 17
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: 17
- name: Setup Gradle
uses: gradle/actions/setup-gradle@v6
with:
cache-read-only: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
# Mirrors release-android.yml's build step. No keystore is provided here,
# so app/build.gradle.kts falls back to debug signing — fine for a build
# smoke; the goal is to exercise the build, not to produce a shippable AAB.
- name: Build release bundles + APKs (both flavors, debug-signed)
run: ./gradlew bundleRelease assembleRelease --console=plain
+5 -1
View File
@@ -38,7 +38,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
# Node 24 ships npm 11, matching the npm that generates
# user-docs/package-lock.json. On npm 10 (Node 20), `npm ci` rejects
# the lock over the optional `search-insights` peer dep of bundled
# docsearch. Keep this aligned with the npm used to write the lock.
node-version: 24
cache: npm
cache-dependency-path: user-docs/package-lock.json
+7 -4
View File
@@ -147,10 +147,13 @@ jobs:
- name: Smoke-test tray exe launch
shell: pwsh
run: |
$home = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
New-Item -ItemType Directory -Force -Path $home | Out-Null
$env:USERPROFILE = $home
$env:HOME = $home
# $HOME is a read-only automatic variable in PowerShell (names are
# case-insensitive), so use a distinct scratch name; only the
# $env:HOME / $env:USERPROFILE environment vars are writable.
$smokeHome = Join-Path $env:RUNNER_TEMP 'hermes-tray-smoke-home'
New-Item -ItemType Directory -Force -Path $smokeHome | Out-Null
$env:USERPROFILE = $smokeHome
$env:HOME = $smokeHome
$proc = Start-Process -FilePath tray/src-tauri/target/release/hermes-relay-desktop.exe -WindowStyle Hidden -PassThru
Start-Sleep -Seconds 5
if ($proc.HasExited) { throw "tray app exited early with code $($proc.ExitCode)" }
+15
View File
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
## [Unreleased]
### Added
- **Desktop CLI: `hermes-relay audit`.** Shows what the remote agent has actually run on this machine through the desktop tools — tool, status, and a short detail per call — read from a local log, no network or auth. Answers "what did the agent just do?" at a glance.
- **Desktop CLI: `hermes-relay relay`.** Inspect the relay server itself: `relay info` (version, uptime, sessions — on the relay host), `relay security` (runtime auth toggles), and `relay context` (audit the system-prompt context the relay injects into the agent, which works from a remote machine with your session).
- **Desktop CLI: background daemon.** `hermes-relay daemon start` runs the headless tool router in the background (no console window, survives closing the terminal), with `daemon stop` and `daemon status` to manage it. `daemon status` reports state, uptime, relay, and advertised-tool count; bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
- **Desktop CLI: per-command help.** Every subcommand now answers `--help`, and `devices`/`sessions`/`plugins`/`voice`/`relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
- **Desktop CLI: startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL — and `hermes-relay logo` prints it on demand. Suppressed for piped/`--json`/`--no-color` output.
### Changed
- **Desktop CLI: visual + ergonomics refresh.** A single color theme across the CLI, aligned tables for `devices`/`sessions`, status dots for on/off states, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
- **Desktop CLI: smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Desktop CLI: voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
- **Crash reports can be shared without GitHub.** The crash dialog now has a **Share** action alongside Copy and Report, handing the full report to the system share sheet (email, chat apps, notes, Drive). This covers users without a GitHub account and sideload installs that Play vitals never sees. Every outbound path stays user-initiated — nothing is sent automatically.
## [1.2.0] - 2026-06-20
### Added
+11 -1
View File
@@ -282,7 +282,17 @@ This is a **public, distributed repo** — every committed file (CHANGELOG, DEVL
| `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/chatAttach.ts` | captureClipboardImage / captureScreenshot / readImageFile; ships base64 to server via `image.attach.bytes` RPC before next prompt.submit |
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat` |
| `desktop/src/cli.ts` | argv parser + subcommand dispatcher — bare → `shell` (PTY), positional-only → `chat`; command-scoped `--help` falls through to each command |
| `desktop/src/lib/theme.ts` | Shared ANSI palette + `colorEnabled()` + `Theme` (semantic helpers, `statusDot`) — single visual language; `--no-color`/`NO_COLOR`/TTY aware |
| `desktop/src/lib/table.ts` | Zero-dep column-aligned table renderer (ANSI-width aware, last column flexes to terminal width) — used by devices/sessions/audit |
| `desktop/src/lib/spinner.ts` | Stderr braille spinner for slow ops (pair probe, gateway connect); no-op when piped/quiet/json |
| `desktop/src/lib/usage.ts` | `UsageSpec` + `renderUsage`/`printUsage`/`unknownSubcommand` — per-subcommand `--help` + self-documenting sub-verb fallback |
| `desktop/src/lib/hints.ts` | `suggestedFix(err, ctx)` → next-step command (re-pair on auth fail, etc.); `formatError` renders error + hint |
| `desktop/src/lib/logo.ts` | Slim box-drawing "Hermes Relay" wordmark; shown atop `--help`, first-run welcome, REPL header, and `hermes-relay logo`; theme/no-color aware |
| `desktop/src/lib/auditLog.ts` | Local desktop-tool audit JSONL (`~/.hermes/desktop-audit.jsonl`); router appends per dispatch; backs `audit` command (relay's ring is loopback-only) |
| `desktop/src/lib/daemonStatus.ts` | Daemon heartbeat file (`~/.hermes/daemon-status.json`) + `isPidAlive` liveness; backs `daemon --status` |
| `desktop/src/commands/audit.ts` | `hermes-relay audit` — tails the local audit log into a table (WHEN/TOOL/STATUS/DETAIL); `--limit`, `--json` |
| `desktop/src/commands/relay.ts` | `hermes-relay relay info/security/context` — relay-server management surface; info/security loopback-only, context works remote with bearer |
| `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; `--grant-tools` (TTY prompt) / `--auto-grant-tools` (silent) stamp `toolsConsented` so `daemon` works without a `shell` round-trip |
+11 -22
View File
@@ -1,36 +1,25 @@
# Hermes-Relay-CLI v__VERSION__
**Release Date:** <!-- YYYY-MM-DD -->
**Since the previous CLI release:** <!-- one line: the theme of this release -->
**Release Date:** 2026-06-21
**Since the previous CLI release:** a first-class command surface — activity audit, relay inspection, a background daemon, a polished visual layer, and v1.2.0 server parity.
<!-- One short paragraph: what this desktop/CLI release is about and who should care. -->
<!--
═══ RELEASE-PREP CHECKLIST (delete this comment block when done) ═══
• This file is the GitHub Release body for `cli-v*` tags. The release workflow
substitutes __VERSION__ (bare, e.g. 0.3.0) and __TAG__ (full, e.g. cli-v0.3.0) —
leave those tokens in the Install section; do NOT hardcode versions there.
• Rewrite the Summary + the Added/Changed/Fixed groups from the CLI/desktop-relevant
bullets in CHANGELOG.md's promoted version block.
• Keep-a-Changelog rules: include only the groups that have entries; delete empty ones.
• Keep the "Experimental phase" notice until the CLI reaches GA.
• Scrub for public distribution (RELEASE.md §2): no personal names, no private infra,
no fork-branch plumbing, no AI self-narration.
═══════════════════════════════════════════════════════════════════
-->
This is a broad CLI uplift: new commands for seeing what the agent did and inspecting the relay, a daemon you can run in the background, and a consistent themed interface with per-command help. Everything is additive — existing commands, flags, and scripts keep working.
**Experimental phase.** Assets are unsigned — Windows SmartScreen and macOS Gatekeeper will warn on first launch. Windows ships a tray installer as the primary desktop surface; CLI binaries remain available for terminal/headless use and for macOS/Linux.
## What's changed
### Added
-
- **`hermes-relay audit`** — see what the remote agent has run on this machine through the desktop tools (tool, status, detail), read from a local log. No network, no auth; works whether the relay is local or remote.
- **`hermes-relay relay`** — inspect the relay server: `relay context` audits the system-prompt context the relay injects into the agent (works from any paired machine), and `relay info` / `relay security` report server state for operators on the relay host.
- **Background daemon.** `hermes-relay daemon start` runs the headless tool router in the background — no console window, survives closing the terminal — with `daemon stop` and `daemon status` to manage it. Bare `daemon` still runs in the foreground. Logs go to `~/.hermes/daemon.log`.
- **Per-command help.** Every subcommand answers `--help`, and `devices` / `sessions` / `plugins` / `voice` / `relay` print their own usage (sub-commands, flags, examples) instead of a terse "unknown sub-verb".
- **Startup banner.** A slim "Hermes Relay" wordmark shows atop `--help`, the first-run welcome, and the chat REPL; `hermes-relay logo` prints it on demand. Suppressed for piped / `--json` / `--no-color` output.
### Changed
-
### Fixed
-
- **Visual + ergonomics refresh.** One consistent color theme across the CLI, aligned tables for `devices` / `sessions`, on/off status dots, and progress spinners for slow operations (the multi-endpoint pairing probe and the gateway connect) so nothing looks hung. Errors now suggest the fix (e.g. re-pair on auth failure).
- **Smoother pairing.** The multi-endpoint probe shows per-endpoint progress and latency; a near-expiry session warns before it fails and prints the exact re-pair command; and a bare `ws://host` (no port) defaults to `:8767`.
- **Voice + consent transparency.** `voice` now surfaces enhanced-voice capabilities (Gemini tone tags / persona, xAI speech tags); the desktop-tool consent prompt is clear that it persists per relay and points at `hermes-relay audit`; and computer-use's observe → grant → act flow is documented in `--help`.
## Install
+26
View File
@@ -1,5 +1,31 @@
# Hermes-Relay — Dev Log
## 2026-06-21 — Profile lock + voice fixes (orchestration batch)
**Why.** User-requested batch (TODO User-Added) covering the profile-lock setting and the concrete voice TODOs. Investigated and implemented via a planning→implementation orchestration pass: four read-only investigators, then three disjoint file-ownership implementation lanes. All changes are client-side Kotlin; the server-side realtime-voice half is deferred to TODO. **Unbuilt at time of writing — pending Studio build + `./gradlew lint`.**
- **Profile lock (new).** Per-connection "lock to one profile": `data/ProfileLockStore.kt` (twin of `ProfileSelectionStore`, same `profile_selections` DataStore; `__server_default__` sentinel via `AgentDisplay`); `ProfileController` gains `lockedProfileName`/`isProfileLocked` + `lockProfile`/`unlockProfile`, with `selectProfile` no-op'd when locked and `resolvePendingProfileFrom` preferring (and holding on missing) the locked target; `ConnectionViewModel` delegations + lock-clear at reset/remove sites + a lock-flow observer; `ConnectionInfoSheet` collapses the picker to a static "Locked to <name>" row when locked; `SettingsScreen` adds the `ProfileLockCard` + dialog — the one surface that still lists all profiles, with a "not found on this server" banner.
- **Voice override in 'auto' (fix).** `VoiceViewModel.shouldPreferRealtimeVoice()` gated on `.route` (configured) instead of `.effectiveRoute` (resolved), so 'auto'+relay-ready never engaged the override-capable relay path and fell back to the host-global Standard `/api/audio/speak` (no override slot) — hence only 'Relay' applied the chosen voice. Switched to `effectiveRoute`. Also wired `connectionId` for per-profile voice-prefs namespacing (`RelayApp` calls `setVoicePrefsConnection(activeConnectionId)` and passes `connectionId` to `VoiceSettingsScreen`, which now takes the param and feeds `setActiveScope`).
- **Realtime voice (fix, client half).** Stall: `RelayVoiceClient.awaitRealtimeAgentCompletion` relaxes the 90s idle watchdog once a `hermes.run.promoted`/long run is seen, keeping the 5-min max-turn backstop. Over-chatty status: per-turn throttle in `VoiceViewModel.emitStatus` (≥22s gap, ≤3 spoken/turn). Waveform: realtime `outputAudioActive` now gates on real playback-start (`RealtimePcmPlayer` head-move/`playbackAmplitude`) instead of decoded-byte RMS, matching the basic-TTS path.
- **Voice UI.** Profile icon now shows in the floating overlay header pill (`VoiceModeOverlay` reads `LocalAgentIconPath`; sphere/pet stays the fallback). Voice Settings: invalid engine/route combos made unreachable (RealtimeAgent disabled without relay, unavailable routes disabled, `coerceAudioRoute` auto-corrects on engine switch / relay loss); long dropdown/provider labels get `maxLines=1`+ellipsis.
- **Method.** Disjoint file-ownership lanes (1: VoiceViewModel/RelayVoiceClient/RelayApp; 2: VoiceSettingsScreen/VoiceModeOverlay; 3: ProfileController/ConnectionViewModel/ConnectionInfoSheet/SettingsScreen/ProfileLockStore) so parallel implementers never touched the same file, and `ChatScreen.kt` was avoided (owned by a concurrent session). Pure helpers (`coerceAudioRoute`, `shouldSpeakStatusNow`, `shouldMarkRealtimeOutputActive`) extracted for unit-testing.
- **Deferred.** See TODO.md "Orchestration batch (2026-06-21)": streaming-path override question, upstream per-profile Standard voice, ChatScreen lock glyph, export decision, CHANGELOG entries, on-device verification.
- **Follow-up (same day).** Built + deployed to device as **1.2.1 / versionCode 15** (`:app:assembleSideloadDebug`, clean). Server-side realtime half implemented in `broker.py` (heartbeat-while-task-running + calmer spoken-status cadence) with `plugin/tests/test_realtime_heartbeat.py` (11) + promotion regression (5) green — **deployed**: committed `d1820fb` → pushed to `origin/dev` → server `~/.hermes/hermes-relay` fast-forwarded + `hermes-relay` restarted (active, clean startup on ws://…:8767). New Kotlin unit suite green: `ProfileLockStoreTest` (9, in-memory DataStore harness), `ProfileControllerLockTest` (8, Robolectric), `CoerceAudioRouteTest` (7), `VoiceStatusGatesTest` (12) — 36/36 via `:app:testSideloadDebugUnitTest`.
## 2026-06-21 — Desktop CLI first-class pass (audit-driven)
**Why.** The desktop CLI hadn't had feature work since 2026-05-19 while the relay plugin shipped a full v1.2.0 wave (relay-management surface, enhanced voice, context injection). A four-axis audit (command UX/visuals, pairing, desktop-tools, plugin parity) found the CLI surfaced ~⅓ of current plugin capability with an ad-hoc visual layer and weak discoverability. This pass closes those gaps; all changes are confined to `desktop/` (no Android, no Python).
- **Shared zero-dep UI foundation (`desktop/src/lib/`).** `theme.ts` (one ANSI palette + `colorEnabled` + `Theme` with `statusDot`/semantic helpers, extracted from `renderer.ts`'s pattern), `table.ts` (ANSI-width-aware column renderer, last column flexes to terminal width), `spinner.ts` (stderr braille spinner, no-op when piped/quiet/json), `hints.ts` (`suggestedFix(err)` → next-step command + `formatError`), `usage.ts` (`UsageSpec` → per-subcommand `--help` + self-documenting unknown-sub-verb), `logo.ts` (slim box-drawing wordmark).
- **Discoverability.** Fixed the `cli.ts` dispatch so command-scoped `--help` reaches the command (was always short-circuiting to global help). Added `--help` + usage specs across `devices`/`sessions`/`status`/`tools`/`plugins`/`voice`/`relay`/`pair`/`daemon`/`doctor`/`workspace`/`paste`; ported list output (`devices`/`sessions`) to aligned tables + status dots; routed command failures through `formatError` (actionable hints); replaced `doctor`'s inconsistent `!!` warning markers with themed `⚠` lines.
- **Pairing.** Threaded an `onProbe` callback into `probeCandidatesByPriority` so `pair` shows per-endpoint progress + latency during the multi-endpoint race; `credentials.ts` warns (TTY-only) when a stored token is near/at expiry with the exact re-pair command; `relayUrlPrompt.normalizeRelayUrl` defaults a bare `ws://host` to `:8767` (scoped to `ws://` so `wss://` proxy fronts on :443 aren't broken), surfaced not silent.
- **Desktop tools first-class.** New `hermes-relay audit` backed by a local JSONL (`~/.hermes/desktop-audit.jsonl`) the `DesktopToolRouter` appends per dispatch — the relay's ring buffer is loopback-only, so the client (the executor) is the right source of truth and this works against a remote relay with no auth. Consent prompt rewritten to state persistence + point at `audit`; computer-use's observe→grant→act flow documented in `--help`.
- **Daemon observability + background run.** `daemon` writes a heartbeat file (`~/.hermes/daemon-status.json`) on each lifecycle transition + a 30s tick; `daemon status` reads it, cross-checks pid liveness (`process.kill(pid,0)`), and exits non-zero when stale. Added `daemon start` (detached spawn — `detached:true` + `windowsHide:true` + stdio→`~/.hermes/daemon.log` + `unref`, no console window, survives terminal close) and `daemon stop` (kills the status-file pid + clears it); bare `daemon` still runs foreground. Validated start→status→stop on Windows against the live relay. A true OS service (reboot/login auto-start) remains the deferred follow-up.
- **Dev loop.** Added `desktop/scripts/dev-install.mjs` + `npm run dev:install` — builds the bun binary for the current platform and drops it over the curl-installed `~/.hermes/bin/` binary (backs the old one up as `.bak`, surfaces EBUSY as "stop the daemon first"). Closes the gap where local changes could only be exercised via `npx tsx`, never as the real global binary.
- **Plugin v1.2.0 parity.** `voice` now renders the `/voice/config` `enhanced` block (Gemini tone-tags/persona, xAI speech-tags). New `hermes-relay relay info|security|context` over the relay-management surface — `context` (the injected-system-prompt audit) works remote with a bearer; `info`/`security` are loopback-only and say so on a remote 403. Deliberately did **not** add a CLI-vs-server "version skew" warning — the two are on independent release tracks, so it would be a false alarm.
- **Logo.** Slim box-drawing "Hermes Relay" wordmark atop `--help`, the first-run welcome, the chat REPL, and a `logo` command; theme/no-color aware, never on piped/`--json` stdout.
- **Verification.** `npm run type-check` and `npm run build` (tsc) green. Runtime-smoked via `npx tsx src/cli.ts` (NO_COLOR): `--help`, `logo`, `devices --help`/`devices bogus` (usage fallback), `audit` (empty-state), `daemon --status` (no-daemon), `doctor`, `workspace`. Docs: CHANGELOG `[Unreleased]`, `desktop/README.md` (audit/relay/daemon-status sections), CLAUDE.md desktop Key Files refreshed. Version bump (`alpha.18`→`alpha.19`) left to the operator — not cutting a CLI release this cycle.
## 2026-06-20 — Release-prep: android-v1.2.0 + plugin-v1.2.0
**Why.** Cut a combined 1.2.0 across both lockstep surfaces (both were at 1.1.0). The accumulated `[Unreleased]` block had captured the major feature arcs but a second wave had landed undocumented — audited every commit since the `*-v1.1.0` tags and backfilled the changelog before promoting it.
+57
View File
@@ -387,6 +387,63 @@ Toolsets: 18 (12 enabled)
Pass `--verbose` to list every tool inside each toolset.
### Audit — what the agent ran on this machine
```sh
hermes-relay audit # last 50 desktop-tool calls
hermes-relay audit --limit 20
hermes-relay audit --json
```
```
Desktop-tool activity (4 most recent)
WHEN TOOL STATUS DETAIL
12s ago desktop_read_file ● ok path=C:\src\app.ts
10s ago desktop_terminal ● ok exit 0
8s ago desktop_write_file ✗ error EACCES: permission denied
2s ago desktop_search ● ok pattern=TODO
```
Read from a local log (`~/.hermes/desktop-audit.jsonl`) the tool router writes whenever the agent runs a `desktop_*` tool — no network, no auth, works whether the relay is local or remote.
### Relay — inspect the server
```sh
hermes-relay relay context # what context the relay injects into the agent's prompt
hermes-relay relay info # version, uptime, sessions (run on the relay host)
hermes-relay relay security # runtime auth toggles (run on the relay host)
```
`relay context` works from any paired machine; `relay info` / `relay security` are loopback-only (for operators on the relay host) and say so if reached remotely.
### Daemon — background tool router
```sh
hermes-relay daemon start # run in the background (no console window)
hermes-relay daemon status # state + uptime of the running daemon
hermes-relay daemon stop # stop it
hermes-relay daemon # run in the FOREGROUND (current console)
```
`daemon start` detaches the headless tool router so it keeps running after you close the terminal — the agent can reach your machine any time, not just while a shell is open. It logs to `~/.hermes/daemon.log`. Bare `hermes-relay daemon` still runs in the foreground (handy for watching logs live or running under your own supervisor).
```
$ hermes-relay daemon status
hermes-relay daemon
state: ● connected
pid: 48213
relay: ws://172.16.24.250:8767
uptime: 3h 12m
updated: 4s ago
server: 1.2.0
tools: 23 advertised
```
`status` reads the heartbeat file a running daemon maintains and cross-checks that the pid is alive — it exits non-zero (and says "not running") when the daemon is gone, so scripts can branch on it.
> **Auto-start on boot/login** (survive a reboot, not just a closed terminal) needs an OS service — a Windows service, a systemd user unit, or a launchd agent. Those installers aren't shipped yet; for now `daemon start` covers "background process, this session."
## Flags and environment
| Flag | Env | Purpose |
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hermes-relay/cli",
"version": "0.3.0-alpha.18",
"version": "0.4.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": {
@@ -42,6 +42,7 @@
"smoke": "npm run build:bin:win && node -e \"const{execFileSync}=require('child_process');const bin='./dist/bin/hermes-relay-win-x64.exe';const pkg=require('./package.json');for(const a of [['--version'],['--help'],['doctor'],['workspace']]){const out=execFileSync(bin,a,{encoding:'utf8'});if(!out||out.length<10)throw new Error('smoke FAIL: '+bin+' '+a.join(' ')+' produced no output');console.log('smoke OK: '+a.join(' ')+' ('+out.split('\\n')[0]+')')}const updOut=execFileSync(bin,['update','--check','--json'],{encoding:'utf8'});const parsed=JSON.parse(updOut);if(parsed.current!==pkg.version)throw new Error('smoke FAIL: update --check --json current='+parsed.current+' != package.json version='+pkg.version);console.log('smoke OK: update --check --json (current='+parsed.current+', up_to_date='+parsed.up_to_date+')')\"",
"prepublishOnly": "npm run build",
"dev": "tsx src/cli.ts",
"dev:install": "node scripts/dev-install.mjs",
"type-check": "tsc --noEmit -p tsconfig.json",
"clean": "rimraf dist"
},
+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
// Local dev install — build the bun binary for THIS platform and drop it over
// the curl-installed binary at ~/.hermes/bin/, so `hermes-relay` on your PATH
// runs your working tree. This is the "test my changes as the REAL binary"
// loop; it is NOT the release path (that's .github/workflows/release-desktop.yml).
//
// npm run dev:install # build + replace the installed binary
//
// The previous binary is saved next to it as `.bak` (reversible). On Windows a
// running daemon locks the .exe — this surfaces that clearly instead of EBUSY.
import { execSync } from 'node:child_process'
import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs'
import { arch, homedir, platform } from 'node:os'
import { join } from 'node:path'
const plat = platform()
const isWin = plat === 'win32'
// Map the current platform to the package.json build target + its artifact name.
const target = {
win32: { script: 'build:bin:win', out: 'hermes-relay-win-x64.exe' },
linux: { script: 'build:bin:linux', out: 'hermes-relay-linux-x64' },
darwin:
arch() === 'arm64'
? { script: 'build:bin:mac-arm', out: 'hermes-relay-darwin-arm64' }
: { script: 'build:bin:mac-x64', out: 'hermes-relay-darwin-x64' }
}[plat]
if (!target) {
console.error(`dev-install: unsupported platform ${plat}`)
process.exit(1)
}
const binDir = join(homedir(), '.hermes', 'bin')
const installed = join(binDir, isWin ? 'hermes-relay.exe' : 'hermes-relay')
const backup = installed + '.bak'
const built = join('dist', 'bin', target.out)
console.log(`dev-install: building ${target.script} (bun --compile)…`)
execSync(`npm run ${target.script}`, { stdio: 'inherit' })
if (!existsSync(built)) {
console.error(`dev-install: expected build artifact is missing: ${built}`)
process.exit(1)
}
mkdirSync(binDir, { recursive: true })
// Move the current binary aside (reversible). On Windows you can't overwrite a
// running .exe, so a held lock means a live daemon — say so plainly.
if (existsSync(installed)) {
if (existsSync(backup)) {
rmSync(backup, { force: true })
}
try {
renameSync(installed, backup)
} catch (e) {
console.error(`dev-install: couldn't move the current binary aside (${e.code}).`)
if (e.code === 'EBUSY' || e.code === 'EPERM') {
console.error(' A daemon is probably running from it. Check + stop it first:')
console.error(' hermes-relay daemon --status')
}
process.exit(1)
}
}
try {
copyFileSync(built, installed)
if (!isWin) {
execSync(`chmod +x "${installed}"`)
}
} catch (e) {
console.error(`dev-install: copy failed (${e.code}); restoring the previous binary.`)
if (existsSync(backup)) {
renameSync(backup, installed)
}
process.exit(1)
}
const sizeMb = (statSync(installed).size / (1024 * 1024)).toFixed(0)
console.log(`dev-install: installed ${installed} (${sizeMb} MB)`)
try {
const ver = execSync(`"${installed}" --version`, { encoding: 'utf8' }).trim()
console.log(`dev-install: ${ver}`)
} catch {
/* version readback is best-effort */
}
console.log(`dev-install: done — previous binary saved as ${backup} (delete when happy).`)
+37 -5
View File
@@ -3,6 +3,7 @@
// 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 { auditCommand } from './commands/audit.js'
import { chatCommand } from './commands/chat.js'
import { chatWorkerCommand } from './commands/chatWorker.js'
import { daemonCommand } from './commands/daemon.js'
@@ -11,6 +12,7 @@ import { doctorCommand } from './commands/doctor.js'
import { pairCommand } from './commands/pair.js'
import { pasteCommand } from './commands/paste.js'
import { pluginsCommand } from './commands/plugins.js'
import { relayCommand } from './commands/relay.js'
import { sessionsCommand } from './commands/sessions.js'
import { shellCommand } from './commands/shell.js'
import { statusCommand } from './commands/status.js'
@@ -18,6 +20,8 @@ import { toolsCommand } from './commands/tools.js'
import { updateCommand } from './commands/update.js'
import { voiceCommand } from './commands/voice.js'
import { workspaceCommand } from './commands/workspace.js'
import { renderLogo } from './lib/logo.js'
import { theme as makeTheme } from './lib/theme.js'
import { finalizePendingUpdate } from './updater.js'
import { VERSION } from './version.js'
@@ -57,6 +61,8 @@ const BOOLEAN_FLAGS = new Set([
'auto-grant-tools',
'log-human',
'log-json',
'status',
'detach',
'allow-tools',
'allow-computer-use',
'experimental-computer-use',
@@ -126,6 +132,7 @@ function parseArgs(argv: string[]): ParsedArgs {
}
const KNOWN_COMMANDS = new Set([
'audit',
'chat',
'chat-worker',
'daemon',
@@ -133,6 +140,7 @@ const KNOWN_COMMANDS = new Set([
'doctor',
'paste',
'pair',
'relay',
'sessions',
'shell',
'plugins',
@@ -156,13 +164,16 @@ Usage:
hermes-relay sessions List / resume / create / kill TUI tmux sessions
hermes-relay status Show stored sessions + grants + TTL
hermes-relay tools List tools available on the server
hermes-relay audit Show what the agent ran on this machine (desktop tools)
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 relay Inspect the relay server (info / security / injected context)
hermes-relay daemon [start|stop|status] Headless tool router — 'start' runs it in the background
hermes-relay doctor Diagnostic report: version, paths, sessions, daemon status
hermes-relay update Check for and install the latest cli-v* release
hermes-relay voice Show native Hermes voice config (STT/TTS/realtime providers)
hermes-relay voice mode Push-to-talk in a browser tab (proxied through this CLI)
hermes-relay workspace Print local workspace context (cwd, git, editor, shell) — --json for scripting
hermes-relay logo Print the Hermes Relay banner
hermes-relay help Show this help
hermes-relay --version Print version and exit
@@ -184,8 +195,14 @@ Flags:
--no-tools chat/shell: disable local tool handlers (fs, exec, search)
--experimental-computer-use
chat/shell/daemon: advertise experimental desktop_computer_*
tools after normal desktop-tool consent. Host input still
requires task grant plus visible local approval.
tools (screenshots + mouse/keyboard). Three-stage safety:
1. observe — desktop_computer_screenshot/status need only
the normal desktop-tool consent (no extra approval).
2. grant — desktop_computer_grant_request(mode=assist|control)
pops a visible local prompt you approve (or a file-bridge
in headless via HERMES_RELAY_GRANT_BRIDGE_DIR).
3. act — desktop_computer_action runs only while a grant is
live (default 15 min); desktop_computer_cancel ends it.
Env: HERMES_RELAY_EXPERIMENTAL_COMPUTER_USE=1
--no-computer-use Disable computer-use advertisement even if env enabled.
--grant-tools pair: prompt for desktop-tool consent during pairing (TTY required;
@@ -253,8 +270,19 @@ export async function main(argv = process.argv): Promise<number> {
return 0
}
if (args.flags.help || args.command === 'help') {
process.stdout.write(HELP)
const noColor = !!args.flags['no-color']
// Global help only when there is no command (bare `--help` / `help`). When a
// command is present, `--help` falls through to it so each subcommand can
// print its own usage (e.g. `hermes-relay devices --help`).
if ((args.flags.help && !args.command) || args.command === 'help') {
process.stdout.write(renderLogo({ theme: makeTheme({ noColor }), subtitle: false }) + '\n' + HELP)
return 0
}
// Explicit on-demand logo (handy for screenshots / docs).
if (args.command === 'logo' || args.command === 'banner') {
process.stdout.write(renderLogo({ theme: makeTheme({ noColor }) }))
return 0
}
@@ -278,6 +306,8 @@ export async function main(argv = process.argv): Promise<number> {
}
switch (args.command) {
case 'audit':
return auditCommand(args)
case 'chat':
return chatCommand(args)
case 'chat-worker':
@@ -294,6 +324,8 @@ export async function main(argv = process.argv): Promise<number> {
return pasteCommand(args)
case 'plugins':
return pluginsCommand(args)
case 'relay':
return relayCommand(args)
case 'sessions':
return sessionsCommand(args)
case 'shell':
+84
View File
@@ -0,0 +1,84 @@
// audit — show what the remote Hermes agent has run on THIS machine via the
// desktop tool router. Reads the local JSONL written by DesktopToolRouter
// (~/.hermes/desktop-audit.jsonl) — no network, no auth, works whether the
// relay is local or remote. Answers the "what did the agent just do?" question
// the audit flagged as the biggest desktop-tools transparency gap.
import type { ParsedArgs } from '../cli.js'
import { auditLogPath, readRecentAudit } from '../lib/auditLog.js'
import { renderTable } from '../lib/table.js'
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
const AUDIT_USAGE: UsageSpec = {
name: 'audit',
summary: 'show recent desktop-tool activity the agent ran on this machine',
usage: ['audit [--limit <n>] [--json]'],
flags: [
{ flag: '--limit <n>', desc: 'How many recent entries to show (default 50)' },
{ flag: '--json', desc: 'Emit raw audit entries as JSON' }
],
examples: ['hermes-relay audit', 'hermes-relay audit --limit 20']
}
function humanAge(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000))
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86_400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86_400)}d`
}
export async function auditCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(AUDIT_USAGE, t)
return 0
}
const rawLimit = typeof args.flags.limit === 'string' ? parseInt(args.flags.limit, 10) : 50
const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? rawLimit : 50
const entries = await readRecentAudit(limit)
if (args.flags.json) {
process.stdout.write(JSON.stringify(entries, null, 2) + '\n')
return 0
}
if (entries.length === 0) {
process.stdout.write(
t.muted('No desktop-tool activity recorded on this machine yet.') + '\n' +
t.muted(` (log: ${auditLogPath()} — written when the agent runs a desktop_* tool)`) + '\n'
)
return 0
}
const now = Date.now()
const rows = entries.map((e) => {
const status = e.ok
? `${t.statusDot(true)} ok`
: e.aborted
? `${t.warn(SYMBOLS.warn)} aborted`
: `${t.err(SYMBOLS.err)} error`
const detail = e.error ?? e.summary ?? e.args_preview ?? ''
return [`${humanAge(now - e.ts)} ago`, e.tool, status, detail]
})
process.stdout.write(t.bold(`Desktop-tool activity (${entries.length} most recent)`) + '\n\n')
process.stdout.write(
renderTable(
[
{ header: 'WHEN', align: 'right' },
{ header: 'TOOL' },
{ header: 'STATUS' },
{ header: 'DETAIL' }
],
rows,
{ theme: t }
) + '\n'
)
return 0
}
export default auditCommand
+3
View File
@@ -25,7 +25,9 @@ import type {
SessionResumeResponse
} from '../gatewayTypes.js'
import { setupGracefulExit } from '../lib/gracefulExit.js'
import { renderLogo } from '../lib/logo.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { theme as makeTheme } from '../lib/theme.js'
import { deleteSession, saveSession } from '../remoteSessions.js'
import { CliRenderer } from '../renderer.js'
import { fetchRecentSessions, pickSession } from '../sessionPicker.js'
@@ -549,6 +551,7 @@ export async function chatCommand(args: ParsedArgs): Promise<number> {
}
// REPL mode.
process.stderr.write('\n' + renderLogo({ theme: makeTheme({ noColor: !!args.flags['no-color'] }) }))
process.stderr.write(
'\nType a message. Ctrl+C to interrupt a turn, /help for slash commands, /quit to exit.\n'
)
+249 -1
View File
@@ -31,14 +31,25 @@
// after the shell detaches; see roadmap for pause-while-interactive).
// - --log-file <path>: for now, redirect stderr if you need a file.
import { promises as fs } from 'node:fs'
import { spawn } from 'node:child_process'
import { openSync, promises as fs } from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import type { ParsedArgs } from '../cli.js'
import { GatewayClient } from '../gatewayClient.js'
import type { GatewayEvent, SessionCreateResponse } from '../gatewayTypes.js'
import {
clearDaemonStatus,
isPidAlive,
readDaemonStatus,
writeDaemonStatus,
type DaemonState,
type DaemonStatus
} from '../lib/daemonStatus.js'
import { rpcErrorMessage, asRpcResult } from '../lib/rpc.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { getSession } from '../remoteSessions.js'
import {
@@ -54,6 +65,33 @@ import { startVoiceServer, type VoiceServer } from '../voiceServer.js'
const VOICE_DISCOVERY_FILE = 'desktop-voice.json'
/** Refresh the status-file `updated_at` on this cadence so `--status` can tell
* a live daemon from a crashed one whose file lingers. */
const STATUS_HEARTBEAT_MS = 30_000
const DAEMON_USAGE: UsageSpec = {
name: 'daemon',
summary: 'run headless — expose desktop tools to the agent even when no shell is open',
usage: ['daemon [run]', 'daemon start', 'daemon stop', 'daemon status'],
subcommands: [
{ verb: 'run', desc: 'Run in the foreground (current console; default)' },
{ verb: 'start', desc: 'Start in the background — no console window; survives terminal close' },
{ verb: 'stop', desc: 'Stop the background daemon' },
{ verb: 'status', desc: 'Print state + uptime of the running daemon (alias: --status)' }
],
flags: [
{ flag: '--detach', desc: 'Alias for `daemon start` — run in the background' },
{ flag: '--remote <url>', desc: 'Relay to connect to (default: stored/active session)' },
{ flag: '--token <token>', desc: 'Use an explicit session token (CI/provisioning)' },
{ flag: '--allow-tools', desc: 'Skip the stored-consent gate (only with --token; implies trust)' },
{ flag: '--no-voice', desc: 'Do not start the loopback voice server' },
{ flag: '--log-human', desc: 'Human-readable logs (auto on a TTY)' },
{ flag: '--log-json', desc: 'Force JSON-line logs even on a TTY' },
{ flag: '--experimental-computer-use', desc: 'Also advertise computer-use tools (see top-level help)' }
],
examples: ['hermes-relay daemon start', 'hermes-relay daemon status', 'hermes-relay daemon stop']
}
type LogLevel = 'info' | 'warn' | 'error'
interface LogFields {
@@ -95,7 +133,184 @@ function resolveRemoteOrNull(args: ParsedArgs): string | null {
return url ? url.trim() : null
}
function fmtAge(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`
}
/** `daemon --status` — read the status file and report. Exit 0 if a daemon is
* live, 1 if the file is stale (pid gone) so scripts can branch on it. */
async function printDaemonStatus(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const status = await readDaemonStatus()
if (!status) {
process.stdout.write(
t.muted('No daemon status file — the daemon is not running (or has never run).') + '\n'
)
return 1
}
const alive = isPidAlive(status.pid)
if (args.flags.json) {
process.stdout.write(JSON.stringify({ ...status, alive }, null, 2) + '\n')
return alive ? 0 : 1
}
const now = Math.floor(Date.now() / 1000)
const staleSec = Math.max(0, now - status.updated_at)
const stale = staleSec > (STATUS_HEARTBEAT_MS / 1000) * 3
const kv = (label: string, value: string): string => ` ${t.muted((label + ':').padEnd(9))} ${value}`
process.stdout.write(t.bold('hermes-relay daemon') + '\n')
if (!alive) {
process.stdout.write(kv('state', `${t.err('not running')} ${t.muted(`(pid ${status.pid} gone — stale file)`)}`) + '\n')
} else {
const label =
status.state === 'connected'
? t.ok('connected')
: status.state === 'reconnecting'
? t.warn('reconnecting')
: status.state
process.stdout.write(
kv('state', `${t.statusDot(status.state === 'connected')} ${label}${stale ? t.warn(' (heartbeat stale)') : ''}`) + '\n'
)
}
process.stdout.write(kv('pid', String(status.pid)) + '\n')
process.stdout.write(kv('relay', status.url) + '\n')
process.stdout.write(kv('uptime', fmtAge(Math.max(0, now - status.started_at))) + '\n')
process.stdout.write(kv('updated', `${fmtAge(staleSec)} ago`) + '\n')
if (status.server_version) {
process.stdout.write(kv('server', status.server_version) + '\n')
}
if (typeof status.advertised_tools === 'number') {
process.stdout.write(kv('tools', `${status.advertised_tools} advertised`) + '\n')
}
if (status.voice_url) {
process.stdout.write(kv('voice', status.voice_url) + '\n')
}
return alive ? 0 : 1
}
function daemonLogPath(): string {
return path.join(os.homedir(), '.hermes', 'daemon.log')
}
/** Rebuild the child argv for the foreground daemon from this invocation's
* flags, so `daemon start --remote … --experimental-computer-use` forwards. */
function buildDaemonChildArgs(args: ParsedArgs): string[] {
const out: string[] = ['daemon']
const fwdValue = (name: string) => {
const v = args.flags[name]
if (typeof v === 'string') {
out.push(`--${name}`, v)
}
}
const fwdBool = (name: string) => {
if (args.flags[name] === true) {
out.push(`--${name}`)
}
}
fwdValue('remote')
fwdValue('token')
for (const f of [
'allow-tools',
'no-voice',
'log-json',
'log-human',
'experimental-computer-use',
'no-computer-use',
'no-color'
]) {
fwdBool(f)
}
return out
}
/** `daemon start` / `--detach` — spawn the foreground daemon as a detached
* background process (no console window on Windows), logging to a file. */
async function startDetachedDaemon(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const existing = await readDaemonStatus()
if (existing && isPidAlive(existing.pid)) {
process.stderr.write(
t.warnLine(`daemon already running (pid ${existing.pid}) — stop it first: hermes-relay daemon stop`) + '\n'
)
return 1
}
const logPath = daemonLogPath()
try {
await fs.mkdir(path.dirname(logPath), { recursive: true })
} catch {
/* best-effort */
}
const logFd = openSync(logPath, 'a')
// Compiled binary: the entry is embedded, so exe + args is enough. Running
// via node/tsx during dev: include the script path so the child re-enters
// the CLI (`node dist/cli.js daemon …`).
const childArgs = buildDaemonChildArgs(args)
const execIsNode = /node(\.exe)?$/i.test(path.basename(process.execPath))
const spawnArgs = execIsNode ? [process.argv[1] ?? '', ...childArgs] : childArgs
const child = spawn(process.execPath, spawnArgs, {
detached: true,
stdio: ['ignore', logFd, logFd],
windowsHide: true
})
child.unref()
process.stdout.write(t.okLine(`daemon started in the background (pid ${child.pid})`) + '\n')
process.stdout.write(t.muted(` logs: ${logPath}`) + '\n')
process.stdout.write(t.muted(' status: hermes-relay daemon status') + '\n')
process.stdout.write(t.muted(' stop: hermes-relay daemon stop') + '\n')
return 0
}
/** `daemon stop` — terminate the running background daemon by its status pid. */
async function stopDaemon(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const status = await readDaemonStatus()
if (!status) {
process.stdout.write(t.muted('No daemon status file — nothing to stop.') + '\n')
return 1
}
if (!isPidAlive(status.pid)) {
await clearDaemonStatus()
process.stdout.write(t.muted(`Daemon (pid ${status.pid}) is already gone — cleared stale status.`) + '\n')
return 0
}
try {
// Default SIGTERM; on Windows this terminates the process. The daemon's own
// cleanup may not run on a hard Windows terminate, so we clear status here.
process.kill(status.pid)
} catch (e) {
process.stderr.write(t.err(`failed to stop daemon pid ${status.pid}: ${(e as Error).message}`) + '\n')
return 1
}
await clearDaemonStatus()
process.stdout.write(t.okLine(`stopped daemon (pid ${status.pid})`) + '\n')
return 0
}
export async function daemonCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(DAEMON_USAGE, makeTheme({ noColor: !!args.flags['no-color'] }))
return 0
}
const sub = args.positional[0]
if (args.flags.status || sub === 'status') {
return printDaemonStatus(args)
}
if (sub === 'stop') {
return stopDaemon(args)
}
if (sub === 'start' || args.flags.detach) {
return startDetachedDaemon(args)
}
// Bare `daemon` (or `daemon run`) → foreground, the existing behavior below.
// 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']
@@ -182,6 +397,22 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
node: process.version
})
// Observable status file — `hermes-relay daemon --status` reads this.
const nowSec = () => Math.floor(Date.now() / 1000)
const status: DaemonStatus = {
pid: process.pid,
url,
state: 'starting',
started_at: nowSec(),
updated_at: nowSec(),
last_event: 'starting'
}
const updateStatus = (partial: Partial<DaemonStatus> & { state?: DaemonState }) => {
Object.assign(status, partial, { updated_at: nowSec() })
void writeDaemonStatus(status)
}
updateStatus({})
const relay = new RelayTransport({
url,
sessionToken: token,
@@ -197,9 +428,11 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
? (info as { attempt?: number; delayMs?: number })
: {}
log.warn({ event: 'reconnecting', attempt: attempt ?? null, delay_ms: delayMs ?? null })
updateStatus({ state: 'reconnecting', last_event: 'reconnecting' })
})
relay.on('reconnected', () => {
log.info({ event: 'reconnected' })
updateStatus({ state: 'connected', last_event: 'reconnected' })
})
relay.on('exit', (code: unknown) => {
// Transport gave up (auth.fail, reconnect gate returned false, or
@@ -228,6 +461,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
server_version: relay.serverVersion ?? null,
transport: relay.authMeta?.transportHint ?? null
})
updateStatus({ state: 'connected', server_version: relay.serverVersion ?? null, last_event: 'authed' })
// Signal downstream handlers that we're running headless. The router
// also checks this env var in its detectInteractive() fallback, so any
@@ -262,6 +496,13 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
experimental_computer_use: computerUseEnabled,
interactive
})
updateStatus({ advertised_tools: [...advertisedTools].length, last_event: 'ready' })
// Keep the status file's updated_at fresh so `--status` can distinguish a
// live daemon from a crashed one whose file lingers (belt-and-suspenders
// with the pid liveness check).
const statusHeartbeat = setInterval(() => updateStatus({}), STATUS_HEARTBEAT_MS)
statusHeartbeat.unref?.()
// ── Voice server ──────────────────────────────────────────────────
// Hosts the same loopback HTTP voice surface that `voice mode` starts
@@ -296,6 +537,7 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
url: voiceServer.url,
session_id: voiceSessionId.slice(0, 8)
})
updateStatus({ voice_url: voiceServer.url, last_event: 'voice_ready' })
} catch (e) {
log.warn({
event: 'voice_unavailable',
@@ -309,6 +551,12 @@ export async function daemonCommand(args: ParsedArgs): Promise<number> {
// (closes the WSS), then let setupGracefulExit's failsafe exit us.
const cleanup = async () => {
log.info({ event: 'shutdown' })
clearInterval(statusHeartbeat)
try {
await clearDaemonStatus()
} catch {
/* ignore */
}
try {
if (voiceServer) await voiceServer.close()
} catch {
+114 -61
View File
@@ -24,10 +24,39 @@
import { humanExpiry } from '../banner.js'
import type { ParsedArgs } from '../cli.js'
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
import { formatError } from '../lib/hints.js'
import { renderTable } from '../lib/table.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import { getSession, listSessions } from '../remoteSessions.js'
const DEFAULT_EXTEND_TTL_SECONDS = 24 * 3600
const DEVICES_USAGE: UsageSpec = {
name: 'devices',
summary: 'manage the devices paired with a relay (server-side sessions)',
usage: [
'devices [list]',
'devices revoke <prefix>',
'devices extend <prefix> [--ttl <seconds>]'
],
subcommands: [
{ verb: 'list', desc: 'List paired devices (default)' },
{ verb: 'revoke <prefix>', desc: 'Delete a session token by prefix' },
{ verb: 'extend <prefix>', desc: 'Push out a session expiry (default +24h)' }
],
flags: [
{ flag: '--remote <url>', desc: 'Relay to target (default: tray-active or sole stored)' },
{ flag: '--ttl <seconds>', desc: 'extend: new TTL in seconds (default 86400)' },
{ flag: '--json', desc: 'Machine-readable output' }
],
examples: [
'hermes-relay devices',
'hermes-relay devices revoke e35a85b2',
'hermes-relay devices extend e35a85b2 --ttl 604800'
]
}
interface ServerSession {
token_prefix: string
device_name?: string
@@ -133,6 +162,19 @@ async function jsonFetch(
return { status: res.status, body }
}
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`
}
async function listDevices(args: ParsedArgs): Promise<number> {
const { url, token } = await resolveRemoteAndToken(args)
const httpBase = wsToHttp(url)
@@ -150,52 +192,63 @@ async function listDevices(args: ParsedArgs): Promise<number> {
return 0
}
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (sessions.length === 0) {
process.stdout.write(`(no paired devices on ${url})\n`)
process.stdout.write(t.muted(`(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 nowSec = Math.floor(Date.now() / 1000)
const rows = sessions.map((s) => {
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')
}
const nameCell = s.is_current ? `${name} ${t.muted('(this device)')}` : name
const lastSeen = s.last_seen ? `${humanAge(nowSec - s.last_seen)} ago` : '—'
const grants =
s.grants && Object.keys(s.grants).length > 0
? Object.entries(s.grants)
.map(([k, v]) => `${k}=${v === null ? 'never' : humanExpiry(v)}`)
.sort()
.join(', ')
: '—'
return [
s.token_prefix,
nameCell,
lastSeen,
humanExpiry(s.expires_at ?? null),
s.transport_hint ?? '—',
grants
]
})
process.stdout.write(t.bold(`Devices paired with ${url} (${sessions.length})`) + '\n\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`
renderTable(
[
{ header: 'PREFIX' },
{ header: 'DEVICE' },
{ header: 'LAST SEEN' },
{ header: 'EXPIRES' },
{ header: 'TRANSPORT' },
{ header: 'GRANTS' }
],
rows,
{ theme: t }
) + '\n'
)
process.stdout.write(
'\n' +
t.muted('revoke: hermes-relay devices revoke <prefix> extend: … extend <prefix> --ttl <s>') +
'\n'
)
return 0
}
async function revokeDevice(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const prefix = args.positional[0]
if (!prefix) {
process.stderr.write('error: `devices revoke` needs a token prefix. Run `devices` to see them.\n')
process.stderr.write('error: `devices revoke` needs a token prefix. Run `hermes-relay devices` to list them.\n')
return 2
}
const { url, token } = await resolveRemoteAndToken(args)
@@ -205,7 +258,9 @@ async function revokeDevice(args: ParsedArgs): Promise<number> {
})
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`)
process.stdout.write(
`${t.okLine(`revoked ${prefix}`)}${revokedSelf ? t.muted(' (this device — subsequent commands will re-pair)') : ''}\n`
)
return 0
}
if (status === 404) {
@@ -221,9 +276,10 @@ async function revokeDevice(args: ParsedArgs): Promise<number> {
}
async function extendDevice(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const prefix = args.positional[0]
if (!prefix) {
process.stderr.write('error: `devices extend` needs a token prefix. Run `devices` to see them.\n')
process.stderr.write('error: `devices extend` needs a token prefix. Run `hermes-relay devices` to list them.\n')
return 2
}
const rawTtl = typeof args.flags.ttl === 'string' ? args.flags.ttl : null
@@ -241,9 +297,9 @@ async function extendDevice(args: ParsedArgs): Promise<number> {
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'
t.okLine(
`extended ${prefix} — now expires ${humanExpiry(expiresAt)}${expiresAt === null ? ' (never)' : ''}`
) + '\n'
)
return 0
}
@@ -252,43 +308,40 @@ async function extendDevice(args: ParsedArgs): Promise<number> {
}
export async function devicesCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(DEVICES_USAGE, t)
return 0
}
// 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'
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
const run = async (fn: (a: ParsedArgs) => Promise<number>): Promise<number> => {
try {
return await fn(args)
} catch (e) {
process.stderr.write(formatError(e, { command: 'devices', url }, t) + '\n')
return 1
}
}
if (sub === 'list') {
if (args.positional.length > 0 && args.positional[0] === 'list') {
if (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
}
return run(listDevices)
}
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
}
return run(revokeDevice)
}
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
}
return run(extendDevice)
}
process.stderr.write(`unknown devices sub-verb "${sub}". Try: list | revoke <prefix> | extend <prefix>\n`)
return 2
return unknownSubcommand(DEVICES_USAGE, sub, t)
}
+31 -11
View File
@@ -19,12 +19,22 @@ import { fileURLToPath } from 'node:url'
import { humanExpiry } from '../banner.js'
import type { ParsedArgs } from '../cli.js'
import { theme as makeTheme, type Theme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { listSessions } from '../remoteSessions.js'
import { VERSION } from '../version.js'
import { detectWorkspaceContext, type WorkspaceContext } from '../workspaceContext.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const DOCTOR_USAGE: UsageSpec = {
name: 'doctor',
summary: 'local diagnostic report: version, binary path, PATH, stored sessions, daemon, workspace',
usage: ['doctor [--json]'],
flags: [{ flag: '--json', desc: 'Machine-readable report (safe to paste — tokens omitted)' }],
examples: ['hermes-relay doctor', 'hermes-relay doctor --json']
}
function readVersion(): string {
if (VERSION) {
return VERSION
@@ -194,19 +204,19 @@ async function gather(): Promise<DoctorReport> {
}
}
function renderHuman(report: DoctorReport): string {
function renderHuman(report: DoctorReport, t: Theme): 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})`)
lines.push(t.bold('hermes-relay doctor'))
lines.push(` ${t.muted('version: ')} ${report.version}`)
lines.push(` ${t.muted('binary: ')} ${report.binary_path}`)
lines.push(` ${t.muted('node: ')} ${report.node_version} (${report.platform}/${report.arch})`)
if (report.on_path) {
lines.push(` on PATH: yes`)
lines.push(` ${t.muted('on PATH: ')} ${t.ok('yes')}`)
} else {
lines.push(`!! on PATH: no (install_dir: ${report.install_dir})`)
lines.push(` ${t.warnLine(`on PATH: ${t.warn('no')} (install_dir: ${report.install_dir})`)}`)
hints.push(
`add ${report.install_dir} to your PATH, or re-run the installer from desktop/scripts/`
)
@@ -214,9 +224,9 @@ function renderHuman(report: DoctorReport): string {
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}`)
lines.push(` ${t.muted('sessions file:')} ${report.sessions_file}${sz}`)
} else {
lines.push(`!! sessions file: ${report.sessions_file} (missing)`)
lines.push(` ${t.warnLine(`sessions file: ${report.sessions_file} (missing)`)}`)
hints.push('run `hermes-relay pair --remote <url>` to create it')
}
@@ -277,10 +287,15 @@ function renderHuman(report: DoctorReport): string {
lines.push(` shell: ${ws.active_shell}`)
}
// doctor is intentionally zero-network — point at the live-relay checks for
// anything that needs to actually talk to the server.
lines.push('')
lines.push(t.muted(' live relay checks: hermes-relay relay info | relay context | voice'))
if (hints.length > 0) {
lines.push('')
for (const hint of hints) {
lines.push(`hint: ${hint}`)
lines.push(t.warn(`hint: ${hint}`))
}
}
@@ -288,6 +303,11 @@ function renderHuman(report: DoctorReport): string {
}
export async function doctorCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(DOCTOR_USAGE, t)
return 0
}
const report = await gather()
if (args.flags.json) {
@@ -295,7 +315,7 @@ export async function doctorCommand(args: ParsedArgs): Promise<number> {
return 0
}
process.stdout.write(renderHuman(report))
process.stdout.write(renderHuman(report, t))
return 0
}
+82 -18
View File
@@ -11,6 +11,9 @@
// render "Paired via LAN / Tailscale / Public" correctly).
import type { ParsedArgs } from '../cli.js'
import { formatError } from '../lib/hints.js'
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import {
cleanCode,
isValidCode,
@@ -22,15 +25,46 @@ import {
probeCandidatesByPriority,
relayPairingCodeFromPayload
} from '../pairingQr.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { DEFAULT_RELAY_PORT, normalizeRelayUrl, resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { saveSession } from '../remoteSessions.js'
import { ensureToolsConsent } from '../tools/consent.js'
import { RelayTransport } from '../transport/RelayTransport.js'
const PAIR_USAGE: UsageSpec = {
name: 'pair',
summary: 'pair with a relay and store a session token',
usage: ['pair [CODE] --remote <url>', 'pair --pair-qr "<invite>"'],
flags: [
{
flag: '--pair-qr <invite>',
desc: 'Paste a full QR payload or hermes-relay://pair invite (recommended — probes endpoints)'
},
{ flag: '--remote <url>', desc: 'Relay URL (with [CODE] or an interactive prompt)' },
{ flag: '--code <code>', desc: '6-char pairing code (or pass it as the positional arg)' },
{
flag: '--grant-tools',
desc: 'Also grant desktop-tool consent now (TTY prompt) — lets `daemon` work with no `shell` round-trip'
},
{ flag: '--auto-grant-tools', desc: 'Grant desktop-tool consent without prompting (scripts/CI)' }
],
examples: [
'hermes-relay pair --pair-qr "hermes-relay://pair?payload=…"',
'hermes-relay pair --remote ws://192.168.1.50:8767',
'hermes-relay pair ABC123 --remote ws://host:8767 --grant-tools'
]
}
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
const raw = (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
if (!raw) {
return null
}
const norm = normalizeRelayUrl(raw)
if (norm.added) {
process.stderr.write(` (no port given — using :${DEFAULT_RELAY_PORT})\n`)
}
return norm.url
}
interface PairTarget {
@@ -61,15 +95,27 @@ async function resolvePairTarget(args: ParsedArgs): Promise<PairTarget | { error
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) }
}
process.stderr.write(`Probing ${candidates.length} endpoint(s)...\n`)
const t = makeTheme({ noColor: !!args.flags['no-color'] })
process.stderr.write(t.bold(`Probing ${candidates.length} endpoint(s)…`) + '\n')
let winner
try {
winner = await probeCandidatesByPriority(candidates)
winner = await probeCandidatesByPriority(candidates, {
onProbe: (ev) => {
const label = `[${ev.index}/${ev.total}] ${ev.candidate.role} ${ev.candidate.relay.url}`
if (ev.phase === 'result' && ev.reachable) {
process.stderr.write(` ${t.okLine(label)} ${t.muted(`${ev.elapsedMs}ms`)}\n`)
} else if (ev.phase === 'result') {
process.stderr.write(` ${t.muted(`${SYMBOLS.dot} ${label} — ${ev.error ?? 'unreachable'}`)}\n`)
} else if (ev.phase === 'cached') {
process.stderr.write(` ${t.okLine(label)} ${t.muted('(cached)')}\n`)
}
}
})
} 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`
` ${t.cyan(SYMBOLS.arrow)} picked ${t.bold(winner.role)} endpoint ${winner.relay.url}\n`
)
return {
url: winner.relay.url,
@@ -120,9 +166,14 @@ async function resolvePairTarget(args: ParsedArgs): Promise<PairTarget | { error
}
export async function pairCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(PAIR_USAGE, t)
return 0
}
const target = await resolvePairTarget(args)
if ('error' in target) {
process.stderr.write(`error: ${target.error}\n`)
process.stderr.write(formatError(target.error, { command: 'pair' }, t) + '\n')
return 1
}
@@ -135,7 +186,7 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
const autoGrant = !!args.flags['auto-grant-tools']
const promptGrant = !!args.flags['grant-tools'] && !autoGrant
process.stderr.write(`Pairing with ${target.url}...\n`)
process.stderr.write(t.muted(`Pairing with ${target.url}…`) + '\n')
const relay = new RelayTransport({
url: target.url,
@@ -156,25 +207,32 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
endpointRole: target.endpointRole,
...(autoGrant ? { toolsConsented: true } : {})
})
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`)
process.stdout.write(t.okLine('Paired. Token stored in ~/.hermes/remote-sessions.json') + '\n')
process.stdout.write(t.muted(` server: ${outcome.serverVersion ?? '?'}`) + '\n')
process.stdout.write(t.muted(` relay: ${target.url}`) + '\n')
if (target.endpointRole) {
process.stdout.write(` Route: ${target.endpointRole}\n`)
process.stdout.write(t.muted(` route: ${target.endpointRole}`) + '\n')
}
if (autoGrant) {
process.stdout.write(`✓ Desktop tool consent granted (--auto-grant-tools).\n`)
process.stdout.write(t.okLine('Desktop tool consent granted (--auto-grant-tools).') + '\n')
} else if (promptGrant) {
const result = await ensureToolsConsent(target.url)
if (result.consented) {
process.stdout.write(`✓ Desktop tool consent granted.\n`)
process.stdout.write(t.okLine('Desktop tool consent granted.') + '\n')
} else {
process.stderr.write(
`! Tool consent not granted: ${result.reason ?? 'declined'}\n` +
` Pair succeeded; rerun \`hermes-relay pair --remote ${target.url} --grant-tools\` on a TTY to grant.\n`
t.warnLine(`Tool consent not granted: ${result.reason ?? 'declined'}`) + '\n' +
t.muted(` Pair succeeded; rerun \`hermes-relay pair --remote ${target.url} --grant-tools\` on a TTY to grant.`) + '\n'
)
}
} else {
// Nudge the daemon-first workflow: most users who pair from a terminal
// want desktop tools, and discovering --grant-tools after the fact means
// an extra `shell` round-trip. Surface it once, here.
process.stdout.write(
t.muted(' tip: add --grant-tools to also enable desktop tools (needed for `daemon`).') + '\n'
)
}
try {
@@ -185,8 +243,14 @@ export async function pairCommand(args: ParsedArgs): Promise<number> {
return 0
}
process.stderr.write(`✗ Pairing failed: ${outcome.reason}\n`)
process.stderr.write(` ${relay.getLogTail(5)}\n`)
process.stderr.write(t.errLine(`Pairing failed: ${outcome.reason}`) + '\n')
const hint = formatError(outcome.reason, { command: 'pair', url: target.url }, t)
// formatError repeats the message; only emit the hint line (2nd line) if present.
const hintLine = hint.split('\n')[1]
if (hintLine) {
process.stderr.write(hintLine + '\n')
}
process.stderr.write(t.muted(` ${relay.getLogTail(5)}`) + '\n')
try {
relay.kill()
} catch {
+21 -2
View File
@@ -18,8 +18,22 @@
import type { ParsedArgs } from '../cli.js'
import { captureClipboardImage } from '../chatAttach.js'
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { getSession, listSessions } from '../remoteSessions.js'
const PASTE_USAGE: UsageSpec = {
name: 'paste',
summary: 'stage the local clipboard image for /paste (or Alt+V) in the Hermes TUI',
usage: ['paste [--remote <url>]'],
flags: [
{ flag: '--remote <url>', desc: 'Relay to stage into (default: stored/active)' },
{ flag: '--json', desc: 'Machine-readable result' },
{ flag: '--quiet', desc: 'Suppress the success line' }
],
examples: ['hermes-relay paste']
}
function wsToHttp(url: string): string {
const trimmed = url.trim()
if (trimmed.startsWith('wss://')) return 'https://' + trimmed.slice(6)
@@ -140,6 +154,11 @@ export async function stageClipboardImageToInbox(
}
export async function pasteCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(PASTE_USAGE)
return 0
}
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const json = !!args.flags.json
const quiet = !!args.flags.quiet
@@ -222,8 +241,8 @@ export async function pasteCommand(args: ParsedArgs): Promise<number> {
}) + '\n'
)
} else if (!quiet) {
process.stdout.write(`✓ Image queued for /paste in TUI (${dims}, ${sizeKb} KB)\n`)
process.stdout.write(` Type /paste (or Alt+V) in the TUI to attach to next message.\n`)
process.stdout.write(t.okLine(`Image queued for /paste in TUI (${dims}, ${sizeKb} KB)`) + '\n')
process.stdout.write(t.muted(' Type /paste (or Alt+V) in the TUI to attach to next message.') + '\n')
}
return 0
}
+36 -3
View File
@@ -1,4 +1,6 @@
import type { ParsedArgs } from '../cli.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import {
getSurfacePlugin,
listSurfacePluginStatuses,
@@ -8,6 +10,33 @@ import {
type SurfacePluginStatus
} from '../surfacePlugins.js'
const PLUGINS_USAGE: UsageSpec = {
name: 'plugins',
summary: 'list / install / update / launch desktop surface plugins (e.g. herm)',
usage: [
'plugins [list]',
'plugins status [id]',
'plugins install <id>',
'plugins update <id>',
'plugins launch <id>',
'plugins resume <id>'
],
subcommands: [
{ verb: 'list', desc: 'List known surface plugins + install state (default)' },
{ verb: 'status [id]', desc: 'Detailed status for one (or all) plugins' },
{ verb: 'install <id>', desc: 'Install via Bun/npm' },
{ verb: 'update <id>', desc: 'Update an installed plugin' },
{ verb: 'launch <id>', desc: 'Launch the plugin surface' },
{ verb: 'resume <id>', desc: 'Resume a running plugin surface' }
],
flags: [{ flag: '--json', desc: 'Machine-readable output' }],
examples: [
'hermes-relay plugins',
'hermes-relay plugins install herm',
'hermes-relay plugins launch herm'
]
}
function renderStatus(status: SurfacePluginStatus): string {
const plugin = status.descriptor
const lines = [
@@ -41,6 +70,10 @@ function resolvePluginOrPrint(id: string): ReturnType<typeof getSurfacePlugin> {
}
export async function pluginsCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(PLUGINS_USAGE)
return 0
}
const sub = args.positional[0] ?? 'list'
const id = args.positional[1] ?? 'herm'
const wantJson = !!args.flags.json
@@ -60,7 +93,8 @@ export async function pluginsCommand(args: ParsedArgs): Promise<number> {
process.stdout.write(JSON.stringify(statuses, null, 2) + '\n')
return 0
}
process.stdout.write('Desktop surface plugins:\n\n')
const t = makeTheme({ noColor: !!args.flags['no-color'] })
process.stdout.write(t.bold('Desktop surface plugins:') + '\n\n')
process.stdout.write(statuses.map(renderStatus).join('\n\n') + '\n')
return 0
}
@@ -106,6 +140,5 @@ export async function pluginsCommand(args: ParsedArgs): Promise<number> {
return code
}
process.stderr.write('unknown plugins sub-verb. Try: list | status [id] | install <id> | update <id> | launch <id> | resume <id>\n')
return 2
return unknownSubcommand(PLUGINS_USAGE, sub)
}
+247
View File
@@ -0,0 +1,247 @@
// relay — inspect the relay SERVER itself (plugin v1.2.0 management surface).
//
// Three read surfaces the relay gained but the CLI never exposed:
// GET /relay/info version, uptime, sessions, pending (LOOPBACK-ONLY)
// GET /relay/security runtime auth toggles (LOOPBACK-ONLY)
// GET /context/injected what the relay injects into the agent's system
// prompt — loopback OR a relay session bearer, so this
// one works from a remote laptop too.
//
// info/security are gated to the relay host (operators); when a remote caller
// hits them we get a 403 and explain that honestly rather than failing raw.
import type { ParsedArgs } from '../cli.js'
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
import { formatError } from '../lib/hints.js'
import { theme as makeTheme, type Theme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import { getSession, listSessions } from '../remoteSessions.js'
const RELAY_USAGE: UsageSpec = {
name: 'relay',
summary: 'inspect the relay server — info, security toggles, injected agent context',
usage: ['relay info', 'relay security', 'relay context'],
subcommands: [
{ verb: 'info', desc: 'Version, uptime, sessions, pending (loopback-only — run on the relay host)' },
{ verb: 'security', desc: 'Runtime security toggles (loopback-only)' },
{ verb: 'context', desc: 'Audit the system-prompt context the relay injects into the agent' }
],
flags: [
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' },
{ flag: '--json', desc: 'Machine-readable output' }
],
examples: ['hermes-relay relay context', 'hermes-relay relay info']
}
function wsToHttp(url: string): string {
const t = url.trim()
if (t.startsWith('wss://')) return 'https://' + t.slice(6)
if (t.startsWith('ws://')) return 'http://' + t.slice(5)
return t
}
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()
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)
const activeDesktopUrl = await getActiveDesktopRelayUrl()
let url: string
if (argUrl || envUrl) {
url = argUrl ?? envUrl!
} else if (activeDesktopUrl) {
url = activeDesktopUrl
} 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 getJson(
httpUrl: string,
token: string
): Promise<{ status: number; body: unknown }> {
const res = await fetch(httpUrl, {
method: 'GET',
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }
})
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 }
}
function fmtUptime(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '?'
const d = Math.floor(seconds / 86_400)
const h = Math.floor((seconds % 86_400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const parts: string[] = []
if (d) parts.push(`${d}d`)
if (h) parts.push(`${h}h`)
if (m || parts.length === 0) parts.push(`${m}m`)
return parts.join(' ')
}
const kv = (t: Theme) => (label: string, value: string): string =>
` ${t.muted((label + ':').padEnd(12))} ${value}`
/** Loopback-only routes return 403 to remote callers. Explain that instead of
* dumping a raw error — it's expected behaviour, not a misconfiguration. */
function loopbackNote(t: Theme, route: string): string {
return (
t.warnLine(`${route} is loopback-only — run this on the relay host.`) + '\n' +
t.muted(' (it serves server operators / the dashboard; remote callers get 403)')
)
}
async function relayInfo(args: ParsedArgs, t: Theme): Promise<number> {
const { url, token } = await resolveRemoteAndToken(args)
const { status, body } = await getJson(`${wsToHttp(url)}/relay/info`, token)
if (status === 403) {
process.stderr.write(loopbackNote(t, '/relay/info') + '\n')
return 1
}
if (status !== 200) {
process.stderr.write(t.err(`error: GET /relay/info returned ${status}`) + '\n')
return 1
}
if (args.flags.json) {
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
return 0
}
const r = (body ?? {}) as Record<string, unknown>
const row = kv(t)
process.stdout.write(t.bold(`Relay ${url}`) + '\n')
process.stdout.write(row('version', String(r.version ?? '?')) + '\n')
process.stdout.write(row('health', String(r.health ?? '?')) + '\n')
process.stdout.write(row('uptime', fmtUptime(Number(r.uptime_seconds))) + '\n')
process.stdout.write(row('sessions', String(r.session_count ?? '?')) + '\n')
process.stdout.write(row('devices', String(r.paired_device_count ?? '?')) + '\n')
process.stdout.write(row('pending', String(r.pending_commands ?? 0)) + '\n')
process.stdout.write(row('media', `${r.media_entry_count ?? 0} entries`) + '\n')
return 0
}
async function relaySecurity(args: ParsedArgs, t: Theme): Promise<number> {
const { url, token } = await resolveRemoteAndToken(args)
const { status, body } = await getJson(`${wsToHttp(url)}/relay/security`, token)
if (status === 403) {
process.stderr.write(loopbackNote(t, '/relay/security') + '\n')
return 1
}
if (status !== 200) {
process.stderr.write(t.err(`error: GET /relay/security returned ${status}`) + '\n')
return 1
}
if (args.flags.json) {
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
return 0
}
const r = (body ?? {}) as Record<string, unknown>
const row = kv(t)
const insecure = r.allow_insecure_api_bearer === true
process.stdout.write(t.bold(`Relay security — ${url}`) + '\n')
process.stdout.write(
row('insecure bearer', `${t.statusDot(!insecure)} ${insecure ? t.warn('allowed (plaintext API bearer)') : 'requires HTTPS off-loopback'}`) + '\n'
)
process.stdout.write(row('trust proxy', String(r.trust_proxy_headers ?? false)) + '\n')
process.stdout.write(row('scope', String(r.scope ?? 'runtime')) + '\n')
return 0
}
async function relayContext(args: ParsedArgs, t: Theme): Promise<number> {
const { url, token } = await resolveRemoteAndToken(args)
const { status, body } = await getJson(`${wsToHttp(url)}/context/injected`, token)
if (status === 404) {
process.stderr.write(
t.muted(`relay at ${url} has no /context/injected — server predates the relay context layer.`) + '\n'
)
return 1
}
if (status !== 200) {
process.stderr.write(t.err(`error: GET /context/injected returned ${status}`) + '\n')
return 1
}
if (args.flags.json) {
process.stdout.write(JSON.stringify(body, null, 2) + '\n')
return 0
}
const r = (body ?? {}) as { enabled?: boolean; blocks?: { name?: string; text?: string }[] }
const blocks = r.blocks ?? []
process.stdout.write(t.bold(`Relay-injected agent context — ${url}`) + '\n')
process.stdout.write(` ${t.statusDot(!!r.enabled)} injection ${r.enabled ? t.ok('enabled') : t.muted('disabled')}\n`)
if (blocks.length === 0) {
process.stdout.write(t.muted(' (no context blocks are being injected into the agent prompt)') + '\n')
return 0
}
process.stdout.write('\n')
for (const b of blocks) {
process.stdout.write(` ${t.bold(b.name ?? '(unnamed)')}\n`)
const text = (b.text ?? '').trim()
const preview = text.length > 280 ? text.slice(0, 279) + '…' : text
for (const line of preview.split('\n')) {
process.stdout.write(` ${t.muted(line)}\n`)
}
}
return 0
}
export async function relayCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(RELAY_USAGE, t)
return 0
}
const sub = args.positional[0] ?? 'info'
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
const run = async (fn: (a: ParsedArgs, th: Theme) => Promise<number>): Promise<number> => {
try {
return await fn(args, t)
} catch (e) {
process.stderr.write(formatError(e, { command: 'relay', url }, t) + '\n')
return 1
}
}
if (sub === 'info') {
if (args.positional[0] === 'info') args.positional.shift()
return run(relayInfo)
}
if (sub === 'security') {
args.positional.shift()
return run(relaySecurity)
}
if (sub === 'context') {
args.positional.shift()
return run(relayContext)
}
return unknownSubcommand(RELAY_USAGE, sub, t)
}
export default relayCommand
+64 -16
View File
@@ -1,4 +1,7 @@
import type { ParsedArgs } from '../cli.js'
import { renderTable } from '../lib/table.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import {
clearActiveTerminalSession,
getActiveTerminalSession
@@ -7,6 +10,32 @@ import { connectAndAuth, shellCommand } from './shell.js'
const COMMAND_TIMEOUT_MS = 15_000
const SESSIONS_USAGE: UsageSpec = {
name: 'sessions',
summary: 'list / resume / create / kill the relay-side Hermes TUI tmux sessions',
usage: [
'sessions [list]',
'sessions resume [name]',
'sessions new [name]',
'sessions kill <name>'
],
subcommands: [
{ verb: 'list', desc: 'List live TUI sessions (default)' },
{ verb: 'resume [name]', desc: 'Attach a shell (active/default if name omitted)' },
{ verb: 'new [name]', desc: 'Start a fresh session and attach' },
{ verb: 'kill <name>', desc: 'Terminate a tmux session' }
],
flags: [
{ flag: '--remote <url>', desc: 'Relay to target' },
{ flag: '--json', desc: 'Machine-readable list output' }
],
examples: [
'hermes-relay sessions',
'hermes-relay sessions resume default',
'hermes-relay sessions kill scratch'
]
}
interface TerminalSessionInfo {
name: string
tmux_name?: string
@@ -120,26 +149,42 @@ async function listTerminalSessions(args: ParsedArgs): Promise<number> {
return 0
}
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (sessions.length === 0) {
process.stdout.write(`No Hermes TUI sessions on ${url}.\n`)
process.stdout.write('Run `hermes-relay` to start the default session.\n')
process.stdout.write(t.muted(`No Hermes TUI sessions on ${url}.`) + '\n')
process.stdout.write(t.muted('Run `hermes-relay` to start the default session.') + '\n')
return 0
}
process.stdout.write(`Hermes TUI sessions on ${url}:\n\n`)
for (const session of sessions) {
const activeMark = active?.name === session.name ? ' * active' : ''
const rows = sessions.map((session) => {
const activeMark = active?.name === session.name ? ` ${t.muted('(active)')}` : ''
const attached = session.attached ?? (session.live ? 1 : 0)
process.stdout.write(` ${session.name}${activeMark}\n`)
process.stdout.write(` tmux: ${session.tmux_name ?? `hermes-${session.name}`}\n`)
process.stdout.write(` attached: ${attached}\n`)
if (session.windows !== undefined) {
process.stdout.write(` windows: ${session.windows}\n`)
}
process.stdout.write(` created: ${formatCreated(session.created_at)}\n\n`)
}
return [
`${session.name}${activeMark}`,
session.tmux_name ?? `hermes-${session.name}`,
String(attached),
session.windows !== undefined ? String(session.windows) : '—',
formatCreated(session.created_at)
]
})
process.stdout.write(t.bold(`Hermes TUI sessions on ${url} (${sessions.length})`) + '\n\n')
process.stdout.write(
' Resume with `hermes-relay sessions resume <name>`, or run bare `hermes-relay` for the active/default session.\n'
renderTable(
[
{ header: 'NAME' },
{ header: 'TMUX' },
{ header: 'ATTACHED', align: 'right' },
{ header: 'WINDOWS', align: 'right' },
{ header: 'CREATED' }
],
rows,
{ theme: t }
) + '\n'
)
process.stdout.write(
'\n' +
t.muted('resume: hermes-relay sessions resume <name> or run bare `hermes-relay` for the active session.') +
'\n'
)
return 0
} finally {
@@ -174,6 +219,10 @@ async function killTerminalSession(args: ParsedArgs): Promise<number> {
}
export async function sessionsCommand(args: ParsedArgs): Promise<number> {
if (args.flags.help) {
printUsage(SESSIONS_USAGE)
return 0
}
const sub = args.positional[0] ?? 'list'
if (sub === 'list') {
@@ -213,6 +262,5 @@ export async function sessionsCommand(args: ParsedArgs): Promise<number> {
return killTerminalSession(args)
}
process.stderr.write('unknown sessions sub-verb. Try: list | resume [name] | new [name] | kill <name>\n')
return 2
return unknownSubcommand(SESSIONS_USAGE, sub)
}
+33 -11
View File
@@ -6,8 +6,21 @@
import { humanExpiry, parseRole, roleLabel } from '../banner.js'
import type { ParsedArgs } from '../cli.js'
import { theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { listSessions, type RemoteSessionRecord } from '../remoteSessions.js'
const STATUS_USAGE: UsageSpec = {
name: 'status',
summary: 'show the relays this machine is paired with (grants, TTL, desktop-tool consent)',
usage: ['status [--json] [--reveal-tokens]'],
flags: [
{ flag: '--json', desc: 'Machine-readable output (tokens redacted by default)' },
{ flag: '--reveal-tokens', desc: 'Include full session tokens (use with care)' }
],
examples: ['hermes-relay status', 'hermes-relay status --json']
}
function humanAge(seconds: number): string {
if (seconds < 60) {
return `${seconds}s`
@@ -30,6 +43,11 @@ function humanAge(seconds: number): string {
const REDACTED = '(redacted — pass --reveal-tokens to show)'
export async function statusCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(STATUS_USAGE, t)
return 0
}
const sessions = await listSessions()
const entries = Object.entries(sessions)
const revealTokens = !!args.flags['reveal-tokens']
@@ -51,28 +69,32 @@ export async function statusCommand(args: ParsedArgs): Promise<number> {
if (entries.length === 0) {
process.stdout.write(
'No paired relays. Run `hermes-relay pair --remote ws://host:port` to pair.\n'
t.muted('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`)
process.stdout.write(t.bold(`Paired relays (${entries.length})`) + '\n\n')
const now = Math.floor(Date.now() / 1000)
const kv = (label: string, value: string): string =>
` ${t.muted((label + ':').padEnd(9))} ${value}`
for (const [url, rec] of entries) {
const age = humanAge(Math.max(0, now - rec.pairedAt))
const tokenDisplay = revealTokens ? rec.token : REDACTED
process.stdout.write(` ${url}\n`)
process.stdout.write(` server: ${rec.serverVersion ?? '(unknown)'}\n`)
process.stdout.write(` paired: ${age} ago\n`)
process.stdout.write(` token: ${tokenDisplay}\n`)
process.stdout.write(` expires: ${humanExpiry(rec.ttlExpiresAt)}\n`)
const expiresRaw = humanExpiry(rec.ttlExpiresAt)
const expires = expiresRaw === 'expired' ? t.err('expired') : expiresRaw
process.stdout.write(` ${t.cyan(url)}\n`)
process.stdout.write(kv('server', rec.serverVersion ?? '(unknown)') + '\n')
process.stdout.write(kv('paired', `${age} ago`) + '\n')
process.stdout.write(kv('token', tokenDisplay) + '\n')
process.stdout.write(kv('expires', expires) + '\n')
const computerUse = rec.toolsConsented ? 'feature-flagged opt-in' : 'no'
process.stdout.write(
` desktop: tools=${rec.toolsConsented ? 'yes' : 'no'}, computer-use=${computerUse}\n`
kv('desktop', `${t.statusDot(!!rec.toolsConsented)} tools=${rec.toolsConsented ? 'yes' : 'no'}, computer-use=${computerUse}`) + '\n'
)
const role = parseRole(rec.endpointRole)
if (role) {
process.stdout.write(` route: ${roleLabel(role)}\n`)
process.stdout.write(kv('route', roleLabel(role)) + '\n')
}
if (rec.grants && Object.keys(rec.grants).length > 0) {
const formatted = Object.entries(rec.grants)
@@ -81,10 +103,10 @@ export async function statusCommand(args: ParsedArgs): Promise<number> {
return `${channel} (${when})`
})
.sort()
process.stdout.write(` grants: ${formatted.join(', ')}\n`)
process.stdout.write(kv('grants', formatted.join(', ')) + '\n')
}
if (rec.certPinSha256) {
process.stdout.write(` cert: sha256:${rec.certPinSha256.slice(0, 12)}…\n`)
process.stdout.write(kv('cert', `sha256:${rec.certPinSha256.slice(0, 12)}…`) + '\n')
}
process.stdout.write('\n')
}
+50 -16
View File
@@ -7,13 +7,29 @@ import type { ParsedArgs } from '../cli.js'
import { resolveCredentials } from '../credentials.js'
import { GatewayClient } from '../gatewayClient.js'
import type { GatewayEvent, ToolsListResponse } from '../gatewayTypes.js'
import { formatError } from '../lib/hints.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { createSpinner } from '../lib/spinner.js'
import { SYMBOLS, theme as makeTheme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { deleteSession, saveSession } from '../remoteSessions.js'
import { RelayTransport } from '../transport/RelayTransport.js'
const READY_TIMEOUT_MS = 60_000
const TOOLS_USAGE: UsageSpec = {
name: 'tools',
summary: 'show the tool access the agent will have on this connection',
usage: ['tools [--verbose] [--json]'],
flags: [
{ flag: '--verbose', desc: 'List individual tools under each toolset' },
{ flag: '--json', desc: 'Machine-readable toolset list' },
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' }
],
examples: ['hermes-relay tools', 'hermes-relay tools --verbose']
}
function resolveRemote(args: ParsedArgs): string | null {
const v = args.flags.remote
return (typeof v === 'string' ? v : null) ?? process.env.HERMES_RELAY_URL ?? null
@@ -39,6 +55,11 @@ function waitForReady(gw: GatewayClient): Promise<void> {
}
export async function toolsCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(TOOLS_USAGE, t)
return 0
}
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
@@ -98,6 +119,12 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
})
})
const spinner = createSpinner(`Connecting to ${url}…`, {
enabled: !args.flags.json && !args.flags.quiet,
theme: t
})
spinner.start()
relay.start()
const outcome = await relay.whenAuthResolved()
@@ -105,7 +132,8 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
if (creds.sessionToken) {
await deleteSession(url)
}
process.stderr.write(`error: ${outcome.reason}\n`)
spinner.fail('connection failed')
process.stderr.write(formatError(outcome.reason, { command: 'tools', url }, t) + '\n')
try {
relay.kill()
} catch {
@@ -114,6 +142,7 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
return 1
}
spinner.update('Loading toolsets…')
const gw = new GatewayClient(relay)
const ready = waitForReady(gw)
gw.start()
@@ -122,13 +151,15 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
try {
await ready
} catch (e) {
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
spinner.fail('gateway not ready')
process.stderr.write(formatError(e, { command: 'tools', url }, t) + '\n')
gw.kill()
return 1
}
try {
const raw = await gw.request<ToolsListResponse>('tools.list', {})
spinner.stop()
const result = asRpcResult<ToolsListResponse>(raw)
const toolsets = result?.toolsets ?? []
@@ -139,43 +170,46 @@ export async function toolsCommand(args: ParsedArgs): Promise<number> {
}
if (toolsets.length === 0) {
process.stdout.write('(server returned no toolsets)\n')
process.stdout.write(t.muted('(server returned no toolsets)') + '\n')
gw.kill()
return 0
}
const enabled = toolsets.filter((t) => t.enabled).length
const enabled = toolsets.filter((ts) => ts.enabled).length
process.stdout.write(
`Server: ${url}\n` +
`Version: ${relay.serverVersion ?? '?'}\n` +
`Toolsets: ${toolsets.length} (${enabled} enabled)\n\n`
`${t.muted('Server: ')} ${url}\n` +
`${t.muted('Version: ')} ${relay.serverVersion ?? '?'}\n` +
`${t.muted('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})`)
process.stdout.write(` ${t.statusDot(!!ts.enabled)} ${t.bold(ts.name)} ${t.muted(`(${count})`)}`)
if (ts.description) {
process.stdout.write(` — ${ts.description}`)
process.stdout.write(` ${t.muted('— ' + 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}`)
for (const tool of ts.tools) {
process.stdout.write(` ${t.muted(SYMBOLS.bullet)} ${tool.name}`)
if (tool.description) {
process.stdout.write(` ${t.muted(tool.description)}`)
}
process.stdout.write('\n')
}
}
}
process.stdout.write('\n ● = enabled for this session ○ = available but off\n')
process.stdout.write(
`\n ${t.statusDot(true)} ${t.muted('enabled for this session')} ` +
`${t.statusDot(false)} ${t.muted('available but off')}\n`
)
gw.kill()
return 0
} catch (e) {
process.stderr.write(`error: ${rpcErrorMessage(e)}\n`)
spinner.stop()
process.stderr.write(formatError(e, { command: 'tools', url }, t) + '\n')
gw.kill()
return 1
}
+93 -26
View File
@@ -32,8 +32,11 @@ import type {
SessionResumeResponse
} from '../gatewayTypes.js'
import { getActiveDesktopRelayUrl } from '../desktopConfig.js'
import { formatError } from '../lib/hints.js'
import { setupGracefulExit } from '../lib/gracefulExit.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import { theme as makeTheme, type Theme } from '../lib/theme.js'
import { printUsage, unknownSubcommand, type UsageSpec } from '../lib/usage.js'
import { resolveFirstRunUrl } from '../relayUrlPrompt.js'
import { deleteSession, getSession, listSessions, saveSession } from '../remoteSessions.js'
import { RelayTransport } from '../transport/RelayTransport.js'
@@ -42,6 +45,34 @@ import { discoverTray, notifyTrayShowVoice } from '../trayBridge.js'
const READY_TIMEOUT_MS = 60_000
const VOICE_USAGE: UsageSpec = {
name: 'voice',
summary: 'inspect native Hermes voice config (STT/TTS/realtime) and run push-to-talk',
usage: ['voice [status]', 'voice mode [--port <n>] [--no-open]'],
subcommands: [
{ verb: 'status', desc: 'Show STT/TTS/realtime providers + enhanced-voice capabilities (default)' },
{ verb: 'mode', desc: 'Push-to-talk in a browser tab, proxied through this CLI' }
],
flags: [
{ flag: '--remote <url>', desc: 'Relay to query (default: stored/active)' },
{ flag: '--json', desc: 'status: raw JSON for scripting' },
{ flag: '--port <n>', desc: 'mode: local voice-server port (default: ephemeral)' },
{ flag: '--no-open', desc: 'mode: do not auto-open the browser' }
],
examples: ['hermes-relay voice', 'hermes-relay voice mode']
}
/** Per-provider enhanced-voice capability hint, surfaced by `/voice/config`
* (plugin v1.2.0). Gemini carries tone-tags + persona; xAI carries speech-tags
* + language. The CLI previously dropped this block entirely. */
interface VoiceEnhanced {
audio_tags_enabled?: boolean
audio_tags_label?: string | null
supports_persona?: boolean
persona_prompt_file?: string | null
overrides?: string[]
}
interface VoiceProvider {
provider?: string | null
model?: string | null
@@ -49,6 +80,7 @@ interface VoiceProvider {
voice_id?: string | null
enabled?: boolean
available?: boolean
enhanced?: VoiceEnhanced | null
}
interface VoiceConfigResponse {
@@ -159,36 +191,57 @@ async function getJson<T>(
return { status: res.status, body: body as T | { error?: string } | string | undefined }
}
function formatProvider(p: VoiceProvider | null | undefined, label: string): string {
function formatProvider(p: VoiceProvider | null | undefined, label: string, t: Theme): string {
if (!p || !p.provider) {
return ` ${label}: (not configured)`
return ` ${t.muted(`${label}: (not configured)`)}`
}
const enabled = p.enabled === false ? '○' : '●'
const provider = p.provider
const model = p.model ? ` · ${p.model}` : ''
const voice = p.voice ?? p.voice_id
const voiceTag = voice ? ` · voice=${voice}` : ''
return ` ${enabled} ${label}: ${provider}${model}${voiceTag}`
return ` ${t.statusDot(p.enabled !== false)} ${t.bold(label)}: ${p.provider}${model}${voiceTag}`
}
function formatRealtime(rt: RealtimeVoiceConfigResponse | null): string[] {
/** Render the enhanced-voice capability sub-line under a provider, if present.
* Surfaces what the relay's per-request `/voice/synthesize` overrides can do
* (Gemini tone-tags + persona, xAI speech-tags + language). */
function formatEnhanced(p: VoiceProvider | null | undefined, t: Theme): string | null {
const e = p?.enhanced
if (!e) {
return null
}
const bits: string[] = []
if (e.audio_tags_label) {
bits.push(`${e.audio_tags_label} ${e.audio_tags_enabled ? t.ok('(on)') : t.muted('(off)')}`)
}
if (e.supports_persona) {
bits.push('persona supported')
}
if (e.overrides?.length) {
bits.push(`overrides: ${e.overrides.join(', ')}`)
}
if (bits.length === 0) {
return null
}
return ` ${t.muted('enhanced: ' + bits.join(' · '))}`
}
function formatRealtime(rt: RealtimeVoiceConfigResponse | null, t: Theme): string[] {
if (!rt) {
return [' Realtime: (unavailable)']
return [` ${t.muted('Realtime: (unavailable)')}`]
}
if (rt.success === false) {
return [` Realtime: error — ${rt.error ?? 'unknown'}`]
return [` ${t.warn(`Realtime: error — ${rt.error ?? 'unknown'}`)}`]
}
const lines: string[] = []
const enabled = rt.enabled ? '●' : '○'
const provider = rt.default_provider ?? '(none)'
const model = rt.default_model ? ` · ${rt.default_model}` : ''
const voice = rt.default_voice ? ` · voice=${rt.default_voice}` : ''
const rate = rt.sample_rate ? ` @ ${rt.sample_rate}Hz` : ''
lines.push(` ${enabled} Realtime: ${provider}${model}${voice}${rate}`)
lines.push(` ${t.statusDot(!!rt.enabled)} ${t.bold('Realtime')}: ${provider}${model}${voice}${rate}`)
const providers = (rt.providers ?? []).filter((p) => p.status && p.status !== 'unavailable')
if (providers.length > 0) {
const labels = providers.map((p) => p.name ?? p.id)
lines.push(` available: ${labels.join(', ')}`)
lines.push(` ${t.muted('available: ' + labels.join(', '))}`)
}
return lines
}
@@ -244,13 +297,22 @@ async function voiceStatus(args: ParsedArgs): Promise<number> {
return 1
}
const t = makeTheme({ noColor: !!args.flags['no-color'] })
const cfg = basic.body as VoiceConfigResponse
const rt = (realtime.status === 200 ? (realtime.body as RealtimeVoiceConfigResponse) : null)
process.stdout.write(`Voice on ${url}:\n\n`)
process.stdout.write(formatProvider(cfg.stt, 'STT') + '\n')
process.stdout.write(formatProvider(cfg.tts, 'TTS') + '\n')
for (const line of formatRealtime(rt)) {
process.stdout.write(t.bold(`Voice on ${url}`) + '\n\n')
process.stdout.write(formatProvider(cfg.stt, 'STT', t) + '\n')
const sttEnhanced = formatEnhanced(cfg.stt, t)
if (sttEnhanced) {
process.stdout.write(sttEnhanced + '\n')
}
process.stdout.write(formatProvider(cfg.tts, 'TTS', t) + '\n')
const ttsEnhanced = formatEnhanced(cfg.tts, t)
if (ttsEnhanced) {
process.stdout.write(ttsEnhanced + '\n')
}
for (const line of formatRealtime(rt, t)) {
process.stdout.write(line + '\n')
}
@@ -258,19 +320,19 @@ async function voiceStatus(args: ParsedArgs): Promise<number> {
const ttsOk = cfg.tts?.enabled !== false && !!cfg.tts?.provider
process.stdout.write('\n')
if (sttOk && ttsOk) {
process.stdout.write(' Native Hermes voice is configured on the server. ✓\n')
process.stdout.write(t.okLine('Native Hermes voice is configured on the server.') + '\n')
} else if (!sttOk && !ttsOk) {
process.stdout.write(
' Neither STT nor TTS is configured.\n' +
' Edit ~/.hermes/config.yaml on the server (stt.provider / tts.provider) and restart.\n'
t.warnLine('Neither STT nor TTS is configured.') + '\n' +
t.muted(' Edit ~/.hermes/config.yaml on the server (stt.provider / tts.provider) and restart.') + '\n'
)
} else {
process.stdout.write(
` Partial: ${sttOk ? 'STT' : 'TTS'} is configured, ${sttOk ? 'TTS' : 'STT'} is not.\n` +
' See ~/.hermes/config.yaml on the server.\n'
t.warnLine(`Partial: ${sttOk ? 'STT' : 'TTS'} is configured, ${sttOk ? 'TTS' : 'STT'} is not.`) + '\n' +
t.muted(' See ~/.hermes/config.yaml on the server.') + '\n'
)
}
process.stdout.write('\n ● = enabled ○ = available but off\n')
process.stdout.write(`\n ${t.statusDot(true)} ${t.muted('enabled')} ${t.statusDot(false)} ${t.muted('available but off')}\n`)
return 0
}
@@ -500,16 +562,22 @@ async function voiceMode(args: ParsedArgs): Promise<number> {
}
export async function voiceCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(VOICE_USAGE, t)
return 0
}
const sub = args.positional[0] ?? 'status'
const url = typeof args.flags.remote === 'string' ? args.flags.remote : undefined
if (sub === 'status') {
if (args.positional.length > 0 && args.positional[0] === 'status') {
if (args.positional[0] === 'status') {
args.positional.shift()
}
try {
return await voiceStatus(args)
} catch (e) {
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
process.stderr.write(formatError(e, { command: 'voice', url }, t) + '\n')
return 1
}
}
@@ -519,11 +587,10 @@ export async function voiceCommand(args: ParsedArgs): Promise<number> {
try {
return await voiceMode(args)
} catch (e) {
process.stderr.write(`error: ${e instanceof Error ? e.message : String(e)}\n`)
process.stderr.write(formatError(e, { command: 'voice', url }, t) + '\n')
return 1
}
}
process.stderr.write(`unknown voice sub-verb "${sub}". Try: status | mode\n`)
return 2
return unknownSubcommand(VOICE_USAGE, sub, t)
}
+33 -12
View File
@@ -8,8 +8,21 @@
import type { ParsedArgs } from '../cli.js'
import { detectActiveEditor, type ActiveEditorHint } from '../activeEditor.js'
import { theme as makeTheme, type Theme } from '../lib/theme.js'
import { printUsage, type UsageSpec } from '../lib/usage.js'
import { detectWorkspaceContext, type WorkspaceContext } from '../workspaceContext.js'
const WORKSPACE_USAGE: UsageSpec = {
name: 'workspace',
summary: 'print the local workspace context the CLI advertises to the relay (cwd, git, editor, shell)',
usage: ['workspace [--json]'],
flags: [{ flag: '--json', desc: 'Emit the exact desktop.workspace payload as JSON' }],
examples: [
'hermes-relay workspace',
'hermes-relay workspace --json | jq .workspace.git_branch'
]
}
function renderStatusSummary(
summary: WorkspaceContext['git_status_summary']
): string {
@@ -43,20 +56,28 @@ function renderEditorLine(hint: ActiveEditorHint): string {
return '(none detected)'
}
function renderHuman(ctx: WorkspaceContext, editor: ActiveEditorHint): string {
const lines: string[] = []
lines.push(`cwd: ${ctx.cwd}`)
lines.push(`repo: ${ctx.repo_name ?? '(not a git repo)'}`)
lines.push(`branch: ${ctx.git_branch ?? '(n/a)'}`)
lines.push(`status: ${renderStatusSummary(ctx.git_status_summary)}`)
lines.push(`host: ${ctx.hostname}`)
lines.push(`platform: ${ctx.platform}/${ctx.arch}`)
lines.push(`shell: ${ctx.active_shell ?? '(unknown)'}`)
lines.push(`editor: ${renderEditorLine(editor)}`)
return lines.join('\n') + '\n'
function renderHuman(ctx: WorkspaceContext, editor: ActiveEditorHint, t: Theme): string {
const kv = (label: string, value: string): string => `${t.muted((label + ':').padEnd(10))} ${value}`
return (
[
kv('cwd', ctx.cwd),
kv('repo', ctx.repo_name ?? '(not a git repo)'),
kv('branch', ctx.git_branch ?? '(n/a)'),
kv('status', renderStatusSummary(ctx.git_status_summary)),
kv('host', ctx.hostname),
kv('platform', `${ctx.platform}/${ctx.arch}`),
kv('shell', ctx.active_shell ?? '(unknown)'),
kv('editor', renderEditorLine(editor))
].join('\n') + '\n'
)
}
export async function workspaceCommand(args: ParsedArgs): Promise<number> {
const t = makeTheme({ noColor: !!args.flags['no-color'] })
if (args.flags.help) {
printUsage(WORKSPACE_USAGE, t)
return 0
}
const ctx = await detectWorkspaceContext()
const editor = await detectActiveEditor()
@@ -69,7 +90,7 @@ export async function workspaceCommand(args: ParsedArgs): Promise<number> {
return 0
}
process.stdout.write(renderHuman(ctx, editor))
process.stdout.write(renderHuman(ctx, editor, t))
return 0
}
+28
View File
@@ -16,7 +16,9 @@
// 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 { humanExpiry } from './banner.js'
import type { EndpointCandidate } from './endpoint.js'
import { theme as makeTheme } from './lib/theme.js'
import { promptForPairingCode } from './pairing.js'
import {
decodePairingPayload,
@@ -26,6 +28,31 @@ import {
} from './pairingQr.js'
import { getSession } from './remoteSessions.js'
/** Warn (on a TTY only) when a stored token is at/near expiry, so a returning
* user gets a heads-up + the exact re-pair command instead of a bare auth
* failure on the next request. Piped/scripted output stays clean. */
function maybeWarnExpiry(url: string, ttlExpiresAt: number | null | undefined): void {
if (ttlExpiresAt === null || ttlExpiresAt === undefined) {
return
}
if (!process.stderr.isTTY) {
return
}
const delta = ttlExpiresAt - Date.now() / 1000
const t = makeTheme()
if (delta <= 0) {
process.stderr.write(
t.warnLine(`stored session for ${url} has expired — re-pair: hermes-relay pair --remote ${url}`) + '\n'
)
} else if (delta < 3600) {
process.stderr.write(
t.warnLine(
`stored session for ${url} expires ${humanExpiry(ttlExpiresAt)} — re-pair soon: hermes-relay pair --remote ${url}`
) + '\n'
)
}
}
export interface Credentials {
sessionToken?: string
pairingCode?: string
@@ -83,6 +110,7 @@ export async function resolveCredentials(
const stored = await getSession(url)
if (stored) {
maybeWarnExpiry(url, stored.ttlExpiresAt)
return { sessionToken: stored.token }
}
+114
View File
@@ -0,0 +1,114 @@
// Local desktop-tool audit log — "what did the agent run on THIS machine?"
//
// The relay keeps a server-side ring buffer of desktop commands, but that
// route (`GET /desktop/health`) is loopback-only — a laptop CLI talking to a
// remote relay can't read it. Since the CLI client is the actual EXECUTOR of
// every desktop tool, it is the right place to record activity: a JSONL log at
// ~/.hermes/desktop-audit.jsonl that `hermes-relay audit` tails. No network,
// no auth, works regardless of where the relay lives.
//
// Best-effort by design: a logging failure must never break a tool dispatch.
import { appendFile, mkdir, readFile, rename, stat } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export interface AuditEntry {
/** Epoch milliseconds when the command completed. */
ts: number
tool: string
ok: boolean
aborted?: boolean
request_id?: string
/** Truncated preview of the call args, for context. */
args_preview?: string
/** Short success summary (path / exit code / first stdout line). */
summary?: string
error?: string
}
/** Rotate the log once it crosses ~1 MB, keeping a single `.1` backup. */
const MAX_BYTES = 1_000_000
export function auditLogPath(): string {
return join(homedir(), '.hermes', 'desktop-audit.jsonl')
}
export async function appendAudit(entry: AuditEntry): Promise<void> {
const path = auditLogPath()
try {
await mkdir(dirname(path), { recursive: true })
try {
const st = await stat(path)
if (st.size > MAX_BYTES) {
await rename(path, path + '.1').catch(() => {})
}
} catch {
/* missing file — nothing to rotate */
}
await appendFile(path, JSON.stringify(entry) + '\n', { mode: 0o600 })
} catch {
// Audit is best-effort; never throw into the dispatch path.
}
}
export async function readRecentAudit(limit = 50): Promise<AuditEntry[]> {
let text: string
try {
text = await readFile(auditLogPath(), 'utf8')
} catch {
return []
}
const lines = text.split('\n').filter((l) => l.trim().length > 0)
const out: AuditEntry[] = []
for (const l of lines.slice(-limit)) {
try {
out.push(JSON.parse(l) as AuditEntry)
} catch {
/* skip a torn/partial line */
}
}
return out
}
/** Best-effort one-line preview of tool args (paths, commands) for the log. */
export function previewArgs(args: Record<string, unknown>): string | undefined {
try {
const parts: string[] = []
for (const key of ['path', 'command', 'cmd', 'pattern', 'cwd', 'pid', 'port', 'name']) {
const v = (args as Record<string, unknown>)[key]
if (v !== undefined && v !== null && typeof v !== 'object') {
parts.push(`${key}=${String(v)}`)
}
if (parts.length >= 2) {
break
}
}
const s = parts.length ? parts.join(' ') : JSON.stringify(args)
return s.length > 120 ? s.slice(0, 119) + '…' : s
} catch {
return undefined
}
}
/** Best-effort short success summary from a handler result. */
export function summarizeResult(result: unknown): string | undefined {
if (result === null || typeof result !== 'object') {
return undefined
}
const r = result as Record<string, unknown>
if (typeof r.exit_code === 'number') {
return `exit ${r.exit_code}`
}
if (typeof r.path === 'string') {
return r.path
}
if (typeof r.pid === 'number') {
return `pid ${r.pid}`
}
if (typeof r.stdout === 'string' && r.stdout.trim()) {
const first = r.stdout.trim().split('\n')[0]!
return first.length > 80 ? first.slice(0, 79) + '…' : first
}
return undefined
}
+72
View File
@@ -0,0 +1,72 @@
// Daemon status file — make the headless daemon observable.
//
// The daemon parks forever and logs JSON lines to stderr; once it's a service,
// there's no easy "is it alive and connected right now?" check without tailing
// journald. This writes a small heartbeat file at ~/.hermes/daemon-status.json
// that `hermes-relay daemon --status` reads — uptime, connection state, server
// version, advertised-tool count. Mirrors the existing desktop-voice.json
// discovery-file pattern in daemon.ts.
import { promises as fs } from 'node:fs'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
export type DaemonState = 'starting' | 'connected' | 'reconnecting' | 'stopped'
export interface DaemonStatus {
pid: number
url: string
state: DaemonState
/** Epoch seconds. */
started_at: number
/** Epoch seconds — bumped on every state change + a periodic heartbeat so
* a reader can tell a live daemon from a crashed one whose file lingers. */
updated_at: number
server_version?: string | null
advertised_tools?: number
voice_url?: string | null
last_event?: string
}
export function daemonStatusPath(): string {
return join(homedir(), '.hermes', 'daemon-status.json')
}
export async function writeDaemonStatus(status: DaemonStatus): Promise<void> {
const filePath = daemonStatusPath()
try {
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, JSON.stringify(status, null, 2) + '\n', { mode: 0o600 })
} catch {
// Best-effort — never let status bookkeeping take down the daemon.
}
}
export async function readDaemonStatus(): Promise<DaemonStatus | null> {
try {
const text = await fs.readFile(daemonStatusPath(), 'utf8')
return JSON.parse(text) as DaemonStatus
} catch {
return null
}
}
export async function clearDaemonStatus(): Promise<void> {
try {
await fs.unlink(daemonStatusPath())
} catch {
/* missing — fine */
}
}
/** Is a process with this pid currently alive? `kill(pid, 0)` sends no signal
* but throws ESRCH when the pid is gone — the standard cross-platform liveness
* probe (works on Windows too via libuv). EPERM means alive-but-not-ours. */
export function isPidAlive(pid: number): boolean {
try {
process.kill(pid, 0)
return true
} catch (e) {
return (e as NodeJS.ErrnoException)?.code === 'EPERM'
}
}
+68
View File
@@ -0,0 +1,68 @@
// Actionable error hints — map a raw failure to a "here's what to do" line.
//
// Commands used to surface bare error strings ("auth error", "no session
// matches prefix") that leave the user stuck. suggestedFix() pattern-matches
// the common failure classes and returns a next-step command; formatError()
// renders the error + hint together so stderr is always actionable.
import type { Theme } from './theme.js'
export interface HintContext {
/** The subcommand that failed (for tailoring the suggestion). */
command?: string
/** The relay URL in play — substituted into suggested commands. */
url?: string
}
function errMessage(err: unknown): string {
if (err instanceof Error) {
return err.message
}
return String(err)
}
/**
* Return a one-line actionable hint for a failure, or null if we have nothing
* better to say than the raw error. Ordered most-specific first.
*/
export function suggestedFix(err: unknown, ctx: HintContext = {}): string | null {
const msg = errMessage(err).toLowerCase()
const url = ctx.url || 'ws://<host>:8767'
const remote = ctx.url ? `--remote ${ctx.url}` : '--remote ws://<host>:8767'
if (/consent|tools? (are )?disabled|--no-tools|not consented/.test(msg)) {
return `Desktop tools need consent. Run: hermes-relay pair ${remote} --grant-tools`
}
if (/cert|spki|pin mismatch|self.signed|tls/.test(msg)) {
return `TLS/cert-pin problem. If the relay's certificate rotated, re-pair to re-pin: hermes-relay pair ${remote}`
}
if (/no (stored )?(credentials|session|token)|not paired|missing (creds|credentials)/.test(msg)) {
return `No stored session. Pair first: hermes-relay pair --remote ${url}`
}
if (/auth|unauthor|401|forbidden|403|token.*(expired|invalid|rejected)|pairing (failed|rejected)/.test(msg)) {
return `Session may be expired or revoked. Re-pair: hermes-relay pair ${remote}`
}
if (/econnrefused|connection refused/.test(msg)) {
return `Nothing is listening at ${url}. Is the relay running on the host?`
}
if (/enotfound|getaddrinfo|eai_again/.test(msg)) {
return `Host not found. Check the URL includes the port, e.g. ws://host:8767`
}
if (/etimedout|timed out|timeout|ehostunreach|enetunreach/.test(msg)) {
return `Timed out reaching the relay. Check the network/Tailscale, or try another endpoint with --remote.`
}
if (ctx.command && /unknown .* sub-?verb|usage/.test(msg)) {
return `Run \`hermes-relay ${ctx.command} --help\` to see the available sub-commands.`
}
return null
}
/** Render "error: <msg>" plus an indented hint line when one applies. */
export function formatError(err: unknown, ctx: HintContext, t: Theme): string {
const lines = [t.err(`error: ${errMessage(err)}`)]
const hint = suggestedFix(err, ctx)
if (hint) {
lines.push(t.muted(` ${hint}`))
}
return lines.join('\n')
}
+41
View File
@@ -0,0 +1,41 @@
// CLI brand logo — slim box-drawing "Hermes Relay" wordmark.
//
// Shown atop `--help`, the first-run welcome, the REPL header, and on demand
// via `hermes-relay logo`. Callers gate it to interactive/help contexts and
// pick the stream — it must never land on stdout for `--json`/`--quiet`/piped
// output. Color flows through the shared Theme so `--no-color`/NO_COLOR/non-TTY
// degrade to a plain (still legible) box-drawing wordmark.
import { VERSION } from '../version.js'
import { theme as makeTheme, type Theme } from './theme.js'
/** The wordmark — 3 rows of box-drawing glyphs spelling "Hermes Relay". */
const WORDMARK = [
'╦ ╦┌─┐┬─┐┌┬┐┌─┐┌─┐ ┬─┐┌─┐┬ ┌─┐┬ ┬',
'╠═╣├┤ ├┬┘│││├┤ └─┐ ├┬┘├┤ │ ├─┤└┬┘',
'╩ ╩└─┘┴└─┴ ┴└─┘└─┘ ┴└─└─┘┴─┘┴ ┴ ┴'
]
const TAGLINE = 'thin client · remote Hermes agent over WSS'
export interface LogoOptions {
theme?: Theme
/** Append the tagline + version lines (default true). */
subtitle?: boolean
}
/** Render the logo block (no trailing newline handling beyond a single `\n`). */
export function renderLogo(opts: LogoOptions = {}): string {
const t = opts.theme ?? makeTheme()
const lines = WORDMARK.map((l) => t.cyan(l))
if (opts.subtitle ?? true) {
lines.push(t.muted(TAGLINE))
lines.push(t.muted(`v${VERSION}`))
}
return lines.join('\n') + '\n'
}
/** Print the logo to stdout (used by the `logo` command). */
export function printLogo(opts: LogoOptions = {}): void {
process.stdout.write(renderLogo(opts))
}
+105
View File
@@ -0,0 +1,105 @@
// Minimal stderr spinner for long operations — zero deps.
//
// The CLI's #1 perceived-friction is silence: pairing probes endpoints for
// 4–12s, chat/shell wait up to 60s for the gateway, update downloads a binary
// — all with no feedback, so it looks hung. This gives a braille spinner on a
// TTY and degrades to a single final result line when piped/quiet/--json.
//
// Always on stderr so it never pollutes stdout (a piped transcript or `> out`
// stays clean). When disabled, start()/update() are no-ops but succeed()/fail()
// still print one line so non-interactive runs aren't left guessing.
import { SYMBOLS, theme as makeTheme, type Theme } from './theme.js'
export interface SpinnerOptions {
/** Animate? Default: stderr is a TTY. Pass false for --quiet / --json. */
enabled?: boolean
stream?: NodeJS.WriteStream
theme?: Theme
}
export interface Spinner {
start(text?: string): Spinner
update(text: string): void
succeed(text?: string): void
fail(text?: string): void
/** Stop and clear the line without printing a result. */
stop(): void
}
class TtySpinner implements Spinner {
private timer: ReturnType<typeof setInterval> | null = null
private frame = 0
private text: string
private readonly stream: NodeJS.WriteStream
private readonly t: Theme
constructor(text: string, private readonly enabled: boolean, stream: NodeJS.WriteStream, t: Theme) {
this.text = text
this.stream = stream
this.t = t
}
start(text?: string): Spinner {
if (text !== undefined) {
this.text = text
}
if (!this.enabled || this.timer) {
return this
}
this.stream.write('\x1b[?25l') // hide cursor
const render = () => {
const f = this.t.cyan(SYMBOLS.spinner[this.frame % SYMBOLS.spinner.length]!)
this.stream.write(`\r\x1b[2K${f} ${this.text}`)
this.frame++
}
render()
this.timer = setInterval(render, 80)
return this
}
update(text: string): void {
this.text = text
if (!this.enabled) {
return
}
if (!this.timer) {
this.stream.write(`\r\x1b[2K${this.t.muted('…')} ${this.text}`)
}
}
private end(symbol: string, text?: string): void {
const line = text ?? this.text
this.clear()
this.stream.write(`${symbol} ${line}\n`)
}
succeed(text?: string): void {
this.end(this.t.ok(SYMBOLS.ok), text)
}
fail(text?: string): void {
this.end(this.t.err(SYMBOLS.err), text)
}
stop(): void {
this.clear()
}
private clear(): void {
if (this.timer) {
clearInterval(this.timer)
this.timer = null
}
if (this.enabled) {
this.stream.write('\r\x1b[2K\x1b[?25h') // clear line + show cursor
}
}
}
export function createSpinner(text: string, opts: SpinnerOptions = {}): Spinner {
const stream = opts.stream ?? process.stderr
const enabled = opts.enabled ?? !!stream.isTTY
const t = opts.theme ?? makeTheme()
return new TtySpinner(text, enabled, stream, t)
}
+109
View File
@@ -0,0 +1,109 @@
// Tiny column-aligned table renderer — zero deps.
//
// status / devices / sessions / tools all list rows of data, but each used to
// format them differently (indented key:value, sparse columns, multi-line
// blocks). This unifies them: pass columns + rows, get an aligned block that
// adapts to terminal width. ANSI-aware so colored cells still align.
import { visibleWidth, stripAnsi, type Theme } from './theme.js'
export interface TableColumn {
header: string
/** Right-align numeric/age columns; default left. */
align?: 'left' | 'right'
}
export interface TableOptions {
/** Leading spaces on every row (default 2 — matches existing list indent). */
indent?: number
/** Spaces between columns (default 2). */
gap?: number
/** Hard width cap; defaults to the terminal width (or 100 when not a TTY). */
maxWidth?: number
/** Show the header row (default true). */
header?: boolean
/** Theme for dimming the header — omit for plain output. */
theme?: Theme
}
/** Pad `text` to `width` visible columns, respecting embedded ANSI. */
function pad(text: string, width: number, align: 'left' | 'right'): string {
const gap = width - visibleWidth(text)
if (gap <= 0) {
return text
}
const spaces = ' '.repeat(gap)
return align === 'right' ? spaces + text : text + spaces
}
/** Truncate to `max` visible columns with an ellipsis. Skips cells that carry
* ANSI (truncating mid-escape would corrupt them); those are assumed short. */
function truncate(text: string, max: number): string {
if (max <= 0) {
return ''
}
if (stripAnsi(text) !== text) {
return text
}
if (text.length <= max) {
return text
}
return max <= 1 ? '…' : text.slice(0, max - 1) + '…'
}
/**
* Render an aligned table. The LAST column is treated as flexible: when the
* natural layout overflows `maxWidth`, only that column is truncated, so
* fixed-width identifiers (token prefixes, names) stay intact and the
* free-text tail (description / error) absorbs the squeeze.
*/
export function renderTable(
columns: TableColumn[],
rows: string[][],
opts: TableOptions = {}
): string {
const indent = opts.indent ?? 2
const gap = opts.gap ?? 2
const showHeader = opts.header ?? true
const maxWidth = opts.maxWidth ?? (process.stdout.columns || 100)
const t = opts.theme
const n = columns.length
const widths = columns.map((col, i) => {
const headerW = showHeader ? visibleWidth(col.header) : 0
const cellW = rows.reduce((m, r) => Math.max(m, visibleWidth(r[i] ?? '')), 0)
return Math.max(headerW, cellW)
})
// Shrink the flexible (last) column if the row would overflow the terminal.
const fixed = indent + gap * (n - 1) + widths.slice(0, -1).reduce((a, b) => a + b, 0)
const lastBudget = maxWidth - fixed
if (n > 0 && lastBudget > 0 && widths[n - 1]! > lastBudget) {
widths[n - 1] = lastBudget
}
const pre = ' '.repeat(indent)
const sep = ' '.repeat(gap)
const lines: string[] = []
const renderRow = (cells: string[], dimHeader = false): string => {
const parts = columns.map((col, i) => {
let cell = cells[i] ?? ''
const w = widths[i]!
if (i === n - 1) {
cell = truncate(cell, w)
}
const padded = pad(cell, w, col.align ?? 'left')
return dimHeader && t ? t.muted(padded) : padded
})
return pre + parts.join(sep).replace(/\s+$/, '')
}
if (showHeader) {
lines.push(renderRow(columns.map((c) => c.header), true))
}
for (const r of rows) {
lines.push(renderRow(r))
}
return lines.join('\n')
}
+132
View File
@@ -0,0 +1,132 @@
// Shared terminal theme — one visual language for the whole CLI.
//
// Before this module, color/symbol choices were scattered: renderer.ts had a
// full ANSI layer, while status/devices/voice/tools each hardcoded "●"/"○" or
// raw `\x1b[..m` strings. That made the CLI feel inconsistent and was a
// maintenance burden. This is the single source of truth.
//
// Zero runtime deps (the package ships no chalk/ora) — just escape codes and
// an env-aware enable check. Construct a `Theme` per command via `theme(opts)`
// so `--no-color` / NO_COLOR / non-TTY all degrade to plain text uniformly.
export const ANSI = {
reset: '\x1b[0m',
dim: '\x1b[2m',
bold: '\x1b[1m',
italic: '\x1b[3m',
underline: '\x1b[4m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
gray: '\x1b[90m'
} as const
/**
* Decide whether ANSI color should be emitted. Precedence mirrors renderer.ts
* so behavior is identical everywhere:
* explicit noColor flag → off
* NO_COLOR (any value) → off (https://no-color.org)
* FORCE_COLOR=0 → off
* FORCE_COLOR=<other> → on
* else → on iff stdout is a TTY
*/
export function colorEnabled(noColor?: boolean): boolean {
if (noColor) {
return false
}
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
}
/** Semantic glyphs. Kept ASCII-adjacent so they render in Windows Terminal,
* the iTerm/Terminal.app default fonts, and over SSH. */
export const SYMBOLS = {
ok: '✓',
err: '✗',
warn: '⚠',
arrow: '→',
on: '●',
off: '○',
dot: '·',
bullet: '•',
spinner: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
} as const
export class Theme {
constructor(public readonly enabled: boolean) {}
/** Wrap `text` in `code` (+reset) when color is on, else return it bare. */
paint(code: string, text: string): string {
return this.enabled ? `${code}${text}${ANSI.reset}` : text
}
// Raw color helpers.
bold = (t: string): string => this.paint(ANSI.bold, t)
dim = (t: string): string => this.paint(ANSI.dim, t)
red = (t: string): string => this.paint(ANSI.red, t)
green = (t: string): string => this.paint(ANSI.green, t)
yellow = (t: string): string => this.paint(ANSI.yellow, t)
blue = (t: string): string => this.paint(ANSI.blue, t)
cyan = (t: string): string => this.paint(ANSI.cyan, t)
magenta = (t: string): string => this.paint(ANSI.magenta, t)
gray = (t: string): string => this.paint(ANSI.gray, t)
// Semantic helpers — prefer these in commands so intent (not color) drives
// the call site; a future palette swap stays in one file.
ok = (t: string): string => this.green(t)
warn = (t: string): string => this.yellow(t)
err = (t: string): string => this.red(t)
muted = (t: string): string => this.gray(t)
heading = (t: string): string => this.bold(t)
/** Green ● for live/enabled, gray ○ for off — used by tools/voice/devices
* status columns so "on vs off" is scannable at a glance. */
statusDot(on: boolean): string {
return on ? this.green(SYMBOLS.on) : this.muted(SYMBOLS.off)
}
/** "✓ label" / "✗ label" / "⚠ label" prefixed status lines. */
okLine = (t: string): string => `${this.ok(SYMBOLS.ok)} ${t}`
errLine = (t: string): string => `${this.err(SYMBOLS.err)} ${t}`
warnLine = (t: string): string => `${this.warn(SYMBOLS.warn)} ${t}`
/** A section header: bold title with a trailing rule of dim dots. Used by
* status/doctor/voice to break dense output into scannable groups. */
section(title: string): string {
return this.bold(title)
}
}
export interface ThemeOptions {
/** Force-disable color regardless of env/TTY (the `--no-color` flag). */
noColor?: boolean
}
/** Build a Theme for a command, honoring env + the `--no-color` flag. */
export function theme(opts: ThemeOptions = {}): Theme {
return new Theme(colorEnabled(opts.noColor))
}
const ANSI_RE = /\x1b\[[0-9;]*m/g
/** Visible width of a string, ignoring ANSI escape sequences. Data in this
* CLI is ASCII-dominant (urls, token prefixes, tool names) so a codepoint
* count is accurate enough; we don't pull in a full East-Asian-width table. */
export function visibleWidth(s: string): number {
return stripAnsi(s).length
}
export function stripAnsi(s: string): string {
return s.replace(ANSI_RE, '')
}
+88
View File
@@ -0,0 +1,88 @@
// Shared per-subcommand usage/help renderer.
//
// Before this, only the top-level `hermes-relay --help` existed; subcommands
// with sub-verbs (devices/sessions/plugins/voice) greeted a wrong verb with a
// terse "unknown sub-verb" instead of explaining themselves. Each command now
// declares a UsageSpec and renders it consistently for both `--help` and the
// unknown-sub-verb fallback.
import { theme as makeTheme, type Theme } from './theme.js'
export interface UsageSub {
verb: string
desc: string
}
export interface UsageFlag {
flag: string
desc: string
}
export interface UsageSpec {
/** Bare command name, e.g. "devices". */
name: string
/** One-line summary shown under the title. */
summary: string
/** Usage lines (without the leading "hermes-relay"). */
usage: string[]
subcommands?: UsageSub[]
flags?: UsageFlag[]
examples?: string[]
}
/** Pad a left column to `width` for two-column lists. */
function col(left: string, right: string, width: number, t: Theme): string {
const gap = Math.max(2, width - left.length + 2)
return ` ${left}${' '.repeat(gap)}${t.muted(right)}`
}
export function renderUsage(spec: UsageSpec, t: Theme = makeTheme()): string {
const lines: string[] = []
lines.push(`${t.bold(spec.name)} — ${spec.summary}`)
lines.push('')
lines.push(t.bold('Usage:'))
for (const u of spec.usage) {
lines.push(` ${t.muted('hermes-relay')} ${u}`)
}
if (spec.subcommands?.length) {
const w = Math.max(...spec.subcommands.map((s) => s.verb.length))
lines.push('')
lines.push(t.bold('Sub-commands:'))
for (const s of spec.subcommands) {
lines.push(col(s.verb, s.desc, w, t))
}
}
if (spec.flags?.length) {
const w = Math.max(...spec.flags.map((f) => f.flag.length))
lines.push('')
lines.push(t.bold('Flags:'))
for (const f of spec.flags) {
lines.push(col(f.flag, f.desc, w, t))
}
}
if (spec.examples?.length) {
lines.push('')
lines.push(t.bold('Examples:'))
for (const ex of spec.examples) {
lines.push(` ${t.muted('$')} ${ex}`)
}
}
return lines.join('\n') + '\n'
}
/** Print usage to stdout (for --help). */
export function printUsage(spec: UsageSpec, t?: Theme): void {
process.stdout.write(renderUsage(spec, t))
}
/** Print usage to stderr with an "unknown sub-command" preamble (for the
* fallback when a user types a verb we don't recognise). Returns exit code 2. */
export function unknownSubcommand(spec: UsageSpec, got: string, t: Theme = makeTheme()): number {
process.stderr.write(t.err(`unknown ${spec.name} sub-command: "${got}"`) + '\n\n')
process.stderr.write(renderUsage(spec, t))
return 2
}
+32
View File
@@ -335,6 +335,23 @@ export interface ProbeResult {
error?: string
}
/** Progress event emitted by `probeCandidatesByPriority` so callers can show
* per-endpoint feedback during the otherwise-silent reachability race. */
export interface ProbeProgress {
phase: 'probing' | 'result' | 'cached'
candidate: EndpointCandidate
/** 1-based position in the full candidate list. */
index: number
total: number
reachable?: boolean
elapsedMs?: number
error?: string
}
export interface ProbeOptions {
onProbe?: (ev: ProbeProgress) => void
}
/**
* Fire one HEAD-equivalent probe. We use GET (not HEAD) because not every
* relay flavor answers HEAD — Tailscale Serve in particular has been
@@ -387,11 +404,14 @@ export async function probeCandidate(
*/
export async function probeCandidatesByPriority(
candidates: EndpointCandidate[],
opts: ProbeOptions = {},
): Promise<EndpointCandidate> {
if (candidates.length === 0) {
throw new Error('no endpoint candidates to probe')
}
const total = candidates.length
const indexOf = new Map<EndpointCandidate, number>(candidates.map((c, i) => [c, i + 1]))
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.
@@ -411,6 +431,7 @@ export async function probeCandidatesByPriority(
for (const c of group) {
const cached = probeCache.get(cacheKey(c))
if (cached && cached.expiresAt > now && cached.reachable) {
opts.onProbe?.({ phase: 'cached', candidate: c, index: indexOf.get(c) ?? 0, total, reachable: true })
return c
}
}
@@ -421,6 +442,8 @@ export async function probeCandidatesByPriority(
// so either can trigger abort.
const groupController = new AbortController()
const probes = group.map(async (c) => {
const idx = indexOf.get(c) ?? 0
opts.onProbe?.({ phase: 'probing', candidate: c, index: idx, total })
const timeout = AbortSignal.timeout(PROBE_TIMEOUT_MS)
// AbortSignal.any is available on Node ≥20 for combining signals.
const signal = AbortSignal.any([groupController.signal, timeout])
@@ -429,6 +452,15 @@ export async function probeCandidatesByPriority(
expiresAt: Date.now() + PROBE_CACHE_TTL_MS,
reachable: result.reachable,
})
opts.onProbe?.({
phase: 'result',
candidate: c,
index: idx,
total,
reachable: result.reachable,
elapsedMs: result.elapsedMs,
...(result.error !== undefined ? { error: result.error } : {}),
})
if (!result.reachable) {
throw new Error(result.error ?? `unreachable: ${c.role}`)
}
+34 -3
View File
@@ -21,12 +21,39 @@
import { createInterface } from 'node:readline/promises'
import { getActiveDesktopRelayUrl } from './desktopConfig.js'
import { renderLogo } from './lib/logo.js'
import { listSessions } from './remoteSessions.js'
const URL_RE = /^wss?:\/\/\S+$/
export const isValidRelayUrl = (raw: string): boolean => URL_RE.test(raw.trim())
/** Default relay WSS port — matches the server's listen port. */
export const DEFAULT_RELAY_PORT = 8767
/**
* Fill in the default relay port when the user typed a bare host. Scoped to
* `ws://` ONLY: a `wss://` URL is almost always a reverse-proxy / Tailscale
* Serve front on the implied :443, so appending :8767 there would break it.
* Plain `ws://host` is the LAN-direct case that wants :8767. Returns `added`
* so the caller can surface the substitution instead of doing it silently.
*/
export function normalizeRelayUrl(raw: string): { url: string; added: boolean } {
const trimmed = raw.trim()
try {
const u = new URL(trimmed)
if (!u.port && u.protocol === 'ws:') {
u.port = String(DEFAULT_RELAY_PORT)
// URL.toString() adds a trailing slash for empty paths — strip it so the
// stored form stays tidy (ws://host:8767, not ws://host:8767/).
return { url: u.toString().replace(/\/$/, ''), added: true }
}
return { url: trimmed, added: false }
} catch {
return { url: trimmed, added: false }
}
}
export interface PromptRelayUrlOptions {
/** Max attempts before we give up. Default 3. */
maxAttempts?: number
@@ -77,7 +104,11 @@ export async function promptForRelayUrl(
}
if (isValidRelayUrl(trimmed)) {
return trimmed
const norm = normalizeRelayUrl(trimmed)
if (norm.added) {
process.stderr.write(` (no port given — using :${DEFAULT_RELAY_PORT})\n`)
}
return norm.url
}
process.stderr.write(
@@ -162,9 +193,9 @@ export async function resolveFirstRunUrl(
// Case (4): 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) {
process.stderr.write('\n' + renderLogo())
const banner =
opts.banner ??
"Welcome to hermes-relay. No stored sessions yet — let's pair with a relay server."
opts.banner ?? "No stored sessions yet — let's pair with a relay server."
process.stderr.write(`\n${banner}\n`)
return promptForRelayUrl()
}
+7 -4
View File
@@ -18,10 +18,13 @@ 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.
Desktop tools let the remote Hermes agent act on THIS machine:
• read/write files, run shell + PowerShell, manage processes & jobs
• this is a ONE-TIME grant per relay URL, stored in
~/.hermes/remote-sessions.json (revoke by re-pairing or editing that file)
• review what the agent runs anytime with: hermes-relay audit
Only enable this for a Hermes server you trust.
Type 'yes' to enable, or rerun with --no-tools to keep it off.
`
export const COMPUTER_USE_CONSENT_PROMPT = `
+19
View File
@@ -25,6 +25,7 @@ import * as os from 'node:os'
import type { RelayTransport } from '../transport/RelayTransport.js'
import { appendAudit, previewArgs, summarizeResult } from '../lib/auditLog.js'
import { VERSION } from '../version.js'
import { getComputerGrantSummary, getComputerUseRuntimeSummary } from './computerGrants.js'
@@ -297,6 +298,15 @@ export class DesktopToolRouter {
// the outcome as the canonical result — the handler decided the
// work was completable.
this.sendResponse({ request_id, ok: true, result })
// Local audit trail — fire-and-forget so logging never delays the reply.
void appendAudit({
ts: Date.now(),
tool,
ok: true,
request_id,
args_preview: previewArgs(args),
summary: summarizeResult(result)
})
} catch (e) {
clearTimeout(timeoutTimer)
// Distinguish aborts (timeout or transport teardown) from genuine
@@ -313,6 +323,15 @@ export class DesktopToolRouter {
// grep'ing through journal logs.
this.lastError = { message, tool, ts: Date.now() }
this.sendResponse({ request_id, ok: false, error: message })
void appendAudit({
ts: Date.now(),
tool,
ok: false,
aborted,
request_id,
args_preview: previewArgs(args),
error: message
})
}
}
+1 -1
View File
@@ -1,2 +1,2 @@
// Regenerated from package.json by gen:version script. Do not edit by hand.
export const VERSION = "0.3.0-alpha.18" as const
export const VERSION = "0.4.0-alpha.1" as const
+80 -7
View File
@@ -103,7 +103,9 @@ _PLAYBACK_DRAIN_TIMEOUT_SECONDS = 2.5
_PRE_HERMES_STATUS_LEAD_SECONDS = 0.75
_HERMES_PROGRESS_INTERVAL_SECONDS = 5.0
_HERMES_SPOKEN_PROGRESS_AFTER_SECONDS = 15.0
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS = 30.0
# Calmer cadence: only re-speak the SAME high-level status this far apart, and
# only when the coarse status actually changed (see _should_repeat_spoken_status).
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS = 90.0
_RESUME_TTL_SECONDS = 30.0
# Max time a completed background result waits for the floor to clear before it
# is spoken anyway (ADR 33 Tier B result delivery).
@@ -2581,7 +2583,13 @@ class RealtimeAgentHandler:
await asyncio.sleep(_HERMES_PROGRESS_INTERVAL_SECONDS)
now = time.time()
status = session.hermes_run_status
if status not in {"running", "waiting_for_confirmation"}:
# Keep the heartbeat alive while the underlying run is still in
# flight, even if `status` transiently drifts off "running" — the
# client kills the turn after ~90s of websocket silence, so this is
# the one thing keeping a long/background run's socket warm.
if not _should_continue_heartbeat(
session.hermes_task, status, session_closed=session.closed
):
return
elapsed_seconds = now - started_at
message, status_key = _hermes_progress_status(session)
@@ -2589,19 +2597,21 @@ class RealtimeAgentHandler:
status_key.startswith("progress:")
and "drafting a response" in status_key.lower()
)
coarse_key = _coarse_spoken_status_key(status, status_key)
should_speak = (
speakable_progress
and elapsed_seconds >= _HERMES_SPOKEN_PROGRESS_AFTER_SECONDS
and session.floor.can_speak(FloorMouth.ANDROID_FILLER)
and (
session.hermes_last_spoken_progress_key != status_key
or now - session.hermes_last_spoken_progress_at
>= _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS
and _should_repeat_spoken_status(
now,
session.hermes_last_spoken_progress_at,
session.hermes_last_spoken_progress_key,
coarse_key,
)
)
if should_speak:
session.hermes_last_spoken_progress_at = now
session.hermes_last_spoken_progress_key = status_key
session.hermes_last_spoken_progress_key = coarse_key
await self._send(
ws,
session,
@@ -3582,6 +3592,69 @@ def _tool_status_line(tool_name: str | None, *, started: bool) -> str:
return f"Finished {label}."
def _should_continue_heartbeat(
task: asyncio.Task[Any] | None,
status: str,
*,
session_closed: bool,
) -> bool:
"""Whether the Hermes run-progress heartbeat should keep ticking.
The heartbeat is the only thing keeping the realtime websocket from going
silent during a long/background Hermes run, and the client kills the turn
after ~90s of silence. So the heartbeat must NOT self-terminate just because
``hermes_run_status`` momentarily drifts off ``running`` (e.g. a status that
briefly reads ``completed``/``idle`` between SSE bursts on a still-running
background run). It keeps ticking while the underlying task is alive and the
socket is open; it only stops once the task is actually finished/None or the
session has closed.
"""
if session_closed:
return False
if task is not None and not task.done():
# The run is still in flight regardless of the transient status label.
return True
# No live task: fall back to the status. Keep ticking only while a run is
# genuinely active/awaiting input; otherwise the heartbeat has nothing to
# guard and should stop.
return status in {"running", "waiting_for_confirmation"}
def _coarse_spoken_status_key(status: str, status_key: str) -> str:
"""Collapse a fine-grained ``status_key`` to a coarse high-level key.
The repeat gate should fire on a *meaningful* status change, not on tool
*message* churn. ``status_key`` values like ``progress:<message text>`` vary
every time a tool emits a new line even though the high-level state ("Hermes
is working") is unchanged. Collapsing those to a single ``progress`` bucket
means message-only churn no longer re-flags ``should_speak`` on the repeat
cadence, while real transitions (entering/leaving a tool, confirmation,
drafting) still register.
"""
if status_key.startswith("progress:"):
return f"{status}:progress"
return f"{status}:{status_key}"
def _should_repeat_spoken_status(
now: float,
last_spoken_at: float,
last_coarse_key: str | None,
coarse_key: str,
*,
repeat_after_seconds: float = _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS,
) -> bool:
"""Whether a *repeat* spoken-progress nudge is warranted.
Returns True when the coarse high-level status changed since the last spoken
progress, OR when the same coarse status has persisted past the (now calmer)
repeat window. A brand-new status (no prior spoken key) always qualifies.
"""
if last_coarse_key is None or coarse_key != last_coarse_key:
return True
return (now - last_spoken_at) >= repeat_after_seconds
def _hermes_progress_status(session: RealtimeAgentSession) -> tuple[str, str]:
if session.hermes_run_status == "waiting_for_confirmation":
return "Waiting for confirmation.", "confirmation"
+150
View File
@@ -0,0 +1,150 @@
"""Unit tests for the realtime-agent Hermes run-progress heartbeat helpers.
Covers two behaviours that keep a long/background Hermes voice turn alive and
calm (see broker.py `_send_hermes_run_progress`):
- `_should_continue_heartbeat`: the heartbeat must keep ticking while the
underlying run task is still in flight even if `hermes_run_status` transiently
drifts off "running" (otherwise the websocket goes silent and the client kills
the turn after ~90s). It only stops once the task is finished/None or the
session has closed.
- `_should_repeat_spoken_status` + `_coarse_spoken_status_key`: spoken progress
is re-flagged only on a *meaningful* high-level status change, or after the
(now calmer) repeat window — tool *message* churn within the same coarse state
no longer re-triggers speech.
"""
from __future__ import annotations
import unittest
from plugin.relay.realtime_agent.broker import (
_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS,
_coarse_spoken_status_key,
_should_continue_heartbeat,
_should_repeat_spoken_status,
)
class _FakeTask:
"""Minimal stand-in for an asyncio.Task exposing only `.done()`."""
def __init__(self, done: bool) -> None:
self._done = done
def done(self) -> bool:
return self._done
class HeartbeatContinuationTest(unittest.TestCase):
def test_continues_while_task_running_even_off_status(self) -> None:
# The run task is still in flight; status briefly read "completed" between
# SSE bursts on a background run. Heartbeat must NOT self-terminate.
running = _FakeTask(done=False)
for status in ("running", "waiting_for_confirmation", "completed", "idle", "error"):
self.assertTrue(
_should_continue_heartbeat(running, status, session_closed=False),
msg=f"should keep ticking for live task at status={status!r}",
)
def test_stops_when_session_closed_even_with_live_task(self) -> None:
running = _FakeTask(done=False)
self.assertFalse(
_should_continue_heartbeat(running, "running", session_closed=True)
)
def test_no_task_falls_back_to_status(self) -> None:
# No live task: keep ticking only while a run is genuinely active.
self.assertTrue(_should_continue_heartbeat(None, "running", session_closed=False))
self.assertTrue(
_should_continue_heartbeat(None, "waiting_for_confirmation", session_closed=False)
)
for status in ("completed", "idle", "cancelled", "error"):
self.assertFalse(
_should_continue_heartbeat(None, status, session_closed=False),
msg=f"no task + terminal status={status!r} should stop",
)
def test_finished_task_falls_back_to_status(self) -> None:
finished = _FakeTask(done=True)
# A finished task behaves like no task: status decides.
self.assertTrue(
_should_continue_heartbeat(finished, "running", session_closed=False)
)
self.assertFalse(
_should_continue_heartbeat(finished, "completed", session_closed=False)
)
class CoarseStatusKeyTest(unittest.TestCase):
def test_progress_messages_collapse_to_one_bucket(self) -> None:
# Two different tool *messages* under the same high-level status collapse
# to a single coarse key, so message churn doesn't re-trigger speech.
a = _coarse_spoken_status_key("running", "progress:Reading file foo.py")
b = _coarse_spoken_status_key("running", "progress:Reading file bar.py")
self.assertEqual(a, b)
self.assertEqual(a, "running:progress")
def test_distinct_tool_states_keep_distinct_keys(self) -> None:
self.assertNotEqual(
_coarse_spoken_status_key("running", "tool:search:running"),
_coarse_spoken_status_key("running", "tool:search:done"),
)
self.assertNotEqual(
_coarse_spoken_status_key("running", "tool:search:running"),
_coarse_spoken_status_key("waiting_for_confirmation", "confirmation"),
)
class SpokenStatusRepeatGateTest(unittest.TestCase):
def test_first_status_always_speaks(self) -> None:
self.assertTrue(
_should_repeat_spoken_status(
now=100.0,
last_spoken_at=0.0,
last_coarse_key=None,
coarse_key="running:tool:search:running",
)
)
def test_meaningful_change_speaks_immediately(self) -> None:
# Different coarse key -> speak even though almost no time has passed.
self.assertTrue(
_should_repeat_spoken_status(
now=101.0,
last_spoken_at=100.0,
last_coarse_key="running:tool:search:running",
coarse_key="running:tool:search:done",
)
)
def test_same_status_message_churn_does_not_respeak_early(self) -> None:
# Same coarse key, only a tool message churned, and the repeat window has
# NOT elapsed -> stay quiet.
self.assertFalse(
_should_repeat_spoken_status(
now=120.0,
last_spoken_at=100.0, # 20s < 90s repeat window
last_coarse_key="running:progress",
coarse_key="running:progress",
)
)
def test_same_status_respeaks_after_repeat_window(self) -> None:
# Same coarse key, but the (calm) repeat window elapsed -> a gentle nudge.
self.assertTrue(
_should_repeat_spoken_status(
now=100.0 + _HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS + 1.0,
last_spoken_at=100.0,
last_coarse_key="running:progress",
coarse_key="running:progress",
)
)
def test_repeat_window_is_calm(self) -> None:
# Guards the cadence relaxation: the repeat window is at least 90s.
self.assertGreaterEqual(_HERMES_SPOKEN_PROGRESS_REPEAT_SECONDS, 90.0)
if __name__ == "__main__":
unittest.main()
+6 -10
View File
@@ -98,11 +98,11 @@ Everything currently shipped works — pairing, shell, chat, tools, devices, sta
## 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.
Server-side: yes. The relay's `desktop` channel keeps a rolling 100-command audit buffer (surfaced on the loopback `/desktop/health` route) with tool name, request ID, and 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.
Client-side: yes — run `hermes-relay audit`. The tool router appends every `desktop_*` call it runs to a local log at `~/.hermes/desktop-audit.jsonl`, and `audit` renders the recent entries (tool, status, detail). No network, no auth — it's your machine's own record of what the agent did, and it works whether the relay is local or remote.
If you care about this (you probably should), pair only with Hermes hosts you control.
If you care about this (you probably should), pair only with Hermes hosts you control — and skim `hermes-relay audit` to see what's been run.
## How is this different from MCP?
@@ -115,15 +115,11 @@ Hermes's desktop tools are:
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?
## Is there 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:
Yes — it shipped. `hermes-relay daemon` runs the tool router headless (no PTY, no TUI), advertising your `desktop_*` tools so the agent can reach you with no shell open. `hermes-relay daemon start` runs it in the **background**: no console window, logs to `~/.hermes/daemon.log`, and it survives closing the terminal. `daemon status` shows state/uptime, `daemon stop` stops it.
- 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` session open in a spare terminal tab for the agent to dispatch tool calls into.
The one piece still outstanding is **auto-start across reboots/logout** — installing as a Windows service / systemd user unit / launchd agent. That's v1.0 work (tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track)); until then, `daemon start` covers "background, this session," or wrap the foreground `hermes-relay daemon` with your own service manager.
## Can multiple people use the same Hermes host from different CLI clients?
+6 -3
View File
@@ -44,11 +44,11 @@ The same chord set works on macOS (`Cmd+Shift+4` → screenshot to clipboard →
| Mode | Command | Best for |
|------|---------|----------|
| **Tools (the hand)** | Automatic, in-session | The remote agent can call 23 `desktop_*` tools — filesystem (`read_file` / `write_file` / `patch` / `search_files`), shell (`terminal` / `powershell`), process control (`spawn_detached` / `list_processes` / `kill_process` / `find_pid_by_port`), a job API for long tasks (`job_start` / `_status` / `_logs` / `_cancel` / `_list`), archive/transfer (`copy_directory` / `zip` / `unzip` / `checksum`), and user-context bridges (`clipboard_read/write` / `screenshot` / `open_in_editor`) — **executed on your machine**, not the server. One-time per-URL consent gate. An experimental computer-use family is off by default. |
| **Daemon** | `hermes-relay daemon` | Headless tool router. Keeps the agent's hands available even when no shell is open. JSON-line lifecycle logs. |
| **Daemon** | `hermes-relay daemon start` | Headless tool router, in the **background** — no console window, survives closing the terminal. `daemon status` / `daemon stop` manage it; bare `daemon` runs foreground with JSON-line logs. |
| **Shell** (default) | `hermes-relay` | The escape hatch: 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. Maintained for scripting; not a growth surface. |
| **Surface plugins** | `hermes-relay plugins` | Install and launch terminal dashboard surfaces. Herm is built in as an installable `herm-tui` plugin with external-terminal and embedded-tray launch paths. |
| **Pair / Sessions / Status / Tools / Devices / Doctor / Update / Workspace / Paste** | `hermes-relay <verb>` | First-time setup, TUI tmux session management, session inventory, server-side toolset introspection, paired-device management, local diagnostics, self-update, workspace-context inspection, one-shot clipboard staging. See [Subcommands](./subcommands.md). |
| **Pair / Sessions / Status / Tools / Devices / Relay / Audit / Doctor / Update / Workspace / Paste** | `hermes-relay <verb>` | First-time setup, TUI tmux session management, session inventory, server-side toolset introspection, paired-device management, relay-server inspection, desktop-tool activity audit, local diagnostics, self-update, workspace-context inspection, one-shot clipboard staging. See [Subcommands](./subcommands.md). |
### In-shell chord set
@@ -74,7 +74,10 @@ While inside the shell/TUI session (bare `hermes-relay`, the default mode), `Ctr
- **[Conversation picker](./subcommands.md#hermes-relay-shell)** — on first or fresh attach, choose from recent server-side Hermes conversations with first-prompt previews before the TUI starts.
- **[TUI session continuity](./subcommands.md#hermes-relay-sessions)** — bare `hermes-relay` resumes the active/default tmux session, replays recent scrollback, and `sessions list/resume/new/kill` gives explicit control when you need it.
- **[Editor tool + interactive patch approval](./tools.md#desktop_open_in_editor-and-interactive-patches)** — agent calls `desktop_open_in_editor(path, line, col)` to open `$VISUAL` / `$EDITOR` / VSCode / Cursor / Sublime / nvim. Agent-proposed patches render as colored unified diffs with `y`/`n`/`e`/`r` prompts.
- **[Daemon mode](./subcommands.md#hermes-relay-daemon)** — `hermes-relay daemon` runs the tool router headless so the agent can reach your machine while you context-switch.
- **[Daemon mode](./subcommands.md#hermes-relay-daemon)** — `hermes-relay daemon start` runs the tool router headless **in the background** (no console window, survives closing the terminal); `daemon status` / `daemon stop` manage it. Bare `hermes-relay daemon` runs in the foreground.
- **[Activity audit](./subcommands.md#hermes-relay-audit)** — `hermes-relay audit` shows what the agent has actually run on your machine through the desktop tools, from a local log — no network, no auth.
- **[Relay inspection](./subcommands.md#hermes-relay-relay)** — `hermes-relay relay context` audits the system-prompt context the relay injects into the agent; `relay info` / `relay security` report server state for operators on the relay host.
- **Polished CLI** — every subcommand answers `--help`; lists render as aligned tables with on/off status dots; slow operations (endpoint probe, gateway connect) show a spinner; pairing reports per-endpoint probe progress and warns before a stored session expires; and a banner greets you (`hermes-relay logo`).
- **[Multi-endpoint pairing](./pairing.md#multi-endpoint-pairing-adr-24)** — one QR carries LAN + Tailscale + public URLs. The client races candidates in priority order, picks the first reachable, and re-probes on every network change.
- **[Reconnect-on-drop + TOFU cert pinning](./pairing.md)** — exponential backoff (1 s → 30 s, 5 min on 429), per-host SPKI sha256 pin captured first-time and verified every reconnect.
- **[Bun-compiled native binary, no Node required](./installation.md)** — curl/irm one-liners install a self-contained binary. Version-aware (`upgrading X → Y` readback), collision-safe `hermes` alias, `~/.hermes/bin/` on PATH.
+18 -6
View File
@@ -48,14 +48,19 @@ Type or paste `F3W7EY`. On success:
```
→ using code: F3W7EY
Pairing with ws://<host>:8767...
Pairing with ws://<host>:8767…
✓ Paired. Token stored in ~/.hermes/remote-sessions.json
Server: 0.6.0
Relay: ws://<host>:8767
Route: lan
server: 1.2.0
relay: ws://<host>:8767
route: lan
tip: add --grant-tools to also enable desktop tools (needed for `daemon`).
```
Subsequent `hermes-relay` commands reuse the stored token.
Subsequent `hermes-relay` commands reuse the stored token. When that token nears its expiry, the CLI warns you (on a TTY) before the next command would fail and prints the exact re-pair command — so an expired session never just silently breaks.
::: tip Port default
A bare `ws://<host>` with no port defaults to `:8767` (the relay's default), and the CLI tells you it did. A `wss://<host>` is left untouched — it's usually a reverse-proxy / Tailscale Serve front on `:443` — so include the port explicitly if your secure relay listens elsewhere.
:::
## Paste safety — what if the code comes out garbled?
@@ -69,7 +74,14 @@ 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 (LAN → Tailscale → public), 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 every network change, the client re-probes — moving from home Wi-Fi to a coffee shop transparently fails over to Tailscale or the public URL.
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 (LAN → Tailscale → public), printing each one's result + latency as it races them, 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 every network change, the client re-probes — moving from home Wi-Fi to a coffee shop transparently fails over to Tailscale or the public URL.
```
Probing 3 endpoint(s)…
✓ [1/3] lan ws://192.168.1.50:8767 145ms
· [2/3] tailscale ws://hermes.tail1234.ts.net:8767 — timeout
→ picked lan endpoint ws://192.168.1.50:8767
```
On the server:
+84 -12
View File
@@ -13,10 +13,13 @@ Full reference for every `hermes-relay` verb. Flags map one-to-one with env vars
| `status` | Local view of stored sessions. Default-redacts tokens. |
| `tools` | Server-side tool inventory (`tools.list` RPC). |
| `devices` | Server-side paired-device management — list / revoke / extend. |
| `daemon` | Headless tool router — keeps `desktop_*` tools advertised even with no shell open. |
| `relay` | Inspect the relay server — `info`, `security`, injected `context`. |
| `daemon` | Headless tool router — keeps `desktop_*` tools advertised even with no shell open. `daemon start` runs it in the background. |
| `audit` | Show what the agent has run on this machine via desktop tools. |
| `doctor` | Local diagnostic — version, install paths, session summary, daemon detection, platform info. |
| `update` | Self-update via GitHub Releases. |
| `workspace` | Print local workspace context (cwd / git / editor / shell) — same envelope shipped to the relay on connect. |
| `logo` | Print the Hermes Relay banner. |
| `help` | Print full help. |
## `hermes-relay` (bare — defaults to `shell`)
@@ -195,19 +198,46 @@ 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.
## `hermes-relay daemon`
## `hermes-relay relay`
Run the tool router headless — no PTY, no Ink TUI, just the WSS connection + `desktop_*` handlers. Lets the agent reach your machine while you're working in another window.
Inspect the relay **server** itself — the management surface the plugin gained in v1.2.0. Resolves the relay + bearer from your stored session, the same way `devices` does.
```bash
hermes-relay daemon # default — auto-human logs on TTY, JSON on pipes
hermes-relay daemon --log-json # force JSON-line lifecycle events on stderr
hermes-relay daemon --log-human # force human-readable
hermes-relay daemon --token <token> # explicit token (for service-managed installs)
hermes-relay relay context # what context the relay injects into the agent's system prompt
hermes-relay relay info # version, uptime, session + device counts, pending commands
hermes-relay relay security # runtime auth toggles (allow_insecure_api_bearer, trust_proxy)
hermes-relay relay <sub> --json
```
`relay context` works from any paired machine (it authenticates with your relay session). `relay info` and `relay security` are **loopback-only** — they answer for operators on the relay host; a remote call gets a clear "run this on the relay host" note instead of a raw 403. Use `context` to audit, e.g., the media-sensitivity block the relay prepends to the agent's prompt.
## `hermes-relay daemon`
Run the tool router headless — no PTY, no Ink TUI, just the WSS connection + `desktop_*` handlers. Lets the agent reach your machine while you're working in another window, or with no terminal open at all.
```bash
hermes-relay daemon start # background — no console window, survives terminal close
hermes-relay daemon status # state, uptime, relay, advertised-tool count
hermes-relay daemon stop # stop the background daemon
hermes-relay daemon # FOREGROUND (current console) — handy for watching logs live
hermes-relay daemon --log-json # foreground: force JSON-line lifecycle events on stderr
hermes-relay daemon --token <t> --allow-tools # skip stored-consent gate (only with --token)
```
**Lifecycle events** (each emitted on stderr — JSON-line by default for journald / log shippers, human-readable on a TTY):
`daemon start` (alias `daemon --detach`) re-spawns the foreground daemon detached: no console window, stdio redirected to `~/.hermes/daemon.log`, and it keeps running after you close the terminal. `daemon status` reads the heartbeat file the running daemon maintains and cross-checks that the pid is alive, so a crashed daemon whose file lingers reads as "not running" (and `status` exits non-zero, for scripts). Bare `hermes-relay daemon` still runs in the foreground.
```
$ hermes-relay daemon status
hermes-relay daemon
state: ● connected
pid: 48213
relay: ws://<host>:8767
uptime: 3h 12m
server: 1.2.0
tools: 23 advertised
```
**Lifecycle events** (emitted on stderr in the foreground, or to `~/.hermes/daemon.log` under `daemon start` — JSON-line by default for journald / log shippers, human-readable on a TTY):
```
starting → process up, transport not yet attempting
@@ -221,14 +251,36 @@ transport_exited → reconnect budget exhausted; exit 1 so service manager
**Fails closed:** no stored session + no `--token` → exits 1. 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).
Service installers (Windows `sc.exe` / systemd user unit / launchd plist) are v1.0 work — until then, run from a terminal tab or wrap with your service manager of choice.
::: tip Background ≠ service
`daemon start` survives closing the terminal, but **not a reboot or logout**. True auto-start (Windows `sc.exe` service / systemd user unit / launchd agent) is still v1.0 work — until then, wrap `hermes-relay daemon` (foreground) with your service manager of choice, or use `daemon start` for "background, this session."
:::
## `hermes-relay audit`
Show what the remote agent has run on **this** machine through the desktop tools — tool, status, and a short detail per call. Read from a local log (`~/.hermes/desktop-audit.jsonl`) the tool router appends to on every `desktop_*` dispatch, so there's no network or auth, and it works whether the relay is local or remote.
```bash
hermes-relay audit # last 50 desktop-tool calls
hermes-relay audit --limit 20 # fewer
hermes-relay audit --json # raw entries for scripting
```
```
Desktop-tool activity (4 most recent)
WHEN TOOL STATUS DETAIL
12s ago desktop_read_file ● ok path=C:\src\app.ts
10s ago desktop_terminal ● ok exit 0
8s ago desktop_write_file ✗ error EACCES: permission denied
2s ago desktop_search ● ok pattern=TODO
```
## `hermes-relay doctor`
Local-only diagnostic report — no network. Useful for support pastes and triaging "is my install OK?"
```bash
hermes-relay doctor # human format (! for warnings, hint at bottom)
hermes-relay doctor # human format (⚠ for warnings, hint at bottom)
hermes-relay doctor --json # machine-readable; safe to paste — tokens are omitted entirely (no prefix)
```
@@ -262,9 +314,26 @@ Fields detected via parallel `git rev-parse` / `git status --porcelain=v1 --bran
The client-side workspace envelope shipped in alpha.6. Server-side prompt-context injection (so the agent reads "Active desktop workspace: machine=X · repo=Y · branch=Z" every turn) is on the way — see [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track-parallel-lane-to-android--experimental). Until then, the envelope is captured by the relay as ephemeral session metadata; you can ask the agent about it explicitly via tool calls.
:::
## `hermes-relay logo`
Print the Hermes Relay banner — the same slim box-drawing wordmark shown atop `--help`, the first-run welcome, and the chat REPL. Handy for screenshots or docs.
```bash
hermes-relay logo
```
```
╦ ╦┌─┐┬─┐┌┬┐┌─┐┌─┐ ┬─┐┌─┐┬ ┌─┐┬ ┬
╠═╣├┤ ├┬┘│││├┤ └─┐ ├┬┘├┤ │ ├─┤└┬┘
╩ ╩└─┘┴└─┴ ┴└─┘└─┘ ┴└─└─┘┴─┘┴ ┴ ┴
thin client · remote Hermes agent over WSS
```
Suppressed automatically for piped / `--json` / `--no-color` output, so it never pollutes a script.
## Global flags
Available on every subcommand.
Available on every subcommand. Every subcommand also answers `--help` with its own usage (sub-commands, flags, examples) — e.g. `hermes-relay devices --help`.
| Flag | Env | Purpose |
|------|-----|---------|
@@ -281,7 +350,10 @@ Available on every subcommand.
| `--no-tools` | — | Don't wire desktop tool handlers for this invocation |
| `--log-human` / `--log-json` | — | `daemon` only: force log format |
| `--allow-tools` | — | `daemon` only: skip stored-consent gate (use only with `--token`; implies trust) |
| `--json` | — | `chat` / `sessions` / `status` / `tools` / `devices` / `doctor` / `workspace` / `update`: JSON output |
| `--detach` | — | `daemon` only: run in the background (alias for `daemon start`) |
| `--status` | — | `daemon` only: print the running daemon's state + uptime and exit (alias for `daemon status`) |
| `--limit <n>` | — | `audit` only: how many recent entries to show (default 50) |
| `--json` | — | `chat` / `sessions` / `status` / `tools` / `devices` / `audit` / `relay` / `voice` / `doctor` / `workspace` / `update`: JSON output |
| `--verbose` | — | `chat` / `tools`: include thinking/reasoning + transport stderr |
| `--quiet`, `-q` | — | Suppress status lines + tool decorations |
| `--no-color` | `NO_COLOR` | Disable ANSI colors |
+4 -2
View File
@@ -209,9 +209,11 @@ If `connected: true` but the agent still says the tool is missing:
## Daemon mode — tools without an open shell
`hermes-relay daemon` runs the WSS connection + tool router headless, so the agent can reach your machine while you're in another window or VS Code or off making coffee. See [Subcommands → daemon](./subcommands.md#hermes-relay-daemon) for full lifecycle/log details.
`hermes-relay daemon` runs the WSS connection + tool router headless, so the agent can reach your machine while you're in another window or VS Code or off making coffee. Use `hermes-relay daemon start` to run it in the **background** (no console window, survives closing the terminal), `daemon status` to check it, and `daemon stop` to stop it. See [Subcommands → daemon](./subcommands.md#hermes-relay-daemon) for full lifecycle/log details.
Service installers (Windows `sc.exe` / systemd user unit / launchd plist) ship with v1.0; until then, run from a terminal tab or wrap with your service manager of choice. Tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track-parallel-lane-to-android--experimental).
Want to see what the agent actually ran on your machine? `hermes-relay audit` lists recent `desktop_*` activity from a local log.
`daemon start` covers "background, this session." True auto-start across reboots/logout (Windows `sc.exe` service / systemd user unit / launchd agent) is still v1.0 work — until then, wrap foreground `hermes-relay daemon` with your service manager of choice. Tracked in [ROADMAP.md](https://github.com/Codename-11/hermes-relay/blob/main/ROADMAP.md#desktop-track-parallel-lane-to-android--experimental).
## Related
+8
View File
@@ -9,6 +9,7 @@
"@vue-flow/core": "^1.48.2"
},
"devDependencies": {
"search-insights": "^2.17.3",
"vitepress": "^1.6.4"
}
},
@@ -2333,6 +2334,13 @@
"fsevents": "~2.3.2"
}
},
"node_modules/search-insights": {
"version": "2.17.3",
"resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz",
"integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/shiki": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz",
+2 -1
View File
@@ -8,7 +8,8 @@
"preview": "vitepress preview"
},
"devDependencies": {
"vitepress": "^1.6.4"
"vitepress": "^1.6.4",
"search-insights": "^2.17.3"
},
"dependencies": {
"@vue-flow/core": "^1.48.2"