Skip to content

fix(desktop, ink): don't wipe messages before final message - #65919

Merged
ethernet8023 merged 3 commits into
mainfrom
ethie/only-last-turn
Jul 20, 2026
Merged

fix(desktop, ink): don't wipe messages before final message#65919
ethernet8023 merged 3 commits into
mainfrom
ethie/only-last-turn

Conversation

@ethernet8023

@ethernet8023 ethernet8023 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Problem

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 via message.delta but then wiped ALL accumulated text at message.complete — keeping only the final response. The user saw text appear during inference, then disappear.

Reproduction (from session 20260716_135908_392375)

The last turn had this sequence in the session DB:

id role content finish_reason
72102 assistant awaaaaaaaaa clean!! tsc zero errors, 167 test files / 1303 tests all green :33 tool_calls
72103 tool todo (all complete)
72106–72127 assistant/tool more verification runs tool_calls
72128 assistant - **npm run typecheck**: exit 0, zero errors... stop

The text from id=72102 was streamed live, then wiped by replaceTextPart at message.complete.

Root Cause — three layers

  1. Agent core: 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. Only the terse post-verification reply was visible.

  2. Gateway transport: The tui_gateway never wired agent.interim_assistant_callback (only the messaging gateway did), so interim messages from tool-call turns were never surfaced to the desktop or TUI.

  3. UI surfaces: Both the desktop and Ink TUI accumulated all text into a single in-flight message bubble. At message.complete, replaceTextPart (desktop) / finalTail (TUI) removed all accumulated text and replaced with only the final response. The TUI already had segment-anchoring machinery but no message.interim handler; the desktop had no segment-anchoring at all.

Fix — complete across all three layers

Agent core (verify-on-stop persistence + response_previewed provenance)

The assistant response is now real content: it's persisted to state.db and emitted as an interim message via _emit_interim_assistant_message() 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.

