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.
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.
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 .)
* 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>
_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.
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.
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.
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.
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.
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.
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.
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.
- 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.
- 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
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"].
#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.
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).
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).
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.
- 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.
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.
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.
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.
- 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.
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.
* 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>
* 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).
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
* 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>
* fix(desktop): stabilize runtime readiness polling
* fix(tui_gateway): pool live-session status polling
* fix(desktop): clear readiness when gateway disconnects
* 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.
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
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>
* fix(env): recognize export-prefixed .env lines in save/remove (#40041)
load_env() parses bash-compatible 'export KEY=value' lines (#6659), so a
hand-added 'export GITHUB_TOKEN=ghp_...' shows as set (green light) in the
desktop Tools & Keys page. But save_env_value/remove_env_value only matched
plain 'KEY=' lines:
- DELETE /api/env 404'd ('not found in .env') — the token could not be
removed through the UI
- PUT /api/env appended a SECOND line; a later delete removed the new line
while the export line silently resurrected the old value
Both writers now match assignments through a shared _env_line_defines_key()
helper that understands the export prefix. Commented-out lines are still
ignored.
Regression tests drive the real dashboard endpoint handlers against a temp
HERMES_HOME with runtime-constructed classic-PAT-shaped fixtures, covering
save-does-not-500, export-line remove, export-line replace-without-duplicate,
and the plain-line path staying intact.
Fixes#40041
* fix(credentials): unify provider key delete/update across .env, auth.json, config.yaml (#51071#59761#62269)
A provider API key can live in three stores at once: ~/.hermes/.env,
auth.json credential_pool (env-seeded 'env:<VAR>' entries persisted by the
pool loader), and config.yaml mirrors (model.api_key, auxiliary.*.api_key,
custom_providers[*].api_key). The desktop/dashboard endpoints and the TUI
gateway RPCs only ever mutated .env, so the stores diverged:
- #51071/#59761: DELETE /api/env removed the key from .env but left the
credential_pool entry (the loader is additive-only and never prunes),
so the provider kept appearing in the model picker — surviving restart
via the stale pool entry + provider_models_cache.json row.
- #62269: PUT /api/env rewrote .env but left the OLD key in config.yaml
(model.api_key wins over env at client construction), producing 401s
with a key the UI no longer showed.
New hermes_cli/credential_lifecycle.py is the single choke point:
- remove_provider_env_credential(): clears the .env entry, prunes
env:<VAR> pool entries across ALL providers (a shared var like
GITHUB_TOKEN can seed several), suppresses the env source so a lingering
shell export can't re-seed it (matching 'hermes auth remove' semantics),
drops the affected providers' model-cache rows, and scrubs value-matched
config.yaml api_key mirrors. Returns 'found' spanning every store so a
stale pool-only entry is cleanable through the same delete button.
- save_provider_env_credential(): writes .env, rotates any config.yaml
mirror that held the PREVIOUS value (value-matched — an unrelated inline
key is untouched), and lifts a prior env-source suppression so re-adding
behaves like 'hermes auth add'.
OAuth preservation: only entries with source == 'env:<VAR>' are pruned.
OAuth/device-code/manual/borrowed pool entries and providers.<id> OAuth
token blocks are never touched by a key-only delete. (model.disconnect in
the TUI gateway still clears OAuth via clear_provider_auth — that surface
is a full provider disconnect, which is the documented intent there.)
Rerouted call sites: PUT/DELETE /api/env (dashboard + desktop),
tui_gateway model.save_key / model.disconnect, save_env_value_secure
(TUI/gateway secret capture), and hermes config set/unset for env-shaped
keys.
E2E tests drive the real endpoint handlers against temp-HERMES_HOME
fixtures (.env + auth.json + config.yaml with runtime-constructed fake
keys) and assert cross-store consistency after delete/update, pool-reload
survival ('restart'), OAuth preservation, models-cache invalidation, and
the suppress/unsuppress round-trip.
Fixes#51071Fixes#59761Fixes#62269
Surface the session's first-class Project in both chat surfaces: the
Desktop status bar (project name as the workspace label, full cwd in the
tooltip) and the TUI status label + /status output.
One source of truth. The per-profile projects.db is the authority, read
in tui_gateway via _project_info_for_cwd (backed by
projects_db.project_for_path) and threaded through every session.info
emission path the TUI consumes. The Desktop already caches that truth in
$projectTree, so it DERIVES the label from it (projectNameForCwd) instead
of carrying a second per-session $currentProject atom fed from
session.info.
That drops the parallel state #64721 introduced and the entire
reset/reconciliation surface it required (resume, agentless cwd.set,
gateway-switch, fresh-draft): the label is purely derived, so it stays
correct whenever the cwd or the project tree changes. Only explicit,
named projects resolve on both surfaces, so an auto-discovered repo root
keeps the cwd-leaf label everywhere.
Excludes the unrelated markdown shell-fence change bundled in #64721.
session.resume built two projections of the same lineage with two separate
get_messages_as_conversation calls — the model-fed copy (tip rows, alternation-
repaired) and the display copy (full lineage, verbatim). The display fetch
already reads a superset of the model fetch (the tip rows are part of the
lineage), so the second call re-scanned the same messages.
Add SessionDB.get_resume_conversations(): one lineage SELECT, split into the tip
(model) and full (display) projections in memory via the extracted
_rows_to_conversation helper. Byte-identical to the two separate reads
(test_get_resume_conversations_matches_separate_reads covers a lineage with a
dangling tool-call tail so repair diverges the two lengths, a single session,
and replayed-user dedup). Both session.resume build paths (deferred + eager) now
use it; the subagent single-read path is unchanged.
From the Desktop performance audit (P1: "Remove repeated resume transcript
work") — the gateway half. The renderer's concurrent REST prefetch is left as-is
(the audit flags dropping it as measure-first).
Fixes the two review defects that kept PR #29923 open, plus docs:
- gateway: exclude --once from the session-store write-through. The
once-override lived only in memory before, but the write-through
persisted it, so a gateway restart before the finally-restore
rehydrated a supposedly one-turn model permanently.
- TUI: skip _sync_agent_model_with_config while a one-turn restore is
pending. The once-model is deliberately not pinned as a session
model_override, so the config sync saw a model mismatch and clobbered
the once-override back to the config model before the turn ran.
- tests: real _handle_model_command drive asserting --once never
touches set_model_override while --session still does; restore-pop
idempotency.
- docs: /model --once in configuring-models.md with an honest
prompt-cache cost note (one-shot switch breaks the cached prefix
twice; wins for short sessions and cheap-to-expensive escalation).
Adds --once to /model across CLI, TUI, and gateway: switch model for the
next turn only, restoring the previous model in a finally block so
success, exception, and interrupt all revert. Parsing extends
parse_model_flags_detailed(); resolve_persist_behavior() treats --once
as a persistence opt-out; --global + --once is rejected.
Salvaged from PR #29923 (image-generation lane split to #59815 per
review; conflict resolution against current main by the maintainers).