The core-tool rename shipped `todo_list` on the wire (legacy alias `todo`
kept for old transcripts), but the Desktop renderer still matched the tool
by the literal `todo` in seven places: the live tool.start/tool.complete
mirror into the composer status stack, the todo-stream router, args
carry-over, the transcript hoist, the silent-tool class, the count noun,
and stored-history hydration. Every live task update therefore went into
the transcript as an ordinary tool row while the task panel stayed empty,
and reopening a chat never restored a finished list.
One predicate (`isTodoToolName`) now owns the wire/legacy name pair and
every site reads it.
Keep command-palette literal filter semantics unchanged; opt model surfaces into the shared fold. Preserve original highlighter contracts and trim new regressions to two invariants. Native Electron catalog before/after and visibility dialog verified; campaign suites remain queued.
Model ids use hyphens/underscores, display names use spaces, versions use
dots. The pickers' filter haystacks contained both the raw id and the
display name, but the highlight only ever saw the display label — so
'qwen3.8-flash' revealed the row (via the id segment) while lighting up
nothing, and model-picker.tsx had the inverse polarity (spaces matched,
hyphens didn't highlight). This violated HighlightMatches' own documented
contract: the query must mirror the filter's semantics or the emphasis
lies.
One length-preserving searchFold ([-_.] -> space, 1 char in / 1 char out)
now runs on both sides of every model-search filter AND inside
HighlightMatches' range finder. Length preservation keeps mark ranges
valid against the original text, so <mark> rendering is untouched. The
fold is a per-character substitution applied to both sides, so any query
that matched before still matches — only coverage grows.
- lib/text.ts: searchFold + foldIncludes, the one matcher for all pickers
- highlight-matches.tsx: ranges computed on folded text, marks slice original
- model-catalog-menu.tsx: family, MoA, and download haystacks use foldIncludes
- model-visibility-dialog.tsx, model-picker.tsx: same (fixes inverse polarity)
- dropdown-menu.tsx: DropdownMenuSearch sets spellCheck={false} — squiggles
under model ids are noise; composer/settings inputs already disable it
Tests: fold primitives (equivalence, 1:1 length, superset), highlighter
fold behavior + index fidelity, end-to-end menu behavior (hyphen query
marks the spaced label; space query finds the hyphenated id without
over-matching), and an updated hidden-model-search test whose id-style
query now legitimately highlights.
Recognize the full producer task header when a batch goal spans lines,
so the next task goal and preceding transcript footer cannot become part
of the displayed result. Extend the existing invariant and real SQLite
producer probe with that case.
Make the live probe artifact path explicit, run Chromium headlessly, and
document its synthetic fixtures and integration fidelity limits.
Keep compact completion labels while rendering only result and job output bodies, including legacy and batch deliveries. Credit Gyarados4157's #101083 investigation; avoid rendering its full model instruction envelope.
Remove whole-document whitespace cleanup that erased hard breaks and fenced-code spacing across all five ingestion paths. Limit generated-image gap joining to the removed image itself. Keep soft newlines as soft breaks.
Investigated #97117 and the approaches in #97431 (@wooyongbin3-cpu) and #97175 (@Jackal991); both leave fenced-code whitespace and sibling ingestion paths exposed, so remove the destructive normalization instead.
- The placeholder ("assistant response not persisted") is removed: it turned a
wholly-empty persisted row into TEXT, which in reconcileResumeMessages could
replace a text-only live stream row at the same ordinal until the turn
settled. The sidecar fallback alone covers the #68321 field repro.
- Phase filter matches the backend (codex_responses_adapter _OutputScan._message):
commentary AND analysis are reasoning-channel narration, not the reply.
- Tests trimmed to the two contracts that are red on main; six that passed on
main (pre-existing reasoning-part behaviour) are gone. File renamed for what
it now covers.
Field-level reproduction (2026-09-02, v0.21.0, in the issue thread): an
assistant row with content length 0 whose user-visible response exists
only in reasoning / reasoning_content / codex_message_items renders
live, then disappears from the transcript after a session/profile
switch-back. DB intact - the row is still there on every re-read; only
the rehydrated render loses it. Six independent confirmations across
macOS and Windows since 2026-07-22; all prior fixes (#68329 envelope
normalization, closed implemented_on_main; #77644 mid-turn reconcile;
#101470 compaction-display projections) addressed adjacent producers,
not this one.
Two producers in toChatMessages hydration, both pinned by the new
chat-messages.reasoning-survival.test.ts (fails 9/9 on current main):
1. codex_message_items never read. Responses-API turns can persist with
`content` empty while the reply the user saw lives only in the
message-items sidecar ({type:'message', role:'assistant', phase,
content:[{type:'output_text', text}]}). The live stream painted that
text; hydration ignored the sidecar, so the rehydrated bubble came
back blank - and the blank chatMessageText at the same role-ordinal
then made reconcileResumeMessages drop the cached row's parts
(sameText/extension/isLiveTailRow all fail), erasing the reply on
every switch-back. Fix: codexMessageItemText() extracts
non-commentary assistant output_text as the bubble's text when
content and reasoning produced no parts. Persisted content still
wins when present; commentary narration is never promoted.
2. the zero-parts drop. An assistant row hydrating to no parts at all
returned early and vanished from the transcript entirely - the
"all assistant messages gone; user messages remain" shape. Fix: a
wholly empty assistant row (display_kind != 'hidden') paints a
_(assistant response not persisted)_ placeholder instead of
disappearing. Hidden scaffolding rows keep dropping as designed
(pinned by a test).
Also adds codex_message_items to the SessionMessage type (the gateway
has shipped it since the branch-copy lane; the type never declared it).
Verification (native Windows, node 24.17):
- new file: 9 passed (fails 9/9 without the hydration changes)
- src/lib full dir: 1243 passed
- reconcile suites (utils + resume-structural-parts): 115 passed
- streaming/timeline reasoning-part suites: 25 passed
- tsc --build tsconfig.json: clean
Fixes#68321
The guard `fromKey === toKey || fromKey !== NO_SESSION` only ever admitted
null -> real; write that directly. Bot Chat kickoffs are visible user rows
(prompt.submit without display_kind), so the comment names only widget
intents as the hidden-row producer. No behaviour change: 98 tests green,
real->real leak test still fails when the guard is loosened.
Follow-ups to the #96622 salvage:
- collectUnspokenTurnSpeech bounded the fallback at the last *visible*
user row. A turn kicked off by a hidden user prompt (inline-preview
widget intents, Bot Chat kickoffs) has no visible user, so the slice
fell back to index -1 and replayed the whole transcript again. Hidden
user rows are real turns for the agent; count them.
- adoptSpokenReplySession copied the null-session anchor onto the created
session but left the null entry behind, where the NEXT new chat (null
session again) would read it and skip its first reply. Move, not copy.
- Pin the "target already has an anchor -> not overwritten" guard.
When lastSpokenId is missing or stale, collectUnspokenTurnSpeech used
index -1 and concatenated every prior assistant reply into one speech
string. Bound collection to the current turn (after the last user
message) and carry the spoken anchor from a null session id onto the
created session so live conversations stay turn-scoped.
Artifact detection runs before the rich-fence registry, so a fence long
enough to look like a code document was promoted to a code card and never
reached its own renderer. A multi-listing set crosses that threshold
easily, which made the same content render two different ways depending on
how many properties it held.
`listing` joins the languages that are never artifacts, next to `mermaid`
for the same reason.
Adds a `listing` fence to the rich-embed registry: JSON in, a native card
out — photo mosaic, address and price, specs, and the catches worth
verifying before a tour.
The rental portals publish no embeddable surface (public APIs deprecated or
broker-gated, no oEmbed, X-Frame-Options on every listing page), so a URL
cannot be framed. The data the agent already gathered can be, and the
portals' image CDNs hotlink fine — so the card is authored rather than
fetched, alongside the existing mermaid and svg fences.
Parsing treats every field as untrusted model output: one object, a bare
array, or `{listings: []}` all work, beds accept `3` and `"3.5"`, non-http
URLs are rejected so a card cannot smuggle a javascript: href, and lists
are capped. Anything unparseable falls back to the plain code block.
Two faults in the crop that "Add N comments" attaches, both visible in a
two-comment batch on one page.
The marker was missing. showDraft sets the marker's style and resolves, but
resolving only means the property is set — the compositor has not drawn it.
capturePage then photographed the frame before the marker existed, so crops
arrived outlined in blue with no number, while the prompt line said "Image N
marks the target in blue". beginCapture now waits two animation frames, which
puts the shot after the paint.
The wrong marker could appear. Saved pins stay drawn on the page, so any pin
within the crop padding of the new element landed inside the shot: a comment
on a heading came back carrying the marker belonging to the comment on the
paragraph below it, pointing the agent at the wrong element. Saved pins and
hover chrome are hidden for the duration of the shot and restored after.
Restoring runs in a finally, so a capture that throws cannot leave every saved
pin invisible on the page, and the guest calls are best-effort — a torn-down
overlay degrades to the old unbracketed shot rather than failing the capture.
Twenty-three comments arrived as twenty-three flat blocks, so the agent made
twenty-three todos and ground through them one at a time. They now arrive
grouped by where they sit in the page, with a line telling the agent to work
the groups rather than the comments.
The renderer groups on structure, not meaning. Whether a comment is a UI nit
or a functional bug is a judgment only the model can make, and prose-matching
it here would be wrong constantly; which pins share a DOM subtree is something
the selector already answers. That split is also the one that makes parallel
work safe — grouping by theme instead ("all the spacing ones") cuts across the
same components and puts several workers in the same files, so the guidance
says to hand out whole groups and never to regroup by theme.
Grouping compares ancestor paths, so a heading and a paragraph in one card
stay together instead of becoming two singletons. Depth is derived rather than
tuned: descend the shared prefix until it stops being shared, then sub-split
any group still holding more than a third of the batch — without that pass a
normal page buries every section under `main`. Batches under four comments,
and batches that all land in one region, stay flat.
Grouping is advice in the prompt, never an action: the renderer does not spawn
or delegate anything. That stays the agent's call.
Comment mode shipped the crop and the note, so an agent got a picture of the
problem and had to grep for the element it showed. Each element comment now
also names its CSS selector, its markup, and the computed styles that decide
layout, which is what the agent needs to land in the right file.
The target line stays prose — it is what the user pointed at — and the DOM
detail rides labelled lines beneath it. Area pins have no element, so they
still get only the crop and the note.
Markup is redacted in the guest before it crosses to the host: password and
hidden input values, and any attribute reading as a key/token/secret, are
replaced with [redacted] on a clone, so a page's secrets never reach the
composer or the model. It is clipped to a 600-char budget so one comment
cannot paste a whole section.
AnnotateIdentity was a hand-copy of CompactIdentity that had already drifted;
it is now an alias, so the guest, the pin, and the packer cannot disagree
about the shape again.
Two new right-click-toggleable status bar items, mirroring the CLI/TUI
Pantheon status bar upgrades: prompt-cache hit rate ("87%") and rolling
output throughput ("42 t/s"). Both are hidden by default and enabled from
the bar's existing 'Show in status bar' context menu, like the context meter.
Renderer-only: the tui_gateway already emits cache_hit_pct and avg_tps in
every session.usage tick and message.complete payload, so the items ride
the same UsageStats the context meter reads — no new RPC, no polling.
Labels show a placeholder until the backend has data, never self-hide.
The approvals loop and broadcast_session_info fan out UNSCOPED session.info
frames for every live session. The event router attributes an unscoped
frame to the active session, and approvalReplaySessionId then re-pulls
approval.pending for it. When the active runtime is already gone (4001),
that made every fan-out tick a fresh dead-id request — the dominant source
of the 1,369 post-restart approval.pending rejections in #100639.
approvalReplaySessionId now takes the frame's explicitness and a gone
predicate and returns null for an unscoped replay onto a latched runtime.
An explicitly scoped frame is the runtime speaking for itself and is never
skipped.
Refs #100639
Desktop "Read replies aloud" / voice conversation, TUI and CLI /voice tts
now hold a lease on the TTS engine. Acquiring pre-loads the configured
provider (piper/kittentts model into the same LRU slot synthesis reads;
lazily-installed cloud SDKs), so the first spoken reply no longer pays the
model load as dead air. Releasing the last lease across surfaces unloads
resident local models.
- tools/tts_tool.py: warm_tts_provider / release_tts_provider /
acquire_tts_lease / release_tts_lease over a _LOCAL_TTS_MODEL_CACHES
registry; piper/kittentts loaders extracted so warm-up and synthesis
share one resolution path.
- web_server: POST /api/audio/tts-lease (profile-scoped, off-loop,
failures reported in body never as HTTP errors).
- tui_gateway voice.toggle + cli.py /voice tts|on|off wire the lease.
- desktop: lib/tts-lease.ts (dedupe, per-lease serialization, latest
intent wins) driven from useComposerVoice; setTtsLease API client.
- docs: features/tts.md section.
Live (real piper, isolated HERMES_HOME): first synthesis 988ms cold →
92ms after the toggle warmed the engine; release drops the model.
Run models locally as a first-class provider. The CLI grows a managed
llama.cpp runtime (engine install, model download, server supervision);
the desktop app grows the full setup and management story on top of it.
GUI surfaces ship behind the desktop --local launch flag (hermes desktop
--local, or the flag on the packaged app); backend routes and the CLI
are always live.
Runtime (hermes_cli/local_runtime/):
- curated GGUF catalog with per-machine variant selection: hardware
probe (VRAM/RAM/UMA), fit planning with spill accounting, quant choice
by context window
- derived recommendation: quality-ranked picks gated by a predicted
decode-speed floor, bandwidth-aware on unified memory; the decision
table is pinned as a test (pick AND reason per memory class), and the
Recommended badge explains its pick in a tooltip fed by the resolver's
actual branch
- engine install + model download with resumable split parts, cumulative
plan-level progress, and staged-model integrity (a split GGUF counts
only when every part is present)
- server supervision: spawn/adopt/stop, router mode with per-model load
progress relayed over SSE, abandoned-request cleanup
Desktop:
- Settings -> Providers -> Local models: one-click quickstart (install
engine, download the recommended model, boot) plus per-model download/
activate/eject, fit-ranked catalog with context pills
- model pickers (composer dropdown + Cmd+K) show staged local models,
in-flight downloads as live progress rows, and load-into-memory bars
- local-setup campaign tip for eligible hardware; System resources
statusbar widget (GPU/VRAM/RAM); in-chat load progress during sends
- friendly dead-server errors, and failed agent builds retry on the next
send instead of wedging the session
Co-developed with NVIDIA field feedback on RTX 5090 and DGX Spark.
Auto-fix perfectionist import/export ordering across the new
preview-annotate files, and extract the conversation-switch annotate
reset into a useCallback so the effect body carries no .current writes
(no-restricted-syntax ref-mirror rule).
Click or drag on the preview page to pin a numbered comment. Saving a pin only adds it to the stack. Add comments drops the crops and a short prompt into the composer and never sends the turn.
Capture takes the visible page then crops in bitmap space, because Electron's rect crop on guest webviews is empty on Windows and shifted on high-DPI.
Date and status labels were separators only. Clicking one now hides the
sessions under it, same as project and profile rows, and Collapse all
covers those buckets too.
Preserve the existing background-process cleanup after interrupting the targeted Desktop session. This salvages the narrow /stop behavior from the broader, conflicting PR #45030 on the current slash-command architecture.
Co-authored-by: AlvaroBiano <alvarobiano@users.noreply.github.com>
Show a Restart backend action that kills the SSH serve before the local child
so reconnect cannot reuse a stale lockfile, instead of dumping raw IPC JSON.
requestModelOptions now sends the owner profile on the RPC, forced reseeds call getGlobalModelInfo(profile), and picker/cache keys include the registry connection so a tile cannot fall back through the ambient socket.
Co-authored-by: fangliquanflq <fangliquan@qq.com>
Desktop sent /btw through the slash worker, which printed the answer after
process_command returned, so only the acknowledgement ever showed. Use the
TUI's prompt.btw RPC and persist btw.complete on the originating session.
Co-authored-by: SsSs <w-kwan@hotmail.com>
Co-authored-by: kokhlo <konstantin.khlopkov93@gmail.com>
Co-authored-by: xxxigm <tuancanhnguyen706@gmail.com>
visibleClarifyCard() bottomed out in queryVisible(), which only drops
[data-pane-hidden] and then returns the first remaining DOM match. That is
enough to tell a foreground tab from a background one, but a split layout
has two chat surfaces on screen at once: both cards survive the hidden-pane
filter, so the winner is decided by document order. The earlier zone owns
Enter and 1..N permanently and the other visible card can never receive its
own shortcut — worse than the mount-order behaviour it replaced, because
mount order at least changed as panes came and went.
Break the tie on the ladder the app already has instead of inventing a
second notion of which surface is "the" one: tree/store's tabTargetGroup
walks hovered zone, then focused zone, and every tab verb (Cmd+1..9,
Ctrl+Tab, the Cmd+W family) targets through it; composerTargetInHoveredZone
(#74447) mirrors it for the model hotkey. visibleClarifyCard now does the
same, matching each visible card's closest [data-tree-group] against those
rungs. The single-card path short-circuits before any store read, so the
common case costs nothing extra.
The final fallback stays document order rather than null on purpose. When
neither rung names a zone holding a card — pointer off every zone, nothing
interacted with yet — returning null would leave Enter doing nothing at all,
a strictly worse regression than answering the first visible card.
Regression coverage pins both halves: the resolver unit tests assert the
active zone selects either card (not just the later one), that hover
overrides focus, that a hovered zone with no card falls through to the
focused one, and that neither rung resolving still yields a card; the
ClarifyTool integration tests render two visible cards in two zones and
assert exactly one clarify.respond fires, carrying whichever zone is
focused. All four order-sensitive assertions fail against the previous
resolver.
A pending clarify card binds Enter / 1-9 / A-Z / arrows on `window`, and
inactive tabs stay MOUNTED, so every parked clarify keeps a live listener.
Nothing in the handler asked whether the card was on screen: the first
listener registered won, called preventDefault(), and the rest bailed on
defaultPrevented. Registration order is mount order, not visibility.
With two chats waiting on a question, answering the one in front of you
sent `clarify.respond` for a background session's request instead —
silently answering a question the user never saw and resuming that turn.
The invariant is already stated one file over, in composer-focus-keys.ts:
"a clarify card waiting in a background thread must not take the
foreground composer's letter keys". `clarifyCardOwnsKey` honours it via
queryVisible(); the card's own listener did not. That asymmetry is the
bug — the key-ownership resolver and the key handler disagreed about
which card is live.
Export that lookup as `visibleClarifyCard()` and have both sides use it,
so they cannot drift apart again. The card bails unless it IS the visible
card, which also leaves the keystroke unprevented for the composer when
the only pending card is hidden.
Scoped by visible-card identity rather than `usePaneVisible()`: split
zones each render their own active pane, so two cards can be visible at
once and a per-pane visible flag would not disambiguate them.
Salvaged from PR #97815 by @itsflownium, slimmed to the schema-free core:
- TodoStore gains a monotonic in-memory revision; the todo tool result
returns it so clients can reject stale updates
- tui_gateway emits a dedicated todo.updated full-snapshot event that
bypasses optional tool-progress display settings
- session resume/activate responses attach the authoritative todo
snapshot; renderer restores it with revision arbitration
- desktop store tracks per-session revisions and rejects regressions
The session_todo_state DB table from the original PR is intentionally
dropped: canonical todo tool results already persist in conversation
history, so resume paths derive the snapshot from the stored transcript
instead of a parallel store.
tool.start for a merge:true todo write used args.todos as a full replace. A one-item status patch became Tasks 1/1, or vanished if content was omitted, so the panel looked stuck at 0/5 until the final complete result. Apply merge by id on start, keep replace for the full result, and show the in-progress spinner on the expanded header too.