Skip to content

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve) - #54568

Merged
OutThisLife merged 10 commits into
mainfrom
bb/shared-websocket-layer
Jun 29, 2026
Merged

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)#54568
OutThisLife merged 10 commits into
mainfrom
bb/shared-websocket-layer

Conversation

@OutThisLife

@OutThisLife OutThisLife commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

Why

Desktop and the web dashboard each carried their own copy of the WebSocket/JSON-RPC gateway client, and the desktop app booted its backend as hermes dashboard --no-open. That coupling made the dashboard look like a desktop prerequisite (the hermes dashboard --tui-era confusion) and meant two diverging client implementations.

This PR does two things:

  1. One shared transport. Extracts the gateway client + WS-URL logic into a framework-agnostic @hermes/shared package consumed by both apps/desktop and web. No more duplicated client.
  2. Decouples desktop from dashboard. Adds a dedicated headless hermes serve backend command (same gateway, no browser) and points the desktop app exclusively at it. dashboard (browser UI) and serve (headless backend) are now independent surfaces that share one server — neither launches the other.

What changed

Shared layer (apps/shared@hermes/shared)

  • JsonRpcGatewayClient (connection state, request/event dispatch, timeouts, deterministic close() that rejects pending RPCs) and the WS-URL/reauth helpers now live here.
  • apps/desktop consumes it directly (HermesGateway extends JsonRpcGatewayClient); a thin re-export shim at apps/desktop/src/lib/gateway-ws-url.ts keeps existing desktop imports/tests working.
  • web depends on it via a file: workspace dep; web/src/lib/gatewayClient.ts drops ~240 lines of bespoke client and extends the shared one. Dashboard-specific WS auth/URL stays behind the api.buildWsUrl() facade so components never touch the low-level primitive.

hermes serve (headless backend)

  • New top-level command sharing cmd_dashboard / start_server with dashboard; shared runtime flags factored into one helper. serve is always headless; accepts the legacy --no-open as a no-op.
  • Wired into _BUILTIN_SUBCOMMANDS, the multi-word-arg coalesce set, gui-log-mode detection, and the stale-backend reaper (so hermes update still reaps/protects desktop backends).