response_previewed provenance fix (review #2): _emit_interim_assistant_message() no longer sets _response_was_previewed — that flag means "the final response was already shown," but the helper is called for ordinary tool-call narration, intermediate acks, and verification candidates alike. Setting it there caused the CLI to suppress a different final summary (e.g. from _handle_max_iterations) when the only streamed text was unrelated mid-turn commentary. Instead, preview provenance is tracked alongside _pending_verification_response and the flag is set in the finalizer only when the pending candidate is actually reused as the final response and was streamed.

  • agent/conversation_loop.py — remove _verification_stop_synthetic / _pre_verify_synthetic from assistant msg; emit interim + persist before nudge; track _pending_verification_response_previewed
  • run_agent.py_emit_interim_assistant_message() emits interim content without setting the turn-wide preview flag
  • agent/turn_finalizer.py_drop_verification_continuation_scaffolding() + content-aware tail check; set _response_was_previewed only when the reused candidate was actually streamed
  • agent/agent_runtime_helpers.py — verification candidate collapsing in repair_message_sequence

Gateway transport (tui_gateway)

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.

  • tui_gateway/server.py_load_interim_assistant_messages() + _agent_cbs() wiring + per-turn set + finally cleanup

Shared types

  • apps/shared/src/json-rpc-gateway.ts — add message.interim to GatewayEventName
  • ui-tui/src/gatewayTypes.ts — typed payload for message.interim

Ink TUI support

Added recordInterimMessage + interimBoundaryIndex to seal segments mid-turn. Updated recordMessageComplete to only dedupe segments after the interim boundary (interim-sealed segments survive).

  • ui-tui/src/app/turnController.ts, ui-tui/src/app/createGatewayEventHandler.ts

Desktop state machine

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. The desktop receives response_previewed on message.complete and settles an identical terminal completion onto the existing preview instead of creating a duplicate.

  • apps/desktop/src/app/session/hooks/use-message-stream/index.ts, gateway-event.ts
  • apps/desktop/src/app/types.ts, chat-runtime.ts, types/hermes.ts

Reasoning dedup fix (#61447)

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 (dedupeReference.startsWith(r)). A short final ("Done.") no longer swallows a longer reasoning block that merely starts with it.

Config gating

Honor display.interim_assistant_messages (default true) across all layers: the tui_gateway gates the callback. Updated hermes_cli/config.py and cli-config.yaml.example comments to document the behavior.

Windows path fix (#53553)

_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.

Testing

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

Supersedes

This PR salvages and supersedes the following PRs:

Closes

Closes #39558 — Desktop: intermediate assistant text (emitted before a tool call) disappears from the rendered thread when the turn completes
Closes #54905 — Desktop: all intermediate assistant text lost during multi-tool turns — only final message survives
Closes #46989 — replaceTextPart causes visible flicker — initial text disappears when message completes
Closes #61297 — desktop可视化的结果不全 (desktop visualization incomplete — interim analysis text swallowed)
Closes #63597
Closes #61447
Closes #62676
Closes #41049
Closes #55361
Closes #53553
Closes #62184

Co-authored-by: Liam Zhang yingliang-zhang@users.noreply.github.com (#63597, #62676)
Co-authored-by: Lucas D'Alessandro lucasfdale@users.noreply.github.com (#61447)
Co-authored-by: Eric Manganaro superposition@users.noreply.github.com (#41049)
Co-authored-by: sweetcornna sweetcornna@users.noreply.github.com (#39576)
Co-authored-by: DECK6 DECK6@users.noreply.github.com (#55361)
Co-authored-by: matantsevs matantsevs@users.noreply.github.com (#53553)
Co-authored-by: gitcommit90 gitcommit90@users.noreply.github.com (#62184)

@alt-glitch alt-glitch added type/bug Something isn't working P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Jul 16, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related: this is a competing fix for the interim-text completion cluster. #63597 uses separate finalized interim messages, while #64492 retains pre-tool text parts; this PR uses sealed interim segments.

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Comment

Looks Good

  • Desktop fix: preserve interim assistant text that was being wiped at message.complete
  • Targets the gateway event handling for content助理 (interim assistant text) in gateway-events.ts
  • New test interim-sealing.test.tsx covers the regression

Note

  • Prior COMMENT review exists from this session; this is a confirmation review

Reviewed by Hermes Agent

@ethernet8023 ethernet8023 changed the title fix(desktop): preserve interim assistant text wiped at message.complete fix(desktop, ink): don't wipe messages before final message Jul 16, 2026
@ethernet8023
ethernet8023 force-pushed the ethie/only-last-turn branch 2 times, most recently from 713559e to 80c70e8 Compare July 16, 2026 21:50
@ethernet8023
ethernet8023 marked this pull request as ready for review July 16, 2026 21:54
@ethernet8023
ethernet8023 force-pushed the ethie/only-last-turn branch 4 times, most recently from 86c79da to 4e77fa4 Compare July 17, 2026 19:00
@teknium1

Copy link
Copy Markdown
Contributor

Reviewed the three-layer fix — the core mechanism is sound and I verified the invariant concern that motivated the approach it reverses. Targeted Python suites all green locally (171 across verification/finalizer/stream_consumer/tui_gateway + 9 continuation-budget). Two findings and one thing I checked that turned out fine, below.

Verified sound (the #55733 adjacency concern)

Dropping _verification_stop_synthetic from the assistant candidate and persisting it looks like it re-introduces the assistant → assistant adjacency that #55733 flagged both messages to prevent. It doesn't, because the resume path is different now: the candidate is flushed with finish_reason = verification_required, and repair_message_sequence's new verification-candidate collapse runs on the SessionDB load path (hermes_state.py, repair_alternation=True), not just live model replay. So on resume assistant(candidate) → assistant(final) collapses to the final. Confirmed the two finish_reason values are consumed only by that collapse — nothing else reads them, so a lone terminal candidate (turn interrupted mid-verification) is harmless. Good call reversing #55733 rather than layering on top of it.

Finding 1 — already_streamed is a dead payload field (minor)

Both emit sites send it:

  • tui_gateway/server.py:4235 (_agent_cbs) and :9525 (per-turn _run_prompt_submit)
  • typed in ui-tui/src/gatewayTypes.ts:718

But no consumer reads it. apps/desktop/.../gateway-event.ts reads only payload?.text; ui-tui/src/app/createGatewayEventHandler.ts reads only ev.payload?.text. The desktop's dedup decision is driven entirely by response_previewed on message.complete, not by already_streamed on message.interim. Either wire it into the segment-dedup decision or drop it from the payload + type so it isn't mistaken for load-bearing later.

Finding 2 — _interim_content_was_streamed is exact-equality only (soft edge)

run_agent.py:4753 decides _response_was_previewed provenance via streamed == visible_content after whitespace normalization + think-block stripping. It's an all-or-nothing match: if the final response is the streamed text plus any trailing delta, or was only partially streamed before the verify nudge fired, the equality fails and the turn is not marked previewed. Consequence is a benign duplicate (interim bubble + identical final bubble) rather than lost text — so this fails safe, and the desktop test keeps an identical final completion distinct ... without response_previewed even encodes that duplicate as expected. Flagging it because the "settle onto the interim" dedup only fires on an exact hit; anything short of exact silently shows two bubbles. If that's acceptable, fine as-is; if not, the match wants to be prefix/containment-based like the reasoning dedup you already extracted in mergeFinalAssistantText.

Checked, not a problem — mid-loop flush double-write

The _flush_messages_to_session_db(...) call after appending final_msg (before the nudge) does not double-persist. _flush_messages_to_session_db_unlocked dedups via the intrinsic _DB_PERSISTED_MARKER stamped per-dict, so the turn-end flush skips the already-stamped candidate. And the nudge is still stripped by _EPHEMERAL_SCAFFOLDING_FLAGS at both persistence sinks. Chain is clean.

Net: core layers (agent persistence + resume collapse + response_previewed dedup) are correct and well-tested. The two findings are polish — dead field and an exact-match dedup that fails safe — neither blocks.

@ethernet8023

Copy link
Copy Markdown
Collaborator Author

@teknium1 the messaging gateway uses already_streamed at gateway/run.py:18895-18916:. it's typed in the TS for correctness.

finding 2 doesn't seem to be a problem in practice but i added a workaround anyways.

@alt-glitch alt-glitch added comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/gateway Gateway runner, session dispatch, delivery area/config Config system, migrations, profiles platform/windows Native Windows-specific behavior or breakage needs-decision Awaiting maintainer decision before any implementation sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Jul 17, 2026
@ethernet8023
ethernet8023 force-pushed the ethie/only-last-turn branch from 3e34a41 to 6e5db7b Compare July 17, 2026 21:16
@alt-glitch alt-glitch removed sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows platform/windows Native Windows-specific behavior or breakage labels Jul 17, 2026
@OutThisLife

Copy link
Copy Markdown
Collaborator

Reviewed. On the merits this is the strongest entry in the interim-text cluster and I'm in favor of consolidating on it:

Two things before it can land, neither about the code:

  1. It now conflicts with main (169 commits behind, single conflict in agent/turn_finalizer.py) — a quick rebase should clear it; CI was green on the last push.
  2. needs-decision is the actual gate. The cluster (fix(desktop): preserve pre-tool-call text parts on message completion #64492, fix(tui_gateway): preserve interim assistant message boundaries #63597, fix: persist and emit agent response during verification stop loop #62676, fix(desktop): honor display.interim_assistant_messages — stop collapsing mid-turn narration #61447, fix(desktop): preserve interim assistant text #41049, fix(agent): preserve verify-on-stop attempted final answer #55361, fix: preserve verify-on-stop streamed responses #53553, fix(tui): verification-stop can discard streamed final answers (#62142) #62184) is still all-open; picking the sealed-segment approach here means closing those as superseded. That consolidation call is a maintainer decision, but for what it's worth my vote is this PR.

Not approving yet only because of the conflict + the open cluster decision — rebase turn_finalizer.py and I'm a yes.

kshitijk4poor pushed a commit that referenced this pull request Aug 10, 2026
A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ma1138569845 pushed a commit to ma1138569845/dechnicAuditor-agent that referenced this pull request Aug 10, 2026
…esearch#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (NousResearch#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (NousResearch#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
lenardhuebner88-rgb pushed a commit to lenardhuebner88-rgb/hermes-agent that referenced this pull request Aug 10, 2026
…esearch#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (NousResearch#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (NousResearch#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 6c2c77e)
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…arch#65919)

* fix(desktop): preserve interim assistant text wiped at message.complete

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

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

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

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

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

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

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

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

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

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

_split_segment_tokens now accepts posix=False and _find_ad_hoc_match tries
both posix modes so ad-hoc verification scripts with Windows backslash
paths are matched correctly. (response_previewed forwarding from NousResearch#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>
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
… are persisted (NousResearch#68149)

The display_history_prefix calculation used by session.resume's
_live_session_payload was display_history[:len(display) - len(raw)].
This assumed the model (repaired) history is always a suffix of the
display history — i.e., repair_message_sequence only removes messages
from the tail. That assumption broke when verification candidates
(finish_reason=verification_required) were persisted to state.db (NousResearch#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
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
NousResearch#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 NousResearch#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.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
… E2E

Complete the NousResearch#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"].
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…-candidate-warm-payload

fix(tui_gateway): candidate-inclusive display on warm/live + child-watch resume (NousResearch#65919 fallout)
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Since NousResearch#65919 the live view seals each chunk of mid-turn assistant
commentary (message.interim) as its own finalized bubble. Every bubble
with visible text renders the hover action footer, so a tool-heavy turn
grew a copy/refresh bar under almost every paragraph — and the live
render didn't match rehydration, which merges the turn into one bubble.

Mark sealed interim bubbles with ChatMessage.interim, carry the flag
into the runtime message metadata (custom.interim), and skip the
AssistantFooter for them. The turn's final reply keeps the footer; a
previewed final that settles onto an interim bubble clears the mark so
the settled reply regains its actions. interim joins COMPARED_FIELDS /
chatMessagesEquivalent so flipping it repaints.

Also fix an id-collision flake this surfaced: stream/interim bubble ids
were Date.now()-only, so an interim seal and the next segment's first
delta in the same millisecond reused the id and the new segment appended
into the sealed bubble. Ids now include a monotonic sequence.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…9580)

* test(desktop): e2e test for interim assistant message preservation (NousResearch#65919)

Adds a Playwright E2E test that reproduces the fix from PR NousResearch#65919 across
all three layers (agent core → tui_gateway → desktop renderer). The mock
inference server is upgraded with a multi-turn scripted response that
exercises several interleaved patterns:

  1. text + tool_call  → should produce an interim message
  2. text + tool_call  → another interim message
  3. no text + tool_call → NO interim (no visible text alongside tools)
  4. text + tool_call  → another interim message
  5. final answer (stop) → message.complete, different from all interims

Two describe blocks exercise display.interim_assistant_messages both on
(default) and off:
  - ON:  all interim texts + the final answer visible in the transcript
  - OFF: only the final answer visible, all interim texts wiped

Also fixes a footgun: test:e2e now runs `npm run build` as a pretest
hook so the renderer dist/ is always fresh. Previously, running
`npx playwright test` locally would silently load a stale dist/ that
predated renderer fixes — the python backend ran from source (had the
fix) but the renderer was frozen in an old bundle. CI already built
fresh, so the explicit build step there is removed to avoid duplication.

* test(desktop): e2e sidebar states — background dot, subagent, cross-session

Add sidebar-states.spec.ts with three E2E tests exercising the desktop
sidebar's session dot states driven by real gateway events:

1. Background process dot appears during a terminal(background=true)
   call and disappears after auto-dismiss; subagent (delegate_task)
   runs concurrently; final answer is visible in the transcript.

2. Background dot remains visible while a subagent runs concurrently
   (longer sleep 5 background process so the dot is catchable).

3. Cross-session dot transition: start a turn with a background process,
   wait for the turn to complete, open a new session, then verify the
   original session's dot transitions from 'background running' to
   'finished — unread' when the background process exits.

The mock server gains SIDEBAR_SCRIPT and SIDEBAR_CROSS_SCRIPT trigger
keywords that return tool_calls for terminal(background=true) and
delegate_task — the agent executes these for real (real background
process, real subagent), so the tests assert against genuine gateway
events rather than mocked UI state.

Verified: 3 passed (1.2m) under cage headless wlroots.

* test(desktop): e2e tests for tile-unread bug (tab passes, split fails)

Two scenarios for the tile-unread bug where a session that finishes
while visible on-screen gets the green 'finished unread' dot even
though the user is looking right at it.

The unread check in handleTransition (session-states.ts:174) only
compares against $selectedStoredSessionId and ignores $sessionTiles,
so a session visible in a tile gets marked unread even though it's
on screen.

1. TAB (hidden, PASSES): ⌃-click opens the session as a stacked tab
   that is NOT visible on screen. The unread dot IS correct here —
   the user isn't looking at it.

2. SPLIT (visible, FAILS): drag the session row to the workspace's
   right edge to create a side-by-side split tile. Both sessions are
   visible on screen. The unread dot is WRONG — the session is visible
   in the split tile, so it should not be marked 'unread'. This test
   is RED until the fix lands.

Also adds explicit page.screenshot() calls at key assertion points in
sidebar-states.spec.ts so the trace viewer has full-res captures of the
sidebar dot states during the test.

* test(desktop): cover compression and queued stop lifecycle

Add real desktop E2E coverage for session compression continuation and
queue parking after an explicit Stop. Extend the mock server with a
blocking scripted turn and submitted-prompt assertions.

* test(desktop): cover busy composer submit routing

Replace the invalid queued-stop E2E scenario: plain text redirects a busy
turn rather than entering the queue. Add focused submit-routing coverage for
plain text, slash commands, attachments, explicit Stop, and idle submission.
blut-agent pushed a commit to blut-agent/hermes-agent-fork that referenced this pull request Aug 11, 2026
…esearch#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (NousResearch#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (NousResearch#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
vashkartik added a commit to vashkartik/hermes-agent that referenced this pull request Aug 12, 2026
* fix(gateway): spool cap-dropped pending transcript messages instead of discarding

When the per-session pending transcript queue hits _MAX_PENDING_PER_SESSION
(200) while the session DB is broken, the gateway previously popped the
oldest message and discarded it permanently — silent user data loss during
live operation (#78182). The on-disk pending spool only ran at shutdown via
flush_pending_to_file.

Extend that existing spool machinery for runtime drops:

- gateway/shutdown_flush.py: add spool_dropped_transcript_message() and
  drain_transcript_spool(), reusing _get_flush_dir/_write_payload (same
  atomic-JSON pending_messages/ spool format). recover_pending_to_db()
  now also replays transcript_cap_drop payloads left over across restarts.
- gateway/session.py: on cap eviction, spool the dropped message and log a
  WARNING that includes the spool path; if spooling fails, degrade to the
  previous drop-and-warn behavior. On the next fully successful transcript
  flush for that session, drain and replay spooled messages in drop order;
  replay failures keep the spool files for the next attempt.
- tests/gateway/test_pending_queue_spool.py: drop→spool→drain roundtrip,
  per-session drain isolation, spool-failure degradation, replay-failure
  retention, and spool primitive ordering/reason filtering.

No new config; extends existing flush_pending_to_file infrastructure per
AGENTS.md guidance.

Refs #82616, #78182

* fix(state): keep canonical writes available when FTS is corrupt

* fix(docker): per-session container isolation and session-scoped workspace mounts

Two bugs reported on the docker terminal backend (desktop app, sandboxed
profiles with container_persistent: false):

1. A NEW chat's container inherited the PREVIOUS session's workspace,
   bind-mounted rw at /workspace, because the mount source was the
   process-global TERMINAL_CWD env var (written by the workspace picker,
   outliving its session) and all sessions shared one 'default' container.

2. Every command failed with exit 126 because the desktop gateway recorded
   the HOST launch directory as the session cwd, and each command was
   prefixed with 'cd /Users/<user>/...' inside the container.

Fixes (class-wide, single owners):

- container_persistent: false + docker now keys containers PER SESSION:
  fresh container per chat, removed at session close/idle. delegate_task
  children share the parent's container via an explicit alias registry.
  container_persistent: true keeps the documented ONE-long-lived-container
  contract unchanged.
- _resolve_task_host_cwd() is the single owner of the cwd->/workspace mount
  policy across all four env-creation sites; under isolation it refuses
  process-global cwd sources and mounts only the session's own attached
  workspace (tui_gateway now tags overrides with cwd_source).
- _resolve_command_cwd() gains the same host-path guard the env-creation
  sites already had (#50636/#54447 sibling site): a recorded host cwd is
  discarded on container backends instead of cd-ing every command into a
  nonexistent path.

E2E-tested against real Docker: distinct containers per session, no stale
mount in a fresh session, no exit 126 from host cwd records, containers
removed at session teardown.

* Port from code-yeongyu/oh-my-openagent: ast-grep structural search/codemod optional skill

Vendors the ast-grep skill from oh-my-openagent's shared-skills bundle
(upstream code-yeongyu/ast-grep-skill @ 3148c69, MIT) into
optional-skills/software-development/ast-grep with Hermes conventions:

- SKILL.md rewritten with Hermes frontmatter (platforms, tags, category)
  and Hermes tool routing (search_files instead of raw rg, terminal for
  sg invocations, patch-vs-ast-grep division of labor)
- scripts/ast_grep_helper.py: fixed argparse so trailing paths after an
  optional flag parse (parse_known_args + fold extras into paths);
  upstream errored 'unrecognized arguments: .' on the documented
  'search PATTERN --lang js .' form
- 7 reference docs, install.sh/install.ps1 (pinned-release GitHub
  fallback), smoke tests carried over verbatim

E2E validated: install (github method, ast-grep 0.45.0), doctor,
search, validate (regex rejection), replace dry-run + apply two-pass,
scan with YAML rule, tests/smoke.sh 15/15 pass.

* fix(desktop): send full tool args so expanded rows show the whole command

The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.

Two paths had this fault:

- tool.start: the payload had no args until tool.complete, so the
  expanded row was truncated while the tool ran. Now tool.start ships
  the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
  discarded them. Hydration from this projection (watch windows,
  compress, branch, seeded create) kept only the preview, so the
  truncation was permanent. Now tool rows carry the args. This
  projection is the display view of the transcript — each renderer
  decides what to paint, and the preview stays for collapsed titles.

The DB rows do not change: the args already persist in tool_calls.

* fix(skills): trim ast-grep description to the 60-char hardline

test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.

* fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too

Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.

_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).

Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).

Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.

Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.

* fix(gateway): also persist user_id and session_key in child-session creates

The sweeper flagged two gaps in the routing-columns fix:

1. /branch create_session() omitted user_id and session_key — the
   fallback lookup path (find_latest_gateway_session_for_peer) requires
   user_id to match the complete peer tuple when session_key lookup fails,
   and /resume IDOR guards reject sessions without matching user_id.

2. Compression-rotation create_session() omitted agent._user_id — same
   problem: rotated child cannot satisfy persisted /resume ownership proof
   before the later gateway backfill.

Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.

Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.

* fix(gateway): carry origin_json/display_name into /branch child sessions too

Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().

The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.

* fix(gateway): distinguish durable cached transcript rows

* chore: map TomAce7 contributor email for attribution audit

* fix(gateway): respect reset boundaries during recovery (#68539)

find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)

* fix(gateway): honor session_reset policy when recovering sessions

Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)

* chore: map contributor email for hillimited

* fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)

Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.

Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.

Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.

* test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes

Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.

* fmt(js): `npm run fix` on merge (#82771)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): make un-highlighted code readable while streaming in light theme

streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.

traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.

fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.

* test: run os-specific tests on their real host, not a faked one

many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.

* ci: add macos and windows test lanes for the os-marked tests

the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.

* ci: print the zero-selection diagnostic instead of dying first

`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.

* test: convert the last host-OS fakes and guard double markers

Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:

- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
  picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
  in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
  marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
  $BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
  fallback could not change the result. The assertion now reads the host, so
  the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
  CoreAudio init raises a TCC prompt, which no Linux runner reproduces.

tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.

The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.

* fix(ci): don't report all-good before jobs start

The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.

* fix(agent): persist completed text turns before the loop exits (#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: follow-up for salvaged PR #81692

- warn (not debug) on final text-turn flush failure: a failure here
  reopens the exact #81641 data-loss window with _persist_session as
  the only remaining retry, unlike the verify siblings which retry
  in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
  test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
  change fails with a clean assertion instead of ValueError from max()

* fix(tui): recover active goals after compression exhaustion

* fix(agent): keep the thinking-prefill marker so the drop pass can strip trailing stubs

* test(agent): cover the API-copy build so restoring the marker pop fails

* fix: trim comments and fix sibling pop site in summary path

Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.

* fix(skills): reject colon in bundle path components (NTFS ADS bypass)

_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.

Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.

Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.

Reported-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>

* fix(cron): load .env on no_agent path so standalone ticks resolve delivery home channels

hermes-cron-tick.service starts without TELEGRAM_HOME_CHANNEL/DISCORD_HOME_CHANNEL
in the unit env; the per-run load_hermes_dotenv reload lived only on the agent
path (after the no_agent short-circuit returns), so every deliver=telegram/all
script job failed with 'no delivery target resolved'. Load the dotenv at the top
of the no_agent branch; override=False keeps the gateway's in-process tick
behavior unchanged.

* fix(cron): surface exception type and traceback for standalone Discord delivery errors

* refactor: drop dead sys.exc_info check in delivery error log

The result-error path in _deliver_result is not inside an except block,
so sys.exc_info() always returns (None, None, None) — the condition was
always False. Simplify to a plain logger.error call with accurate comment.

* chore: AUTHOR_MAP for aameobius@gmail.com → francialisomlimoeiro

PR #82682 salvage contributor attribution.

* fix(gateway): keep the personality pivot out of the truncate ordinal space (#82756)

`truncate_before_user_ordinal` is an index into the list of *real* user
turns. The gateway builds that list with `role == "user" and not
display_kind`, and `test_prompt_submit_truncate_ordinal_skips_display_kind_rows`
already pins why: "Without the filter, a trailing marker shifts the ordinal
so the wrong message is targeted for truncation."

`_apply_personality_to_session` broke that invariant at the producer. Its
pivot marker rides as `role=user` — deliberately, so strict
OpenAI-compatible providers accept it mid-conversation (the same reason
`_append_model_switch_marker` does) — but unlike the model-switch marker it
carried no `display_kind`. The gateway therefore counted it as a real user
turn while no client ever renders it as one.

After a personality change the two sides address different lists: every
later rewind/edit/regenerate resolves one slot too early, and
`replace_messages()` hard-DELETEs the extra span. That is the reported
signature — an in-range, valid ordinal, `confirm_truncate: true`, and a cut
that moved backwards with no user rewind action.

Tag the pivot like the model-switch marker, and teach the desktop to
project the kind as a timeline row so a persisted marker is never rendered
— or counted — as a user turn on the client side either. Both ends must
exclude it; excluding it on only one end just inverts the drift.

The regression test drives the real injection point rather than a
hand-written marker dict. Without the fix it fails with "the pivot shifted
the ordinal: the cut landed at 3 instead of 5", losing a turn the user
never asked to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(state): make a rewind truncation recoverable instead of a hard DELETE (#82756)

Guarding the *aim* of a rewind still leaves every other way of aiming it
wrong terminal. All three reported incidents (#70516, #80763, #82756) ended
at the same write — `replace_messages()` in the `prompt.submit` truncation
path — and all three were unrecoverable for the same reason: the rows are
DELETEd, which also evicts them from the FTS index, so there is no `active=0`
archive and nothing to restore from.

The codebase already draws this distinction and already has the safe half of
it. `archive_and_compact` is documented as "the durability-preserving
alternative to replace_messages"; `rewind_to_message` — the `/undo` path —
soft-deletes to `active=0, compacted=0` and keeps the rows "on disk for audit
/ forensic inspection". The desktop rewind is the same user-facing operation
as `/undo` and was the one taking the destructive branch.

`replace_messages(..., archive_dropped=True)` flips the DELETE to a
content-preserving `UPDATE messages SET active = 0`, reusing the existing
transaction and the existing `active=0, compacted=0` marking so the dropped
turns stay readable via `get_messages(..., include_inactive=True)` and stay
out of session search (`compacted=0` = "the user took it back", vs
compaction's `compacted=1` = "summarized away, still discoverable").

The live transcript is byte-identical either way — only the durability of the
dropped turns changes. The parameter defaults to False, so the fork handler,
the ACP adapter and `gateway/session.py` keep their current semantics
untouched; a test pins that.

`active_only=True` stays on the call: #80216 still applies, and archiving must
not disturb rows an earlier compaction deliberately archived.

Test doubles for `replace_messages` in the gateway suite are widened to the
real signature — they are stand-ins for SessionDB, and a double that does not
accept what production passes silently converts this write into a 5008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gateway): reject boolean ordinals and bare confirm_truncate on prompt.submit

Two hardening guards extracted from #82766 by @StanleyStetson:

- bool is an int subclass, so a JSON `true` in truncate_before_user_ordinal
  coerced via int() to ordinal 1 and aimed a CONFIRMED rewind at the second
  user turn — the same silent-loss class as #82756. Reject with 4004.
- confirm_truncate with no truncation target is leaked client rewind state
  on an ordinary submit; fail fast with 4004 instead of silently ignoring
  the flag, so the corrupted client state is surfaced.

Part of the composite fix for #82756.

* fix: close sibling display_kind drops and ui-tui parity for #82756

Review follow-ups on the composite salvage (whole-bug-class sweep):

- session.branch and _persist_branch_seed copied parent history without
  display_kind/display_metadata, so a tagged timeline marker (personality
  pivot, model switch, auto-continue) re-entered the branched session as a
  bare role=user row after a restart — re-planting the phantom-ordinal
  class this PR fixes. Both projection dicts now carry the tags; regression
  asserts added to both branch tests (mutation-checked: fail without the
  fix).
- ui-tui renderer learns display_kind=personality_switch (was falling
  through to an opaque user bubble; desktop got the case in commit 1).
- programmatic-integration docs: document the two new 4004 refusals
  (boolean ordinal, bare confirm_truncate).
- hermes_state comment: archived rows are searchable only with
  include_inactive=True, not by default search — align comment with the
  actual FTS filter.
- strip stray trailing blank line in test_tui_gateway_server.py

* fmt(js): `npm run fix` on merge (#82962)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): keep react-router in one runtime chunk

* feat(skills-hub): fall back to live repo for optional skills missing from local checkout

Optional skills merged to main after a user's install was cut were
invisible to 'hermes skills install official/...' until they ran
'hermes update' — the OptionalSkillSource only scanned the local
optional-skills/ checkout.

Now, when an official/<category>/<skill> identifier is not found
locally, OptionalSkillSource resolves it against the live default
branch of NousResearch/hermes-agent: one Trees API call enumerates
optional-skills/*/SKILL.md dirs (cached on disk via the shared index
cache, 1h TTL), then the full skill directory is downloaded byte-exact
(including root-level install scripts, LICENSE, tests/ — files the
generic GitHubSource.fetch path drops). search() and inspect() also
surface remote-only skills so discovery works pre-update too.

Local checkout always wins when present; offline degrades to the old
local-only behavior; traversal and ambiguous bare names are refused;
provenance stays official/builtin.

* fix(update): force-reload config modules before migration check

hermes update runs in the PRE-pull Python process. After git pull
updates the source files on disk, sys.modules still holds the OLD
hermes_cli.config and hermes_cli.config_migrations. Function-level
imports return the cached module, so DEFAULT_CONFIG["_config_version"]
is the OLD value and check_config_version() reports (33, 33) —
"up to date" — even though the freshly-pulled code has v34 with a
migration to run.

The personality reset migration (#81946) was silently skipped this
way: display.personality: kawaii stayed active after updates that
should have reset it. Every user who updated from a pre-v34 codebase
to a post-v34 codebase was affected.

Fix: _run_config_check_fresh and _run_migrate_config_fresh call
importlib.reload() on hermes_cli.config_defaults, hermes_cli.config,
and hermes_cli.config_migrations before calling check_config_version
and migrate_config. This forces the modules to be re-read from the
updated source files on disk.

* fix(transport): use getattr for supports_prompt_cache_key on stale profiles

After a partial update (stash restore overwriting providers/base.py with
an older version), the NousProfile singleton was instantiated from a
ProviderProfile class that predates the supports_prompt_cache_key field
(added in f4fb23f3d). Accessing profile.supports_prompt_cache_key raised
AttributeError, crashing every API call with:
  'NousProfile' object has no attribute 'supports_prompt_cache_key'

Use getattr(profile, 'supports_prompt_cache_key', False) so a stale
profile degrades to 'no prompt cache key' instead of crashing.

* docs(sessions): document repair-routing and the continuity guarantees

User-visible surface from the #82616 session-continuity campaign:
- sessions.md: 'Repair Stranded Gateway Sessions' (evidence rules,
  dry-run-first, why adoption is never automatic) and 'Continuity After
  Crashes and Restarts' (atomic identity, self-heal, recency resolution,
  reset-boundary fence)
- cli-commands.md: repair-routing row in the hermes sessions table

Docs build verified (en + zh-Hans).

* feat(tools): stat-based special-file guard for read_file + readtool eval harness

read_file on a workspace FIFO/socket blocked until the exec timeout —
the existing device guard is name-based (/dev/*, /proc/*) and cannot
see an arbitrary special file. Add _special_file_kind(): one os.stat
on the resolved path, refusing FIFO/socket/char/block devices with a
plain note ('no read was attempted') instead of hanging. Host-visible
filesystems only; regular files, dirs, and missing paths unchanged.

Also adds evals/readtool/: an A/B harness that runs the real AIAgent
against hostile-file fixtures (huge lockfile, one-line bundle, FIFO,
NFD filenames, lying extensions) and measures accuracy, turns, tool
calls, and tokens. Measured for this guard (3 reps, file-only arm):
qwen3.8-max fifo task tokens 122k -> 26k (-79%), turns 9.3 -> 5.0;
opus-4.8 tokens 40k -> 23k; accuracy held 1.00 both arms.

* chore(evals): track results/.gitignore (its own * rule excluded it from the original add)

* feat(tools): unicode-equivalent filename retry + near-miss suggestions in read_file

NFC/NFD, narrow no-break space (U+202F), and curly quotes render
identically in a terminal — a model retyping a visually-correct path
gets 'file not found' and can never discover the byte mismatch on its
own. On not-found, canonicalize the requested name and compare against
directory entries; exactly ONE equivalent spelling reads transparently
with an explanatory note. Zero or several matches (homoglyph twins)
fall through — never guess between collisions.

Also: difflib.SequenceMatcher >=0.8 fallback in _suggest_similar_files
catches near-miss typos (AGENT.md -> AGENTS.md) that substring scoring
misses entirely.

Measured (file-only arm, 3 reps, control=guard-only vs feature):
unicode task qwen3.8-max 31k->16k tok (-48%), turns 6.7->3.7;
opus-4.8 57k->33k tok (-42%), turns 8.3->5.0; accuracy held 1.00.
near-miss: opus mildly better, qwen flat, no regressions.

* fix(ci): start the poller on in_progress, key concurrency per repo

The requested trigger fires when GitHub creates the run. A run from a
first-time contributor waits in action_required, and the poller then
polls a run that never starts until its timeout. The in_progress
trigger fires when the run starts, and it also fires on a re-run.

The concurrency group now contains the head repository. Fork PRs
frequently share a branch name, and two PRs must not cancel the
poller of each other.

* fix(ci): keep review-gated files out of the js-autofix patch

The dep-version-gate ruleset requires a team review for package
manifests, eslint configs, and workflow files. If the autofix patch
contains one of these files, the bot PR waits for that review and
auto-merge stops. The patch step now excludes them, so a bot PR
never gates itself. The eslint check in typecheck.yml still reports
their lint errors.

* fix(ci): unbuffer live comment poller output

* feat(tools): name the dead end — past-EOF and empty-file notes in read_file

A read past EOF returned content '900|' (a phantom line-number prefix
that looks like a real line) and an empty file returned '1|' — both
ambiguous silence: indistinguishable, from inside the model, from a
broken tool, so it re-reads and widens windows. Name the dead end and
its recovery instead: 'offset 900 is beyond the end of the file (412
lines total). Retry with offset <= 412.' / 'File is empty (0 bytes).'
Notes, not errors — a fact about the file is not a failure.

Boundary pinned by test: offset == total_lines still reads (an
off-by-one in a resume hint is a silently corrupted read).

Measured (file-only arm, 3 reps, control vs feature): qwen3.8-max
-18% tokens, -26% tool calls, -17% turns across the two affected
tasks; opus-4.8 flat (within rep noise); accuracy held 1.00.

* fix(process): reject non-positive wait timeouts; distinguish log offset=0 from default

Two falsy-zero coercions in process_registry (salvaged from PR #60004,
credit @isheng-eqi; the EOF half of that PR landed separately in
893792c99):

- wait(timeout=0): schema says minimum=1 but the handler let 0 fall
  through '0 or max_timeout' to the DEFAULT wait instead of rejecting.
- read_log(offset=0): conflated with the offset-unset default, silently
  returning the TAIL of the log when the caller asked for the head.
  Default is now offset=None; explicit 0 paginates from line one.

* chore: map contributor email for salvaged commit

* fix(file-ops): stop read_file blocking forever on non-regular files

The size probe every read path starts with — `wc -c < path` — opens the
path. On a FIFO with no writer, a socket, or a character device that never
reaches EOF, that read never returns, and read_file/read_file_raw/
read_file_bytes all pass no timeout to _exec. The turn wedges until the
process is killed.

The device blocklist in tools/file_tools.py cannot close this: it matches
literal /dev/* names, so it can only ever cover paths someone thought to
enumerate. A FIFO is a file type and can sit at any path.

Gate the probe behind `[ -f ]`, which stats instead of opening, and report
a path that exists but is not a regular file as such. A missing path keeps
its existing not-found handling.

* test: adapt read mocks and fifo guard test to the sentinel probe

The combined [ -f ]/wc -c probe changes the first shell command each
read issues; update the stale mocks that only answered bare 'wc -c'.
The fifo tool-layer test now accepts the merged stat-guard's
success=False note (a fact, not an error) with the shell sentinel
behind it.

* test: adapt edge-case pagination mock to the sentinel probe

Same stale-mock class as the previous commit — the sweep missed
test_file_operations_edge_cases.py. Verified no bare wc -c mocks
remain anywhere under tests/.

* fix(desktop): support keyless plugin rows

* feat(profiles): serve a cross-profile project tree and per-profile usage totals

`projects.tree` answers for the backend's own profile, so the grouped
sidebar had nothing to draw once the user asked to see every profile.
Run the same authoritative builder once per profile against that
profile's state.db and merge the results by folder, so one checkout is
one group no matter how many profiles work in it, and the owning profile
rides on each session row where the badge and filter can read it.

Group totals are summed in SQL rather than over the loaded page — a
number that shrank as you scrolled would be worse than no number.

Scope the batched sidebar slices while we're here: cron and messaging
came back cross-profile unconditionally, which is why a concrete profile
showed another profile's Telegram threads and cronjobs.

Closes #65710
Closes #42651
Closes #70629

* fix(desktop): preserve keyless plugin row identity

* fix(desktop): hoist the sidebar's sort key out of the flat list

The sort key was applied where the flat recents list is assembled, so it
did nothing at all once rows moved into groups: picking "cost" while
grouped by project or profile left every lane in the order the backend
sent it. Rank in a store instead, above any one view, so a grouped
surface can order the rows it owns by the same key.

* fix(desktop): read-only keyless plugin rows + backend contract v6

Rework of the salvaged #82828 compatibility layer: keep the crash guards
(optional key, safe filter/search, synthetic React row identity) but drop
the name-addressed toggle fallback — bare names collide across category
dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
moved to key-addressed toggles (a60b492e07). Keyless rows from a
pre-contract backend now render with a disabled switch and an 'update
your backend' tooltip instead of resurrecting the collision-prone
protocol.

Bump DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT to 6 so the
existing skew toast surfaces the real remedy (one-click backend update)
on session open.

* feat(desktop): show every profile's sessions in the sidebar

All-profiles mode listed a flat page of chats and stopped there: the
project tree was the active profile's, grouping and filtering had no
notion of an owner, and each profile lane paged itself against a
separate endpoint. Multi-agent workflows live across profiles, so the
sidebar now treats the owner as a first-class axis.

Group by profile (the default in this scope, with its own persisted
choice so flipping the rail doesn't reset how you read one profile),
filter by profile, and start or import one from the same menu. Profile
groups take the project row's shape rather than a hand-rolled header,
preview the same three sessions a project does, and carry their whole
tokens-and-spend total in the slot the kebab hovers over.

Grouped lanes now rank by the active sort key, before they trim
themselves, so the rows a group hides are the ones the sort ranked last.

Defaults live in one const: the sidebar ships grouped by date, sorted by
recency, with the timestamp pinned — and "Reset to defaults" puts back
exactly that.

* fix(telegram): reset failed primary transport pool

Retryable primary errors can leave pooled sockets in CLOSE_WAIT while fallback retries continue. Replace and close failed primary generation before fallback selection.\n\nRefs #82920

* feat(file-ops): clamp oversized lines in the shell pipeline before transport

ShellFileOperations.read_file previously ran sed -n '{off},{end}p' bare, so
a file with one pathological line (e.g. a 50MB+ minified bundle on a single
line) shipped the entire line across the exec transport before Python's
per-line clamp (_add_line_numbers, MAX_LINE_LENGTH=2000) could trim it.
read_file now pipes through 'cut -b1-{4*max_line_length+1}' so the shell
bounds every line to 8001 bytes before the bytes ever reach Python.

UTF-8 finding: GNU 'cut -c' is byte-based despite its name (verified:
cutting a line of 2-byte 'é' at -c8004 splits a codepoint, leaving a bare
0xC3 lead byte). The transport decodes with errors='replace', so a split
codepoint becomes U+FFFD rather than raising — but a clamp of
max_line_length+1 BYTES would deliver under max_line_length CHARS for
multibyte text, so the Python clamp would never fire and truncation would
be silent. Using 4*max_line_length+1 bytes (UTF-8 max 4 bytes/codepoint)
guarantees any line longer than max_line_length chars still decodes to
more than max_line_length chars, so len(line) > max_line_length always
triggers the existing '... [truncated]' suffix, and any boundary U+FFFD
lands past char max_line_length where the clamp removes it — verified
empirically with fixtures ('é'*4001 splits at the byte boundary yet the
result contains no U+FFFD and ends with the truncated suffix). 'cut -b'
is used explicitly to document the byte semantics.

cut (unlike sed -n p) always newline-terminates its output, which would
grow a phantom empty final line on files without a trailing newline; the
final-page path now probes the last byte (tail -c 1 | wc -l) and strips
the artifact.

read_file_raw is untouched: it is documented as no-per-line-truncation.

Benchmark (50MB single-line fixture, /usr/bin/time -v, median of 3):
  before: 191.1 MB peak RSS, 1260 ms wall
  after:   97.8 MB peak RSS,  490 ms wall
Correctness identical in both arms: monster line returns the clamped
2000-char form + '... [truncated]', offset=2 returns the trailing normal
lines intact.

Tests: 153 passed, 0 failed, 4 skipped across the file-ops suites plus a
new tests/tools/test_read_shell_line_clamp.py pinning the monster-line
clamp, offset-past-monster reads, no-trailing-newline preservation, both
UTF-8 boundary cases, and read_file_raw's exemption. Two existing mocks
asserting the exact sed command string were updated for the pipeline.

* feat(vision): disclose downscale factor and crop offset for coordinate mapping

* feat(desktop): fade the sidebar's scrollbars out until you're in the list

A thumb parked on a list you aren't touching is chrome, not information,
and the sidebar stacks several scrollers so it draws several of them at
once. Fade them in on hover instead, sharing the existing scrollbar
colors and the webkit/Firefox split rather than styling a second kind of
bar. Only the thumb's color changes, so the reserved gutter still keeps
rows from shifting sideways.

* Port from lobehub/lobehub#17855: render notebook outputs in read_file ipynb extraction

read_file's .ipynb extraction previously dropped cell outputs entirely,
so a notebook's training logs, tracebacks, and printed results were
invisible to the model. Ported LobeHub's token-efficient conversion:

- stream text and error tracebacks are kept (ANSI-stripped, \r
  progress-bar rewrites collapsed to the final frame)
- execute_result/display_data prefer text/plain over the HTML twin
- base64 images become sized placeholders ([image/png output — 3 KB,
  omitted]); widget state and script-bearing HTML are omitted
- legacy nbformat v3 pyout/pyerr flat-field shapes handled
- per-cell output block capped at 20k chars

* feat(read): jq retrieval hint in notebook output truncation marker

* fix(gateway): carry desktop_contract when activating a lazy session (#68392)

_live_session_payload() falls back to _fallback_session_info() while a
session's agent is still None (lazy/deferred build). That fallback omitted
desktop_contract, so session.activate returned lazy metadata with no contract
field. Desktop feeds the value straight into reportBackendContract(), where a
missing field reads as contract 0 — a current backend is then falsely flagged
"Backend out of date" on every activate of a live lazy session.

The sibling session.create shape (_lazy_resume_info) was fixed the same way in
#36112; this closes the remaining session.activate gap by advertising
DESKTOP_BACKEND_CONTRACT in the fallback payload.

Adds test_session_activate_lazy_info_reports_desktop_contract pinning the
session.activate path against a lazy (agent=None) session.

* fix(desktop): give every row's trailing metadata one right-aligned slot

The PR and profile chips rendered in the row body, left of the kebab's own
column: they never sat flush right and never handed their space to the kebab
on hover, so a row showing only a PR left a hole where the age would have been.
Both now join the tokens/cost/age figures in the actions slot, and the kebab
covers the end of it — losing whichever item reads last, not the whole slot.

* fix(desktop): ship the sidebar grouped by date in every scope

The all-profiles scope defaulted to grouping by profile, so "Reset to defaults"
handed back a grouping the user never picked. Both scopes now ship by date, and
a reset clears the scope you are not looking at too — otherwise flipping the
rail restored the customization the reset was supposed to undo.

Hovering a row's PR chip also holds the kebab back now: the chip is a link, and
the button that covers the end of the trailing slot was taking the click.

* fmt(js): `npm run fix` on merge (#83078)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): titlebar clusters — macOS Y nudge, 24px targets, 13.9px icons

Left cluster gets a macOS-only translate to sit on the traffic-light row.
All titlebar tools use 24×24 hit areas with 13.9px Codicons (inline size
beats unlayered codicon.css). Clusters share one flex shell with no gap —
buttons abut and the hit target is the spacing.

* fix(desktop): sort titlebar import for eslint

* fmt(js): `npm run fix` on merge (#83099)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): don't let webview guests swallow drag gestures

* fix(desktop): keep min-width floors on stacked flex zones

* fix(desktop): reopen docked tiles at their last split share

* fix(desktop): satisfy eslint on pane-share-memory test

* fix(desktop): stop HUD window growing on drag; add corner resize handle (#83091)

* fix(desktop): stop HUD window growing on drag; add corner resize handle

The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.

Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
  drag, so the OS can never accumulate drift (verified: 500 moveBy calls
  with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
  hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
  the ability to resize a window that is otherwise non-resizable

* fix(desktop): pin HUD drag size in renderer, not main-process globals

The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.

Supersedes #82455.

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state

The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.

Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.

---------

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* feat(desktop): snap HUD to cursor with global ⌘⇧G

Register CommandOrControl+Shift+G in main while HUD mode is open so the
floating bar can jump under the pointer from any app. Tap-to-snap only —
Electron globalShortcut has no keyup for hold-to-follow.

* fix(desktop): list HUD snap chord in keyboard shortcuts panel

Document ⌘⇧G as a read-only global shortcut active while HUD mode is up.

* fix(desktop): sort hud snap imports for eslint

* fmt(js): `npm run fix` on merge (#83132)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): skip titlebar Y nudge on Tahoe and macOS fullscreen

Tahoe already aligns traffic lights without the optical translate. In
fullscreen, drop windowButtonPosition in the main process and clear the
right-cluster inset so traffic-light dodge chrome goes away on both sides.

* perf(desktop): multi-tile grids stop lagging — evict leaked session states, index lineage aliases, split the turn journal (#83133)

* fix(desktop): evict settled session states nothing on screen references

Closing a tile never removed its runtime's entry from $sessionStates, so
every tile ever closed parked its full transcript in the map for the life
of the process. Each leftover entry taxes every subsequent stream flush —
the map is spread-copied per delta and the busy/attention/draft projections
walk every entry per publish — so the app got slower the longer it ran,
which users read as "I need to clean my sessions/dbs".

Publish now evicts a settling state when no tile and not the primary view
holds its runtime (transition side effects still fire, so the settle keeps
its unread dot), and closing a tile drops an already-settled state on the
spot. Busy and needs-input states stay: background turns feed the sidebar
dots, and a first publish always lands because a resume can publish a beat
before the surface binds the runtime.

16 tiles streaming in a 2x2 grid with a day's worth of closed-tile residue:
worst-second 34 -> 58 fps, p99 frame 90 -> 28 ms, longtasks 37 -> 0.

* perf(desktop): index lineage aliases per sessions-list reference

lineageAliases scanned the whole recents list per call, and it is called
per cached session state per status projection per message delta — with a
populated sessions DB and a few busy sessions that multiplied out to
millions of row checks a second during streaming. Build the alias index
once per list reference (the list is replaced wholesale, never mutated)
and look aliases up in O(1).

* perf(desktop): journal each in-flight turn under its own storage key

The v1 journal kept every session's tail in one localStorage key, so each
throttled write re-parsed and re-stringified EVERY busy session's snapshot
— a grid of concurrent streams turned that into a whole-store JSON round
trip dozens of times a second, all on the main thread. Per-session keys
make a write O(own tail) no matter how many other sessions are streaming.
A v1 store migrates on first touch; expired/overflow crash residue is
pruned once per renderer.

* perf(desktop): stress the multitab scenario across grid/streaming/DB axes

The one-stack multitab run hid every cost this round of fixes removed: it
drove hook.publish (store only — no journal, no wiring cache), with an
empty recents list and no closed-tile residue. Streaming now routes through
hook.update (the real gateway write path), and the scenario grows axes for
the workloads users actually hit: --zones splits tiles across visible grid
zones, --streaming caps how many sessions are mid-turn (zone leaders
first), --sessions seeds a lived-in recents list, --dead models settled
sessions no surface references. launch.mjs pins HERMES_DESKTOP_CDP_PORT so
a non-default --port survives the app's own dev-CDP flag.

* fix(desktop): satisfy no-extra-boolean-cast in fullscreen guard

* fmt(js): `npm run fix` on merge (#83139)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#83143)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore: add Angriff36 to AUTHOR_MAP for PR #29543 salvage

* perf(cli): sub-400ms warm startup — probe-mode check_fns, lazy MCP SDK, banner snapshot, parallel worktree add

Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:

- aux availability probes built REAL OpenAI/httpx clients (openai import
  ~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
  returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
  construction) at module import even with zero MCP servers configured.
  SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
  find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
  defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
  launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
  (config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
  with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
  network) per launch; the tool-search gate now prefers the on-disk
  context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
  (~85ms) per SessionDB(); the reference parse is now disk-memoized by
  DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
  to a daemon thread; plugin discovery starts in the background and every
  synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
  test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
  building all ~40 subcommand parsers (bails to full dispatch on anything
  else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
  overlaps HermesCLI construction; --skills preload runs in the background
  and is folded in at agent init (finalize_preloaded_skills, same
  fail-loud contract for fully-unknown skill lists); stale-worktree prune
  moved off the banner path.

Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.

* test: read _MCP_LOGGING_CALLBACK_SUPPORTED via module after _ensure_mcp_sdk

The SDK-support flag is now bound lazily (startup-latency change); a
by-value module-level import freezes the pre-bind False. Read it off the
module after _ensure_mcp_sdk() so the test observes the real support
state — same contract, lazy-aware.

* feat(browser): integrate Browser Use CLI 3.0

* fix(browser): persist workspace across browser_exec calls; raise exec timeout 300s/1800s max; teach in-code aggregation + count verification in tool header

* fix(browser): rm secrets from browser_exec subprocess; /browser off; hide windows console

* fix(browser): apply safety checks to browser_exec URLs

* fix(browser): gate browser_exec on terminal surface; pin schema helpers digest

Follow-ups on the salvaged Browser Use CLI integration (PR #66476):

- browser_exec runs model-written Python on the host. Strip it at
  tool-definition time for sessions whose resolved toolsets exclude
  'terminal' so terminal-less surfaces (locked-down messaging configs)
  don't silently regain host code execution through the browser toolset.
  Session-level gate in model_tools, not a check_fn (check_fn results are
  TTL-cached process-wide across sessions).
- Replace the live 'browser-use skill' schema fetch with a pinned helpers
  digest: no third-party version-drifting text in the prompt, byte-stable
  schema across machines. A/B benchmarked (108 runs, opus-4.8 + kimi-k3,
  6 multi-step web tasks x 3 arms x 3 reps): pinned digest matches the
  full skill dump 36/36 vs 36/36 at ~equal tokens; both cut total task
  tokens ~60% vs the legacy browser_* toolset.
- Docs note for the terminal gate; contributor mapping for salvage.

* fix(browser): don't migrate Camofox users to Browser Use CLI mode

Camofox is selected via CAMOFOX_URL env var, not browser.cloud_provider —
so a Camofox user with a stray BROWSER_USE_API_KEY in .env matched the
legacy-migration predicate (cloud_provider unset + key present) and got
silently flipped into CLI mode, losing browser_* / Camofox entirely
(browser_exec cannot drive Camofox: its HTTP API exposes no CDP endpoint,
and the browser-use harness is CDP-only against Chromium).

is_legacy_browser_use_cloud_config() now defers to is_camofox_mode().

* feat(browser): Browser Use mode composes with all CDP browser backends

Reframe (per review): browser.backend: browser-use is now a DRIVER over
whatever browser source is configured, not a competing backend choice.

- browser_exec resolves its CDP endpoint through the same chain the
  built-in tools use: BU_* env override > BROWSER_CDP_URL/browser.cdp_url
  (/browser connect) > the configured cloud provider via browser_tool's
  _get_session_info() — sharing the per-task session cache, expiry
  replacement, inactivity reaper, and atexit cleanup instead of
  duplicating them. Live-validated against Browserbase (session created,
  driven, reaped) and gateway-provisioned Browser Use cloud browsers.
- Direct-API Browser Use configs skip provider resolution (the CLI talks
  to their cloud natively via BU_AUTOSPAWN); the Nous-gateway variant
  resolves through the provider, so subscribers get CLI mode without a
  raw BROWSER_USE_API_KEY.
- Camofox: only true fallback — Firefox-based, custom HTTP API, no CDP
  surface (its own health probes fail on CDP-schema calls). Active
  Camofox setups keep the built-in browser tools even with
  backend: browser-use set.
- hermes tools picker: provider rows and the Browser Use row are no
  longer mutually exclusive; selecting a provider keeps the driver
  choice, and both rows highlight when composed.
- Docs updated for driver-over-source semantics.

* fix(ci): review comment poller deadlocked on its own run

The poller job set GITHUB_RUN_ID in env: to point at the CI run.
The Actions runner sets the GITHUB_* defaults itself and ignores
the override. Thus the poller read its own run id and watched
itself. Its own run stays in_progress while the poller runs, so
runs_all_completed() was never true. The comment froze at
'waiting for jobs to start' and the job burned its full 3000s
timeout on every PR.

Rename the variable to CI_RUN_ID. Also drop the GITHUB_REPOSITORY
override — it was a no-op for the same reason, and the runner
default already holds the correct value.

* fix(sec): patch the npm advisories main left open

Main (7537de9e7) moved most of the vulnerable locked versions, but some
fixes live only in the lockfiles and some advisories stayed open. This
commit closes the rest:

website/package.json gets durable overrides for js-yaml 4.3.1,
dompurify 3.4.13, mermaid 11.16.1, and tar 7.5.22. The root workspace
gets the same tar override, which moves the tar 6.2.1 copies under
get-windows and @mapbox/node-pre-gyp past twelve open advisories.
Without an override, a reinstall can pull an old transitive copy back
in.

image-size <=2.0.2 has two infinite-loop DoS advisories and no fixed
release upstream. An override points it at @nous-research/image-size
2.0.3, our maintained fork of the real repo. The OSV scanner resolves
the aliased fork cleanly, so no ignore entries are needed.

The photon sidecar moves @opentelemetry/core to 2.10.0. The
whatsapp-bridge gets a body-parser 1.20.6 override, so the lockfile-only
fix from main cannot regress on reinstall.

website/.npmrc gets matching min-release-age exclusions for the fix
releases that are less than two weeks old.

electron stays at 40.10.2. The 41.x fix for GHSA-9f4c-93c8-jc8g brings
back the install failure that bb8280b75 reverted: install.js in 40.10.3+
extracts with an MSVC native binding, which fails on Windows machines
without the VC++ Redistributable. Upstream tracks this in
electron/electron#52481, with no fix released.

* fix(sec): move cryptography to 50.0.0

cryptography 48.0.1 carries three advisories (GHSA-m2h6-j472-rp4c,
GHSA-jwv3-5hgf-82ww, CVE-2026-69247). msal and alibabacloud-tea-openapi
cap cryptography below 49, so the bump needs an override-dependencies
entry in [tool.uv] to take effect.

The cap is conservative, not a real limit: we installed tea-openapi
against cryptography 50 and its client ran with no errors.

This override only governs `uv lock` / `uv sync`. The lazy-install
path does not read [tool.uv] and can still downgrade the pin; the next
commit closes that path.

aiohttp moves to 3.14.3 in the same pass, for GHSA-9548-qrrj-x5pj.

* docs(kanban): document the parent-link context handoff for follow-up cards

Adds 'Handing context to follow-up cards (the parent link)' to the kanban
feature page and a CI-remediation worked example to the tutorial, with
zh-Hans…
vashkartik added a commit to vashkartik/hermes-agent that referenced this pull request Aug 12, 2026
* fix(desktop): send full tool args so expanded rows show the whole command

The gateway sent only an 80-char preview (context) for a tool call.
The desktop rebuilds the expanded tool row from the args of the part.
When the args were absent, the row showed the preview, and long
commands ended in '...' after the user expanded them.

Two paths had this fault:

- tool.start: the payload had no args until tool.complete, so the
  expanded row was truncated while the tool ran. Now tool.start ships
  the args, the same as tool.complete already does.
- _history_to_messages: the projection read the full arguments, then
  discarded them. Hydration from this projection (watch windows,
  compress, branch, seeded create) kept only the preview, so the
  truncation was permanent. Now tool rows carry the args. This
  projection is the display view of the transcript — each renderer
  decides what to paint, and the preview stays for collapsed titles.

The DB rows do not change: the args already persist in tool_calls.

* fix(skills): trim ast-grep description to the 60-char hardline

test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.

* fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too

Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.

_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).

Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).

Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.

Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.

* fix(gateway): also persist user_id and session_key in child-session creates

The sweeper flagged two gaps in the routing-columns fix:

1. /branch create_session() omitted user_id and session_key — the
   fallback lookup path (find_latest_gateway_session_for_peer) requires
   user_id to match the complete peer tuple when session_key lookup fails,
   and /resume IDOR guards reject sessions without matching user_id.

2. Compression-rotation create_session() omitted agent._user_id — same
   problem: rotated child cannot satisfy persisted /resume ownership proof
   before the later gateway backfill.

Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.

Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.

* fix(gateway): carry origin_json/display_name into /branch child sessions too

Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().

The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.

* fix(gateway): distinguish durable cached transcript rows

* chore: map TomAce7 contributor email for attribution audit

* fix(gateway): respect reset boundaries during recovery (#68539)

find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)

* fix(gateway): honor session_reset policy when recovering sessions

Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)

* chore: map contributor email for hillimited

* fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)

Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.

Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.

Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.

* test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes

Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.

* fmt(js): `npm run fix` on merge (#82771)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): make un-highlighted code readable while streaming in light theme

streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.

traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.

fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.

* test: run os-specific tests on their real host, not a faked one

many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.

* ci: add macos and windows test lanes for the os-marked tests

the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.

* ci: print the zero-selection diagnostic instead of dying first

`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.

* test: convert the last host-OS fakes and guard double markers

Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:

- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
  picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
  in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
  marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
  $BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
  fallback could not change the result. The assertion now reads the host, so
  the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
  CoreAudio init raises a TCC prompt, which no Linux runner reproduces.

tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.

The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.

* fix(ci): don't report all-good before jobs start

The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.

* fix(agent): persist completed text turns before the loop exits (#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: follow-up for salvaged PR #81692

- warn (not debug) on final text-turn flush failure: a failure here
  reopens the exact #81641 data-loss window with _persist_session as
  the only remaining retry, unlike the verify siblings which retry
  in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
  test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
  change fails with a clean assertion instead of ValueError from max()

* fix(tui): recover active goals after compression exhaustion

* fix(agent): keep the thinking-prefill marker so the drop pass can strip trailing stubs

* test(agent): cover the API-copy build so restoring the marker pop fails

* fix: trim comments and fix sibling pop site in summary path

Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.

* fix(skills): reject colon in bundle path components (NTFS ADS bypass)

_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.

Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.

Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.

Reported-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>

* fix(cron): load .env on no_agent path so standalone ticks resolve delivery home channels

hermes-cron-tick.service starts without TELEGRAM_HOME_CHANNEL/DISCORD_HOME_CHANNEL
in the unit env; the per-run load_hermes_dotenv reload lived only on the agent
path (after the no_agent short-circuit returns), so every deliver=telegram/all
script job failed with 'no delivery target resolved'. Load the dotenv at the top
of the no_agent branch; override=False keeps the gateway's in-process tick
behavior unchanged.

* fix(cron): surface exception type and traceback for standalone Discord delivery errors

* refactor: drop dead sys.exc_info check in delivery error log

The result-error path in _deliver_result is not inside an except block,
so sys.exc_info() always returns (None, None, None) — the condition was
always False. Simplify to a plain logger.error call with accurate comment.

* chore: AUTHOR_MAP for aameobius@gmail.com → francialisomlimoeiro

PR #82682 salvage contributor attribution.

* fix(gateway): keep the personality pivot out of the truncate ordinal space (#82756)

`truncate_before_user_ordinal` is an index into the list of *real* user
turns. The gateway builds that list with `role == "user" and not
display_kind`, and `test_prompt_submit_truncate_ordinal_skips_display_kind_rows`
already pins why: "Without the filter, a trailing marker shifts the ordinal
so the wrong message is targeted for truncation."

`_apply_personality_to_session` broke that invariant at the producer. Its
pivot marker rides as `role=user` — deliberately, so strict
OpenAI-compatible providers accept it mid-conversation (the same reason
`_append_model_switch_marker` does) — but unlike the model-switch marker it
carried no `display_kind`. The gateway therefore counted it as a real user
turn while no client ever renders it as one.

After a personality change the two sides address different lists: every
later rewind/edit/regenerate resolves one slot too early, and
`replace_messages()` hard-DELETEs the extra span. That is the reported
signature — an in-range, valid ordinal, `confirm_truncate: true`, and a cut
that moved backwards with no user rewind action.

Tag the pivot like the model-switch marker, and teach the desktop to
project the kind as a timeline row so a persisted marker is never rendered
— or counted — as a user turn on the client side either. Both ends must
exclude it; excluding it on only one end just inverts the drift.

The regression test drives the real injection point rather than a
hand-written marker dict. Without the fix it fails with "the pivot shifted
the ordinal: the cut landed at 3 instead of 5", losing a turn the user
never asked to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(state): make a rewind truncation recoverable instead of a hard DELETE (#82756)

Guarding the *aim* of a rewind still leaves every other way of aiming it
wrong terminal. All three reported incidents (#70516, #80763, #82756) ended
at the same write — `replace_messages()` in the `prompt.submit` truncation
path — and all three were unrecoverable for the same reason: the rows are
DELETEd, which also evicts them from the FTS index, so there is no `active=0`
archive and nothing to restore from.

The codebase already draws this distinction and already has the safe half of
it. `archive_and_compact` is documented as "the durability-preserving
alternative to replace_messages"; `rewind_to_message` — the `/undo` path —
soft-deletes to `active=0, compacted=0` and keeps the rows "on disk for audit
/ forensic inspection". The desktop rewind is the same user-facing operation
as `/undo` and was the one taking the destructive branch.

`replace_messages(..., archive_dropped=True)` flips the DELETE to a
content-preserving `UPDATE messages SET active = 0`, reusing the existing
transaction and the existing `active=0, compacted=0` marking so the dropped
turns stay readable via `get_messages(..., include_inactive=True)` and stay
out of session search (`compacted=0` = "the user took it back", vs
compaction's `compacted=1` = "summarized away, still discoverable").

The live transcript is byte-identical either way — only the durability of the
dropped turns changes. The parameter defaults to False, so the fork handler,
the ACP adapter and `gateway/session.py` keep their current semantics
untouched; a test pins that.

`active_only=True` stays on the call: #80216 still applies, and archiving must
not disturb rows an earlier compaction deliberately archived.

Test doubles for `replace_messages` in the gateway suite are widened to the
real signature — they are stand-ins for SessionDB, and a double that does not
accept what production passes silently converts this write into a 5008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gateway): reject boolean ordinals and bare confirm_truncate on prompt.submit

Two hardening guards extracted from #82766 by @StanleyStetson:

- bool is an int subclass, so a JSON `true` in truncate_before_user_ordinal
  coerced via int() to ordinal 1 and aimed a CONFIRMED rewind at the second
  user turn — the same silent-loss class as #82756. Reject with 4004.
- confirm_truncate with no truncation target is leaked client rewind state
  on an ordinary submit; fail fast with 4004 instead of silently ignoring
  the flag, so the corrupted client state is surfaced.

Part of the composite fix for #82756.

* fix: close sibling display_kind drops and ui-tui parity for #82756

Review follow-ups on the composite salvage (whole-bug-class sweep):

- session.branch and _persist_branch_seed copied parent history without
  display_kind/display_metadata, so a tagged timeline marker (personality
  pivot, model switch, auto-continue) re-entered the branched session as a
  bare role=user row after a restart — re-planting the phantom-ordinal
  class this PR fixes. Both projection dicts now carry the tags; regression
  asserts added to both branch tests (mutation-checked: fail without the
  fix).
- ui-tui renderer learns display_kind=personality_switch (was falling
  through to an opaque user bubble; desktop got the case in commit 1).
- programmatic-integration docs: document the two new 4004 refusals
  (boolean ordinal, bare confirm_truncate).
- hermes_state comment: archived rows are searchable only with
  include_inactive=True, not by default search — align comment with the
  actual FTS filter.
- strip stray trailing blank line in test_tui_gateway_server.py

* fmt(js): `npm run fix` on merge (#82962)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): keep react-router in one runtime chunk

* feat(skills-hub): fall back to live repo for optional skills missing from local checkout

Optional skills merged to main after a user's install was cut were
invisible to 'hermes skills install official/...' until they ran
'hermes update' — the OptionalSkillSource only scanned the local
optional-skills/ checkout.

Now, when an official/<category>/<skill> identifier is not found
locally, OptionalSkillSource resolves it against the live default
branch of NousResearch/hermes-agent: one Trees API call enumerates
optional-skills/*/SKILL.md dirs (cached on disk via the shared index
cache, 1h TTL), then the full skill directory is downloaded byte-exact
(including root-level install scripts, LICENSE, tests/ — files the
generic GitHubSource.fetch path drops). search() and inspect() also
surface remote-only skills so discovery works pre-update too.

Local checkout always wins when present; offline degrades to the old
local-only behavior; traversal and ambiguous bare names are refused;
provenance stays official/builtin.

* fix(update): force-reload config modules before migration check

hermes update runs in the PRE-pull Python process. After git pull
updates the source files on disk, sys.modules still holds the OLD
hermes_cli.config and hermes_cli.config_migrations. Function-level
imports return the cached module, so DEFAULT_CONFIG["_config_version"]
is the OLD value and check_config_version() reports (33, 33) —
"up to date" — even though the freshly-pulled code has v34 with a
migration to run.

The personality reset migration (#81946) was silently skipped this
way: display.personality: kawaii stayed active after updates that
should have reset it. Every user who updated from a pre-v34 codebase
to a post-v34 codebase was affected.

Fix: _run_config_check_fresh and _run_migrate_config_fresh call
importlib.reload() on hermes_cli.config_defaults, hermes_cli.config,
and hermes_cli.config_migrations before calling check_config_version
and migrate_config. This forces the modules to be re-read from the
updated source files on disk.

* fix(transport): use getattr for supports_prompt_cache_key on stale profiles

After a partial update (stash restore overwriting providers/base.py with
an older version), the NousProfile singleton was instantiated from a
ProviderProfile class that predates the supports_prompt_cache_key field
(added in f4fb23f3d). Accessing profile.supports_prompt_cache_key raised
AttributeError, crashing every API call with:
  'NousProfile' object has no attribute 'supports_prompt_cache_key'

Use getattr(profile, 'supports_prompt_cache_key', False) so a stale
profile degrades to 'no prompt cache key' instead of crashing.

* docs(sessions): document repair-routing and the continuity guarantees

User-visible surface from the #82616 session-continuity campaign:
- sessions.md: 'Repair Stranded Gateway Sessions' (evidence rules,
  dry-run-first, why adoption is never automatic) and 'Continuity After
  Crashes and Restarts' (atomic identity, self-heal, recency resolution,
  reset-boundary fence)
- cli-commands.md: repair-routing row in the hermes sessions table

Docs build verified (en + zh-Hans).

* feat(tools): stat-based special-file guard for read_file + readtool eval harness

read_file on a workspace FIFO/socket blocked until the exec timeout —
the existing device guard is name-based (/dev/*, /proc/*) and cannot
see an arbitrary special file. Add _special_file_kind(): one os.stat
on the resolved path, refusing FIFO/socket/char/block devices with a
plain note ('no read was attempted') instead of hanging. Host-visible
filesystems only; regular files, dirs, and missing paths unchanged.

Also adds evals/readtool/: an A/B harness that runs the real AIAgent
against hostile-file fixtures (huge lockfile, one-line bundle, FIFO,
NFD filenames, lying extensions) and measures accuracy, turns, tool
calls, and tokens. Measured for this guard (3 reps, file-only arm):
qwen3.8-max fifo task tokens 122k -> 26k (-79%), turns 9.3 -> 5.0;
opus-4.8 tokens 40k -> 23k; accuracy held 1.00 both arms.

* chore(evals): track results/.gitignore (its own * rule excluded it from the original add)

* feat(tools): unicode-equivalent filename retry + near-miss suggestions in read_file

NFC/NFD, narrow no-break space (U+202F), and curly quotes render
identically in a terminal — a model retyping a visually-correct path
gets 'file not found' and can never discover the byte mismatch on its
own. On not-found, canonicalize the requested name and compare against
directory entries; exactly ONE equivalent spelling reads transparently
with an explanatory note. Zero or several matches (homoglyph twins)
fall through — never guess between collisions.

Also: difflib.SequenceMatcher >=0.8 fallback in _suggest_similar_files
catches near-miss typos (AGENT.md -> AGENTS.md) that substring scoring
misses entirely.

Measured (file-only arm, 3 reps, control=guard-only vs feature):
unicode task qwen3.8-max 31k->16k tok (-48%), turns 6.7->3.7;
opus-4.8 57k->33k tok (-42%), turns 8.3->5.0; accuracy held 1.00.
near-miss: opus mildly better, qwen flat, no regressions.

* fix(ci): start the poller on in_progress, key concurrency per repo

The requested trigger fires when GitHub creates the run. A run from a
first-time contributor waits in action_required, and the poller then
polls a run that never starts until its timeout. The in_progress
trigger fires when the run starts, and it also fires on a re-run.

The concurrency group now contains the head repository. Fork PRs
frequently share a branch name, and two PRs must not cancel the
poller of each other.

* fix(ci): keep review-gated files out of the js-autofix patch

The dep-version-gate ruleset requires a team review for package
manifests, eslint configs, and workflow files. If the autofix patch
contains one of these files, the bot PR waits for that review and
auto-merge stops. The patch step now excludes them, so a bot PR
never gates itself. The eslint check in typecheck.yml still reports
their lint errors.

* fix(ci): unbuffer live comment poller output

* feat(tools): name the dead end — past-EOF and empty-file notes in read_file

A read past EOF returned content '900|' (a phantom line-number prefix
that looks like a real line) and an empty file returned '1|' — both
ambiguous silence: indistinguishable, from inside the model, from a
broken tool, so it re-reads and widens windows. Name the dead end and
its recovery instead: 'offset 900 is beyond the end of the file (412
lines total). Retry with offset <= 412.' / 'File is empty (0 bytes).'
Notes, not errors — a fact about the file is not a failure.

Boundary pinned by test: offset == total_lines still reads (an
off-by-one in a resume hint is a silently corrupted read).

Measured (file-only arm, 3 reps, control vs feature): qwen3.8-max
-18% tokens, -26% tool calls, -17% turns across the two affected
tasks; opus-4.8 flat (within rep noise); accuracy held 1.00.

* fix(process): reject non-positive wait timeouts; distinguish log offset=0 from default

Two falsy-zero coercions in process_registry (salvaged from PR #60004,
credit @isheng-eqi; the EOF half of that PR landed separately in
893792c99):

- wait(timeout=0): schema says minimum=1 but the handler let 0 fall
  through '0 or max_timeout' to the DEFAULT wait instead of rejecting.
- read_log(offset=0): conflated with the offset-unset default, silently
  returning the TAIL of the log when the caller asked for the head.
  Default is now offset=None; explicit 0 paginates from line one.

* chore: map contributor email for salvaged commit

* fix(file-ops): stop read_file blocking forever on non-regular files

The size probe every read path starts with — `wc -c < path` — opens the
path. On a FIFO with no writer, a socket, or a character device that never
reaches EOF, that read never returns, and read_file/read_file_raw/
read_file_bytes all pass no timeout to _exec. The turn wedges until the
process is killed.

The device blocklist in tools/file_tools.py cannot close this: it matches
literal /dev/* names, so it can only ever cover paths someone thought to
enumerate. A FIFO is a file type and can sit at any path.

Gate the probe behind `[ -f ]`, which stats instead of opening, and report
a path that exists but is not a regular file as such. A missing path keeps
its existing not-found handling.

* test: adapt read mocks and fifo guard test to the sentinel probe

The combined [ -f ]/wc -c probe changes the first shell command each
read issues; update the stale mocks that only answered bare 'wc -c'.
The fifo tool-layer test now accepts the merged stat-guard's
success=False note (a fact, not an error) with the shell sentinel
behind it.

* test: adapt edge-case pagination mock to the sentinel probe

Same stale-mock class as the previous commit — the sweep missed
test_file_operations_edge_cases.py. Verified no bare wc -c mocks
remain anywhere under tests/.

* fix(desktop): support keyless plugin rows

* feat(profiles): serve a cross-profile project tree and per-profile usage totals

`projects.tree` answers for the backend's own profile, so the grouped
sidebar had nothing to draw once the user asked to see every profile.
Run the same authoritative builder once per profile against that
profile's state.db and merge the results by folder, so one checkout is
one group no matter how many profiles work in it, and the owning profile
rides on each session row where the badge and filter can read it.

Group totals are summed in SQL rather than over the loaded page — a
number that shrank as you scrolled would be worse than no number.

Scope the batched sidebar slices while we're here: cron and messaging
came back cross-profile unconditionally, which is why a concrete profile
showed another profile's Telegram threads and cronjobs.

Closes #65710
Closes #42651
Closes #70629

* fix(desktop): preserve keyless plugin row identity

* fix(desktop): hoist the sidebar's sort key out of the flat list

The sort key was applied where the flat recents list is assembled, so it
did nothing at all once rows moved into groups: picking "cost" while
grouped by project or profile left every lane in the order the backend
sent it. Rank in a store instead, above any one view, so a grouped
surface can order the rows it owns by the same key.

* fix(desktop): read-only keyless plugin rows + backend contract v6

Rework of the salvaged #82828 compatibility layer: keep the crash guards
(optional key, safe filter/search, synthetic React row identity) but drop
the name-addressed toggle fallback — bare names collide across category
dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
moved to key-addressed toggles (a60b492e07). Keyless rows from a
pre-contract backend now render with a disabled switch and an 'update
your backend' tooltip instead of resurrecting the collision-prone
protocol.

Bump DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT to 6 so the
existing skew toast surfaces the real remedy (one-click backend update)
on session open.

* feat(desktop): show every profile's sessions in the sidebar

All-profiles mode listed a flat page of chats and stopped there: the
project tree was the active profile's, grouping and filtering had no
notion of an owner, and each profile lane paged itself against a
separate endpoint. Multi-agent workflows live across profiles, so the
sidebar now treats the owner as a first-class axis.

Group by profile (the default in this scope, with its own persisted
choice so flipping the rail doesn't reset how you read one profile),
filter by profile, and start or import one from the same menu. Profile
groups take the project row's shape rather than a hand-rolled header,
preview the same three sessions a project does, and carry their whole
tokens-and-spend total in the slot the kebab hovers over.

Grouped lanes now rank by the active sort key, before they trim
themselves, so the rows a group hides are the ones the sort ranked last.

Defaults live in one const: the sidebar ships grouped by date, sorted by
recency, with the timestamp pinned — and "Reset to defaults" puts back
exactly that.

* fix(telegram): reset failed primary transport pool

Retryable primary errors can leave pooled sockets in CLOSE_WAIT while fallback retries continue. Replace and close failed primary generation before fallback selection.\n\nRefs #82920

* feat(file-ops): clamp oversized lines in the shell pipeline before transport

ShellFileOperations.read_file previously ran sed -n '{off},{end}p' bare, so
a file with one pathological line (e.g. a 50MB+ minified bundle on a single
line) shipped the entire line across the exec transport before Python's
per-line clamp (_add_line_numbers, MAX_LINE_LENGTH=2000) could trim it.
read_file now pipes through 'cut -b1-{4*max_line_length+1}' so the shell
bounds every line to 8001 bytes before the bytes ever reach Python.

UTF-8 finding: GNU 'cut -c' is byte-based despite its name (verified:
cutting a line of 2-byte 'é' at -c8004 splits a codepoint, leaving a bare
0xC3 lead byte). The transport decodes with errors='replace', so a split
codepoint becomes U+FFFD rather than raising — but a clamp of
max_line_length+1 BYTES would deliver under max_line_length CHARS for
multibyte text, so the Python clamp would never fire and truncation would
be silent. Using 4*max_line_length+1 bytes (UTF-8 max 4 bytes/codepoint)
guarantees any line longer than max_line_length chars still decodes to
more than max_line_length chars, so len(line) > max_line_length always
triggers the existing '... [truncated]' suffix, and any boundary U+FFFD
lands past char max_line_length where the clamp removes it — verified
empirically with fixtures ('é'*4001 splits at the byte boundary yet the
result contains no U+FFFD and ends with the truncated suffix). 'cut -b'
is used explicitly to document the byte semantics.

cut (unlike sed -n p) always newline-terminates its output, which would
grow a phantom empty final line on files without a trailing newline; the
final-page path now probes the last byte (tail -c 1 | wc -l) and strips
the artifact.

read_file_raw is untouched: it is documented as no-per-line-truncation.

Benchmark (50MB single-line fixture, /usr/bin/time -v, median of 3):
  before: 191.1 MB peak RSS, 1260 ms wall
  after:   97.8 MB peak RSS,  490 ms wall
Correctness identical in both arms: monster line returns the clamped
2000-char form + '... [truncated]', offset=2 returns the trailing normal
lines intact.

Tests: 153 passed, 0 failed, 4 skipped across the file-ops suites plus a
new tests/tools/test_read_shell_line_clamp.py pinning the monster-line
clamp, offset-past-monster reads, no-trailing-newline preservation, both
UTF-8 boundary cases, and read_file_raw's exemption. Two existing mocks
asserting the exact sed command string were updated for the pipeline.

* feat(vision): disclose downscale factor and crop offset for coordinate mapping

* feat(desktop): fade the sidebar's scrollbars out until you're in the list

A thumb parked on a list you aren't touching is chrome, not information,
and the sidebar stacks several scrollers so it draws several of them at
once. Fade them in on hover instead, sharing the existing scrollbar
colors and the webkit/Firefox split rather than styling a second kind of
bar. Only the thumb's color changes, so the reserved gutter still keeps
rows from shifting sideways.

* Port from lobehub/lobehub#17855: render notebook outputs in read_file ipynb extraction

read_file's .ipynb extraction previously dropped cell outputs entirely,
so a notebook's training logs, tracebacks, and printed results were
invisible to the model. Ported LobeHub's token-efficient conversion:

- stream text and error tracebacks are kept (ANSI-stripped, \r
  progress-bar rewrites collapsed to the final frame)
- execute_result/display_data prefer text/plain over the HTML twin
- base64 images become sized placeholders ([image/png output — 3 KB,
  omitted]); widget state and script-bearing HTML are omitted
- legacy nbformat v3 pyout/pyerr flat-field shapes handled
- per-cell output block capped at 20k chars

* feat(read): jq retrieval hint in notebook output truncation marker

* fix(gateway): carry desktop_contract when activating a lazy session (#68392)

_live_session_payload() falls back to _fallback_session_info() while a
session's agent is still None (lazy/deferred build). That fallback omitted
desktop_contract, so session.activate returned lazy metadata with no contract
field. Desktop feeds the value straight into reportBackendContract(), where a
missing field reads as contract 0 — a current backend is then falsely flagged
"Backend out of date" on every activate of a live lazy session.

The sibling session.create shape (_lazy_resume_info) was fixed the same way in
#36112; this closes the remaining session.activate gap by advertising
DESKTOP_BACKEND_CONTRACT in the fallback payload.

Adds test_session_activate_lazy_info_reports_desktop_contract pinning the
session.activate path against a lazy (agent=None) session.

* fix(desktop): give every row's trailing metadata one right-aligned slot

The PR and profile chips rendered in the row body, left of the kebab's own
column: they never sat flush right and never handed their space to the kebab
on hover, so a row showing only a PR left a hole where the age would have been.
Both now join the tokens/cost/age figures in the actions slot, and the kebab
covers the end of it — losing whichever item reads last, not the whole slot.

* fix(desktop): ship the sidebar grouped by date in every scope

The all-profiles scope defaulted to grouping by profile, so "Reset to defaults"
handed back a grouping the user never picked. Both scopes now ship by date, and
a reset clears the scope you are not looking at too — otherwise flipping the
rail restored the customization the reset was supposed to undo.

Hovering a row's PR chip also holds the kebab back now: the chip is a link, and
the button that covers the end of the trailing slot was taking the click.

* fmt(js): `npm run fix` on merge (#83078)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): titlebar clusters — macOS Y nudge, 24px targets, 13.9px icons

Left cluster gets a macOS-only translate to sit on the traffic-light row.
All titlebar tools use 24×24 hit areas with 13.9px Codicons (inline size
beats unlayered codicon.css). Clusters share one flex shell with no gap —
buttons abut and the hit target is the spacing.

* fix(desktop): sort titlebar import for eslint

* fmt(js): `npm run fix` on merge (#83099)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): don't let webview guests swallow drag gestures

* fix(desktop): keep min-width floors on stacked flex zones

* fix(desktop): reopen docked tiles at their last split share

* fix(desktop): satisfy eslint on pane-share-memory test

* fix(desktop): stop HUD window growing on drag; add corner resize handle (#83091)

* fix(desktop): stop HUD window growing on drag; add corner resize handle

The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.

Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
  drag, so the OS can never accumulate drift (verified: 500 moveBy calls
  with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
  hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
  the ability to resize a window that is otherwise non-resizable

* fix(desktop): pin HUD drag size in renderer, not main-process globals

The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.

Supersedes #82455.

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state

The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.

Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.

---------

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* feat(desktop): snap HUD to cursor with global ⌘⇧G

Register CommandOrControl+Shift+G in main while HUD mode is open so the
floating bar can jump under the pointer from any app. Tap-to-snap only —
Electron globalShortcut has no keyup for hold-to-follow.

* fix(desktop): list HUD snap chord in keyboard shortcuts panel

Document ⌘⇧G as a read-only global shortcut active while HUD mode is up.

* fix(desktop): sort hud snap imports for eslint

* fmt(js): `npm run fix` on merge (#83132)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): skip titlebar Y nudge on Tahoe and macOS fullscreen

Tahoe already aligns traffic lights without the optical translate. In
fullscreen, drop windowButtonPosition in the main process and clear the
right-cluster inset so traffic-light dodge chrome goes away on both sides.

* perf(desktop): multi-tile grids stop lagging — evict leaked session states, index lineage aliases, split the turn journal (#83133)

* fix(desktop): evict settled session states nothing on screen references

Closing a tile never removed its runtime's entry from $sessionStates, so
every tile ever closed parked its full transcript in the map for the life
of the process. Each leftover entry taxes every subsequent stream flush —
the map is spread-copied per delta and the busy/attention/draft projections
walk every entry per publish — so the app got slower the longer it ran,
which users read as "I need to clean my sessions/dbs".

Publish now evicts a settling state when no tile and not the primary view
holds its runtime (transition side effects still fire, so the settle keeps
its unread dot), and closing a tile drops an already-settled state on the
spot. Busy and needs-input states stay: background turns feed the sidebar
dots, and a first publish always lands because a resume can publish a beat
before the surface binds the runtime.

16 tiles streaming in a 2x2 grid with a day's worth of closed-tile residue:
worst-second 34 -> 58 fps, p99 frame 90 -> 28 ms, longtasks 37 -> 0.

* perf(desktop): index lineage aliases per sessions-list reference

lineageAliases scanned the whole recents list per call, and it is called
per cached session state per status projection per message delta — with a
populated sessions DB and a few busy sessions that multiplied out to
millions of row checks a second during streaming. Build the alias index
once per list reference (the list is replaced wholesale, never mutated)
and look aliases up in O(1).

* perf(desktop): journal each in-flight turn under its own storage key

The v1 journal kept every session's tail in one localStorage key, so each
throttled write re-parsed and re-stringified EVERY busy session's snapshot
— a grid of concurrent streams turned that into a whole-store JSON round
trip dozens of times a second, all on the main thread. Per-session keys
make a write O(own tail) no matter how many other sessions are streaming.
A v1 store migrates on first touch; expired/overflow crash residue is
pruned once per renderer.

* perf(desktop): stress the multitab scenario across grid/streaming/DB axes

The one-stack multitab run hid every cost this round of fixes removed: it
drove hook.publish (store only — no journal, no wiring cache), with an
empty recents list and no closed-tile residue. Streaming now routes through
hook.update (the real gateway write path), and the scenario grows axes for
the workloads users actually hit: --zones splits tiles across visible grid
zones, --streaming caps how many sessions are mid-turn (zone leaders
first), --sessions seeds a lived-in recents list, --dead models settled
sessions no surface references. launch.mjs pins HERMES_DESKTOP_CDP_PORT so
a non-default --port survives the app's own dev-CDP flag.

* fix(desktop): satisfy no-extra-boolean-cast in fullscreen guard

* fmt(js): `npm run fix` on merge (#83139)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#83143)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore: add Angriff36 to AUTHOR_MAP for PR #29543 salvage

* perf(cli): sub-400ms warm startup — probe-mode check_fns, lazy MCP SDK, banner snapshot, parallel worktree add

Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:

- aux availability probes built REAL OpenAI/httpx clients (openai import
  ~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
  returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
  construction) at module import even with zero MCP servers configured.
  SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
  find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
  defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
  launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
  (config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
  with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
  network) per launch; the tool-search gate now prefers the on-disk
  context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
  (~85ms) per SessionDB(); the reference parse is now disk-memoized by
  DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
  to a daemon thread; plugin discovery starts in the background and every
  synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
  test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
  building all ~40 subcommand parsers (bails to full dispatch on anything
  else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
  overlaps HermesCLI construction; --skills preload runs in the background
  and is folded in at agent init (finalize_preloaded_skills, same
  fail-loud contract for fully-unknown skill lists); stale-worktree prune
  moved off the banner path.

Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.

* test: read _MCP_LOGGING_CALLBACK_SUPPORTED via module after _ensure_mcp_sdk

The SDK-support flag is now bound lazily (startup-latency change); a
by-value module-level import freezes the pre-bind False. Read it off the
module after _ensure_mcp_sdk() so the test observes the real support
state — same contract, lazy-aware.

* feat(browser): integrate Browser Use CLI 3.0

* fix(browser): persist workspace across browser_exec calls; raise exec timeout 300s/1800s max; teach in-code aggregation + count verification in tool header

* fix(browser): rm secrets from browser_exec subprocess; /browser off; hide windows console

* fix(browser): apply safety checks to browser_exec URLs

* fix(browser): gate browser_exec on terminal surface; pin schema helpers digest

Follow-ups on the salvaged Browser Use CLI integration (PR #66476):

- browser_exec runs model-written Python on the host. Strip it at
  tool-definition time for sessions whose resolved toolsets exclude
  'terminal' so terminal-less surfaces (locked-down messaging configs)
  don't silently regain host code execution through the browser toolset.
  Session-level gate in model_tools, not a check_fn (check_fn results are
  TTL-cached process-wide across sessions).
- Replace the live 'browser-use skill' schema fetch with a pinned helpers
  digest: no third-party version-drifting text in the prompt, byte-stable
  schema across machines. A/B benchmarked (108 runs, opus-4.8 + kimi-k3,
  6 multi-step web tasks x 3 arms x 3 reps): pinned digest matches the
  full skill dump 36/36 vs 36/36 at ~equal tokens; both cut total task
  tokens ~60% vs the legacy browser_* toolset.
- Docs note for the terminal gate; contributor mapping for salvage.

* fix(browser): don't migrate Camofox users to Browser Use CLI mode

Camofox is selected via CAMOFOX_URL env var, not browser.cloud_provider —
so a Camofox user with a stray BROWSER_USE_API_KEY in .env matched the
legacy-migration predicate (cloud_provider unset + key present) and got
silently flipped into CLI mode, losing browser_* / Camofox entirely
(browser_exec cannot drive Camofox: its HTTP API exposes no CDP endpoint,
and the browser-use harness is CDP-only against Chromium).

is_legacy_browser_use_cloud_config() now defers to is_camofox_mode().

* feat(browser): Browser Use mode composes with all CDP browser backends

Reframe (per review): browser.backend: browser-use is now a DRIVER over
whatever browser source is configured, not a competing backend choice.

- browser_exec resolves its CDP endpoint through the same chain the
  built-in tools use: BU_* env override > BROWSER_CDP_URL/browser.cdp_url
  (/browser connect) > the configured cloud provider via browser_tool's
  _get_session_info() — sharing the per-task session cache, expiry
  replacement, inactivity reaper, and atexit cleanup instead of
  duplicating them. Live-validated against Browserbase (session created,
  driven, reaped) and gateway-provisioned Browser Use cloud browsers.
- Direct-API Browser Use configs skip provider resolution (the CLI talks
  to their cloud natively via BU_AUTOSPAWN); the Nous-gateway variant
  resolves through the provider, so subscribers get CLI mode without a
  raw BROWSER_USE_API_KEY.
- Camofox: only true fallback — Firefox-based, custom HTTP API, no CDP
  surface (its own health probes fail on CDP-schema calls). Active
  Camofox setups keep the built-in browser tools even with
  backend: browser-use set.
- hermes tools picker: provider rows and the Browser Use row are no
  longer mutually exclusive; selecting a provider keeps the driver
  choice, and both rows highlight when composed.
- Docs updated for driver-over-source semantics.

* fix(ci): review comment poller deadlocked on its own run

The poller job set GITHUB_RUN_ID in env: to point at the CI run.
The Actions runner sets the GITHUB_* defaults itself and ignores
the override. Thus the poller read its own run id and watched
itself. Its own run stays in_progress while the poller runs, so
runs_all_completed() was never true. The comment froze at
'waiting for jobs to start' and the job burned its full 3000s
timeout on every PR.

Rename the variable to CI_RUN_ID. Also drop the GITHUB_REPOSITORY
override — it was a no-op for the same reason, and the runner
default already holds the correct value.

* fix(sec): patch the npm advisories main left open

Main (7537de9e7) moved most of the vulnerable locked versions, but some
fixes live only in the lockfiles and some advisories stayed open. This
commit closes the rest:

website/package.json gets durable overrides for js-yaml 4.3.1,
dompurify 3.4.13, mermaid 11.16.1, and tar 7.5.22. The root workspace
gets the same tar override, which moves the tar 6.2.1 copies under
get-windows and @mapbox/node-pre-gyp past twelve open advisories.
Without an override, a reinstall can pull an old transitive copy back
in.

image-size <=2.0.2 has two infinite-loop DoS advisories and no fixed
release upstream. An override points it at @nous-research/image-size
2.0.3, our maintained fork of the real repo. The OSV scanner resolves
the aliased fork cleanly, so no ignore entries are needed.

The photon sidecar moves @opentelemetry/core to 2.10.0. The
whatsapp-bridge gets a body-parser 1.20.6 override, so the lockfile-only
fix from main cannot regress on reinstall.

website/.npmrc gets matching min-release-age exclusions for the fix
releases that are less than two weeks old.

electron stays at 40.10.2. The 41.x fix for GHSA-9f4c-93c8-jc8g brings
back the install failure that bb8280b75 reverted: install.js in 40.10.3+
extracts with an MSVC native binding, which fails on Windows machines
without the VC++ Redistributable. Upstream tracks this in
electron/electron#52481, with no fix released.

* fix(sec): move cryptography to 50.0.0

cryptography 48.0.1 carries three advisories (GHSA-m2h6-j472-rp4c,
GHSA-jwv3-5hgf-82ww, CVE-2026-69247). msal and alibabacloud-tea-openapi
cap cryptography below 49, so the bump needs an override-dependencies
entry in [tool.uv] to take effect.

The cap is conservative, not a real limit: we installed tea-openapi
against cryptography 50 and its client ran with no errors.

This override only governs `uv lock` / `uv sync`. The lazy-install
path does not read [tool.uv] and can still downgrade the pin; the next
commit closes that path.

aiohttp moves to 3.14.3 in the same pass, for GHSA-9548-qrrj-x5pj.

* docs(kanban): document the parent-link context handoff for follow-up cards

Adds 'Handing context to follow-up cards (the parent link)' to the kanban
feature page and a CI-remediation worked example to the tutorial, with
zh-Hans mirrors. Claims live-verified against kanban_db on an isolated
board: create_task creates children of done parents directly in ready,
recompute_ready leaves children of open parents in todo, and
build_worker_context surfaces the parent's completion summary and
metadata under '## Parent task results'.

* docs(delegation): document frontier-planner / inexpensive-worker cost split

Surface the existing planner/worker cost-split capability as an explicit
strategy in the docs:

- delegation.md: new 'Cost strategy: frontier planner, inexpensive workers'
  subsection under Model Override, with a config.yaml snippet using the
  verified delegation.model / delegation.provider keys, the resolution order
  (base_url > provider > inherit parent; model applies in all cases, empty =
  inherit), and a note that delegate_task has no per-task model parameter —
  quality-sensitive tasks should use kanban's per-task override instead.
- kanban.md: matching 'Cost strategy: frontier orchestrator, inexpensive
  workers' subsection using the verified per-profile config mechanism
  (dispatcher injects profile-scoped HERMES_HOME at worker spawn) and the
  existing per-task model_override (--model/--provider, set-model, dashboard).
- zh-Hans mirrors for both pages.
- cli-config.yaml.example: cost tip comment under the delegation section.

Config resolution was live-verified against tools/delegate_tool.py
(_load_config + _resolve_delegation_credentials) with a temp HERMES_HOME:
delegation.model pins children to the sentinel model; with no delegation
keys, children inherit the parent model and credentials.

* fix(desktop): isolate plugin render hooks

* feat(skills): add bundled merge-reconciler skill for neutral multi-agent conflict resolution

Adds skills/autonomous-ai-agents/merge-reconciler — a bundled skill teaching
a neutral third-party agent to resolve git merge conflicts between two
agents' branches: gather both diffs + intents, classify each hunk
(disjoint-intent / same-question-different-answer / superseded), resolve
under an impartiality contract, verify, and hand back a per-hunk summary.
Procedure was live-tested end-to-end against a real conflict fixture.

Includes contract tests (tests/skills/test_merge_reconciler_skill.py) and a
kanban docs cross-reference (en + zh-Hans): assign a third neutral profile a
reconciliation card with both conflicted cards as parents.

* Port from earendil-works/pi#7493: advertise AI_AGENT env var for child-process attribution

CLI and gateway entry points now set AI_AGENT=hermes (the emerging
cross-agent standard read by e.g. huggingface_hub agent detection) and
HERMES_AGENT=true, via setdefault so an outer harness is never
clobbered.

* fix(attribution): correct AI_AGENT id to registry value and carry harness markers into all terminal backends

The Hugging Face agent-harness registry matches standard-var values
EXACTLY against the harness id. Our registry id is 'hermes-agent'
(huggingface.js agent-harnesses.ts), so AI_AGENT=hermes was counted as
'unknown' — fixed at both entry points.

Remote terminal backends (Docker/SSH/Modal/Daytona/Singularity/Vercel)
never inherit the Hermes process env, and the cross-session leak guard
deliberately strips HERMES_SESSION_* from subprocess envs in engaged
multi-session hosts — so hf/huggingface_hub traffic from those shells was
unattributable. _wrap_command now exports AI_AGENT/HERMES_AGENT inside
every wrapped command with ${VAR:-default} semantics (outer harness is
never clobbered), and the snapshot dump excludes both names so a baked
value can never shadow a later outer harness.

E2E: verified against real huggingface_hub 1.27.0 detect_agent() with a
cached registry — 'hermes-agent' detected via AI_AGENT and via
HERMES_SESSION_ID; old 'hermes' value reproduced the 'unknown' bug.

* fix(ci): merge all duration slices, not one

Each test slice uploads an artifact with the same file name,
test_durations.json. The save-durations job downloaded the 12
artifacts with merge-multiple, so all extractions wrote to one
path in parallel. This caused two faults:

- A race between two extractions wrote two JSON documents into
  one file. The merge step then failed with 'JSONDecodeError:
  Extra data' (run 31382130252).
- On green runs, the last write eras…
gottabstrong added a commit to gottabstrong/hermes-agent that referenced this pull request Aug 12, 2026
* fix(skills): trim ast-grep description to the 60-char hardline

test_authoring_standards.py::test_description_hardline red on main since
461c493972 landed with a 383-char description. The trimmed detail is all
preserved in the SKILL.md body (When-to-use, decision tree, search_files
comparison). Unbreaks every open PR's slice 4.

* fix(gateway): carry chat_id/thread_id/session_key into /branch child sessions too

Same defect as the compression-rotation fix in the prior commit, found
during a full-audit of every create_session() call site per the repo's
'fix the whole bug class, sibling call paths included' contribution
guidance.

_handle_branch_command() (gateway/slash_commands.py) creates the branched
child session via create_session() without chat_id/chat_type/thread_id.
The routing columns are only backfilled later, when switch_session() runs
at the end of the function and calls _record_gateway_session_peer(). In
between, the function copies the parent's conversation history to the new
session_id one message at a time, with each append_message() call
independently try/excepted (best-effort) — a crash/kill anywhere in that
window leaves the branched session permanently unroutable, same failure
mode as the compression bug: NULL chat_id/thread_id can never be found by
find_latest_gateway_session_for_peer, AND unreachable via /resume's IDOR
guard (which requires the row's chat_id/thread_id to match the caller's).

Fix: forward source.chat_id/chat_type/thread_id at create_session() time,
mirroring the existing correct pattern already used by /title's
auto-create path a few hundred lines up in the same file (which has an
explicit IDOR-scoping comment justifying it).

Tests: tests/gateway/test_branch_routing_columns.py drives the real
_handle_branch_command against a real SessionStore + SessionDB (SQLite in
tmp_path, no DB/session-store mocks). Patches switch_session to simulate a
crash landing before it runs (the exact gap the routing columns need to
survive), then asserts the branched child's chat_id/chat_type/thread_id
are already correct in state.db at that point. RED verified against
unpatched code (assert None == '170829464'), GREEN after the fix.

Regression: 102/102 across the new test + pre-existing /branch, session
boundary, compression rotation, DM thread seeding, session API, and
resume-command suites. Broader tests/gateway/ -k "branch or session_api or
resume or topic_mode or session_boundary" sweep: 255/255 passed, 1
(unrelated) skip.

* fix(gateway): also persist user_id and session_key in child-session creates

The sweeper flagged two gaps in the routing-columns fix:

1. /branch create_session() omitted user_id and session_key — the
   fallback lookup path (find_latest_gateway_session_for_peer) requires
   user_id to match the complete peer tuple when session_key lookup fails,
   and /resume IDOR guards reject sessions without matching user_id.

2. Compression-rotation create_session() omitted agent._user_id — same
   problem: rotated child cannot satisfy persisted /resume ownership proof
   before the later gateway backfill.

Forward user_id and session_key at CREATE time in both call sites so
the child row is immediately fully routable with zero backfill gap.

Extended tests: compression rotation asserts user_id is carried (and None
for CLI sessions). Branch routing asserts both user_id and session_key on
the child row before switch_session runs.

* fix(gateway): carry origin_json/display_name into /branch child sessions too

Complete the /branch routing-identity fix (salvaged from PR #62278 by
@jcjc81): in addition to user_id/session_key/chat_id/chat_type/thread_id,
forward origin_json and display_name at create_session() time, matching
the reset-path db_create_kwargs pattern (#82633) so the branch row is
born with full identity — no backfill gap for state.db consumers
(mcp_serve, mirror, channel directory) if a crash lands before
switch_session().

The obsolete compression-rotation half of #62278 was dropped: rotation
now goes exclusively through publish_compression_child, which already
copies all identity columns in-transaction.

* fix(gateway): distinguish durable cached transcript rows

* chore: map TomAce7 contributor email for attribution audit

* fix(gateway): respect reset boundaries during recovery (#68539)

find_latest_gateway_session_for_peer filtered non-recoverable rows out of
candidacy BEFORE ordering, so recovery could search behind a /new reset
boundary and resurrect an older still-open row for the same peer —
silently restoring the exact context the user reset.

Rebuilt against the #82633 finder (has-messages ranking +
COALESCE(last_activity_at, started_at) recency): the fence is expressed
as a NOT EXISTS guard inside both the exact-key and peer-fallback
queries — a candidate is rejected when an intentional boundary row
(session_reset / session_switch / idle / daily / suspended /
resume_pending_expired) for the same peer ended after the candidate's
last activity. If the conversation's most recent event is an intentional
reset, recovery returns nothing rather than reaching behind it.

Cherry-picked from #68617 and adapted to the rewritten finder.
(cherry picked from commit bb2c562a165d91e00f64d42cf7495e6c8a5da9d7)

* fix(gateway): honor session_reset policy when recovering sessions

Both session recovery paths (the startup stale-entry repoint and the
lazy in-message recovery) rebuilt the routing entry with updated_at=now
and never consulted _should_reset, so an opt-in idle/daily session_reset
policy was silently dead across any gateway restart: a recovered session
always looked freshly active, and since every subsequent message bumps
updated_at, a session recovered stale could then never age out at all.

Fix in three parts:

- _create_entry_from_recovered_row derives updated_at from the durable
  last_activity_at the finder already returns on the row (no extra DB
  round-trip; the original PR added SessionDB.get_last_activity for
  this, unnecessary post-#82633), falling back to created_at. An
  invalid or missing started_at now maps to epoch 0 instead of now — an
  invalid durable timestamp must look old, never freshly active.
  reset_had_activity is set from the row's durable activity/message
  signals so the continuity hint stays accurate.

- _recover_session_from_db evaluates _should_reset on the rebuilt entry:
  an overdue session is durably promoted to a reset boundary
  (promote_to_session_reset, falling back to end_session) and the stale
  mapping is dropped instead of repointed.

- _query_recoverable_session no longer reopens the row; the
  get_or_create_session recovery phase evaluates _should_reset first and
  either feeds the normal auto-reset create path (reset notice,
  prev_session_id continuity, durable promotion) or reopens and
  publishes the recovered entry exactly as before.

Behavior is unchanged under the default session_reset mode "none":
_should_reset returns None there, so recovery still resumes every
recoverable row — only users who opted into idle/daily resets see the
policy actually applied across restarts.

Cherry-picked from #78618 and adapted to the #82633 finder.
(cherry picked from commit 31c71f762961638c199287fc6ffe836115c4892b)

* chore: map contributor email for hillimited

* fix(desktop-ssh): stop resolving exec-wrappers to python in locateHermes (#74411)

Problem 1: resolveLauncher() read bash 'exec <python> <script>' wrappers
and returned ONLY the python interpreter path, discarding the script.
This made probeHermesVersion() run '<python> --version', which always
printed 'Python x.y.z' instead of the Hermes version. And
remoteSupportsSshOwnership() ran '<python> serve --help' which failed
entirely because no 'serve' module exists in the python stdlib.

Problem 2: When the user set remoteHermesPath (an explicit override),
resolveLauncher() resolved it to the python interpreter, replacing the
user's specified path. The override was effectively ignored for version
checking and capability probing.

Fix: resolveLauncher now returns the candidate path directly. The hermes
binary or wrapper script is already executable and handles argument
forwarding (e.g. 'exec <python> <script> "$@"') correctly on its own.
No additional remote SSH round-trip or python script needed.

* test(desktop-ssh): cover wrapper preservation and explicit-path passthrough in locateHermes

Replaces the canonicalization test (which pinned the behavior #74425
removes) with wrapper-preservation coverage for auto-detection and an
explicit remoteHermesPath, both asserting no python3 -c parser call is
issued. Verified both fail against the pre-fix implementation.

* fmt(js): `npm run fix` on merge (#82771)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): make un-highlighted code readable while streaming in light theme

streaming code blocks in the light theme render near-white text on the
white code card until shiki's highlight lands, then snap to normal token
colors. the pale text is @tailwindcss/typography's pre foreground: its
prose theme styles pre as a dark slab (--tw-prose-pre-code = gray-200 on
a gray-800 bg). we strip the bg for our own code card but the near-white
foreground survives on the container. shiki's opaque per-token span
colors normally hide it — it shows through wherever text renders without
spans: the streaming delay window, the lazy-chunk suspense fallback, and
over-budget blocks that never highlight.

traced on the live renderer: computed color on the wrapper of mid-stream
code was oklch(0.928 0.006 264.531) (gray-200), supplied by the
.prose :where(pre) rule.

fix: prose-pre:text-foreground on the markdown container, so every
fenced path inherits the transcript foreground instead. the utility
layer is emitted after typography's base rule in the built css, so the
override wins by order at equal specificity.

* test: run os-specific tests on their real host, not a faked one

many tests patched sys.platform or a module's _IS_WINDOWS flag, then
ran on linux ci. the patch selects the branch under test, but the host
does not have the behavior the branch exists for. the test proves the
patch, not the platform. some gated assertions never ran on any host.

this commit adds three markers: linux_only, macos_only, windows_only.
a conftest hook skips a marked test on the other hosts, with a clear
reason. no test fakes a host now. two documented fakes remain
(android/termux, freebsd) because no ci runner exists for them.

each fake site got one of four treatments:
- gate it: the real host supplies the platform; mocks cover real
  dependencies only, never host identity
- patch the module's own probe when the subject is the probe's consumer
- assert against the real host when the fake stood in for any non-x host
- delete the patch when it set the value the host already has

bare skipif(sys.platform != ...) guards became markers too. the lane
model skips these on linux and never imports them on windows, so they
ran on no host. platform parametrize tables are now one marked test
per os.

running on real hosts found real errors: a chrome-sandbox failure in
test_gui_command that main hides, and two windows failures fixed here.
the agents.md testing section now documents the policy.

* ci: add macos and windows test lanes for the os-marked tests

the markers from the previous commit skip off-host. without a host to
run them on, every marked test is a silent skip. this commit adds the
hosts.

- tests-os.yml runs -m macos_only on macos-latest and -m windows_only
  on windows-latest. ci.yml requires both lanes in all-checks-pass.
- a lane fails on pytest exit code 5 (zero tests selected). a renamed
  marker cannot produce a green job that ran nothing.
- each lane repeats 'not integration' because a command-line -m
  replaces the addopts filter.
- scripts/ci/list_os_marked_tests.py selects which files each lane
  imports. -m filters after collection, and collection imports every
  module. without this helper, one unrelated ImportError on the
  foreign host fails a job whose own tests passed. the helper exits
  non-zero when a marker matches no file, and writes bytes with
  explicit lf so windows crlf translation cannot corrupt the bash
  file list. it has its own tests in tests/ci/.
- the local runner now reports the skipped count and prints a note:
  macos_only/windows_only tests were skipped on this host, and this
  ci lane runs them. a green local run on linux no longer reads as
  coverage of the other hosts.
- the runner default job count is now #cpu, not #cpu*2.

* ci: print the zero-selection diagnostic instead of dying first

`shell: bash` runs the step with -e injected, and `set -uo pipefail` does
not clear it. A non-zero pytest exit killed the script before `status=$?`,
so the -eq 5 branch and its ::error message never ran. The job still failed
red, but the diagnostic that names the cause never printed.

* test: convert the last host-OS fakes and guard double markers

Six test files still selected an OS branch with a faked host. Each one now
carries the marker for the host that owns the branch, or derives the
expectation from the real host:

- test_clipboard: macos_only on the has_clipboard_image dispatch. The fake
  picked the branch, but _macos_has_image needs osascript.
- test_claw: windows_only on the tasklist/powershell scan, with return_value
  in place of a side_effect list that pinned the call count.
- test_linux_desktop_entry: the parametrize over "darwin"/"win32" becomes one
  marked test per host. A fake left POSIX paths and a POSIX XDG layout.
- test_graphical_browser_detection: linux_only on the display-server arm. The
  $BROWSER check runs before the platform branch, so its test stays unmarked.
- test_auth_nous_provider: the fixture pinned linux so the macOS certifi
  fallback could not change the result. The assertion now reads the host, so
  the macOS lane covers the fallback too.
- test_tts_macos_output and test_voice_mode: the afplay policy exists because
  CoreAudio init raises a TCC prompt, which no Linux runner reproduces.

tests/conftest.py refuses collection when one test carries two OS markers.
Each marker skips on all but one host, so two of them make a test that runs
nowhere while every lane reports green. tests/test_os_marker_gating.py pins
that behavior.

The docstring on TestConfirmDestructiveSlash said the Windows job runs it.
The class has no marker, so -m windows_only deselects it.

* fix(ci): don't report all-good before jobs start

The live comment poller inferred completion from the job list. An empty
job list looks the same as a finished run: GitHub has not spawned the
jobs yet, so nothing is pending, and the poller posted a final
"all good!" comment and exited.

The run status is now the authoritative signal. collect_run_jobs()
returns whether the CI run and every watched sibling run report
status=completed, and the loop exits only when no job is pending AND
all runs are complete. While a run is still queued or in progress with
no visible jobs, the comment shows "waiting for jobs to start" instead
of a final banner.

* fix(agent): persist completed text turns before the loop exits (#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* refactor: follow-up for salvaged PR #81692

- warn (not debug) on final text-turn flush failure: a failure here
  reopens the exact #81641 data-loss window with _persist_session as
  the only remaining retry, unlike the verify siblings which retry
  in-loop; include session id for triage
- trim the flush-site comment to sibling proportion, pointing to the
  test module for the full incident narrative
- test: assert _persist_session presence before indexing, so a wiring
  change fails with a clean assertion instead of ValueError from max()

* fix(tui): recover active goals after compression exhaustion

* fix(agent): keep the thinking-prefill marker so the drop pass can strip trailing stubs

* test(agent): cover the API-copy build so restoring the marker pop fails

* fix: trim comments and fix sibling pop site in summary path

Trim verbose comments in conversation_loop.py and run_agent.py to 2 lines
each. Fix the same bug class in the compression summary path at
chat_completion_helpers.py: remove _thinking_prefill from the explicit
pop tuple and move the generic underscore-key sweep to after
_drop_thinking_only_and_merge_users, so the drop pass can recognize
prefill stubs there too.

* fix(skills): reject colon in bundle path components (NTFS ADS bypass)

_normalize_bundle_path rejected absolute paths, .. traversal, and a bare
drive-letter prefix, but permitted a colon inside a later path component.
On NTFS a bundle member named scripts/helper.py:payload writes a hidden
Alternate Data Stream into the visible file scripts/helper.py. The skill
scanner walks with rglob('*'), which does not enumerate streams, so both
operator review and the guard scanner miss the executable bytes.

Reject a colon in any component (the whole class, not just the trailing
one). This subsumes the previous bare drive-letter check, which is folded
into the single colon guard. '/' is the only legal separator once
normalized, so no portable bundle path needs a colon.

Adds an OS-independent quarantine_bundle regression plus a direct
normalizer unit test covering leading/mid/trailing-component colons,
bare/qualified drive letters, and the empty stream name.

Reported-by: JoaoMarcos44 <87440198+JoaoMarcos44@users.noreply.github.com>

* fix(cron): load .env on no_agent path so standalone ticks resolve delivery home channels

hermes-cron-tick.service starts without TELEGRAM_HOME_CHANNEL/DISCORD_HOME_CHANNEL
in the unit env; the per-run load_hermes_dotenv reload lived only on the agent
path (after the no_agent short-circuit returns), so every deliver=telegram/all
script job failed with 'no delivery target resolved'. Load the dotenv at the top
of the no_agent branch; override=False keeps the gateway's in-process tick
behavior unchanged.

* fix(cron): surface exception type and traceback for standalone Discord delivery errors

* refactor: drop dead sys.exc_info check in delivery error log

The result-error path in _deliver_result is not inside an except block,
so sys.exc_info() always returns (None, None, None) — the condition was
always False. Simplify to a plain logger.error call with accurate comment.

* chore: AUTHOR_MAP for aameobius@gmail.com → francialisomlimoeiro

PR #82682 salvage contributor attribution.

* fix(gateway): keep the personality pivot out of the truncate ordinal space (#82756)

`truncate_before_user_ordinal` is an index into the list of *real* user
turns. The gateway builds that list with `role == "user" and not
display_kind`, and `test_prompt_submit_truncate_ordinal_skips_display_kind_rows`
already pins why: "Without the filter, a trailing marker shifts the ordinal
so the wrong message is targeted for truncation."

`_apply_personality_to_session` broke that invariant at the producer. Its
pivot marker rides as `role=user` — deliberately, so strict
OpenAI-compatible providers accept it mid-conversation (the same reason
`_append_model_switch_marker` does) — but unlike the model-switch marker it
carried no `display_kind`. The gateway therefore counted it as a real user
turn while no client ever renders it as one.

After a personality change the two sides address different lists: every
later rewind/edit/regenerate resolves one slot too early, and
`replace_messages()` hard-DELETEs the extra span. That is the reported
signature — an in-range, valid ordinal, `confirm_truncate: true`, and a cut
that moved backwards with no user rewind action.

Tag the pivot like the model-switch marker, and teach the desktop to
project the kind as a timeline row so a persisted marker is never rendered
— or counted — as a user turn on the client side either. Both ends must
exclude it; excluding it on only one end just inverts the drift.

The regression test drives the real injection point rather than a
hand-written marker dict. Without the fix it fails with "the pivot shifted
the ordinal: the cut landed at 3 instead of 5", losing a turn the user
never asked to drop.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(state): make a rewind truncation recoverable instead of a hard DELETE (#82756)

Guarding the *aim* of a rewind still leaves every other way of aiming it
wrong terminal. All three reported incidents (#70516, #80763, #82756) ended
at the same write — `replace_messages()` in the `prompt.submit` truncation
path — and all three were unrecoverable for the same reason: the rows are
DELETEd, which also evicts them from the FTS index, so there is no `active=0`
archive and nothing to restore from.

The codebase already draws this distinction and already has the safe half of
it. `archive_and_compact` is documented as "the durability-preserving
alternative to replace_messages"; `rewind_to_message` — the `/undo` path —
soft-deletes to `active=0, compacted=0` and keeps the rows "on disk for audit
/ forensic inspection". The desktop rewind is the same user-facing operation
as `/undo` and was the one taking the destructive branch.

`replace_messages(..., archive_dropped=True)` flips the DELETE to a
content-preserving `UPDATE messages SET active = 0`, reusing the existing
transaction and the existing `active=0, compacted=0` marking so the dropped
turns stay readable via `get_messages(..., include_inactive=True)` and stay
out of session search (`compacted=0` = "the user took it back", vs
compaction's `compacted=1` = "summarized away, still discoverable").

The live transcript is byte-identical either way — only the durability of the
dropped turns changes. The parameter defaults to False, so the fork handler,
the ACP adapter and `gateway/session.py` keep their current semantics
untouched; a test pins that.

`active_only=True` stays on the call: #80216 still applies, and archiving must
not disturb rows an earlier compaction deliberately archived.

Test doubles for `replace_messages` in the gateway suite are widened to the
real signature — they are stand-ins for SessionDB, and a double that does not
accept what production passes silently converts this write into a 5008.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(gateway): reject boolean ordinals and bare confirm_truncate on prompt.submit

Two hardening guards extracted from #82766 by @StanleyStetson:

- bool is an int subclass, so a JSON `true` in truncate_before_user_ordinal
  coerced via int() to ordinal 1 and aimed a CONFIRMED rewind at the second
  user turn — the same silent-loss class as #82756. Reject with 4004.
- confirm_truncate with no truncation target is leaked client rewind state
  on an ordinary submit; fail fast with 4004 instead of silently ignoring
  the flag, so the corrupted client state is surfaced.

Part of the composite fix for #82756.

* fix: close sibling display_kind drops and ui-tui parity for #82756

Review follow-ups on the composite salvage (whole-bug-class sweep):

- session.branch and _persist_branch_seed copied parent history without
  display_kind/display_metadata, so a tagged timeline marker (personality
  pivot, model switch, auto-continue) re-entered the branched session as a
  bare role=user row after a restart — re-planting the phantom-ordinal
  class this PR fixes. Both projection dicts now carry the tags; regression
  asserts added to both branch tests (mutation-checked: fail without the
  fix).
- ui-tui renderer learns display_kind=personality_switch (was falling
  through to an opaque user bubble; desktop got the case in commit 1).
- programmatic-integration docs: document the two new 4004 refusals
  (boolean ordinal, bare confirm_truncate).
- hermes_state comment: archived rows are searchable only with
  include_inactive=True, not by default search — align comment with the
  actual FTS filter.
- strip stray trailing blank line in test_tui_gateway_server.py

* fmt(js): `npm run fix` on merge (#82962)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): keep react-router in one runtime chunk

* feat(skills-hub): fall back to live repo for optional skills missing from local checkout

Optional skills merged to main after a user's install was cut were
invisible to 'hermes skills install official/...' until they ran
'hermes update' — the OptionalSkillSource only scanned the local
optional-skills/ checkout.

Now, when an official/<category>/<skill> identifier is not found
locally, OptionalSkillSource resolves it against the live default
branch of NousResearch/hermes-agent: one Trees API call enumerates
optional-skills/*/SKILL.md dirs (cached on disk via the shared index
cache, 1h TTL), then the full skill directory is downloaded byte-exact
(including root-level install scripts, LICENSE, tests/ — files the
generic GitHubSource.fetch path drops). search() and inspect() also
surface remote-only skills so discovery works pre-update too.

Local checkout always wins when present; offline degrades to the old
local-only behavior; traversal and ambiguous bare names are refused;
provenance stays official/builtin.

* fix(update): force-reload config modules before migration check

hermes update runs in the PRE-pull Python process. After git pull
updates the source files on disk, sys.modules still holds the OLD
hermes_cli.config and hermes_cli.config_migrations. Function-level
imports return the cached module, so DEFAULT_CONFIG["_config_version"]
is the OLD value and check_config_version() reports (33, 33) —
"up to date" — even though the freshly-pulled code has v34 with a
migration to run.

The personality reset migration (#81946) was silently skipped this
way: display.personality: kawaii stayed active after updates that
should have reset it. Every user who updated from a pre-v34 codebase
to a post-v34 codebase was affected.

Fix: _run_config_check_fresh and _run_migrate_config_fresh call
importlib.reload() on hermes_cli.config_defaults, hermes_cli.config,
and hermes_cli.config_migrations before calling check_config_version
and migrate_config. This forces the modules to be re-read from the
updated source files on disk.

* fix(transport): use getattr for supports_prompt_cache_key on stale profiles

After a partial update (stash restore overwriting providers/base.py with
an older version), the NousProfile singleton was instantiated from a
ProviderProfile class that predates the supports_prompt_cache_key field
(added in f4fb23f3d). Accessing profile.supports_prompt_cache_key raised
AttributeError, crashing every API call with:
  'NousProfile' object has no attribute 'supports_prompt_cache_key'

Use getattr(profile, 'supports_prompt_cache_key', False) so a stale
profile degrades to 'no prompt cache key' instead of crashing.

* docs(sessions): document repair-routing and the continuity guarantees

User-visible surface from the #82616 session-continuity campaign:
- sessions.md: 'Repair Stranded Gateway Sessions' (evidence rules,
  dry-run-first, why adoption is never automatic) and 'Continuity After
  Crashes and Restarts' (atomic identity, self-heal, recency resolution,
  reset-boundary fence)
- cli-commands.md: repair-routing row in the hermes sessions table

Docs build verified (en + zh-Hans).

* feat(tools): stat-based special-file guard for read_file + readtool eval harness

read_file on a workspace FIFO/socket blocked until the exec timeout —
the existing device guard is name-based (/dev/*, /proc/*) and cannot
see an arbitrary special file. Add _special_file_kind(): one os.stat
on the resolved path, refusing FIFO/socket/char/block devices with a
plain note ('no read was attempted') instead of hanging. Host-visible
filesystems only; regular files, dirs, and missing paths unchanged.

Also adds evals/readtool/: an A/B harness that runs the real AIAgent
against hostile-file fixtures (huge lockfile, one-line bundle, FIFO,
NFD filenames, lying extensions) and measures accuracy, turns, tool
calls, and tokens. Measured for this guard (3 reps, file-only arm):
qwen3.8-max fifo task tokens 122k -> 26k (-79%), turns 9.3 -> 5.0;
opus-4.8 tokens 40k -> 23k; accuracy held 1.00 both arms.

* chore(evals): track results/.gitignore (its own * rule excluded it from the original add)

* feat(tools): unicode-equivalent filename retry + near-miss suggestions in read_file

NFC/NFD, narrow no-break space (U+202F), and curly quotes render
identically in a terminal — a model retyping a visually-correct path
gets 'file not found' and can never discover the byte mismatch on its
own. On not-found, canonicalize the requested name and compare against
directory entries; exactly ONE equivalent spelling reads transparently
with an explanatory note. Zero or several matches (homoglyph twins)
fall through — never guess between collisions.

Also: difflib.SequenceMatcher >=0.8 fallback in _suggest_similar_files
catches near-miss typos (AGENT.md -> AGENTS.md) that substring scoring
misses entirely.

Measured (file-only arm, 3 reps, control=guard-only vs feature):
unicode task qwen3.8-max 31k->16k tok (-48%), turns 6.7->3.7;
opus-4.8 57k->33k tok (-42%), turns 8.3->5.0; accuracy held 1.00.
near-miss: opus mildly better, qwen flat, no regressions.

* fix(ci): start the poller on in_progress, key concurrency per repo

The requested trigger fires when GitHub creates the run. A run from a
first-time contributor waits in action_required, and the poller then
polls a run that never starts until its timeout. The in_progress
trigger fires when the run starts, and it also fires on a re-run.

The concurrency group now contains the head repository. Fork PRs
frequently share a branch name, and two PRs must not cancel the
poller of each other.

* fix(ci): keep review-gated files out of the js-autofix patch

The dep-version-gate ruleset requires a team review for package
manifests, eslint configs, and workflow files. If the autofix patch
contains one of these files, the bot PR waits for that review and
auto-merge stops. The patch step now excludes them, so a bot PR
never gates itself. The eslint check in typecheck.yml still reports
their lint errors.

* fix(ci): unbuffer live comment poller output

* feat(tools): name the dead end — past-EOF and empty-file notes in read_file

A read past EOF returned content '900|' (a phantom line-number prefix
that looks like a real line) and an empty file returned '1|' — both
ambiguous silence: indistinguishable, from inside the model, from a
broken tool, so it re-reads and widens windows. Name the dead end and
its recovery instead: 'offset 900 is beyond the end of the file (412
lines total). Retry with offset <= 412.' / 'File is empty (0 bytes).'
Notes, not errors — a fact about the file is not a failure.

Boundary pinned by test: offset == total_lines still reads (an
off-by-one in a resume hint is a silently corrupted read).

Measured (file-only arm, 3 reps, control vs feature): qwen3.8-max
-18% tokens, -26% tool calls, -17% turns across the two affected
tasks; opus-4.8 flat (within rep noise); accuracy held 1.00.

* fix(process): reject non-positive wait timeouts; distinguish log offset=0 from default

Two falsy-zero coercions in process_registry (salvaged from PR #60004,
credit @isheng-eqi; the EOF half of that PR landed separately in
893792c99):

- wait(timeout=0): schema says minimum=1 but the handler let 0 fall
  through '0 or max_timeout' to the DEFAULT wait instead of rejecting.
- read_log(offset=0): conflated with the offset-unset default, silently
  returning the TAIL of the log when the caller asked for the head.
  Default is now offset=None; explicit 0 paginates from line one.

* chore: map contributor email for salvaged commit

* fix(file-ops): stop read_file blocking forever on non-regular files

The size probe every read path starts with — `wc -c < path` — opens the
path. On a FIFO with no writer, a socket, or a character device that never
reaches EOF, that read never returns, and read_file/read_file_raw/
read_file_bytes all pass no timeout to _exec. The turn wedges until the
process is killed.

The device blocklist in tools/file_tools.py cannot close this: it matches
literal /dev/* names, so it can only ever cover paths someone thought to
enumerate. A FIFO is a file type and can sit at any path.

Gate the probe behind `[ -f ]`, which stats instead of opening, and report
a path that exists but is not a regular file as such. A missing path keeps
its existing not-found handling.

* test: adapt read mocks and fifo guard test to the sentinel probe

The combined [ -f ]/wc -c probe changes the first shell command each
read issues; update the stale mocks that only answered bare 'wc -c'.
The fifo tool-layer test now accepts the merged stat-guard's
success=False note (a fact, not an error) with the shell sentinel
behind it.

* test: adapt edge-case pagination mock to the sentinel probe

Same stale-mock class as the previous commit — the sweep missed
test_file_operations_edge_cases.py. Verified no bare wc -c mocks
remain anywhere under tests/.

* fix(desktop): support keyless plugin rows

* feat(profiles): serve a cross-profile project tree and per-profile usage totals

`projects.tree` answers for the backend's own profile, so the grouped
sidebar had nothing to draw once the user asked to see every profile.
Run the same authoritative builder once per profile against that
profile's state.db and merge the results by folder, so one checkout is
one group no matter how many profiles work in it, and the owning profile
rides on each session row where the badge and filter can read it.

Group totals are summed in SQL rather than over the loaded page — a
number that shrank as you scrolled would be worse than no number.

Scope the batched sidebar slices while we're here: cron and messaging
came back cross-profile unconditionally, which is why a concrete profile
showed another profile's Telegram threads and cronjobs.

Closes #65710
Closes #42651
Closes #70629

* fix(desktop): preserve keyless plugin row identity

* fix(desktop): hoist the sidebar's sort key out of the flat list

The sort key was applied where the flat recents list is assembled, so it
did nothing at all once rows moved into groups: picking "cost" while
grouped by project or profile left every lane in the order the backend
sent it. Rank in a store instead, above any one view, so a grouped
surface can order the rows it owns by the same key.

* fix(desktop): read-only keyless plugin rows + backend contract v6

Rework of the salvaged #82828 compatibility layer: keep the crash guards
(optional key, safe filter/search, synthetic React row identity) but drop
the name-addressed toggle fallback — bare names collide across category
dirs (image_gen/fal vs video_gen/fal), which is exactly why the backend
moved to key-addressed toggles (a60b492e07). Keyless rows from a
pre-contract backend now render with a disabled switch and an 'update
your backend' tooltip instead of resurrecting the collision-prone
protocol.

Bump DESKTOP_BACKEND_CONTRACT / REQUIRED_BACKEND_CONTRACT to 6 so the
existing skew toast surfaces the real remedy (one-click backend update)
on session open.

* feat(desktop): show every profile's sessions in the sidebar

All-profiles mode listed a flat page of chats and stopped there: the
project tree was the active profile's, grouping and filtering had no
notion of an owner, and each profile lane paged itself against a
separate endpoint. Multi-agent workflows live across profiles, so the
sidebar now treats the owner as a first-class axis.

Group by profile (the default in this scope, with its own persisted
choice so flipping the rail doesn't reset how you read one profile),
filter by profile, and start or import one from the same menu. Profile
groups take the project row's shape rather than a hand-rolled header,
preview the same three sessions a project does, and carry their whole
tokens-and-spend total in the slot the kebab hovers over.

Grouped lanes now rank by the active sort key, before they trim
themselves, so the rows a group hides are the ones the sort ranked last.

Defaults live in one const: the sidebar ships grouped by date, sorted by
recency, with the timestamp pinned — and "Reset to defaults" puts back
exactly that.

* fix(telegram): reset failed primary transport pool

Retryable primary errors can leave pooled sockets in CLOSE_WAIT while fallback retries continue. Replace and close failed primary generation before fallback selection.\n\nRefs #82920

* feat(file-ops): clamp oversized lines in the shell pipeline before transport

ShellFileOperations.read_file previously ran sed -n '{off},{end}p' bare, so
a file with one pathological line (e.g. a 50MB+ minified bundle on a single
line) shipped the entire line across the exec transport before Python's
per-line clamp (_add_line_numbers, MAX_LINE_LENGTH=2000) could trim it.
read_file now pipes through 'cut -b1-{4*max_line_length+1}' so the shell
bounds every line to 8001 bytes before the bytes ever reach Python.

UTF-8 finding: GNU 'cut -c' is byte-based despite its name (verified:
cutting a line of 2-byte 'é' at -c8004 splits a codepoint, leaving a bare
0xC3 lead byte). The transport decodes with errors='replace', so a split
codepoint becomes U+FFFD rather than raising — but a clamp of
max_line_length+1 BYTES would deliver under max_line_length CHARS for
multibyte text, so the Python clamp would never fire and truncation would
be silent. Using 4*max_line_length+1 bytes (UTF-8 max 4 bytes/codepoint)
guarantees any line longer than max_line_length chars still decodes to
more than max_line_length chars, so len(line) > max_line_length always
triggers the existing '... [truncated]' suffix, and any boundary U+FFFD
lands past char max_line_length where the clamp removes it — verified
empirically with fixtures ('é'*4001 splits at the byte boundary yet the
result contains no U+FFFD and ends with the truncated suffix). 'cut -b'
is used explicitly to document the byte semantics.

cut (unlike sed -n p) always newline-terminates its output, which would
grow a phantom empty final line on files without a trailing newline; the
final-page path now probes the last byte (tail -c 1 | wc -l) and strips
the artifact.

read_file_raw is untouched: it is documented as no-per-line-truncation.

Benchmark (50MB single-line fixture, /usr/bin/time -v, median of 3):
  before: 191.1 MB peak RSS, 1260 ms wall
  after:   97.8 MB peak RSS,  490 ms wall
Correctness identical in both arms: monster line returns the clamped
2000-char form + '... [truncated]', offset=2 returns the trailing normal
lines intact.

Tests: 153 passed, 0 failed, 4 skipped across the file-ops suites plus a
new tests/tools/test_read_shell_line_clamp.py pinning the monster-line
clamp, offset-past-monster reads, no-trailing-newline preservation, both
UTF-8 boundary cases, and read_file_raw's exemption. Two existing mocks
asserting the exact sed command string were updated for the pipeline.

* feat(vision): disclose downscale factor and crop offset for coordinate mapping

* feat(desktop): fade the sidebar's scrollbars out until you're in the list

A thumb parked on a list you aren't touching is chrome, not information,
and the sidebar stacks several scrollers so it draws several of them at
once. Fade them in on hover instead, sharing the existing scrollbar
colors and the webkit/Firefox split rather than styling a second kind of
bar. Only the thumb's color changes, so the reserved gutter still keeps
rows from shifting sideways.

* Port from lobehub/lobehub#17855: render notebook outputs in read_file ipynb extraction

read_file's .ipynb extraction previously dropped cell outputs entirely,
so a notebook's training logs, tracebacks, and printed results were
invisible to the model. Ported LobeHub's token-efficient conversion:

- stream text and error tracebacks are kept (ANSI-stripped, \r
  progress-bar rewrites collapsed to the final frame)
- execute_result/display_data prefer text/plain over the HTML twin
- base64 images become sized placeholders ([image/png output — 3 KB,
  omitted]); widget state and script-bearing HTML are omitted
- legacy nbformat v3 pyout/pyerr flat-field shapes handled
- per-cell output block capped at 20k chars

* feat(read): jq retrieval hint in notebook output truncation marker

* fix(gateway): carry desktop_contract when activating a lazy session (#68392)

_live_session_payload() falls back to _fallback_session_info() while a
session's agent is still None (lazy/deferred build). That fallback omitted
desktop_contract, so session.activate returned lazy metadata with no contract
field. Desktop feeds the value straight into reportBackendContract(), where a
missing field reads as contract 0 — a current backend is then falsely flagged
"Backend out of date" on every activate of a live lazy session.

The sibling session.create shape (_lazy_resume_info) was fixed the same way in
#36112; this closes the remaining session.activate gap by advertising
DESKTOP_BACKEND_CONTRACT in the fallback payload.

Adds test_session_activate_lazy_info_reports_desktop_contract pinning the
session.activate path against a lazy (agent=None) session.

* fix(desktop): give every row's trailing metadata one right-aligned slot

The PR and profile chips rendered in the row body, left of the kebab's own
column: they never sat flush right and never handed their space to the kebab
on hover, so a row showing only a PR left a hole where the age would have been.
Both now join the tokens/cost/age figures in the actions slot, and the kebab
covers the end of it — losing whichever item reads last, not the whole slot.

* fix(desktop): ship the sidebar grouped by date in every scope

The all-profiles scope defaulted to grouping by profile, so "Reset to defaults"
handed back a grouping the user never picked. Both scopes now ship by date, and
a reset clears the scope you are not looking at too — otherwise flipping the
rail restored the customization the reset was supposed to undo.

Hovering a row's PR chip also holds the kebab back now: the chip is a link, and
the button that covers the end of the trailing slot was taking the click.

* fmt(js): `npm run fix` on merge (#83078)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): titlebar clusters — macOS Y nudge, 24px targets, 13.9px icons

Left cluster gets a macOS-only translate to sit on the traffic-light row.
All titlebar tools use 24×24 hit areas with 13.9px Codicons (inline size
beats unlayered codicon.css). Clusters share one flex shell with no gap —
buttons abut and the hit target is the spacing.

* fix(desktop): sort titlebar import for eslint

* fmt(js): `npm run fix` on merge (#83099)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): don't let webview guests swallow drag gestures

* fix(desktop): keep min-width floors on stacked flex zones

* fix(desktop): reopen docked tiles at their last split share

* fix(desktop): satisfy eslint on pane-share-memory test

* fix(desktop): stop HUD window growing on drag; add corner resize handle (#83091)

* fix(desktop): stop HUD window growing on drag; add corner resize handle

The HUD window is created frame:false + transparent:true + resizable:true.
On Windows, a transparent frameless window silently grows ~1px per
setPosition call (worse at >100% DPI scaling) — every drag of the composer
bar accumulated size drift, and the HUD could end up enormous (reported at
1385x1052 against a 620x320 default). Reading the size back mid-drag
compounds the drift because getSize() returns the already-drifted value.

Fix, mirroring the pet overlay's pattern:
- create the HUD window non-resizable (no system edge resize hot-zone)
- moveBy uses setBounds with a size snapshotted on the first move of each
  drag, so the OS can never accumulate drift (verified: 500 moveBy calls
  with zero size change on Electron 40 / Win11 / 175% DPI)
- add a bottom-right corner resize handle (resize-handle.ts) driving a new
  hermes:hud:set-bounds IPC that flips resizable on for the call, restoring
  the ability to resize a window that is otherwise non-resizable

* fix(desktop): pin HUD drag size in renderer, not main-process globals

The superseding pass drops hudDragWidth/hudDragHeight from main: composer
drag snapshots outerWidth/outerHeight when the hold arms (pet overlay
pattern) and passes them on every moveBy. Adds one test for that contract.

Supersedes #82455.

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* fix(desktop): keep the HUD solid through a corner resize; drop dead handle state

The resize handle's `resizing` flag only fed a CSS rule that restated the
cursor it already had, so nothing pinned the window mid-gesture: click-through
hands the mouse away the moment the growing edge outruns the cursor. Raise the
composer drag's existing `data-hud-grabbing` instead — one flag for "a gesture
owns the window" — and cover it in click-through's tests.

Also drops the hook's always-true `enabled` param and routes teardown through a
`reset` callback, matching composer-drag.ts and clearing the atom-mirrored-ref
lint rule.

---------

Co-authored-by: Ringo6107 <199014580+Ringo6107@users.noreply.github.com>

* feat(desktop): snap HUD to cursor with global ⌘⇧G

Register CommandOrControl+Shift+G in main while HUD mode is open so the
floating bar can jump under the pointer from any app. Tap-to-snap only —
Electron globalShortcut has no keyup for hold-to-follow.

* fix(desktop): list HUD snap chord in keyboard shortcuts panel

Document ⌘⇧G as a read-only global shortcut active while HUD mode is up.

* fix(desktop): sort hud snap imports for eslint

* fmt(js): `npm run fix` on merge (#83132)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fix(desktop): skip titlebar Y nudge on Tahoe and macOS fullscreen

Tahoe already aligns traffic lights without the optical translate. In
fullscreen, drop windowButtonPosition in the main process and clear the
right-cluster inset so traffic-light dodge chrome goes away on both sides.

* perf(desktop): multi-tile grids stop lagging — evict leaked session states, index lineage aliases, split the turn journal (#83133)

* fix(desktop): evict settled session states nothing on screen references

Closing a tile never removed its runtime's entry from $sessionStates, so
every tile ever closed parked its full transcript in the map for the life
of the process. Each leftover entry taxes every subsequent stream flush —
the map is spread-copied per delta and the busy/attention/draft projections
walk every entry per publish — so the app got slower the longer it ran,
which users read as "I need to clean my sessions/dbs".

Publish now evicts a settling state when no tile and not the primary view
holds its runtime (transition side effects still fire, so the settle keeps
its unread dot), and closing a tile drops an already-settled state on the
spot. Busy and needs-input states stay: background turns feed the sidebar
dots, and a first publish always lands because a resume can publish a beat
before the surface binds the runtime.

16 tiles streaming in a 2x2 grid with a day's worth of closed-tile residue:
worst-second 34 -> 58 fps, p99 frame 90 -> 28 ms, longtasks 37 -> 0.

* perf(desktop): index lineage aliases per sessions-list reference

lineageAliases scanned the whole recents list per call, and it is called
per cached session state per status projection per message delta — with a
populated sessions DB and a few busy sessions that multiplied out to
millions of row checks a second during streaming. Build the alias index
once per list reference (the list is replaced wholesale, never mutated)
and look aliases up in O(1).

* perf(desktop): journal each in-flight turn under its own storage key

The v1 journal kept every session's tail in one localStorage key, so each
throttled write re-parsed and re-stringified EVERY busy session's snapshot
— a grid of concurrent streams turned that into a whole-store JSON round
trip dozens of times a second, all on the main thread. Per-session keys
make a write O(own tail) no matter how many other sessions are streaming.
A v1 store migrates on first touch; expired/overflow crash residue is
pruned once per renderer.

* perf(desktop): stress the multitab scenario across grid/streaming/DB axes

The one-stack multitab run hid every cost this round of fixes removed: it
drove hook.publish (store only — no journal, no wiring cache), with an
empty recents list and no closed-tile residue. Streaming now routes through
hook.update (the real gateway write path), and the scenario grows axes for
the workloads users actually hit: --zones splits tiles across visible grid
zones, --streaming caps how many sessions are mid-turn (zone leaders
first), --sessions seeds a lived-in recents list, --dead models settled
sessions no surface references. launch.mjs pins HERMES_DESKTOP_CDP_PORT so
a non-default --port survives the app's own dev-CDP flag.

* fix(desktop): satisfy no-extra-boolean-cast in fullscreen guard

* fmt(js): `npm run fix` on merge (#83139)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* fmt(js): `npm run fix` on merge (#83143)

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>

* chore: add Angriff36 to AUTHOR_MAP for PR #29543 salvage

* perf(cli): sub-400ms warm startup — probe-mode check_fns, lazy MCP SDK, banner snapshot, parallel worktree add

Cold CLI time-to-banner was ~1.8s (hermes) / ~2.8s (hermes -w). The banner
path was paying for work the session doesn't need before first input:

- aux availability probes built REAL OpenAI/httpx clients (openai import
  ~0.3s + SSL context) just to answer check_fns. New aux_probe_mode()
  returns a cache-excluded stub; resolution policy unchanged.
- tools/mcp_tool imported the mcp SDK (~260ms, mcp.types pydantic model
  construction) at module import even with zero MCP servers configured.
  SDK import is now lazy behind _ensure_mcp_sdk(); _MCP_AVAILABLE is a
  find_spec probe so every existing gate/test keeps its semantics.
- banner blocked 500ms on the update-check prefetch; now waits 50ms and
  defers the warning line to a daemon thread (prints above the prompt).
- banner recomputed get_tool_definitions + skills scan + git state every
  launch; now snapshotted to ~/.hermes/cache/banner_snapshot.json keyed on
  (config.yaml, .env, checkout rev, toolsets) and replayed on warm launches
  with a background refresh. Agent tool list is still computed fresh.
- _resolve_active_context_length probed the Nous portal /models (~200ms
  network) per launch; the tool-search gate now prefers the on-disk
  context cache when present.
- schema reconciliation re-executed SCHEMA_SQL in a scratch SQLite DB
  (~85ms) per SessionDB(); the reference parse is now disk-memoized by
  DDL hash (live-DB diffing still runs every startup).
- bundled-skills sync (~120-170ms rglob/hash) moved off the startup path
  to a daemon thread; plugin discovery starts in the background and every
  synchronous consumer joins via discover_plugins().
- hermes_cli.auth imported httpx eagerly (~30ms); now a lazy proxy that
  test monkeypatching still reaches (setattr forwards to the real module).
- fast chat launch: unambiguous 'hermes'/'hermes chat' invocations skip
  building all ~40 subcommand parsers (bails to full dispatch on anything
  else, incl. container mode).
- -w path: git worktree add runs with checkout.workers=8 (0.6s→0.2s) and
  overlaps HermesCLI construction; --skills preload runs in the background
  and is folded in at agent init (finalize_preloaded_skills, same
  fail-loud contract for fully-unknown skill lists); stale-worktree prune
  moved off the banner path.

Warm results (PTY time-to-banner, 5-run): hermes 1.80s → 0.38-0.40s;
hermes -w -s hermes-agent-dev --yolo 2.82s → 0.57-0.69s.

* test: read _MCP_LOGGING_CALLBACK_SUPPORTED via module after _ensure_mcp_sdk

The SDK-support flag is now bound lazily (startup-latency change); a
by-value module-level import freezes the pre-bind False. Read it off the
module after _ensure_mcp_sdk() so the test observes the real support
state — same contract, lazy-aware.

* feat(browser): integrate Browser Use CLI 3.0

* fix(browser): persist workspace across browser_exec calls; raise exec timeout 300s/1800s max; teach in-code aggregation + count verification in tool header

* fix(browser): rm secrets from browser_exec subprocess; /browser off; hide windows console

* fix(browser): apply safety checks to browser_exec URLs

* fix(browser): gate browser_exec on terminal surface; pin schema helpers digest

Follow-ups on the salvaged Browser Use CLI integration (PR #66476):

- browser_exec runs model-written Python on the host. Strip it at
  tool-definition time for sessions whose resolved toolsets exclude
  'terminal' so terminal-less surfaces (locked-down messaging configs)
  don't silently regain host code execution through the browser toolset.
  Session-level gate in model_tools, not a check_fn (check_fn results are
  TTL-cached process-wide across sessions).
- Replace the live 'browser-use skill' schema fetch with a pinned helpers
  digest: no third-party version-drifting text in the prompt, byte-stable
  schema across machines. A/B benchmarked (108 runs, opus-4.8 + kimi-k3,
  6 multi-step web tasks x 3 arms x 3 reps): pinned digest matches the
  full skill dump 36/36 vs 36/36 at ~equal tokens; both cut total task
  tokens ~60% vs the legacy browser_* toolset.
- Docs note for the terminal gate; contributor mapping for salvage.

* fix(browser): don't migrate Camofox users to Browser Use CLI mode

Camofox is selected via CAMOFOX_URL env var, not browser.cloud_provider —
so a Camofox user with a stray BROWSER_USE_API_KEY in .env matched the
legacy-migration predicate (cloud_provider unset + key present) and got
silently flipped into CLI mode, losing browser_* / Camofox entirely
(browser_exec cannot drive Camofox: its HTTP API exposes no CDP endpoint,
and the browser-use harness is CDP-only against Chromium).

is_legacy_browser_use_cloud_config() now defers to is_camofox_mode().

* feat(browser): Browser Use mode composes with all CDP browser backends

Reframe (per review): browser.backend: browser-use is now a DRIVER over
whatever browser source is configured, not a competing backend choice.

- browser_exec resolves its CDP endpoint through the same chain the
  built-in tools use: BU_* env override > BROWSER_CDP_URL/browser.cdp_url
  (/browser connect) > the configured cloud provider via browser_tool's
  _get_session_info() — sharing the per-task session cache, expiry
  replacement, inactivity reaper, and atexit cleanup instead of
  duplicating them. Live-validated against Browserbase (session created,
  driven, reaped) and gateway-provisioned Browser Use cloud browsers.
- Direct-API Browser Use configs skip provider resolution (the CLI talks
  to their cloud natively via BU_AUTOSPAWN); the Nous-gateway variant
  resolves through the provider, so subscribers get CLI mode without a
  raw BROWSER_USE_API_KEY.
- Camofox: only true fallback — Firefox-based, custom HTTP API, no CDP
  surface (its own health probes fail on CDP-schema calls). Active
  Camofox setups keep the built-in browser tools even with
  backend: browser-use set.
- hermes tools picker: provider rows and the Browser Use row are no
  longer mutually exclusive; selecting a provider keeps the driver
  choice, and both rows highlight when composed.
- Docs updated for driver-over-source semantics.

* fix(ci): review comment poller deadlocked on its own run

The poller job set GITHUB_RUN_ID in env: to point at the CI run.
The Actions runner sets the GITHUB_* defaults itself and ignores
the override. Thus the poller read its own run id and watched
itself. Its own run stays in_progress while the poller runs, so
runs_all_completed() was never true. The comment froze at
'waiting for jobs to start' and the job burned its full 3000s
timeout on every PR.

Rename the variable to CI_RUN_ID. Also drop the GITHUB_REPOSITORY
override — it was a no-op for the same reason, and the runner
default already holds the correct value.

* fix(sec): patch the npm advisories main left open

Main (7537de9e7) moved most of the vulnerable locked versions, but some
fixes live only in the lockfiles and some advisories stayed open. This
commit closes the rest:

website/package.json gets durable overrides for js-yaml 4.3.1,
dompurify 3.4.13, mermaid 11.16.1, and tar 7.5.22. The root workspace
gets the same tar override, which moves the tar 6.2.1 copies under
get-windows and @mapbox/node-pre-gyp past twelve open advisories.
Without an override, a reinstall can pull an old transitive copy back
in.

image-size <=2.0.2 has two infinite-loop DoS advisories and no fixed
release upstream. An override points it at @nous-research/image-size
2.0.3, our maintained fork of the real repo. The OSV scanner resolves
the aliased fork cleanly, so no ignore entries are needed.

The photon sidecar moves @opentelemetry/core to 2.10.0. The
whatsapp-bridge gets a body-parser 1.20.6 override, so the lockfile-only
fix from main cannot regress on reinstall.

website/.npmrc gets matching min-release-age exclusions for the fix
releases that are less than two weeks old.

electron stays at 40.10.2. The 41.x fix for GHSA-9f4c-93c8-jc8g brings
back the install failure that bb8280b75 reverted: install.js in 40.10.3+
extracts with an MSVC native binding, which fails on Windows machines
without the VC++ Redistributable. Upstream tracks this in
electron/electron#52481, with no fix released.

* fix(sec): move cryptography to 50.0.0

cryptography 48.0.1 carries three advisories (GHSA-m2h6-j472-rp4c,
GHSA-jwv3-5hgf-82ww, CVE-2026-69247). msal and alibabacloud-tea-openapi
cap cryptography below 49, so the bump needs an override-dependencies
entry in [tool.uv] to take effect.

The cap is conservative, not a real limit: we installed tea-openapi
against cryptography 50 and its client ran with no errors.

This override only governs `uv lock` / `uv sync`. The lazy-install
path does not read [tool.uv] and can still downgrade the pin; the next
commit closes that path.

aiohttp moves to 3.14.3 in the same pass, for GHSA-9548-qrrj-x5pj.

* docs(kanban): document the parent-link context handoff for follow-up cards

Adds 'Handing context to follow-up cards (the parent link)' to the kanban
feature page and a CI-remediation worked example to the tutorial, with
zh-Hans mirrors. Claims live-verified against kanban_db on an isolated
board: create_task creates children of done parents directly in ready,
recompute_ready leaves children of open parents in todo, and
build_worker_context surfaces the parent's completion summary and
metadata under '## Parent task results'.

* docs(delegation): document frontier-planner / inexpensive-worker cost split

Surface the existing planner/worker cost-split capability as an explicit
strategy in the docs:

- delegation.md: new 'Cost strategy: frontier planner, inexpensive workers'
  subsection under Model Override, with a config.yaml snippet using the
  verified delegation.model / delegation.provider keys, the resolution order
  (base_url > provider > inherit parent; model applies in all cases, empty =
  inherit), and a note that delegate_task has no per-task model parameter —
  quality-sensitive tasks should use kanban's per-task override instead.
- kanban.md: matching 'Cost strategy: frontier orchestrator, inexpensive
  workers' subsection using the verified per-profile config mechanism
  (dispatcher injects profile-scoped HERMES_HOME at worker spawn) and the
  existing per-task model_override (--model/--provider, set-model, dashboard).
- zh-Hans mirrors for both pages.
- cli-config.yaml.example: cost tip comment under the delegation section.

Config resolution was live-verified against tools/delegate_tool.py
(_load_config + _resolve_delegation_credentials) with a temp HERMES_HOME:
delegation.model pins children to the sentinel model; with no delegation
keys, children inherit the parent model and credentials.

* fix(desktop): isolate plugin render hooks

* feat(skills): add bundled merge-reconciler skill for neutral multi-agent conflict resolution

Adds skills/autonomous-ai-agents/merge-reconciler — a bundled skill teaching
a neutral third-party agent to resolve git merge conflicts between two
agents' branches: gather both diffs + intents, classify each hunk
(disjoint-intent / same-question-different-answer / superseded), resolve
under an impartiality contract, verify, and hand back a per-hunk summary.
Procedure was live-tested end-to-end against a real conflict fixture.

Includes contract tests (tests/skills/test_merge_reconciler_skill.py) and a
kanban docs cross-reference (en + zh-Hans): assign a third neutral profile a
reconciliation card with both conflicted cards as parents.

* Port from earendil-works/pi#7493: advertise AI_AGENT env var for child-process attribution

CLI and gateway entry points now set AI_AGENT=hermes (the emerging
cross-agent standard read by e.g. huggingface_hub agent detection) and
HERMES_AGENT=true, via setdefault so an outer harness is never
clobbered.

* fix(attribution): correct AI_AGENT id to registry value and carry harness markers into all terminal backends

The Hugging Face agent-harness registry matches standard-var values
EXACTLY against the harness id. Our registry id is 'hermes-agent'
(huggingface.js agent-harnesses.ts), so AI_AGENT=hermes was counted as
'unknown' — fixed at both entry points.

Remote terminal backends (Docker/SSH/Modal/Daytona/Singularity/Vercel)
never inherit the Hermes process env, and the cross-session leak guard
deliberately strips HERMES_SESSION_* from subprocess envs in engaged
multi-session hosts — so hf/huggingface_hub traffic from those shells was
unattributable. _wrap_command now exports AI_AGENT/HERMES_AGENT inside
every wrapped command with ${VAR:-default} semantics (outer harness is
never clobbered), and the snapshot dump excludes both names so a baked
value can never shadow a later outer harness.

E2E: verified against real huggingface_hub 1.27.0 detect_agent() with a
cached registry — 'hermes-agent' detected via AI_AGENT and via
HERMES_SESSION_ID; old 'hermes' value reproduced the 'unknown' bug.

* fix(ci): merge all duration slices, not one

Each test slice uploads an artifact with the same file name,
test_durations.json. The save-durations job downloaded the 12
artifacts with merge-multiple, so all extractions wrote to one
path in parallel. This caused two faults:

- A race between two extractions wrote two JSON documents into
  one file. The merge step then failed with 'JSONDecodeError:
  Extra data' (run 31382130252).
- On green runs, the last write erased the other 11 slices. The
  merged cache held ~230 of ~2760 file durations.

Remove merge-multiple so each artifact extracts into its own
directory, and point the glob at durations/*/test_durations.json.
A local merge of the 12 real artifacts from the failed run gives
2761 durations.

* feat(file-ops): name the binary type in read_file refusals (magic-byte sniff)

'Binary file - use appropriate tools' names a recovery the model may
not have — in a file-only toolset it thrashed for 41 turns / 178 tool
calls / 1.5M tokens on a PNG-behind-.txt (readtool eval, qwen3.8-max)
hunting for tools that did not exist. Name the type instead: 25 magic
signatures (images, archives, executables, media, SQLite), ftyp check
for ISO media, size in human units. 'Binary file (PNG image data,
4.1 KB) - cannot display as text.' answers what-is-this in one read.

Both ShellFileOperations refusal sites (read_file + read_file_raw) use
the shared describe_binary_file(); the extension-based guard keeps its
extension mes…
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
Since NousResearch#65919 the live view seals each chunk of mid-turn assistant
commentary (message.interim) as its own finalized bubble. Every bubble
with visible text renders the hover action footer, so a tool-heavy turn
grew a copy/refresh bar under almost every paragraph — and the live
render didn't match rehydration, which merges the turn into one bubble.

Mark sealed interim bubbles with ChatMessage.interim, carry the flag
into the runtime message metadata (custom.interim), and skip the
AssistantFooter for them. The turn's final reply keeps the footer; a
previewed final that settles onto an interim bubble clears the mark so
the settled reply regains its actions. interim joins COMPARED_FIELDS /
chatMessagesEquivalent so flipping it repaints.

Also fix an id-collision flake this surfaced: stream/interim bubble ids
were Date.now()-only, so an interim seal and the next segment's first
delta in the same millisecond reused the id and the new segment appended
into the sealed bubble. Ids now include a monotonic sequence.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…9580)

* test(desktop): e2e test for interim assistant message preservation (NousResearch#65919)

Adds a Playwright E2E test that reproduces the fix from PR NousResearch#65919 across
all three layers (agent core → tui_gateway → desktop renderer). The mock
inference server is upgraded with a multi-turn scripted response that
exercises several interleaved patterns:

  1. text + tool_call  → should produce an interim message
  2. text + tool_call  → another interim message
  3. no text + tool_call → NO interim (no visible text alongside tools)
  4. text + tool_call  → another interim message
  5. final answer (stop) → message.complete, different from all interims

Two describe blocks exercise display.interim_assistant_messages both on
(default) and off:
  - ON:  all interim texts + the final answer visible in the transcript
  - OFF: only the final answer visible, all interim texts wiped

Also fixes a footgun: test:e2e now runs `npm run build` as a pretest
hook so the renderer dist/ is always fresh. Previously, running
`npx playwright test` locally would silently load a stale dist/ that
predated renderer fixes — the python backend ran from source (had the
fix) but the renderer was frozen in an old bundle. CI already built
fresh, so the explicit build step there is removed to avoid duplication.

* test(desktop): e2e sidebar states — background dot, subagent, cross-session

Add sidebar-states.spec.ts with three E2E tests exercising the desktop
sidebar's session dot states driven by real gateway events:

1. Background process dot appears during a terminal(background=true)
   call and disappears after auto-dismiss; subagent (delegate_task)
   runs concurrently; final answer is visible in the transcript.

2. Background dot remains visible while a subagent runs concurrently
   (longer sleep 5 background process so the dot is catchable).

3. Cross-session dot transition: start a turn with a background process,
   wait for the turn to complete, open a new session, then verify the
   original session's dot transitions from 'background running' to
   'finished — unread' when the background process exits.

The mock server gains SIDEBAR_SCRIPT and SIDEBAR_CROSS_SCRIPT trigger
keywords that return tool_calls for terminal(background=true) and
delegate_task — the agent executes these for real (real background
process, real subagent), so the tests assert against genuine gateway
events rather than mocked UI state.

Verified: 3 passed (1.2m) under cage headless wlroots.

* test(desktop): e2e tests for tile-unread bug (tab passes, split fails)

Two scenarios for the tile-unread bug where a session that finishes
while visible on-screen gets the green 'finished unread' dot even
though the user is looking right at it.

The unread check in handleTransition (session-states.ts:174) only
compares against $selectedStoredSessionId and ignores $sessionTiles,
so a session visible in a tile gets marked unread even though it's
on screen.

1. TAB (hidden, PASSES): ⌃-click opens the session as a stacked tab
   that is NOT visible on screen. The unread dot IS correct here —
   the user isn't looking at it.

2. SPLIT (visible, FAILS): drag the session row to the workspace's
   right edge to create a side-by-side split tile. Both sessions are
   visible on screen. The unread dot is WRONG — the session is visible
   in the split tile, so it should not be marked 'unread'. This test
   is RED until the fix lands.

Also adds explicit page.screenshot() calls at key assertion points in
sidebar-states.spec.ts so the trace viewer has full-res captures of the
sidebar dot states during the test.

* test(desktop): cover compression and queued stop lifecycle

Add real desktop E2E coverage for session compression continuation and
queue parking after an explicit Stop. Extend the mock server with a
blocking scripted turn and submitted-prompt assertions.

* test(desktop): cover busy composer submit routing

Replace the invalid queued-stop E2E scenario: plain text redirects a busy
turn rather than entering the queue. Add focused submit-routing coverage for
plain text, slash commands, attachments, explicit Stop, and idle submission.
33hodl pushed a commit to 33hodl/hermes-agent that referenced this pull request Aug 12, 2026
…esearch#81641)

A pure-text assistant turn (finish_reason=stop) had no durable write of
its own. Its answer reached the user through the streaming / interim
display path, which is display-only and never touches state.db, and the
first durable write was finalize_turn's _persist_session — after the
loop exits and behind post-turn work that can include micro-compaction's
aux-LLM call.

Anything that ended the process or tore the session down inside that
window lost a reply the user had already been shown. On a remote
(non-loopback) backend the window is easy to hit: WS 1006 closures drive
ws_orphan_reap teardown, and affected sessions ended up with user rows
and zero assistant rows in state.db.

The neighbouring exits of the same loop already close this gap:

  * the tool-call exit flushes the assistant(tool_calls) block before
    handing control to _execute_tool_calls (NousResearch#49045)
  * the verify-on-stop and pre_verify exits flush final_msg before
    appending their nudge (NousResearch#65919 §7)

Apply that same idiom to the ordinary text exit rather than adding a new
persistence mechanism. The intrinsic _DB_PERSISTED_MARKER dedup makes the
later _persist_session a no-op for this row, so no duplicate rows and no
extra write — the same write, just earlier.

Unlike the tool-call exit, a failed flush must not abort the turn: no
side effect runs after this point and the answer is already produced, so
the failure is logged and _persist_session remains the retry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard comp/desktop Electron desktop app (apps/desktop/*) comp/gateway Gateway runner, session dispatch, delivery comp/tui Terminal UI (ui-tui/ + tui_gateway/) needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

6 participants