mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-30 11:52:04 +00:00
feat(cli): add headless hermes serve backend; desktop no longer launches dashboard
The desktop app spawned `hermes dashboard --no-open` as its backend, which made the dashboard look like a desktop prerequisite. Add a dedicated headless `hermes serve` command that boots the same gateway (shared cmd_dashboard / start_server) but never opens a browser, and point the desktop backend spawn exclusively at it. dashboard and serve are now independent surfaces — neither launches the other. - subcommands/dashboard.py: factor shared server args; add `serve` parser (always headless; accepts legacy --no-open as a no-op) - main.py: register serve in _BUILTIN_SUBCOMMANDS + coalesce set + gui-log detection; extend stale-backend reaper patterns to match `serve` - desktop electron: spawn `serve`, rename dashboardArgs -> backendArgs, update comments + windows-child-process test assertions - docs: desktop README, desktop.md (incl. remote-backend), AGENTS.md, and cli-commands.md now describe `hermes serve` as the desktop/headless backend
This commit is contained in:
parent
f019a999d8
commit
dff491a2b9
8 changed files with 142 additions and 84 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** — 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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 <name>` 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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 `<profile> 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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|--------|---------|-------------|
|
||||
|
|
|
|||
|
|
@ -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 <tailscale-ip>`) and use `http://<tailscale-ip>: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 <tailscale-ip>`) and use `http://<tailscale-ip>: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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue