From dfb561a3aebf459da9f05718da04784af8e661f3 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:20:35 -0500 Subject: [PATCH 01/10] refactor(desktop+dashboard): extract shared WebSocket/JSON-RPC layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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". --- .../src/app/gateway/hooks/use-gateway-boot.ts | 2 +- .../app/gateway/hooks/use-gateway-request.ts | 2 +- apps/desktop/src/lib/gateway-ws-url.test.ts | 3 +- apps/desktop/src/lib/gateway-ws-url.ts | 91 ------- apps/desktop/src/store/gateway.ts | 3 +- apps/shared/src/index.ts | 11 + apps/shared/src/websocket-url.ts | 123 +++++++++ package-lock.json | 1 + web/package.json | 1 + web/src/components/ChatSidebar.tsx | 14 +- web/src/lib/api.ts | 13 +- web/src/lib/gatewayClient.ts | 256 +++--------------- web/src/pages/ChatPage.tsx | 23 +- web/tsconfig.app.json | 5 +- web/vite.config.ts | 1 + 15 files changed, 210 insertions(+), 339 deletions(-) delete mode 100644 apps/desktop/src/lib/gateway-ws-url.ts create mode 100644 apps/shared/src/websocket-url.ts diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 1db1c2aaa0d..26a4e2ce7c8 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -1,10 +1,10 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useEffect, useRef } from 'react' import type { HermesConnection } from '@/global' import { HermesGateway } from '@/hermes' import { translateNow } from '@/i18n' import { desktopDefaultCwd } from '@/lib/desktop-fs' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $desktopBoot, applyDesktopBootProgress, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index 29b6cbd80c8..d6c9ab0e029 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -1,8 +1,8 @@ +import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { useStore } from '@nanostores/react' import { useCallback, useEffect, useRef } from 'react' import type { HermesGateway } from '@/hermes' -import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway' import { $activeGatewayProfile } from '@/store/profile' import { $gatewayState, setConnection } from '@/store/session' diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts index 2884f08d29a..e8b09765923 100644 --- a/apps/desktop/src/lib/gateway-ws-url.test.ts +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -1,7 +1,6 @@ +import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared' import { describe, expect, it, vi } from 'vitest' -import { GatewayReauthRequiredError, isGatewayReauthRequired, resolveGatewayWsUrl } from './gateway-ws-url' - const oauthConn = { authMode: 'oauth' as const, wsUrl: 'ws://host/api/ws?ticket=stale' } const tokenConn = { authMode: 'token' as const, wsUrl: 'ws://host/api/ws?token=abc' } diff --git a/apps/desktop/src/lib/gateway-ws-url.ts b/apps/desktop/src/lib/gateway-ws-url.ts deleted file mode 100644 index db483be7137..00000000000 --- a/apps/desktop/src/lib/gateway-ws-url.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { HermesConnection } from '@/global' - -/** - * The desktop main process exposes `getGatewayWsUrl()` to re-mint a WebSocket - * URL immediately before every `gateway.connect()`. For OAuth-gated remote - * gateways the WS ticket is single-use with a ~30s TTL, so the ticket baked - * into the cached `conn.wsUrl` is stale (and, after the first connect, already - * consumed). For local/token gateways the URL carries a long-lived token and - * never needs re-minting. - * - * Resolution rules: - * - * - OAuth: the fresh mint is the *only* viable URL. If it fails, do NOT fall - * back to `conn.wsUrl` — that ticket is dead and the connect is guaranteed to - * fail with an opaque "connection closed" error. Instead, let the mint error - * propagate so the caller can surface the gateway's reauth message - * ("session has expired… Sign in again"). - * - * - token / local, or when the preload method is genuinely absent (older - * preload shapes): fall back to `conn.wsUrl`. The token URL is long-lived, so - * the fallback is safe and preserves compatibility. - * - * The error thrown for OAuth mint failures is tagged with `needsOauthLogin` so - * callers can distinguish "the user must re-authenticate" from a generic - * transport failure. - */ -export interface ResolveGatewayWsUrlDeps { - /** `window.hermesDesktop.getGatewayWsUrl`, if the preload exposes it. The - * optional profile selects which backend to mint for — critical when swapping - * to a pooled profile, since the default mint resolves the primary backend. */ - getGatewayWsUrl?: (profile?: null | string) => Promise -} - -export class GatewayReauthRequiredError extends Error { - readonly needsOauthLogin = true - - constructor(message: string, options?: { cause?: unknown }) { - super(message, options) - this.name = 'GatewayReauthRequiredError' - } -} - -export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError { - return ( - error instanceof GatewayReauthRequiredError || - (typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true) - ) -} - -export async function resolveGatewayWsUrl( - desktop: ResolveGatewayWsUrlDeps, - conn: Pick -): Promise { - const mint = desktop.getGatewayWsUrl - // Mint for THIS connection's profile, not the primary. Without it a pooled - // profile swap re-mints the default backend's URL and connects to the wrong - // backend. - const profile = conn.profile ?? null - - if (conn.authMode === 'oauth') { - if (!mint) { - // OAuth gateway but no way to mint a fresh ticket: the cached ticket is - // dead, so connecting with it cannot succeed. Surface a reauth error - // rather than silently attempting a doomed connect. - throw new GatewayReauthRequiredError( - 'Your remote gateway session needs to be refreshed. Open Settings → Gateway and click "Sign in" again.' - ) - } - - try { - return await mint(profile) - } catch (error) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', - { cause: error } - ) - } - } - - // token / local: the URL carries a long-lived token. Re-mint when available - // (cheap, keeps parity), but the cached URL is a safe fallback. - if (mint) { - const fresh = await mint(profile).catch(() => null) - - if (fresh) { - return fresh - } - } - - return conn.wsUrl -} diff --git a/apps/desktop/src/store/gateway.ts b/apps/desktop/src/store/gateway.ts index 8cc8efedd4a..ee51119dd78 100644 --- a/apps/desktop/src/store/gateway.ts +++ b/apps/desktop/src/store/gateway.ts @@ -1,8 +1,7 @@ -import type { ConnectionState, GatewayEvent } from '@hermes/shared' +import { type ConnectionState, type GatewayEvent, resolveGatewayWsUrl } from '@hermes/shared' import { atom } from 'nanostores' import { HermesGateway } from '@/hermes' -import { resolveGatewayWsUrl } from '@/lib/gateway-ws-url' import { setGatewayState } from '@/store/session' // ── Multi-profile gateway routing ────────────────────────────────────────── diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index 3a900ee488e..50f9936bfb3 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -8,3 +8,14 @@ export { type JsonRpcFrame, type WebSocketLike } from './json-rpc-gateway' +export { + GatewayReauthRequiredError, + buildHermesWebSocketUrl, + isGatewayReauthRequired, + resolveGatewayWsUrl, + type GatewayAuthMode, + type GatewayWsConnection, + type HermesWebSocketUrlOptions, + type ResolveGatewayWsUrlDeps, + type WebSocketAuthParam +} from './websocket-url' diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts new file mode 100644 index 00000000000..a4641399534 --- /dev/null +++ b/apps/shared/src/websocket-url.ts @@ -0,0 +1,123 @@ +export type GatewayAuthMode = 'oauth' | 'token' | (string & {}) + +export interface GatewayWsConnection { + authMode?: GatewayAuthMode | null + profile?: null | string + wsUrl: string +} + +export interface ResolveGatewayWsUrlDeps { + /** + * Returns a fresh WebSocket URL for the selected backend/profile. + * OAuth-gated gateways use single-use tickets, so callers should mint + * immediately before opening the socket. + */ + getGatewayWsUrl?: (profile?: null | string) => Promise +} + +export class GatewayReauthRequiredError extends Error { + readonly needsOauthLogin = true + + constructor(message: string, options?: { cause?: unknown }) { + super(message, options) + this.name = 'GatewayReauthRequiredError' + } +} + +export function isGatewayReauthRequired(error: unknown): error is GatewayReauthRequiredError { + return ( + error instanceof GatewayReauthRequiredError || + (typeof error === 'object' && error !== null && (error as { needsOauthLogin?: unknown }).needsOauthLogin === true) + ) +} + +export async function resolveGatewayWsUrl( + deps: ResolveGatewayWsUrlDeps, + conn: GatewayWsConnection +): Promise { + const mint = deps.getGatewayWsUrl + const profile = conn.profile ?? null + + if (conn.authMode === 'oauth') { + if (!mint) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.' + ) + } + + try { + return await mint(profile) + } catch (error) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: error } + ) + } + } + + if (mint) { + const fresh = await mint(profile).catch(() => null) + + if (fresh) { + return fresh + } + } + + return conn.wsUrl +} + +export type WebSocketAuthParam = readonly [name: string, value: string] + +export interface HermesWebSocketUrlOptions { + /** Dashboard or gateway-relative endpoint path, e.g. "/api/ws". */ + path: string + /** Optional URL prefix when the backend is reverse-proxied below a subpath. */ + basePath?: string + /** Query auth pair, usually ["token", value] or ["ticket", value]. */ + authParam?: WebSocketAuthParam + /** Extra query params merged before auth. */ + params?: Record + /** Browser protocol string such as "https:"; defaults to window.location.protocol. */ + protocol?: string + /** Host with optional port; defaults to window.location.host. */ + host?: string +} + +function readWindowLocation(): { host: string; protocol: string } { + if (typeof window === 'undefined') { + return { host: '', protocol: 'http:' } + } + + return { host: window.location.host, protocol: window.location.protocol } +} + +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath) { + return '' + } + + const withLead = basePath.startsWith('/') ? basePath : `/${basePath}` + return withLead.replace(/\/+$/, '') +} + +function normalizeEndpointPath(path: string): string { + return path.startsWith('/') ? path : `/${path}` +} + +export function buildHermesWebSocketUrl(options: HermesWebSocketUrlOptions): string { + const loc = readWindowLocation() + const protocol = options.protocol ?? loc.protocol + const host = options.host ?? loc.host + const wsScheme = protocol === 'https:' || protocol === 'wss:' ? 'wss:' : 'ws:' + const qs = new URLSearchParams(options.params ?? {}) + + if (options.authParam) { + const [name, value] = options.authParam + qs.set(name, value) + } + + const query = qs.toString() + const suffix = query ? `?${query}` : '' + + return `${wsScheme}//${host}${normalizeBasePath(options.basePath)}${normalizeEndpointPath(options.path)}${suffix}` +} diff --git a/package-lock.json b/package-lock.json index 2fe3537733e..ec901a54115 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19571,6 +19571,7 @@ "web": { "version": "0.0.0", "dependencies": { + "@hermes/shared": "file:../apps/shared", "@nous-research/ui": "0.18.2", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", diff --git a/web/package.json b/web/package.json index 6666773c737..0e9987b51b3 100644 --- a/web/package.json +++ b/web/package.json @@ -12,6 +12,7 @@ "test": "vitest run" }, "dependencies": { + "@hermes/shared": "file:../apps/shared", "@nous-research/ui": "0.18.2", "@observablehq/plot": "^0.6.17", "@react-three/fiber": "^9.6.0", diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index 03eadf59288..d924c6d2ee4 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -26,6 +26,7 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card } from "@nous-research/ui/ui/components/card"; +import { buildHermesWebSocketUrl } from "@hermes/shared"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; @@ -230,14 +231,17 @@ export function ChatSidebar({ let unmounting = false; let ws: WebSocket | null = null; void (async () => { - const [authName, authValue] = await buildWsAuthParam(); - if (!authValue || unmounting) { + const authParam = await buildWsAuthParam(); + if (!authParam[1] || unmounting) { return; } - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams({ [authName]: authValue, channel }); ws = new WebSocket( - `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/events?${qs.toString()}`, + buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + params: { channel }, + path: "/api/events", + }), ); // `unmounting` suppresses the banner during cleanup — `ws.close()` diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 983baffae0f..81643f3ac5c 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -1,3 +1,5 @@ +import { buildHermesWebSocketUrl } from "@hermes/shared"; + // The dashboard can be served either at the root of its host (e.g. // https://kanban.tilos.com/) or under a URL prefix when reverse-proxied // (e.g. https://mission-control.tilos.com/hermes/). The Python backend @@ -291,11 +293,12 @@ export async function buildWsUrl( path: string, params?: Record, ): Promise { - const [authName, authValue] = await buildWsAuthParam(); - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; - const qs = new URLSearchParams(params ?? {}); - qs.set(authName, authValue); - return `${proto}//${window.location.host}${BASE}${path}?${qs}`; + return buildHermesWebSocketUrl({ + authParam: await buildWsAuthParam(), + basePath: BASE, + params, + path, + }); } /** Build a ``?profile=`` query suffix, or "" when unset. diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 16b31ae68a0..325c8cd2bd1 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -13,241 +13,53 @@ * await gw.request("prompt.submit", { session_id, text: "hi" }) */ -import { HERMES_BASE_PATH, getWsTicket } from "@/lib/api"; +import { + JsonRpcGatewayClient, + buildHermesWebSocketUrl, + type ConnectionState, + type GatewayEvent, + type GatewayEventName, +} from "@hermes/shared"; -export type GatewayEventName = - | "gateway.ready" - | "session.info" - | "message.start" - | "message.delta" - | "message.complete" - | "thinking.delta" - | "reasoning.delta" - | "reasoning.available" - | "status.update" - | "tool.start" - | "tool.progress" - | "tool.complete" - | "tool.generating" - | "clarify.request" - | "approval.request" - | "sudo.request" - | "secret.request" - | "background.complete" - | "error" - | "skin.changed" - | (string & {}); +import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; -export interface GatewayEvent

{ - type: GatewayEventName; - session_id?: string; - payload?: P; -} +export type { ConnectionState, GatewayEvent, GatewayEventName }; -export type ConnectionState = - | "idle" - | "connecting" - | "open" - | "closed" - | "error"; - -interface Pending { - resolve: (v: unknown) => void; - reject: (e: Error) => void; - timer: ReturnType; -} - -const DEFAULT_REQUEST_TIMEOUT_MS = 120_000; - -/** Wildcard listener key: subscribe to every event regardless of type. */ -const ANY = "*"; - -export class GatewayClient { - private ws: WebSocket | null = null; - private reqId = 0; - private pending = new Map(); - private listeners = new Map void>>(); - private _state: ConnectionState = "idle"; - private stateListeners = new Set<(s: ConnectionState) => void>(); +export class GatewayClient extends JsonRpcGatewayClient { + constructor() { + super({ + closedErrorMessage: "WebSocket closed", + connectErrorMessage: "WebSocket connection failed", + notConnectedErrorMessage: "gateway not connected", + requestIdPrefix: "w", + }); + } get state(): ConnectionState { - return this._state; - } - - private setState(s: ConnectionState) { - if (this._state === s) return; - this._state = s; - for (const cb of this.stateListeners) cb(s); - } - - onState(cb: (s: ConnectionState) => void): () => void { - this.stateListeners.add(cb); - cb(this._state); - return () => this.stateListeners.delete(cb); - } - - /** Subscribe to a specific event type. Returns an unsubscribe function. */ - on

( - type: GatewayEventName, - cb: (ev: GatewayEvent

) => void, - ): () => void { - let set = this.listeners.get(type); - if (!set) { - set = new Set(); - this.listeners.set(type, set); - } - set.add(cb as (ev: GatewayEvent) => void); - return () => set!.delete(cb as (ev: GatewayEvent) => void); - } - - /** Subscribe to every event (fires after type-specific listeners). */ - onAny(cb: (ev: GatewayEvent) => void): () => void { - return this.on(ANY as GatewayEventName, cb); + return this.connectionState; } async connect(token?: string): Promise { - if (this._state === "open" || this._state === "connecting") return; - this.setState("connecting"); - - // Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the - // SPA must fetch a single-use ticket via /api/auth/ws-ticket instead. - // Explicit ``token`` overrides the gate check (test-only path). - let authParamName: string; - let authParamValue: string; - if (token) { - authParamName = "token"; - authParamValue = token; - } else if (window.__HERMES_AUTH_REQUIRED__) { - const { ticket } = await getWsTicket(); - authParamName = "ticket"; - authParamValue = ticket; - } else { - authParamName = "token"; - authParamValue = window.__HERMES_SESSION_TOKEN__ ?? ""; - if (!authParamValue) { - this.setState("error"); - throw new Error( - "Session token not available — page must be served by the Hermes dashboard", - ); - } - } - - const scheme = location.protocol === "https:" ? "wss:" : "ws:"; - const ws = new WebSocket( - `${scheme}//${location.host}${HERMES_BASE_PATH}/api/ws?${authParamName}=${encodeURIComponent(authParamValue)}`, - ); - this.ws = ws; - - // Register message + close BEFORE awaiting open — the server emits - // `gateway.ready` immediately after accept, so a listener attached - // after the open promise resolves can race past it and drop the - // initial skin payload. - ws.addEventListener("message", (ev) => { - try { - this.dispatch(JSON.parse(ev.data)); - } catch { - /* malformed frame — ignore */ - } - }); - - ws.addEventListener("close", () => { - this.setState("closed"); - this.rejectAllPending(new Error("WebSocket closed")); - }); - - await new Promise((resolve, reject) => { - const onOpen = () => { - ws.removeEventListener("error", onError); - this.setState("open"); - resolve(); - }; - const onError = () => { - ws.removeEventListener("open", onOpen); - this.setState("error"); - reject(new Error("WebSocket connection failed")); - }; - ws.addEventListener("open", onOpen, { once: true }); - ws.addEventListener("error", onError, { once: true }); - }); - } - - close() { - this.ws?.close(); - this.ws = null; - } - - private dispatch(msg: Record) { - const id = msg.id as string | undefined; - - if (id !== undefined && this.pending.has(id)) { - const p = this.pending.get(id)!; - this.pending.delete(id); - clearTimeout(p.timer); - - const err = msg.error as { message?: string } | undefined; - if (err) p.reject(new Error(err.message ?? "request failed")); - else p.resolve(msg.result); + if (this.connectionState === "open" || this.connectionState === "connecting") { return; } - if (msg.method !== "event") return; - - const params = (msg.params ?? {}) as GatewayEvent; - if (typeof params.type !== "string") return; - - for (const cb of this.listeners.get(params.type) ?? []) cb(params); - for (const cb of this.listeners.get(ANY) ?? []) cb(params); - } - - private rejectAllPending(err: Error) { - for (const p of this.pending.values()) { - clearTimeout(p.timer); - p.reject(err); - } - this.pending.clear(); - } - - /** Send a JSON-RPC request. Rejects on error response or timeout. */ - request( - method: string, - params: Record = {}, - timeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, - ): Promise { - if (!this.ws || this._state !== "open") { - return Promise.reject( - new Error(`gateway not connected (state=${this._state})`), + // Gated mode: legacy ``?token=`` is rejected by ``_ws_auth_ok``; the SPA + // must fetch a single-use ticket. Explicit ``token`` keeps the test-only + // override path. + const authParam = token ? (["token", token] as const) : await buildWsAuthParam(); + if (!authParam[1]) { + throw new Error( + "Session token not available — page must be served by the Hermes dashboard server", ); } - const id = `w${++this.reqId}`; - - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - if (this.pending.delete(id)) { - reject(new Error(`request timed out: ${method}`)); - } - }, timeoutMs); - - this.pending.set(id, { - resolve: (v) => resolve(v as T), - reject, - timer, - }); - - try { - this.ws!.send(JSON.stringify({ jsonrpc: "2.0", id, method, params })); - } catch (e) { - clearTimeout(timer); - this.pending.delete(id); - reject(e instanceof Error ? e : new Error(String(e))); - } - }); - } -} - -declare global { - interface Window { - __HERMES_SESSION_TOKEN__?: string; - __HERMES_AUTH_REQUIRED__?: boolean; + await super.connect( + buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + path: "/api/ws", + }), + ); } } diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index ad52214c980..202fedbf8e7 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -24,6 +24,7 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@nous-research/ui/ui/components/typography/index"; +import { buildHermesWebSocketUrl, type WebSocketAuthParam } from "@hermes/shared"; import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, RotateCcw, X } from "lucide-react"; @@ -42,24 +43,28 @@ import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; function buildWsUrl( - authParam: [string, string], + authParam: WebSocketAuthParam, resume: string | null, channel: string, profile: string, fresh: boolean, ): string { - const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; // ``authParam`` is ``["token", ]`` in loopback mode and // ``["ticket", ]`` in gated mode. The server-side helper // ``_ws_auth_ok`` picks whichever shape matches the current gate state. - const qs = new URLSearchParams({ [authParam[0]]: authParam[1], channel }); - if (resume) qs.set("resume", resume); - if (fresh) qs.set("fresh", "1"); + const params: Record = { channel }; + if (resume) params.resume = resume; + if (fresh) params.fresh = "1"; // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the // selected profile, so the conversation runs with that profile's model, // skills, memory, and sessions (see web_server._resolve_chat_argv). - if (profile) qs.set("profile", profile); - return `${proto}//${window.location.host}${HERMES_BASE_PATH}/api/pty?${qs.toString()}`; + if (profile) params.profile = profile; + return buildHermesWebSocketUrl({ + authParam, + basePath: HERMES_BASE_PATH, + params, + path: "/api/pty", + }); } // Channel id ties this chat tab's PTY child (publisher) to its sidebar @@ -767,7 +772,9 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { } if (ev.code === 4404) { setBanner( - "Embedded chat is disabled on this server (start it with --tui).", + ev.reason + ? `Chat websocket unavailable: ${ev.reason}.` + : "Chat websocket unavailable on this server.", ); return; } diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json index ed6ca708523..5e743884fb0 100644 --- a/web/tsconfig.app.json +++ b/web/tsconfig.app.json @@ -18,7 +18,8 @@ /* Path aliases */ "paths": { - "@/*": ["./src/*"] + "@/*": ["./src/*"], + "@hermes/shared": ["../apps/shared/src/index.ts"] }, /* Linting */ @@ -29,5 +30,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "../apps/shared/src"] } diff --git a/web/vite.config.ts b/web/vite.config.ts index fc92eb924ce..6521751af96 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -62,6 +62,7 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@hermes/shared": path.resolve(__dirname, "../apps/shared/src"), }, // When @nous-research/ui is symlinked via `file:../../design-language`, // Node's module resolution would pick up shared deps from From 5a4bdfda5062055ca8ab4bfb68a8d7bc99067e10 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:25:12 -0500 Subject: [PATCH 02/10] fix(shared): close websocket clients deterministically 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. --- apps/shared/src/json-rpc-gateway.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index a138edbb1c2..4cc869f1519 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -185,8 +185,19 @@ export class JsonRpcGatewayClient { } close(): void { - this.socket?.close() - this.socket = null + const socket = this.socket + + if (!socket) { + return + } + + try { + socket.close() + } finally { + this.socket = null + this.setState('closed') + this.rejectAllPending(new Error(this.options.closedErrorMessage)) + } } on

(type: GatewayEventName, handler: (event: GatewayEvent

) => void): () => void { From f6ccf08ee6f53a9d02893ace22fb33f76ace92d9 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:27:06 -0500 Subject: [PATCH 03/10] refactor(web): centralize dashboard websocket URL calls 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. --- web/src/components/ChatSidebar.tsx | 16 +++--------- web/src/pages/ChatPage.tsx | 41 ++++++++---------------------- 2 files changed, 14 insertions(+), 43 deletions(-) diff --git a/web/src/components/ChatSidebar.tsx b/web/src/components/ChatSidebar.tsx index d924c6d2ee4..0c3286f528e 100644 --- a/web/src/components/ChatSidebar.tsx +++ b/web/src/components/ChatSidebar.tsx @@ -26,13 +26,12 @@ import { Button } from "@nous-research/ui/ui/components/button"; import { Badge } from "@nous-research/ui/ui/components/badge"; import { Card } from "@nous-research/ui/ui/components/card"; -import { buildHermesWebSocketUrl } from "@hermes/shared"; import { ModelPickerDialog } from "@/components/ModelPickerDialog"; import { ModelReloadConfirm } from "@/components/ModelReloadConfirm"; import { ReasoningPicker } from "@/components/ReasoningPicker"; import { GatewayClient, type ConnectionState } from "@/lib/gatewayClient"; -import { api, HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; +import { api, buildWsUrl } from "@/lib/api"; import { titleFromSessionInfoPayload } from "@/lib/chat-title"; import { cn } from "@/lib/utils"; @@ -231,18 +230,11 @@ export function ChatSidebar({ let unmounting = false; let ws: WebSocket | null = null; void (async () => { - const authParam = await buildWsAuthParam(); - if (!authParam[1] || unmounting) { + const url = await buildWsUrl("/api/events", { channel }); + if (unmounting) { return; } - ws = new WebSocket( - buildHermesWebSocketUrl({ - authParam, - basePath: HERMES_BASE_PATH, - params: { channel }, - path: "/api/events", - }), - ); + ws = new WebSocket(url); // `unmounting` suppresses the banner during cleanup — `ws.close()` // from the effect's return fires a close event with code 1005 that diff --git a/web/src/pages/ChatPage.tsx b/web/src/pages/ChatPage.tsx index 202fedbf8e7..a3297cb7cca 100644 --- a/web/src/pages/ChatPage.tsx +++ b/web/src/pages/ChatPage.tsx @@ -24,8 +24,6 @@ import { Terminal } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import { Button } from "@nous-research/ui/ui/components/button"; import { Typography } from "@nous-research/ui/ui/components/typography/index"; -import { buildHermesWebSocketUrl, type WebSocketAuthParam } from "@hermes/shared"; -import { HERMES_BASE_PATH, buildWsAuthParam } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Copy, PanelRight, RotateCcw, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -42,31 +40,6 @@ import { PluginSlot } from "@/plugins"; import { useTheme } from "@/themes"; import { useProfileScope } from "@/contexts/useProfileScope"; -function buildWsUrl( - authParam: WebSocketAuthParam, - resume: string | null, - channel: string, - profile: string, - fresh: boolean, -): string { - // ``authParam`` is ``["token", ]`` in loopback mode and - // ``["ticket", ]`` in gated mode. The server-side helper - // ``_ws_auth_ok`` picks whichever shape matches the current gate state. - const params: Record = { channel }; - if (resume) params.resume = resume; - if (fresh) params.fresh = "1"; - // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the - // selected profile, so the conversation runs with that profile's model, - // skills, memory, and sessions (see web_server._resolve_chat_argv). - if (profile) params.profile = profile; - return buildHermesWebSocketUrl({ - authParam, - basePath: HERMES_BASE_PATH, - params, - path: "/api/pty", - }); -} - // Channel id ties this chat tab's PTY child (publisher) to its sidebar // (subscriber). Generated once per mount so a tab refresh starts a fresh // channel — the previous PTY child terminates with the old WS, and its @@ -140,7 +113,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { // Lazy-init: the missing-token check happens at construction so the effect // body doesn't have to setState (React 19's set-state-in-effect rule). // In gated (OAuth) mode the server intentionally omits the session token — - // the SPA authenticates the WS via a single-use ticket (buildWsAuthParam), + // the dashboard API layer authenticates the WS via a single-use ticket, // so a missing token there is expected, not an error. const [banner, setBanner] = useState(() => typeof window !== "undefined" && @@ -393,7 +366,7 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { const token = window.__HERMES_SESSION_TOKEN__; const gated = !!window.__HERMES_AUTH_REQUIRED__; // Banner already initialised above; just bail before wiring xterm/WS. - // In gated mode the token is absent by design — buildWsAuthParam() mints + // In gated mode the token is absent by design — api.buildWsUrl() mints // a WS ticket instead, so don't bail; let the effect reach that path. if (!token && !gated) { return; @@ -696,9 +669,15 @@ export default function ChatPage({ isActive = true }: { isActive?: boolean }) { }, delayMs); }; void (async () => { - const authParam = await buildWsAuthParam(); if (unmounting) return; - const url = buildWsUrl(authParam, resumeParam, channel, scopedProfile, forceFresh); + const params: Record = { channel }; + if (resumeParam) params.resume = resumeParam; + if (forceFresh) params.fresh = "1"; + // Profile-scoped chat: the PTY child gets HERMES_HOME pointed at the + // selected profile, so the conversation runs with that profile's model, + // skills, memory, and sessions (see web_server._resolve_chat_argv). + if (scopedProfile) params.profile = scopedProfile; + const url = await api.buildWsUrl("/api/pty", params); const ws = new WebSocket(url); ws.binaryType = "arraybuffer"; wsRef.current = ws; From 216ace4bf3ce88d142057813970554fff91a8e6a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:30:43 -0500 Subject: [PATCH 04/10] style(shared): apply workspace formatter to websocket helpers Run the package-appropriate Prettier config on the shared WebSocket files so the extracted helpers match the surrounding desktop/shared TypeScript style. --- apps/shared/src/json-rpc-gateway.ts | 3 +-- apps/shared/src/websocket-url.ts | 5 +---- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/shared/src/json-rpc-gateway.ts b/apps/shared/src/json-rpc-gateway.ts index 4cc869f1519..2cf4ed1dff4 100644 --- a/apps/shared/src/json-rpc-gateway.ts +++ b/apps/shared/src/json-rpc-gateway.ts @@ -79,8 +79,7 @@ export class JsonRpcGatewayClient { closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed', connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed', connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, - createRequestId: - options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), + createRequestId: options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`), notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected', requestIdPrefix: options.requestIdPrefix ?? 'r', requestTimeoutMs: options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS, diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts index a4641399534..78562b55df0 100644 --- a/apps/shared/src/websocket-url.ts +++ b/apps/shared/src/websocket-url.ts @@ -31,10 +31,7 @@ export function isGatewayReauthRequired(error: unknown): error is GatewayReauthR ) } -export async function resolveGatewayWsUrl( - deps: ResolveGatewayWsUrlDeps, - conn: GatewayWsConnection -): Promise { +export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: GatewayWsConnection): Promise { const mint = deps.getGatewayWsUrl const profile = conn.profile ?? null From e9b95dfd19b5046ffd633e6ce2dfd87e504a372b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:43:56 -0500 Subject: [PATCH 05/10] fix(docker): include apps/shared in dashboard image build 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. --- .dockerignore | 6 +++++- Dockerfile | 4 ++++ web/src/lib/api.ts | 1 + web/src/lib/gatewayClient.ts | 4 ---- 4 files changed, 10 insertions(+), 5 deletions(-) diff --git a/.dockerignore b/.dockerignore index a5b50068f02..ec3d52f8141 100644 --- a/.dockerignore +++ b/.dockerignore @@ -66,8 +66,12 @@ runtime/ # ---------- Not needed inside the Docker image ---------- -# Desktop app source (Tauri/Electron); never installed in the container +# Desktop app source (Tauri/Electron); never installed in the container. +# apps/shared is the dashboard↔desktop websocket helper and is linked from +# web/package.json as a file: workspace dep — keep it in the build context. apps/ +!apps/shared/ +!apps/shared/** # Test suite — not shipped in production images tests/ diff --git a/Dockerfile b/Dockerfile index 6a5f5f1eef5..6f957f77967 100644 --- a/Dockerfile +++ b/Dockerfile @@ -119,6 +119,9 @@ COPY package.json package-lock.json ./ COPY web/package.json web/ COPY ui-tui/package.json ui-tui/ COPY ui-tui/packages/hermes-ink/ ui-tui/packages/hermes-ink/ +# apps/shared/ is copied IN FULL because web/package.json references it as a +# `file:` workspace dependency (same pattern as hermes-ink above). +COPY apps/shared/ apps/shared/ # `npm_config_install_links=false` forces npm to install `file:` deps as # symlinks instead of copies. This is the default since npm 10+, which is @@ -184,6 +187,7 @@ RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra # invalidate the (relatively slow) web + ui-tui build layer. COPY web/ web/ COPY ui-tui/ ui-tui/ +COPY apps/shared/ apps/shared/ RUN cd web && npm run build && \ cd ../ui-tui && npm run build diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 81643f3ac5c..db347997271 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -315,6 +315,7 @@ function appendProfileParam(url: string, profile?: string): string { } export const api = { + buildWsUrl, getStatus: () => fetchJSON("/api/status"), /** * Identity probe for the dashboard auth gate (Phase 7). diff --git a/web/src/lib/gatewayClient.ts b/web/src/lib/gatewayClient.ts index 325c8cd2bd1..d5ef547ab77 100644 --- a/web/src/lib/gatewayClient.ts +++ b/web/src/lib/gatewayClient.ts @@ -35,10 +35,6 @@ export class GatewayClient extends JsonRpcGatewayClient { }); } - get state(): ConnectionState { - return this.connectionState; - } - async connect(token?: string): Promise { if (this.connectionState === "open" || this.connectionState === "connecting") { return; From f019a999d85c4fe6408858fbd7c022122ab4c373 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 21:50:33 -0500 Subject: [PATCH 06/10] docs: clarify desktop is self-contained, not dependent on the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 +- apps/desktop/README.md | 2 +- website/docs/user-guide/desktop.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 21244765491..dbe82d5384b 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)`). 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 only spawns a headless `hermes dashboard --no-open` backend server, never the browser dashboard UI. 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/README.md b/apps/desktop/README.md index 8a6d3efe9bf..3df212feacb 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig ### How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes dashboard --no-open` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: `dashboard` here is just the name of the backend server it manages, **not** the browser dashboard UI — you never run or open the web dashboard to use the desktop app. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. ### Verification diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index 1f022b58f66..ae803d8a949 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -144,7 +144,7 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works ## How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a `hermes dashboard` backend over the `tui_gateway`/dashboard APIs and reuses the agent runtime rather than embedding `hermes --tui`. Install, backend-resolution, and self-update logic live in the Electron main process. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes dashboard --no-open` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: `dashboard` here is just the name of that backend server, **not** the browser dashboard UI — you never need to run or open the [web dashboard](./features/web-dashboard.md) to use the app. Install, backend-resolution, and self-update logic live in the Electron main process. ## Connecting to a remote backend From dff491a2b993e9e9db43d1834ac5d46c2f87173e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 22:04:22 -0500 Subject: [PATCH 07/10] feat(cli): add headless `hermes serve` backend; desktop no longer launches `dashboard` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 2 +- apps/desktop/README.md | 2 +- apps/desktop/electron/main.cjs | 46 +++---- .../electron/windows-child-process.test.cjs | 4 +- hermes_cli/main.py | 15 ++- hermes_cli/subcommands/dashboard.py | 125 ++++++++++++------ website/docs/reference/cli-commands.md | 10 +- website/docs/user-guide/desktop.md | 22 +-- 8 files changed, 142 insertions(+), 84 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dbe82d5384b..ae945b101ce 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 only spawns a headless `hermes dashboard --no-open` backend server, never the browser dashboard UI. 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, and never launches `hermes dashboard`** — 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. 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/README.md b/apps/desktop/README.md index 3df212feacb..d42fef408c8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig ### How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes dashboard --no-open` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: `dashboard` here is just the name of the backend server it manages, **not** the browser dashboard UI — you never run or open the web dashboard to use the desktop app. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never launches or requires the web dashboard. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. ### Verification diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index c873a4bc915..b2e398dd9e9 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -791,7 +791,7 @@ let rendererReloadTimes = [] // the renderer's "Reload and retry" path or by quitting the app. let bootstrapFailure = null // Latched non-bootstrap backend spawn failure — stops getConnection() from -// respawning hermes dashboard children in a tight loop while boot is broken. +// respawning hermes serve backend children in a tight loop while boot is broken. let backendStartFailure = null // Active first-launch install, so the renderer's Cancel button (and app quit) // can abort the in-flight install.sh/ps1 instead of leaving it running. @@ -1309,7 +1309,7 @@ function isCommandScript(command) { return IS_WINDOWS && /\.(cmd|bat)$/i.test(command || '') } -function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { +function unwrapWindowsVenvHermesCommand(command, backendArgs) { if (!IS_WINDOWS || !command || isCommandScript(command)) return null const resolved = path.resolve(String(command)) @@ -1326,7 +1326,7 @@ function unwrapWindowsVenvHermesCommand(command, dashboardArgs) { return { label: `existing Hermes no-console Python at ${python}`, command: python, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, @@ -2372,14 +2372,14 @@ async function applyUpdatesPosixInApp() { PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin')) } - // `hermes update` reaps stale `hermes dashboard` backends (a code update + // `hermes update` reaps stale `hermes serve` backends (a code update // leaves the running process serving old Python against the freshly-updated // JS bundle). But OUR backend is one of those processes, and killing it // mid-update produces the boot→kill→crash loop in #37532 — the desktop // already restarts its own backend via the rebuild+relaunch below, so the // reap must spare it. Hand the live backend's PID to the update process; // _kill_stale_dashboard_processes reads HERMES_DESKTOP_CHILD_PID and excludes - // it while still reaping any genuinely-orphaned dashboards. (#37532) + // it while still reaping any genuinely-orphaned backends. (#37532) // Exclude every desktop-managed backend (primary + all pool profiles) from // the update reaper. _kill_stale_dashboard_processes accepts a comma-separated // list (a single int still parses for back-compat). @@ -2830,7 +2830,7 @@ function writeDefaultProjectDir(dir) { } } -function createPythonBackend(root, label, dashboardArgs, options = {}) { +function createPythonBackend(root, label, backendArgs, options = {}) { const python = findPythonForRoot(root) if (!python) return null @@ -2842,7 +2842,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { kind: 'python', label, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [root, ...getVenvSitePackagesEntries(venvRoot)], @@ -2858,7 +2858,7 @@ function createPythonBackend(root, label, dashboardArgs, options = {}) { // canonical install location shared with the CLI installer. The venv at // VENV_ROOT may not exist yet on first run; bootstrap=true tells // ensureRuntime() to create / refresh it before launch. -function createActiveBackend(dashboardArgs) { +function createActiveBackend(backendArgs) { const venvPython = getVenvPython(VENV_ROOT) const command = fileExists(venvPython) ? getNoConsoleVenvPython(VENV_ROOT) : toNoConsolePython(findSystemPython()) @@ -2866,7 +2866,7 @@ function createActiveBackend(dashboardArgs) { kind: 'python', label: `Hermes at ${ACTIVE_HERMES_ROOT}`, command, - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], env: buildDesktopBackendEnv({ hermesHome: HERMES_HOME, pythonPathEntries: [ACTIVE_HERMES_ROOT, ...getVenvSitePackagesEntries(VENV_ROOT)], @@ -2878,12 +2878,12 @@ function createActiveBackend(dashboardArgs) { }) } -function resolveHermesBackend(dashboardArgs) { +function resolveHermesBackend(backendArgs) { // 1. Explicit override -- HERMES_DESKTOP_HERMES_ROOT points at a developer // checkout. Honour it as-is (no bootstrap; the user is driving). const overrideRoot = process.env.HERMES_DESKTOP_HERMES_ROOT && path.resolve(process.env.HERMES_DESKTOP_HERMES_ROOT) if (overrideRoot && isHermesSourceRoot(overrideRoot)) { - const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, dashboardArgs) + const backend = createPythonBackend(overrideRoot, `Hermes source at ${overrideRoot}`, backendArgs) if (backend) return backend } @@ -2892,7 +2892,7 @@ function resolveHermesBackend(dashboardArgs) { // installed `hermes` on PATH so local Python edits are actually exercised. // (In dev with no checkout, SOURCE_REPO_ROOT won't pass isHermesSourceRoot.) if (!IS_PACKAGED && isHermesSourceRoot(SOURCE_REPO_ROOT)) { - const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, dashboardArgs) + const backend = createPythonBackend(SOURCE_REPO_ROOT, `Hermes source at ${SOURCE_REPO_ROOT}`, backendArgs) if (backend) return backend } @@ -2903,7 +2903,7 @@ function resolveHermesBackend(dashboardArgs) { // to spawning hermes. Updates flow through the in-app update path // (applyUpdates -> git pull) or `hermes update` from the CLI. if (isBootstrapComplete()) { - return createActiveBackend(dashboardArgs) + return createActiveBackend(backendArgs) } // 4. Existing `hermes` on PATH -- installed via install.ps1 / install.sh from @@ -2936,7 +2936,7 @@ function resolveHermesBackend(dashboardArgs) { } if (hermesCommand) { - const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) + const unwrapped = unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) if (unwrapped) { return unwrapped } @@ -2951,10 +2951,10 @@ function resolveHermesBackend(dashboardArgs) { const shellForProbe = isCommandScript(hermesCommand) if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( - unwrapWindowsVenvHermesCommand(hermesCommand, dashboardArgs) || { + unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, command: hermesCommand, - args: dashboardArgs, + args: backendArgs, bootstrap: false, env: {}, kind: 'command', @@ -2986,7 +2986,7 @@ function resolveHermesBackend(dashboardArgs) { kind: 'python', label: `installed hermes_cli module via ${python}`, command: toNoConsolePython(python), - args: ['-m', 'hermes_cli.main', ...dashboardArgs], + args: ['-m', 'hermes_cli.main', ...backendArgs], bootstrap: false, env: {}, shell: false @@ -3009,7 +3009,7 @@ function resolveHermesBackend(dashboardArgs) { kind: 'bootstrap-needed', label: 'Hermes Agent not installed yet; bootstrap required', command: null, - args: dashboardArgs, + args: backendArgs, bootstrap: true, env: {}, shell: false, @@ -5242,8 +5242,8 @@ async function spawnPoolBackend(profile, entry) { // --profile wins over the inherited HERMES_HOME env (see _apply_profile_override // step 3 in hermes_cli/main.py), so the child re-homes to this profile. // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. - const dashboardArgs = ['--profile', profile, 'dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0'] + const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -5459,7 +5459,7 @@ async function startHermes() { const token = crypto.randomBytes(32).toString('base64url') // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. - const dashboardArgs = ['dashboard', '--no-open', '--host', '127.0.0.1', '--port', '0'] + const backendArgs = ['serve', '--host', '127.0.0.1', '--port', '0'] // Pin the desktop's chosen profile via the global --profile flag. This is // deterministic (it wins over the sticky ~/.hermes/active_profile file) and // resolves HERMES_HOME the same way `hermes -p ` does on the CLI. An @@ -5467,10 +5467,10 @@ async function startHermes() { // unaffected. const activeProfile = readActiveDesktopProfile() if (activeProfile) { - dashboardArgs.unshift('--profile', activeProfile) + backendArgs.unshift('--profile', activeProfile) } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) - const backend = await ensureRuntime(resolveHermesBackend(dashboardArgs)) + const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null diff --git a/apps/desktop/electron/windows-child-process.test.cjs b/apps/desktop/electron/windows-child-process.test.cjs index 0a91272fac4..3b42c6d7318 100644 --- a/apps/desktop/electron/windows-child-process.test.cjs +++ b/apps/desktop/electron/windows-child-process.test.cjs @@ -38,7 +38,7 @@ test('desktop background child processes opt into hidden Windows consoles', () = requireHiddenChildOptions(source, /hermesProcess = spawn\(\s*backend\.command,\s*backend\.args/) requireHiddenChildOptions(source, /spawn\(\s*py,\s*\['-m', 'hermes_cli\.main', 'uninstall', '--gui-summary'\]/) - assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, dashboardArgs\)/) + assert.match(source, /function unwrapWindowsVenvHermesCommand\(command, backendArgs\)/) assert.match(source, /existing Hermes no-console Python at/) assert.match(source, /function getNoConsoleVenvPython\(venvRoot\)/) assert.match(source, /function toNoConsolePython\(pythonPath\)/) @@ -50,7 +50,7 @@ test('desktop background child processes opt into hidden Windows consoles', () = assert.match(source, /readyFile: true/) assert.match(source, /function getVenvSitePackagesEntries\(venvRoot\)/) assert.match(source, /path\.join\(venvRoot, 'Lib', 'site-packages'\)/) - assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.dashboardArgs\]/) + assert.match(source, /args: \['-m', 'hermes_cli\.main', \.\.\.backendArgs\]/) }) test('getNoConsoleVenvPython prefers base pythonw over the uv re-exec shim', () => { diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 0420aef4fe4..5f76c1fc8d4 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -571,7 +571,7 @@ try: mode=( "gui" if next((arg for arg in sys.argv[1:] if not arg.startswith("-")), "") - in {"dashboard", "gui", "desktop"} + in {"dashboard", "serve", "gui", "desktop"} else "cli" ) ) @@ -5805,9 +5805,9 @@ def _find_stale_dashboard_pids( *exclude_pids* is an optional set of PIDs that must never be returned. This is used by the Hermes Desktop Electron app to protect its own - backend child process: when the desktop spawns ``hermes dashboard`` as + backend child process: when the desktop spawns ``hermes serve`` as a backend and triggers an auto-update, the update must not kill the - dashboard that the desktop itself manages. The desktop sets the + backend that the desktop itself manages. The desktop sets the environment variable ``HERMES_DESKTOP_CHILD_PID`` on the spawned backend process; ``_kill_stale_dashboard_processes`` reads it and passes it here. (#37532) @@ -5818,6 +5818,12 @@ def _find_stale_dashboard_pids( "hermes dashboard", "hermes_cli.main dashboard", "hermes_cli/main.py dashboard", + # The headless backend (`hermes serve`) is the same long-lived server + # under a different command name — the desktop app spawns it. Reap it + # on update for the same frontend/backend-mismatch reason. + "hermes serve", + "hermes_cli.main serve", + "hermes_cli/main.py serve", ] self_pid = os.getpid() dashboard_pids: list[int] = [] @@ -10739,6 +10745,7 @@ def _coalesce_session_name_args(argv: list) -> list: "uninstall", "profile", "dashboard", + "serve", "desktop", "gui", "honcho", @@ -11915,7 +11922,7 @@ _BUILTIN_SUBCOMMANDS = frozenset( { "acp", "auth", "backup", "bundles", "checkpoints", "claw", "completion", "computer-use", - "config", "cron", "curator", "dashboard", "debug", "doctor", + "config", "cron", "curator", "dashboard", "serve", "debug", "doctor", "dump", "fallback", "gateway", "hooks", "import", "insights", "gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa", "model", "pairing", "pets", "plugins", "portal", "postinstall", "profile", diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index 4bfb05202c9..bd605ab1561 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -10,39 +10,32 @@ import argparse from typing import Callable -def build_dashboard_parser( - subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable -) -> None: - """Attach the ``dashboard`` subcommand (and its ``register`` action).""" - # ========================================================================= - # dashboard command - # ========================================================================= - dashboard_parser = subparsers.add_parser( - "dashboard", - help="Start the web UI dashboard", - description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions", - ) - dashboard_parser.add_argument( +def _add_server_runtime_args(parser) -> None: + """Attach the runtime flags shared by ``dashboard`` and ``serve``. + + Both subcommands boot the *same* ``web_server.start_server`` (the + JSON-RPC/WebSocket gateway). ``dashboard`` opens a browser UI on top of + it; ``serve`` is the headless backend the desktop app and remote clients + connect to. The shared server logic lives in one place — only the + browser-opening behavior and help framing differ. + """ + parser.add_argument( "--port", type=int, default=9119, help="Port (default 9119, 0 for auto-assign by OS)" ) - dashboard_parser.add_argument( + parser.add_argument( "--host", default="127.0.0.1", help="Host (default 127.0.0.1)" ) - dashboard_parser.add_argument( - "--no-open", action="store_true", help="Don't open browser automatically" - ) - dashboard_parser.add_argument( + parser.add_argument( "--insecure", action="store_true", help=( - "DEPRECATED / NO-OP. Formerly bypassed dashboard auth on a " - "non-loopback bind. As of the June 2026 hardening it no longer " - "disables authentication — a public bind always requires an auth " - "provider (password or OAuth). Bind 127.0.0.1 + tunnel to keep it " - "local." + "DEPRECATED / NO-OP. Formerly bypassed auth on a non-loopback " + "bind. As of the June 2026 hardening it no longer disables " + "authentication — a public bind always requires an auth provider " + "(password or OAuth). Bind 127.0.0.1 + tunnel to keep it local." ), ) - dashboard_parser.add_argument( + parser.add_argument( "--skip-build", action="store_true", help=( @@ -51,21 +44,19 @@ def build_dashboard_parser( "where npm may not be available. Pre-build with: cd web && npm run build" ), ) - dashboard_parser.add_argument( + parser.add_argument( "--isolated", action="store_true", help=( - "When launched from a named profile (e.g. `worker dashboard`), run " - "a dedicated dashboard server scoped to that profile instead of " - "routing to the machine dashboard. Default behavior is unified: " - "profile launches attach to (or start) ONE machine-level dashboard " - "and preselect the profile in the UI's profile switcher." + "When launched from a named profile, run a dedicated server scoped " + "to that profile instead of routing to the machine-level server. " + "Default behavior is unified: profile launches attach to (or start) " + "ONE machine-level server and preselect the profile." ), ) # Internal flag set by the unified-launch re-exec (cmd_dashboard) to - # preselect the launching profile in the SPA switcher. Hidden from - # --help: users get this behavior automatically via ` dashboard`. - dashboard_parser.add_argument( + # preselect the launching profile in the SPA switcher. Hidden from --help. + parser.add_argument( "--open-profile", dest="open_profile", default="", @@ -73,19 +64,44 @@ def build_dashboard_parser( ) # Lifecycle flags — mutually exclusive with each other and with the # start-a-server flags above (if both are passed, --stop / --status win - # because they exit before the server is started). The dashboard has - # no service manager and no PID file, so these scan the process table - # for `hermes dashboard` cmdlines and SIGTERM them directly — the same - # path `hermes update` uses to clean up stale dashboards. - dashboard_parser.add_argument( + # because they exit before the server is started). The server has no + # service manager and no PID file, so these scan the process table for + # `hermes dashboard` / `hermes serve` cmdlines and SIGTERM them directly — + # the same path `hermes update` uses to clean up stale servers. + parser.add_argument( "--stop", action="store_true", - help="Stop all running hermes dashboard processes and exit", + help="Stop all running Hermes web server processes and exit", ) - dashboard_parser.add_argument( + parser.add_argument( "--status", action="store_true", - help="List running hermes dashboard processes and exit", + help="List running Hermes web server processes and exit", + ) + + +def build_dashboard_parser( + subparsers, *, cmd_dashboard: Callable, cmd_dashboard_register: Callable +) -> None: + """Attach the ``dashboard`` and ``serve`` subcommands. + + Both share the same backend (``cmd_dashboard`` → ``start_server``). + ``dashboard`` is the browser UI; ``serve`` is the headless backend used by + the desktop app and remote clients. They are independent surfaces — neither + "launches" the other — so the desktop app spawns ``serve``, never + ``dashboard``. + """ + # ========================================================================= + # dashboard command — the browser web UI + # ========================================================================= + dashboard_parser = subparsers.add_parser( + "dashboard", + help="Start the web UI dashboard", + description="Launch the Hermes Agent web dashboard for managing config, API keys, and sessions", + ) + _add_server_runtime_args(dashboard_parser) + dashboard_parser.add_argument( + "--no-open", action="store_true", help="Don't open browser automatically" ) # Backward-compat shim: older Hermes desktop app shells (<= 0.15.x) spawn the # backend as `hermes dashboard --no-open --tui --host ... --port ...`. The @@ -104,6 +120,33 @@ def build_dashboard_parser( ) dashboard_parser.set_defaults(func=cmd_dashboard) + # ========================================================================= + # serve command — the headless backend server + # + # `serve` boots the exact same gateway as `dashboard` but never opens a + # browser. It exists so the Hermes Desktop app (and headless remote + # backends) can launch a backend WITHOUT invoking `dashboard`: the desktop + # app and the web dashboard are independent surfaces that merely share this + # server, and neither should appear to launch the other. + # ========================================================================= + serve_parser = subparsers.add_parser( + "serve", + help="Start the Hermes backend server (headless; powers the desktop app and remote backends)", + description=( + "Run the Hermes backend server — the JSON-RPC/WebSocket gateway the " + "desktop app and remote clients connect to. Headless: it never opens " + "a browser UI." + ), + ) + _add_server_runtime_args(serve_parser) + # Accepted but redundant: `serve` is always headless (see set_defaults + # below). Kept so callers that pass the legacy `--no-open` flag (e.g. the + # desktop backend spawn) don't trip "unrecognized arguments". + serve_parser.add_argument( + "--no-open", action="store_true", help=argparse.SUPPRESS + ) + serve_parser.set_defaults(func=cmd_dashboard, no_open=True) + # `hermes dashboard register` — register a self-hosted dashboard OAuth # client with Nous Portal and write the client_id into ~/.hermes/.env. # Nested subparser so bare `hermes dashboard` keeps launching the server diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index cdd5aab3737..2f35858ef04 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1427,13 +1427,21 @@ hermes claw migrate --preset user-data --overwrite hermes claw migrate --source /home/user/old-openclaw ``` +## `hermes serve` + +```bash +hermes serve [options] +``` + +Start the Hermes **backend server** — the JSON-RPC/WebSocket gateway the [desktop app](/user-guide/desktop) and remote clients connect to. It is the same server `hermes dashboard` runs, but **headless**: it never opens a browser UI. The desktop app launches its own `hermes serve` backend; use this command directly when you want a headless backend on a remote host. Accepts the same `--host` / `--port` / `--insecure` / `--skip-build` / `--stop` / `--status` options as `hermes dashboard` below (a non-loopback bind engages the same auth gate). Requires the `[web]` extra; the embedded Chat socket additionally needs `[pty]` on a POSIX host. + ## `hermes dashboard` ```bash hermes dashboard [options] ``` -Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. Requires `cd ~/.hermes/hermes-agent && uv pip install -e ".[web]"` (FastAPI + Uvicorn). The embedded browser Chat tab is always available and additionally needs the `pty` extra (`cd ~/.hermes/hermes-agent && uv pip install -e ".[web,pty]"`) plus a POSIX PTY environment such as Linux, macOS, or WSL2. See [Web Dashboard](/user-guide/features/web-dashboard) for full documentation. +Launch the web dashboard — a browser-based UI for managing configuration, API keys, and monitoring sessions. (For a headless backend with no browser UI — e.g. what the desktop app spawns — use [`hermes serve`](#hermes-serve) above.) Requires `cd ~/.hermes/hermes-agent && uv pip install -e ".[web]"` (FastAPI + Uvicorn). The embedded browser Chat tab is always available and additionally needs the `pty` extra (`cd ~/.hermes/hermes-agent && uv pip install -e ".[web,pty]"`) plus a POSIX PTY environment such as Linux, macOS, or WSL2. See [Web Dashboard](/user-guide/features/web-dashboard) for full documentation. | Option | Default | Description | |--------|---------|-------------| diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index ae803d8a949..b85ee93f91e 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -144,17 +144,17 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works ## How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes dashboard --no-open` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: `dashboard` here is just the name of that backend server, **not** the browser dashboard UI — you never need to run or open the [web dashboard](./features/web-dashboard.md) to use the app. Install, backend-resolution, and self-update logic live in the Electron main process. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never launches or requires the [web dashboard](./features/web-dashboard.md). Install, backend-resolution, and self-update logic live in the Electron main process. ## Connecting to a remote backend By default the app starts and manages its own **local** backend. You can instead point it at a Hermes backend running on another machine — a VPS, a home server, or a Mini behind Tailscale. -:::info The remote backend is a running `hermes dashboard` process -"Remote backend" means a **`hermes dashboard`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that dashboard is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes dashboard` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps. +:::info The remote backend is a running `hermes serve` process +"Remote backend" means a **`hermes serve`** server running on the remote machine — that is the process the desktop app connects to. Nothing in this section works unless that backend is actually up and reachable. The desktop app does not start it for you; you (or a `systemd` service) keep `hermes serve` running on the remote host, and the app attaches to it. If you also use messaging channels (Telegram, Discord, etc.), the **gateway** is a *separate* long-running process you start independently — see the note after the setup steps. ::: -The connection has two halves: on the backend you protect the dashboard with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the dashboard to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through. +The connection has two halves: on the backend you protect it with an **auth provider**, and in the app you enter the backend's URL and sign in. Binding the backend to a non-loopback address automatically engages its auth gate, and the provider you configure is what lets the desktop app through. **Pick a provider based on where the backend lives:** @@ -165,7 +165,7 @@ The rest of this section shows the username/password path because it's the quick ### On the backend (the remote machine) -Set a username and password, then start the dashboard bound to a reachable address. The credentials live in `~/.hermes/.env` (the secrets file, mode 0600): +Set a username and password, then start the backend bound to a reachable address. The credentials live in `~/.hermes/.env` (the secrets file, mode 0600): ```bash # 1. Set the dashboard login credentials. @@ -179,21 +179,21 @@ HERMES_DASHBOARD_BASIC_AUTH_SECRET=$(openssl rand -base64 32) EOF chmod 600 ~/.hermes/.env -# 2. Run the dashboard bound to a reachable address. The non-loopback bind +# 2. Run the backend bound to a reachable address. The non-loopback bind # engages the auth gate; the username/password provider handles login. -hermes dashboard --no-open --host 0.0.0.0 --port 9119 +hermes serve --host 0.0.0.0 --port 9119 ``` -Keep that `hermes dashboard` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots. +Keep that `hermes serve` process running for as long as you want the desktop app to be able to connect — if it stops, the app can no longer reach the backend. Run it under `systemd`, `tmux`, or your process manager of choice so it survives logout and reboots. -Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the dashboard backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup. +Separately, make sure the **gateway is running** on the remote host if you rely on messaging channels — the `hermes serve` backend is what the desktop app talks to, but your Telegram/Discord/Slack gateway sessions are a different process that you start and keep running on their own. See [Messaging](./messaging/index.md) for gateway setup. Prefer not to keep a plaintext password at rest? Set `HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH` to a scrypt hash instead — compute it with `python -c "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"`. Full configuration surface (config.yaml keys, every env var, the rate limiter): [Web Dashboard → Username/password provider](./features/web-dashboard.md#usernamepassword-provider-no-oauth-idp). -Running the dashboard as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot. +Running the backend as a systemd service? Give the unit `EnvironmentFile=%h/.hermes/.env` so the credentials are in the environment at boot. :::warning -The dashboard reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected dashboard directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead. +The backend reads and writes your `.env` (API keys, secrets) and can run agent commands. The **username/password** setup shown above is for a trusted network — never expose a password-protected backend directly to the open internet; put it behind a VPN. [Tailscale](https://tailscale.com/) is the clean option: bind to the machine's tailscale IP (`--host `) and use `http://:9119` as the Remote URL so only your tailnet can reach it. To reach a backend over the public internet, use the **OAuth (Nous Portal)** provider instead. ::: ### In the app From e684b808adf4c4865a10e77f057535b2fd785f64 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 22:10:42 -0500 Subject: [PATCH 08/10] fix(desktop): route old runtimes through `dashboard` when `serve` is 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 --- AGENTS.md | 2 +- apps/desktop/README.md | 2 +- apps/desktop/electron/backend-command.cjs | 51 ++++++++++++ .../desktop/electron/backend-command.test.cjs | 83 +++++++++++++++++++ apps/desktop/electron/main.cjs | 67 +++++++++++++++ website/docs/user-guide/desktop.md | 2 +- 6 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 apps/desktop/electron/backend-command.cjs create mode 100644 apps/desktop/electron/backend-command.test.cjs diff --git a/AGENTS.md b/AGENTS.md index ae945b101ce..d8306d9bdb8 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, and never launches `hermes dashboard`** — 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. 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). `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/README.md b/apps/desktop/README.md index d42fef408c8..3182b3ac238 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -85,7 +85,7 @@ Installers are built and uploaded to GitHub Releases manually. macOS/Windows sig ### How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never launches or requires the web dashboard. The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — the **same layout a CLI install uses**, so the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers/troubleshooting. The renderer (React, in `src/`) talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — through the framework-agnostic client in [`apps/shared`](../shared/) (the same client the web dashboard consumes), and reuses the agent runtime rather than embedding `hermes --tui`. The app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the web dashboard UI. (For backward compatibility, a runtime that predates the `serve` command automatically falls back to a headless `dashboard --no-open` — see `electron/backend-command.cjs` — so mid-upgrade installs never break.) The install, backend-resolution, and self-update logic all live in `electron/main.cjs`. ### Verification diff --git a/apps/desktop/electron/backend-command.cjs b/apps/desktop/electron/backend-command.cjs new file mode 100644 index 00000000000..9ada2cdf034 --- /dev/null +++ b/apps/desktop/electron/backend-command.cjs @@ -0,0 +1,51 @@ +'use strict' + +// Backend subcommand routing for the desktop-managed Hermes process. +// +// The desktop app launches its own headless backend via `hermes serve` — it +// must NEVER depend on or launch the browser `dashboard`. But `serve` is a +// newer subcommand: a runtime that predates it (an older managed install the +// app hasn't updated yet, or an older `hermes` resolved from PATH) only knows +// `dashboard --no-open`. To avoid bricking those users mid-upgrade we detect +// whether the resolved runtime understands `serve` and, only when it does not, +// fall back to the legacy `dashboard --no-open` invocation. Both produce the +// exact same headless gateway; `serve` is just the decoupled name. +// +// These helpers are pure so they can be unit-tested without Electron. + +/** + * Build the canonical headless backend argv (always `serve`). + * @param {string} [profile] optional Hermes profile to pin via `--profile`. + */ +function serveBackendArgs(profile) { + const head = profile ? ['--profile', profile] : [] + return [...head, 'serve', '--host', '127.0.0.1', '--port', '0'] +} + +/** + * Rewrite a resolved backend argv from `serve` to the legacy + * `dashboard --no-open` form, preserving every other argument (incl. a leading + * `-m hermes_cli.main` and any `--profile `). Returns a copy; if there is + * no `serve` token the argv is returned unchanged. + */ +function dashboardFallbackArgs(args) { + const i = args.indexOf('serve') + if (i === -1) return args.slice() + return [...args.slice(0, i), 'dashboard', '--no-open', ...args.slice(i + 1)] +} + +/** + * True when a runtime's `hermes_cli/subcommands/dashboard.py` source registers + * the `serve` subcommand. Matches `add_parser("serve"` / `add_parser('serve'` + * specifically so the substring "server" (e.g. "start_server", "web server") + * never produces a false positive. + */ +function sourceDeclaresServe(dashboardPySource) { + return /add_parser\(\s*["']serve["']/.test(String(dashboardPySource || '')) +} + +module.exports = { + serveBackendArgs, + dashboardFallbackArgs, + sourceDeclaresServe, +} diff --git a/apps/desktop/electron/backend-command.test.cjs b/apps/desktop/electron/backend-command.test.cjs new file mode 100644 index 00000000000..d483ad2fa5c --- /dev/null +++ b/apps/desktop/electron/backend-command.test.cjs @@ -0,0 +1,83 @@ +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') + +const { + serveBackendArgs, + dashboardFallbackArgs, + sourceDeclaresServe, +} = require('./backend-command.cjs') + +test('serveBackendArgs builds a headless serve invocation', () => { + assert.deepEqual(serveBackendArgs(), [ + 'serve', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('serveBackendArgs pins a profile when provided', () => { + assert.deepEqual(serveBackendArgs('worker'), [ + '--profile', + 'worker', + 'serve', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs rewrites serve -> dashboard --no-open, keeping the -m prefix', () => { + const serve = ['-m', 'hermes_cli.main', 'serve', '--host', '127.0.0.1', '--port', '0'] + assert.deepEqual(dashboardFallbackArgs(serve), [ + '-m', + 'hermes_cli.main', + 'dashboard', + '--no-open', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs preserves a --profile flag ahead of serve', () => { + const serve = ['-m', 'hermes_cli.main', '--profile', 'worker', 'serve', '--host', '127.0.0.1', '--port', '0'] + assert.deepEqual(dashboardFallbackArgs(serve), [ + '-m', + 'hermes_cli.main', + '--profile', + 'worker', + 'dashboard', + '--no-open', + '--host', + '127.0.0.1', + '--port', + '0', + ]) +}) + +test('dashboardFallbackArgs is a no-op (copy) when there is no serve token', () => { + const args = ['-m', 'hermes_cli.main', 'dashboard', '--no-open'] + const out = dashboardFallbackArgs(args) + assert.deepEqual(out, args) + assert.notEqual(out, args, 'should return a copy, not the same reference') +}) + +test('sourceDeclaresServe detects the serve subparser registration', () => { + assert.equal(sourceDeclaresServe('subparsers.add_parser("serve", help="...")'), true) + assert.equal(sourceDeclaresServe("subparsers.add_parser('serve')"), true) + assert.equal(sourceDeclaresServe('subparsers.add_parser(\n "serve",\n)'), true) +}) + +test('sourceDeclaresServe does not false-positive on the substring "server"', () => { + const oldSource = ` + dashboard_parser = subparsers.add_parser("dashboard", help="Start the web UI dashboard") + from hermes_cli.web_server import start_server # web server + ` + assert.equal(sourceDeclaresServe(oldSource), false) +}) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index b2e398dd9e9..1b5f7fa9ae2 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -39,6 +39,7 @@ const { createLinkTitleWindow } = require('./link-title-window.cjs') const { probeGatewayWebSocket } = require('./gateway-ws-probe.cjs') const { adoptServedDashboardToken } = require('./dashboard-token.cjs') const { waitForDashboardPortAnnouncement } = require('./backend-ready.cjs') +const { dashboardFallbackArgs, sourceDeclaresServe } = require('./backend-command.cjs') const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-request.cjs') const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs') const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs') @@ -1334,11 +1335,73 @@ function unwrapWindowsVenvHermesCommand(command, backendArgs) { venvRoot }), kind: 'python', + // Surfaced so backendSupportsServe() can read this runtime's source for the + // `serve` capability check instead of falling back to a heavyweight probe. + root, readyFile: true, shell: false } } +// Does the resolved runtime understand the `serve` subcommand? The desktop +// spawns `hermes serve`; runtimes older than serve only have `dashboard`. We +// detect support so getBackendArgsForRuntime() can route old runtimes through +// the legacy `dashboard --no-open` form instead of crashing on an unknown +// subcommand (would brick every user mid-upgrade — #54568 follow-up). +// +// Fast path: read the runtime's own dashboard.py (instant, covers managed +// installs, dev checkouts, and the Windows venv). Fallback: probe the CLI once +// (covers a bare `hermes` resolved from PATH with no known source root). Result +// is cached per resolved runtime so we probe at most once per backend. +const _serveSupportCache = new Map() +function backendSupportsServe(backend) { + if (!backend || !backend.command) return true + const key = `${backend.command}::${backend.root || ''}` + if (_serveSupportCache.has(key)) return _serveSupportCache.get(key) + + let supported = null + if (backend.root) { + try { + const src = fs.readFileSync( + path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), + 'utf8' + ) + supported = sourceDeclaresServe(src) + } catch { + supported = null // source unreadable — fall through to the probe + } + } + + if (supported === null) { + try { + const prefix = backend.args && backend.args[0] === '-m' ? backend.args.slice(0, 2) : [] + execFileSync(backend.command, [...prefix, 'serve', '--help'], { + cwd: backend.root || undefined, + env: { ...process.env, HERMES_HOME, ...(backend.env || {}) }, + timeout: 15000, + stdio: 'ignore', + windowsHide: true + }) + supported = true + } catch { + supported = false + } + } + + _serveSupportCache.set(key, supported) + rememberLog( + `[backend] \`serve\` ${supported ? 'supported' : 'unsupported → routing via legacy `dashboard`'} for ${backend.label || key}` + ) + return supported +} + +// Given a resolved backend whose args target `serve`, return the args the +// runtime actually understands: unchanged when `serve` is supported, or +// rewritten to `dashboard --no-open` for older runtimes. +function getBackendArgsForRuntime(backend) { + return backendSupportsServe(backend) ? backend.args : dashboardFallbackArgs(backend.args) +} + function normalizeExecutablePathForCompare(commandPath) { if (!commandPath) return null @@ -5244,6 +5307,8 @@ async function spawnPoolBackend(profile, entry) { // --port 0: the OS assigns an ephemeral port; the child announces it on stdout. const backendArgs = ['--profile', profile, 'serve', '--host', '127.0.0.1', '--port', '0'] const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. + backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null @@ -5471,6 +5536,8 @@ async function startHermes() { } await advanceBootProgress('backend.runtime', 'Resolving Hermes runtime', 28) const backend = await ensureRuntime(resolveHermesBackend(backendArgs)) + // Route old runtimes (no `serve`) through the legacy `dashboard --no-open`. + backend.args = getBackendArgsForRuntime(backend) const hermesCwd = resolveHermesCwd() const webDist = resolveWebDist() const readyFile = backend.readyFile ? makeDashboardReadyFile() : null diff --git a/website/docs/user-guide/desktop.md b/website/docs/user-guide/desktop.md index b85ee93f91e..389ce104479 100644 --- a/website/docs/user-guide/desktop.md +++ b/website/docs/user-guide/desktop.md @@ -144,7 +144,7 @@ To launch via the CLI, simply run `hermes desktop`. By default it installs works ## How it works -The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never launches or requires the [web dashboard](./features/web-dashboard.md). Install, backend-resolution, and self-update logic live in the Electron main process. +The packaged app ships the Electron shell and a native React chat surface. On first launch it can install the Hermes Agent runtime into `HERMES_HOME` (`~/.hermes`, or `%LOCALAPPDATA%\hermes` on Windows) — **the same layout a CLI install uses**, which is why the two are interchangeable. Backend resolution first honours `HERMES_DESKTOP_HERMES_ROOT`, then a completed managed install, then a probed `hermes` on `PATH` (unless `--ignore-existing` / `HERMES_DESKTOP_IGNORE_EXISTING=1` is set), and finally an explicit `HERMES_DESKTOP_HERMES` command override for packagers such as Nix. The React renderer talks to a headless backend the app launches for you — a `hermes serve` process that serves the `tui_gateway` JSON-RPC/WebSocket API — and reuses the agent runtime rather than embedding `hermes --tui`. The desktop app is **self-contained**: it runs its own `hermes serve` backend and never opens or requires the [web dashboard](./features/web-dashboard.md). (Runtimes older than the `serve` command fall back to a headless `dashboard --no-open` automatically, so an app update never outruns its backend.) Install, backend-resolution, and self-update logic live in the Electron main process. ## Connecting to a remote backend From 9d9a50c2bc8675684138dd6f3f45861e950199ce Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 22:11:48 -0500 Subject: [PATCH 09/10] test(cli): pin the `hermes serve` decoupling contract 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. --- hermes_cli/subcommands/dashboard.py | 9 ++-- tests/hermes_cli/test_serve_command.py | 60 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 tests/hermes_cli/test_serve_command.py diff --git a/hermes_cli/subcommands/dashboard.py b/hermes_cli/subcommands/dashboard.py index bd605ab1561..bea3c2244de 100644 --- a/hermes_cli/subcommands/dashboard.py +++ b/hermes_cli/subcommands/dashboard.py @@ -1,7 +1,10 @@ -"""``hermes dashboard`` subcommand parser. +"""``hermes dashboard`` / ``hermes serve`` subcommand parsers. -Extracted verbatim from ``hermes_cli/main.py:main()`` (god-file Phase 2). -Handler injected to avoid importing ``main``. +``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 +``hermes_cli/main.py:main()`` (god-file Phase 2); handler injected to avoid +importing ``main``. """ from __future__ import annotations diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py new file mode 100644 index 00000000000..6b1f566cfcd --- /dev/null +++ b/tests/hermes_cli/test_serve_command.py @@ -0,0 +1,60 @@ +"""Contract for the headless ``hermes serve`` backend command. + +``serve`` is what the desktop app and remote backends launch — the same gateway +as ``dashboard`` (shared handler) but always headless, and decoupled in name so +the desktop never invokes ``dashboard``. These tests pin that contract: + +- ``serve`` routes to the same handler as ``dashboard``; +- ``serve`` is headless by default, ``dashboard`` is not; +- both expose the identical server-runtime flag surface. +""" + +from __future__ import annotations + +import argparse + +import pytest + +from hermes_cli.subcommands.dashboard import build_dashboard_parser + +_DASH = object() +_REGISTER = object() + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser() + build_dashboard_parser( + parser.add_subparsers(dest="command"), + cmd_dashboard=_DASH, + cmd_dashboard_register=_REGISTER, + ) + return parser + + +def test_serve_routes_to_the_shared_dashboard_handler(): + args = _parser().parse_args(["serve"]) + assert args.func is _DASH + + +def test_serve_is_headless_by_default_but_dashboard_is_not(): + assert _parser().parse_args(["serve"]).no_open is True + assert _parser().parse_args(["dashboard"]).no_open is False + + +def test_serve_accepts_the_legacy_no_open_flag_as_a_noop(): + # The desktop backend spawn (and old shells) may still pass --no-open; + # serve must tolerate it rather than erroring on an unknown argument. + assert _parser().parse_args(["serve", "--no-open"]).no_open is True + + +def test_serve_takes_the_same_runtime_flags_as_dashboard(): + argv = ["--host", "0.0.0.0", "--port", "0", "--insecure", "--skip-build", "--isolated"] + serve = _parser().parse_args(["serve", *argv]) + dash = _parser().parse_args(["dashboard", *argv]) + for field in ("host", "port", "insecure", "skip_build", "isolated"): + assert getattr(serve, field) == getattr(dash, field) + + +@pytest.mark.parametrize("flag", ["--stop", "--status"]) +def test_serve_supports_the_lifecycle_flags(flag): + assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True From 1af109c79cefea8fd47d0059363e48fbc79daa5d Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 28 Jun 2026 23:24:45 -0500 Subject: [PATCH 10/10] test(cli): drop pytest dep + use real sentinel handlers in serve test 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. --- tests/hermes_cli/test_serve_command.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/hermes_cli/test_serve_command.py b/tests/hermes_cli/test_serve_command.py index 6b1f566cfcd..19db4b82380 100644 --- a/tests/hermes_cli/test_serve_command.py +++ b/tests/hermes_cli/test_serve_command.py @@ -13,27 +13,30 @@ from __future__ import annotations import argparse -import pytest - from hermes_cli.subcommands.dashboard import build_dashboard_parser -_DASH = object() -_REGISTER = object() + +def _dash(args): # sentinel handler — identity-compared, never invoked + return args + + +def _register(args): + return args def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser() build_dashboard_parser( parser.add_subparsers(dest="command"), - cmd_dashboard=_DASH, - cmd_dashboard_register=_REGISTER, + cmd_dashboard=_dash, + cmd_dashboard_register=_register, ) return parser def test_serve_routes_to_the_shared_dashboard_handler(): args = _parser().parse_args(["serve"]) - assert args.func is _DASH + assert args.func is _dash def test_serve_is_headless_by_default_but_dashboard_is_not(): @@ -55,6 +58,6 @@ def test_serve_takes_the_same_runtime_flags_as_dashboard(): assert getattr(serve, field) == getattr(dash, field) -@pytest.mark.parametrize("flag", ["--stop", "--status"]) -def test_serve_supports_the_lifecycle_flags(flag): - assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True +def test_serve_supports_the_lifecycle_flags(): + for flag in ("--stop", "--status"): + assert getattr(_parser().parse_args(["serve", flag]), flag.lstrip("-")) is True