Commit graph

515 commits

Author SHA1 Message Date
brooklyn!
9024835bf2
Merge pull request #69602 from NousResearch/bb/voice-interrupt-note
feat(voice): tell the model when the user interrupts its spoken reply
2026-07-22 18:13:19 -05:00
brooklyn!
0ac07fdafd
Merge pull request #69511 from NousResearch/bb/voice-streaming
feat(voice): streaming, conversational TTS with barge-in across all surfaces
2026-07-22 18:13:08 -05:00
Brooklyn Nicholson
05b3637d8b feat(voice): CLI + TUI tell the model when its spoken reply was cut
CLI: the VAD monitor's playback cut and the record-key interrupt both
mark the latch; the chat path prepends the note via the existing
_prepend_note_to_message channel. TUI: _tts_stream_stop() grows a
user_barge flag (False for /voice off — a mode change isn't an
interruption; no-op when speech already finished), the VAD monitor
marks on cut, prompt.submit accepts interrupted:true from clients, and
_run_prompt_submit pops the latch into the run message.
2026-07-22 17:53:06 -05:00
Brooklyn Nicholson
68e1fedd2d feat(voice): stream turn deltas through the TUI gateway, with barge-in
Turn deltas feed a per-turn TTS pipeline; the post-complete speak_text
call survives only as a fallback. session.interrupt, /voice toggles,
and new turns cut in-flight speech. VAD barge-in emits
voice.interrupted at detection, then the captured interruption goes out
as voice.transcript — the same event the TUI already submits as a
spoken turn.
2026-07-22 17:47:15 -05:00
ethernet
d84e11af4d
rip out brew + pip/PyPI wheel support (#68217)
Removes Homebrew and PyPI wheel/sdist as Hermes distribution paths while
preserving the supported source, Docker, and Nix workflows.

Changes:
- Removes the Homebrew formula, PyPI publish workflow, sdist manifest
  (MANIFEST.in), and wheel/sdist release-attachment logic from scripts/release.py.
- Keeps setuptools metadata and entry points required by editable installs
  and Docker/Nix builds, but adds a setup.py guard that rejects wheel/sdist
  builds outside a sealed Nix derivation (HERMES_NIX_BUILD=1).
- Removes pip/Homebrew install detection, PyPI update checks, the pip
  self-update path, the deprecation-banner state, the postinstall subcommand,
  wheel data-directory fallbacks in agent/i18n.py and hermes_constants.py,
  and the ACP Registry manifest/version-lockstep release logic.
- Adds /nix/store/ path detection so `nix run` / `nix profile install`
  installs (which don't set HERMES_MANAGED) are correctly identified as
  "nix" rather than falling through to "git"/"unknown".
- Retired install-method values ("pip", "homebrew") in existing
  .install_method stamps (both code-scoped and home-scoped) are ignored by
  the allowlist reader and fall through to "unknown" instead of resurrecting
  a retired enum value.
- Updates Nix packaging to ship bare runtime data (locales, optional-mcps)
  through store symlinks and wrapper env vars instead of wheel data-files.
- Removes the ACP Registry manifest/icon and their version-lockstep tests.
- Deletes or rewrites packaging, pip-update, Homebrew, and ACP Registry
  tests; adds parametrized coverage for the packaging build guard covering
  BOTH sdist and wheel paths (the guards live in separate cmdclass entries
  — a passing sdist test proves nothing about the wheel path).
- Updates installation/platform documentation and related user-facing copy.
- Adjusts the supply-chain scan so deleted install-hook files do not trigger
  a finding, while additions or modifications still require the existing
  ci-reviewed label gate.

Supported installation paths (unchanged):
- git installer (install.sh)
- Docker
- Nix/NixOS
- editable development installs (uv sync, uv pip install -e ., pip install -e .)
2026-07-22 16:51:01 -04:00
ethernet
41e00dcf87
fix(desktop): salvage /compress cluster — session.compress RPC + dedicated RPC routing (#68229)
* fix(desktop): route /compress through session.compress RPC with transcript replacement

Salvages #44462, #53755, and #68218 into a single canonical fix for the
desktop /compress cluster.

The desktop routed /compress through slash.exec, which sends it to the
_SlashWorker subprocess. Compressing a large session outlives both the
desktop's 30s WS timeout and the worker's 45s pipe timeout — the client
gives up, runExec's blanket catch swallows the error, and command.dispatch
surfaces a misleading "not a quick/plugin/skill command: compress" (#44456).
Even when compression succeeded via the _mirror_slash_side_effects path,
the desktop never received the post-compress message list, so summarized
bubbles stayed on screen forever — /compress looked like a no-op.

This change routes /compress to the dedicated session.compress RPC (the TUI's
path), combining the best of all three PRs:

- 120s client timeout matching the TUI's HERMES_TUI_RPC_TIMEOUT_MS (#44462)
- Transcript replacement from the response `messages` via toChatMessages,
  the same converter session.resume uses (#68218, teknium1 review on #44462)
- Session-isolation guard: updateSessionState only publishes for the active
  runtime, so a late result after a session switch can't clobber the
  foreground transcript (#53755, teknium1 review on #53755)
- Coalescing: dedup concurrent compress requests per session (#53755)
- Progress toast ("compressing context...") outside the transcript (#53755)
- Error unmasking in runExec: when slash.exec fails and command.dispatch only
  adds "not a quick/plugin/skill command" routing noise, surface the original
  worker error instead (#44462)
- /compact alias + focus_topic forwarding

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>

* feat(desktop): route slash commands with dedicated RPCs to those RPCs

Salvages #63513 — introduces a new `rpc` kind on DesktopCommandSurface so
commands with a first-class gateway @method handler bypass slash.exec /
command.dispatch entirely, and a `renderRpcResult` utility that shapes
each RPC's structured reply into readable transcript text.

Migrates 6 commands from exec() to rpc(...):
  /agents → agents.list
  /save   → session.save
  /status → session.status
  /steer  → session.steer
  /stop   → process.stop
  /usage  → session.usage

/compress stays as action('compress') — it needs transcript replacement
from the response `messages`, which the generic rpc path can't do (per
teknium1 review on #44462/#63513).

Also includes the json-rpc-gateway timeout message improvement: the error
now includes the configured timeout duration ("request timed out after 120s:
session.compress") so a user can tell whether the default 30s fired or a
per-call override.

Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>

* fix(desktop): preserve provider choice during config initialization

* fix(desktop): preserve slash command and host compression semantics

Keep commands whose CLI behavior exceeds their current RPC contracts on slash.exec.
Propagate the full compression timeout through compute-host control, return structured
host compression outcomes with metadata, and retain successful compression feedback
in the desktop transcript.

Add regressions for timeout forwarding, host aborts and metadata sync, structured host
control responses, command routing parity, and numeric stop counts.

* fix(desktop): harden compression state handling

Preserve the invoking stored-session binding for delayed compression results,
normalize replacement histories, and serialize provider selection. Stabilize
gateway platform tests and guard the desktop Git facade during renderer teardown.

---------

Co-authored-by: AlliDev <AIalliAI@users.noreply.github.com>
Co-authored-by: PinkEVO <PINKIIILQWQ@users.noreply.github.com>
Co-authored-by: Jelvin <SmallNew2003@users.noreply.github.com>
2026-07-22 16:40:06 -04:00
brooklyn!
e0b9ab5ac5
Merge pull request #69533 from NousResearch/bb/skin-live-broadcast
fix(themes): live skin sync reaches every surface — WS fan-out + missed-activation recovery
2026-07-22 14:17:38 -05:00
Brooklyn Nicholson
39f72e4a5e refactor(themes): DRY the event frame, type the transport registry, tighten comments
_emit and _broadcast_global_event were each building the JSON-RPC event
envelope — extract _event_frame and use it from both. Type the registry as
set[Transport] (protocol already imported), and cut comment bloat at the
call sites. No behavior change; suites stay green.
2026-07-22 14:07:18 -05:00
brooklyn!
bb24364f25
Merge pull request #63104 from NousResearch/bb/active-turn-steering
feat: redirect active turns when users correct the agent
2026-07-22 13:54:32 -05:00
Brooklyn Nicholson
3459461663 fix(themes): broadcast live skin.changed to WS surfaces (desktop/dashboard), not just stdio
The cross-surface theme SDK's live-repaint relies on a gateway skin watcher
that polls config and emits skin.changed on any move. But that emit is
session-less and fires from a background thread, so write_json fell through
its (session-transport -> contextvar -> stdio) ladder to the module stdio
transport — which only reaches the stdio TUI (tee'd to the dashboard WS
publisher). WS clients (the desktop app, dashboard chat) never got it, so
'Hermes themes itself' repainted the CLI/TUI but not the GUI.

Add a live-transport registry (one entry per connected WS peer, maintained by
handle_ws) and a _broadcast_global_event primitive that fans session-less
announcements out to every connected client, falling back to write_json when
none are registered (stdio path unchanged). Route both skin.changed emits
(watcher + the /skin RPC) through it, so a skin switch from any surface
repaints all of them.

Backend-only; desktop already handles skin.changed and does not drop
session-less events.
2026-07-22 12:50:18 -05:00
Brooklyn Nicholson
2b27c171ca fix(redirect): cover the build-window and post-reconnect correction races
Two narrow timing windows (reported by null-runner) silently downgraded a
mid-turn correction to a plain next-turn message on the desktop client:

- Turn-build window: a fresh turn flips running=True and builds the agent
  asynchronously, so session["agent"] is briefly None. session.redirect
  answered 4010 "unsupported", which the renderer's catch swallowed into a
  lost follow-up. Queue the correction server-side instead and return
  status="queued" — lossless, and honest about what happened.

- Stale runtime id after reconnect: session.redirect 404s on a sid the
  gateway no longer maps. redirectPrompt now resumes the stored session and
  retries once, mirroring stopPrompt, so a correction fired right after a
  reconnect isn't dropped.

The desktop treats "queued" like "redirected": the correction reaches the
model either way, so it's recorded once as a real user message.
2026-07-22 12:48:14 -05:00
Brooklyn Nicholson
70ba3c4828 feat(desktop): agent can focus panes + shared desktop-UI event bridge
Extract the open_preview emitter into a shared tools/desktop_ui bridge
(one gateway-injected sink, routed by HERMES_UI_SESSION_ID) and add a
second desktop-gated tool on top of it:

- focus_pane(chat|files|terminal|review|sessions) -> pane.reveal event.
  The desktop runs each pane's own reveal path (revealDesktopPane table)
  and only acts on the active window -- a background turn never moves the
  user's focus (desktop AGENTS.md: offer, don't hijack).

open_preview now emits through the same bridge. Both tools are check_fn
on HERMES_DESKTOP (zero footprint elsewhere), sitting beside
read_terminal/close_terminal in _HERMES_CORE_TOOLS.

Deliberately not adding run_slash: letting the agent fire slash commands
mid-turn (/model, /new, /clear) fights prompt-cache + conversation
invariants.
2026-07-22 12:13:01 -05:00
Brooklyn Nicholson
34d0de80e6 feat(surfaces): route busy-input corrections through active-turn redirect
The default `busy_input_mode: interrupt` now redirects the live turn instead
of hard-stopping it and re-queuing a fresh turn, wired consistently across
every first-party surface via the shared core primitive.

- CLI, gateway (busy + PRIORITY paths), TUI (`_handle_busy_submit`), desktop
  (`session.redirect` RPC), and ACP call `redirect()` when the agent advertises
  `_supports_active_turn_redirect`, and fall back to the proven interrupt +
  next-turn queue for older runtimes.
- Redirect is gated to plain text with no attachments: captioned or
  attachment-bearing events (including adapters that classify unknown media as
  `TEXT`) stay queued so media is never dropped.
- ACP `cancel()` records the interrupted prompt, sets its cancel event, and
  hard-stops the agent while holding `runtime_lock`, closing the
  cancel-then-correct ordering gap; connection I/O happens after the lock is
  released.
- Desktop appends the correction as a real user transcript message so the live
  view matches the durable history after reload.
- `/busy` help, onboarding hints, and the new `session.redirect` RPC describe
  the redirect behavior; `/stop` remains the hard stop.
2026-07-22 12:10:58 -05:00
Brooklyn Nicholson
f071f42244 feat(desktop): let the agent open the preview pane
Add a desktop-gated open_preview tool so 'open cnn.com in the preview
pane' works. The tool (check_fn on HERMES_DESKTOP, zero footprint
elsewhere) emits a preview.open event through a gateway-injected emitter,
mirroring the close_terminal -> terminal.close bridge. The desktop
handles it in usePreviewRouting, normalizing the target and opening the
pane for the active session only -- a background turn never hijacks it.

Bare domains and localhost are coaxed into fetchable URLs (www.cnn.com ->
https://, localhost:3000 -> http://); file paths and schemes pass through
to the renderer's normalizer.
2026-07-22 12:03:24 -05:00
Teknium
961b832c11 test(sessions): cover branch-seed and compression timestamp round-trips
Follow-up to the salvaged #28840 change: forward original timestamps in
_persist_branch_seed (TUI first-turn branch persist), add behavioral
round-trip tests for branch copy and compression-style replace, TUI
protocol tests asserting session.branch and _persist_branch_seed forward
timestamps, and register the contributor email mapping.
2026-07-22 06:58:27 -07:00
tangyi
9d73006ade fix: forward timestamp in CLI, gateway, and TUI branch copy loops 2026-07-22 06:58:27 -07:00
Teknium
1927b60775 fix(tui): extend deferred context-engine finalize to the compute-host compress routes
The salvaged deferred-finalize wiring (#65670) covered cli.py, the gateway
slash command, and the three in-process tui_gateway/server.py compress
sites — but the dashboard compute-host isolated session.compress /
slash.compress routes run the compress mirror inside the host child, where
a control-handler exception after compress_context() queued the boundary
notification would leak it: never fired on success paths, or worse, fired
by a LATER compress against a boundary the host had rejected.

- tui_gateway/compute_host.py: _handle_control discards any pending
  context-engine compression notification (committed=False) when a
  session.compress / slash.compress control frame errors. finalize is
  exactly-once, so this is a no-op when the mirror already emitted or
  discarded it.
- tui_gateway/server.py: _mirror_slash_side_effects normalizes the
  /compact alias onto the compress branch so isolated-session compacts
  actually compress and hit the same finalize/discard wiring instead of
  silently no-oping.
- tests/tui_gateway/test_compute_host_phase1.py: compute-host route tests —
  commit-then-notify ordering, discard on host commit failure, /compact
  alias routing.
2026-07-22 06:58:16 -07:00
3ASiC
d46f0fb2d5 fix(compression): notify context engine after commit 2026-07-22 06:58:16 -07:00
Teknium
15d33d5ab1 fix(desktop,tui,docs): dedup commands.catalog, route desktop /compact to /compress, update README
- tui_gateway commands.catalog: skip _TUI_EXTRA entries that collide with a
  registry command or alias (the /compact class of bug, #57133; also removes
  the pre-existing /sessions duplicate) — registry entry is canonical.
- apps/desktop: /compact now dispatches as an alias of /compress (matching
  the registry's canonical alias) instead of dead-ending; /density (the
  renamed TUI display toggle) is marked terminal-only so the desktop palette
  doesn't advertise a command its dispatcher can't run.
- ui-tui/README.md: document /density instead of the old /compact toggle.
- tests: commands.catalog regression test asserting no duplicate advertised
  names and no command shadowing another command's alias; desktop routing test.
2026-07-22 06:58:05 -07:00
liuhao1024
27a4e92802 fix(acp,tui): rename /compact to /compress and /density to resolve command collision
- acp_adapter/server.py: rename compact -> compress for context compression command
- tui_gateway/server.py: rename /compact -> /density for display density toggle
- ui-tui/core.ts: rename compact -> density for display density toggle
- Internal config keys (tui_compact) and UI state (ctx.ui.compact) unchanged
2026-07-22 06:58:05 -07:00
embw_l0x
9fed768b56
fix(desktop): scope model options by profile (#62795)
Co-authored-by: embwl0x <embwl0x@users.noreply.github.com>
2026-07-22 09:40:50 -04:00
brooklyn!
367810a942
Merge pull request #68857 from NousResearch/bb/theme-sdk
feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop, live
2026-07-21 21:23:44 -05:00
brooklyn!
8f51376db3
Merge pull request #69040 from NousResearch/bb/fix-verify-candidate-warm-payload
fix(tui_gateway): candidate-inclusive display on warm/live + child-watch resume (#65919 fallout)
2026-07-21 21:16:38 -05:00
Brooklyn Nicholson
77855ce1f8 fix(tui_gateway): candidate-inclusive display on child-watch resume + E2E
Complete the #65919 warm/live-payload fix across its sibling path and add
real-SessionDB cross-builder coverage.

- Child-watch (lazy) resume: the delegated-subagent watch window served
  _history_to_messages(repaired_history) for its user-visible messages, which
  collapses out persisted verification candidates just like the warm-payload
  path did. Build the visible messages from the verbatim child-only display
  projection (repair_alternation=False) while the repaired history still feeds
  live replay; fall back to the repaired history if the display read fails.

- E2E cross-builder consistency (real SessionDB, not mocks): a persisted
  verification candidate is collapsed out of the model projection but kept in
  the display projection, and _live_visible_history now equals the eager
  session.resume display projection (candidate present). Adds the combined
  candidate + fully-flushed-second-turn case and a lazy child-watch handler
  test that asserts the candidate survives in resp["result"]["messages"].
2026-07-21 21:05:05 -05:00
Brooklyn Nicholson
850b8da332 fix(tui_gateway): serve candidate-inclusive display on warm/live resume
#65919 persists verification candidates (finish_reason=verification_required
/ verify_hook_continue) to state.db but collapses them out of the in-memory
model history via repair_message_sequence. The eager session.resume + REST
paths read the verbatim display lineage (candidate present), but the
warm/live-reuse payload (_live_session_payload) built its user-visible
messages from the collapsed in-memory model history — so switching to a
still-live session dropped the substantive verification answer that a cold
resume of the SAME session showed. That divergence is the cross-session
"substantive text vanishes on switch" class, and the direct sibling of the
resume-duplication regression fixed in #68149.

Reconcile the persisted display lineage (candidate-inclusive, the same
get_messages_as_conversation(..., include_ancestors=True) read the eager
resume + REST paths use) with the fresh in-memory tail in
_live_visible_history, so all three surfaces agree by construction while a
not-yet-flushed live turn is still shown. Extracted
_reconcile_display_with_live as a pure, DI-testable function (anchors on the
last persisted row's (role, text); appends only the uncovered in-memory tail;
trusts the DB display when the tail can't be anchored).

Tests: unit coverage for candidate-inclusion, freshness, empty/raising-DB
fallback, and the combined candidate+fresh-tail case. The existing freshness
guard (test_session_resume_live_payload_uses_current_history_with_ancestors)
stays green.
2026-07-21 21:02:10 -05:00
Brooklyn Nicholson
eb454919b2 feat(themes): agent-authored skins switch live via a gateway skin watcher
A skin Hermes activates (`hermes config set display.skin X`) or recolors in
place now goes live on every surface (CLI, TUI, desktop) within ~half a
second, on its own — no `/skin`, no tool-hook timing, no user action.

A gateway daemon polls the resolved skin signature `(name, active-file mtime)`
every 0.5s and broadcasts `skin.changed` on any real move — a name switch OR a
live color edit to the active skin. It routes through the SAME path `/skin`
uses, so all surfaces repaint identically. The watcher seeds its baseline at
gateway.ready (stdio + ws) so it only fires on a real change; the `/skin` RPC
seeds the baseline too so it never double-broadcasts.

Subsumes the desktop's post-turn `config.get skin` poll (its skin.changed
handler already applies).
2026-07-21 21:00:43 -05:00
Brooklyn Nicholson
a8444fbcae feat(themes): cross-surface theme SDK — one skin themes CLI, TUI, and desktop
Make the Python skin engine the single source of truth for a canonical theme
shape consumed by every surface, so a skin authored in $HERMES_HOME/skins/*.yaml
(by a user or by Hermes from a prompt) themes the CLI, TUI, and desktop GUI at
once — the theme analogue of the plugin SDK.

- @hermes/shared: canonical `HermesSkin` token shape + `SKIN_COLOR_TOKENS` enum,
  consumed by both TS surfaces (TUI `GatewaySkin` and desktop dedup onto it).
- Desktop: `skinToDesktopTheme` resolver (skin → CSS-var palette, VS Code-style
  derive-from-seed) + `backend-sync` that registers backend skins into the theme
  registry (Appearance/Cmd-K/`/skin`) and applies on a real change. Seeds on
  gateway.ready (never stomps a persisted pick), applies on skin.changed and the
  post-turn `config.get skin` poll (catch-all for agent-edited config.yaml).
- TUI: `fromSkin` now maps the status bar + `background` keys it was dropping.
- Gateway: `config.get skin` also returns the full resolved palette (additive).
- Skill: `hermes-themes` teaches the agent to author + activate a skin.

Each surface keeps its own normalizing resolver (ansi for the TUI, CSS vars for
the desktop, prompt_toolkit/Rich for the CLI).
2026-07-21 21:00:42 -05:00
Brooklyn Nicholson
b11b5ece2e fix(tui): revision-aware reload.mcp — an ack now means the revision was LOADED
Review on #20379, finding 1 (High). Two ways an MCP config revision could
be silently acknowledged without ever being applied:

Client: the poll advanced its accepted mcp_rev BEFORE calling reload.mcp,
and quietRpc collapses failures to null — a reload that failed against a
temporarily broken server left the revision recorded as applied, and no
subsequent poll retried until an unrelated MCP edit. The handshake is now
syncMcpReload(): send the observed rev with the request, advance `accepted`
only when the server answers status=reloaded (to the server's loaded_rev,
falling back to the requested rev on older gateways), and re-compare on
EVERY poll tick — decoupled from mtime — so a transient failure heals on
the next tick. An in-flight guard stops the 5s poll from stacking requests
behind a slow reload.

Server: generation-only coalescing let a follower triggered by revision B
ack against revision A's registry when the config changed under a slow
leader. The leader now re-hashes the MCP-relevant config after discovery
and repeats until stable (bounded), records _mcp_reload_loaded_rev, and a
follower coalesces only when the revision it was asked to load matches —
otherwise it re-runs the full reload itself. Responses carry loaded_rev.

Deterministic tests for the exact failure sequences: failed reload → no
ack, no generation advance; A-then-B overlap → follower re-runs; matching
rev → coalesces; failed leader → follower re-runs; legacy no-rev callers
keep generation-only coalescing (thread ordering via an instrumented lock,
no sleeps). Client: 6 vitest cases on the ack/retry/in-flight contract.
2026-07-21 20:27:15 -05:00
brooklyn!
967e078ae4
fix(windows): share one bounded, tree-killing git probe across both call sites (#68997)
subprocess.run(["git", ...], timeout=...) deadlocks on Windows: run()'s
post-timeout cleanup calls an unbounded communicate() after killing git.
Killing the PATH-resolved launcher can leave a suspended descendant git.exe
holding duplicates of the captured stdout/stderr handles, so the pipes never
reach EOF and the reader-thread join blocks forever — leaking a process +
two reader threads per fired timeout (the accumulating git.exe load behind
Windows Defender CPU spikes).

Two fail-open probe call sites had this identical flaw:
  - tui_gateway/git_probe.py::run_git — on the Desktop agent-build path
    (_start_agent_build -> _session_info -> branch() -> run_git), where the
    hang turned an optional branch label into "agent initialization timed
    out" (#68609).
  - agent/coding_context.py::_git — hangs the agent turn inside
    build_coding_workspace_block under an ACP host (#66037).

Consolidate both onto one shared bounded_git_probe() in
hermes_cli/_subprocess_compat.py (both files already import from there, so
no new import surface):
  - explicit communicate(timeout), then on ANY failure a tree-kill —
    proc.kill() AND, on Windows, best-effort taskkill /T /F so the suspended
    descendant that holds the pipe writers dies too — plus a bounded 1s
    post-kill drain; if the pipes are still held they're abandoned (the
    orphaned reader threads are daemonic and cost nothing).
  - fail open to "" on every path: spawn error, timeout, kill() raising
    (access denied / already reaped — a raise inside the except handler
    previously escaped the contract), and non-timeout communicate() failures
    now also terminate the child instead of leaving it running.
  - the taskkill spawn can't re-enter the deadlock class: it captures no
    pipes (DEVNULL), so its own timeout cleanup has no reader threads to join.

Normal-path spawn contract is preserved byte-for-byte: PIPE/PIPE/DEVNULL,
text + utf-8 errors="replace", hidden-window creationflags on Windows only,
nonzero returncode -> "". Each call site keeps its own timeout (1.5s / 2.5s).

Supersedes #68622 (Sora-bluesky — git_probe fix + tree-kill) and #66038
(iamwongeeeee — coding_context fix), folding both into one shared helper so
the two sites can't drift and every timeout tree-kills the descendant. Tests
consolidated onto the helper, incl. the previously-missing assertion that a
Windows timeout escalates to taskkill /T /F.

Co-authored-by: Sora-bluesky <sora.bluesky.dev@gmail.com>
Co-authored-by: iamwongeeeee <wykim777@naver.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 20:18:43 -05:00
Brooklyn Nicholson
6982c61b8c fix(tui): mcp_rev includes mcp_servers; reload survives leader failure; /theme persists first (Bugbot r2)
- mcp_rev hash now covers `mcp_servers` (the server DEFINITIONS the classic
  CLI watches) alongside `mcp`/`tools` — editing a server previously bumped
  mtime but not mcp_rev, so the TUI skipped reload.mcp and new servers never
  connected until a manual /reload-mcp.
- reload.mcp coalescing survives a failed leader: a completed-generation
  counter (bumped only after a successful shutdown+discover) gates the
  follower. If the leader threw (flapping server) the follower re-runs the
  full reload itself instead of returning a bogus success over an empty
  registry.
- /theme applies AFTER config.set confirms (mirrors /indicator) with a
  guardedErr catch — a failed persist no longer leaves the session showing a
  theme that reverts on restart.

384 tui_gateway + 1246 TS tests, lint, build green.
2026-07-21 16:36:58 -05:00
Brooklyn Nicholson
fdac23b641 fix(tui): reload.mcp lock scope + coalesced refresh + macOS polarity flip (Bugbot)
Three real findings from the review of the reload.mcp pooling + OSC-10 work:

- Lock released too early: the leader now holds _mcp_reload_lock across
  shutdown+discover AND its own agent refresh — releasing after discover let
  a second reload tear the registry down while the first was still reading it
  to rebuild the session's tool snapshot.
- Coalesced reload skipped the agent refresh: a follower returned
  "reloaded" without rebuilding ITS OWN session's snapshot, so a coalesced
  session kept stale tools. Followers now wait, then refresh their agent
  against the freshly-built registry (under the lock, skipping the redundant
  shutdown/discover). Shared _finish_reload tail for the `always` opt-out.
- macOS AppleInterfaceStyle fallback never set `resolved`, so a late OSC-10
  foreground reply could re-flip the committed inference (visible churn). It
  now marks resolved; a real OSC-11 background measurement still corrects it
  (that listener intentionally doesn't gate on resolved — measurement beats
  inference).

Bugbot's other four findings were diff-truncation false positives (color.ts,
themeBoot.ts, and the mcp_rev/light_colors/dark_colors types all exist;
typecheck + build green). 384 tui_gateway + TS suites pass.
2026-07-21 16:36:58 -05:00
Brooklyn Nicholson
296303302d perf(tui): skin switches stay hot — MCP gating, pooled reload, swap repaint
Four responsiveness/hardening fixes surfaced by rapid /skin switching:
config.get mtime now carries an mcp_rev hash so the TUI reloads MCP only
when MCP-relevant config changed (cosmetic /skin writes cost seconds of
reconnects before); reload.mcp runs on the RPC pool serialized by a lock
(inline it froze the stdio reader for the duration of a flapping server's
retry loop — config.set/complete.slash sat unread and the TUI appeared
dead); theme swaps schedule one full repaint (incremental diffs after a
recolor tear — stale cells keep the old palette, read as "shadows"); and
OSC-11 pure-black answers are distrusted universally (unset-default
fingerprint on xterm.js hosts and tmux). Parent EIO zombie fixed: a dead
PTY made every render write throw once a second forever; exit after 5
consecutive dead-stream errors.
2026-07-21 16:36:57 -05:00
Brooklyn Nicholson
cd05498e2c feat(ui-tui): background-aware theme adaptation + paired palettes + /theme pin
OSC-11 asks the terminal for its actual background at startup (env
heuristics are blind on xterm.js hosts) and the theme re-derives against
the answer: desktop-contract adaptation (contrast floors + fill polarity),
a shared list-row selection primitive instead of per-picker panel fills,
paired light_colors/dark_colors skin blocks with a machine audit, and a
/theme auto|light|dark pin (display.tui_theme) for hosts whose probe lies.
E2E coverage for the OSC reply chain + /theme-info diagnostics.
2026-07-21 16:36:57 -05:00
brooklyn!
413ed6b9df
Merge pull request #68860 from NousResearch/bb/battery-status
feat(status-bar): add /battery toggle for a color-coded battery read-out
2026-07-21 15:33:21 -05:00
Brooklyn Nicholson
3c2903a2fb fix(status-bar): address Copilot review on /battery
- TUI /battery matches the CLI surface: adds `status` (live reading via
  system.battery), and the help/usage strings now consistently read
  [on|off|status].
- batteryLabel() renders `--` for an unknown percent so a null can never
  surface as "null%" even without the showBattery guard.
- Move the system.battery RPC out of the config section into
  "Methods: tools & system" where system.* RPCs belong.
2026-07-21 14:47:49 -05:00
Teknium
f218474552 fix(tui): gate the shared-owner MCP discovery wait on the stdio TUI flag
The TUI retry-allowance follow-up made tui_gateway.entry.wait_for_mcp_discovery
delegate to hermes_cli.mcp_startup unconditionally when no entry-local thread
exists. But server._make_agent already calls the startup wait directly for
dashboard /api/ws sessions, so every non-stdio agent build paid the bounded
wait twice (caught by test_make_agent_waits_for_shared_mcp_discovery). Gate
the retry-spawn AND the delegated wait on _mcp_discovery_enabled, which only
the stdio TUI arms in main().
2026-07-21 12:43:27 -07:00
Teknium
c54b568a38 fix(tui): give the stdio TUI discovery a retry-after-zero-connected path
Builds on fazerluga-creator's #66981 (cherry-picked as the previous two
commits). start_background_mcp_discovery()'s retry allowance only fires
when the function is CALLED again, but tui_gateway/entry.py main() calls
it exactly once at startup — so a first discovery run that connected
nothing still latched the stdio TUI MCP-less for the whole session.

Re-invoke the idempotent spawn from wait_for_mcp_discovery() (the
per-agent-build wait) when the process is MCP-enabled, gated on a flag
set in main() so non-MCP sessions never pay the MCP import on the wait
path. Adds regression tests for both the retry re-invocation and the
non-MCP skip.
2026-07-21 12:43:27 -07:00
fazerluga-creator
8b6e92acf5 fix(tui): centralize stdio TUI MCP discovery on the shared owner
Review follow-up: the stdio hermes --tui path spawned its own one-shot
discovery thread, so the retry-after-zero-connected semantics added to
start_background_mcp_discovery() did not cover it.

Spawn TUI discovery through the shared owner and make the entry-side
wait_for_mcp_discovery() fall through to the shared owner when no local
thread exists (mcp_discovery_in_flight/join_mcp_discovery already consult
both owners). Keeps the cheap no-mcp-servers config guard on the TUI path.

Adds regression tests for the entry-side wait delegation.
2026-07-21 12:43:27 -07:00
Brooklyn Nicholson
625687f334 feat(status-bar): add /battery toggle for a color-coded battery read-out
Add an opt-in battery indicator to the CLI and TUI status bars, shown as
the first element and colour-coded by charge (green/yellow/orange/red, or
green while charging). Off by default and a no-op on machines without a
battery.

- agent/battery.py: shared psutil-backed reader with a short TTL cache,
  category bucketing, and a compact 🔋/ label. Fails open to
  "unavailable" everywhere.
- CLI: /battery [on|off|status] toggle persisted to display.battery,
  rendered first in every status-bar width tier.
- TUI: /battery slash command, config sync, a system.battery RPC polled
  while enabled, and a pinned first segment in StatusRule.
2026-07-21 13:54:11 -05:00
Austin Pickett
d355e0e71d
feat(desktop): configure repository discovery (supersedes #67630) (#68642)
* feat(desktop): configure repository discovery

* fix(config): preserve additive default migration

* fix(desktop): stabilize session-actions-menu gateway mock for repo-scan subscribe

projects.ts now runs $gateway.subscribe(syncReposScanning) at module load, and
nanostores fires the subscriber synchronously. session-actions-menu.test.ts
reaches projects.ts transitively via the session store but mocked
@/store/gateway without $gateway, crashing the whole desktop vitest suite
("No \ export is defined"). Simply adding $gateway: atom(null) exposed a
second issue: the synchronous subscriber calls the mock's activeGateway()
during the transitive import, before the module-level const initializes (TDZ).

Hoist the mock fns via vi.hoisted() so activeGateway is defined before the
hoisted vi.mock factory runs, and add $gateway: atom(null) to the mock. Mirrors
the self-contained mock pattern already used in projects.test.ts. Also maps the
PR author's commit email for attribution.

Supersedes #67630; incorporates review feedback from that PR.

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>

---------

Co-authored-by: Rudimar Ronsoni <rudimar@outlook.com>
Co-authored-by: Austin Pickett <austinpickett@users.noreply.github.com>
2026-07-21 10:09:31 -04:00
Siddharth Balyan
b0da653ac8
fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355)
* fix(billing): rename user-facing "terminal billing" copy to Remote Spending

The capability was renamed Remote Spending on the portal (consent CTA:
"Allow Remote Spending"; per-terminal states Granted/Stopped), but the
terminal, desktop, and docs still said "terminal billing" everywhere.

- Feature name: Remote Spending in titles/labels, lowercase mid-sentence.
- Step-up action verb is now "allow", matching the portal consent CTA.
- Kill-switch-off recovery copy points at the actual control ("a billing
  admin can turn it on from the portal's Hermes Agent page") instead of
  the dead-end "manage it on the portal".
- Per-terminal revoke copy uses the portal vocabulary ("stopped").
- Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are
  unchanged; copy, comments, docs, and test expectations only.

* fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename

Adversarial review findings: (1) a repeated insufficient_scope after a
successful step-up is a per-terminal authorization failure, but the copy
blamed the org kill-switch and pointed at the wrong recovery control —
now: "Remote Spending still isn't active for this terminal — the
authorization didn't take. Retry, or make this change on the portal."
(2) the desktop step-up flow started in Remote Spending vocabulary but
finished in "billing management access" — renamed both end states.
(3) prettier formatting on the touched files (matches the post-merge
fmt bot).
2026-07-21 12:20:25 +05:30
ethernet
3133af8215
fix(desktop): prevent duplicate messages when verification candidates are persisted (#68149)
The display_history_prefix calculation used by session.resume's
_live_session_payload was display_history[:len(display) - len(raw)].
This assumed the model (repaired) history is always a suffix of the
display history — i.e., repair_message_sequence only removes messages
from the tail. That assumption broke when verification candidates
(finish_reason=verification_required) were persisted to state.db (#65919):

  - repair collapses consecutive assistant messages, removing the
    verification candidate from the MODEL history
  - the candidate stays in the DISPLAY history (it's real persisted content)
  - the length gap (gap = len(display) - len(raw)) counts BOTH ancestor
    messages AND repair-removed tip messages
  - the prefix = display[:gap] grabs the first N display messages, which
    are tip messages (not ancestors) when there are no compression ancestors
  - _live_session_payload concatenates prefix + model_history, duplicating
    the first N messages

On session 20260720_110036_a33889 (8 verification candidates), this
duplicated the first 8 messages in every warm-cache session.activate
response, producing visible duplicate user messages in the desktop.

Fix: add SessionDB.get_ancestor_display_prefix() which returns ONLY
genuine ancestor messages (rows where session_id != tip_session_id),
identified at the row level before _rows_to_conversation strips
session_id. Both resume paths (eager + deferred) now use this instead
of the length-slice heuristic.

Tests:
  - test_get_ancestor_display_prefix_single_session_returns_empty
  - test_get_ancestor_display_prefix_returns_ancestor_only_messages
  - Updated 12 mock DBs across test_protocol.py + test_tui_gateway_server.py
  - 848 passed (run_tests.sh), 0 regressions
2026-07-20 17:14:41 +00:00
ethernet
c363db81e0
fix(desktop, ink): don't wipe messages before final message (#65919)
* fix(desktop): preserve interim assistant text wiped at message.complete

When the agent emits interim text (commentary alongside tool calls, or the
attempted final answer before a verify-on-stop nudge), all UI surfaces
streamed it live but then wiped it at message.complete — keeping only the
final response. The user saw text appear during inference, then disappear.

This is the complete fix across all three layers: agent core, gateway
transport, and all UI surfaces (desktop + Ink TUI).

The verify-on-stop and pre_verify paths flagged the assistant's attempted
final answer as _verification_stop_synthetic, suppressing it from both
state.db and the UI. The user only saw the terse post-verification reply.

Now the assistant response is real content: it's persisted to state.db and
emitted as an interim message via _emit_interim_assistant_message(force_display=True)
before the verification loop runs. Only the synthetic nudge messages keep
the synthetic flags. The turn finalizer drops nudges from live history and
compares content (not just role) to avoid duplicating a published candidate.
Message sequence repair collapses verification candidates in the
consecutive-assistant merge.

Wire agent.interim_assistant_callback both at construction (_agent_cbs())
and per-turn (defense-in-depth), emitting a new message.interim event with
{text, already_streamed}. Gated on display.interim_assistant_messages
(default true). Cleared in the finally block so a stale closure can't
fire on a later turn.

Add message.interim to the GatewayEventName union (apps/shared) and a
typed payload to the TUI's GatewayEvent discriminated union.

The TUI already had the segment-anchoring machinery (flushStreamingSegment +
finalTail) but had no handler for message.interim. Added recordInterimMessage
+ interimBoundaryIndex to seal segments mid-turn, and updated
recordMessageComplete to only dedupe segments after the interim boundary.

Replaced the fragile sealed-set approach with a proper interimBoundaryPending
state flag on ClientSessionState. finalizeInterimAssistantMessage finalizes
the streaming bubble in place (or creates a standalone one), rotates the
stream ID so next deltas create a new bubble, and sets the flag. When the
final text equals an already-sealed interim, they stay as distinct messages.

Extracted mergeFinalAssistantText() as a pure function in chat-messages.ts,
used by both completeAssistantMessage and finalizeInterimAssistantMessage.
Split the bidirectional dedup predicate: reasoning is a restatement only when
the final FULLY covers it. A short final ("Done.") no longer swallows a
longer reasoning block that merely starts with it.

Honor display.interim_assistant_messages (default true) across all layers:
the tui_gateway gates the callback, the desktop wires it to a nanostores
atom via use-hermes-config. Updated hermes_cli/config.py and
cli-config.yaml.example comments to document the Desktop behavior.

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from #53553
is not included — our emit-interim + persist approach makes it unnecessary
since the attempted answer is now surfaced before the verification loop.)

- tsc: clean (desktop + TUI + shared)
- vitest desktop: 73/73 pass (7 interim-sealing + 5 mergeFinalAssistantText + 4 config atom)
- vitest TUI: 83/83 pass (4 new message.interim tests)
- python: 390 tests pass (340 tui_gateway + 33 verification/finalizer + 6 config gating + 3 evidence + 8 continuation budget)

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>

* fix: prefix-match interim streamed content to avoid benign duplicate bubbles

_interim_content_was_streamed used exact equality (streamed == visible_content),
so a final response that was the streamed text plus a trailing delta — or a
partial stream before the verify nudge fired — failed the match and left
_response_was_previewed false. The turn then showed two bubbles (interim +
identical final) instead of settling the interim in place.

Relax to a prefix check (visible_content.startswith(streamed)) in both the
core match and the desktop's settle-in-place gate. The TUI already used
prefix matching via finalTail. The reverse direction (streamed longer than
final) is intentionally not matched — that could suppress a needed resend
in the gateway path where already_streamed=True calls on_segment_break().

* test(desktop): add partial-stream-then-nudge dedup edge case

Third edge case for the interim-sealing dedup: model streams part of its
answer via message.delta, verify nudge fires, interim seals the streamed
prefix, then the final response is the same text plus a trailing delta.
Asserts one bubble (not two) containing the full final text.

Acceptance protocol #2 — covers all three dedup edges:
  1. interim == final (existing)
  2. interim = strict prefix of final (existing)
  3. partial-stream-then-nudge (this commit)

---------

Co-authored-by: Liam Zhang <yingliang-zhang@users.noreply.github.com>
Co-authored-by: Lucas D'Alessandro <lucasfdale@users.noreply.github.com>
Co-authored-by: Eric Manganaro <superposition@users.noreply.github.com>
Co-authored-by: sweetcornna <sweetcornna@users.noreply.github.com>
Co-authored-by: DECK6 <DECK6@users.noreply.github.com>
Co-authored-by: matantsevs <matantsevs@users.noreply.github.com>
Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
2026-07-20 11:42:29 -04:00
UnathiCodex
8f33e39682
fix(desktop): prevent false runtime-not-ready under gateway load (#66174)
* fix(desktop): stabilize runtime readiness polling

* fix(tui_gateway): pool live-session status polling

* fix(desktop): clear readiness when gateway disconnects
2026-07-20 10:51:27 -04:00
Teknium
47fb20c0bd
fix: session-scoped /fast + full /new reset to config defaults (#67979)
* fix(fast): default /fast to session scope on CLI and gateway

Completes the session-first policy from #67946 for the /fast toggle
(the remaining half of #54084). A bare /fast fast|normal now applies to
the current session only; --global persists agent.service_tier to
config.yaml.

Gateway: new _session_service_tier_overrides dict (registered in
_CONVERSATION_SCOPED_STATE so /new clears it) resolved at both agent
turn sites via _resolve_session_service_tier(); the /fast handler and
its choice picker apply session overrides and evict the cached agent.
The TUI config.set fast path was already session-scoped.

CLI: /fast parses --global (parity with /reasoning); bare toggles
mutate self.service_tier only.

* fix(sessions): /new resets model, reasoning, and fast to config defaults

/new and /reset are full conversation boundaries: session-scoped
runtime overrides do not carry into the next session (#48055, #23131).

CLI new_session(): clears the one-turn model restore, re-derives
service_tier from config, and — when the session's model differs from
the config default — switches back via the shared switch_model()
pipeline (live agent swap included; best-effort so an unreachable
default never blocks /new).

TUI _reset_session_agent(): stops forwarding model_override /
create_reasoning_override / create_service_tier_override into the
rebuilt agent and pops the pins so later rebuilds can't resurrect
them. The gateway already cleared its per-session overrides via
_clear_conversation_scope on /new.

Cross-session contamination stays impossible: nothing here touches
process-global env or other sessions' pins.
2026-07-20 03:27:22 -07:00
YAMAGUCHI Seiji
8590c2d0d9 feat(cli): make reasoning effort session-scoped 2026-07-20 02:22:22 -07:00
liuhao1024
0d6d73525d fix(model): default --provider switches to session-only persistence
When /model is called with --provider but without --global or --session,
the switch now defaults to session-only instead of persisting to
config.yaml. Provider switches are typically exploratory — the user is
trying a different backend for this conversation, not reconfiguring the
default. --global can still force persist when desired.

This addresses a regression from fad4b40d9 where /model switched to
persist-by-default, causing /model xxx --provider xxx to overwrite the
global config when the user only intended a temporary switch.

Fixes #58290
2026-07-20 02:22:22 -07:00
kshitijk4poor
113d9f63b5 fix: Windows guard, dedup recovery, profile-safe paths, clear SO_RCVTIMEO
Follow-up fixes for salvaged PR #67686 (issue #67639):

1. Windows regression: bare 'import fcntl' at module level in entry.py and
   slash_worker.py would crash on Windows (fcntl is POSIX-only). entry.py
   explicitly aims to 'import cleanly on Windows'. Extracted all fcntl/socket
   logic to tui_gateway/_stdin_recovery.py with try/except import guards.

2. fd leak: socket.fromfd() dups fd 0; s.detach() returned the fd number
   without closing it, leaking one fd per recovery call. Changed to s.close()
   (safe — fromfd duped the fd, closing won't close stdin).

3. Code duplication: ~80 lines of recovery loop + diagnostic were copy-pasted
   between entry.py and slash_worker.py. Extracted to shared
   tui_gateway/_stdin_recovery.py (handle_spurious_eof + diagnose_stdin_state).

4. Profile-unsafe path: scanner used Path.home() / '.hermes' / 'plugins' but
   canonical plugin discovery uses get_hermes_home() / 'plugins'. With
   HERMES_HOME pointing elsewhere, scanner missed the actual plugin dir.
   Also gated project plugins behind HERMES_ENABLE_PROJECT_PLUGINS to match
   hermes_cli/plugins.py.

5. SO_RCVTIMEO not cleared: recovery only called os.set_blocking(0, True)
   but a child that set SO_RCVTIMEO would cause the next readline to time
   out and loop. Now clears SO_RCVTIMEO alongside O_NONBLOCK.
2026-07-20 12:48:50 +05:30
x7peeps
01c02c6f8f fix(tui): recover from spurious stdin EOF caused by child O_NONBLOCK flip
Fix #67639

根因分析:
子进程继承 fd 0 (stdin) 后设置 O_NONBLOCK 标志时,该标志作用于共享的
open file description 而非单个文件描述符。这导致 gateway 的下一次 read()
返回 EAGAIN,CPython 的缓冲层将其转换为 b'',表现为 EOF,gateway 因此
意外退出。这不是真正的 TUI 关闭管道,而是子进程修改了共享文件状态。

修复涉及三个 Gap:

Gap 1 — check_subprocess_stdin.py 扫描范围不足:
- 原正则仅匹配 subprocess.run/Popen,扩展到 call/check_output/check_call/
  os.system/asyncio.create_subprocess_exec/shell
- 新增扫描 ~/.hermes/plugins/ 和 ./.hermes/plugins/ 用户插件目录
  (hermes_cli/plugins.py:10-12 定义的插件加载路径)

Gap 2 — Gateway 无自愈能力:
- entry.py 和 slash_worker.py 的 stdin 循环改为 while True + readline()
  模式
- 当 readline() 返回空字符串时,检查 O_NONBLOCK 标志判断是否为虚假 EOF
- 虚假 EOF → 恢复 blocking 模式并继续; 真实 EOF → 正常退出
- 添加恢复频率限制 (10次/分钟),防止无限循环
- 添加 _diagnose_stdin_state() 诊断函数,记录 O_NONBLOCK/SO_RCVTIMEO 状态

Gap 3 — 日志消息误导:
- 原 "stdin EOF (TUI closed the command pipe)" 改为 "stdin EOF (peer closed)"
  或 "stdin spurious EOF (subprocess O_NONBLOCK flip)",附带诊断信息

附带修复: compute_host.py 的 subprocess.check_output 缺少 stdin= 参数
2026-07-20 12:48:50 +05:30
Austin Pickett
33d71d687f
fix(desktop): preserve new-chat selector choices (#67729)
Salvaged and rebased from #66354 by @UnathiCodex onto current main.

Fixes a fresh-chat race in Hermes Desktop where a model, reasoning-effort,
or Fast selection made before the first Send could be replaced by an
in-flight profile refresh, or read only after the profile handshake
yielded. Send is now the linearization point: the visible selector state is
snapshotted before awaiting profile readiness, and intent-generation guards
make older config/model responses stand down after a picker/toggle action.
Adds the contract-v4 session-create wire contract for explicit Fast=false.

Conflict resolution vs the original branch (use-model-controls.ts / .test.tsx):
combined main's catalog-aware keepManualPick() sticky-pick logic with the
PR's profileRefreshEpoch + composerSelectionGeneration staleness guards so
both a removed-from-catalog reseed and the in-flight-picker race are handled.

Verified on current main: apps/desktop tsc --noEmit clean; 80 affected
UI/store tests pass (use-model-controls, use-hermes-config,
use-session-actions, model-edit-submenu, model-presets, updates).

Co-authored-by: UnathiCodex <theunathi@gmail.com>
2026-07-19 19:31:34 -04:00