From f0f8c84d1b7cbc739b4e05904d004f3ea3322945 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 30 Jun 2026 17:58:47 -0500 Subject: [PATCH] feat(cli): make hermes serve a real headless backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- AGENTS.md | 2 +- apps/desktop/electron/backend-ready.cjs | 9 +++-- apps/desktop/electron/backend-ready.test.cjs | 7 ++++ hermes_cli/main.py | 16 +++++++-- hermes_cli/subcommands/dashboard.py | 11 ++++-- hermes_cli/web_server.py | 36 ++++++++++++++++---- tests/hermes_cli/test_serve_command.py | 7 ++++ tests/hermes_cli/test_web_server.py | 21 ++++++++++++ 8 files changed, 93 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e89c819844e..e1dbaaa5c43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -491,7 +491,7 @@ The dashboard embeds the real `hermes --tui` — **not** a rewrite. See `hermes ### Electron Desktop Chat App (`apps/desktop/`) -A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. +A **separate** chat surface from both the classic CLI and the dashboard's embedded TUI. It is an Electron + React + nanostore renderer (`@assistant-ui/react`) that talks to a `tui_gateway` backend over JSON-RPC (`requestGateway(method, params)`). The WebSocket/JSON-RPC transport lives in the framework-agnostic `apps/shared` package (`@hermes/shared` — `JsonRpcGatewayClient` + WS URL helpers), which the web dashboard (`web/`) also consumes; **desktop has no build/runtime dependency on the dashboard frontend** — it spawns a headless `hermes serve` backend server (the same gateway `dashboard` serves, minus the browser UI entirely: `serve` sets `headless_backend=True`, so `cmd_dashboard` skips `_build_web_ui` AND exports `HERMES_SERVE_HEADLESS=1` so `mount_spa()` disables the SPA even if a stray `web_dist/` exists — only the JSON-RPC/WS/API surface is reachable). `dashboard` and `serve` share `cmd_dashboard`/`start_server` but are independent surfaces — neither launches the other. The one exception is a backward-compat *fallback*: `serve` is newer, so the desktop spawn (`electron/backend-command.cjs` + `backendSupportsServe()` in `main.cjs`) detects whether the resolved runtime registers `serve` and, only when it does not (an older managed install / PATH `hermes` the app hasn't updated yet), rewrites the argv to the legacy `dashboard --no-open`. Without that, a new app against an un-upgraded runtime would crash on an unknown subcommand and brick every mid-upgrade user. It does NOT embed `hermes --tui` — it has its own composer, transcript, and slash-command pipeline. Route desktop bugs to the `hermes-desktop-app-work` skill, not `hermes-dashboard-work`. **Slash commands in the desktop app are curated client-side, then dispatched to the backend.** The pipeline: diff --git a/apps/desktop/electron/backend-ready.cjs b/apps/desktop/electron/backend-ready.cjs index 016572bec91..ef2ee27ef9a 100644 --- a/apps/desktop/electron/backend-ready.cjs +++ b/apps/desktop/electron/backend-ready.cjs @@ -1,6 +1,9 @@ const fs = require('node:fs') -const _READY_RE = /^HERMES_DASHBOARD_READY port=(\d+)/m +// `hermes serve` announces HERMES_BACKEND_READY; the legacy `hermes dashboard` +// backend announces HERMES_DASHBOARD_READY. Accept either so the desktop spawn +// works against both the headless backend and old/dashboard runtimes. +const _READY_RE = /^HERMES_(?:BACKEND|DASHBOARD)_READY port=(\d+)/m // The announcement clock starts the instant the backend process is spawned — // before uvicorn binds its socket. On a cold install the child must first @@ -30,8 +33,8 @@ function resolvePortAnnounceTimeoutMs(env = process.env) { } /** - * Watch a child process's stdout for the `HERMES_DASHBOARD_READY port=` - * line that web_server.py prints after uvicorn binds its socket. + * Watch a child process's stdout for the `HERMES_(BACKEND|DASHBOARD)_READY + * port=` line that web_server.py prints after uvicorn binds its socket. * * Returns the parsed port. Rejects if: * - the child exits before emitting the line diff --git a/apps/desktop/electron/backend-ready.test.cjs b/apps/desktop/electron/backend-ready.test.cjs index 2792baf371a..d89b7f1a2d8 100644 --- a/apps/desktop/electron/backend-ready.test.cjs +++ b/apps/desktop/electron/backend-ready.test.cjs @@ -86,6 +86,13 @@ test('resolves with the announced port', async () => { assert.equal(await p, 54321) }) +test('resolves with a HERMES_BACKEND_READY port (headless `serve`)', async () => { + const child = makeFakeChild() + const p = waitForDashboardPort(child, 1000) + child.stdout.emit('data', 'HERMES_BACKEND_READY port=43210\n') + assert.equal(await p, 43210) +}) + test('parses the port even when the line arrives split across chunks', async () => { const child = makeFakeChild() const p = waitForDashboardPort(child, 1000) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b71c59f3835..300e5bc9a34 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -11648,6 +11648,11 @@ def cmd_dashboard(args): remaining = _find_stale_dashboard_pids() sys.exit(1 if remaining else 0) + # `serve` is the headless backend: no UI build, no SPA mount, neutral + # ready sentinel. Resolved once and threaded through the re-exec, the + # build gate, and start_server. + _headless_backend = getattr(args, "headless_backend", False) + # ── Unified profile launch routing ──────────────────────────────── # The dashboard is a MACHINE management surface: it can read/write any # profile via the per-request ?profile= scoping. Running one dashboard @@ -11693,7 +11698,9 @@ def cmd_dashboard(args): reexec_argv = [ sys.executable, "-m", "hermes_cli.main", "-p", "default", - "dashboard", + # Preserve the lean serve path across the re-exec so a named-profile + # `serve` doesn't silently rebuild the UI as `dashboard`. + "serve" if _headless_backend else "dashboard", "--port", str(args.port), "--host", args.host, "--open-profile", _launch_profile, @@ -11762,7 +11769,11 @@ def cmd_dashboard(args): # backend is the desktop's primary entrypoint and needs the same. _sync_bundled_skills_quietly() - if "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): + if _headless_backend: + # Don't build the SPA, and tell mount_spa() (read at web_server import + # below) to disable it even if a stray dist exists. Set it first. + os.environ["HERMES_SERVE_HEADLESS"] = "1" + elif "HERMES_WEB_DIST" not in os.environ and not getattr(args, "skip_build", False): if not _build_web_ui(PROJECT_ROOT / "web", fatal=True): sys.exit(1) elif getattr(args, "skip_build", False): @@ -11835,6 +11846,7 @@ def cmd_dashboard(args): open_browser=not args.no_open, allow_public=getattr(args, "insecure", False), initial_profile=getattr(args, "open_profile", "") or "", + headless=_headless_backend, ) diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index bea3c2244de..a345a9d9d59 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -1,8 +1,9 @@ """``hermes dashboard`` / ``hermes serve`` subcommand parsers. ``dashboard`` is the browser web UI; ``serve`` is the same gateway, headless — -what the desktop app and remote backends run. Both share one handler -(``cmd_dashboard`` → ``start_server``). Extracted from +what the desktop app and remote backends run. ``serve`` also skips the web UI +build (``headless_backend=True``): pure JSON-RPC/WS clients never load the SPA. +Both share one handler (``cmd_dashboard`` → ``start_server``). Extracted from ``hermes_cli/main.py:main()`` (god-file Phase 2); handler injected to avoid importing ``main``. """ @@ -148,7 +149,11 @@ def build_dashboard_parser( serve_parser.add_argument( "--no-open", action="store_true", help=argparse.SUPPRESS ) - serve_parser.set_defaults(func=cmd_dashboard, no_open=True) + # `headless_backend` marks the lean path: desktop/remote clients speak pure + # JSON-RPC/WS, so `serve` skips the web UI build AND never serves the SPA + # (cmd_dashboard exports HERMES_SERVE_HEADLESS=1). `dashboard` leaves it + # unset and serves the browser UI as before. + serve_parser.set_defaults(func=cmd_dashboard, no_open=True, headless_backend=True) # `hermes dashboard register` — register a self-hosted dashboard OAuth # client with Nous Portal and write the client_id into ~/.hermes/.env. diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index e01f925b332..805455cae93 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -12837,13 +12837,21 @@ def mount_spa(application: FastAPI): and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix without rebuilding the bundle. """ - if not WEB_DIST.exists(): + # `hermes serve` is the headless backend: it must NEVER serve the browser + # SPA, even if a dist is lying around from a prior `dashboard`/build. Take + # the no-frontend path so only the JSON-RPC/WS/API surface is reachable. + _headless = os.environ.get("HERMES_SERVE_HEADLESS") == "1" + if _headless or not WEB_DIST.exists(): + _msg = ( + "Headless backend (hermes serve): web UI disabled — use " + "`hermes dashboard` for the browser UI." + if _headless + else "Frontend not built. Run: cd web && npm run build" + ) + @application.get("/{full_path:path}") async def no_frontend(full_path: str): - return JSONResponse( - {"error": "Frontend not built. Run: cd web && npm run build"}, - status_code=404, - ) + return JSONResponse({"error": _msg}, status_code=404) return _index_path = WEB_DIST / "index.html" @@ -13998,6 +14006,7 @@ def start_server( open_browser: bool = True, allow_public: bool = False, initial_profile: str = "", + headless: bool = False, ): """Start the web UI server. @@ -14005,6 +14014,10 @@ def start_server( URL as ``?profile=`` so the SPA's profile switcher preselects it — used when a profile alias (`` dashboard``) routes to the machine dashboard. + + ``headless`` is the ``serve`` path: the JSON-RPC/WS backend with no UI + build and no SPA mount (mount_spa() honours ``HERMES_SERVE_HEADLESS``), so + the banner announces the bind rather than a browser URL. """ import uvicorn @@ -14150,8 +14163,17 @@ def start_server( app.state.bound_port = actual_port _write_dashboard_ready_file(actual_port) - print(f"HERMES_DASHBOARD_READY port={actual_port}", flush=True) - print(f" Hermes Web UI → http://{host}:{actual_port}") + # Port-discovery sentinel parsed by the desktop spawn. `serve` is a + # plain backend, not a dashboard, so it announces a neutral token; + # `dashboard` keeps the legacy one. The desktop matches either. + ready_token = "HERMES_BACKEND_READY" if headless else "HERMES_DASHBOARD_READY" + print(f"{ready_token} port={actual_port}", flush=True) + if headless: + # No SPA, and the JSON-RPC/WS endpoints are auth-gated — don't + # advertise a paste-and-connect URL, just announce the bind. + print(f" Hermes backend listening on {host}:{actual_port}") + else: + print(f" Hermes Web UI → http://{host}:{actual_port}") _maybe_open_browser(host, actual_port, open_browser, initial_profile) # Collapse the peer-hangup teardown flood (#50005). When the Desktop diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py index 19db4b82380..911b0db9583 100644 --- a/tests/hermes_cli/test_serve_command.py +++ b/tests/hermes_cli/test_serve_command.py @@ -61,3 +61,10 @@ def test_serve_takes_the_same_runtime_flags_as_dashboard(): def test_serve_supports_the_lifecycle_flags(): for flag in ("--stop", "--status"): assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True + + +def test_serve_is_a_headless_backend_but_dashboard_is_not(): + # `headless_backend` is the flag cmd_dashboard reads to skip the web UI + # build; only `serve` carries it. + assert getattr(_parser().parse_args(["serve"]), "headless_backend", False) is True + assert getattr(_parser().parse_args(["dashboard"]), "headless_backend", False) is False diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 1b2caf95291..3087b9e9b4b 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -2509,6 +2509,27 @@ class TestWebServerEndpoints: assert seen_encodings == {"index": "utf-8", "css": "utf-8"} + def test_headless_serve_disables_spa_even_with_a_dist(self, monkeypatch, tmp_path): + """`hermes serve` (HERMES_SERVE_HEADLESS) must NOT serve the SPA even + when a built dist is present — only the API/WS surface is reachable.""" + from fastapi import FastAPI + from starlette.testclient import TestClient + import hermes_cli.web_server as ws + + dist = tmp_path / "web_dist" + (dist / "assets").mkdir(parents=True) + (dist / "index.html").write_text("UI", encoding="utf-8") + + monkeypatch.setattr(ws, "WEB_DIST", dist) + monkeypatch.setenv("HERMES_SERVE_HEADLESS", "1") + app_ = FastAPI() + ws.mount_spa(app_) + + for route in ("/", "/chat"): + resp = TestClient(app_).get(route) + assert resp.status_code == 404 + assert "web UI disabled" in resp.json()["error"] + def test_set_model_main_nous_applies_gateway_defaults(self, monkeypatch): """Switching the main provider to Nous calls apply_nous_managed_defaults (mirroring the CLI's post-model-selection Tool Gateway routing) and