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.
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.
- 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.activateand streams live events back. - Sends prompts with
prompt.submit. - Handles active-control prompts:
approval.respond(deny,once,session,always)clarify.respondsudo.respondsecret.respond
- Can create a fresh gateway session with
session.createif no live session exists.
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
reconnectingindicator, not a blankofflinescreen), and the previously attached session is re-bound automatically. - Half-open detection. Mobile network handoffs often leave a socket that still reads as
OPENbut is dead. An idle heartbeat (asession.active_listround-trip — the bridge has no dedicated ping) detects the zombie and forces a reconnect. - Lifecycle-aware.
online,visibilitychange, andpageshow(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 (
4401token,4403host/origin) stop the loop and reopen settings rather than retrying forever.
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.
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-notifyDefault 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.
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.
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 servegives youhttps://<machine>.<tailnet>.ts.netfor the app,/notify, andwss://paths while the local services remain bound to loopback.
Run the notifier by itself:
npm run notifyOr run the mobile app and notifier together in one terminal:
npm run dev:with-notifyIt 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 /notify → http://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:notifyFor 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-registryBy 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.
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-notifyFrom a Hermes checkout that includes the remote bridge PR:
hermes --tui --remote-controlDefault 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-controlnpm install
npm run devnpm 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 devThe 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 devOr enter a direct WebSocket URL in the app:
ws://host:8769/api/tui/ws
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.
npm run build
npm run smoke
npm testOptional 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:wsA browser-driven regression check for the on-the-go reconnect path. It is not part of npm test — run it occasionally:
npm run test:e2eIt 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 devApp health file:
/healthz.json
Bridge health through Vite proxy:
/bridge-healthz