Three review follow-ups on the salvaged #79286 commit:
- update_model() zeroed the in-memory prune runway but left the durable
model_config copy stale, breaking the method's own durable-sync
discipline (the strike reset three lines above keeps its durable copy
in sync). A restart after a model switch resurrected a runway
computed under the old model's trigger sizes. New
_clear_durable_proactive_prune_rearm() removes the persisted key via
patch_session_model_config() without touching the transcript.
- The archive_and_compact capability check ran AFTER the expensive
3-pass prune scan, so a duck-typed session store lacking the method
paid the full scan on every eligible iteration forever with pruning
permanently no-opping. Hoist it above the scan (all in-tree stores
pass a real SessionDB; this only affects third-party stores).
- _load_proactive_prune_rearm_tokens now uses the shared
get_session_model_config_value() accessor instead of inlining a 5th
copy of the model_config JSON parse, matching its sibling loaders'
typed-accessor pattern.
Also documents why the rotation-publish-failure branch restores only
the runway field rather than the full attempt snapshot.
Tests: model-switch durable clear, patch_session_model_config
merge/delete/no-op, and a guard proving incapable stores skip the scan.
archive_and_compact's new model_config_patch block was the third
near-identical SELECT -> tolerant-parse -> merge -> UPDATE copy in
hermes_state.py (update_session_runtime_lock and set_session_yolo carry
the other two). Extract _merge_model_config_json(conn, sid, patch,
on_missing=...) and route all three through it, preserving each
caller's missing-row policy (flag setters skip, archive raises).
Also adds the two small accessors the compressor needs:
- patch_session_model_config(): standalone atomic merge for callers
that must update model_config without rewriting the transcript
- get_session_model_config_value(): tolerant single-key read
Follow-up to the salvaged #79286 commit, per the repo's
extend-don't-duplicate rule.
Ships the hard A/B evaluation used for the August 2026 core-toolset
performance batch (#77056) as a reusable harness: 9 error-inducing trap
tasks derived from measured production waste classes, two-arm
PYTHONPATH-only comparison, ATOF-trace-based scoring, resume-safe
batteries.
Hardened from the original one-off: paths de-hardcoded (ABEVAL_ROOT /
ABEVAL_HOME), encoding= on all file IO, startup crashes retry on resume
instead of polluting cells, post-hoc grading fix for err_inline_script
baked in. Live-smoked end to end (baseline arm, qwen3-coder-30b,
err_multi_dir: exit 0, correct on-disk verification, resume record
written).
* fix(dashboard): add events-feed reconnect policy helpers
Extract the reconnect arithmetic and close-code classification for the
ChatSidebar /api/events socket into a pure module so both can be tested
without a fake WebSocket or a mounted component.
Two decisions live here rather than inline in the effect:
- `shouldRetryEventsClose` — 1000 (normal) and 4401/4403 (auth) are
terminal; everything else, including 1005/1006 from a killed gateway
or a dropped network, is retryable.
- `isEventsFeedMessage` — the sidebar's banner is shared with
`info.credential_warning` and the JSON-RPC sidecar, so a reconnect may
only clear a message the events feed wrote itself.
Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>
* fix(dashboard): auto-reconnect the events WebSocket with backoff
The chat sidebar's /api/events subscriber surfaced a static "disconnected"
banner on a transient drop and never retried, so a gateway restart or a
network blip left the feed dead until the user reloaded the page. The feed
drives the live chat title (session.info) and dashboard.new_session_requested,
both of which silently stopped working.
Reconnect with exponential backoff (1s → 2s → 4s → … → 30s cap, 15 attempts
then a terminal banner). Specifically:
- One scheduling path. `close` always follows `error` for a failed socket,
so scheduling from both — as the superseded PRs did — queues two timers
and leaks the one that is no longer tracked for cleanup. `scheduleReconnect`
returns early when a retry is already pending.
- The auth ticket is re-minted per attempt via `buildWsUrl`; tickets are
single-use with a short TTL, so replaying the first URL would 4401 on the
second attempt.
- A superseded socket's late close cannot schedule a retry on top of its
replacement (`isCurrent` generation check).
- A successful open resets the backoff and clears only the events feed's own
banner, leaving a credential warning or sidecar error visible.
- The pending timer is cleared on unmount, not merely neutered by the
`unmounting` flag.
Also retires two strings the tools box left behind when it was removed in
47fccc073 (#51737): the banner no longer promises "tool calls may not
appear" and the button reads "reconnect events feed".
Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>
* test(dashboard): cover the events-feed reconnect bug class
Fake-timer coverage for the behaviors the superseded PRs changed without
tests. Each case was mutation-checked — reverting the corresponding guard
in ChatSidebar.tsx makes exactly that test fail:
- transient close reconnects, and backoff grows 1s → 2s → 4s
- error + close on one socket schedules ONE retry, not two
- a successful open resets the backoff to 1s
- 4401/4403 and a normal 1000 close never retry
- the attempt cap stops the loop instead of retrying forever
- reconnect clears the feed's own banner but not a credential warning
- unmount clears the pending timer (asserted via `vi.getTimerCount()`,
since the `unmounting` flag alone hides a leaked timer)
Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>
* fix(dashboard): stop the events feed overwriting a foreign banner
Review catch: `clearEventsBanner` guarded the shared banner but `surface`
did not, so the guard was only half applied. A sidecar error or
`credential_warning` already on screen when the feed dropped was replaced
by "events feed disconnected" — and lost for good, since `error` is that
message's only home and the sidecar does not re-emit.
`surface` now writes only over an empty banner or one of the feed's own
messages. Declining to write does not affect the retry itself; the
reconnect still runs on schedule, it just stays silent while a more
important message holds the banner.
Both directions are covered: a foreign banner survives a drop, and the
reconnect still fires while suppressed.
---------
Co-authored-by: Ishan Parihar <ishan@supreme-god.dev>
Co-authored-by: Vyre <vyre@ishanparihar.com>
Co-authored-by: eric-senyao <178080753+eric-senyao@users.noreply.github.com>
* feat(update): emit an action-scoped terminal receipt from hermes update
The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
* fix(desktop): make remote backend updates terminal-state driven
Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.
A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.
Fixes#47359Fixes#58764
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
---------
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
* feat(agent): read_preview — the desktop-gated tool that reads the in-app browser
The agent could open the preview pane (open_preview) and read the embedded
terminal (read_terminal), but the browser it had just opened was a black box —
'what does this page say?' had no answer. read_preview mirrors read_terminal
end to end: HERMES_DESKTOP-gated via check_fn (zero schema footprint outside
the GUI), dispatched through the same agent callback pattern, windowed with
start/count so a long page pages instead of flooding context.
* feat(gateway): preview.read blocking bridge
Same lifecycle as terminal.read: the tool blocks on preview.read.request, the
renderer answers preview.read.respond (allow_expired — a slow page extraction
losing the 45s race must not surface a raw 4009), and a timeout emits
preview.read.expire so late answers resolve quietly.
* feat(desktop): the renderer serializes the active preview tab for the agent
preview-reader.ts is the preview analog of the terminal's buffer registry: the
URL pane registers a page reader (webview executeJavaScript → title + visible
innerText) keyed by tab id; readActivePreview resolves the ACTIVE tab, windows
the text (24k cap per read), and answers file/artifact tabs with identity plus
a note pointing at the tool that reads that content directly. The gateway
event handler answers preview.read.request beside terminal.read.request.
Remote backend updates failed with "Backend update failed." on nearly
every run: applyBackendUpdate() polled for only 30×1.5s ≈ 45s, then
read exit_code null off the still-running action and called it a
failure. Real updates (backup + uv sync + npm install + vite build)
routinely run longer, and the no-op "Already up to date" path never
restarted the gateway so the old return-check timed out too.
A still-running, reachable action is now never converted into failure
by an elapsed budget — only a nonzero exit is. The apply loop keeps one
in-flight promise, tolerates reconnects during the dashboard restart
without extending the fixed six-minute deadline forever, and confirms
success by the action-specific receipt that survives the restart,
falling back to proving the requested commit / up-to-date check for
older backends without action_id support. Inconclusive completion
fails closed.
Fixes#47359Fixes#58764
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: Mark Vlcek <markvlcek@gmail.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
The dashboard now mints an action_id per backend update, hands it to the
spawned `hermes update` via HERMES_ACTION_ID, and reuses an in-flight
update action instead of spawning a duplicate. The updater prints a
bounded `=== hermes-update completed <id> ===` receipt on every success
path — normal, zip, dependency-repair, and the no-op "Already up to
date!" path that previously ended with no terminal marker at all
(#58764) — so the Desktop can prove completion across the dashboard
restart boundary instead of guessing from stale log text.
Co-authored-by: Vitor Cepeda Lopes <vitor@vitorcepedalopes.com>
Co-authored-by: doncazper <caztronics@yahoo.com>
* feat(wake): client-capture wake word for remote desktop
Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.
- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)
* fix(wake): address review on client-capture re-arm and feed queue
- wake.status reports effective capture from the armed detector (client vs
local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works
* fix(wake): auto capture keeps the backend mic when one exists
With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.
Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.
* perf(desktop): coalesce wake.feed frames
Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the
ear is armed. Drain up to 4 queued frames into a single wake.feed payload
(backend feed() already splits long buffers into engine frames) — ~3 RPCs/s
steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not
0.5 s).
* docs(config): document wake_word.capture in cli-config.yaml.example
---------
Co-authored-by: Andrew <drew@kainotomic.com>
PDFs were classified as generic binary/text previews, rendering raw %PDF
bytes locally and failing entirely for remote-only files. Classify PDFs as
their own preview kind, load bytes through the existing local/remote
filesystem bridge, convert them to revocable Blob URLs for Chromium's
embedded viewer, migrate persisted pre-PDF tabs at restore, and retry
restored previews when the active filesystem connection changes.
Salvaged from #76008-era base onto current main: PDF classification now
composes with the remote-HTML enrichment branch, and the persisted-tab
migration runs before the One-Browser URL rekey in decodePreviewTabs.
Supersedes #76565.
Co-authored-by: Brooklyn Nicholson <brooklyn@brooklyn.sh>
The GUI now passes client_capture: true on wake.start, wake.status, and the
post-voice re-arm; update the store and slash-handler tests to the new
param shape. 123/123 pass locally.
* feat(desktop): shared pane-strip primitives — one bar, one glyph, one close menu
The zone header hand-rolled its tab bar, its close-verb context menu, and the
bare-glyph "+" inline; the preview rail kept a second copy of all three. Extract
PaneTabStrip (the bar), PaneStripGlyph/PaneStripTool (glyph buttons as data,
titlebar-tool style), and paneTabCloseItems (the four close verbs) into the
pane-tab primitives, and render the zone header through them. Panes contribute
strip glyphs via PaneChrome.stripTools; $stripToolsRevision tells the strip to
re-read.
* refactor(desktop): preview tabs are layout-tree tiles like session and page tiles
The in-app browser / preview rail carried its own tab strip beside the zone's
own — a second bar at a different height with its own close menu, label casing,
⌘W rung, and welded to the file browser's zone so ⌘J toggled it away. It
predated the layout tree.
$previewTabs now mirrors into pane contributions through the same paneMirror
session and route tiles use, so a preview tab IS a zone tab: one strip, drag/
stack/split, the shared close verbs, plain ⌘W, its own zone docked beside main.
URL tabs are titled Browser (the tab names the surface, not the page); files
keep their filename and a file-type lead glyph.
Deleted with the rail: the preview pane contribution + PREVIEW_PANE_ID + its
visibility binding, the 'preview' placements in the default tree and presets,
the ⌘W rail rung, the reveal listener, and the preview.close* i18n keys (copies
of zones.*). lone-header now keys on "closeable placement:main" instead of the
session-tile: id prefix, so any tile dragged into its own zone keeps its tab.
* fix(desktop): preview console/DevTools live on the strip, and DevTools tells the truth
The two toggles were titlebar tools — far from the preview they act on and one
ambiguous global pair once two previews were open. They're strip glyphs now,
contributed per-tab as PaneStripTool data with real tooltips: the console store
is cached by tab id so the glyph and the panel read the same logs, and the pane
registers a DevTools handle for its tab.
DevTools active state was also a lie: it tracked our click handler, so closing
the DevTools window itself left the glyph stuck on. The webview's
devtools-opened/closed events drive it now.
* fix(desktop): ⌘W and ⌃Tab work over preview and page zones
The generic tab verbs keyed zone eligibility on the CHAT strip (workspace /
session-tile: ids), so a zone holding only a Browser or page tile was invisible
to them: ⌃Tab skipped it, and ⌘W fell through the chat rung and emptied the
MAIN chat while you were looking at a preview. ⌘1…⌘9 already worked — the
verbs disagreed about what counts as a tab strip.
New isMainStripPane (any placement:'main' tenant — sessions, pages, previews)
drives ⌘W and ⌃Tab; isSessionStripPane keeps gating what it should: where a
session may dock (⌘T's anchor, the strip's +).
* fix(desktop): preview tab selection follows the tree, not just the reverse
openPreview drove tree reveals, but clicking a preview TAB only activated its
pane in the tree — $rightRailActiveTabId kept naming the previous tab, so
$previewTarget (⌘L quote labels, the titlebar's has-preview state) reported a
tab that wasn't on screen. The mirror now also listens tree→store: when the
interacted zone's active pane is a preview tile, the store selection follows.
Both directions converge on the same id, so no ping-pong.
* fix(desktop): session drags land in preview and page zones
tileZoneHost replaces chatZonePane: a zone hosting any main tile (a Browser
tile, a page) accepts stack and split drops — the known asymmetry where you
could drag a preview tab out but never drag a session in. Only a CHAT zone's
center is the link-to-composer drop; a preview zone's center stacks, since
there's no composer to link to.
* chore(desktop): drop the rail's dead multi-close verbs
closeActiveRightRailTab / closeOtherRightRailTabs / closeRightRailTabsToRight
lost their last callers when ⌘W and the close menu moved to the zone strip's
shared rungs; the tests now exercise closeRightRailTab's own fallback
behavior directly.
* fix(desktop): open_preview lands whenever its session is on screen
The preview.open handler honored the event only when its session was the
FOCUSED one — but the turn that runs open_preview is usually a tile's session,
and by the time the tool fires the user's last click has often parked focus on
main (or anywhere else). The tool reported success, the store never wrote, and
nothing appeared: an explicit 'open reddit' silently vanished.
On-screen is the right bar: honor the open when the session is the primary
chat or any open tile, which keeps truly invisible background sessions from
yanking the pane (offer, don't hijack) without eating opens the user asked
for.
* fix(desktop): one Browser — a second URL navigates it, not a second tab
Tabs were keyed url:<address>, so every distinct page the agent opened
stacked another BROWSER tab — three opens, three Browsers, each titled
identically because the tab deliberately names the surface, not the page.
The title already said singleton; the key disagreed.
URL targets now share one url:browser id: openPreview re-fronts the tab and
swaps its target, and the pane rebuilds its webview against the new address.
Files and artifacts keep per-identity tabs. Restored storage rekeys old
per-address rows and keeps only the most recent.
A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.
Supersedes #70296 and #57878.
Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
A plain click on a composer file row in remote mode handed the backend's
file:// URL to the local browser bridge, which cannot resolve a path that
only exists on the gateway host. Route remote non-HTML file targets to the
gateway-backed in-app preview pane instead; local files, ordinary URLs, and
remote HTML (staged locally by openPreviewTargetInBrowser) keep their
existing browser path.
Supersedes #70296 and #57878.
Co-authored-by: lesterlxt <153183032+lesterlxt@users.noreply.github.com>
Co-authored-by: cj52973 <cjenkins@scacpa.org>
Sending one 80 ms frame per RPC is ~12.5 gateway calls/s for as long as the
ear is armed. Drain up to 4 queued frames into a single wake.feed payload
(backend feed() already splits long buffers into engine frames) — ~3 RPCs/s
steady-state. Fix the wake.feed size-cap comment (64000 bytes = 2 s, not
0.5 s).
With capture:auto the desktop always preferred client streaming, so a local
desktop with a working backend mic silently switched from PortAudio to
getUserMedia default-device — dropping wake_word.input_device selection
(#74363). A ready backend input now wins under auto; client capture is the
fallback for a preferring surface on a mic-less backend, and capture:client
still forces streaming.
Also removes the dead auto branch (both arms returned local) and lets the
client-feed test skip cleanly when numpy is absent.
- wake.status reports effective capture from the armed detector (client vs
local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works
Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.
- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)
The original PR #78453 said 'A job that was mid-run during a restart
resumes according to the attempt policy described in this page.' This
is misleading — the existing docs explicitly state 'Unknown attempts
are audit records and are never automatically rerun.' Corrected to
accurately describe: the mid-run attempt is marked unknown (not retried),
but the job's next scheduled tick fires normally.
- cron: state explicitly that job definitions survive updates, gateway
restarts and reboots (asked directly in #37542)
- mcp: add a Claude Code bridge tip - mcpServers maps to mcp_servers and
hermes import-agent migrates it (the MCP page never says 'mcpServers'
in the client direction; arrivals from Claude Code get no pointer)
- installation: surface loginctl enable-linger in the non-sudo/service
user section where affected users start (currently only on the
gateway page; #43748)
- sessions: document optimize, optimize-storage, repair, recover and
retitle-skills in the CLI reference (shipped in v0.19.1 --help but
absent from the table) and recommend non-destructive optimize before
prune in the db-growth tip
All wording verified against hermes v0.19.1 --help output and the live
pages on 2026-08-04.
Users with Claude Pro/Max, ChatGPT/Codex, SuperGrok or Gemini plans
cannot find what their plan pays for in Hermes in one place (e.g.
#15291, #27228). One comparison table + per-provider notes; cells the
docs do not yet specify are marked 'not currently documented' rather
than guessed.
The four-file map is currently split across the memory, personality and
context-files pages; 'which file is my agent's brain' is one of the most
frequent support questions (e.g. #20245, #29476). One master table, the
frozen-snapshot rule surfaced with a link, and the two canonical mix-ups
answered directly. Content is drawn from the existing three pages.
The goals page never mentions Kanban and the kanban page references /goal
only inside the goal-mode-cards section, so users assume /goal hands work
to the board (see #26116 - /goal is single-session continuation only).
Adds a decision section to goals.md and the inverse note to kanban.md.
On Linux a freshly-built desktop app had no presence in the application
launcher: no Hermes in the KDE/GNOME menu, no icon, nothing to pin. Users
had to hand-write ~/.local/share/applications/hermes.desktop and remember
to reindex the menu caches themselves.
`hermes desktop` now writes that entry itself (best-effort, idempotent,
never blocking a launch), and `hermes uninstall --gui` removes it again.
Both fields that matter are absolute:
- Exec — the launcher runs with a minimal environment and no shell PATH
customizations, so a bare `hermes desktop` silently fails for anyone
whose hermes lives in ~/.local/bin or a venv. We resolve the real binary
via relaunch.resolve_hermes_bin(), falling back to an absolute
interpreter + `-m hermes_cli.main`.
- Icon — an unqualified name only resolves against an indexed icon theme,
which we are not in. The spec allows an absolute path, so we point at
apps/desktop/assets/icon.png in the checkout. No copy is installed: Exec
already depends on that same tree, so a second copy would add bytes and
an uninstall step without surviving anything Exec wouldn't.
Menu-cache refresh is tool-gated — update-desktop-database, then
kbuildsycoca6 or kbuildsycoca5 — each only when the binary is actually on
PATH, because most desktops ship none of them and a missing one is not an
error. The entry is only rewritten when its contents change, so a launch
doesn't churn the caches every run.
Verified on NixOS: the generated entry passes desktop-file-validate, a
real kbuildsycoca6 on PATH is invoked with --noincremental, a real
update-desktop-database writes mimeinfo.cache, absent tools are skipped
cleanly, and removal leaves the checkout's icon untouched.
Every CodingStatusRow mounted its own WorktreeDialog and subscribed to the
same global `$newWorktreeRequest` token, so a single ⌘⇧B with two composers on
screen opened two stacked dialogs — dismissing the front one revealed an
identical empty dialog behind it, which read as the dialog "staying open" after
creating a worktree.
Mount it exactly once in the sidebar (beside ProjectDialog) and drive it from a
`$worktreeDialog` atom, mirroring how the project dialog already works. One
mount cannot double-open. Every entry point (⌘⇧B, the rail's kebab, the
sidebar's + button) now publishes intent instead of rendering its own copy; the
rail and the button pin their own repo so a tile's kebab still targets that
tile's worktree.
The target is resolved at open time by `resolveWorktreeRepoPath`, which walks
the focused surface's cwd then the entered project's root, validating each
candidate against the repo-status probe cache — a project's root folder is not
necessarily a git repo, so existence alone isn't proof. That makes the resolver
the sole authority, so the hotkey no longer pre-gates on `$repoStatus` and now
works from a detached session that sits inside a project. When nothing in reach
is a repo it is a silent no-op: a worktree only exists inside a repo, so there
is nothing to report.
Also adds a project picker to the dialog so the repo can be retargeted before
naming the branch.
E2E: extends worktree-branch-status.spec.ts with a 10-branch repo, visual
snapshots of the base-branch picker and the convert-branch view, a geometry
assertion that the picker isn't clipped by the dialog (fails headlessly on
regression rather than waiting for a human to compare diff images), and a
two-composer test asserting one keypress opens exactly one dialog. Tests 1 and
4 fail against the previous code and pass now.
DialogContent published itself as the portal container for popovers opened
inside a dialog (so focus stays in the dialog and dismissal doesn't close it),
but that same element carried `overflow-y-auto`. Every Select/Popover/
DropdownMenu in a dialog was therefore born inside a scroll box and got
cropped at the dialog's edge — most visibly the worktree dialog's base-branch
combobox, where the branch list was cut off entirely and only the search field
showed.
Split the box in two: the shell keeps position/size/skin and no longer clips
(it stays the portal container), while a new inner body div owns layout and
scrolling. Popovers remain DOM descendants of the dialog, so focus and
dismissal behave exactly as before, but they can now paint past the dialog's
bounds. The banner variant had the same `overflow-hidden` on its shell; its
clip moves to the banner itself, which keeps the rounded bottom edge.
Callers that passed layout/scroll classes (grid, gap-*, p-*, overflow-*) now
pass them via the new `bodyClassName`; `className` keeps sizing and skin.
The artifact download URL returns a 302 redirect to a signed blob URL.
urllib sent the Authorization header to the blob, and the blob rejected it
with a 401 error. The download now has two hops. The first hop authenticates
to the API. The second hop follows the redirect without the auth header.
The query runs?event=workflow_call returns nothing for this repository.
GitHub flattens reusable-workflow jobs and their artifacts into the caller
run. The fetch now lists the artifacts on the orchestrator run only. The
dead sub-run enumeration is gone. Two API calls per cycle are gone with it.
The 'artifact statuses updated' reason never appeared. The code updated the
count before the comparison. Now the code compares first and updates after.
The code rejects zip members that contain '..' or start with '/'.
tests/ci/test_live_comment.py is deleted. This repository does not keep
tests for CI infrastructure.
The live comment poller got its review statuses from two sources. The first
was the REVIEW_STATUSES environment variable, fixed at the start of the
comment-live job. The second was one ci-timings artifact, downloaded at the
end of the run. Status details (error messages, action_required items)
appeared only after all jobs finished. The job pass/fail results were visible
as each job completed.
Now every status-producing workflow_call uploads a small review-status
artifact when it completes. The poller lists all review-status-* artifacts
from the orchestrator run and its workflow_call runs every cycle. It
downloads each artifact and merges the statuses into the comment. A status
appears as soon as its job finishes.
Changes:
- live_comment.py: _fetch_artifact_statuses became fetch_all_review_statuses.
The new function lists the artifacts via the API, downloads each one, and
parses it. Removed the review_statuses_json parameter, the
--review-statuses-file argument, and the subprocess import.
- ci.yml: removed the REVIEW_STATUSES environment variable, the inline Python
merger, and the --review-statuses-file argument. Renamed the
ci-timings-review-status artifact to review-status-ci-timings.
- Eight workflow_call files: added a step that writes review-status.json and
uploads it as an artifact after each review_status output.
- test_live_comment.py: added tests for _parse_status_file and
_merge_statuses.
The poller logs transitions between polls. It reports newly completed jobs
(with their results), newly appeared jobs, and jobs that left the pending
list. Each comment update shows the reason for the change. For example:
'1 new completion(s); artifact statuses updated'. When nothing changed, the
poller lists the jobs that are still pending. The status line shows the raw
job count from the API and the number of infra jobs that the filter removed.