Desktop

  • Spawns hermes serve (was hermes dashboard --no-open); dashboardArgsbackendArgs.
  • Backward-compat routing (critical for existing installs): serve is newer than the desktop release cadence, so a new app against an un-upgraded runtime would crash on an unknown subcommand. backendSupportsServe() detects support (fast read of the runtime's dashboard.py, one-time CLI probe fallback) and rewrites the argv to the legacy dashboard --no-open only when serve is absent. Happy path pays nothing.

Docker / docs

  • apps/shared is a web file: dep, so it's added to the Docker build context (.dockerignore un-ignore + two COPY apps/shared/). This was the original CI failure.
  • Desktop README, desktop.md (incl. remote-backend), AGENTS.md, and cli-commands.md describe serve as the desktop/headless backend; dashboard stays the browser UI.

Tests

  • tests/hermes_cli/test_serve_command.py (new) — serve routes to the shared handler, is headless by default while dashboard is not, tolerates --no-open, shares the runtime/lifecycle flag surface.
  • tests/hermes_cli/test_startup_plugin_gating.py_BUILTIN_SUBCOMMANDS ↔ argparse parity holds with serve added (37 pass).
  • tests/hermes_cli/test_update_stale_dashboard.py, test_dashboard_lifecycle_flags.py, test_dashboard_unified_launch.py, test_default_interface_resolution.py, test_subcommands_batch.py — all green.
  • apps/desktop/electron/backend-command.test.cjs (new) — pure serve/dashboard argv routing + serve vs "server" detection (no false positives).
  • apps/desktop/electron/windows-child-process.test.cjs — updated for backendArgs.
  • Validated locally: web build, desktop typecheck, desktop electron suite (one unrelated pre-existing bootstrap-runner failure), CLI test subset.

Risk / compatibility

  • Old app + new CLI: still works (existing dashboard --tui shim).
  • New app + old runtime: routes to dashboard --no-open via capability detection — no break.
  • Prompt-caching / message-alternation untouched; no core toolset/schema changes.

The Electron desktop app and the web dashboard each carried their own
copy of the tui_gateway JSON-RPC WebSocket client plus near-identical
auth'd WS-URL construction. The dashboard's copy was the historical
source of the "is the dashboard required to run the desktop app?"
confusion, since the two surfaces looked coupled.

Consolidate the genuinely shared transport into the existing
framework-agnostic `@hermes/shared` package so both surfaces consume it
independently — neither app depends on the other:

- Move `resolveGatewayWsUrl` + `GatewayReauthRequiredError` (single-use
  OAuth ticket re-mint vs long-lived token fallback) into
  `@hermes/shared`; desktop now imports them directly.
- Add `buildHermesWebSocketUrl`, one base-path/scheme/auth-aware URL
  builder, and route every dashboard WS endpoint through it
  (`/api/ws`, `/api/events`, `/api/pty`, plugin WS URLs).
- Reduce the dashboard `GatewayClient` to a thin subclass of the shared
  `JsonRpcGatewayClient`, deleting ~210 lines of duplicated pending-call
  /event-dispatch/connect plumbing while keeping its dashboard-specific
  ticket-vs-token auth selection.
- Drop the stale "start it with --tui" chat banner, which implied the
  dashboard flag was required.

Behavior is preserved on both surfaces; the dashboard additionally
inherits the shared client's 15s connect timeout (previously
desktop-only), so a hung connect now fails fast instead of pinning the
composer in "connecting".
@OutThisLife
OutThisLife requested a review from a team June 29, 2026 02:20
Ensure intentional client closes mark the transport closed and reject pending
RPCs immediately instead of relying on a browser close event that can be
ignored after the socket reference is cleared.
Keep dashboard pages and components on the dashboard API helper instead of
calling the raw shared URL primitive directly. The shared helper remains the
single low-level implementation; web/src/lib/api.ts is the dashboard-specific
facade for auth, base path, and ticket minting.
@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change comp/desktop Electron desktop app (apps/desktop/*) comp/dashboard Web dashboard / control panel UI (dashboard/, landing) P3 Low — cosmetic, nice to have labels Jun 29, 2026
Run the package-appropriate Prettier config on the shared WebSocket files so
the extracted helpers match the surrounding desktop/shared TypeScript style.
The shared websocket package is a web file: dependency but was excluded
by .dockerignore and never copied into the Docker build context. Also fix
tsc -b errors: expose buildWsUrl on api and drop the GatewayClient state
getter that conflicted with the shared base class.
@github-actions

github-actions Bot commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

🔎 Lint report: bb/shared-websocket-layer vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 11697 on HEAD, 11697 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 6142 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

The desktop app spawns a headless `hermes dashboard --no-open` backend and
talks to it through the shared @hermes/shared WebSocket client — it never
runs or requires the browser dashboard UI. Spell this out in the desktop
README, the desktop docs page, and AGENTS.md so "dashboard" stops reading
as a desktop prerequisite.
…nches `dashboard`

The desktop app spawned `hermes dashboard --no-open` as its backend, which
made the dashboard look like a desktop prerequisite. Add a dedicated headless
`hermes serve` command that boots the same gateway (shared cmd_dashboard /
start_server) but never opens a browser, and point the desktop backend spawn
exclusively at it. dashboard and serve are now independent surfaces — neither
launches the other.

- subcommands/dashboard.py: factor shared server args; add `serve` parser
  (always headless; accepts legacy --no-open as a no-op)
- main.py: register serve in _BUILTIN_SUBCOMMANDS + coalesce set + gui-log
  detection; extend stale-backend reaper patterns to match `serve`
- desktop electron: spawn `serve`, rename dashboardArgs -> backendArgs,
  update comments + windows-child-process test assertions
- docs: desktop README, desktop.md (incl. remote-backend), AGENTS.md, and
  cli-commands.md now describe `hermes serve` as the desktop/headless backend
…absent

`hermes serve` is newer than the desktop binary's release cadence, so a new
app launched against an un-upgraded managed install / PATH `hermes` would
crash on an unknown subcommand and brick the user mid-upgrade. Detect whether
the resolved runtime registers `serve` (fast source read of its dashboard.py,
with a one-time CLI probe fallback) and rewrite the backend argv to the legacy
`dashboard --no-open` only when it does not. Happy path (current runtimes)
pays nothing and still spawns `serve`.

- electron/backend-command.cjs: pure serve/dashboard argv helpers + serve-
  source detection (unit-tested in backend-command.test.cjs)
- main.cjs: backendSupportsServe() cache + getBackendArgsForRuntime() guard at
  both backend spawn sites; expose `root` from the Windows venv unwrap so the
  fast source check covers Windows too
- docs: note the backward-compat fallback in README, desktop.md, AGENTS.md
Add a focused contract test for the headless `serve` command (routes to the
shared dashboard handler, headless by default while `dashboard` is not, accepts
the legacy --no-open, shares the same runtime/lifecycle flag surface). Also
refresh the dashboard.py module docstring to cover both commands.
@OutThisLife OutThisLife changed the title refactor(desktop+dashboard): share one WebSocket/JSON-RPC layer; decouple desktop from dashboard refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve) Jun 29, 2026
Comment thread apps/desktop/electron/main.cjs
Comment thread apps/desktop/README.md
Clears the ty diff bot's warnings on the new test: pass real callables to
build_dashboard_parser (not object()) and replace the pytest.mark.parametrize
with a plain loop so the file is stdlib-only.
@OutThisLife
OutThisLife merged commit 388268e into main Jun 29, 2026
36 checks passed
@OutThisLife
OutThisLife deleted the bb/shared-websocket-layer branch June 29, 2026 04:43
OutThisLife added a commit that referenced this pull request Jun 30, 2026
`serve` (added in #54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
waefrebeorn pushed a commit to waefrebeorn/slermes that referenced this pull request Jul 2, 2026
…socket-layer

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
habarmc1223-sudo pushed a commit to habarmc1223-sudo/hermes-agent-fluxmem that referenced this pull request Jul 8, 2026
…socket-layer

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
…socket-layer

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
santhreal pushed a commit to santhreal/hermes-agent that referenced this pull request Jul 13, 2026
`serve` (added in NousResearch#54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
justemu pushed a commit to justemu/hermes-agent that referenced this pull request Jul 18, 2026
`serve` (added in NousResearch#54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
…socket-layer

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
Gravezzz pushed a commit to Gravezzz/hermes-agent that referenced this pull request Jul 21, 2026
`serve` (added in NousResearch#54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
…socket-layer

refactor(desktop+dashboard): shared WebSocket layer + decouple desktop from dashboard (hermes serve)
leewenjie pushed a commit to leewenjie/hermes-agent that referenced this pull request Aug 7, 2026
`serve` (added in NousResearch#54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
`serve` (added in NousResearch#54568) reused cmd_dashboard wholesale, so it still
behaved like a dashboard: it ran a full vite build every launch, mounted
and served the SPA whenever a stray web_dist/ existed, printed
"Hermes Web UI →", and announced HERMES_DASHBOARD_READY. It's the headless
JSON-RPC/WS backend the desktop app and remote clients run — pure socket
clients that never load the browser SPA.

Mark serve with headless_backend=True (resolved once in cmd_dashboard) and:

- skip _build_web_ui entirely on the serve path
- export HERMES_SERVE_HEADLESS=1 so mount_spa() disables the SPA even when a
  dist is present — only the JSON-RPC/WS/API surface is reachable
- announce the bind ("Hermes backend listening on host:port") instead of a
  browser/auth-gated URL
- print a neutral HERMES_BACKEND_READY sentinel; dashboard keeps the legacy
  one and the desktop port-discovery regex matches either
- preserve serve across the named-profile re-exec so it can't rebuild as
  dashboard

`hermes dashboard` is unchanged (builds + serves the browser UI). Backward
compatible: old apps only ever spawn dashboard (legacy token + UI intact)
and never invoke serve; the ready-file side channel is name-agnostic. The
one behavior change is that a remote `hermes serve` no longer serves the
browser dashboard as a side effect — that's `hermes dashboard`'s job.

Tests: serve headless_backend contract, SPA-disabled-with-dist, the
HERMES_BACKEND_READY desktop parse (17/17 node), and the existing
serve/dashboard/web_server suites. AGENTS.md documents the behavior.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/dashboard Web dashboard / control panel UI (dashboard/, landing) comp/desktop Electron desktop app (apps/desktop/*) P3 Low — cosmetic, nice to have type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants