Commit graph

519 commits

Author SHA1 Message Date
Brooklyn Nicholson
530e7c0d4e fix(tui): offer skill completions for a slash typed mid-message
A slash only opened completions at position 0, so `please run /cle`
suggested nothing. That is correct for execution — commands only run from
the start of a message — but it also removed the ability to reference a
skill inside prose.

Position 0 and mid-message are now detected separately. A leading slash
stays a command invocation with arg completion and the full command set; a
slash after whitespace is an inline skill reference. Only skills are
offered there, since a built-in like /model or /new acts on the app and
reads as nothing useful inside a sentence.

The gateway tags each completion with its kind, derived from the same
skill-command and skill-bundle providers the completer already consumes,
so the TUI filters on data rather than sniffing the display meta glyphs.

applyCompletion keyed its leading-slash check off the start of the input,
which is only the replace point for a position-0 command. It now keys off
the character before compReplace, so an inline pick lands as
`please run /clean` instead of `please run //clean`. The Tab handler
carried its own divergent copy of that logic and now calls the shared
helper.
2026-07-25 21:19:23 -05:00
bo.fu
3e340d93f4 fix(tui_gateway): inject live session id into HERMES_SESSION_ID
_set_session_context() called set_session_vars() without session_id, so the
HERMES_SESSION_ID contextvar was set to "" (explicitly empty) on every
prompt.submit / session bind. The subprocess-env bridge
(_inject_session_context_env) treats an explicit "" as authoritative and does
NOT fall back to os.environ, so terminal/execute_code commands in a
dashboard/TUI/web session saw an empty $HERMES_SESSION_ID — even though
agent_init had populated it via set_current_session_id().

Every other set_session_vars() call site (gateway/run.py, cron, api_server)
passes session_id; tui_gateway was the only one that dropped it, affecting all
three of its frontends (Ink TUI, desktop, web).

Fix: derive the live id in the existing _sessions lookup loop
(agent.session_id, falling back to session_key — same derivation used at
session finalize) and pass it through to set_session_vars.

