fix(wake): keep desktop ownership and select input devices - #74363
Merged
OutThisLife merged 1 commit intoJul 29, 2026
Conversation
helix4u
marked this pull request as ready for review
July 29, 2026 20:08
This was referenced Aug 5, 2026
OutThisLife
added a commit
that referenced
this pull request
Aug 5, 2026
…reaming (#79491) * feat(wake): client-capture wake word for remote desktop Remote headless backends have no PortAudio mic, so "hey hermes" fails even when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via wake.feed while detection stays server-side. - wake_word.capture: auto|local|client (+ GUI client_capture prefer) - WakeWordDetector external_audio queue + feed_audio API - wake.feed RPC; wake.start/status report capture + frame_length - Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice - Docs + unit tests (26 pass in tests/tools/test_wake_word.py) * fix(wake): address review on client-capture re-arm and feed queue - wake.status reports effective capture from the armed detector (client vs local), plus frame_length/sample_rate; GUI status probes prefer client - Gateway test doubles accept external_audio on start_listening - Desktop PCM feeder uses a bounded ordered queue instead of dropping frames while a wake.feed RPC is in flight - /wake on and status/re-arm paths pass client_capture so remote reattach works * fix(wake): auto capture keeps the backend mic when one exists With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent. * perf(desktop): coalesce wake.feed frames Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the ear is armed. Drain up to 4 queued frames into a single wake.feed payload (backend feed() already splits long buffers into engine frames) — ~3 RPCs/s steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not 0.5 s). * docs(config): document wake_word.capture in cli-config.yaml.example --------- Co-authored-by: Andrew <drew@kainotomic.com>
pull Bot
pushed a commit
to bryanwills/hermes-agent
that referenced
this pull request
Aug 5, 2026
With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (NousResearch#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent.
dkbjornn
added a commit
to dkbjornn/hermes-agent
that referenced
this pull request
Aug 5, 2026
…#1) * feat(observability): aggregate bounded tool metrics * fix(observability): derive tool metrics from runtime metadata Signed-off-by: Alex Fournier <afournier@nvidia.com> * fix(observability): harden tool lifecycle metrics Signed-off-by: Alex Fournier <afournier@nvidia.com> * feat(observability): add Relay skill metrics Signed-off-by: Alex Fournier <afournier@nvidia.com> * feat(observability): add Relay client resource metrics Signed-off-by: Alex Fournier <afournier@nvidia.com> * feat(observability): add Relay active install metrics Signed-off-by: Alex Fournier <afournier@nvidia.com> * test(observability): assert strict client resources Signed-off-by: Alex Fournier <afournier@nvidia.com> * fix(desktop): ⌘1 / ⌃Tab return to the chat from a full-page view Hitting ⌘1 (or cycling ⌃Tab onto the main tab) while Capabilities / Messaging / Artifacts covered the workspace looked dead: the workspace pane was already the zone's active tab behind the page, so fronting it changed nothing on screen. activateTreeTabSlot / cycleTreeTabInFocusedZone now return the activated pane id, and the keybind handlers route back to the loaded session (or the new-chat draft) when the landing pane is the workspace under a full page — the same rule openSession already applies. * feat(desktop): expose native OS notifications to plugins via ctx.notifyNative Desktop plugins can toast in-app (host.notify) but have no sanctioned way to reach the OS notification pipeline the app's own approval/turn alerts use, so a plugin surfacing a genuinely notable background event (e.g. a discovery plugin finding a match) stays invisible once the user steps away from Hermes. Add a curated per-plugin door instead of exporting the raw dispatcher: - ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed to the plugin id, routed through dispatchNativeNotification so every existing gate applies (master + per-kind prefs, post-connect baseline, away-from-app gating, throttle). - New 'plugin' native-notification kind with its own Settings ▸ Notifications toggle (default on), so users silence plugins without losing app alerts. - New optional `tag` discriminator on the notify payload keys the renderer throttle and main-process cross-window dedupe per plugin, so two plugins can't collapse each other's session-less notifications. Consumer: the Index Network desktop plugin wants background opportunity alerts; anything in ~/.hermes/desktop-plugins gets the same door. * refactor(desktop): share render weight between the two transcript budgets messageRenderWeight moves out of thread/list.tsx into lib/render-weight.ts. The DOM page budget already spends render cost rather than message count — the store window added next needs the same currency, and one weight function keeps the two layers from drifting apart. No behavior change. * fix(desktop): bound the transcript reaching assistant-ui by render cost (#55191) An oversized session rebuilt an unbounded runtime repository on every store update and exhausted the renderer's V8 heap, crash-looping the window. The DOM budget in thread/list.tsx bounds what PAINTS, but every message was still normalized into the repository first, so a session only had to be heavy — not visible — to kill the renderer. selectTranscriptWindow keeps the tail that fits one render-weight page. Weight, not message count: measured against a real 1,175-session store, a 400-message cap disengages on 37 sessions that are heavy but short (one is 133 messages / 1.05MB) while firing on 92 long-but-light sessions that were never at risk. The cut aligns off branch-group boundaries. useRuntimeMessageRepository records a group's fork point the first time it sees the group, so a window starting mid-group would re-parent the surviving branches to whatever happened to precede them. Co-authored-by: HexLab <8422520+HexLab98@users.noreply.github.com> * feat(desktop): Show earlier pages the DOM, then pulls older history from the store Show earlier spends the already-materialized DOM budget first and only asks the session store for another page once that is exhausted, so the click stays cheap and the store window stays as small as it can be. Paging has no ceiling: each expand grows the window by one budget page until the whole transcript is loaded. Branch persistence stays wired throughout — setMessages is never dropped, so switchToBranch and applyBranchVisibility keep working on a windowed session. Co-authored-by: HexLab <8422520+HexLab98@users.noreply.github.com> * feat(desktop): ctx.os — the curated OS door for plugins Fold ctx.notifyNative into a ctx.os namespace so every way a plugin reaches outside the app window lives behind one attributed door instead of accreting one top-level ctx method per capability: - ctx.os.notify — the native-notification door from the previous commit, unchanged semantics (plugin kind pref, away-gating, per-plugin throttle). - ctx.os.openExternal / ctx.os.revealPath / ctx.os.writeClipboard — the existing window.hermesDesktop bridge capabilities, now sanctioned and result-shaped: each resolves false (never throws) when the bridge or member is missing, so a plugin branches on the result instead of sniffing the preload surface or crashing on an older shell. No new Electron surface: everything routes through bridge members the app already ships; the notification path keeps every existing gate. * fix(credential-pool): clear exhaustion state on key rotation (#22622) * fix(credential-pool): clear exhaustion state on key rotation When a user rotates an API key (e.g. via `hermes setup` after hitting a rate limit), _upsert_entry updates the access_token on the existing pool entry but preserves the stale last_status=exhausted from the old key. On the next session the pool finds the entry, sees it exhausted, and returns no usable credentials — even though the new key is valid. Fix: when access_token changes on an existing entry, reset last_status, last_error_code, last_error_reason, last_error_message, and last_error_reset_at. The exhaustion state belongs to the old key, not the new one. * chore: add pasevin@gmail.com to AUTHOR_MAP * fix: clear last_status_at on key rotation, remove unused pytest import Address review feedback from teknium1 on PR #22622: - Add last_status_at=None to the reset block (matches all other token-sync reset paths in credential_pool.py) - Assert last_status_at is None in the regression test - Remove unused pytest import flagged by ruff + ty * fix(agent): adopt .env credential/base-url edits at the turn boundary (#67843) * fix(agent): adopt .env credential/base-url edits at the turn boundary A Settings save (desktop PUT /api/env, hermes setup) updates .env and the saving process's os.environ, but a live session worker keeps the base_url/api_key captured at agent init until restart — an open chat silently kept calling the old endpoint (e.g. a local-server key sent to api.openai.com, failing with an opaque 401). Add AIAgent._try_refresh_env_client_credentials(), called at the start of each conversation turn: re-resolve the provider's env-sourced credentials (load_env() is mtime-memoized, so an unchanged file costs one stat()) and rebuild the client via the existing _replace_primary_openai_client machinery when the user edited them. The refresh reacts only to env edits — resolved values changed since the last look — never to mere divergence from the agent's current values: credential-pool rotation and failover legitimately move the session off the env credential, and stomping those back would flap. Config model.base_url / pool custom endpoints keep precedence: edits are only adopted while the session still runs on the registry default or the previously-seen env value. Lift _get_env_prefer_dotenv out of _seed_from_env to module level (get_env_prefer_dotenv) so both the pool seeder and the per-turn refresh share the same .env-over-os.environ resolution, including the op:// indirection handling. Fixes #67821 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(agent): address sweeper review on env credential refresh - Cover named custom providers (#67935): provider="custom" has no PROVIDER_REGISTRY entry, so resolve the config block's key_env through the same lookup the runtime resolver uses. - Make the edit baseline transactional: a failed client rebuild rolls the agent back and leaves _env_creds_seen un-advanced so the unchanged edit is retried next turn. - Recompute route-derived TLS material and default headers on a base-url change, via a _reapply_route_client_config helper shared with credential-pool rotation so the two paths cannot drift. - Rebase onto main: get_env_prefer_dotenv keeps the scoped _get_secret semantics from the profile-isolation fix (no raw os.environ reads). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: map jskang@lablup.com to rapsealk --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Brooklyn Nicholson <brooklyn.bb.nicholson@gmail.com> * fix(debug): say where a client-side log lives instead of "(file not found)" (#78687) hermes debug share runs on the backend. A desktop app connected to a remote, docker, or SSH backend writes desktop.log on the client machine, so the bundle can never contain it — and the report rendered that as a bare "(file not found)", which reads as "the app logged nothing" and sends triage after a client-side bug it cannot see. Name the writer and the path to collect by hand. Backend-written logs are unchanged, a present desktop.log is still captured, and an empty one still reports "(file empty)" — the app ran and logged nothing is a different fact from the file being on another host. * fix(credential-pool): short cooldown for sole credential on transient throttle A pool with only one usable (non-DEAD) credential has nothing to rotate to. On a transient throttle (429 rate-limit, 403 edge-throttle, 5xx) the offending key was benched for a full hour (EXHAUSTED_TTL_429/DEFAULT), so single-key / no-fallback setups got an hour of hard failures for a throttle that resets in seconds. The pool already special-cases 401 to recover quickly for single-key setups; extend that to transient throttles when the credential is the sole non-DEAD entry. 402 (billing/quota) keeps the full bench — a quick retry can't help. Provider-supplied reset_at still overrides. Adds tests covering sole 429/403 recovery, 402 full-bench, and multi-key (no early recovery). * fix: thread sole_credential into next_available_at sibling site next_available_at() was computing the full 1-hour TTL for a sole credential on a 429, contradicting the 60s cooldown in _available_entries. The fallback restore gate (agent_runtime_helpers) uses next_available_at to decide when to switch back from fallback to primary — so the agent stayed on fallback for an hour instead of ~60s. Add sole_credential computation in next_available_at mirroring _available_entries, and a test verifying the short cooldown propagates. * fix(credential-pool): bench a billing 403 fully, even as the sole key The sole-credential cooldown sized the bench from the raw HTTP status, but 403 is overloaded: error_classifier maps OpenRouter's "key limit exceeded" and xAI's spending-limit block to FailoverReason.billing, while an edge throttle with the same status is transient. Only 402 was excluded from the short cooldown, so a spent account on a single key retried every 60 seconds and re-failed forever. Thread the classified reason from recover_with_credential_pool through mark_exhausted_and_rotate to _exhausted_ttl. Billing keeps the full bench regardless of status; everything else transient still recovers in 60s. The verdict is stored on the entry (_EXTRA_KEYS, so it persists to auth.json) — without that a restart would re-read a bare 403 and downgrade the bench. Tests: sole billing-403 stays benched, survives reload, unclassified 403 still recovers; call-site coverage that the reason actually reaches the pool. Three existing kwargs assertions updated for the new argument. * test: teach the hand-rolled fake pools the failure_reason kwarg Three fakes pin mark_exhausted_and_rotate's signature explicitly and broke on the new argument. They now assert it rather than just tolerate it — the xAI spending-limit case is exactly the billing-403 this fixes, so it should be pinning `failure_reason == "billing"`. * feat(profiles): REST export/import + extra_files overlay hook export_profile() accepts extra_files (root-relative filename -> text) so a caller can stage companion files into the archive; the desktop uses it for desktop.json, its appearance/interface overlay, now part of the default profile's export allow-list. New routes wrapping the existing hermes profile export/import machinery: - POST /api/profiles/{name}/export (extra_files + optional output path) - POST /api/profiles/import (returns the bundled desktop overlay) - GET /api/profiles/{name}/desktop-overlay Paths cross the API, not bytes - the desktop's native dialogs and its local/pooled backends share a filesystem. * feat(cli): /export and /import slash commands for profile sharing /export [profile] [-o output.tar.gz] bundles a profile into the shareable archive; /import <archive> [--name <name>] adopts one as a new profile (wrapper alias created when safe). Registry-driven, cli_only, so the CLI and TUI both pick them up in autocomplete and help. * feat(desktop): share a profile as a portable bundle - theme, layout, skills Export stages desktop.json (skin + mode, bundled user-theme definitions, rail color, layout tree) into the CLI's own profile archive; import applies it, so the receiver gets the whole look as a ready-to-use profile. Doors: Export/Import profile... in Cmd-K, an import button beside the rail's +, and Export in each profile square's context menu. New selectSavePath IPC (native save dialog); credentials never leave the machine (CLI filter). * Discord drops an empty outbound message instead of sending it (#78815) * fix(discord): reject empty outbound messages * test(discord): cover empty final reply backfill state Missed-message backfill decides what to replay from discord_messages, so a dropped final reply must be recorded as failed by the new guard the same way the exception path records one — otherwise the reply is both never sent and never retried. Co-authored-by: Jony <619963502@qq.com> * chore: map 619963502@qq.com to zyz619963502zyz for PR #73449 salvage --------- Co-authored-by: Jony <619963502@qq.com> * feat(state): sessions carry read/unread state Adds a last_read_at watermark to the sessions table so surfaces (CLI, TUI, desktop) can badge unread conversations. Read state derives from the watermark vs latest activity, so new messages flip a conversation back to unread with zero writes on the message path. NULL means never tracked, so shipping the column doesn't badge pre-existing history. set_session_read() stamps the whole compression lineage, matching the archive/pin semantics; list_sessions_rich() rows carry a derived `unread` key. DB layer only — no surface exposes it yet. * feat(desktop): the layout tree moves a tab block as one unit movePanes/reorderPanesInGroup/mergeZonesWithPane now take a block of pane ids in strip order: the lead pane decides the drop geometry (slot, split, span-merge) and the rest stack in behind it, with the pressed tab fronting at the destination. The tab-selection store holds the block (Chrome grammar: toggle, anchor range, collapse on plain click), drag-session carries it — every dragged tab dims, the insertion slot skips the whole block, and a landed drop spends the selection while a deny-area release keeps it for a retry. * feat(desktop): shift-click and opt-click select tabs to drag together Chrome's grammar on every zone tab strip: Shift-click ranges from the anchor, ⌥-click (Ctrl-click off-Mac — ⌘ stays close, ⌃ stays the macOS context menu) toggles, plain click collapses back to one tab. Selected tabs wear an accent wash; dragging any of them carries the block — the ghost chip counts it — into a strip slot, a zone edge, or a Shift-span, so three tabs land in a new zone as one gesture. * feat(gateway): session.workspace.move — re-home a stored session's workspace A session created in the wrong directory needs its cwd corrected after the fact. session.cwd.set only reaches live runtime sessions, so cold rows were stuck. The new RPC targets the persisted row by session_key, validates the folder, and REPLACES the git branch/root identity (update_session_cwd grows a replace_git_meta flag) so the project tree's grouping follows the move instead of pinning the session under the project it left via a stale git_repo_root. A live idle agent bound to the row is re-anchored through the runtime path; a mid-turn session refuses with 'session busy'. Runs on the RPC pool — the git probes are subprocesses. * feat(desktop): move a session to another project from its row menu 'Move to project' submenu in the session actions menu (kebab and right-click, via the shared MenuKit) listing every project with a folder except the current owner. Picking one calls session.workspace.move at the project root, mirrors the new cwd/branch/root into the $sessions cache, and refreshes the tree so the row hops immediately. * fix(profiles): exported archives open in Finder (GNU tar, not PAX) shutil.make_archive writes PAX with fractional-mtime records, which macOS Archive Utility rejects ("Error 94 - Bad message") on double-click. Write the profile archive with tarfile in GNU format instead: integer mtimes, longlink for deep paths, extracts under Finder, bsdtar, and gnutar alike. Verified against /usr/bin/tar (bsdtar) with >100-char member paths. * fix(models): a model id missing its vendor prefix says so instead of 404ing (#78856) Selecting an NVIDIA NIM model whose id reached config without the nvidia/ prefix produced a bare "HTTP 404: 404 page not found" — retried three times, never naming the model. It reads exactly like an outage or an auth failure, which is where the Discord thread spent its time before the id was spotted. normalize_model_for_provider() had no branch for nvidia, so a bare id passed straight through to the API. Repair it from the provider's curated catalogue: a bare name that matches exactly one entry modulo the prefix gets it back. That's a lookup, not a guess — build.nvidia.com also fronts local NIM containers and third-party models, and anything absent from the catalogue is left alone. Because the repair runs on every runtime setup, an already-broken config self-heals on the next turn and prints what it changed. If a bare id still reaches the wire, the 404 now explains itself. The classifier consults the same catalogue: a prefix-less id the provider only serves as vendor/model is a deterministic failure, so it classifies as model_not_found instead of burning three retries on a retryable "unknown", and the error trace names the id to use. Fixes #78796 * fix(models): a model id missing its vendor prefix says so instead of 404ing (#78909) Selecting an NVIDIA NIM model whose id reached config without the nvidia/ prefix produced a bare "HTTP 404: 404 page not found" — retried three times, never naming the model. It reads exactly like an outage or an auth failure, which is where the Discord thread spent its time before the id was spotted. normalize_model_for_provider() had no branch for nvidia, so a bare id passed straight through to the API. Repair it from the provider's curated catalogue: a bare name that matches exactly one entry modulo the prefix gets it back. That's a lookup, not a guess — build.nvidia.com also fronts local NIM containers and third-party models, and anything absent from the catalogue is left alone. Because the repair runs on every runtime setup, an already-broken config self-heals on the next turn and prints what it changed. If a bare id still reaches the wire, the 404 now explains itself. The classifier consults the same catalogue: a prefix-less id the provider only serves as vendor/model is a deterministic failure, so it classifies as model_not_found instead of burning three retries on a retryable "unknown", and the error trace names the id to use. Fixes #78796 * fix(install): resolve 8.3 profile aliases so a built desktop app stops reporting failure Windows aliases a profile folder whose name has a space, a dot, or an accented character (FIRST~1.LAS, STONE~1.ZEN, RUBN~1). PowerShell's FileSystem provider then throws "does not exist" the moment such a path reaches a provider cmdlet, which every Node/Electron stage hits through Tee-Object and the desktop stage hits again probing the binary it just built. The install fails on an artifact that is sitting on disk. install.ps1 already tried to expand these, but only via COM and only for TEMP/TMP. COM cannot expand an alias on a non-English locale, and it cannot expand one at all when 8dot3 generation is disabled or the alias is stale -- both return the short path unchanged. LOCALAPPDATA was never normalized either, so InstallDir stayed short even when TEMP got fixed. Three resolvers now run in order, each covering what the last one cannot: kernel32!GetLongPathNameW (locale-independent), COM (P/Invoke blocked), and profile-root substitution (nothing to resolve -- rebuild on a root we can prove is long). All five profile-rooted variables are normalized, and HermesHome/InstallDir are re-derived from them. An explicitly passed -HermesHome/-InstallDir is normalized in place, never replaced. Every resolver degrades to returning its input, so a host where none apply behaves exactly as before. Rewrites are logged to stderr: this bug class has only ever been reported as a bare "does not exist" with no hint that a short alias was involved. Co-authored-by: Sahil-SS9 <218421507+Sahil-SS9@users.noreply.github.com> * test(install): exercise 8.3 normalization by running install.ps1, not by parsing it The previous suite pulled ConvertTo-LongPath out of install.ps1 via the AST and dot-sourced the extracted text. AGENTS.md bans source-reading tests, and this one showed why: it never executed the script-level Add-Type the kernel32 resolver depends on, so the resolver that does the actual work was untestable by construction. Each case now spawns install.ps1 as a real subprocess with a crafted environment. -ProtocolVersion is a side-effect-free early exit below the normalization block, so the whole block runs exactly as it does mid-install and the assertions read what it reports back. Verified RED against the pre-fix install.ps1 (10 of 24 assertions fail) and GREEN after. The profile-root substitution is pure path arithmetic, so those cases run on any host including non-Windows CI. Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com> * ci(install): actually run the PowerShell installer tests scripts/tests/ has held three PowerShell suites that no workflow ever invoked -- there is no Windows runner in CI, so they have been inert since they landed. A regression test nothing executes is worse than none: it reads as coverage. Adds a windows-latest job, gated on a new `installer` lane so it only fires for PRs touching install.ps1 or its tests. The 8.3 suite runs under both pwsh 7 and Windows PowerShell 5.1, since install.ps1 arrives via `irm | iex` into whichever shell the user already has and 5.1 is what ships with Windows. Only the 8.3 suite is wired up. The other two fail on main today for unrelated reasons; they can join once they are fixed. * feat(dev-sandbox): support fake installer / fake main / git clones allow you to simulate the whole official curl | bash installer, and subsequent hermes updates. Run development commands in a bubblewrap filesystem and network sandbox with a local HTTPS MITM fixture server and a fake github git-upload-pack transport. Package the sandbox command and expose it from the nix devShell. Stage the local installer at its canonical fake HTTPS URL and add a persistent installation/update test path. Route root installs through sandbox-owned filesystem locations and snapshot dirty source worktrees into temporary fake commits so update tests can fast-forward without changing the real checkout. Includes a --install-ref sandbox installer mode that fetches any commit (--from-main is a nice shorthand for local development) outside the sealed sandbox, installs from that snapshot, and then promotes the fake remote to the current worktree so update flows can be exercised with FF. Notes on non-root sandboxes: Giving a non-root sandbox a network is tricky. slirp4netns joins the target userns and setuids to root before configuring the netns, so the userns must map a uid 0; bwrap's --unshare-user maps exactly ONE uid, so --uid 1000 leaves no root to become and slirp diedswith `setns(CLONE_NEWNET): Operation not permitted`. Stage 1 builds the user+net namespaces with `unshare` and two one-id ranges: inner 0 -> a subuid, unused by the payload, present only so slirp can become root inner 1000 -> our real host uid Mapping the payload to the *host* uid (not a second subuid) keeps everything the sandbox writes owned by us, so `rm -rf` on a persistent sandbox still needs no privileges. Stage 2 execs bwrap WITHOUT --unshare-user -- it only adds mount/pid -- sidestepping bwrap's refusal to accept --uid outside a userns it created. Costs a /etc/subuid range for the invoking user (we error with the exact line to add) and util-linux `unshare`; `--root` needs neither. * test(install): prove updating from a release reaches this commit Nothing covered the update path, which is the worst thing to break: a broken updater strands users on the version that cannot fix itself. `hermes update` alone is ~2000 lines (hermes_cli/update_cmd.py) and had no end-to-end test. tests/install/install-update-e2e.sh installs a genuine earlier Hermes through the real one-liner (curl -fsSL https://…/install.sh | bash, served by dev-sandbox's MITM proxy at the canonical URL, cloning "github.com" through the upload-pack shim), which really installs uv, a managed Python, Node and the venv. It then applies ONE update route and requires the checkout to land on this commit with `hermes --version` still working -- so a pass means the venv and entry point survived, not merely that git moved. One route per run, each on a sandbox built from scratch. Sharing one install across routes -- or rewinding with `git reset --hard` between them -- leaves the second route running against a tree the first already updated (same venv, same console script, same __pycache__), which is not the state any real user is in: a route could pass only because its predecessor did the work, and a failure in the first left the second exercising something undefined. --install-ref chooses what to install first, so this covers "update from an older release", not just from the tip. Installer flags are probed against the target rather than assumed, because releases from months back predate flags current Hermes takes for granted: --skip-browser is read out of that ref's own install.sh, and `--yes` is asked of the installed `hermes update --help` (the update subcommand has lived in main.py, subcommands/update.py and update_cmd.py across the tags we sample, so a static parse rots silently -- and did). Without those probes, old releases die on "Unknown option: --skip-browser" and "unrecognized arguments: --yes" before doing any work. Installer output is streamed through tee rather than captured: a real install of uv, Python, Node and the venv IS the substance of this test, so it belongs in the job log, not only in an artifact. pipefail keeps the installer's exit status rather than tee's, so a failed install cannot look like a pass. The sandbox's own proxy log is printed in full on failure, since a rejected TLS handshake explains a failure that otherwise reads as a bare `curl: (35)`. Deliberately reuses dev-sandbox rather than adding a second harness. An earlier draft rewrote install.sh's hardcoded URLs with insteadOf and ran it against the host; that tested the installer LESS faithfully (bash install.sh instead of the real one-liner, host libs instead of a clean machine, ssh disabled to keep a failed rewrite from reaching real GitHub) while duplicating a fake Internet we already have. Shell, not pytest, so scripts/run_tests.sh and run_tests_parallel.py stay untouched: a pytest version needed an entry in the former's `env -i` credential allowlist and a _SKIP_PARTS exclusion in the latter, and every meaningful line was a command run inside the sandbox anyway. Two guards, both earned during bring-up. It prefers the `sandbox` wrapper and falls back to the raw script only when bwrap is on PATH (under Nix the wrapper supplies the PATH and DEV_SANDBOX_* vars, so the bare script exits 127). And it refuses to run on a dirty worktree: every dev-sandbox invocation re-derives fake main from the working copy, so uncommitted changes move the update target between the call that installs and the call that verifies -- a failure that looks like a broken updater but is a moving reference. * ci: test updating from sampled release tags, on tag + every 12h Wires tests/install/install-update-e2e.sh into CI as a reusable workflow plus a caller that fans out over real releases, because that is the question users care about: can someone on a version they actually installed get to this commit? install-e2e-run.yml takes `route` and `install-ref`, so the combinations that matter are expressible without duplicating runner setup. Each leg is independent -- its own runner, its own sandbox, its own install, nothing shared or rewound. The starting versions are chosen at runtime by scripts/sandbox/pick-release-tags.sh: newest, oldest, and an evenly spaced spread between (5 by default). Choosing at runtime rather than hardcoding keeps the matrix honest -- a pinned list stops covering the newest release the day after it ships, and pins an "oldest" long after anyone still runs it. Newest catches "did the last release break updating?", oldest is the longest upgrade jump still possible, and the spread samples the migrations in between (config-schema bumps, venv layout changes, dependency floors). Tags are read from the checkout with `git tag --list`, not `git ls-remote`: the job has the repository already, so this needs no network, works offline and on a fork, and takes 8ms. The repo is derived from the script's own resolved path rather than $PWD, so a copy cannot silently report a different checkout's tags. The pick-releases job takes the checkout that suits it -- blob:none filter, sparse-checkout of just that script, and fetch-tags, since tags are the entire input and the default shallow checkout has none. Triggers match the shape of the work: * every 12 hours, so upstream drift (a new uv, a Node bump, a PyPI change) surfaces on a schedule instead of in someone's review cycle; * on release tags, the moment the set of versions users can update FROM changes and the moment a broken updater would strand them; * manually, with the route and the sample size as inputs. Not on pull_request: a leg is ~9 minutes of real toolchain installation and the matrix multiplies it. fail-fast is off so one broken release does not mask the others, and max-parallel caps the fan-out so a run does not hammer the runners or PyPI. The tag list is resolved once and shared by both route matrices, so the two routes cover the same versions. Artifact names include the sanitized install-ref, since a matrix runs the reusable workflow several times per route and same-named artifacts collide; that name is built in a step because Actions expressions have no string-replace function. The name step runs with `if: always()`, since a failing leg is exactly when its logs are wanted. .gitignore covers .hermes-sandbox-e2e*/ rather than the bare directory: the per-route sandbox trees (-update, -installer) fell outside it, so the sandbox made the worktree dirty and dev-sandbox reacted by snapshotting the working copy into a fresh fake-main commit on every invocation. * test(observability): exercise the active worktree in metrics smoke Signed-off-by: Alex Fournier <afournier@nvidia.com> * docs(observability): clarify active profile identity Signed-off-by: Alex Fournier <afournier@nvidia.com> * fix(git): kill the whole probe process tree on timeout (port of openai/codex#36793) Timing out a bounded git probe must not leave helper descendants (credential helpers, git-remote-https, hook children) running after the probe fails open. bounded_git_probe now spawns the child in its own process group on POSIX (process_group=0), and _kill_git_process_tree signals the whole group with os.killpg — gated on the child actually leading its own group (pgid == pid), so a shared-group spawn can never take down unrelated processes. Windows keeps the existing taskkill /T /F tree kill. Proven live on main: a fake git that forks a 300s descendant left the descendant running after the probe timeout; with the fix the descendant dies with the launcher. Fast path and fail-open contract unchanged. Port of openai/codex#36793 (Terminate timed-out Git process trees). * chore: suppress windows-footgun false positive on gated killpg * fix(console): handle string SystemExit code in _capture_output A dispatched console handler that calls sys.exit("message") or raise SystemExit("message") sets exc.code to a string. int(exc.code or 0) then raises ValueError, which is not a ConsoleCommandError, so it escapes execute()'s handler and crashes the local REPL on an ordinary user mistake (e.g. removing a credential that does not exist). Treat a string exit code as a status-1 failure carrying that message. * fix(cli): make profile.yaml and skin writes atomic to stop silent field loss `write_profile_meta` and `hermes skin set` are both read-modify-write helpers that rewrite a user-visible YAML file with a bare truncating write, bypassing `utils.atomic_yaml_write` — the shared helper whose docstring states that "every destructive file rewrite in the codebase shares one implementation". Both read halves swallow a parse error and fall back to `{}`, so a truncated file is not transient corruption. The next call reads `{}` and silently, permanently drops every field the caller did not explicitly pass: * `write_profile_meta` promises "unspecified fields preserve existing values". After an interrupted write, a follow-up call that only sets `description_auto` erases the profile's `description` — it vanishes from `hermes profile list` and never comes back. * `_skin_set` exists so that "changing one token never disturbs the rest of the look". `path.write_text(...)` neither fsyncs nor swaps atomically, so a crash or power loss can leave `<skin>.yaml` zero-length; the next tweak then rewrites from empty and the whole palette is gone. The gateway's skin watcher repaints live surfaces from this file within ~1s, so a half-written file is observable. Routing both through `atomic_yaml_write` gives temp file + fsync + `atomic_replace`, which also preserves a symlinked target (GitHub #16743) and restores owner/mode, and emits emoji descriptions as real UTF-8 instead of `\UXXXXXXXX` escapes (GitHub #51356). Supersedes #51808, which fixed the unicode-escaping symptom alone by adding `allow_unicode=True` to the same `yaml.safe_dump` call. * fix(cli): correct the skin_cmd fallback comment to match the actual read path _skin_set has no try/except around yaml.safe_load, so invalid YAML raises and aborts the command. The {} fallback comes only from safe_load() returning None on a zero-length file — which is exactly the state a torn, unsynced write leaves behind, so the data-loss chain is unchanged. * refactor: trim verbose comments + drop redundant default_flow_style kwarg simplify-code follow-up: collapse 9-12 line inline comments to 3 lines (keeping the loss-chain WHY + issue refs), and remove redundant default_flow_style=False (atomic_yaml_write already defaults to False). * fix(desktop): scope restored navigation by profile (#67709) Scope remembered desktop route and session keys by the encoded active profile. Discard ambiguous legacy global navigation keys instead of assigning them to an arbitrary profile. Delay cold-start restoration until the primary profile and session list have reached renderer.ready. Preserve explicit deep-link and hidden-window destinations. Restore and persist session routes only when a direct ID or lineage root is explicitly owned by the active profile. Clear both profile-scoped route and session state after resume exhaustion. Closes #67709 Co-authored-by: Tranquil-Flow <tranquilflow@users.noreply.github.com> * fmt(js): `npm run fix` on merge (#79155) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(cache): scope prompt_cache_key by session to stop cross-session bucket sharing Cherry-picked from PR #78959 by @JoaoMarcos44 with authorship preserved. Follow-up: hoist _cache_scope_from_session_id(session_id) to a local in build_kwargs so it's computed once instead of 4 times per call. Closes #78941. Closes #79012. Closes #79013. Closes #79014. Closes #79015. Co-authored-by: JoaoMarcos44 <joaomarcosdias444@gmail.com> * fix(agent): prevent historical steer replay * chore: add contributor mapping for burak33bb * fix(state): stop delegate/tool children corrupting compression lineage get_compression_lineage's forward walk accepted any non-branch child as the compression continuation. Delegate subagent rows (_delegate_from) and tool-tagged rows (source=tool) created before the real continuation were picked as the lineage successor, so the lineage — and session .md export built on it — followed a subagent's transcript instead of the actual conversation continuation. Rename _is_branch_child_row to _is_explicit_fork_child_row, treat _delegate_from and source=tool rows as explicit forks alongside _branched_from, and require _is_compression_child_row in the forward walk instead of merely excluding branches. Sliced from PR #79024 by @RyderFreeman4Logos (the cache-scope portion of that PR is tracked separately in #79017). * fix(delegation): keep subagents alive during slow model waits Top-level delegate_task runs in the background, and the 450s progress-stall monitor only sees api_call_count / tool / last_activity_ts. Subagents use non-streaming direct_api_call, which previously touched activity once and then went silent — so a healthy local GGUF / long-prefill wait looked frozen and was interrupted around ~450s as "Operation interrupted: waiting for model response", even when child_timeout_seconds was raised. Refresh activity while the inline request is open, and treat last_activity_ts advances as sync heartbeat progress too. * docs(delegation): note in-flight model waits count as progress Clarify that activity-timestamp ticks during a provider wait keep the staleness monitor from treating a slow completion as a wedged child. * fix: join heartbeat thread in finally + add error-path test Add activity_hb.join(timeout=2.0) after activity_hb_stop.set() in direct_api_call's finally block so the heartbeat thread is deterministically stopped before client teardown. Add test verifying no stray _touch_activity fires after direct_api_call raises an exception. Follow-up to PR #78548 by @xxxigm. * fix(terminal): skip binary content on the referenced-script remote-read fallback (#77703) The gateway terminal guard crashed with 'ValueError: embedded null byte' (command never ran, exit_code -1) when a command invoked an ELF binary by full path. _read_referenced_script correctly rejects the binary locally (NUL in first chunk), but the read_remote_script fallback (_read_script_in_env) then re-read the SAME file's bytes without a NUL guard, decoded them, and fed machine code back into the scanner, which re-tokenized it into a bogus NUL-bearing path and crashed at os.open. - _read_script_in_env: skip content containing a NUL byte on both the local-read and remote-cat branches (mirrors _read_referenced_script: a binary is nothing to scan), so binary never re-enters the guard. - _read_referenced_script: tolerate ValueError from os.open on a NUL-in-path, alongside the existing OSError guard, so the guard can never crash the terminal tool regardless of input. Extends the #76762 NUL-safety fix (local path only) to the gateway's remote-read fallback path. * ci: add detailed logging to live comment poller The poller logs transitions between polls. It reports newly completed jobs (with their results), newly appeared jobs, and jobs that left the pending list. Each comment update shows the reason for the change. For example: '1 new completion(s); artifact statuses updated'. When nothing changed, the poller lists the jobs that are still pending. The status line shows the raw job count from the API and the number of infra jobs that the filter removed. * ci: poll review statuses from artifacts every cycle The live comment poller got its review statuses from two sources. The first was the REVIEW_STATUSES environment variable, fixed at the start of the comment-live job. The second was one ci-timings artifact, downloaded at the end of the run. Status details (error messages, action_required items) appeared only after all jobs finished. The job pass/fail results were visible as each job completed. Now every status-producing workflow_call uploads a small review-status artifact when it completes. The poller lists all review-status-* artifacts from the orchestrator run and its workflow_call runs every cycle. It downloads each artifact and merges the statuses into the comment. A status appears as soon as its job finishes. Changes: - live_comment.py: _fetch_artifact_statuses became fetch_all_review_statuses. The new function lists the artifacts via the API, downloads each one, and parses it. Removed the review_statuses_json parameter, the --review-statuses-file argument, and the subprocess import. - ci.yml: removed the REVIEW_STATUSES environment variable, the inline Python merger, and the --review-statuses-file argument. Renamed the ci-timings-review-status artifact to review-status-ci-timings. - Eight workflow_call files: added a step that writes review-status.json and uploads it as an artifact after each review_status output. - test_live_comment.py: added tests for _parse_status_file and _merge_statuses. * fix(ci): follow artifact download redirect without auth The artifact download URL returns a 302 redirect to a signed blob URL. urllib sent the Authorization header to the blob, and the blob rejected it with a 401 error. The download now has two hops. The first hop authenticates to the API. The second hop follows the redirect without the auth header. The query runs?event=workflow_call returns nothing for this repository. GitHub flattens reusable-workflow jobs and their artifacts into the caller run. The fetch now lists the artifacts on the orchestrator run only. The dead sub-run enumeration is gone. Two API calls per cycle are gone with it. The 'artifact statuses updated' reason never appeared. The code updated the count before the comparison. Now the code compares first and updates after. The code rejects zip members that contain '..' or start with '/'. tests/ci/test_live_comment.py is deleted. This repository does not keep tests for CI infrastructure. * fix(desktop): stop dialogs clipping popovers opened inside them DialogContent published itself as the portal container for popovers opened inside a dialog (so focus stays in the dialog and dismissal doesn't close it), but that same element carried `overflow-y-auto`. Every Select/Popover/ DropdownMenu in a dialog was therefore born inside a scroll box and got cropped at the dialog's edge — most visibly the worktree dialog's base-branch combobox, where the branch list was cut off entirely and only the search field showed. Split the box in two: the shell keeps position/size/skin and no longer clips (it stays the portal container), while a new inner body div owns layout and scrolling. Popovers remain DOM descendants of the dialog, so focus and dismissal behave exactly as before, but they can now paint past the dialog's bounds. The banner variant had the same `overflow-hidden` on its shell; its clip moves to the banner itself, which keeps the rounded bottom edge. Callers that passed layout/scroll classes (grid, gap-*, p-*, overflow-*) now pass them via the new `bodyClassName`; `className` keeps sizing and skin. * fix(desktop): mount one worktree dialog instead of one per composer Every CodingStatusRow mounted its own WorktreeDialog and subscribed to the same global `$newWorktreeRequest` token, so a single ⌘⇧B with two composers on screen opened two stacked dialogs — dismissing the front one revealed an identical empty dialog behind it, which read as the dialog "staying open" after creating a worktree. Mount it exactly once in the sidebar (beside ProjectDialog) and drive it from a `$worktreeDialog` atom, mirroring how the project dialog already works. One mount cannot double-open. Every entry point (⌘⇧B, the rail's kebab, the sidebar's + button) now publishes intent instead of rendering its own copy; the rail and the button pin their own repo so a tile's kebab still targets that tile's worktree. The target is resolved at open time by `resolveWorktreeRepoPath`, which walks the focused surface's cwd then the entered project's root, validating each candidate against the repo-status probe cache — a project's root folder is not necessarily a git repo, so existence alone isn't proof. That makes the resolver the sole authority, so the hotkey no longer pre-gates on `$repoStatus` and now works from a detached session that sits inside a project. When nothing in reach is a repo it is a silent no-op: a worktree only exists inside a repo, so there is nothing to report. Also adds a project picker to the dialog so the repo can be retargeted before naming the branch. E2E: extends worktree-branch-status.spec.ts with a 10-branch repo, visual snapshots of the base-branch picker and the convert-branch view, a geometry assertion that the picker isn't clipped by the dialog (fails headlessly on regression rather than waiting for a human to compare diff images), and a two-composer test asserting one keypress opens exactly one dialog. Tests 1 and 4 fail against the previous code and pass now. * feat(desktop): let convert-a-branch reach remote branches too * fix(desktop): worktree dialog names the project, not the branch * feat(desktop): register a Linux launcher entry for `hermes desktop` On Linux a freshly-built desktop app had no presence in the application launcher: no Hermes in the KDE/GNOME menu, no icon, nothing to pin. Users had to hand-write ~/.local/share/applications/hermes.desktop and remember to reindex the menu caches themselves. `hermes desktop` now writes that entry itself (best-effort, idempotent, never blocking a launch), and `hermes uninstall --gui` removes it again. Both fields that matter are absolute: - Exec — the launcher runs with a minimal environment and no shell PATH customizations, so a bare `hermes desktop` silently fails for anyone whose hermes lives in ~/.local/bin or a venv. We resolve the real binary via relaunch.resolve_hermes_bin(), falling back to an absolute interpreter + `-m hermes_cli.main`. - Icon — an unqualified name only resolves against an indexed icon theme, which we are not in. The spec allows an absolute path, so we point at apps/desktop/assets/icon.png in the checkout. No copy is installed: Exec already depends on that same tree, so a second copy would add bytes and an uninstall step without surviving anything Exec wouldn't. Menu-cache refresh is tool-gated — update-desktop-database, then kbuildsycoca6 or kbuildsycoca5 — each only when the binary is actually on PATH, because most desktops ship none of them and a missing one is not an error. The entry is only rewritten when its contents change, so a launch doesn't churn the caches every run. Verified on NixOS: the generated entry passes desktop-file-validate, a real kbuildsycoca6 on PATH is invoked with --noincremental, a real update-desktop-database writes mimeinfo.cache, absent tools are skipped cleanly, and removal leaves the checkout's icon untouched. * feat(nix): desktop app icon * fix(nix): fix electron headers sha * docs: state the /goal vs Kanban boundary on both pages The goals page never mentions Kanban and the kanban page references /goal only inside the goal-mode-cards section, so users assume /goal hands work to the board (see #26116 - /goal is single-session continuation only). Adds a decision section to goals.md and the inverse note to kanban.md. * docs: add 'Which File Does What?' - one-page map of SOUL/USER/MEMORY/AGENTS The four-file map is currently split across the memory, personality and context-files pages; 'which file is my agent's brain' is one of the most frequent support questions (e.g. #20245, #29476). One master table, the frozen-snapshot rule surfaced with a link, and the two canonical mix-ups answered directly. Content is drawn from the existing three pages. * docs: add per-plan subscription billing table to providers page Users with Claude Pro/Max, ChatGPT/Codex, SuperGrok or Gemini plans cannot find what their plan pays for in Hermes in one place (e.g. #15291, #27228). One comparison table + per-provider notes; cells the docs do not yet specify are marked 'not currently documented' rather than guessed. * docs: four small accuracy fixes - cron: state explicitly that job definitions survive updates, gateway restarts and reboots (asked directly in #37542) - mcp: add a Claude Code bridge tip - mcpServers maps to mcp_servers and hermes import-agent migrates it (the MCP page never says 'mcpServers' in the client direction; arrivals from Claude Code get no pointer) - installation: surface loginctl enable-linger in the non-sudo/service user section where affected users start (currently only on the gateway page; #43748) - sessions: document optimize, optimize-storage, repair, recover and retitle-skills in the CLI reference (shipped in v0.19.1 --help but absent from the table) and recommend non-destructive optimize before prune in the db-growth tip All wording verified against hermes v0.19.1 --help output and the live pages on 2026-08-04. * docs: surface existing answers users can't find (migration, prompt-size, tool-call parsing, Desktop label) * docs: add security-posture guide for running Hermes on a personal or work machine * docs: add troubleshooting checklist for perceived agent-quality regressions * docs: warn against pointing two agents at one Hermes home (memory, profiles, FAQ) * docs: explain the slow silent first turn (prefill) on local hardware * docs: fix stale PATH location in windows-native common pitfalls * fix: correct cron mid-run restart claim in salvaged docs The original PR #78453 said 'A job that was mid-run during a restart resumes according to the attempt policy described in this page.' This is misleading — the existing docs explicitly state 'Unknown attempts are audit records and are never automatically rerun.' Corrected to accurately describe: the mid-run attempt is marked unknown (not retried), but the job's next scheduled tick fires normally. * feat(wake): client-capture wake word for remote desktop Remote headless backends have no PortAudio mic, so "hey hermes" fails even when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via wake.feed while detection stays server-side. - wake_word.capture: auto|local|client (+ GUI client_capture prefer) - WakeWordDetector external_audio queue + feed_audio API - wake.feed RPC; wake.start/status report capture + frame_length - Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice - Docs + unit tests (26 pass in tests/tools/test_wake_word.py) * fix(wake): address review on client-capture re-arm and feed queue - wake.status reports effective capture from the armed detector (client vs local), plus frame_length/sample_rate; GUI status probes prefer client - Gateway test doubles accept external_audio on start_listening - Desktop PCM feeder uses a bounded ordered queue instead of dropping frames while a wake.feed RPC is in flight - /wake on and status/re-arm paths pass client_capture so remote reattach works * fix(wake): auto capture keeps the backend mic when one exists With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent. * perf(desktop): coalesce wake.feed frames Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the ear is armed. Drain up to 4 queued frames into a single wake.feed payload (backend feed() already splits long buffers into engine frames) — ~3 RPCs/s steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not 0.5 s). * docs(config): document wake_word.capture in cli-config.yaml.example * fix(desktop): preview remote HTML over SSH (#76008) * fix(desktop): preview remote HTML over SSH * fix(desktop): harden remote HTML sanitization * fix(desktop): open remote file rows in the in-app preview A plain click on a composer file row in remote mode handed the backend's file:// URL to the local browser bridge, which cannot resolve a path that only exists on the gateway host. Route remote non-HTML file targets to the gateway-backed in-app preview pane instead; local files, ordinary URLs, and remote HTML (staged locally by openPreviewTargetInBrowser) keep their existing browser path. Supersedes #70296 and #57878. Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com> Co-authored-by: cj52973 <cjenkins@scacpa.org> * chore: fix import order, map contributor email - perfectionist/sort-imports in store/wake-word.ts - contributors/emails mapping for drew@kainotomic.com -> appletechie * fmt(js): `npm run fix` on merge (#79496) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * In-app browser and previews are real layout-tree tabs (#77705) * feat(desktop): shared pane-strip primitives — one bar, one glyph, one close menu The zone header hand-rolled its tab bar, its close-verb context menu, and the bare-glyph "+" inline; the preview rail kept a second copy of all three. Extract PaneTabStrip (the bar), PaneStripGlyph/PaneStripTool (glyph buttons as data, titlebar-tool style), and paneTabCloseItems (the four close verbs) into the pane-tab primitives, and render the zone header through them. Panes contribute strip glyphs via PaneChrome.stripTools; $stripToolsRevision tells the strip to re-read. * refactor(desktop): preview tabs are layout-tree tiles like session and page tiles The in-app browser / preview rail carried its own tab strip beside the zone's own — a second bar at a different height with its own close menu, label casing, ⌘W rung, and welded to the file browser's zone so ⌘J toggled it away. It predated the layout tree. $previewTabs now mirrors into pane contributions through the same paneMirror session and route tiles use, so a preview tab IS a zone tab: one strip, drag/ stack/split, the shared close verbs, plain ⌘W, its own zone docked beside main. URL tabs are titled Browser (the tab names the surface, not the page); files keep their filename and a file-type lead glyph. Deleted with the rail: the preview pane contribution + PREVIEW_PANE_ID + its visibility binding, the 'preview' placements in the default tree and presets, the ⌘W rail rung, the reveal listener, and the preview.close* i18n keys (copies of zones.*). lone-header now keys on "closeable placement:main" instead of the session-tile: id prefix, so any tile dragged into its own zone keeps its tab. * fix(desktop): preview console/DevTools live on the strip, and DevTools tells the truth The two toggles were titlebar tools — far from the preview they act on and one ambiguous global pair once two previews were open. They're strip glyphs now, contributed per-tab as PaneStripTool data with real tooltips: the console store is cached by tab id so the glyph and the panel read the same logs, and the pane registers a DevTools handle for its tab. DevTools active state was also a lie: it tracked our click handler, so closing the DevTools window itself left the glyph stuck on. The webview's devtools-opened/closed events drive it now. * fix(desktop): ⌘W and ⌃Tab work over preview and page zones The generic tab verbs keyed zone eligibility on the CHAT strip (workspace / session-tile: ids), so a zone holding only a Browser or page tile was invisible to them: ⌃Tab skipped it, and ⌘W fell through the chat rung and emptied the MAIN chat while you were looking at a preview. ⌘1…⌘9 already worked — the verbs disagreed about what counts as a tab strip. New isMainStripPane (any placement:'main' tenant — sessions, pages, previews) drives ⌘W and ⌃Tab; isSessionStripPane keeps gating what it should: where a session may dock (⌘T's anchor, the strip's +). * fix(desktop): preview tab selection follows the tree, not just the reverse openPreview drove tree reveals, but clicking a preview TAB only activated its pane in the tree — $rightRailActiveTabId kept naming the previous tab, so $previewTarget (⌘L quote labels, the titlebar's has-preview state) reported a tab that wasn't on screen. The mirror now also listens tree→store: when the interacted zone's active pane is a preview tile, the store selection follows. Both directions converge on the same id, so no ping-pong. * fix(desktop): session drags land in preview and page zones tileZoneHost replaces chatZonePane: a zone hosting any main tile (a Browser tile, a page) accepts stack and split drops — the known asymmetry where you could drag a preview tab out but never drag a session in. Only a CHAT zone's center is the link-to-composer drop; a preview zone's center stacks, since there's no composer to link to. * chore(desktop): drop the rail's dead multi-close verbs closeActiveRightRailTab / closeOtherRightRailTabs / closeRightRailTabsToRight lost their last callers when ⌘W and the close menu moved to the zone strip's shared rungs; the tests now exercise closeRightRailTab's own fallback behavior directly. * fix(desktop): open_preview lands whenever its session is on screen The preview.open handler honored the event only when its session was the FOCUSED one — but the turn that runs open_preview is usually a tile's session, and by the time the tool fires the user's last click has often parked focus on main (or anywhere else). The tool reported success, the store never wrote, and nothing appeared: an explicit 'open reddit' silently vanished. On-screen is the right bar: honor the open when the session is the primary chat or any open tile, which keeps truly invisible background sessions from yanking the pane (offer, don't hijack) without eating opens the user asked for. * fix(desktop): one Browser — a second URL navigates it, not a second tab Tabs were keyed url:<address>, so every distinct page the agent opened stacked another BROWSER tab — three opens, three Browsers, each titled identically because the tab deliberately names the surface, not the page. The title already said singleton; the key disagreed. URL targets now share one url:browser id: openPreview re-fronts the tab and swaps its target, and the pane rebuilds its webview against the new address. Files and artifacts keep per-identity tabs. Restored storage rekeys old per-address rows and keeps only the most recent. * test(desktop): expect client_capture in wake.start/status params The GUI now passes client_capture: true on wake.start, wake.status, and the post-voice re-arm; update the store and slash-handler tests to the new param shape. 123/123 pass locally. * fix(desktop): render remote PDFs in preview rail PDFs were classified as generic binary/text previews, rendering raw %PDF bytes locally and failing entirely for remote-only files. Classify PDFs as their own preview kind, load bytes through the existing local/remote filesystem bridge, convert them to revocable Blob URLs for Chromium's embedded viewer, migrate persisted pre-PDF tabs at restore, and retry restored previews when the active filesystem connection changes. Salvaged from #76008-era base onto current main: PDF classification now composes with the remote-HTML enrichment branch, and the persisted-tab migration runs before the One-Browser URL rekey in decodePreviewTabs. Supersedes #76565. Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh> * fmt(js): `npm run fix` on merge (#79505) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * feat(update): emit an action-scoped terminal receipt from hermes update The dashboard now mints an action_id per backend update, hands it to the spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight update action instead of spawning a duplicate. The updater prints a bounded `=== hermes-update completed <id> ===` receipt on every success path — normal, zip, dependency-repair, and the no-op "Already up to date!" path that previously ended with no terminal marker at all (#58764) — so the Desktop can prove completion across the dashboard restart boundary instead of guessing from stale log text. Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: doncazper <caztronics@yahoo.com> * fix(desktop): make remote backend updates terminal-state driven Remote backend updates failed with "Backend update failed." on nearly every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then read exit_code null off the still-running action and called it a failure. Real updates (backup + uv sync + npm install + vite build) routinely run longer, and the no-op "Already up to date" path never restarted the gateway so the old return-check timed out too. A still-running, reachable action is now never converted into failure by an elapsed budget — only a nonzero exit is. The apply loop keeps one in-flight promise, tolerates reconnects during the dashboard restart without extending the fixed six-minute deadline forever, and confirms success by the action-specific receipt that survives the restart, falling back to proving the requested commit / up-to-date check for older backends without action_id support. Inconclusive completion fails closed. Fixes #47359 Fixes #58764 Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com> Co-authored-by: Mark Vlcek <markvlcek@gmail.com> Co-authored-by: doncazper <caztronics@yahoo.com> * Hermes can read the in-app browser (#79482) * feat(agent): read_preview — the desktop-gated tool that reads the in-app browser The agent could open the preview pane (open_preview) and read the embedded terminal (read_terminal), but the browser it had just opened was a black box — 'what does this page say?' had no answer. read_preview mirrors read_terminal end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside the GUI), dispatched through the same agent callback pattern, windowed with start/count so a long page pages instead of flooding context. * feat(gateway): preview.read blocking bridge Same lifecycle as terminal.read: the tool blocks on preview.read.request, the renderer answers preview.read.respond (allow_expired — a slow page extraction losing the 45s race must not surface a raw 4009), and a timeout emits preview.read.expire so late answers resolve quietly. * feat(desktop): the renderer serializes the active preview tab for the agent preview-reader.ts is the preview analog of the terminal's buffer registry: the URL pane registers a page reader (webview executeJavaScript → title + visible innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows the text (24k cap per read), and answers file/artifact tabs with identity plus a note pointing at the tool that reads that content directly. The gateway event handler answers preview.read.request beside terminal.read.request. * fmt(js): `npm run fix` on merge (#79521) Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> * fix(dashboard): auto-reconnect the events WebSocket with backoff (supersedes #47876, #47921, #24315) (#79524) * fix(dashboard): add events-feed reconnect policy helpers Extract the reconnect arithmetic and close-code classification for the ChatSidebar /api/events socket into a pure module so both can be tested without a fake WebSocket or a mounted component. Two decisions live here rather than inline in the effect: - `shouldRetryEventsClose` — 1000 (normal) a…
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…nput-device fix(wake): keep desktop ownership and select input devices
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (NousResearch#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent.
randlee
pushed a commit
to randlee/hermes-agent
that referenced
this pull request
Aug 11, 2026
…reaming (NousResearch#79491) * feat(wake): client-capture wake word for remote desktop Remote headless backends have no PortAudio mic, so "hey hermes" fails even when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via wake.feed while detection stays server-side. - wake_word.capture: auto|local|client (+ GUI client_capture prefer) - WakeWordDetector external_audio queue + feed_audio API - wake.feed RPC; wake.start/status report capture + frame_length - Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice - Docs + unit tests (26 pass in tests/tools/test_wake_word.py) * fix(wake): address review on client-capture re-arm and feed queue - wake.status reports effective capture from the armed detector (client vs local), plus frame_length/sample_rate; GUI status probes prefer client - Gateway test doubles accept external_audio on start_listening - Desktop PCM feeder uses a bounded ordered queue instead of dropping frames while a wake.feed RPC is in flight - /wake on and status/re-arm paths pass client_capture so remote reattach works * fix(wake): auto capture keeps the backend mic when one exists With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (NousResearch#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent. * perf(desktop): coalesce wake.feed frames Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the ear is armed. Drain up to 4 queued frames into a single wake.feed payload (backend feed() already splits long buffers into engine frames) — ~3 RPCs/s steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not 0.5 s). * docs(config): document wake_word.capture in cli-config.yaml.example --------- Co-authored-by: Andrew <drew@kainotomic.com>
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…nput-device fix(wake): keep desktop ownership and select input devices
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (NousResearch#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent.
33hodl
pushed a commit
to 33hodl/hermes-agent
that referenced
this pull request
Aug 12, 2026
…reaming (NousResearch#79491) * feat(wake): client-capture wake word for remote desktop Remote headless backends have no PortAudio mic, so "hey hermes" fails even when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via wake.feed while detection stays server-side. - wake_word.capture: auto|local|client (+ GUI client_capture prefer) - WakeWordDetector external_audio queue + feed_audio API - wake.feed RPC; wake.start/status report capture + frame_length - Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice - Docs + unit tests (26 pass in tests/tools/test_wake_word.py) * fix(wake): address review on client-capture re-arm and feed queue - wake.status reports effective capture from the armed detector (client vs local), plus frame_length/sample_rate; GUI status probes prefer client - Gateway test doubles accept external_audio on start_listening - Desktop PCM feeder uses a bounded ordered queue instead of dropping frames while a wake.feed RPC is in flight - /wake on and status/re-arm paths pass client_capture so remote reattach works * fix(wake): auto capture keeps the backend mic when one exists With capture:auto the desktop always preferred client streaming, so a local desktop with a working backend mic silently switched from PortAudio to getUserMedia default-device — dropping wake_word.input_device selection (NousResearch#74363). A ready backend input now wins under auto; client capture is the fallback for a preferring surface on a mic-less backend, and capture:client still forces streaming. Also removes the dead auto branch (both arms returned local) and lets the client-feed test skip cleanly when numpy is absent. * perf(desktop): coalesce wake.feed frames Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the ear is armed. Drain up to 4 queued frames into a single wake.feed payload (backend feed() already splits long buffers into engine frames) — ~3 RPCs/s steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not 0.5 s). * docs(config): document wake_word.capture in cli-config.yaml.example --------- Co-authored-by: Andrew <drew@kainotomic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Fixes two wake-word failures exposed by Windows Desktop:
/wakewas not registered as a Desktop-owned command, so it could fall through toslash.execand create a separate CLI-owned listener instead of controlling the GUI listener.Desktop
/wake [on|off|status]now uses the existingwake.*RPCs and stays in the gateway process that owns the GUI microphone lease. The backend also accepts an optionalwake_word.input_devicePortAudio index or name, reports the resolved device and host API throughwake.status, and gives platform-specific guidance when an open stream remains silent.Related Issue
Discord support report: https://discord.com/channels/1053877538025386074/1532104941928317059
Type of Change
Changes Made
/wakeas a native action and route on/off/status throughwake.start,wake.stop, andwake.status, never the slash worker.wake_word.input_deviceto the merged config defaults and pass the selector directly to the backendsounddevice.InputStream.wake.status.How to Test
cd apps/desktop && npx vitest run src/lib/desktop-slash-commands.test.ts src/app/session/hooks/use-prompt-actions/index.test.tsx src/store/wake-word.test.ts(150 tests pass).cd apps/desktop && npm run typecheck(passes).compileallfor the changed Python source/tests and the input-selector/silence-hint smoke check (passes)./wake on,/wake status, bare/wake, and/wake offshould remain owned by the GUI gateway. On Windows, setwake_word.input_deviceto a working PortAudio input and confirm/wake statusnames that device and host API.Python pytest was not run locally because this workstation's current pytest path is intentionally disabled for resource-safety reasons. Full Python coverage is left to GitHub CI.
Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests passDocumentation & Housekeeping
docs/, docstrings) — or N/Acli-config.yaml.exampleif I added/changed config keys — or N/A (no matching example section)CONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — or N/AScreenshots / Logs
The support bundle showed Windows Desktop on Hermes
0.19.0 [bc747001]: the GUI gateway repeatedly reporteddisabled_for_surface, while a later Desktop/wakeinvocation started a separate listener and alternated betweenmic delivers only silenceandmic audio detected. The added tests exercise the corrected process-ownership path and the new input diagnostics without opening a real microphone.