Every `Tip` carried its own `TooltipProvider`, and there are ~107 call
sites. Each is a subtree that re-renders when anything above it does, so
they dominated unrelated interactions: 52,784 TooltipProvider renders and
18.3s of component time in a single sash drag.
Radix's provider holds only refs and stable callbacks (no reactive state)
— hoisting one to the app root is what it is designed for. `Tooltip`
still reads delayDuration/disableHoverableContent from context, and the
per-Tip overrides are preserved.
`Tip` keeps a local provider as a FALLBACK, chosen by context: a
component rendered in isolation has no root provider and Radix throws
"`Tooltip` must be used within `TooltipProvider`". Without this, 20 unit
tests that render a single control fail. Inside the app the flag is
always true, so the common path is a bare Tooltip.
This is the shape the earlier lazy-mount attempt should have taken. That
one deferred the Radix subtree until hover, which moved
data-slot="tooltip-trigger" off the mounted DOM and broke 18 tests
encoding that contract. Hoisting keeps the contract intact — every one of
those tests passes unchanged.
Measured on the same drag:
TooltipProvider 52,784 renders / 18.3s -> gone from the table
Primitive.div 40.5s -> 13.4s
Popper 10.5s -> 2.7s
Tooltip 15.7s -> 4.4s
A CDP trace of one sash drag settled what the render counters could not.
I had assumed the remaining cost was layout/paint; it was not:
script 6770ms | style 1866ms | layout 71ms
Layout was never the problem. The top attributable callsite in our own
code was use-resize-observer.ts at 977ms.
Counting the callbacks named the mechanism exactly: 8,620 ResizeObserver
instances constructed, and during a 40-move drag 2,600 callbacks each
carrying exactly ONE entry — 65 separate callbacks per pointermove. Every
consumer owned a private observer, so N elements resizing under a common
ancestor meant N trips through the observer machinery instead of one
batched delivery. With five mounted tiles that is ~100 user bubbles, each
with its own observer, all woken by a width change.
One shared observer with a WeakMap of target -> handlers. Callers keep
their exact contract: a handler observing several elements is still
invoked once with all of its entries, and unobserve happens when the last
handler for an element goes away.
Verified by trace, before -> after:
use-resize-observer 977ms -> 42.5ms (-96%)
style recalc 1866ms -> 1145ms (-39%)
total script 6770ms -> 3929ms (-42%)
Callback count 2,600 -> 43: one delivery per frame instead of 65.
Adds the two probes that found it. diag-drag-trace.mjs takes a real
timeline trace and prints the style/layout/script split plus the top
script callsites — that split is what disproved the layout theory.
diag-ro-storm.mjs counts RO callbacks vs entries, which is what
distinguished 'a few expensive calls' from 'very many cheap ones'.
Two halves close the 'legacy pythonw gateways survive updates forever' gap:
1. hermes update now regenerates the installed Scheduled Task / Startup
launcher scripts (gateway.cmd + gateway.vbs) during the gateway resume
phase. They are persistence artifacts written once at install time;
updates never touched them, so pre-aa2ae36c3f installs kept launching
the gateway through pythonw.exe forever — every descendant spawn
flashed a conhost (#54220/#56747) and, since #70344, the console-less
gateway died at startup with RuntimeError: sys.stderr is None (#71671).
The task /TR points at a stable script path, so rewriting the files
retargets it with no schtasks call and no UAC. No-op for modern
installs; best-effort so a failed refresh never fails the update.
2. _resolve_detached_python() normalizes a legacy pythonw.exe interpreter
to its sibling console python.exe when it exists, so the update
pause/resume argv-replay path (and any other caller handed a legacy
command line) respawns on the current design instead of faithfully
resurrecting the old one. Keeps pythonw when no sibling exists — a
failed respawn is worse than a console-less gateway.
Electron's findInPage selection is per-webContents, not per-route: without
an explicit teardown, the highlight overlay (and a stale match counter)
from one chat survived navigating to another session or a settings page.
The FindBar now closes itself via effect cleanup keyed on the router
pathname — the first render never fires it, a route change tears down the
previous route's search, and unmount (the session/profile switch paths
that remount the global overlays) gets the same teardown. closeFindBar is
already idempotent, so a closed bar never re-enters the bridge, and a
navigation with the bar closed makes zero bridge calls.
Tests (find-bar.test.tsx 42 -> 48):
- navigation closes the bar, resets the store, and calls
stopFindInPage exactly once (highlight cleanup on session switch)
- navigation with the bar closed never reaches into the bridge
- keybind registration pins: view.findInPage is mod+f in the view
category; mod+f passes comboAllowedInInput (so ⌘F fires from the
composer instead of typing 'f'); the find-next/find-previous pair
ships unbound while view.toggleReview keeps mod+g; all three actions
have en + zh labels for the keybinds panel
The component tests now render inside a MemoryRouter (FindBar reads
useLocation), with a navigation harness that captures useNavigate.
PR #53891 (cherry-picked in the three preceding commits) landed the Electron
bridge, the store, the find bar overlay, and Cmd/Ctrl+F to open. It stopped
short of the rest of the accelerator set and had two lifecycle leaks. This
completes the surface to match the platform convention that Chrome, Safari,
VS Code, and Claude Desktop's own findInPage bundle all ship.
Accelerators now wired:
- Cmd/Ctrl+F open the find bar (view.findInPage, from the PR)
- Cmd/Ctrl+G find next (new)
- Cmd/Ctrl+Shift+G find previous (new)
- Enter / Shift+Enter step from the input (from the PR)
- Escape close + stopFindInPage('clearSelection') (from the PR)
Keyboard ownership (the substantive fix)
Cmd+G was already bound to `view.toggleReview` and Escape to
`composer.cancel`. The find bar's own capture-phase window listener cannot
win those keys by calling stopPropagation: the keybind dispatcher's listener
sits on the SAME window target in the SAME phase, and propagation control
does not suppress sibling listeners on one target. Left alone, Cmd+G would
step a match AND toggle the review pane, and Escape would dismiss the bar AND
abort a running turn.
So ownership is decided by the dispatcher, which AGENTS.md already names the
single owner of combo dispatch: `findBarClaimsCombo` is consulted in
use-keybinds before the registry lookup, and yields mod+g / mod+shift+g /
escape to the bar only while it is open. Closing the bar hands every one of
them straight back. That is the "keyboard ownership follows focus / one cancel
gesture does exactly one thing" invariant.
`view.findNext` / `view.findPrevious` are registered with EMPTY defaults on
purpose — shipping mod+g as a second default would flag a permanent conflict
in the keybinds panel against view.toggleReview. The entries document the pair
and let a user bind a dedicated chord; stepping is a no-op unless the bar is
open with a query, so a bound key can never search invisibly.
Listener-leak fixes
- The found-in-page bridge subscription is now refcounted in the store, so a
remount (the connection re-home path remounts the global overlays) cannot
stack duplicate subscribers that each re-dispatch the same result and
outlive their component. The subscription is deliberately mount-scoped, not
active-scoped: results for an in-flight search must still land if the bar
just closed.
- `setFindQuery` now refuses to search a closed bar. The component clears its
debounce on close, but a 200ms timer that already fired would re-issue a
find and re-highlight the page after the user pressed Escape. Caught by the
test, fixed in the store rather than papered over in the component.
- `closeFindBar` is idempotent — Escape is a shared gesture, so a second close
must not reach into Electron again.
Pure logic extracted for testing (no source regexing)
- `src/lib/find-in-page.ts`: `formatMatchLabel` (three distinct counter
states: hidden with no query, explicit 0/0, ordinal/count; clamps the
ordinal and never emits NaN — Electron legitimately reports ordinal 0 on a
non-final update), `findBarKeyAction` (the keybinding matcher, DOM-free),
and `findBarClaimsCombo` (the ownership predicate above).
Also: match counter and buttons get accessible names and the counter is
aria-live, the hardcoded English "Previous"/"Next"/"Close" tooltips move to
i18n (en + zh) alongside the new keybind labels, and the input gets an
aria-label so the bar is reachable by role.
Tests: apps/desktop/src/components/find-bar.test.tsx — 42 cases over the
pure helpers, the store (open/close, next/prev dispatch shape, escape clears,
refcount, double-release), and the component (focus on open, debounce
coalescing, Cmd+G from outside the input, unmount releases both the bridge
subscription and the window listener).
cd apps/desktop && npx vitest run src/components/find-bar.test.tsx \
electron/find-in-page.test.ts
-> 62 passed (42 new + 20 from the PR)
Adjacent suites (src/lib/keybinds, src/i18n, src/store): 487 passed.
`tsc -p tsconfig.electron.json --noEmit` clean; `tsc -p .` has 114 pre-existing
errors vs 120 on the merge base (all @assistant-ui / bippy / composer), none in
the touched files. eslint clean on every touched file.
Co-authored-by: David Metcalfe <DavidMetcalfe@users.noreply.github.com>
Vitest coverage for apps/desktop/electron/find-in-page.ts.
The helpers and the IPC handlers in main.ts are the only
consumers, so the tests pin the wire shape (match counter
shape, options defaults, no-throw-on-destroyed) and the
multi-window correctness that the original CJS PR missed:
- formatFoundInPage:
- Maps { activeMatchOrdinal, matches } → wire payload.
- Coerces missing fields to zero (the renderer never
sees NaN).
- Tolerates null / undefined inputs.
- performFind:
- Forwards query + options to webContents.findInPage.
- Defaults forward=true and findNext=false when omitted.
- Treats null / non-object options as "all defaults".
- Coerces a non-string query to string (defensive against
a misbehaving renderer).
- Is a no-op on null webContents.
- Is a no-op on destroyed webContents (does not throw
across the IPC boundary).
- stopFind:
- Calls stopFindInPage with the default action
('clearSelection').
- Honors an explicit action argument.
- Is a no-op on null or destroyed webContents.
- installFoundInPageForwarder:
- Forwards 'found-in-page' to the sender as a formatted
payload.
- Handles missing fields without throwing.
- Skips send when webContents is destroyed at fire time.
- Returned uninstall removes the listener.
- Returned uninstall on null/destroyed webContents is a
safe no-op.
- Regression: two forwarders installed on distinct
webContents do not cross-fire. This is the bug the
original PR shipped — the global mainWindow listener
routed results to the primary regardless of which
renderer invoked findInPage. Pinning this here keeps
the per-sender routing from regressing.
Run with:
cd apps/desktop && npx vitest run electron/find-in-page.test.ts --project electron
Brings forward the renderer-side changes from PR #53891,
adapted to the current main branch (where app-shell.tsx
has been replaced by apps/desktop/src/app/contrib/wiring.tsx
and keybinds/actions.ts has gained new view.* entries):
- apps/desktop/src/store/find-in-page.ts (new): nanostores
atom + actions for the find bar (openFindBar, closeFindBar,
setFindQuery, findNext, findPrevious, updateFindResults,
initFindInPageListener). openFindBar is dispatched by the
view.findInPage keybind handler in use-keybinds.
- apps/desktop/src/components/find-bar.tsx (new): the find
bar overlay (top-right, below the titlebar). Debounces
input 200ms before issuing findInPage, focuses on open,
supports Enter (next) / Shift+Enter (previous) / Escape
(close), shows a "3/12" match counter from the
'hermes:found-in-page' stream. Global capture-phase
Escape listener so the bar closes regardless of focus.
- apps/desktop/src/lib/keybinds/actions.ts: adds
view.findInPage with default combo 'mod+f'. The keybinds
runtime already routes any mod+ / ctrl+ combo through
editable-focus contexts (see comboAllowedInInput in
lib/keybinds/combo.ts:193), so ⌘F focuses the find bar
instead of typing 'f' into a textarea — matches browser
behavior.
- apps/desktop/src/app/hooks/use-keybinds.ts: wires
view.findInPage → openFindBar in the global handler map.
- apps/desktop/src/app/contrib/wiring.tsx: mounts <FindBar />
alongside the other global overlays (CommandPalette,
SessionSwitcher, etc.).
- apps/desktop/src/i18n/{en,zh}.ts: labels
'view.findInPage' for the keybinds panel.
Closes#46169
The original PR targeted the CJS Electron files
(apps/desktop/electron/main.cjs and preload.cjs), but commit
39d09453f "feat(desktop): ts-ify everything" renamed them to
main.ts and preload.ts on current main. The PR's diff therefore
targeted files that no longer exist on main.
Brings the bridge forward to the current TypeScript Electron
files and extracts the IPC bridge helpers into a focused
pure-helpers module:
- apps/desktop/electron/find-in-page.ts (new):
- performFind(webContents, query, options) — wraps
webContents.findInPage with default-coercing options.
- stopFind(webContents, action) — clears highlights.
- formatFoundInPage(result) — pure projection of
Electron's FoundInPageResult onto the wire payload shape
({ activeMatchOrdinal, count }).
- installFoundInPageForwarder(webContents) — wires a
sender-scoped 'found-in-page' forwarder; returns an
uninstall function. Returns a no-op uninstall for null
or destroyed webContents so callers don't need guards.
- apps/desktop/electron/main.ts:
- ipcMain.handle('hermes:find-in-page', event => ...)
resolves the requesting window via
BrowserWindow.fromWebContents(event.sender) and routes
the search to THAT window, not the global primary. This
fixes a multi-window bug where Cmd+F pressed in a
secondary session window (one per chat, spawned via
hermes🪟openSession) searched the primary window
instead of the focused surface.
- ipcMain.handle('hermes:stop-find-in-page', event => ...)
routes stopFind through the requesting window for
multi-window correctness.
- A per-sender lazy forwarder registry
(foundInPageForwarders: Map<webContentsId, () => void>)
installs installFoundInPageForwarder on first
findInPage call, scoped to the sender's webContents.
Cleans up automatically via webContents.once('destroyed',
...). The forwarder sends results back to the SAME
renderer that initiated the search, never the global
primary — so a secondary session window's Cmd+F shows
matches from THAT window and the match counter reports
matches from THAT window's DOM.
- apps/desktop/electron/preload.ts:
- hermesDesktop.findInPage(query, options) — invokes
the IPC handler.
- hermesDesktop.stopFindInPage() — invokes the IPC
handler.
- hermesDesktop.onFoundInPage(callback) — subscribes to
'hermes:found-in-page' results from the sender;
returns an unsubscribe function so the FindBar can
clean up on unmount.
- apps/desktop/src/global.d.ts:
- Three new hermesDesktop method declarations:
findInPage, stopFindInPage, onFoundInPage. The new
forwarder install registers a 'found-in-page' listener
bound to the sender's webContents and emits
'hermes:found-in-page' results back to the sender.
The multi-window fix is part of the same port — the old
PR's behavior (Cmd+F in a secondary session window searched
the global primary) was a bug present in the CJS files,
not a design constraint we wanted to preserve. The new
helper module uses event.sender by design, so the
multi-window correctness lands with the TS port.
Fixes#46169
Append a "⊙ goal 3/20" segment (turns used / turn budget) to the CLI
status bar whenever a standing /goal is active. Mirrors the desktop
composer goal indicator: active-goal-only — paused/done goals stay out
of the bar since they already print their own glyph lines in-thread.
- Snapshot: goal_active / goal_turns_used / goal_max_turns from the
cached GoalManager (in-memory attribute read, no DB hit per repaint).
- Rendered in all three width tiers of both _build_status_bar_text and
_get_status_bar_fragments, and it respects the /statusbar toggle for
free (the toggle gates _get_status_bar_fragments as a whole).
- Tests: segment composition, active-only contract, all width tiers.
Status-bar goal indicator concept from #43020.
Co-authored-by: Akshan Krithick <akshankrithick305@gmail.com>
Assisted-by: Claude Fable 5 via Hermes Agent
Follow-ups on the salvaged goal-status display (#63527):
- Seed the goal store from the /goal dispatch notice ("⊙ Goal set …") and
from /goal status|pause|resume|clear exec output in slash.ts. The backend
only emits status.update kind:"goal" after the first turn's post-turn
judge, so without this the indicator stayed empty while the kickoff turn
ran (sweeper review finding on #63527).
- Add the missing ja / zh-hant statusStack goal copy — desktop ships four
locales, not two (sweeper review finding on #55651).
- Add a component-level vitest for the composer goal indicator rendering
from store states: none / active / paused / detail line / other-session.
Co-authored-by: HaisamAbbas <95044189+HaisamAbbas@users.noreply.github.com>
Assisted-by: Claude Fable 5 via Hermes Agent
Maps CLAUDE.md/AGENTS.md, permission allowlists, MCP servers, skills, and memories into their Hermes equivalents. Follows the openclaw migration pattern. Inspired by ChatGPT Work import-from-another-agent onboarding.
Cross-surface coverage #23768 missed (per review feedback):
- tools/clarify_gateway.py: _ClarifyEntry carries a multi_select flag
(register() accepts it; signature() exposes it to adapters).
_coerce_text_response now parses multi-select replies — comma- or
space-separated numbers ('1,3' / '1 3'), exact labels, dedup — into a
JSON array string that _parse_multi_select_response decodes into a
list. Out-of-range/unknown tokens reject the reply (native button UI)
or fall back to custom text (awaiting_text/'Other' mode).
- gateway/run.py: _clarify_callback_sync accepts multi_select and
registers it on the pending entry.
- gateway/platforms/base.py: default numbered-list text fallback tells
the user multiple selections are allowed and how to reply.
- tui_gateway/server.py: clarify_callback passes multi_select through
the clarify.request payload as a hint; renderers without checkbox
support ignore the field and remain single-select-compatible.
- tests: 13 new gateway tests (flag storage, comma/space/single-number
parsing, label matching, out-of-range rejection, dedup, end-to-end
resolve, single-select regressions).
Follow-ups to the salvaged #23768 commit, which targeted a pre-79559214
codebase:
- agent/tool_executor.py + agent/agent_runtime_helpers.py: pass
multi_select at both current clarify dispatch points (the PR's
run_agent.py edits landed on dead code paths).
- tools/clarify_tool.py: replace the broad TypeError-retry in
_invoke_callback with inspect.signature detection, so a compatible
callback that raises TypeError internally is not invoked twice
(addresses hermes-sweeper review feedback on #23768).
- tests: cover single-invocation on internal TypeError, legacy 2-arg
callbacks, **kwargs callbacks, and registry handler multi_select
pass-through (schema arg → handler → callback).
hermes update's lazy-refresh pass re-asserts LAZY_DEPS pins whenever the
package is present (active_features() is presence-based). The
tool.trace_upload pin huggingface-hub==1.2.3 sat below transformers'
>=1.5.0,<2 requirement, so every update force-downgraded the shared
package and broke Hindsight local embeddings on daemon startup (#60783).
Keep the exact-pin security posture — no ranges — but move the pin to
1.24.0 (current) and bump uv.lock in lockstep (uv lock --upgrade-package
huggingface-hub: hub 1.4.1->1.24.0, hf-xet 1.3.1->1.5.2, click
8.3.1->8.4.2, drops typer-slim), so the entire tree converges on ONE hub
version. The refresh pass now reports 'current' with zero churn.
Invariant tests (not snapshots): the lazy pin must equal the uv.lock
resolved version, and must sit inside transformers' accepted window.
HfApi surface used by trace upload (whoami/create_repo/upload_file)
verified present with identical kwargs on 1.24.0 in a live venv.
Compile and checksum-pin SQLite 3.53.4 in the published image, preserve Hermes' required SQLite features, and assert the final Python linkage plus FTS5 trigram behavior during image builds.\n\nMake doctor remediation install-aware so Docker users pull and recreate every Hermes container instead of running the inapplicable git updater.\n\nFixes #70480
The managed uv is installed with UV_UNMANAGED_INSTALL, which disables
'uv self update' by design — the swallowed failure left its embedded
python-build-standalone catalog frozen at bootstrap age forever.
python-build-standalone re-releases existing patch versions with fixed
SQLite (3.11.15 was re-cut with 3.53.1), so a stale catalog resolves
the same version number to the OLD vulnerable build, the probe rejects
it, and the patch-retry loop cannot recover because the fixed build
carries no newer number to try. Result: 'hermes update' printed a
guaranteed-failure provisioning warning on every run (issue #72093).
- When provisioning fails, re-bootstrap the Hermes-managed uv binary
via the official installer (the only supported refresh for unmanaged
installs) and retry provisioning once — only when the binary version
actually changed, so no wasted download cycles.
- Never touch a caller-supplied uv outside the managed path.
- Soften the failure report from alarming ⚠ to informational ℹ and say
why it is safe to wait: the WAL gate keeps databases out of WAL on
vulnerable builds, and the next update retries.
Verified: 56 unit tests green; sabotage run (retry block removed) fails
the 3 new retry tests; live E2E replaced a fake managed uv via the real
astral installer and the refreshed binary resolved the 3.11 catalog.
Fixes#72093
A providers: entry with only a default_model/model (no explicit models:
list) is un-narrowed — the singular field is just the active selection.
Section 3 derived has_explicit_models from the merged models list, so
the lone default_model entry counted as an explicit catalog and
suppressed the /v1/models probe for no-key endpoints, leaving a
one-line /model picker menu for local llama.cpp/Ollama/vLLM servers.
Track explicit models: declarations separately at group-build time
(mirrors section 4's declaration-tracking from #40542 / PR #61928) and
gate the probe on that instead.
Salvaged from PR #68984 by @vigilancetech-com (the probe_custom_providers
gate removal in that PR is not taken — the GUI no-probe gate is
intentional).
faulthandler.enable() writes to sys.stderr by default, and raises
RuntimeError('sys.stderr is None') when the gateway is launched
without an attached console — e.g. via the Windows Startup VBS shim,
pythonw.exe, a detached service, or any parent that redirects stderr
to DEVNULL. Because this happens on the very first line of
GatewayRunner.start(), the whole gateway used to die at startup and
every configured platform adapter (Discord bot, Telegram, Slack, …)
would silently show offline until the user manually re-ran
'hermes gateway run --replace' from a real terminal.
Wrap the call and fall back to a log-file file descriptor
(logs/gateway_faulthandler.log) when stderr is unavailable, so
fatal-error stack dumps still land somewhere useful. If even the
fallback fails we log-and-continue rather than kill the gateway.
Repro traceback (from a real user's gateway-exit-diag.log, launched
via the Startup VBS with stdin_is_tty=false):
File "gateway/run.py", line 7821, in start
faulthandler.enable()
RuntimeError: sys.stderr is None
Completes #51690 on top of the salvaged #60378 timeout metadata:
- async_delegation: terminal 'stalled' events now carry structured
stall context (stalled_after_quiet_seconds, stall_threshold_seconds,
stall_phase idle|in_tool, stall_grace_seconds) on both single and
batch paths, persisted in the durable row so restart-restored events
keep it. Mirrors the sync path's timeout_seconds/timed_out_after_
seconds/timeout_phase from #60378.
- list_async_delegations(): exposes seconds_since_progress and live
children_activity (per-child api_calls, current_tool,
seconds_since_activity) sampled from the dispatch's progress_fn
outside the records lock; private monitor bookkeeping and callables
never leak.
- /agents (CLI + gateway): background delegations render per-child
activity rows, quiet-time hints, and the stalling state; gateway
section is new (previously async delegations were invisible there).
New locale key gateway.agents.background_delegations in all 17
catalogs.
Tests: stall-metadata event shape, live-listing projection, gateway
/agents rendering (real registry dispatch, sabotage-verified), sync
timeout metadata fields, non-timeout None contract.
Add timeout_seconds, timed_out_after_seconds, and timeout_phase to timeout
results so parent agents and users can distinguish timeouts before the
first LLM call from timeouts after one or more API calls.
Also attach diagnostic_path to the N>0 API-call timeout error message,
matching the existing zero-API-call timeout path.
Addresses part of #51690 and #17308.
Investigating the missing-spinner report turned up no defect in the seeding
path: the active_list poll already lights a row for a turn the renderer never
saw start, holds it across polls, and follows a recycled runtime id onto its
new stored session. Pin all three so the reap change can't silently regress
turn-start while fixing turn-end.
Two boundaries worth naming rather than rediscovering:
- `starting` is deliberately NOT working. It means agent_build_started without
agent_ready, and _start_agent_build runs on any incidental RPC that needs the
agent — not just a prompt — so treating it as a turn would spin the row on
merely opening a session.
- $workingSessionIds is keyed by STORED id and drops entries whose
storedSessionId is null, while message.start flips busy without carrying one.
A runtime that was never seeded with a stored id therefore goes busy
invisibly. That is the remaining path by which a spinner can go missing.
Follow-up to the salvaged #71756: instead of webhook importing cron's
private _is_cron_silence_response, the loose autonomous-lane matcher now
lives in gateway/response_filters.py as is_autonomous_silence_response,
sharing LIVE_GATEWAY_SILENT_MARKERS with the interactive exact-marker
rule so the marker sets can never drift. Cron and webhook both delegate
to it. Interactive gateway behavior unchanged.
A webhook route that answered `[SILENT]` still delivered, whenever the model
added a sentence saying why it was staying quiet:
[SILENT]
The new inbound was the same email quoted back a second time, on a ticket
we already answered. Nothing new to reply to, so I closed it.
Webhook subscription prompts tell the agent to answer `[SILENT]` on a tick that
produced no story — a duplicate inbound, a stand-down because a sibling lane
already replied, a routine close. Nobody is waiting on the other end of a
webhook, so a "nothing happened" message has no reader.
Delivery went through the live gateway's `is_intentional_silence_response`,
which requires the response to be EXACTLY a marker. That rule is right for an
interactive chat: swallowing a real answer because it opens with a marker is
much worse than showing a stray marker. It is the wrong trade for an autonomous
lane, where a leaked non-story is a pointless notification on every tick and
models reliably append the explanation that flips the check back to "deliver".
Cron already resolved this the other way — `cron/scheduler.py` treats a marker
on its own first or last line as silence — so the two autonomous lanes
disagreed while the interactive path was fine.
Suppress in `WebhookAdapter.send`, before the deliver-type switch, so every
route (log, github_comment, cross-platform) behaves the same. Reuses cron's
`_is_cron_silence_response` rather than restating the rule, so the two lanes
cannot drift; prose that merely mentions a marker mid-sentence still delivers.
The interactive gateway path is untouched.
Tests: six cases in tests/gateway/test_webhook_adapter.py — bare marker,
marker + trailing prose (the reported shape), marker on the last line, a real
report, a report quoting a marker mid-sentence, and a `log` route. Verified
red-first: with the suppression removed the three silence cases fail
("Expected send to not have been awaited") while the three delivery cases still
pass, so the tests assert the fix rather than the framework.
A run of 3+ adjacent tool calls collapses into the `.tool-group-scroll`
window, but `shouldBoundToolGroup` took `hasUnboundable` as a run-level
veto: a single exempt row anywhere in the range disabled collapsing for
the entire run. The exempt set was clarify, image_generate, execute_code,
read_file and every file-edit tool — which is most of what a coding
session does, so in practice runs never collapsed. Replaying a real
session's transcript: 84 consecutive calls, 30 of them veto-triggering,
zero windows.
The code-body entries were never needed. Everything ToolEntry renders
carries `data-tool-row`, and the `:has([data-tool-row][data-tool-open])`
rule already lifts the cap and the mask. A diff row mounts open, so it
frees the group the moment it appears; a collapsed row is a one-line
status whose body is not in the DOM at all, so there is nothing to clip.
Collapsing the row drops the group back to a compact window, which the
JS veto could not do.
Narrow the opt-out to the two components that bypass ToolEntry and so
can never emit `data-tool-row`: clarify and image_generate.
preserveReasoningParts was gated on exact text equality with the cached row.
Mid-turn the authoritative text has advanced by a delta or two, so the guard
fails and the row is rebuilt from the gateway's inflight projection — which
is text-only. The renderer's cache is the sole carrier of a running turn's
structure, so switching away and back stripped the reasoning and every tool
call, leaving the turn looking inert.
Carry tool calls alongside reasoning, dedupe them on toolCallId, and match on
same-turn (identical text, or authoritative text extending the cached text)
rather than strict equality. Attachment refs and image re-appending stay on
the strict path: those reconcile a settled row, and a growing row is by
definition not settled.
session.active_list is authoritative about absence, but the renderer only
read the rows it returned. A turn that ends while the websocket is degraded
— a remote gateway on a flaky link, a reconnect, a profile swap — drops out
of the gateway's _sessions without Desktop ever seeing the running=false
edge, so the row spins forever and the busy->idle transition that paints the
green unread dot never fires.
Track live runtime ids per gateway profile and settle anything that
disappears between polls through publishSessionState so the real transition
fires. Profile scoping is load-bearing: background profiles are served by
other gateways and never appear in this profile's snapshot, so an unscoped
reap would dark out every other profile's running rows.
Sessions that die before title generation (or predate model tracking)
rendered as 'Untitled · unknown · 0 msgs' — two placeholders stacked in
one row reads as breakage to Hermes Cloud users. Now:
- Session rows omit the model segment entirely when the store has no
model (no more 'unknown' + dangling separator).
- The Overview 'Recent Sessions' card falls back to the message preview
as the row label (italic, same treatment as the History list) instead
of a bare 'Untitled', and skips the duplicate preview paragraph when
the preview IS the label.
The production dashboard build packed almost every page plus xterm/three/
plot into one large JS chunk, which trips Vite's 500kB warning and slows
first paint even when the user only opens Sessions/Config.
- Lazy-load route pages in App.tsx behind Suspense
- Defer mounting the persistent embedded chat host (and xterm) until the
first /chat visit, while keeping the sticky PTY latch afterward
- Add rolldown vendor codeSplitting groups (react, xterm, three, plot,
motion, ui) and raise chunkSizeWarningLimit modestly to 600kB
Addresses #25912 (partial: route lazy-load + vendor splits + fallbacks;
not yet CI bundle analysis or documented entry budget).
Verified locally: npm run typecheck, npm run test (97), npm run build
with separate page/vendor chunks.
Stop offering deepseek-chat/reasoner in the static catalog and point
fallback/aux defaults at the permanent v4 IDs. Keep retired aliases in
a detection-only map so /model deepseek-chat still resolves to deepseek.
DeepSeek cut off deepseek-chat and deepseek-reasoner on 2026-07-24.
Sending those IDs now returns HTTP 400; rewrite them (and fuzzy
reasoner names) to deepseek-v4-flash so saved configs keep working.
A long-lived gateway can have platform routing (HERMES_SESSION_* /
HERMES_CRON_AUTO_DELIVER_*) mirrored in os.environ from a previous turn.
_default_spawn() copied that process environment verbatim into detached
kanban workers, so a worker calling kanban_create treated the inherited
chat/topic as its origin and auto-subscribed the child task — the task's
terminal notification then woke an unrelated chat.
Strip every registered session-context routing key from the worker env
unconditionally (the dispatcher is detached from every conversation);
board, workspace, task, branch, profile, model, and credential
propagation are unchanged.
Salvaged from PR #69181 (both commits squashed; the PR's second commit
fixed the first's engagement-latch assumption).
A gateway running under a named active profile (e.g. `hermes -p main gateway`)
stamps kanban auto-subscriptions with notifier_profile=main, but
_authorization_adapter() treated any name other than the literal "default"
as a multiplex secondary and consulted only _profile_adapters — empty on
standalone gateway-per-profile deployments. The helper failed closed, the
notifier rewound the claim, and the notification was silently retried
forever (#71340).
Recognize the gateway's own active profile name as primary so its stamped
subscriptions resolve via self.adapters; genuinely secondary profiles keep
the fail-closed lookup.
Salvaged from PR #62380 (the unrelated blocked-reason truncation change is
intentionally not taken).