Adds tests/tui_gateway/test_session_id_injection.py covering agent id,
session_key fallback, and unknown/ephemeral key.
2026-07-24 23:09:02 -07:00
brooklyn!
62e07223d6
Merge pull request #71184 from NousResearch/bb/desktop-stream-resume
fix(desktop): survive and resume turns interrupted mid-stream
2026-07-24 23:42:57 -05:00
teknium1
60c8fc6290 fix(gateway): deliver the first message when the deferred agent build outlives 30s (#63078)
Leg 2 of #63078: prompt.submit returns {"status":"streaming"} immediately and
runs _start_agent_build + _wait_agent(timeout=30s) behind it. The deferred
build (MCP discovery with per-server retry backoff, synchronous model-metadata
HTTP, skills scanning) routinely outlives 30s on cold starts; on timeout
run_after_agent_ready emitted an error EVENT and returned without ever calling
_run_prompt_submit — the user's first message was permanently discarded while
the build finished successfully in the background. The desktop's optimistic
row eventually cleared with no visible error: the blank first session.

New _wait_agent_for_prompt replaces the flat cliff for the deferred prompt
path only (_sess()'s RPC-blocking _wait_agent keeps its 30s contract):

- The pending prompt stays attached to the (already off-RPC) run thread and
  is delivered the moment the still-running build completes — a slow build is
  no longer message loss.
- The wait runs in 5s slices so a cancel (session.interrupt / churn) is
  honored promptly; the cancelled path returns None and defers to the
  caller's cancel branch (the #65567 emit) for user-visible messaging.
- Past 30s the client gets ONE keyed notification.show ('Still starting the
  agent…', key=agent-build-slow, desktop toast / TUI status bar), cleared on
  delivery — patient, never silent.
- Permanent failure only when the build itself fails: agent_error set at
  ready, the build thread died without signalling ready (fail fast via the
  new _agent_build_thread handle instead of sitting out the cap on a corpse),
  or the bounded cap expired on a genuinely hung build. The cap defaults to
  600s and is tunable via agent.build_wait_timeout in config.yaml (no new
  env vars); the error message states the message was not sent.

Tests: slow-build delivery with zero error events; the keyed progress notice
shown once and cleared; build-failure surfacing exactly one error event with
the real reason; dead-thread fail-fast; cancel honored mid-wait; config
override + fallback semantics; cap expiry message. The compute-host fallback
test stubs the new waiter alongside _wait_agent.
2026-07-24 21:40:08 -07:00
jinglun010
996d459fb6 fix(gateway): emit error event when a turn is cancelled before agent ready
run_after_agent_ready() silently returned when _turn_cancel_requested or
running=False was set during lazy agent startup, leaving the Desktop with
a {"status":"streaming"} reply that never produced a message.start or
error event. The _wait_agent error branch 6 lines above already emits;
this mirrors it so the client can surface feedback instead of hanging.

This is the server-side half of #63078 — the client-side drift guard is
addressed by #64327, but even with that fix the server-side cancel race
still silently dropped the turn.

Adds two regression tests that capture _emit (the existing sibling test
mocked it to a no-op, so it could not catch the silent drop).

Closes #63078
2026-07-24 21:40:08 -07:00
Brooklyn Nicholson
082bd17122 feat(desktop): auto-continue turns interrupted by a crash
Mid-turn progress lives only in process memory — the agent flushes to
SQLite at turn end — so an app/backend/machine death mid-turn lost the
turn entirely: reopening the session showed the recovered partial, but
the work never finished and the prompt itself survived nowhere durable.

Turns now write a durable marker (bounded per-profile sidecar) when they
start running and clear it when they conclude; success, handled error,
and interrupt all clear it, so a surviving marker is positive proof of a
process death. session.resume reads the marker: a fresh interruption
(desktop.auto_continue.freshness_minutes, default 15) is re-submitted
automatically as a continuation turn carrying the original prompt in an
interruption note, streams live to the client that just resumed, and
renders as a "resumed interrupted turn" event row. Stale markers are
cleared and the recovered partial speaks for itself; a turn that keeps
crashing stops auto-continuing after max_attempts (default 2) — the same
freshness + crash-loop-breaker posture as the messaging gateway's
restart auto-resume.
2026-07-24 23:31:56 -05:00
Brooklyn Nicholson
57b351d368 fix(tui_gateway): retain failed turns as replayable inflight snapshots
A turn that ended in error cleared inflight_turn and emitted its terminal
frame in the same breath. If the client was disconnected during that window
(the exact case for a failure like a network drop), the frame went to the
detached drop-transport and the in-memory state was already gone — the
desktop reconnected to a session with no trace of the failure.

Failed turns now retain a compact error snapshot (user prompt, partial
assistant text, error, recoverable) that session.resume's inflight payload
carries to a reconnecting client. Covers all three loss sites: the
returned-error result path, the turn exception path (which now closes with
the same status:"error" message.complete frame shape instead of a bare
error event), and agent-init failure. The snapshot lives until the next
turn starts or the session closes; _run_prompt_submit replaces a retained
error leftover instead of appending onto it.

Co-authored-by: Reza Sayar <rsayar@uvic.ca>
2026-07-24 23:31:49 -05:00
Brooklyn Nicholson
9b33f54913 fix(desktop): lead persisted image turns with the caption
Session previews are the first 60 characters of the first user message, so
persisting the @image: directives ahead of the caption labelled the session
with a truncated file path in the sidebar, session switcher, and command
palette. Clients lift the refs out of the body line by line, so moving them
after the caption changes nothing about how the turn renders.
2026-07-24 21:20:52 -05:00
Brooklyn Nicholson
ffbcfa1597 fix(desktop): persist the image ref for natively-vision-capable models too
A turn routed to a model that takes pixels directly sends `content` as a
parts list, and the session store deliberately ignores a plain-string
persist override for a list payload — a text override must not erase a
turn's image summary. So the override was dropped for every user on a
vision-capable main model, and the durable row kept only the caption plus a
literal `[Image attached at: ...]` / `[screenshot]`, which the renderer
cannot turn back into an image. Only vision-preprocessed (text-mode) turns
were actually fixed.

Mirror the shape instead: swap the text part for the `@image:` ref form and
keep the image parts, so the model still has the pixels for the rest of the
session, and drop the `[screenshot]` stand-in on the way into the bubble
when a ref was lifted from the same message.
2026-07-24 21:20:52 -05:00
Brooklyn Nicholson
6811a79b62 fix(desktop): quote persisted @image: paths so spaced paths render
The unquoted alternative in the directive pattern is `\S+`, so a ref built
by string interpolation truncates at the first space and strands the tail
as loose text next to a broken thumbnail. Composer images live in the app's
userData dir, which on macOS is `~/Library/Application Support/<App>/` — so
every pasted or dropped image hit this.

Adds format_reference_value next to REFERENCE_PATTERN, mirroring
formatRefValue in the desktop's directive-text.tsx, and covers the
round-trip through the parser.
2026-07-24 21:20:52 -05:00
alelpoan
d0f5ef7041 fix(desktop): persist @image: refs instead of the vision-enrichment text
The desktop gateway passed the vision-enriched, model-only message text
(carrying an `image_url:<path>` hint) straight into run_conversation as
the persisted user turn. The renderer only parses `@image:<path>`, so it
could not rebuild the attachment from history: after a restart the image
was gone and only the caption survived, and on a live session switch the
warm cache disagreed with the authoritative text and the frontend
"rescued" the image by appending it after the caption.

run_conversation already supports persist_user_message for exactly this
"what the model sees" vs "what gets stored" split; it was simply never
wired up for the attachment path.
2026-07-24 21:20:52 -05:00
teknium1
3b762ba695 fix: serialize on-demand slash-worker spawn per session
With the eager pre-warm removed (PR #66783), slash.exec is the only spawn
path — and it runs on the RPC thread pool, so two concurrent worker-routed
commands on a fresh session could both see slash_worker=None and each fork
a full stdio-MCP-fleet worker (the _attach_worker race loser leaking
unclosed). Add a per-session spawn lock with a double-check, plus a
regression test racing two slash.exec calls through handle_request.

Also maps Ne0teric's contributor email.
2026-07-24 15:58:32 -07:00
Ne0teric
5aa3536b32 fix(tui): spawn slash workers on demand instead of one per session
Every slash_worker child runs its own MCP discovery (#61891), which
forks the full configured stdio MCP fleet — on a config with a handful
of stdio servers that is ~20 OS processes per worker once npx/cmd
wrappers are counted. The gateway pre-warmed a worker for every session
at create/build time, and sessions held by a live transport are (by
design) never reaped, so a desktop app left open for days accumulates
one fleet per retained session. On a real setup this reached ~120
processes across 6 sessions and pushed Windows commit charge to the
point where CreateProcess started failing system-wide ("Not enough
memory resources are available to process this command").

slash.exec already spawns a worker on demand when the session has none
and already recovers from a dead worker the same way, so the eager
pre-warm is pure pre-warming:

- drop the pre-warm in the deferred session-build path
- drop the pre-warm in _init_session
- make _restart_slash_worker a no-op for sessions that never spawned a
  worker (the next slash.exec builds one with the current session
  key/model, so no stale-key worker can exist)

Only sessions that actually run a worker-routed slash command now pay
for a fleet. Cost: the first such command in a session takes the CLI
build + MCP discovery hit that session.create used to absorb.

Tests: the two create/close-race guards now assert the build thread
never constructs a worker (the notify-unregister guarantees are kept);
the restart-orphan guard seeds a live worker so the close path is still
exercised; new test pins the restart no-op for workerless sessions.
2026-07-24 15:58:32 -07:00
abundantbeing
4a9447c726 fix(api-server): expose model options inventory
Add authenticated GET /api/model/options to the gateway API server,
sharing the dashboard/TUI picker payload builder so external clients
can sync to the user's configured Hermes provider catalog instead of
scraping the single OpenAI-compatible /v1/models alias.

- new shared hermes_cli.inventory.build_model_options_payload() wraps
  build_models_payload with the stable picker shape and safe
  custom-provider probe policy (probe current only on normal open,
  probe all + cache bust on explicit refresh)
- dashboard web_server and TUI gateway model.options refactored onto
  the shared builder; dashboard build moved off the event loop via
  run_in_threadpool
- capabilities endpoint advertises model_options
- docs for both API server and programmatic integration

Salvaged from PR #54689 by @abundantbeing.
2026-07-24 11:20:07 -07:00
teknium1
3070de1963 fix(tui_gateway): recover custom provider identity from the session's model name
A session pinned to a named custom provider could silently reroute to the
user's default provider on resume/rebuild. Session rows persist the RESOLVED
provider — bare "custom" for every named providers:/custom_providers: entry —
and when no base_url survived in model_config, the existing heal
(canonical_custom_identity) had only the config.model.provider fallback left.
For users whose global default is a BUILT-IN provider (e.g. nous) that tier
cannot fire, so the bare provider was dropped, resume fell back to the default
provider with the session's custom model name, and the default endpoint 404'd
with "Model '<x>' not found. The requested model does not exist in our
configuration or OpenRouter catalog." Re-selecting via /model fixed it until
the next resume — the reported symptom.

Add a model-name recovery tier between the base_url reverse-lookup and the
config fallback: find_custom_provider_identity_by_model() maps the stored
model back to the entry that serves it (model/default_model/models catalog,
dict and legacy list shapes). The session row always stores the model, so the
entry identity survives even when the row has no base_url AND the global
default points elsewhere.

All five bare-custom heal sites in tui_gateway/server.py now pass the model:
_ensure_session_db_row, _stored_session_runtime_overrides,
_runtime_model_config, _make_agent, and _model_picker_context.
2026-07-24 10:47:32 -07:00
kshitij
6de7c0f7a7 fix: use error code 4028 (4025 already taken by session.handoff) 2026-07-24 23:14:57 +05:30
HexLab98
819e01c134 fix(tui): refuse empty prompt.submit truncation without confirm
Stale truncate_before_user_ordinal=0 from a desynced Desktop client
resolved to history[:0] and replace_messages() wiped the durable
transcript. Require confirm_empty_truncate for that edge and have
intentional first-turn restore/regenerate paths send it.
2026-07-24 23:14:57 +05:30
Sahil-SS9
a214f3026d fix: address review — add regression test, revert cosmetic churn, cross-link #61595 2026-07-24 09:48:28 -07:00
Sahil-SS9
51fcf055df fix: harden tui_gateway subprocess reads against Windows locale UnicodeDecodeError (#53137) 2026-07-24 09:48:28 -07:00
Brooklyn Nicholson
29dd621498 fix(tui_gateway): bind the branched agent to the parent profile's home + state.db
session.branch wrote the child ROW into the parent's profile db but
built the live agent with the launch defaults: _make_agent fell back to
_get_db() and no HERMES_HOME override was active. The branched agent's
own message flushes — and any later compression rotation it performed —
therefore landed back on the launch profile, splitting the lineage one
turn after the branch. Mirror session.create/resume: open the parent
profile's SessionDB for the agent and hold the home override across the
build, so config/skills/memory resolve to the profile too.

Spotted in #70605's sibling implementation of the same fix.

Co-authored-by: HexLab98 <liruixinch@outlook.com>
2026-07-24 10:05:05 -05:00
Brooklyn Nicholson
e9a243ef78 fix(state): inherit and stamp profile_name across rotation and branch children
profile_name was only written on the agent's initial lazy create
(e8b7ce8c1); every parented child row — compression rotation, TUI
/branch, desktop branch first-persist — was created without it. A
non-default profile's lineage therefore turned NULL on its first
compression or branch and aggregated as "default" in unified session
lists, completing the cross-profile session-jump.

Fix the class at the DB layer: _insert_session_row's parent backfill now
COALESCEs profile_name from the parent alongside cwd/git_* (#64709
pattern), so any parented child inherits its lineage's owning profile.
Stamp it explicitly at the three create sites as well — compression
rotation (mirroring _ensure_db_session), TUI session.branch, and the
TUI first-prompt row persist — so rows are self-describing even when the
parent row predates the profile_name column.
2026-07-24 01:49:22 -05:00
Bartok9
b9d2eb7f43 fix(tui_gateway): honor params.profile for session.* state.db access
Closes #62503

Root cause: session.resume already opened the requested profile's state.db,
but session.list / most_recent / delete / history / title / status, create's
immediate info payload, teardown, session.branch, and post-turn pending_title
still used the launch profile (_get_db / _current_profile_name).

Fix: add _db_for_profile / _profile_db / _session_db helpers and route
session.* methods through them; report requested profile on create/status;
lifecycle uses session-owned profile stores with branch profile_home inherit.

Rebase onto current main keeps _session_usage_snapshot in session info.
2026-07-24 01:37:43 -05:00
SquabbyZ
89e6f4c989 feat(agent): add MOA progress indicator (#59546)
Adds per-reference progress events and a phase-transition marker to the
MoA display pipeline so TUI / CLI / desktop surfaces can render a status
bar like `MOA: 2/3 refs done` and surface which phase (reference vs
aggregator) is currently active.

  - `moa.progress`  — fired once per reference completion with
                       `refs_done`, `refs_total`, and the source label
  - `moa.phase`     — fired on phase transitions (currently the single
                       `phase="aggregator"` transition once the fan-out
                       finishes)

Plumbed through the existing `reference_callback` →
`tool_progress_callback` → gateway path; no new UI surface. The legacy
`moa.reference` / `moa.aggregating` events are unchanged for backwards
compatibility.

AI-assisted fix by https://github.com/SquabbyZ/peaks-loop
2026-07-23 18:11:57 -07:00
annguyenNous
c5b62fdbae fix(gateway): guard chained .get() against None intermediate values
.get("key", {}) only applies the default when the key is ABSENT.
When the key exists with value None (null in JSON), .get() returns
None and the subsequent .get() raises AttributeError.

Fix: replace .get("key", {}).get(...) with (.get("key") or {}).get(...)
which handles both missing keys AND None values.

8 instances across 6 files:
- gateway/run.py: tool_call function name check
- acp_adapter/server.py: tool name/description extraction
- gateway/platforms/qqbot/onboard.py: API response task_id
- gateway/platforms/yuanbao.py: message content parsing (x2)
- gateway/platforms/slack.py: block text extraction (x2)
- tui_gateway/server.py: error message extraction
2026-07-23 12:01:24 -07:00
ethernet
a4bc1ca502
fix(timeline): persist typed display events (#69771)
* fix(desktop): hide persisted agent-only history scaffolding

Filter verification-stop nudges and context-compaction handoffs at the
stored-history mapper boundary. Preserve a real reply when a compaction
handoff shares its stored message.

* test(desktop): build persisted E2E sessions through the real agent

Drive tui_gateway.entry over its stdio JSON-RPC transport against the mock
provider, wait for real completion events, and persist normal session history
through AIAgent and SessionDB. Migrate resume and hidden-history coverage,
including real compression and live verify-on-stop scaffolding, then remove
the unused direct SessionDB import scripts.

* fix(desktop): use the provisioned Python for real-session E2Es

Run the stdio gateway through uv's synced project environment outside the
Nix dev shell, while retaining the fully provisioned Nix Python when the
shell advertises HERMES_PYTHON_SRC_ROOT.

* fix(nix): expose the provisioned Python environment to uv

Mark the Nix-built Python environment active in the dev shell so the shared
E2E session builder can always run through `uv run --active --no-sync`.

* fix(timeline): persist typed display events

* fix(timeline): strip display-only fields from provider payloads, preserve through rewrites, fix /resume display history

Three review findings from PR #69771:

1. Provider payload leak: display_kind and display_metadata were forwarded
   to the provider API as unknown message fields. Strict OpenAI-compatible
   backends can reject the next request after a model switch or resumed
   typed event. Strip both from the per-request api_msg copy in
   conversation_loop alongside the existing api_content pop.

2. Rewrite/import data loss: _insert_message_rows preserved display_kind
   but silently dropped display_metadata. After replace_messages,
   archive_and_compact, or session import, async-delegation completion
   events lost their task counts and fell back to generic display text.
   Add display_metadata to the INSERT columns and bind tuple.

3. CLI /resume stale recap: startup --resume A set _resume_display_history
   from A's lineage. A subsequent in-session /resume B loaded B only into
   conversation_history via get_messages_as_conversation, leaving the stale
   A display projection. _display_resumed_history preferentially read the
   stale attribute, showing A's recap for B. Switch /resume to
   get_resume_conversations and update _resume_display_history alongside
   conversation_history.

Tests: 890 Python (5 files), 35 desktop TS — all green.

* feat(tui): render typed display events as ◈ markers in the Ink TUI

The TUI was not handling display_kind at all — model switch markers and
async delegation completions rendered as opaque user messages with the
full [System: ...] text, and hidden compaction handoffs were visible.

Wire display_kind through the full TUI chain:

- _history_to_messages (tui_gateway/server.py) forwards display_kind
  and display_metadata to the gateway transcript payload.
- GatewayTranscriptMessage (gatewayTypes.ts) gains both fields.
- Msg.kind (types.ts) gains 'event' value.
- toTranscriptMessages (domain/messages.ts) maps:
  - hidden → skip entirely
  - model_switch → event "model changed"
  - async_delegation_complete → event "N background agents finished"
    (or "background agent work finished" without metadata)
- messageGroup (blockLayout.ts) routes event to its own group, with
  SELF_SPACED + PAINTS_TRAILING_GAP so it owns its margins.
- messageLine.tsx renders event-kind as a dim ◈ marker with no gutter,
  matching the CLI's ◈ event rendering.
- 4 new TUI tests for hidden/model_switch/async_delegation mapping.

TUI typecheck: clean. TUI lint: 0 errors (2 pre-existing warnings).
TUI tests: 9 passed (1 pre-existing failure on main, unrelated).
2026-07-23 14:46:24 -04:00
Teknium
4cb85fb7fc fix(compress): type-pin the lock-skip signal check at every consumer
The bare truthiness test on _compression_skipped_due_to_lock is fooled
by MagicMock auto-attributes on test-double agents (skill pitfall:
MagicMock defeats hasattr/truthiness duck-typing) — the type-ahead CLI
test's MagicMock agent took the lock-skip branch and skipped the
transcript commit. Real values are None/True/holder-string; pin the
check to 'is True or isinstance(str)' at all three consumer sites.
2026-07-23 08:19:14 -07:00
Teknium
eb7be2edde fix(compress): classify unconfirmed lock-acquire failures and cover all manual-compress surfaces
Follow-up to the salvaged #57634 commits:

- agent/manual_compression_feedback.py: new describe_compression_lock_skip()
  — single source of truth for lock-skip wording. A descriptive holder
  string means another compressor CONFIRMED holds the lock ('already in
  progress (holder: ...)'); True/None means acquisition failed without a
  confirmed holder (hermes_state.try_acquire_compression_lock catches
  sqlite3.Error internally and returns False), so the message says
  'could not acquire ... the lock check failed' instead of falsely
  claiming a concurrent compression is running.
- cli.py, gateway/slash_commands.py, tui_gateway/server.py (all three
  in-process consumers: session.compress RPC, command.dispatch compress
  branch, slash.exec mirror) now route through the shared helper.
- tui_gateway/server.py command.dispatch compress branch: catch
  CompressionLockHeld explicitly — it previously fell into the generic
  'compress failed' error handler.
- Deferred-notify contract (#69324): lock-skip discards the pending
  context-engine notification (committed=False) in _compress_session_history
  and the CLI path before returning.
- tests: lock-skip wording pins per surface, VISIBLE_COMPRESSION_MESSAGES
  noise-filter carve-outs for both wordings, MagicMock signal opt-outs for
  sibling tests added on main after the original PR.
2026-07-23 08:19:14 -07:00
Ethan
07cb4a697e fix(tui): show lock-hold reason when /compress no-ops 2026-07-23 08:19:14 -07:00
Basil Al Shukaili
714d7cc1a4 fix(windows): hide console flashes in GUI-reachable exec paths and provider transports (#56747)
Six spawn sites reachable from the desktop GUI / TUI gateway lacked CREATE_NO_WINDOW, so a windowless parent (pythonw/Electron) flashed a conhost per spawn: cli.exec RPC, quick-commands exec dispatch, and shell.exec RPC in tui_gateway/server.py; the CLI REPL quick-commands exec in cli.py; and the per-session provider transports in agent/copilot_acp_client.py and agent/transports/codex_app_server.py (Popen, hide-only so PIPE stdio stays intact).

All use hermes_cli._subprocess_compat.windows_hide_flags() (no-op on POSIX), matching the pattern already used at three other sites in tui_gateway/server.py. Deliberately hide-only — no detach flags, no Electron changes (per the #54220 revert history).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 07:33:28 -07:00
Teknium
2cabeeabca refactor(tui): relocate /compress arg parsing into _compress_session_history choke point
Follow-up to the salvaged #35533 fix: instead of parsing 'here [N]' only in
_mirror_slash_side_effects, parse it inside _compress_session_history — the
single helper all three manual-compress routes converge on (session.compress
RPC, command.dispatch /compress|/compact, slash-exec mirror). Every route now
honors the boundary-aware forms (here [N], up to here, --keep N) with the
same head/tail split + rejoin as cli.py and gateway/slash_commands.py, and
keeps the choke point's existing guards (lock-free LLM call, history_version
race check, deferred context-engine notification).

Tests: choke-point unit tests for 'here N', degenerate-split fallback, and
focus-topic passthrough, plus endpoint-level tests on each of the three
routes.
2026-07-23 07:24:34 -07:00
AhmetArif0
9284a3402f fix(tui-gateway): parse partial compress args in /compress here [N]
_mirror_slash_side_effects() passed the raw argument after /compress
directly as focus_topic to _compress_session_history. So /compress here
3 silently used "here 3" as a summary focus topic and did a full
compress instead of preserving the last 3 exchanges verbatim.

Fix: call parse_partial_compress_args() on the argument first. When it
detects a boundary-aware form (here, here N, up to here, --keep N),
split the history into head/tail using split_history_for_partial_compress,
compress only the head via agent._compress_context, and rejoin with
rejoin_compressed_head_and_tail — exactly mirroring cli.py and
gateway/run.py's /compress here implementation (PR #35252).

Non-boundary forms (plain /compress, /compress <focus>) fall through
to _compress_session_history unchanged.
2026-07-23 07:24:34 -07:00
wz-heng
91546b8337 fix: preserve named custom provider vision overrides 2026-07-23 17:57:33 +05:30
Brooklyn Nicholson
a01499136d fix(tui_gateway): skip credential warning for keyless custom providers
`no-key-required` is a valid sentinel for local/self-hosted/custom
routers. Warning on it made Desktop treat a working setup as missing
API keys.

Co-authored-by: Yingliang Zhang <zhangyingliang@outlook.com>
Co-authored-by: Brandon R <kingdomwarrior23@gmail.com>
2026-07-23 00:18:21 -05:00
Brooklyn Nicholson
62bfba521d fix(gateway): hide [System:] bookkeeping markers from every transcript
Model-switch and personality notices are persisted as role=user `[System: …]`
rows so strict providers accept them mid-history, but they are model-facing
runtime metadata, not user turns. `_history_to_messages` — the single display
projection every client reads — passed them straight through, so on resume or
reload they rendered as a fake user bubble in the desktop, TUI, CLI, and web
transcripts.

Drop them in that projection. The raw marker stays in `session["history"]`
for the model, so nothing changes for inference; only the display loses a row
that never belonged to the user. This also removes the stored marker from the
payload the desktop reconciles against, killing the ordinal shift that
duplicated the optimistic prompt (#67603) at its source — the desktop-side
marker exclusion remains as a fallback for older backends.

Co-authored-by: Dolverin <Dolverin@users.noreply.github.com>
2026-07-22 23:54:05 -05:00
brooklyn!
d21165c2f0
fix(desktop): keep clarify answerable across reconnect/hydration + tool-progress off (#69795)
* fix(desktop): keep clarify lifecycle when tool progress is off

* fix(desktop): render clarify prompt from the request event

Re-authored onto the current use-message-stream/gateway-event.ts (the
original patched the pre-split use-message-stream.ts). When the tool.start
row that normally mounts the inline clarify UI is missed (stream reconnect
/ hydration race), upsert a stable pending clarify tool row from
clarify.request itself so the prompt stays answerable; a real
tool.start/complete with the same request id merges rather than duplicates.

Co-authored-by: 정수환 <centerid@naver.com>

* chore(contributors): map centerid@naver.com -> lidises

Attribution mapping for the salvaged #47544 commit.

* fix(desktop): correlate clarify rows by question so hydration can't duplicate

The hydrated row (from clarify.request's request_id) and the real tool.start
row (the model's tool_call_id) have different ids, so id-only matching appended
a second clarify card in the normal path (caught by the BLOCKING_CLARIFY e2e:
'question' resolved to 2 elements). Add 'question' to the tool match-value keys
so a clarify upsert merges into the existing pending clarify row regardless of
id (same request<->args correlation ClarifyToolPending already uses); when no
row exists yet (reconnect/hydration) it still creates one.

---------

Co-authored-by: 정수환 <centerid@naver.com>
2026-07-22 23:25:48 -05:00
brooklyn!
507d479c8c
fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway (#69774)
* test(clarify-gateway): cover signature, timeout fallback, and notify paths for 100% coverage

Fixes #36531

(cherry picked from commit 5265dfe2f5)

* fix(clarify): one canonical timeout across CLI, TUI/desktop, and gateway

The clarify wait timeout was resolved three different (wrong) ways:

- CLI (`cli.py`, `hermes_cli/callbacks.py`) read a non-existent top-level
  `clarify.timeout`, so it always fell through to a hardcoded 120s instead of
  the canonical `agent.clarify_timeout` (default 3600) the gateway uses (#42969).
- The TUI/desktop bridge called `_block("clarify.request", …)` with no timeout,
  so it used the hardcoded 300s `_block` default and ignored config (#51960).
- There was no way to disable the auto-skip: a user who wanted the agent to wait
  indefinitely while they think couldn't get it.

Collapse all of this onto a single resolver:

- `tools.clarify_gateway.resolve_clarify_timeout(config)` is the one source of
  truth. Order: explicit legacy `clarify.timeout` (back-compat) → canonical
  `agent.clarify_timeout` → 3600. `<= 0` is preserved verbatim as "unlimited".
- CLI, callbacks, and the TUI bridge (`_clarify_timeout_seconds`) all route
  through it, so the three surfaces can't drift.
- `<= 0` means unlimited everywhere: `wait_for_response` and `_block` drop the
  deadline (heartbeat still fires), and the CLI hides its countdown.

Tests: resolver order / default / non-numeric / unlimited-sentinel; an
unlimited `wait_for_response` blocks until resolved rather than auto-skipping;
the TUI clarify bridge passes the configured timeout to `_block`.

Supersedes #42974 (CLI key), #51993 (TUI honors config), and #68986 (unlimited
wait); folds in #52031 (clarify_gateway coverage).

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>

---------

Co-authored-by: Christopher-Schulze <210261288+Christopher-Schulze@users.noreply.github.com>
Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: lkevincc0 <lkevincc0@users.noreply.github.com>
Co-authored-by: theone139344 <theone139344@users.noreply.github.com>
Co-authored-by: baauzi <baauzi@users.noreply.github.com>
2026-07-22 23:25:13 -05:00
brooklyn!
3dd9d5e692
fix(tui_gateway): tolerate late clarify + terminal.read replies after timeout (#69773)
`_block()` bridges four blocking request types — secret, sudo, clarify,
terminal.read — with an identical lifecycle: on timeout the tool gives up and
returns empty, but a slow renderer (or a WebSocket reconnect that dropped
tool.complete) can still answer afterward. Only secret and sudo tolerated that
late reply; clarify and terminal.read still hit the generic 4009 "no pending
request" error, which clients surface as a raw JSON-RPC string (and at least
one desktop fork re-armed the pending request on the error).

Bring the two stragglers in line with the pair that already works:
- `_block` now emits `{event}.expire` on timeout for all four request types.
- `clarify.respond` and `terminal.read.respond` pass `allow_expired=True`, so a
  late answer resolves to `{"status": "expired"}` instead of erroring.

Tests parametrize the timeout-expiry and late-idempotent-response cases over
all four bridges; the old test asserting clarify stays a 4009 error is updated
to the new graceful contract.

Supersedes #56571 (clarify) and #64886 (terminal.read) — same root cause, one fix.

Co-authored-by: liuhao1024 <liuhao1024@users.noreply.github.com>
Co-authored-by: pierrenode <pierrenode@users.noreply.github.com>
2026-07-22 23:24:42 -05:00
brooklyn!
2c1d585002
Merge pull request #69655 from NousResearch/bb/out-of-credits-ux
feat(billing): consistent out-of-credits UX across CLI, TUI, and desktop
2026-07-22 21:03:38 -05:00
ethernet
6d17b2a593
fix(desktop): preserve active correction on warm resume (#69725)
Update the gateway's live turn projection after an accepted correction so a warm session resume does not add the stale original prompt beside the persisted correction.

Cover the inference-time correction path end to end and assert both redirect entry points refresh the live user text.
2026-07-22 21:53:56 -04:00
babak
a007ac55c6 fix(compress): allow manual /compress when auto-compaction is disabled [overflow error directs users there]
compression.enabled: false is documented (agent/conversation_loop.py
overflow path) as disabling *automatic* compaction only — the terminal
context-overflow error explicitly tells users to run /compress manually,
and the gateway handler has never gated on the flag. But the classic CLI
(_manual_compress) and the ACP adapter (/compact) refused with
'Compression is disabled', leaving users at a full context with no
manual escape hatch on those surfaces.

Remove the stale gates (they predate the overflow-path design; the CLI
gate came from the original /compress commit's boilerplate) and unify
force=True across all manual-compaction call sites: ACP /compact and the
TUI's _compress_session_history (manual-only helper) now bypass the
summary-failure cooldown exactly like the CLI and gateway already did.
Also reword the ACP /context status line so a disabled-compression agent
no longer implies /compact is unavailable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 16:59:29 -07:00
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
960d339f86 feat(billing): shared cross-surface out-of-credits signal
Detect a billing wall once (agent/error_classifier → FailoverReason.billing) and
map it to a recovery link + label in one place, then carry that structured
BillingBlock to every surface instead of re-parsing free-form error text per
surface.

- agent/billing_links.py: provider-agnostic slug/host → (label, billing URL)
  table (single source of truth), Nous-aware (is_nous routes to the in-app flow);
  unknown providers degrade to a readable label with no invented URL.
- conversation_loop: both billing exit paths return a billing_block through one
  helper; the guidance message carries the derived URL for every provider.
- gateway forwards billing_block on message.complete (it was dropped).
- @hermes/shared: BillingBlock type shared by desktop + TUI.
2026-07-22 18:08:59 -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