mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
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
This commit is contained in:
parent
dff491a2b9
commit
e684b808ad
6 changed files with 204 additions and 3 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
51
apps/desktop/electron/backend-command.cjs
Normal file
51
apps/desktop/electron/backend-command.cjs
Normal file
|
|
@ -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 <name>`). 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,
|
||||
}
|
||||
83
apps/desktop/electron/backend-command.test.cjs
Normal file
83
apps/desktop/electron/backend-command.test.cjs
Normal file
|
|
@ -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)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue