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