The macOS self-update relaunches and installs over the app it derives via
resolve_hermes_desktop_app (.../Hermes.app/Contents/MacOS/Hermes ->
.../Hermes.app). That derivation is load-bearing for both the ditto
install target and the auto-relaunch (open <app>), but had no test.
Add unit coverage:
- resolve_hermes_desktop_app_finds_built_bundle: a fake built release tree
resolves to the .app bundle on macOS (and the exe elsewhere).
- resolve_hermes_desktop_app_is_none_without_a_build: no build => None.
Verified the positive test FAILS if the .app parent-walk is wrong (e.g.
one too few .parent() hops), so it's a real guard against a regression
that would break the post-update relaunch target.
cargo test -> 17 passed.
The macOS self-update bundle swap (install_macos_app_update, added in
#38296) could leave the user with NO app installed. If moving the
existing /Applications/Hermes.app aside failed, the code deleted the
running app outright and set moved_old=false; if the subsequent move of
the freshly built bundle into place then also failed, the rollback was
gated on moved_old (now false) and skipped — leaving the target deleted
with no replacement.
Extract the swap into swap_in_new_bundle() with a strict invariant: on
ANY failure path the target is left pointing at a working bundle (either
the original, rolled back, or untouched) and is never deleted with no
replacement. Also clean up the staged .hermes-update-new copy on the
failure paths instead of orphaning it.
Add unit tests covering the happy path, the rollback-on-install-failure
path, and the catastrophic both-moves-fail path. The catastrophic-path
test was verified to FAIL against the old code ("original app must NOT
be deleted on failure") and pass against the fix.
* fix(packaging): ship locales/ i18n catalogs in wheel, sdist, and Nix
locales/ is a bare data dir (no __init__.py), invisible to packages.find
and package-data. Sealed installs (pip wheel, Nix store venv) dropped it,
so gateway/CLI commands rendered raw i18n keys like
gateway.reset.header_default.
- pyproject: [tool.setuptools.data-files] locales = ["locales/*.yaml"] (wheel)
- MANIFEST.in: graft locales (sdist)
- agent/i18n._locales_dir: env override -> source -> sysconfig data scheme
- nix/hermes-agent.nix: copy locales into the store + set HERMES_BUNDLED_LOCALES
as defense-in-depth. The wheel's data-files already materialize into the
uv2nix venv, so resolution works with no env var; the override pins the
store path against a future uv2nix change that could drop data-files.
- tests: metadata regression, wheel + sdist build-install smoke tests, and a
bundled-locales flake check that verifies BOTH the wrapper override and the
env-var-less data-files path. Smoke test wired into CI.
Closes#23943, #27632, #35374.
Supersedes #23966, #27716, #30261, #33841, #35429, #35494, #35735, #36697.
* test: cap locale e2e timeout, tighten catalog count guard
The two wheel/sdist e2e tests inherit the global --timeout=30 from
addopts; a cold-CI run (isolated build env + venv create + network pip
install) can plausibly exceed it. Add @pytest.mark.timeout(300) so they
don't ride the unit-test budget and flake intermittently.
Also assert the shipped catalog count equals len(SUPPORTED_LANGUAGES)
instead of a hardcoded >=16 floor, so the guard self-updates and trips
on a single dropped catalog (not just a fully-empty graft).
Avoid stale WebSocket events from an old reconnect attempt flipping the gateway state after a newer socket opens. Also limit session-search dedupe to compression edges so branch-specific hits still open the branch instead of collapsing to the parent.
Four related desktop session-management bugs:
- Pins lost until refresh: pinned sessions are joined against the
paginated in-memory session list, so a pinned chat that aged off the
most-recent page got evicted on the next refresh (every message.complete
triggers one) and the Pinned section went empty. mergeWorkingSessions ->
mergeSessionPage now also preserves pinned rows (matched by live id or
lineage root). Pin id checks in the chat header, command center, and
delete/archive are normalized to the durable sessionPinId so pins survive
auto-compression.
- Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer
WebSocket; nothing reconnected on wake so the composer stayed disabled.
The gateway boot hook now auto-reconnects with backoff on close/error and
on wake signals (powerMonitor resume/unlock-screen IPC, window online,
visibilitychange). connect() gains an open timeout so a hung reconnect
can't deadlock in 'connecting'. Composer placeholder distinguishes
"Reconnecting to Hermes" from a cold start.
- Loses chats from itself: the same hard-replace that dropped pins also
dropped loaded sessions; mergeSessionPage keeps them.
- Multiple copies/branches in search: /api/sessions/search deduped only by
raw session_id, so compression segments and branches surfaced as separate
hits. It now dedupes by lineage root and returns the live compression tip,
matching the session_search tool's behavior.
PR #38296 added four emit_log() calls using the old 3-arg signature, but
main had already changed emit_log to take a `stream: LogStream` argument
(#38312, "stop mislabeling stdout-style progress as stderr"). The two PRs
touched different lines, so the merge auto-resolved with no conflict and
left main unable to compile the bootstrap installer (E0061: 4 args expected,
3 supplied).
Supply the missing stream: Stdout for the update/install progress lines and
Stderr for the "could not auto-launch desktop" failure, matching the
convention from #38312. cargo check passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
The Desktop App and Web Dashboard remote-connect instructions told users
to start the backend with `hermes dashboard --no-open --insecure --host
0.0.0.0`, omitting --tui. Without --tui the embedded-chat WebSockets
(/api/ws, /api/pty) are refused, so the desktop passes the /api/status
health check and reports the backend "ready" — but chat never works
because the socket is closed on connect.
- Add --tui to both backend command blocks (with an inline why-comment).
- Explain that the desktop chat runs over /api/ws + /api/pty and needs
the embedded-chat surface enabled; a plain dashboard/gateway is not
enough.
- Add a troubleshooting entry for the exact symptom (connects, says
ready, chat dead) on both pages.
Assert _exec_schtasks passes an explicit encoding and errors="replace" to
subprocess.run, and that _schtasks_encoding falls back to utf-8 when the
locale lookup is empty or raises (#38172).
_exec_schtasks ran schtasks.exe with text=True but no encoding/errors, so
localized Windows (e.g. Chinese) output in the console code page raised
UnicodeDecodeError tracebacks from subprocess' reader threads during
`hermes gateway status`. Decode with the locale's preferred encoding and
errors="replace" so non-UTF-8 status output is read cleanly.
Fixes#38172
* fix(dashboard): clamp PTY resize dimensions for WSL2 winsize garbage
WSL2 reports columns=131072, rows=1 from a broken winsize probe. The
dashboard /chat tab forwards xterm.js dimensions through PtyBridge.resize(),
which packs them as unsigned short via struct.pack. 131072 > 65535 raised
struct.error — uncaught (only OSError was handled) — breaking the resize
path and leaving the TUI laid out for a one-row, absurdly-wide screen, which
surfaces as blank/disappearing text.
Clamp cols/rows to a sane [1, 2000]x[1, 1000] range before packing.
Non-finite/non-integer probes fall back to the minimum so nothing can reach
struct.pack and raise.
* test(dashboard): de-flake pub/events broadcast test
test_pub_broadcasts_to_events_subscribers round-tripped a frame through
two nested Starlette TestClient WebSocket portals within a 10s wall-clock
budget. Under heavy parallel CI load a starved ASGI thread occasionally
blew that budget even though the server logic is correct, producing
intermittent 'broadcast not received within 10s' failures.
Drive _broadcast_event directly under asyncio with fake subscribers
instead. Same fan-out contract (verbatim delivery to every subscriber on
the channel, nothing to other channels), zero scheduling surface. Runs in
~0.3s, deterministic across 10 consecutive runs.
Both installers (Electron bootstrap-runner + Tauri) hardcoded a literal
`stderr: ` prefix onto every line that arrived on fd 2. Tools like
uv/pip/git/npm write normal progress to stderr by design, so routine
install output showed up tagged as "stderr" (and rendered red in the
Tauri progress UI), making a healthy install look like it was erroring.
Carry the stream as structured metadata (`stream: 'stdout' | 'stderr'`)
on the log event instead of mangling the line text. The UI now styles
stderr subtly (dimmed) rather than alarmingly, and the persistent
forensic logs keep their stdout/stderr distinction.
Chromium exposes the same pasted image on both DataTransfer.items and
.files as distinct Blob objects, which attached twice. Prefer items and
skip the files mirror when items already yielded images.
The macOS DMG / in-app update could leave Hermes unable to relaunch: the
staged updater rebuilt the desktop without managed Node on PATH ("npm not
found"), never installed the rebuilt bundle over the running app, and could
race itself on `git stash`. Child install scripts also inherited a deleted
cwd from the .app bundle replaced during self-update.
- update.rs: prepend $HERMES_HOME/node/bin + venv bin to the rebuild PATH;
read --branch / --target-app from args; add a macOS "install" stage that
dittos the rebuilt bundle over the target app, clears quarantine, and
relaunches via `open` (rolling back on a failed swap); guard start_update
with an AtomicBool so concurrent startUpdate() calls can't race git stash.
- main.cjs: pass --branch <configured> and --target-app <running bundle> to
the staged updater, and spawn it with HERMES_HOME + managed Node/venv on
PATH and cwd=HERMES_HOME.
- bootstrap.rs: launch the desktop via `open <App>.app` on macOS instead of
exec'ing Contents/MacOS/Hermes, avoiding cwd/quarantine issues post-rebuild.
- powershell.rs: pin child install scripts to a stable cwd so they don't emit
getcwd errors when the launching .app is replaced mid-install.
- failure.tsx: in update mode show "Update didn't finish" / "Retry update"
and retry via startUpdate() instead of re-running the installer bootstrap.
* fix(tui): save TUI /save snapshots under Hermes home with system prompt
The TUI gateway's session.save RPC wrote hermes_conversation_<ts>.json to
the workspace/project CWD via os.path.abspath(...) and only exported model
and messages. This diverged from the classic CLI /save (which writes under
the Hermes profile home) and from the dashboard save (which includes the
system prompt).
Write the snapshot under get_hermes_home()/sessions/saved/ and include
system_prompt, session_id, and session_start so the TUI export matches the
CLI and dashboard behavior.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(tui): prefer agent.session_start for /save export; assert it in test
Address review feedback: derive session_start from the agent's session_start
datetime (matching the classic CLI export) and fall back to the gateway
session's created_at only when unavailable. Assert session_start in the
regression test.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
* feat(desktop): enrich profiles dashboard and de-dupe channel env vars
Add active-profile switching, role descriptions (manual + auto-generate
via the auxiliary LLM), per-profile model selection, and gateway-running
/ distribution badges to the GUI Profiles page. New profile creation
gains clone-all, optional description and model assignment.
Hide messaging-platform credentials (channel_managed) from the Keys/Env
page since the Channels page is the canonical surface for them, and
relabel the trimmed "messaging" category as "Gateway".
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address review feedback on profiles/env changes
- ProfilesPage: scope the action-menu outside-click handler to the menu's
own container via a ref so opening one card's menu no longer leaves
others open.
- EnvPage: route the "Gateway" label and hint through i18n
(t.common.gateway / gatewayHint) instead of hard-coded English, with an
English fallback for untranslated locales.
- web_server: only report description_auto=true when auto-generation
actually succeeded.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): address second-round review on profiles
- ProfilesPage: treat describe-auto success by null-checking the
description and trust the response's description_auto flag instead of
assuming true; disable the model-editor Save button unless the selected
choice resolves to a real /api/model/options entry (avoids silent
no-op saves).
- tests: cover the new profile endpoints (active get/set + 404,
description round-trip + 404, model round-trip + 400 validation, and
describe-auto success/failure contracts).
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(desktop): more profiles review fixes (toggles, races, tests)
- ProfilesPage: use the canonical `active` returned by setActiveProfile;
make the SOUL/description/model action-menu items toggle their editor
closed when already open; guard description save/auto-describe against
stale responses via an activeDescRequest ref so a late reply can't
clobber a different open editor.
- tests: assert /api/env channel_managed classification matches
_channel_managed_env_keys().
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
The "Build desktop app" install step failed with an opaque "exit code 1"
on machines with an old Node, and nothing in the logs explained it.
Reproduced: on Node 20.5.1, `npm run pack`'s `vite build` crashes with
You are using Node.js 20.5.1. Vite requires Node.js version 20.19+ or 22.12+.
SyntaxError: The requested module 'node:util' does not provide an
export named 'styleText'
Vite 8 (rolldown) imports node:util.styleText, which doesn't exist before
Node 20.12, so the build dies before producing the app. The installer's
check_node / Test-Node accepted ANY pre-existing Node with no version
floor, so a too-old system Node was used for the build instead of the
bundled Node 22.
Add a version floor (^20.19 || >=22.12) to check_node (install.sh) and
Test-Node (install.ps1): a too-old system Node is replaced with the
Hermes-managed Node 22 LTS, and the desktop stage re-resolves Node so the
build always runs on a satisfying version. Declare the same range in
apps/desktop/package.json engines.
Verified: build succeeds on Node 22, fails on 20.5.1 with the error above;
the floor logic matches Vite's range across boundary versions (20.18/20.19,
21.x, 22.11/22.12).
Git for Windows defaults to core.autocrlf=true, which renormalizes the
repo's LF-only text files to CRLF in the working tree. On a managed,
never-user-edited clone this makes tracked files (.envrc, AGENTS.md,
agent/*.py, workflows) show as locally modified, so the update path's
bare git checkout aborts with 'Your local changes would be overwritten
by checkout' and the desktop bootstrap fails at stage=repository.
The bash installer already autostashes before checkout; the PowerShell
path had no dirty-tree handling at all and never pinned autocrlf.
Fix: (1) git reset --hard HEAD before fetch/checkout in the update path
to discard any pre-existing dirt, and (2) pin core.autocrlf=false on both
the update and fresh-clone paths so the dirt is never created again.
* fix(doctor): detect + repair stale HERMES_MAX_ITERATIONS .env ghost shadowing config.yaml
hermes doctor now flags when ~/.hermes/.env carries a HERMES_MAX_ITERATIONS
value that disagrees with agent.max_turns in config.yaml, and 'hermes doctor
--fix' removes the stale .env line so config.yaml is authoritative. 'hermes
config show' surfaces the same drift inline under Max turns.
The setup wizard stopped dual-writing this value, but users who edited only
config.yaml from a pre-fix install keep a .env ghost. The gateway bridge
normally overrides it at startup, but if the bridge bails on any earlier
config-parse error the ghost silently wins — config says 400 while the
gateway activity line reads N/90.
The detector reads the .env FILE directly (load_env), not get_env_value/
os.environ, since the startup bridge may already have overwritten os.environ
with the config value.
Closes#17534.
* fix(config): stop offering HERMES_MAX_ITERATIONS as an editable env var
Removes HERMES_MAX_ITERATIONS from OPTIONAL_ENV_VARS so the dashboard env
editor (PUT /api/env) and any env-var prompt no longer let a user write it
to .env — which would recreate the stale ghost that shadows config.yaml's
agent.max_turns (issue #17534). The iteration budget is configured only via
config.yaml; the env var stays a read-only backward-compat fallback in the
gateway/CLI, never a promoted write target.
Regression test asserts it is absent from OPTIONAL_ENV_VARS.
CI slice 3 caught that tests/test_transform_tool_result_hook.py monkeypatches
invoke_hook but not has_hook, so the new has_hook("transform_tool_result")
gate skipped the emit and the transform never ran. Stub has_hook=True in the
shared _run_handle_function_call helper whenever a custom invoke_hook is
supplied (the test intends hooks to fire). The no-hook-registered test keeps
the real has_hook=False path — that's the gate's intended behavior.
The salvaged observer contract gated the API-request hot path on has_hook()
but left the per-tool emit ungated: every tool call ran result-field
derivation + payload dict build + invoke_hook dispatch even with zero
plugins registered.
- _emit_post_tool_call_hook now short-circuits on has_hook("post_tool_call")
and derives status/error fields lazily (after the gate, only when a
listener will consume them). status defaults to None -> derived; explicit
blocked/cancelled callers still pass status through.
- transform_tool_result emit (pre-existing hook) likewise gated on
has_hook(); skips _tool_result_observer_fields when no listener.
- Removed the now-redundant _tool_result_observer_fields pre-computation at
the three ok-path call sites (model_tools, agent_runtime_helpers,
tool_executor) — the helper derives them, so the no-listener path costs
one dict lookup and the call sites shrink.
- Tests: stub has_hook=True where payload correctness is asserted; add a
no-listener regression proving post_tool_call/transform_tool_result emit
is skipped when nothing is registered.
The salvaged PR incidentally stripped a trailing blank line from two
unrelated test files (test_file_tools_cwd_resolution.py,
test_tool_search.py). Restore them to keep the salvage diff scoped to
the observability feature.
Adds backend-neutral observer hooks for plugins: session, turn, API
request, tool, approval, and subagent lifecycle events with stable
correlation IDs (session_id, task_id, turn_id, api_request_id,
tool_call_id, parent/child subagent ids). Extends VALID_HOOKS with
api_request_error and subagent_start.
Hot path is zero-cost when no plugin subscribes: has_hook()/presence
checks gate all payload construction, request payloads are returned
by reference when no middleware rewrites, and the sanitized response
payload no longer embeds raw response objects.
Bundles the optional NeMo-Relay observability plugin
(plugins/observability/nemo_relay) as an in-repo consumer of the new
hooks, peer to the existing langfuse plugin. Fails open when the
optional nemo-relay package is not installed.
Authored-by: Bryan Bednarski <bbednarski@nvidia.com>
Salvaged from #29722 onto current main.
A kanban worker that exhausted its retries purely on a provider rate
limit / quota wall (e.g. opencode-go's 5-hour window) exited with code 1.
The dispatcher counted that as a crash, and with DEFAULT_FAILURE_LIMIT=2
two quota-wall hits permanently blocked the card. Fanning out many
workers against one shared quota made this routine.
Now a rate-limited worker exits with EX_TEMPFAIL (75); the dispatcher
classifies that as a 'rate_limited' exit, releases the task back to
'ready' WITHOUT incrementing consecutive_failures (the breaker can't trip
on a transient throttle), and the respawn guard defers the next attempt
on a cooldown (default 5min, HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS)
until the quota window clears. Genuine crashes still count and trip the
breaker as before. The 120s Retry-After cap is unchanged — no worker
parks for hours holding a slot.
- conversation_loop.py: surface failure_reason in the exhaustion return
- cli.py: kanban worker picks exit 75 on rate_limit/billing failure
- kanban_db.py: rate_limited exit kind, no-count requeue, cooldown guard
A heavy --tui session (browser snapshots, large tool outputs) silently
OOM-killed the Node parent within minutes — closing the gateway child's
stdin, which the user saw only as a bare "gateway exited" / stdin EOF.
CLI was immune. Root cause: each completed tool's verbose trail line
embedded up to 16KB of result_text, persisted in transcript Msg.tools[]
for the whole session and rendered EXPANDED by default, so an Ink
render-node tree was built for every one of up to 800 messages at once.
That tree blew past Node's heap at a few hundred MB — far below the 2.5GB
memory-monitor exit threshold, so the death was never even attributed.
- text.ts: persisted verbose tool-trail blocks now cap to a small preview
(VERBOSE_TRAIL_MAX_CHARS=800/12 lines), not the 16KB live-render budget.
Retained trail strings drop ~17x (12.2MB -> 0.7MB at 800 msgs); the live
streaming tail still uses the larger LIVE_RENDER budget.
- tui_gateway/server.py: lower the gateway-side verbose text cap to match
(1KB/16 lines) so we stop shipping output the TUI no longer renders.
- memoryMonitor.ts: derive critical/high thresholds from the real V8 heap
ceiling (~88%/70%) instead of the hardcoded 2.5GB that killed the process
at 31% of an 8GB ceiling; add a one-shot onWarn early-warning on fast
sub-threshold heap growth so the next such death is diagnosable, not silent.
- entry.tsx: wire onWarn to a crash-log breadcrumb + stderr line.
Full tool output is unchanged in the agent context and SQLite session — this
is display/transport only, no behavior or context change.
Fixes#34095. Related #27282.
Tests: ui-tui text + new memoryMonitor suites (33 pass), python verbose-cap
guard (5 pass); full ui-tui suite shows no new failures vs pristine main.
E2E repro confirms the retention drop.
The dashboard's update button ran 'hermes update' immediately with no
preview. Now the System page shows whether an update is available and
asks the user to confirm before applying it.
- New GET /api/hermes/update/check: reports install method, current
version, and commits-behind (via banner.check_for_updates, 6h-cached;
?force=1 busts the cache). Soft-fails to behind=null on network error;
marks docker/nix/homebrew as can_apply=false with the out-of-band cmd.
- System page: update-status badge on the Hermes version row (latest /
N behind), a Check-for-updates button, and an Update-now button that
opens a ConfirmDialog showing the commit count before POST /api/hermes/
update fires. Cached status loads with the rest of the page.
- Docs + 5 endpoint tests (git/up-to-date/docker/soft-failure + auth gate).
The thread scroll-anchor hook in apps/desktop/src/components/assistant-ui/
thread-virtualizer.tsx was disarming sticky-bottom whenever scrollTop
decreased by >1px between scroll events. That check was too eager: when
content height grows mid-frame (virtualizer measurement of a newly visible
turn, streaming token, Streamdown/Shiki re-tokenization, composer chip
toggle), the browser emits an interim 'scroll' event whose scrollTop is
smaller than the previous frame's because scrollHeight just jumped. The
rAF-scheduled pinToBottom hasn't run yet, so programmaticScrollPendingRef
is 0 and the disarm fired. With sticky-bottom disarmed the scroller stuck
~50px above bottom — the visible at-rest backward jump that #37997
describes (and the same root cause as the wheel-up variant in #37527).
Fix:
- Track scrollHeight per frame (lastHeightRef). Disarm on scrollTop
decrease ONLY when scrollHeight did not grow this frame. Real upward
user intent (scrollbar drag, keyboard PgUp, programmatic scrollIntoView)
still disarms because it moves scrollTop without growing the content.
Wheel-up and touchmove continue to disarm via their own listeners.
- Stop observing the scroller element itself in the ResizeObserver; only
observe its content child. Viewport-only resizes (window resize,
devtools panel toggle) no longer trigger spurious pins, matching the
intent of the auto-stick-to-bottom behavior.
Verified:
- apps/desktop `tsc -b` clean.
- apps/desktop `vitest run src/components/assistant-ui/streaming.test.tsx`
passes (9/9), including the existing wheel-up disarm regression test
that asserts scrollTop stays at 420 after a wheel-up + content growth.
Lockfile regeneration invalidated the flake's pinned npm-deps hash.
Hash taken from fetchNpmDeps' authoritative 'got:' line (the
prefetch-npm-deps Diagnose helper reports a different, wrong value
due to a fetcherVersion normalization discrepancy).
- Add @testing-library/dom to apps/desktop devDeps in package-lock.json
so npm ci validates against the manifest change (contributor left the
lockfile out of the PR intentionally).
- Removes stale 'peer: true' flags now that dom is an explicit devDep.
- AUTHOR_MAP: prostoandrei9@gmail.com -> vladkvlchk (CI author gate).
@testing-library/react@16 declares @testing-library/dom as a peerDependency
and re-exports waitFor/fireEvent/screen/within from it. Without dom installed
as a direct dependency, tsc -b fails with TS2305 in every test file that
imports those names — which breaks the apps/desktop build during installer
bootstrap (Hermes Setup → "INSTALL DIDN'T FINISH").
The Electron desktop app writes boot failures, backend spawn output, and
Python tracebacks to HERMES_HOME/logs/desktop.log, but debug-share only
captured agent/errors/gateway — so desktop boot issues never made it into
shared debug reports.
- logs.py: register desktop -> desktop.log (enables 'hermes logs desktop')
- debug.py: capture desktop snapshot, add to summary report, upload full
desktop.log in 'share', update privacy notice
- gateway /debug inherits the desktop tail via collect_debug_report()
- main.py + docs: help text and log-name table (also adds missing gui row)
- tests: desktop seed in fixture, new report test, three_pastes -> four_pastes
get_mcp_status() treated every non-connected server as a failure, so a
server configured with enabled: false rendered as red '— failed' in the
startup banner even though it was intentionally off. Add a 'disabled'
field derived from the enabled flag and render disabled servers dim as
'— disabled' instead.
The section explained why the Session token is hidden but punted the actual
setup steps to the web-dashboard page via a link — a bounce for someone on
the Desktop App page trying to connect. Inline the concrete steps instead:
backend command block (mint token -> .env -> hermes dashboard --insecure),
the in-app Remote gateway steps, the env-var override, Tailscale guidance,
and a troubleshooting list. Keep a short pointer to the web-dashboard page
for the same setup from that angle.
The Python half (#37538) reads HERMES_DESKTOP_CHILD_PID to exclude the
desktop-managed backend from _kill_stale_dashboard_processes, but nothing
set it. applyUpdatesPosixInApp now passes the live backend PID in the
`hermes update` env, completing the #37532 fix end-to-end.
The Desktop App page covered install, settings, and chat but not how to
connect the app to a backend on another machine — the exact thing
@PedjaDrazic asked about. Add a 'Connecting to a remote backend' section
that explains the Session token is the dashboard token Hermes never
surfaces (pin it via HERMES_DASHBOARD_SESSION_TOKEN + run --insecure),
and link to the web-dashboard page for the full backend setup rather than
duplicating it. Add a reciprocal link from the web-dashboard remote section
back to the Desktop App page.
Follow-up to the salvaged contributor commit:
- Underscore→hyphen tolerance now emits a resolvable token. Previously
the detect set accepted the hyphenated variant but emit returned the
raw token, so '!set_home' produced '/set_home' which the dispatcher
could not resolve. Now emits '/set-home'. Aliases are left as-is — the
gateway dispatcher canonicalizes them itself.
- Fix dead skill-command branch: skill command keys are stored
slash-prefixed (e.g. '/arxiv') in get_skill_commands(), but the check
compared the bare token, so '!arxiv' never normalized. Now compares
the '/candidate' form, making skill aliases (e.g. !gif-search) work.
- Re-run bang normalization after Matrix reply-fallback stripping so a
quoted reply whose content is a bang command reaches command parity
with the slash form.
- Replace silent 'except Exception: pass' with logger.debug(exc_info=True).
- Add AUTHOR_MAP entry for @nepenth.
Tests: +5 (underscore-alias, skill-command branch, quoted-reply bang +
slash parity). 162 Matrix tests pass.
The desktop Remote gateway field asks for a session token that Hermes never
surfaces — by default web_server.py mints an ephemeral token per boot and
injects it into the served HTML, so there is nothing in config.yaml, /gateway,
or env to copy. Document that you pin it yourself via
HERMES_DASHBOARD_SESSION_TOKEN, run the backend with --insecure (keeps the
legacy token auth path instead of engaging the OAuth gate), then paste that
value into the desktop app.
- web-dashboard.md: new 'Connecting Hermes Desktop to a remote backend' section
(backend + desktop steps, --insecure vs OAuth-gate nuance, HERMES_DESKTOP_*
env override, Tailscale guidance, troubleshooting).
- environment-variables.md: new 'Web Dashboard & Hermes Desktop' env-var table
(HERMES_DASHBOARD_SESSION_TOKEN, HERMES_DESKTOP_REMOTE_URL/TOKEN, the OAuth
and public-url vars) — none were previously documented.