Skip to content

lsaether/hermes-remote-control

Repository files navigation

Hermes Remote Control

Mobile-friendly remote control client for Hermes TUI sessions. The installed app is branded Hermes Remote.

Hermes Remote Control is a 1-to-1 attach client for one TUI process's session orchestrator. If you want multiple TUIs to dynamically advertise themselves to a single phone/app endpoint, use the registry sidecar.

Hermes patch requirement

This client needs the Hermes TUI remote bridge patch from NousResearch/hermes-agent#35993.

If your hermes does not recognize --remote-control, or if http://127.0.0.1:8769/healthz is not served after launch, install that PR first. The easiest path is to ask your local Hermes agent to apply it for you, for example:

Apply the patch from https://github.com/NousResearch/hermes-agent/pull/35993 to my installed Hermes checkout, then verify `hermes --tui --remote-control` exposes http://127.0.0.1:8769/healthz.

What it does

  • Connects to the TUI remote bridge over WebSocket JSON-RPC.
  • In registry mode, fans in one or many live TUI process bridges through a loopback-only sidecar.
  • Lists live TUI sessions with session.active_list.
  • Attaches with session.activate and streams live events back.
  • Sends prompts with prompt.submit.
  • Handles active-control prompts:
    • approval.respond (deny, once, session, always)
    • clarify.respond
    • sudo.respond
    • secret.respond
  • Can create a fresh gateway session with session.create if no live session exists.

Connection resilience (on the go)

The client is built to ride out mobile network churn — tunnels, screen locks, Wi-Fi↔cellular handoffs — without manual reconnects:

  • Auto-reconnect with exponential backoff + full jitter after any unexpected drop. The transcript is preserved (state goes to a distinct reconnecting indicator, not a blank offline screen), and the previously attached session is re-bound automatically.
  • Half-open detection. Mobile network handoffs often leave a socket that still reads as OPEN but is dead. An idle heartbeat (a session.active_list round-trip — the bridge has no dedicated ping) detects the zombie and forces a reconnect.
  • Lifecycle-aware. online, visibilitychange, and pageshow (iOS bfcache) all trigger an immediate liveness probe / reconnect on wake.
  • Seamless re-attach. Reconnect calls session.activate, which the bridge hydrates from a bounded (500-message) display journal — so user prompts, tool rows, completed assistant messages, the open blocking prompt, and any in-flight turn come back. (Reasoning traces are live-only and not journaled, so they don't reappear.)
  • Session ended. If the attached session is gone on reconnect (TUI quit / Hermes restart), the client shows a clear "session ended" state instead of silently submitting into a dead session.
  • Send while offline is blocked with an in-place hint and the draft is preserved (sends are not queued); a send that fails in flight clears the optimistic bubble and restores the draft for retry.
  • Auth-rejection close codes (4401 token, 4403 host/origin) stop the loop and reopen settings rather than retrying forever.

Notifications

The in-app buzz (navigator.vibrate) and status changes are foreground-only — they fire only while the page is open.

For background alerts (phone locked / app closed) there's an opt-in Web Push path via the notifier sidecar — see Sidecars below.

Preferred workflow: manual TUI + foreground wrapper

Use two terminals. Keep the Hermes TUI lifecycle manual, and let the wrapper run the mobile app plus notifier for as long as that terminal is open:

# terminal 1 — Hermes owns the active session and bridge
hermes --tui --remote-control

# terminal 2 — mobile web app + push notifier, both loopback by default
npm install
npm run dev:with-notify

Default local surfaces:

Hermes bridge: http://127.0.0.1:8769
Registry:      http://127.0.0.1:8775  (optional, many-process mode)
Mobile app:    http://127.0.0.1:5174
Notifier API:  http://127.0.0.1:8770

For phone access, prefer exposing only the mobile app origin through a trusted HTTPS front door, e.g. tailscale serve 5174 or another reverse proxy pointed at 127.0.0.1:5174. The bridge and notifier should stay on loopback and be reached only through the app's same-origin proxy paths.

The systemd units in deploy/ are optional persistent templates. They are not the default day-to-day workflow.

Sidecars

Hermes Remote can run optional host-side sidecars for capabilities that should stay outside the browser app. They all bind to loopback by default; expose only the app origin through your trusted HTTPS front door.

Notifier sidecar

The notifier sidecar (sidecars/notifier/index.mjs) watches the bridge for blocking prompts (approval / clarify / sudo / secret) and sends Web Push to the PWA. No Hermes fork changes — it attaches as an ordinary bridge client and mirrors session events. The push payload is end-to-end encrypted between the host and the browser (the relay can't read it); the body is kept generic/lock-screen-safe and the detail is fetched in-app on attach.

Requirements:

  • Android Chrome (this path targets Android; iOS needs an installed PWA and is out of scope here).
  • HTTPS / secure context so the service worker can register — over plain-HTTP LAN it stays disabled. Behind Tailscale, tailscale serve gives you https://<machine>.<tailnet>.ts.net for the app, /notify, and wss:// paths while the local services remain bound to loopback.

Run the notifier by itself:

npm run notify

Or run the mobile app and notifier together in one terminal:

npm run dev:with-notify

It generates a VAPID keypair on first run (persisted to .notifier-state.json, gitignored) and serves the subscription API on 127.0.0.1:8770 by default. Useful env: BRIDGE_WS_URL, BRIDGE_TOKEN, NOTIFIER_HOST, NOTIFIER_PORT, NOTIFIER_TOKEN (gates /subscribe), NOTIFY_ON (default approval,clarify,sudo,secret).

The app talks to it same-origin at /notify (Vite proxies /notifyhttp://127.0.0.1:8770; override with HERMES_NOTIFY_TARGET). Then open the app over HTTPS and tap enable notifications in the settings panel.

Dry-run smoke (no real push, no live Hermes):

npm run smoke:notify

Registry sidecar

For workflows with several separate hermes --tui --remote-control processes, run each TUI bridge on a separate loopback port and put the registry sidecar in front of them. The phone still talks to one app endpoint; the registry tracks process bridges as they go up/down and exposes a single virtual bridge.

# terminal 1+
HERMES_TUI_REMOTE_BRIDGE_PORT=8769 hermes --tui --remote-control
HERMES_TUI_REMOTE_BRIDGE_PORT=8771 hermes --tui --remote-control

# app terminal — starts the registry and points /api/tui/ws at it
npm run dev:with-registry

By default the registry listens on 127.0.0.1:8775 and scans 127.0.0.1:8769-8799 for Hermes TUI bridges. It also supports advert files in ~/.config/hermes-remote-control/processes/*.json and static bridge entries via HERMES_REGISTRY_BRIDGES='main=http://127.0.0.1:8769,site=http://127.0.0.1:8771'.

See docs/registry.md for the protocol, discovery contract, and safety notes.

Registry + notifier

For the full foreground development stack — registry, notifier, and app — use the combined wrapper. It points the app at the registry and makes the notifier watch the registry instead of a direct bridge:

npm run dev:with-registry-and-notify

Start the Hermes side

From a Hermes checkout that includes the remote bridge PR:

hermes --tui --remote-control

Default bridge endpoint:

ws://127.0.0.1:8769/api/tui/ws

Preferred security boundary: do not expose the bridge directly. Keep it on 127.0.0.1:8769, expose only the mobile app origin, and let Vite proxy /api/tui/ws to the bridge.

If you intentionally expose the bridge on a non-loopback interface for a trusted development network, set a strong token and treat it as an active-control API:

HERMES_TUI_REMOTE_BRIDGE_HOST=0.0.0.0 \
HERMES_TUI_REMOTE_BRIDGE_TOKEN='<strong-random-token>' \
hermes --tui --remote-control

Run this app

npm install
npm run dev

npm run dev, npm run preview, and the sidecar dev wrappers all default the app to 127.0.0.1:5174. Override with HERMES_REMOTE_CONTROL_HOST / HERMES_REMOTE_CONTROL_PORT only when you deliberately want another bind address.

For app + notifier or app + registry launchers, see Sidecars. Those wrappers keep every service on loopback by default, which works well with tailscale serve 5174.

Open locally:

http://127.0.0.1:5174/

Open from phone through the trusted HTTPS front door you configured, for example a Tailscale Serve URL:

https://<machine>.<tailnet>.ts.net/

For a direct trusted-LAN experiment without a reverse proxy, bind the app explicitly. If anyone else can reach that LAN address, also start Hermes with a bridge token and enter it in the app settings:

HERMES_REMOTE_CONTROL_HOST=0.0.0.0 npm run dev

The default app bridge URL is same-origin:

/api/tui/ws

Vite proxies that path to http://127.0.0.1:8769, including WebSocket upgrades, so the Hermes bridge can stay bound to workstation loopback while the phone talks to the app server.

If Hermes bridge runs somewhere else:

HERMES_BRIDGE_TARGET=http://127.0.0.1:8769 npm run dev

Or enter a direct WebSocket URL in the app:

ws://host:8769/api/tui/ws

Security note

This is an active-control surface. Anyone who can reach the app and bridge path can submit prompts and approvals. The conservative boundary is: TUI bridges on 127.0.0.1:8769-8799, registry on 127.0.0.1:8775 when used, notifier on 127.0.0.1:8770, app on 127.0.0.1:5174, and only the app origin exposed through a trusted HTTPS front door. Use bridge/notifier tokens for anything beyond local loopback experiments, and do not bind services to 0.0.0.0 on an untrusted network.

Checks

npm run build
npm run smoke
npm test

Optional WebSocket/proxy smoke with the included fake bridge:

FAKE_BRIDGE_PORT=9880 python scripts/fake-bridge.py
HERMES_BRIDGE_TARGET=http://127.0.0.1:9880 npm run dev
npm run smoke:ws

Reconnect e2e (run on demand)

A browser-driven regression check for the on-the-go reconnect path. It is not part of npm test — run it occasionally:

npm run test:e2e

It builds the app, starts a controllable fake bridge (scripts/reconnect-bridge.py, with /control/drop + /control/end-session endpoints), drives a headless Chromium with playwright-core, and asserts: connect → submit → forced socket drop → auto-reconnect → session re-attaches and the transcript rebuilds without duplication → and the "session ended" state when the session is gone on reconnect. Requires Python 3 and a Chromium at /usr/bin/chromium (override with CHROMIUM_PATH). playwright-core is a devDependency and downloads no browser of its own.

If another local service already owns 8769, run Hermes bridge on another port and point the app proxy at it:

HERMES_TUI_REMOTE_BRIDGE_PORT=8789 hermes --tui --remote-control
HERMES_BRIDGE_TARGET=http://127.0.0.1:8789 npm run dev

App health file:

/healthz.json

Bridge health through Vite proxy:

/bridge-healthz

About

Mobile-friendly remote control client for Hermes TUI sessions

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors