Repository navigation
fix(chat): show Codex chats whose stored Windows paths are prefixed - #582
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configuration
📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 4 remain after this review. 📝 SummarySummary by CodeRabbit
WalkthroughCodex path handling now normalizes supported Windows verbatim paths during rollout resolution. Thread queries check both plain and verbatim working-directory forms. Tests cover path normalization, rollout boundaries, and transcript matching. ChangesWindows Codex path handling
Priority: ➖ Normal Estimated code review effort: 2 (Simple) | ~10 minutes Change: Bug fix · Severity of issue fixed: Medium Merge Risk: ⚪ Minimal · up to This change adds a changelog note about Windows Codex path handling. No merge-blocking risk was identified in the supplied change. Security Architecture ReviewSecurity architecture risk: 🔵 Low · up to Normal Windows path resolution retains its containment checks. However, an existing fallback becomes reachable for additional database rows and can read outside the session store. This requires control of those rows and specific pane conditions; direct disclosure or increased privileges were not established. Retained concerns
Security review detailsSecurity Blast Radius
Security Findings and Attack Paths
Trust Boundaries and Controls
Hardening Proposals
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
A \\?\Volume{...} or \\?\GLOBALROOT path without its prefix reads as a
relative path and resolves against the server's working directory, so it
keeps the prefix. The Windows-only traversal case now climbs with raw ..
segments to a file that exists, instead of a missing one.
# Conflicts: # CHANGELOG.md
devswha
left a comment
There was a problem hiding this comment.
Thanks @David-Sousa-Web, this is a real fix for the first two points of #518. I reviewed it, pushed two small commits on top (plus merges of main), and am merging it. #518 stays open for the codex resume picker.
What I checked
- The fix itself. Both lookups that failed now work:
codexRolloutPathstrips\\?\beforerealpathSync, and both cwd queries matchcwd IN (?, ?). The newcodex-processes.test.tscase fails againstmain'sserver/codex.ts(1 fail, 29 pass) and passes with the PR. - Containment. A stripped path still has to resolve inside
<CODEX_HOME>/sessions. Scratch tests confirmed that a prefix that leaves a relative path is still refused when the server's cwd is outside the store. - Cross-binding. An in-memory
threadstable matchedD:\work\apponly toD:\work\appand\\?\D:\work\app. A sibling (app2), a child, the parent, another drive,\\?\UNC\D:\...and POSIX/work/appwere never returned. UNC (\\host\share\app) matched only its own\\?\UNC\form. - Linux/macOS.
storedCwdsgives a POSIX cwd twice, unchanged, so the query returns the same rows.codexRolloutPathreturns the same results asmainon POSIX inputs, including file names that contain backslashes. Case and forward-slash variants (d:\work,D:/work) are never mapped to another directory. - Second opinion. I ran an adversarial review with Codex (gpt-6.1-sol, xhigh). It found no High issues and I verified each finding.
What I changed on the branch
20fbddastrips\\?\only from drive (X:\) andUNC\paths. Before this,\\?\Volume{GUID}\...or\\?\GLOBALROOT\...lost its prefix and became a relative path, whichrealpathSyncthen resolved against the server's working directory. Those forms now stay as they are, andcodex.test.tscovers them.20fbddaalso fixes the Windows-only traversal assertion.join()folded the..segments, so it pointed at a file that did not exist and passed throughENOENT. It now climbs with raw..segments toother.jsonl, which exists.4e3eac6adds the changelog link and credit, as([#582](...) by @David-Sousa-Web).
Left as follow-ups (not blocking)
- Spelling mismatch between the two cwds. If herdr's cwd and Codex's stored cwd differ in case or separator (
d:\workvs\\?\D:\work), neither candidate matches. After/new, the pane can then keep showing its older thread, but never one from another directory. Matching Windows directories case-insensitively would cover this, and it fits a separate PR together with the resume-picker point. - Pre-existing containment edge case.
path.win32.relativefolds case, so in a per-directory case-sensitive folder aSESSIONSdirectory next tosessionswould pass the store check. Plain paths already behaved this way before this PR.
What ran and what did not
- Ran.
HERDR_TEST_MODE=unit bun testoncodex.test.ts,codex-processes.test.ts,windows-native.test.tsandconversation.test.ts: 130 pass, 5 skip, 0 fail.bun run check fastpassed on4e3eac6(the branch merged withmainat90536be): 1717 pass, 6 skip, 0 fail. The same files plusscripts/plugin.test.tspass onb993574, which also merges #577. CI (Fast checks, Integration and browser, Native Windows install) is green onb993574. - Not run. The five
windows-native.test.tscases, including the new prefix case and my traversal fix, skip on Linux. I relied on your Windows run and on the Native Windows install job. I did not test with a live Windows Codex pane.
## Change Prepares v0.4.1 and remote bundle 21. - `package.json` and `herdr-plugin.toml`: 0.4.0 → 0.4.1. A patch release: fixes from the audit and the pre-release reviews (#566, #570, #588, #592), Windows chat discovery (#563, #582), the plugin's port handling (#577), GJC menus (#593) and more. The new pieces are small and opt-in or additive: the sidebar's Activity order and Quiet opened finishes (#529, both off by default), the `/effort` card (#594), the clipboard-from-a-pane switch (#570) and the Portal guide (#229). - `shared/machines.ts`: `REMOTE_BUNDLE_VERSION` 20 → 21. `server/` code that ships in the bundle changed since v0.4.0 (#566, #563, #572, #576, #582, #583, #588, #519, #570, #592, #593, #594), so remote PCs need a new bridge. - `CHANGELOG.md`: the Unreleased notes become `[0.4.1] - 2026-10-08`, with the compare links. #592 merged without entries; its three user-visible fixes are added under Fixed (forwarded-address token limit, https-only web push that never follows a redirect, Tab and Escape in Add PC over a file preview). - `release-summaries.json`: 0.4.1 in English, Korean, Japanese and Chinese: four new, seven improved and eight fixed lines (eight is the most a list keeps). ## After the merge 1. `git tag remote-v21 <merge commit> && git push origin remote-v21` → the remote-bundles workflow publishes the five bundles and `manifest.json` as release `remote-v21`. 2. Only then `gh workflow run release.yml --ref main -f version=0.4.1`: preflight validates version, notes and summaries, CI runs on the exact commit, and the tag `v0.4.1` with its GitHub release is created. ## Validation - `bun run check fast` (workflow syntax, generated types, typecheck, build, unit tests). - `bun scripts/release-notes.ts 0.4.1` prints the notes (version sources agree, one heading, summaries in all four languages).
…e notes (#598) Two changes the owner asked for, one commit each. ## 1. Clipboard from a pane (OSC 52) is on by default #570 turned **Settings → Terminal → Clipboard from a pane** off by default, so copying from vim, tmux and Claude Code silently did nothing. It is on again; the switch stays for anyone running output they do not trust. **Migration: a new storage key.** Settings are saved as one whole record, so any 0.4.1 install whose user changed *any* setting has `terminalOsc52: false` stored without choosing it. The choice now lives under `paneClipboard` (default `true`) and `terminalOsc52` is ignored and dropped by `sanitizeSettings`, which already discards unknown keys. That is the simplest correct option: - a 0.4.1 record loads as on (its `false` cannot be told apart from a real choice, so it is not trusted); - an off chosen from now on is written under `paneClipboard` and stays off across saves and reloads; - no version field or one-off migration state is needed. A settings version would do the same with more code. Known edge: a tab still running the 0.4.1 bundle that saves settings writes its own record, which has no `paneClipboard`, so a new "off" would read as on again until it is re-chosen. Tabs pick up the new bundle on reload, and settings already have no cross-tab sync (each tab saves its in-memory record), so this is no worse than any other setting. Tests (`src/lib/settings.test.ts`, "clipboard from a pane"): fresh default on; a 0.4.1 record with `terminalOsc52: false` loads as on; an off chosen after the change survives a save, a reload and an unrelated change. A mutation that falls back to the old key fails the second case. Also: the setting's description and code comments say what is true now, ko/ja/zh entries are updated, and there is a CHANGELOG line under Unreleased → Changed. No doc in `docs/`, `DESIGN.md`, `INSTALL.md` or `README.md` mentions the default. ## 2. Patch-note style GitHub release notes `scripts/release-notes.ts` printed the whole CHANGELOG section (181 lines for v0.4.1). It now prints the English lists from `release-summaries.json` (`### New features`, `### Improvements`, `### Bug fixes`, a list with no lines left out), then the full section inside `<details><summary>Full changelog</summary>` with blank lines around it so GitHub renders the markdown. Lines come through `readSummary`, so they are trimmed and capped exactly as an install shows them. Every validation is unchanged (version format, the three version sources, exactly one heading, nonempty notes, all four languages). `scripts/release-notes.test.ts` pins the shape, the order and an empty list left out. `docs/development.md` → Releasing and `scripts/AGENTS.md` describe the new body. The published v0.4.1 release is not touched here; the lead regenerates it after the merge. ### v0.4.1 rendered with this script (`bun scripts/release-notes.ts 0.4.1`) ### New features - Typing /effort in a Claude Code chat opens a card to pick the effort for this session - Settings → Appearance can keep waiting and the latest agents on top of the Agents list - A finished agent you opened in the web UI can lose its dot, as it would in herdr (opt-in) - The guide shows how to give the app a public HTTPS address with Portal ### Improvements - A wrong access token waits longer after five tries, up to a minute - A program in a pane copies to your clipboard only once you allow it in Settings → Terminal - Dialogs keep Tab inside them, and screen readers announce tabs, panes and status lines - Add PC, Reconnect PC and Update remote bridge open as a bottom sheet on a phone - Updating a remote PC that is already current reuses its bridge without a new download - Alerts go only to https push services and never follow a redirect - Long drafts wrap, and the scrollbar no longer covers text when the page is zoomed ### Bug fixes - A long line of unclosed brackets or broken escape codes no longer freezes the chat - On Windows, more Claude Code and Codex panes show their chat instead of an error - Your own devices are recognised by their Tailscale login again, without pairing - A Claude Code chat shows what a slash command answered, and /goal is suggested - Settings, the palette, Add PC and file previews stack in order; Escape closes the top one - The plugin's start keeps the app on its port while herdr is away - A secret or typed text no longer reaches a pane you have already left - Gajae Code's selection and startup menus show their choices, not only arrow keys <details><summary>Full changelog</summary> ### Added - `/effort` sent from a Claude Code chat opens a card with the effort levels, low to max, so the level no longer has to be set in the terminal. A pick applies to this session only, as a pick from the `/model` card does, and leaves the default for new sessions alone. `/effort` is also in the chat's command list now. ([#594](#594), [#523](#523) by @suho-han) - Settings → Terminal has a **Clipboard from a pane** switch, off by default: a program running in a pane can no longer put text on your clipboard unless you turn it on. Programs that copy this way (vim, tmux, Claude Code) copy again once it is on. ([#570](#570) by @radicor) - The guide's **Behind a reverse proxy** shows how to give the app a public HTTPS address with [Portal](https://github.com/gosuda/portal-tunnel) v2.6.1 or later, behind a long random token and with a visitor's `Tailscale-User-Login` header dropped. ([#229](#229) by @rabbitson87) - **Settings → Appearance → Agents order → Activity** keeps a waiting agent on top of the Agents list and orders the rest by their latest state change, as herdr's agents panel keeps the latest work in view: the agent you just sent a message to stays on top while it runs and after it finishes, and a new one starts there. herdr's own order and the workspace rows are unchanged. **Workspaces** (herdr's order) remains the default. ([#529](#529) by @phirschybar) - **Settings → Appearance → Quiet opened finishes** (off by default): a finished agent you have opened in the web UI loses its dot and reads as ready, as viewing it in herdr would make it. herdr's DONE otherwise stands until herdr itself shows the pane. It is remembered per PC in this browser. ([#529](#529) by @phirschybar) ### Changed - A wrong access token is refused with a growing wait after five tries, up to a minute, whether it is typed into the sign-in form or sent with a request. Each visitor behind `tailscale serve` has their own count, and a browser holding an old token never stops you signing in with the new one. ([#570](#570) by @radicor) - The app now sends itself a Content-Security-Policy, so third-party content rendered in a chat — math, agent marks — cannot run script in the app. ([#570](#570) by @radicor) - A web-push subscription must be an https endpoint. ([#570](#570) by @radicor) ### Fixed - Gajae Code's selection menus show their choices instead of only arrow-key buttons, including startup selectors shown before the pane reports that it is waiting. Answers move to the selected row and recheck the menu before confirming. ([#593](#593)) - Secret input and the Codex follow-up fallback validate the live screen, so a password prompt or collapsed question queue in scrollback cannot send input into the current program. ([#566](#566)) - The PC's Tailscale login is read from a real Tailscale user id. These ids are too large for a JavaScript number, so the owner was not recognised and the owner's own devices had to pair. Two logins with neighbouring ids are also no longer taken for one. ([#572](#572)) - On Windows, a Claude Code pane started with a second account's `~/.claude-*` directory as its `CLAUDE_CONFIG_DIR` shows its chat. Before, the pane fell back to `~/.claude`, so the chat said **Conversation unavailable** and only the terminal worked. Windows does not let the server read another process's environment, so the store is the one among the server's own `CLAUDE_CONFIG_DIR`, `~/.claude` and the `~/.claude-*` directories beside it that holds the Claude process's own record, checked against the time the process started. A directory elsewhere is not looked in. Claude Code processes reported as `claude.exe` are recognized too. ([#563](#563) by @David-Sousa-Web) - A long line of brackets, `\(` or underscores that never close, as an agent prints in a log or a minified file, no longer freezes the chat: a megabyte of them took a minute or more to read, and now takes milliseconds. What every message shows is unchanged. ([#574](#574)) - On a mirrored pane (Windows, where herdr cannot attach a terminal), text typed while a message was still being sent no longer reaches the pane once you have left it, or left it and opened it again, before the text's turn came. ([#576](#576)) - The plugin's `start` keeps the app on its port when the app's own server holds it but cannot reach herdr. It used to move the app to another port beside the running one and blame another program; now it says that the app runs there without herdr and exits, and the app answers again on its port once herdr is back. ([#577](#577)) - On Windows, a Codex pane shows its chat when Codex stored its paths with the `\\?\` prefix, as it does for a canonical Windows path (`\\?\D:\work` for `D:\work`). Before, the chat said **Conversation unavailable**: the session's file seemed to lie outside Codex's store, and none of the threads matched the pane's directory as herdr reports it. ([#582](#582) by @David-Sousa-Web) - In a Claude Code pane, the chat shows what a slash command answered, so a `/goal` that Claude Code refuses says why in the chat instead of only in the terminal. `/goal` is also among the commands the message box suggests. ([#583](#583)) - Settings opened over a file preview is visible above it; Escape and Back close Settings first, preserving the preview and its history entry until the file itself is closed. ([#568](#568)) - Command palette buttons keep their native Enter action; IME commit and cancel keys stay with text input, and arrow navigation keeps the selected result visible. ([#567](#567)) - Long drafts in the message box wrap and keep a narrow scroll cue in reserved space, so the scrollbar no longer covers text at fractional zoom. ([#522](#522) by @suho-han) - A secret sent from a pane you then left and opened again, while another browser kept the pane open, is no longer typed into the pane: you send it again from the pane you opened. ([#588](#588)) - A Claude Code slash command whose answer holds a long run of broken terminal escape codes no longer stalls the server while the chat reads it. Its answer also drops a link cut off before its end and a stray `B` after a charset switch, and an answer with both output and errors shows both. ([#588](#588)) - The command palette opened over Settings and a file preview shows above both, instead of taking the keyboard unseen beneath them. ([#588](#588)) - The plugin's `start` no longer takes another program on the app's port for the app because its answer says `ok`: it treats it as any other program there, moving the app to a free port, or saying the port is taken when `PORT` is set. ([#588](#588)) - A 500 from the server no longer repeats the system's own error text, which carried absolute paths and the herdr socket location; it names a short id you can quote in a bug report instead. ([#570](#570) by @radicor) - Settings, the command palette, the file viewer, the file browser and the new-workspace dialog keep Tab inside them and give the focus back to whatever opened them. ([#570](#570) by @radicor) - The tabs of a workspace name the pane region they govern, so a screen reader announces the tab and the pane together. ([#570](#570) by @radicor) - Agent headings in a chat no longer pose as the app's own page structure; they sit below the app's own headings and look the same as before. ([#570](#570) by @radicor) - The "reconnecting" line and the composer's terminal-only hint are announced when they appear. ([#570](#570) by @radicor) - A chat locked out by the token gate, the "Last checked" line under Settings → About (with its date in your language) and a remote PC's state word in the sidebar are translated like the rest of the UI. ([#570](#570) by @radicor) - The alerts menu item now says the same thing the same way in every state. ([#570](#570) by @radicor) - Held terminal input typed while disconnected is forgotten after a day. ([#570](#570) by @radicor) - A link printed in the terminal opens only if it is an http(s) address, on both link paths. ([#570](#570) by @radicor) - A second tab open on one pane no longer sends a message the first tab is already sending: it sees that send on its way and holds back. Two tabs that press Send at nearly the same moment can still both send it. ([#570](#570) by @radicor) - A row's ⋯ menu is capped to the room its button leaves and scrolls instead of being cut off by the viewport, so the pane picker of a tab with many panes keeps every entry reachable with the pointer as well as the keyboard. Before, items below the fold were rendered but unreachable. ([#570](#570) by @radicor) - A row menu open while the window crosses the 640 px breakpoint now switches between bottom sheet and popover instead of keeping the form it opened with. ([#570](#570) by @radicor) - The workspace drawer a narrow window opened is closed again when the window is widened past 768 px, so narrowing it no longer brings back a drawer and its scrim unasked. ([#570](#570) by @radicor) - **Add PC**, **Reconnect PC** and **Update remote bridge** open as a bottom sheet on a phone, like every other dialog, and keep clear of the on-screen keyboard. Before, the one native dialog stayed a centred card on a phone. ([#570](#570) by @radicor) - **Remove PC**'s first click is a quiet ghost button that only arms the removal; the second is the red one, as revoking a device already was. ([#570](#570) by @radicor) - A PC's rename, connect and disconnect buttons disable while their request is in flight, so a double click no longer sends two overlapping requests. ([#570](#570) by @radicor) - A failed pane or workspace rename keeps the field open with what you typed, so a network blip no longer makes you write the name again. ([#570](#570) by @radicor) - A workspace reorder that fails no longer undoes a later, successful reorder. ([#570](#570) by @radicor) - A tab watching several busy panes gives up its oldest cached conversation answers when they grow past a byte budget, not only past sixteen of them. ([#570](#570) by @radicor) - The usage meters' note is the same size as every other advisory and empty state. ([#570](#570) by @radicor) - The PDF viewer's page colour, the pill radii, the tab dot and the pairing-code size come from design tokens now, and the pairing code follows the compact density setting. ([#570](#570) by @radicor) - Updating an already current remote bridge verifies and reuses it without downloading or restarting it again, while a bridge from a newer app is left running instead of downgraded. A PC that waits on such a conflict says so in the sidebar and under the header, with a **Reconnect** button, instead of asking for setup approval. ([#519](#519) by @suho-han) - A wrong access token sent through a proxy on this PC with a made-up `X-Forwarded-For` address that itself says " via " now counts against the limit every visitor through that proxy shares, like any other wrong token. Before, each such try started a fresh count. ([#592](#592)) - A web-push subscription must be an https address, with no exception for this PC, and an alert is never sent on where a push service redirects it, so an alert can never be posted to a service running on this PC. ([#592](#592)) - In the **Add PC** dialog opened over a file preview, Tab moves through the dialog's own controls and Escape closes the dialog alone, leaving the preview beneath it open. ([#592](#592)) </details> ## Checks - `bun run check fast`: ok (1817 pass, 6 platform skips, 0 fail). - No browser regression covers OSC 52 (`terminal-copy-regression.ts` covers drag and Ctrl+C copy), so none was run for it. ## Codex review (gpt-6-astra, read-only) - **Fixed** (P2): summary text was put into the markdown as-is, so `"…\n\n<!--"` could hide the whole changelog fold. The gate now refuses a summary line with a line break in any language, and the English lines escape `<`. A test covers both and fails without the fix. - **Declined, documented** (P2): a tab still on the 0.4.1 bundle that saves settings drops `paneClipboard`, so a new "off" reads as on until it is chosen again. A versioned storage key would stop that, but then every setting changed in such a tab would be lost instead. It lasts only until that tab reloads, and every setting added before this one had the same gap. - **Pre-existing, not in this diff** (P2): settings have no cross-tab sync, so a stale tab's next save writes its own copy of every setting, this one included. Reported to the lead as a follow-up.
…e stores (#714) ## What was reproduced No Windows PC here, so every case was reproduced with Win32 path semantics (`path.win32`) and an in-memory SQLite on Linux: - **#587 case 1 (prefixed `CODEX_HOME`)**: `path.win32.relative("\\?\D:\codex\sessions", "D:\codex\sessions\2026\x.jsonl")` returns the absolute file path, so the containment check in `codexRolloutPath` rejected a rollout inside the store whenever only one side kept `\\?\`. The reverse (plain root, prefixed file) fails the same way. - **#587 case 2 (case-different cwd)**: `cwd IN ('D:\work\app', '\\?\D:\work\app')` returns no row for a stored `\\?\d:\work\app`. - **#586**: with records of the same live process in `~/.claude` and `~/.claude-copy`, `windowsProcessStore` returned `null`, the same as "no record", and `claudeTranscriptPath` fell back to `~/.claude`. ## Already fixed on main (#518) - Point 1 (rollout paths with `\\?\`) and the prefix spelling of point 2 (`cwd IN (?, ?)` with both spellings) were fixed in c04c760 (#582, released in v0.4.1). Point 3 (picker resume) is #717. ## What changed - `server/codex.ts`: - `rolloutInsideStore(root, file, paths)` strips `\\?\` on **both** sides before `relative()`. - A Windows cwd is one directory under the `\\?\` prefix and either drive-letter case, and is otherwise compared exactly. - `storedCwdCondition(cwd)` gives the three thread queries a binary `cwd IN (…)` over those spellings, so the queries stay on Codex's `(archived, cwd, …)` index. - `sameDirectory` / `codexPeersIn` apply the same rule to the peer checks (`claimedByOtherPanes`, `resumedElsewhere`, the short-thread guard), so the peer checks and the queries agree. - POSIX cwds are exact as before. - `server/claude-store.ts`: - `windowsProcessStore` throws `AmbiguousClaudeStore` when more than one store holds the process's live PID record. Like a miss, it is not cached; "no record" still returns `null` and keeps the default-store fallback. - Stores are told apart by `claudeStoreKey`: volume + file id where there is one, else the path without `\\?\` and without case. So `CLAUDE_CONFIG_DIR=\\?\C:\…\.claude`, a junction or a link to `~/.claude` is one store, not an ambiguous pair. - `server/conversation.ts` (one hunk): `AmbiguousClaudeStore` becomes `ConversationUnavailable("no_session_id")` instead of `~/.claude`. ### Scope narrowed after review A cwd stored in another letter case **past the drive letter** (`\\?\d:\Work\app` for `D:\work\app`) is deliberately **not** matched: the chat stays unavailable. - A Windows directory can be case-sensitive (`app` and `App` are then two directories), and only the file system can tell. - The identity-based version took a stat per spelling, a discovery scan with a limit and a cache. Two adversarial review passes showed each of those could fail open: another directory's or another pane's conversation. - Fail-closed was chosen instead. #587 therefore stays open for that case. ## How it was verified - Unit cases, each failing before its fix: - `server/codex.test.ts`: a prefixed root or rollout counts as inside the store, with traversal, another drive and UNC cases. - `server/codex.test.ts`: the drive-letter and prefix spellings are found; a case-sensitive sibling with 33 newer threads neither joins nor crowds out the 32; POSIX case stays exact; the query plan is a SEARCH on `(archived=? AND cwd=?)`; peers follow the same rule. - `server/claude-store.test.ts`: two stores that both claim the process reject with `AmbiguousClaudeStore`; a `CLAUDE_CONFIG_DIR` link to `~/.claude` is one store; `claudeStoreKey` drops `\\?\` and case without a file id. - `bun run typecheck`, `HERDR_TEST_MODE=unit bun test ./server/codex.test.ts ./server/claude-store.test.ts ./server/codex-processes.test.ts ./server/conversation.test.ts ./server/windows-native.test.ts`, `bun run check fast` (all ok). - Not run on a Windows PC. ## Issues Closes #586 Refs #587: case 1 and the drive-letter part of case 2 are fixed; a cwd differing in case past the drive letter stays unavailable by design (see above). Refs #518: points 1–2 fixed in #582, point 3 in #717. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ci): gate deployment on source-bound verification (#692)
## Change
- Require reusable CI and the site build to succeed for the same SHA
before Pages publishes the website, demo, or installers. Pin checkouts,
keep deployment serialization, and fail closed on stale-main/API errors,
including manual runs.
- Strengthen existing `bun run check`: durable source-bound JSON and
command logs, source/dist invalidation, interrupted/not-run/skipped
states, shared lock for `run`, and `run --build` for browser
prerequisites.
- Retain reports on success and failure. Capture isolated failure
screenshots, bounded console errors, and optional traces without
DOM/network capture in the main UI and prompt-dock regressions.
- Typecheck six critical tooling/demo entrypoints without relaxing
strictness. Document parallel ownership, genuine-dependency-only stacks,
squash/restack validation and explicit authority.
## Validation
- `bun run check fast`: passed after lifecycle fixes; 2242 tests passed,
7 skipped, 0 failed (before the final fail-closed correction, which has
focused coverage below). Includes workflow syntax, generated types, both
typechecks and app build.
- Final focused check/report/browser-evidence/Pages/browser-runner
tests: 39 passed, 233 assertions; both typechecks passed.
- Actual CLI lifecycle smoke: success/failure logs, build provenance,
source/dist mutation invalidation, competing-run lock refusal, SIGTERM
report and subsequent lock release passed in a disposable fixture
repository with a fake herdr executable.
- Actual Playwright synthetic failure: PNG, console metadata, private
artifact modes and trace without DOM/network capture captured from an
isolated fixture.
- Independent policy, deployment/evidence and report reviews completed.
Fixed interrupt cleanup ordering, inherited-output draining,
shared-report overwrite, unavailable source/build identities, and
bounded trace teardown. Actual CLI smoke also covered stubborn
descendants, shared-report lock refusal and source-identity loss.
Required GitHub CI remains authoritative for final SHA f44e9cc.
## Repository settings and remaining credential boundary
- Added `Native Windows install` to required main checks; retained
strict checks, admin enforcement, conversation resolution and optional
approval count. Latest 30 completed CI runs had 27 Windows successes,
one cancellation, and two fork runs awaiting approval without jobs.
- This PR does not provision an agent account or GitHub App credential.
Non-admin implementation credentials require separate operator
provisioning; no claim that current administrator credentials have been
replaced.
## Coordination record
Owner: coordinating maintainer session. Scope: verification/deployment
tooling and operating documentation. Start SHA:
b87d9d50019cdb87422415f7650ad29285a832cc. Branch/worktree:
worktree/rapid-stone-f62b. Dependencies: existing ci.yml/check
entrypoints only; no stacked PR dependency. Integration and release are
explicitly authorized by the user, subject to required checks and
unresolved-review blocking.
* chore(release): prepare 0.4.4 (#694)
## Change
Prepare app release 0.4.4 from main after #692, using the existing
release process only.
- Bump package.json and herdr-plugin.toml together.
- Move the existing Unreleased entries under 0.4.4 (2026-10-09 UTC),
preserving all PR links and external contributor credits.
- Add matching English, Korean, Japanese and Chinese release highlights
(3 new, 1 improved, 4 fixed; every line <=90 characters).
- No tags are created by this PR. The existing Release workflow must
validate the exact main SHA before publishing.
## Validation and review
- `bun scripts/release-notes.ts 0.4.4`: passed, generated expected
release body.
- Release-notes tests: 27 passed, 397 assertions, 0 failures.
- `bun install --frozen-lockfile`: passed without dependency changes.
- Reviewed the release metadata diff; all prior summary entries are
unchanged. The only non-metadata change is the one-line recorder
synchronization fix documented below.
- Audited release-range PR authors and existing changelog credits;
implementation PR #692 is linked.
- Required CI must pass on this PR before squash merge; no administrator
bypass.
Owner: release coordinator (explicitly authorized by user). Start SHA:
ceae7a259fa6f0d16ece3e56abb2178e752541dd. Scope: four release metadata
files and one release-blocking contract-test synchronization line.
Branch: chore/release-0.4.4. Dependencies: merged #692; no open stack.
## Observed release blocker
Website run
[37973801948](https://github.com/devswha/herdr-web-ui/actions/runs/37973801948)
correctly skipped deployment when the existing pending-input contract
test read `barrier` before the recorder observed its final Enter. A
WebSocket receipt is not a receipt from the separate recorder process.
Commit fe09359 adds the existing bounded `waitBytes("barrier\r")` before
the exact-byte and no-enqueue assertions, matching adjacent tests. No
production behavior, expectation, timeout, or skip condition changed.
This tiny prerequisite stays in the release PR rather than creating an
artificial release stack; it is a separate commit for review. No
changelog entry is needed for an internal test synchronization
correction.
Verification on the isolated herdr: full pending-input file **37
passed**, then the affected case **10/10 passed** with `--rerun-each
10`. Source-bound reports were preserved. Required CI is rerunning for
the new PR SHA; no failed check is bypassed or blindly rerun.
* fix(terminal): keep a tab out of use updating; pausing it is a setting (#699)
## Change
**Problem.** Since 0.4.4 (#660), a desktop tab detaches its pane one
second after the user switches to another window, so that herdr's own
TUI gets the pane's size back. The tab then stops updating until it is
clicked again. To someone who does not run herdr's TUI next to the
browser, this looks like the terminal froze. It also happens on a second
monitor where the tab stays in view.
**Change.**
- A tab that is out of use keeps its pane and its output again, as in
0.4.3. It still sends no resize and reconnects with `keep_size`. Only
the release is affected.
- **Settings → Terminal → Pause in another window** (`releasePaneAway`,
off by default) turns #660's release back on, unchanged, for people who
also work in herdr's own TUI on the same PC.
- The herdr API has no read-only way to tell whether a TUI client shows
the pane: the snapshot has no client field, and `no_foreground_client`
comes only from `client.window_title.set/clear`, which change the user's
title. So the choice stays with the user.
@jiunshinn: #660 made a desktop tab stop updating a second after the
user switches windows, and we had a report that this reads as a frozen
terminal. This PR makes that release opt-in, off by default. A follow-up
will make the setting switch the tab to a read-only `herdr terminal
session observe` stream instead of pausing. I measured on 0.9.3 that the
observer leaves herdr's TUI at its own size (44x37 stays 44x37, while an
attach makes it 150x50), so the tab keeps updating and the TUI is not
cut off. Feedback welcome.
## Validation
- `scripts/chat-size-regression.ts` against a `bun run check run
--build` herdr: 20/20 PASS. The new check (default settings, window
blurred, clock run past the 1 s release delay) confirms there is no
`detach`, no paused banner, and the pane's `pty-data` still arrives. The
existing #660 checks run with the setting on and still pass.
- `bun run check fast`: workflow syntax, generated types, typecheck,
build, unit tests all ok.
- `src/lib/settings.test.ts`: the setting is off by default and only a
boolean `true` turns it on.
- Settings → Terminal opened in a browser against the isolated test
session: the toggle shows up off, turns on, and is saved to
`herdr-web-ui:settings`. Korean, Japanese and Chinese strings were
added, and `i18n.test.ts` passes.
* feat(terminal): a tab out of use watches its pane read-only instead of pausing (#700)
## Change
**Problem.** #699 made #660's release opt-in: with the setting on, a
desktop tab out of use detaches its pane so that herdr's own TUI gets
the pane's size back, and then the tab freezes until it is clicked.
People who run herdr's TUI next to the browser had to pick one of two
problems: a frozen web terminal, or a TUI cut off at the browser's size.
**Fix.** herdr ≥ 0.7.2 has a read-only observer, `herdr terminal session
observe <pane> --cols --rows`. Unlike `terminal attach`, it does not
take ownership of the pane's size. I measured this on 0.9.3 with a real
TUI client and a split pane at 44 columns:
| state | pane size (`stty size`) |
|---|---|
| TUI only | 37 44 |
| browser tab in use (attach) | 37 145 |
| browser tab out of use, **watching** | **37 44** |
| browser back in use | 37 145 |
With the setting on, the tab now detaches as before, then watches the
pane instead of pausing. The terminal shows "View only while you use
another window · click to type". Focus or a pointerdown unwatches and
attaches again at the tab's size. A grid change starts the view again at
the new grid. A released tab that moves to another pane watches that
one.
- **Wire** (`shared/protocol.ts`, feature `"watch"`): client `watch
{pane_id, cols, rows}` / `unwatch {pane_id}`; server `watch-data
{pane_id, data}` / `watch-end {pane_id}`. A watch is not an attach:
there is no input, resize, ACK or input-ready, and it never touches
`attachments`, claims or `pane-geometry`, so a phone can keep its attach
while a desktop watches.
- **Server** (`server/watch.ts`): one observer per (connection, pane),
started with plain `Bun.spawn`. It is not the PTY sidecar.
`HERDR_SOCKET_PATH` is pinned the same way the attach does it. Frames
are NDJSON; `bytes` is base64 of ANSI, and herdr labels it
`encoding:"ansi"`. They are decoded with a streaming UTF-8 decoder. The
observer is killed on unwatch, a replacing watch, WS close, a
slow-client stop, device revoke and `stop()`.
- **Fallbacks:** an older bridge has no `"watch"` feature, and a
`watch-end` can arrive when the pane is gone or herdr has no observer.
In both cases the tab stays paused on its last screen, as before. The
site's demo answers `watch` with `watch-end`; its page never releases
anyway.
- **Setting renamed** to describe the situation rather than the
mechanism: **Use alongside herdr's own window** (still off by default,
same `releasePaneAway` key, not yet released).
- The herdr API has no read-only way to detect a TUI client, so this
stays a setting. `herdr api schema --json` has no client listing, and
`no_foreground_client` only comes from `client.window_title.set/clear`,
which change the user's title.
## Validation
- `server/watch.contract.test.ts` (new, live herdr via `bun run check
run`), 6/6 passing: the `"watch"` feature is advertised; an unattached
pane streams real command output to an observe-role client while no
`terminal attach` process runs; unwatch kills the observer without a
`watch-end`; WS close kills it; a missing pane gets `watch-end`; invalid
geometry gets `invalid_geometry`.
- `server/watch.test.ts` (new, unit): NDJSON across chunk boundaries, a
UTF-8 character split across two frames, `terminal.closed`, unknown
records.
- `src/lib/ws.test.ts`: a watch goes out after the snapshot and again
after a reconnect; it is not re-sent after `watch-end`; a reconnect to a
bridge without the feature ends it.
- `scripts/chat-size-regression.ts` against `bun run check run --build`,
21/21 PASS. The release checks now expect the view-only banner and a
`watch` frame. New: once the phone leaves and no attach runs, the window
still receives the pane's output through `watch-data`.
- End to end, by hand, with a real herdr TUI in tmux on a throwaway
session: the sizes in the table above, and `echo LIVE-WHILE-WATCHING`
showed up in the browser while it watched.
- `bun run check fast`: all ok. New strings are in ko/ja/zh.
* fix(claude): certify background counts against scanned file versions (#701)
## Change
Fix R1 from the independent v0.4.4 review: a concurrent parent append
could be certified under the newer post-scan signature, freezing a stale
count. Cache only identical pre/post scan versions and dependencies that
were quiet before the scan. Parent identity and folder replacement now
participate in invalidation.
## Validation
Reproduced the race 3/3 before the fix; all 3 after-fix probes converge.
Focused public behavior regressions cover replacement and stable reads.
The complete remediation rebased on current main passes `bun run check
full`: 2,259 unit passes (7 platform skips), 323 integration passes, all
20 browser scripts and the demo build. This PR also requires its own CI
before merge.
## Ownership
owner: devswha via Amp
scope: R1 count-cache certification only
branch/worktree: fix/claude-count-scan / dedicated
release-review-remediation worktree
start SHA: 3a5b05068a5a2c623cb9dacd372e351a61ad2470
dependencies: none
verification: local combined full check passed; PR CI pending
status: ready for review
No code adapted from another project.
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): retry incomplete process identity at bounded intervals (#702)
## Change
Fix verified R3 from the v0.4.4 review: an initial missing PID or
process-start lookup used to stay unknown forever. Retry incomplete
identity at the existing bounded interval, including resolver-only
callers. A recovered process boundary correctly marks tasks from the
previous process lost; no timestamp is invented.
## Validation
Reproduced the sticky unknown identity before fixing. Public status
tests cover a missing PID, missing start and resolver-only recovery.
Focused claude-subagents tests pass on current main. The combined
remediation full run on the latest terminal changes and metadata
identity fix passed 2,259 unit tests, 323 integration tests and all 20
browser scripts plus demo build. This PR requires its own CI and native
gates.
## Ownership
owner: devswha via Amp
scope: R3 process identity recovery
branch/worktree: fix/claude-process-identity / dedicated remediation
worktree
start SHA: 36209a0 (main after #701)
dependencies: none; based on latest main
verification: focused tests passed; PR CI pending
status: ready for review
Changelog PR links will be finalized in the release preparation PR. No
externally adapted code.
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): retain observed rest across delayed transcript discovery (#703)
## Change
Fix verified R2: delayed transcript discovery used to erase the observed
working-to-rest transition, leaving a turn with background work at DONE
instead of BG. Associate independently observed rest times with the
discovered prompt; keep the first eligible deadline across automatic
resumes. A positively replaced session/process clears the old wait
evidence, including equal-count transitions. Initial
discovery/refinement does not reset it.
This does not suppress or retract an initial DONE notification emitted
before discovery; that remains a separate product policy.
## Validation
Reproduced early vs delayed discovery before fixing. State/concurrency
design was checked with Oracle. Tests cover delayed discovery,
first-deadline preservation, initially resting panes and positive
session replacement. Existing live contract tests check exact frames and
one final push; their injected clock and transcript timestamps now share
one time domain. All 9 contract tests and the complete combined full
check passed; focused unit checks pass again on latest main. This PR
must pass its own CI/native/remote gates.
## Ownership
owner: devswha via Amp
scope: R2 delayed BG state
branch/worktree: fix/claude-delayed-background / dedicated remediation
worktree
start SHA: d4fa866 (main after #702)
dependencies: integrated process-identity recovery
verification: local focused and combined full checks passed; PR CI
pending
status: ready for review
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(licenses): include the bundled TanStack Highlight license (#704)
## Change
Fix verified R4 from the v0.4.4 independent review: the distributed
notices omitted the bundled TanStack Highlight MIT text. Include the
exact installed upstream license and a focused regression comparing it
with the dependency license. No runtime behavior changes.
## Validation
The license test passes on latest main. Production and demo builds were
checked byte-for-byte against THIRD_PARTY_NOTICES.md. The combined
remediation with review corrections and the readline fixture input-order
fix passes the full local suite: 2,260 unit tests, 323 integration
tests, all 20 browser scripts plus demo build. This PR also requires its
own CI.
## Ownership
owner: devswha via Amp
scope: bundled Highlight license only
branch/worktree: fix/highlight-license / dedicated remediation worktree
start SHA: c824aab (main after #703)
dependencies: none
verification: focused license test and combined full suite passed; PR CI
pending
status: ready for review
The added text is the MIT license distributed by @tanstack/highlight;
implementation code is not adapted.
Co-authored-by: Amp <amp@ampcode.com>
* fix(deps): update source-map-js to patched 1.2.2 (#705)
## Change
Resolve the inherited high-severity source-map-js advisory identified in
the v0.4.4 release review. This is a lockfile-only 1.2.1 → 1.2.2 patch;
no dependency range or unrelated package changes.
## Validation
The original audit reported one high advisory. After the patch, frozen
install succeeds and bun audit reports zero vulnerabilities across 159
packages. The combined remediation full suite and production/demo builds
pass. Native CI and remote bundle/SSH validation must pass on this PR
before merge.
## Ownership
owner: devswha via Amp
scope: source-map-js patch only
branch/worktree: fix/source-map-security / dedicated remediation
worktree
start SHA: 814754d (main after #704)
dependencies: none
verification: frozen install, audit and combined full suite passed; PR
CI pending
status: ready for review
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): invalidate background counts at task age boundaries (#706)
## Change
Fix verified R6: an unchanged file signature bypassed the existing
one-day task age rule, so a warm badge kept a task that a direct read
had already expired. Include the age eligibility boundary in the watched
signature using the same clock as the poll. No new age policy or
configurable expiry is introduced.
## Validation
Before/after probes reproduced a warm count of 1 vs direct count 0;
after the fix both are 0. Public status regression covers both sides of
the age boundary without any file write. Focused claude-subagents tests
pass on latest main; the combined full suite with review corrections
passes 2,260 unit tests, 323 integration tests and 20 browser scripts
plus demo build. PR CI must pass separately.
## Ownership
owner: devswha via Amp
scope: R6 age-boundary invalidation
branch/worktree: fix/claude-task-age / dedicated remediation worktree
start SHA: 56d9ae0 (main after #705)
dependencies: integrated count-cache certification
verification: focused and combined full checks passed; PR CI pending
status: ready for review
---------
Co-authored-by: Amp <amp@ampcode.com>
* test(ci): gate releases on native session identity regressions (#709)
## Changes
Run existing Windows-native identity tests in reusable CI and add native
macOS Claude-store/process-start coverage. Release and Website callers
inherit the checks. Preserve existing platform coverage and all
assertions.
Also fix the verified readline fixture input race: prior browser
keystrokes could prefix the direct-RPC launch command. Send cleanup and
launch through the same browser input stream. Separate commits preserve
the independent changes.
## Validation and CI timing
Typecheck passed on this branch. Combined full local validation passed
(2260 unit, 323 integration, 20 browser scripts). Native behavior must
pass on the corresponding GitHub runners before merge.
Measured integration job queue 2s versus execution 485s; browser lane
462.9s and integration lane 208.7s already overlap. No test deletion,
relaxed deadline, new cache or gate reuse. Structural sharding remains
follow-up work.
## Ownership
- owner: devswha / Amp
- scope: native validation registration and verified browser fixture
race
- branch/worktree: test/native-release-validation / dedicated
release-review-remediation
- start SHA: d12a77c
- dependencies: none
- verification: local typecheck, full combined run, native and browser
PR CI
- status: ready for review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(chat): retain page-scoped Claude completion identities (#708)
## Change
Keep completion identities across settled/live transcript boundaries
within the requested page. Reparse live suffixes with a copy, so later
polls and resumed completions remain visible. Do not deduplicate across
pages.
## Validation
Regression reproduced before the fix. Restacked conversation suite: 78
pass. Combined remediation check full passed; required PR checks run on
this exact revision.
## Ownership
- owner: devswha / Amp
- scope: Claude completion identity cache and regression
- branch/worktree: fix/claude-completion-dedup in dedicated
release-review-remediation worktree
- start SHA: d12a77c (latest origin/main)
- dependencies: none
- verification: focused unit suite, required CI
- status: ready for independent review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(chat): preserve trailing code whitespace in manual selections (#710)
## Change
Normalize CRLF only; preserve trailing source newlines in rendered code
for manual selection with highlighting on or off. Copy code still uses
the original source.
## Validation
Focused unit tests passed on this branch. The combined full check
passed, including file-viewer DOM/Range assertions for both highlighting
settings. Synthetic rendered screenshots were inspected:
- On:
https://ampcode.com/user-content/artifacts/d1a195977397e5703819cda7bdd33f742c8d4cf6dd62c538e9a6ccb498a01be0-file.png
- Off:
https://ampcode.com/user-content/artifacts/c3bca89c20ccadc70690e0984208a6274b8314387aec34c2b189d801e45060bc-file.png
Required CI runs again for this PR.
## Ownership
- owner: devswha / Amp
- scope: source whitespace fidelity and regression coverage
- branch/worktree: fix/code-selection-whitespace / dedicated
release-review-remediation
- start SHA: d12a77c
- dependencies: none
- verification: unit tests, browser DOM/Range and visual inspection, PR
CI
- status: ready for review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* chore(release): prepare v0.4.5 and remote runtime v24 (#711)
## Release
Prepare patch v0.4.5 with matching package/plugin versions, dated
changelog, four-language update summaries and remote bundle version 24.
Includes independent review remediation #701–#706, #708–#710, plus
already merged #699/#700. All product PRs passed required CI on their
current base; native Windows and macOS validation is now inherited by
Release. All review threads are resolved.
Remote-v24 MUST publish all five checked archives and manifest before
dispatching Release for v0.4.5. Remote PCs will require Update bridge…
once. Never push the app v-tag manually.
## Validation
Metadata generation and frozen install/audit passed. A fresh complete
local check and required PR CI run on this integrated revision. Previous
combined check: 2260 unit, 323 integration, 20 browser scripts plus demo
build. Actual native jobs: 11 Windows and 37 macOS tests passed.
## CI investigation
Queue versus execution measured separately: integration/browser 2s/485s
before, 13s/493s after fixture correction. No speedup claimed. Official
ECR Debian mirror avoids verified Docker Hub pull throttling; browser
fixture now orders cleanup and launch with earlier input. Tests, support
platforms and exact-SHA release/deployment gates are retained. Browser
sharding or validation reuse are separate follow-up proposals.
## Limitations
Initial alerts emitted before transcript discovery cannot be retracted;
status events lack process identity. Completion dedup is page-scoped.
Real phone/Safari/PWA naming, live paid agents and delivered production
push timing remain unverified. Optional architecture cleanup is
excluded.
## Ownership
- owner: devswha / Amp
- scope: v0.4.5 release metadata and remote-v24
- branch/worktree: chore/release-v0.4.5 / dedicated
release-review-remediation
- start SHA: e6a7f81
- dependencies: all product remediation PRs merged
- verification: fresh check full, native CI, five platform bundle
checks, Release exact-SHA gate
- status: release candidate; publish only after gates pass
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* docs(agents): drop the Session panel row, whose component is gone (#693)
The `Session panel` row in the WHERE TO LOOK table named four things:
- `components/SessionPanel.tsx`
- `lib/activity.ts`
- an `onActivity` prop on ChatView
- a `herdr-web-ui:session-panel` storage key
None of them exist. `grep -rn SessionPanel src/` matches this row and
nothing else; `onActivity` and `session-panel` likewise appear nowhere
but here.
`components/BackgroundTasks.tsx` is **not** a rename of that surface —
it is omo task runs polled from the server (`lib/omoTasks.ts`,
`OmoTask`), where the row described turns derived client-side inside
ChatView. Repointing the row at it would have been a plausible-looking
lie, so the row is deleted.
No behaviour change. Found by the V8 finding of the 2026-10-06
accessibility/visual audit.
Co-authored-by: Hako <devswha@gmail.com>
* ci: typecheck three more entry-point scripts (#696)
No code changes. `tsconfig.tools.json` covers six entry points; three
more that CI actually runs typecheck clean under it today and are simply
not listed:
- `scripts/build-remote-bundle.ts` (`build:remote`)
- `scripts/ssh-integration.ts` (`test:ssh`)
- `scripts/file-viewer-regression.ts` (the browser lane — AGENTS.md
notes `test:ui` skips it but CI runs it)
Adding them makes `bun run typecheck` fail on a missing or mistyped
import in any of the three, which is the failure class that let a bad
commit reach `main` green before.
## What is still uncovered, and why it is not in this PR
The one I most wanted is `scripts/ui-regression.ts` — the main browser
entry point. Its transitive closure carries 17 errors across five files:
```
7 scripts/notification-startup-regression.ts
4 scripts/mobile-viewport-regression.ts
3 scripts/alert-sound-regression.ts
2 scripts/ui-regression.ts
1 scripts/csp-violations.ts
```
Two of those are worth your eye regardless of the typecheck question:
- `ui-regression.ts:304` uses `Array.prototype.findLast`, which needs
`lib: ES2023`; the root tsconfig is `target: ES2022`. That is a config
gap rather than a script bug, and bumping `lib` is a project-wide call I
did not want to make inside a hygiene PR.
- `csp-violations.ts:72` — `Object is possibly undefined`.
`generate-protocol-types.ts` is also clean-except-for-one (an unused
local at :114), so I left it out rather than bundle a code change into a
PR promising none.
Fixing the 17 is a small, mechanical follow-up if you want the browser
lane covered — say so and I will do it as its own PR, or fold the `lib`
bump in separately if you would rather have the coverage than the
smaller diff now.
Co-authored-by: Hako <devswha@gmail.com>
* fix(a11y): never read a pane id out as the terminal's name (#695)
Follow-up to #633. That PR labels the terminal region with the pane's
title and falls back to the pane id:
```ts
paneId === null ? t("Terminal") : t("Terminal for {title}", { title: title ?? paneId })
```
`title` is null until the snapshot that names the pane arrives, so a
pane opened on that gap announces itself as **"Terminal for
p_1a2b3c4d"** — a screen reader reads the id aloud as the name of the
product's central surface.
A pane with nothing to call it now takes the grid's own kind, which is
the name the idle grid (`paneId === null`) already uses.
The rule is pulled into `src/lib/terminalLabel.ts` so it can be pinned
by a test — this is the one a11y surface in the app with no unit
coverage at all, and the failure mode (a fallback string) is exactly
what a test should hold.
```
bun test ./src/lib/terminalLabel.test.ts 2 pass
bun run typecheck clean
bun run test:unit 1720 pass / 0 fail
```
No new translation keys: both strings already ship for #633.
**Scope note, not fixed here.** `displayPaneTitle()`
(src/components/Sidebar.tsx:55) itself falls back to `pane.pane_id` when
a pane has no label and no derivable title, so a genuinely untitled pane
can still carry an id here — and it shows one in the sidebar today as
well. Distinguishing "no title yet" from "no title to be had" needs a
nullable accessor beside `displayPaneTitle`, which changes that
function's contract and is a wider change than this. Happy to do it as a
follow-up if you want it; say the word.
---------
Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): a session cookie with no Origin is cross-site (#697)
The S6 finding of the 2026-10-06 audit. `sameOrigin()` returns `true`
when the `Origin` header is absent, so a cookie-bearing request whose
`Origin` went missing — or was stripped by something in front — passes
the origin gate.
A browser always attaches `Origin` to a cross-origin state-changing
request, and the WS upgrade already carries the device cookie, so this
is not a live browser hole: `SameSite=Strict` covers the case. It is
defence in depth, and the audit rated it Low for that reason.
**What I am not claiming:** I did not find an exploit chain, and I am
not asking you to treat this as one.
## The three paths that make this hard
This is the second attempt. The first was reverted in `e755194` because
tightening `sameOrigin` broke the documented wire contract. These three
carry a session cookie and state no `Origin` in exactly the cases that
need them:
| Path | Why it cannot state one |
|---|---|
| `GET /ws` upgrade | a browser cannot attach the custom mutation header
to a WebSocket handshake |
| `DELETE /api/auth` | sign-out; it manages the sender's own session |
| `POST /api/push/subscribe` | the same session's own push subscription
|
So each is exempted by path, and each is now pinned:
- `server/auth.test.ts` — 11 cases: the three exempted, the two cookies
alone, the `x-herdr-machine` proof, a CLI with no cookie, and a browser
that does state an origin.
- `server/open-access.test.ts` — the same thing through the HTTP seam: a
watch device with no `Origin` is refused `invalid_origin`, and with the
header it gets past the origin gate and is refused `read_only` instead —
i.e. the origin check stopped being the thing answering.
```
bun test ./server/auth.test.ts ./server/open-access.test.ts 21 pass / 0 fail
```
I also ran a throwaway probe of those 11 cases against `main` without
this change: 5 of them flip to `true`, so the tests are not passing
vacuously.
## Notes
- `machine-security.ts` is byte-identical to the copy this was written
against, so the change carried over without a conflict.
- `pty-compare.ts` was in the same working tree and is deliberately not
included — unrelated.
- If you would rather this sat as a recorded observation than a change
(the audit already lists S6 as `"recorded"`), close it and I will drop
it. I would rather ask than assume.
---------
Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(machines): show an unreachable PC as reconnecting after a failed bridge update (#716)
## Problem
A registered remote PC that is switched off kept showing **Bridge update
needed** / "needs a bridge update to reconnect" with an **Update
bridge** button that cannot succeed.
## Root cause
`MachineManager.setup()`'s catch put every failed bridge update of a
registered PC (other than a `MachineActionRequired`) into `state:
"error"`, `action_required: "update_bridge"`, including a failure that
came from SSH not reaching the PC at all. Nothing re-probes an `error`
state, so the prompt stayed until the user acted.
The likely path: after an app update raises `REMOTE_BUNDLE_VERSION`, the
reconnect finds the old bridge and queues the automatic update. That
update opens a new SSH connection. If the MacBook has gone to sleep or
been switched off by then (or drops out mid-update), the connect fails
and the PC is parked on the update prompt for good.
## Fix
- An update job that could not reach a registered PC no longer parks it.
It goes back on the normal reconnect backoff (`retryLater`, factored out
of `lost()`), shows **Reconnecting…** with ssh's error, and has
`action_required: null`. When the PC answers, `reconnect()` re-runs the
version check, so `update_bridge` (and the automatic update) comes back
only when the bridge really is out of date.
- "Could not reach" means one of two things. SSH never came up and ssh's
error is not a refusal (`Permission denied`, host-key failures). Or SSH
came up and the master connection then died (new
`SshConnection.connected()`, read before `disconnect` closes it).
- Behaviour that stays the same: a PC that refuses the saved key keeps
**Update bridge** / **Sign in and update…**, as the `updateBridge`
comment requires. An update that fails on a PC that still answers
(download, install) also keeps the button and its reason. The job's own
`action_required`, which the dialog reads, is unchanged.
- No new UI strings. CHANGELOG (Unreleased → Fixed) and
`docs/remote-pcs.md` are updated.
## Tests
`server/machines.test.ts` has four new cases on a registered PC with SSH
faked:
- connect timeout → `reconnecting`, no action
- connection dropped mid-update → `reconnecting`, no action
- `Permission denied (publickey)` → keeps `update_bridge`
- a failure on a PC that still answers → keeps `update_bridge`
The first two fail without the fix. `bun run check fast` passes.
## Second path: a cancelled update (182bdd8)
On the user's live server, the MacBook record read `state:
"disconnected"`, `action_required: "update_bridge"`, with the error
`mux_client_request_session: read from master failed: Broken pipe …
Connection timed out`. That record has two steps behind it:
1. An earlier update failed after the SSH master died. The old catch
stored that error, `update_bridge` and `state: "error"`. The first
commit now turns this into reconnecting.
2. A later update job was **cancelled** (Cancel update, a closed dialog,
or the 600 s `job.timer`). The catch calls `disconnect()`, which sets
`"disconnected"`, then skips everything else for a cancelled phase. The
stale `action_required`/`error` stay, and no retry is scheduled, so the
PC stays on the prompt for good.
The new test `reconnects after a cancelled update, and asks again only
once the PC answers` reproduces that exact record without the fix.
The fix: a cancelled update of a registered, enabled PC (owning the
runtime, server not stopping) clears both fields and goes back on the
reconnect backoff. When the PC answers with an old bridge, it shows the
**Update bridge** button. The cancelled automatic update does not
restart by itself (`updateCancelled`) until the PC connects or the user
starts an update.
**Restarts and long outages:** `machines.json` stores no state, error or
action, so a restart starts every PC `disconnected` and reconnects it.
While a PC stays off, `reconnect()` fails at `ssh.start` and `lost()`
keeps it reconnecting. `action_required` is set in only two places: when
`prepare`/`verify` throw `MachineActionRequired` (SSH up and the PC
answering), or in the setup catch for a refusal or a failure with the
master still alive (the PC answered).
## Not covered / not verified
- Not tried against a real Mac that goes offline; the SSH failures are
faked at `SshConnection`.
- A PC whose update failed while it was still reachable, and which was
turned off afterwards, still shows the update prompt, because nothing
re-probes an `error` state. A periodic SSH liveness probe could cover
that case. It would change how the prompt behaves, so I left it for a
separate decision.
- Telling a refusal apart from an unreachable PC before SSH comes up
relies on ssh's stderr wording (OpenSSH's standard messages).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(omo): read no DONE for a turn that ended on a model error (#715)
## What was reproduced
An OmO pane's status comes from its session file
(`server/omo-status.ts`). An assistant answer with `stopReason: "error"`
— what OmO writes when the provider gives up after its retries or a
request times out — ended the turn exactly like a normal `stop`.
`CompletionTracker.settle()` then saw "idle after work" and latched the
pane as `done`, so it read DONE and web push / the browser alerted "work
finished" while the agent was still alive.
The new test in `server/omo-status.test.ts` replays the issue's sequence
as OmO records it: work, an error answer ("…could not complete the
request after 5 retries…"), the stop-state record, a `model_change`
fallback, more work on the fallback model, a second error ("Request
timed out."). Before the fix it read `working, done, working, done`.
## What changed
- `omoTurnAfter` marks a rest that came from an `error` answer as
`failed`. The pane still reads READY (not RUN: nothing OmO writes tells
a retry from giving up, as before).
- `OmoStatus.poll` tells that rest as `rest: "failed"` on `onChange`;
`omoChanged` passes it to `CompletionTracker.observe(…, rest)`, which
drops the pane's "worked" mark and any DONE kept from an earlier turn:
READY, no DONE, no alert. Snapshots, resync and restarts then agree,
since nothing is latched.
- A later answer after a failed rest (a retry that got through, or a new
prompt answered within one poll) is told as `rest: "answered"`: the
tracker marks the work itself and the pane goes straight to DONE. No
synthetic RUN is sent first, since that would make the turn 0 ms long
and the default "long turn" alerts would skip it. With no turn start
seen, push and the browser treat the length as unknown and alert.
- The failed rest is kept with what the pane read when its session could
not be told for a while, so a retry that answered meanwhile is still a
finish.
- Kept conservative (a missed DONE rather than a made-up or repeated
one): the remembered failure belongs to its session file; only an answer
timestamped after the failure counts as its retry; the tracker accepts
it only while nothing (work, DONE) was told of the pane since the failed
rest; an error read from a file found anew (restart, `/resume`) is
`failed-before` and leaves a DONE kept from another turn alone.
- Known limit: a retry's DONE that never read RUN has an unknown length
to both alert paths, so the default "long turn" setting alerts it even
after a short retry. Carrying the real duration needs turn timing in the
pane-status frames; left for a follow-up.
- Other agents are untouched: the flag is only set by OmO's session
reader and `observe` defaults to `failed = false`.
Not changed: `stopReason: "aborted"` (user interrupt) still ends as
DONE; an OmO pane whose session file cannot be told keeps herdr's status
as before. Wrong DONEs already saved in `completions.json` clear on the
pane's next turn or focus.
## How it was verified
- New unit tests: the issue's sequence reads `working, idle, working,
idle`, READY in every snapshot after; a retry's answer then reads
`done`; a normal turn afterwards still reads `working, done`. A DONE
kept from an earlier turn is dropped by a failed rest, and a retry that
answered while the session could not be told is still DONE.
- `bun run typecheck`, `HERDR_TEST_MODE=unit bun test` for `completion`,
`omo-status`, `omo`, `omo-tasks`.
- `bun run check run bun test ./server/omo.contract.test.ts
./server/omo-prompt.contract.test.ts` on an isolated herdr: 10 pass.
- `bun run check fast`: all ok (2261 pass, 0 fail).
- Alerts: web push (`server/push.ts`) and the browser's alerts
(`shouldNotifyStatus` in `shared/notify-policy.ts`) fire only on
`done`/`blocked`, so a READY rest alerts nothing on either path.
Closes #687
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): match Windows Codex store paths and refuse ambiguous Claude stores (#714)
## What was reproduced
No Windows PC here, so every case was reproduced with Win32 path
semantics (`path.win32`) and an in-memory SQLite on Linux:
- **#587 case 1 (prefixed `CODEX_HOME`)**:
`path.win32.relative("\\?\D:\codex\sessions",
"D:\codex\sessions\2026\x.jsonl")` returns the absolute file path, so
the containment check in `codexRolloutPath` rejected a rollout inside
the store whenever only one side kept `\\?\`. The reverse (plain root,
prefixed file) fails the same way.
- **#587 case 2 (case-different cwd)**: `cwd IN ('D:\work\app',
'\\?\D:\work\app')` returns no row for a stored `\\?\d:\work\app`.
- **#586**: with records of the same live process in `~/.claude` and
`~/.claude-copy`, `windowsProcessStore` returned `null`, the same as "no
record", and `claudeTranscriptPath` fell back to `~/.claude`.
## Already fixed on main (#518)
- Point 1 (rollout paths with `\\?\`) and the prefix spelling of point 2
(`cwd IN (?, ?)` with both spellings) were fixed in c04c760 (#582,
released in v0.4.1). Point 3 (picker resume) is #717.
## What changed
- `server/codex.ts`:
- `rolloutInsideStore(root, file, paths)` strips `\\?\` on **both**
sides before `relative()`.
- A Windows cwd is one directory under the `\\?\` prefix and either
drive-letter case, and is otherwise compared exactly.
- `storedCwdCondition(cwd)` gives the three thread queries a binary `cwd
IN (…)` over those spellings, so the queries stay on Codex's `(archived,
cwd, …)` index.
- `sameDirectory` / `codexPeersIn` apply the same rule to the peer
checks (`claimedByOtherPanes`, `resumedElsewhere`, the short-thread
guard), so the peer checks and the queries agree.
- POSIX cwds are exact as before.
- `server/claude-store.ts`:
- `windowsProcessStore` throws `AmbiguousClaudeStore` when more than one
store holds the process's live PID record. Like a miss, it is not
cached; "no record" still returns `null` and keeps the default-store
fallback.
- Stores are told apart by `claudeStoreKey`: volume + file id where
there is one, else the path without `\\?\` and without case. So
`CLAUDE_CONFIG_DIR=\\?\C:\…\.claude`, a junction or a link to
`~/.claude` is one store, not an ambiguous pair.
- `server/conversation.ts` (one hunk): `AmbiguousClaudeStore` becomes
`ConversationUnavailable("no_session_id")` instead of `~/.claude`.
### Scope narrowed after review
A cwd stored in another letter case **past the drive letter**
(`\\?\d:\Work\app` for `D:\work\app`) is deliberately **not** matched:
the chat stays unavailable.
- A Windows directory can be case-sensitive (`app` and `App` are then
two directories), and only the file system can tell.
- The identity-based version took a stat per spelling, a discovery scan
with a limit and a cache. Two adversarial review passes showed each of
those could fail open: another directory's or another pane's
conversation.
- Fail-closed was chosen instead. #587 therefore stays open for that
case.
## How it was verified
- Unit cases, each failing before its fix:
- `server/codex.test.ts`: a prefixed root or rollout counts as inside
the store, with traversal, another drive and UNC cases.
- `server/codex.test.ts`: the drive-letter and prefix spellings are
found; a case-sensitive sibling with 33 newer threads neither joins nor
crowds out the 32; POSIX case stays exact; the query plan is a SEARCH on
`(archived=? AND cwd=?)`; peers follow the same rule.
- `server/claude-store.test.ts`: two stores that both claim the process
reject with `AmbiguousClaudeStore`; a `CLAUDE_CONFIG_DIR` link to
`~/.claude` is one store; `claudeStoreKey` drops `\\?\` and case without
a file id.
- `bun run typecheck`, `HERDR_TEST_MODE=unit bun test
./server/codex.test.ts ./server/claude-store.test.ts
./server/codex-processes.test.ts ./server/conversation.test.ts
./server/windows-native.test.ts`, `bun run check fast` (all ok).
- Not run on a Windows PC.
## Issues
Closes #586
Refs #587: case 1 and the drive-letter part of case 2 are fixed; a cwd
differing in case past the drive letter stays unavailable by design (see
above).
Refs #518: points 1–2 fixed in #582, point 3 in #717.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(server): use pane.get for single-pane lookups (#724)
## Change
Fix audit finding H6 by replacing whole-session snapshots used only to
inspect one pane.
- Add a typed `paneGet(paneId, socketPath?)` wrapper over `pane.get`
with `{ pane_id }`, returning the generated `PaneInfo`; reuse it for
scroll metadata.
- Use it for pane context, chat input checks, collapsed Codex queue
checks, image uploads, push title lookup and on-demand Claude
background-task discovery.
- Keep the previous optional-pane behavior in input checks. Herdr 0.9.3
returns `pane_not_found` with the existing message for unknown panes, so
the same HTTP helpers and envelopes continue to apply.
- Add unknown-pane HTTP contract cases for commands and files; the
existing image unknown-pane contract also passes.
`terminalInfoFor` still needs snapshot layout rects. Conversation
readers still need peers to disambiguate Devin, Codex and OmO transcript
identities, so `server/conversation.ts` remains unchanged. Global
collector, completion normalization, agent name allocation and cache
recovery retain their snapshots.
No HTTP/WS shapes, endpoints, browser source, generated types or agent
settings changed.
## Validation
All checks ran in
`/home/devswha/workspace/herdr-web-ui-wt/refactor-pane-get-lookups`.
- PASS: `bun install`.
- PASS: `bun run check fast` - workflow syntax, generated types,
typecheck and build passed; 2,285 unit tests passed, 7 existing
platform/fixture skips, 0 failures.
- PASS: `bun run check run bun test --timeout 15000
./server/api.contract.test.ts ./server/submit.contract.test.ts
./server/pending-input.contract.test.ts ./server/herdr/client.test.ts` -
180 tests passed, 0 failures.
- PASS: `bun run check run bun test --timeout 15000
./server/codex.contract.test.ts ./server/omp.contract.test.ts
./server/opencode.contract.test.ts
./server/claude-session.contract.test.ts` - 19 tests passed, 0 failures.
- PASS: `git diff --check`.
The source-state verification reports and full logs are preserved under:
- `/tmp/parity-qa-H6/fast/report.json`
- `/tmp/parity-qa-H6/contracts/report.json`
- `/tmp/parity-qa-H6/conversation-contracts/report.json`
### Isolated real-surface smoke
Built client served on port 7457 with a temporary app state directory
and isolated Herdr session `herdr-web-ui-qa-H6`.
- Opened the real chat pane in Playwright/Chromium at 1280 x 800 and 390
x 844; screenshots visually inspected and phone page had no horizontal
overflow.
- Sent a command from web chat while Herdr's TUI was concurrently
attached in a PTY; exact output appeared in the TUI and socket output
event.
- Typed a command through the Herdr TUI; the same output appeared in web
chat.
- `curl -sS -i -X POST http://127.0.0.1:7457/api/pane/image -H
'Content-Type: application/json' --data
'{"pane_id":"w1:p1","content_type":"image/png","data_base64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="}'`
returned HTTP 200 and a project-local PNG path.
- No Playwright page errors.
Screenshots:
- `/tmp/parity-qa-H6/desktop-chat.png`
- `/tmp/parity-qa-H6/phone-chat.png`
- `/tmp/parity-qa-H6/phone-reverse.png`
Supporting evidence:
- `/tmp/parity-qa-H6/image-curl.txt`
- `/tmp/parity-qa-H6/tui-web-to-herdr.txt`
- `/tmp/parity-qa-H6/tui-herdr-to-web.txt`
- `/tmp/parity-qa-H6/output-events.json`
- `/tmp/parity-qa-H6/pane-list.json`
- `/tmp/parity-qa-H6/session-snapshot.json`
- `/tmp/parity-qa-H6/pane-get-schema.json`
- `/tmp/parity-qa-H6/qa-report.md`
Herdr RPCs used: `pane.get` for converted metadata reads; retained
`session.snapshot` for layout/global/peer needs. QA used
`workspace.create`, `pane.read`, `pane.send_text`/`pane.send_keys`,
`agent.prompt` with the existing shell fallback, `events.subscribe` with
exact output matches, plus CLI `pane list` and `api snapshot`.
### Cleanup and limits
Browsers, web service, both owned TUI processes, QA Herdr session and
persistent service monitors stopped. `server stop` exited 0; the
temporary QA root and uploads were removed. Evidence under
`/tmp/parity-qa-H6/` was retained. Successful check runners cleaned
their isolated sessions.
No browser source changed, so no UI regression suite was required beyond
the requested smoke. The large `server/index.ts` LSP diagnostic request
timed out, but full TypeScript checking passed. Optional gate-reviewer
spawning was unavailable at the task nesting limit; the implementer
reviewed the full diff. No product failures or open behavior issues
found.
* feat(settings): show herdr's agent integrations read-only (#722)
## What changed
- **`GET /api/integrations`** wraps herdr's `integration.list` RPC
(herdr 0.9.3) via `integrationList()` in `server/herdr/client.ts`, one
connection per call. It answers `{ integrations: AgentIntegration[] }`
in herdr's order; any other method is `400 method_not_allowed`.
`AgentIntegration` / `IntegrationsResponse` are hand-written in
`shared/protocol.ts` (the saved schema predates the RPC; `state` is
widened with `(string & {})`), with the route catalogued there.
- **Remote PCs:** it is a plain GET like `/api/agents`, so it joins the
bridge path list and `MACHINE_PROXY_PATH` (`machines.test.ts` asserts
it). The client is `fetchIntegrations(machineId)` in `src/lib/api.ts`,
bound in `useMachineApi()`. The Settings dialog renders outside a
`MachineContext`, so the page shows the local PC; a remote PC's list is
reachable through the proxy but no UI picks a PC yet.
- **Settings → Agent integrations** (new page, `Plug` icon, before
About) built from `SettingsGroup`/`SettingsRow`: agents found on the
PC's PATH first, then a "Not found on this PC" group. Installed rows
show a check. Missing or outdated rows show `herdr integration install
<label>` (the label is the CLI name, e.g. `antigravity-cli`) with a Copy
button using `copyText` and the select-the-text fallback.
- **Read-only by design:** nothing calls
`integration.install`/`integration.uninstall`, and there is no
install/uninstall button; installing writes into an agent's own
hooks/settings, which stays the user's call in a terminal.
- i18n ko/ja/zh, demo answer in `site/demo/transport.ts` (one of each
state), the page added to `scripts/settings-pages-demo-regression.ts`,
CHANGELOG line under Unreleased → Added.
## How it was checked
- `bun run check fast` → PASS on 81becc7 (workflow syntax, generated
types, typecheck, build, unit tests: 2285 pass, 0 fail)
- `bun run check run bun test --timeout 15000
./server/api.contract.test.ts` → PASS (99 pass, 0 fail) (new case:
`/api/integrations` equals `integration.list` from the isolated herdr;
`claude` present; states in the known set; POST refused)
- `bun scripts/settings-pages-demo-regression.ts` → PASS (every page
incl. Agent integrations fits 390px/320px in en/ko/ja/zh; Back/history
cases pass)
- Manual QA, isolated session `herdr-web-ui-qa-D` with
`HOME`/`XDG_CONFIG_HOME` in a temp dir, web server on :7455, Playwright
Chromium at 1280px and 390px:
- before: 17 rows, all "Not installed", 0 install/uninstall buttons,
Copy put `herdr integration install pi` on the clipboard, no page
errors, no sideways scroll; only `GET /api/integrations` requests.
- acted in herdr: Enter in herdr's own TUI integrations tab (TUI client
attached to the same session) installed claude + codex; `herdr
integration install opencode` from the CLI; codex's temp hook marked v1
to get `outdated`.
- after reopening the page: claude/opencode "Installed" with a check,
codex "Installed, but older than this herdr" + its command, matching
`herdr integration status` (`claude: current (v10)`, `codex: outdated
(v1 < v8)`, `opencode: current (v13)`).
- Screenshots (local): `/tmp/parity-qa-D/before-desktop.png`,
`before-phone.png`, `after-desktop.png`, `after-phone.png`;
TUI/CLI/snapshot evidence beside them.
## herdr RPCs used
`integration.list` only.
## Known limits
- herdr's `integration.list` omits Letta (experimental in `herdr
integration status`), so the page does too.
- The page reads once per opening; it does not poll. Reopen it after
installing.
- The Settings page shows the local PC only (see Remote PCs above).
Remote PCs: an SSH PC whose bridge predates this endpoint shows a plain
"not offered yet" message; it gets the page with the next remote runtime
release (no bundle is published here).
* fix(tabs): preserve numeric custom names (#723)
## Change
A tab label is automatic only when empty or equal to its current place,
so a numeric custom label survives closing an earlier tab. Adds unit
coverage and an Unreleased Fixed entry.
## Validation
- bun run check fast — PASS (workflow syntax, generated types,
typecheck, build, unit suite).
- bun test ./src/lib/tabName.test.ts — PASS (5 tests).
- Isolated herdr session herdr-web-ui-qa-H5, 1280px Chromium capture:
/tmp/parity-qa-H5/tab-strip-desktop.png shows label 3 after the earlier
tab closed.
- herdr api schema --json confirms tab fields include label and stable
number, without a custom-label marker. Repro used tab.create, tab.list,
tab.close, and pane.split; remaining state was w1:t2, number:2, label:3.
No known limitations.
* feat(palette): list and run herdr plugin actions (#720)
## What changed
The web UI can list and run the manifest actions of the herdr plugins
installed on a PC, as herdr's own TUI does.
- **Server**: `GET /api/plugins/actions` (`plugin.list` +
`plugin.action.list`, grouped by plugin with its enabled state; actions
for another platform are left out, command lines stay on the server) and
`POST /api/plugin/action { plugin_id, action_id, pane_id? }`
(`plugin.action.invoke`). Wrappers in `server/herdr/client.ts`, shapes
and route catalogue in `shared/protocol.ts`, both routes added to the
remote-PC proxy allowlist and to the bridge-token paths, answers in
`site/demo/transport.ts`.
- **Palette**: a **Plugin actions** group runs an action against the
selected pane. The row shows "Running…" and the palette stays open until
the server answers; a failed command says why above the list
(`role="alert"`); a pane the action opened and focused is selected.
- **Settings → About**: a small read-only "herdr plugins" group (name,
version, actions, Enabled/Disabled). It is not drawn when no plugin is
installed.
## What herdr does, measured on 0.9.3
- `plugin.action.invoke` takes each `PluginInvocationContext` field as
given and fills a missing one from **its own focus**, not from the pane
named: `focused_pane_id` alone came back with the focused workspace's ID
and label, and an unknown pane ID was accepted. So the server validates
the pane against a snapshot and sends workspace, tab, labels, cwd, agent
and status itself.
- Invoke answers while the command is still `running`. The route polls
`plugin.log.list` for that log entry for up to 5 s, so the browser gets
`succeeded`, `failed` (exit code and the end of stderr) or `running`.
- **Plugin panes**: the invoke answer names no pane, but an action can
open one by calling `herdr plugin pane open` (this repository's own
`phone` action does). `opened_pane_id` is a pane that did not exist
before the invoke and that herdr focuses after it; the palette selects
it.
- `plugin.action.list` includes a disabled plugin's actions and other
platforms' actions; herdr refuses them with `plugin_disabled` /
`platform_unsupported`. The palette offers neither.
herdr RPCs used: `plugin.list`, `plugin.action.list`,
`plugin.action.invoke`, `plugin.log.list`, `session.snapshot`.
## How it was checked
| Command | Result |
|---|---|
| `bun run check fast` | PASS (2288 pass, 7 skip, 0 fail; typecheck
included) |
| `bun run check run bun test --timeout 15000
./server/plugins.contract.test.ts` | PASS (8 pass, 0 fail) |
| `bun run check run bun scripts/file-viewer-regression.ts` (drives the
palette over Settings and a preview) | PASS |
| Browser + TUI QA driver on an isolated herdr session (own
`XDG_CONFIG_HOME`), Chromium via playwright-core | PASS, 18 of 18 checks
|
The contract test links a plugin from a temp directory into the run's
own herdr config and unlinks it afterwards. herdr keeps linked plugins
per user, not per session, so the suite skips itself when the herdr
under test uses the user's own `~/.config/herdr` (the shared
`herdr-web-ui-test` session); `bun run check` and CI give it a config
directory of its own.
QA evidence (local paths on the build PC, not committed):
`/tmp/parity-qa-C/`
- `01-desktop-palette-group.png`, `02-desktop-in-flight.png`,
`04-desktop-failed-action.png`, `05-desktop-opened-pane-selected.png`,
`06-desktop-settings-plugins.png` at 1280px
- `09-phone-palette-group.png`, `10-phone-in-flight.png`,
`11-phone-failed-action.png` at 390px
- Web → herdr: `marker-from-web.json` (the context the plugin command
received: the web's selected pane, `invocation_source: herdr-web-ui`),
`tui-02-after-web-marker.txt` and
`socket-02-pane-list-after-web-marker.json` (the pane the action
renamed), `tui-03-after-web-open-board.txt` and
`socket-03-snapshot-after-web-open-board.json` (the plugin pane,
focused)
- herdr → web: `herdr-side-invoke.json` +
`07-desktop-after-herdr-side-invoke.png` (a plugin pane opened from
herdr's CLI appears in the web),
`08-desktop-plugin-disabled-in-herdr.png` (a plugin disabled in herdr
leaves the palette)
- `qa-results.txt`, `qa-driver.ts`, `qa-plugin.toml`
## Known limits
- Where an action's pane opens is the plugin's choice: `herdr plugin
pane open` uses herdr's focus unless the command passes `--target-pane`
or `--workspace`. The context is correct either way (`HERDR_PANE_ID` and
the context JSON name the web's pane).
- A popup-placement plugin pane has no pane ID and shows only in a herdr
TUI client; the web cannot select it.
- An action whose only context is `selection` is not offered: the web
has no selected text to hand over. Without a selected pane only `global`
(or context-less) actions are offered.
- A command still running after 5 s is reported as `running` and the
palette closes; its later failure is only in `herdr plugin log list`.
- Installing, enabling, disabling and removing plugins stay with herdr.
A remote PC on an older bridge answers 404 and the group is simply
absent there.
- Remote PCs get the routes with the next `remote-vN` bundle; none was
built here.
* fix(collector): refresh sessions on herdr lifecycle events (#725)
## Change
Fix audit H4: structural changes made in herdr's TUI now invalidate the
web roster/layout through the existing `session-changed` frame instead
of waiting for five-second polls.
- Subscribe to workspace, worktree, tab, pane and layout lifecycle
invalidations.
- Coalesce event bursts in the existing 500 ms window; snapshot retry
timers cannot delay browser invalidation.
- Reconcile authoritative pane IDs, closing and reopening the complete
per-pane status subscription after moves.
- Retain browser, machine and collector polls and recovery/health
checks. No UI or wire-shape changes.
## Validation
- PASS `bun install` (Bun 1.4.2).
- PASS `bun run check fast`: workflow syntax, generated types,
typecheck, build and unit suite, exit 0.
- PASS `bun run check run bun test --timeout 15000
./server/collector.contract.test.ts`: 3 tests, 0 failures; socket
`workspace.rename`, `tab.rename`, `pane.zoom` each produced WS
`session-changed` within 591-601 ms. Isolated herdr 0.9.3.
- PASS `bun run check run --build bun scripts/ui-regression.ts`: built
real client and existing desktop/mobile regression suite, exit 0. Only
lock refusals were retried, in bounded 30-second intervals; no test
failure was retried.
- PASS concurrent real browser and TUI on isolated `herdr-web-ui-qa-H4`,
bridge port 7456, temporary state: web tab rename appeared in TUI and
socket snapshot; TUI workspace rename painted in 703 ms; TUI tab rename
push arrived in 546 ms and painted in 650 ms.
- APPROVE read-only Codex `gpt-6.1-sol`, `xhigh`, fast-tier review under
an 8 GB memory cap: no must-fix findings
(`/tmp/parity-qa-H4/review.txt`).
## Evidence
Local evidence root: `/tmp/parity-qa-H4/`.
Screenshots: `desktop-web-to-tui.png`,
`desktop-tui-workspace-rename.png`, `desktop-tui-tab-rename.png`
(1280x800), `phone-tui-tab-rename.png`, `phone-tui-workspace-rename.png`
(390x844).
Timings/state/receipts: `timings.json`, `snapshot.json`,
`pane-list.json`, `tui-screen.txt`, `tui.typescript`, `qa-report.md`,
`cleanup.txt`, `fast-report.json`, `contract-report.json`,
`browser-report.json`.
QA setup RPCs: `workspace.create` and `tab.create` with `focus:false`,
`workspace.focus`, `session.snapshot`. Reverse actions used actual TUI
Ctrl+B, Shift+W/Shift+T rename dialogs, not API substitutes.
## Limits and cleanup
Screenshots are local paths, not hosted attachments. The first QA bridge
startup used an unset process environment and briefly reached the
default socket; attach was refused as `attach_held`, without takeover.
It was stopped; all retained captures came from a fresh owned browser
context against the explicitly isolated socket.
The browser, port-7456 bridge, TUI and QA herdr were stopped; temporary
QA state was removed. Both check runners cleaned their owned sessions.
No merge, main push or tag. Remote bridge deployment and GitHub CI are
not claimed by these local checks.
* feat(terminal): add native pane history search (#729)
## Change
Add **Find in terminal** to the header's More menu and Mod+Shift+F. The
find bar searches
herdr's stored pane history, shows the native match ordinal/count and
supports next/previous,
Enter, Shift+Enter and Escape. Keyboard focus remains in Find across
Chat transitions and
deferred upload completion; find text is never delivered to the
terminal.
`POST /api/pane/find` owns the HTTP contract in `shared/protocol.ts`,
validates requests and
uses fresh herdr RPC connections. Remote-PC routing is allowlisted and
machine-bound. Add
ko/ja/zh translations, explicit demo unavailability, a changelog entry
and native/browser
regression coverage. xterm scrollback remains zero; native attach
redraws display the match.
## Validation
- `bun install` — PASS.
- `bun run check fast` — PASS: workflow syntax, generated-type
freshness, typecheck, build
and unit tests. Report:
`node_modules/.cache/check/fb6199c3-9334-4764-b95e-97dbf943080b/report.json`.
- `bun run check run bun test --timeout 15000
./server/find.contract.test.ts` — PASS,
8 tests: found, next/previous, not found, punctuation, malformed query,
unknown pane,
navigation validation and a real native revision race with no stale
scroll. Report:
`node_modules/.cache/check/73624152-7932-4eb7-a90c-8f068b246879/report.json`.
- `bun run check run --build bun scripts/ui-regression.ts` — PASS,
including the new
find regression, real keyboard focus, Chat-to-Find, touch-menu access
and a delayed real
image upload. Report:
`node_modules/.cache/check/bef52677-5a76-4903-9af4-9973b4c51b86/report.json`.
- `UI_EVIDENCE_DIR=/tmp/parity-qa-G bun run check run --build bun
scripts/pane-find-regression.ts`
— PASS as a focused check before the full suite.
- Codex `gpt-6.1-sol`, xhigh, fast tier — final APPROVE after focus/race
findings were
fixed and regression-tested.
`/tmp/parity-qa-G/codex-review-upload-followup.txt`.
Final manual QA used owned `herdr-web-ui-qa-G`, port 7454 and a
temporary app state directory.
Print `FIND_QA_G_UNIQUE` followed by 320 lines: GUI search finds 1 of 1
and displays it.
A concurrently attached herdr TUI shows the same match; typing
`TUI_REVERSE_QA_G` there
updates the web terminal. Desktop 1280px and phone 390px captures in
both themes:
- `/tmp/parity-qa-G/desktop-find.png`
- `/tmp/parity-qa-G/desktop-find-light.png`
- `/tmp/parity-qa-G/phone-find.png`
- `/tmp/parity-qa-G/phone-find-light.png`
RPC/TUI evidence: `/tmp/parity-qa-G/reviewed-evidence.json`,
`reviewed-tui-found.txt`,
`reviewed-tui-reverse.txt`, `native-pane-list.txt`,
`native-snapshot.json`, `history-read.json`
and `search-schema.json`. Cleanup receipts:
`/tmp/parity-qa-G/cleanup-receipts.json`.
## Native API and limits
Use `pane.get`, `pane.copy_motion`, `pane.copy_search` and
`pane.scroll`. Copy mode nee…
* fix(ci): gate deployment on source-bound verification (#692)
## Change
- Require reusable CI and the site build to succeed for the same SHA
before Pages publishes the website, demo, or installers. Pin checkouts,
keep deployment serialization, and fail closed on stale-main/API errors,
including manual runs.
- Strengthen existing `bun run check`: durable source-bound JSON and
command logs, source/dist invalidation, interrupted/not-run/skipped
states, shared lock for `run`, and `run --build` for browser
prerequisites.
- Retain reports on success and failure. Capture isolated failure
screenshots, bounded console errors, and optional traces without
DOM/network capture in the main UI and prompt-dock regressions.
- Typecheck six critical tooling/demo entrypoints without relaxing
strictness. Document parallel ownership, genuine-dependency-only stacks,
squash/restack validation and explicit authority.
## Validation
- `bun run check fast`: passed after lifecycle fixes; 2242 tests passed,
7 skipped, 0 failed (before the final fail-closed correction, which has
focused coverage below). Includes workflow syntax, generated types, both
typechecks and app build.
- Final focused check/report/browser-evidence/Pages/browser-runner
tests: 39 passed, 233 assertions; both typechecks passed.
- Actual CLI lifecycle smoke: success/failure logs, build provenance,
source/dist mutation invalidation, competing-run lock refusal, SIGTERM
report and subsequent lock release passed in a disposable fixture
repository with a fake herdr executable.
- Actual Playwright synthetic failure: PNG, console metadata, private
artifact modes and trace without DOM/network capture captured from an
isolated fixture.
- Independent policy, deployment/evidence and report reviews completed.
Fixed interrupt cleanup ordering, inherited-output draining,
shared-report overwrite, unavailable source/build identities, and
bounded trace teardown. Actual CLI smoke also covered stubborn
descendants, shared-report lock refusal and source-identity loss.
Required GitHub CI remains authoritative for final SHA f44e9cc.
## Repository settings and remaining credential boundary
- Added `Native Windows install` to required main checks; retained
strict checks, admin enforcement, conversation resolution and optional
approval count. Latest 30 completed CI runs had 27 Windows successes,
one cancellation, and two fork runs awaiting approval without jobs.
- This PR does not provision an agent account or GitHub App credential.
Non-admin implementation credentials require separate operator
provisioning; no claim that current administrator credentials have been
replaced.
## Coordination record
Owner: coordinating maintainer session. Scope: verification/deployment
tooling and operating documentation. Start SHA:
b87d9d50019cdb87422415f7650ad29285a832cc. Branch/worktree:
worktree/rapid-stone-f62b. Dependencies: existing ci.yml/check
entrypoints only; no stacked PR dependency. Integration and release are
explicitly authorized by the user, subject to required checks and
unresolved-review blocking.
* chore(release): prepare 0.4.4 (#694)
## Change
Prepare app release 0.4.4 from main after #692, using the existing
release process only.
- Bump package.json and herdr-plugin.toml together.
- Move the existing Unreleased entries under 0.4.4 (2026-10-09 UTC),
preserving all PR links and external contributor credits.
- Add matching English, Korean, Japanese and Chinese release highlights
(3 new, 1 improved, 4 fixed; every line <=90 characters).
- No tags are created by this PR. The existing Release workflow must
validate the exact main SHA before publishing.
## Validation and review
- `bun scripts/release-notes.ts 0.4.4`: passed, generated expected
release body.
- Release-notes tests: 27 passed, 397 assertions, 0 failures.
- `bun install --frozen-lockfile`: passed without dependency changes.
- Reviewed the release metadata diff; all prior summary entries are
unchanged. The only non-metadata change is the one-line recorder
synchronization fix documented below.
- Audited release-range PR authors and existing changelog credits;
implementation PR #692 is linked.
- Required CI must pass on this PR before squash merge; no administrator
bypass.
Owner: release coordinator (explicitly authorized by user). Start SHA:
ceae7a259fa6f0d16ece3e56abb2178e752541dd. Scope: four release metadata
files and one release-blocking contract-test synchronization line.
Branch: chore/release-0.4.4. Dependencies: merged #692; no open stack.
## Observed release blocker
Website run
[37973801948](https://github.com/devswha/herdr-web-ui/actions/runs/37973801948)
correctly skipped deployment when the existing pending-input contract
test read `barrier` before the recorder observed its final Enter. A
WebSocket receipt is not a receipt from the separate recorder process.
Commit fe09359 adds the existing bounded `waitBytes("barrier\r")` before
the exact-byte and no-enqueue assertions, matching adjacent tests. No
production behavior, expectation, timeout, or skip condition changed.
This tiny prerequisite stays in the release PR rather than creating an
artificial release stack; it is a separate commit for review. No
changelog entry is needed for an internal test synchronization
correction.
Verification on the isolated herdr: full pending-input file **37
passed**, then the affected case **10/10 passed** with `--rerun-each
10`. Source-bound reports were preserved. Required CI is rerunning for
the new PR SHA; no failed check is bypassed or blindly rerun.
* fix(terminal): keep a tab out of use updating; pausing it is a setting (#699)
## Change
**Problem.** Since 0.4.4 (#660), a desktop tab detaches its pane one
second after the user switches to another window, so that herdr's own
TUI gets the pane's size back. The tab then stops updating until it is
clicked again. To someone who does not run herdr's TUI next to the
browser, this looks like the terminal froze. It also happens on a second
monitor where the tab stays in view.
**Change.**
- A tab that is out of use keeps its pane and its output again, as in
0.4.3. It still sends no resize and reconnects with `keep_size`. Only
the release is affected.
- **Settings → Terminal → Pause in another window** (`releasePaneAway`,
off by default) turns #660's release back on, unchanged, for people who
also work in herdr's own TUI on the same PC.
- The herdr API has no read-only way to tell whether a TUI client shows
the pane: the snapshot has no client field, and `no_foreground_client`
comes only from `client.window_title.set/clear`, which change the user's
title. So the choice stays with the user.
@jiunshinn: #660 made a desktop tab stop updating a second after the
user switches windows, and we had a report that this reads as a frozen
terminal. This PR makes that release opt-in, off by default. A follow-up
will make the setting switch the tab to a read-only `herdr terminal
session observe` stream instead of pausing. I measured on 0.9.3 that the
observer leaves herdr's TUI at its own size (44x37 stays 44x37, while an
attach makes it 150x50), so the tab keeps updating and the TUI is not
cut off. Feedback welcome.
## Validation
- `scripts/chat-size-regression.ts` against a `bun run check run
--build` herdr: 20/20 PASS. The new check (default settings, window
blurred, clock run past the 1 s release delay) confirms there is no
`detach`, no paused banner, and the pane's `pty-data` still arrives. The
existing #660 checks run with the setting on and still pass.
- `bun run check fast`: workflow syntax, generated types, typecheck,
build, unit tests all ok.
- `src/lib/settings.test.ts`: the setting is off by default and only a
boolean `true` turns it on.
- Settings → Terminal opened in a browser against the isolated test
session: the toggle shows up off, turns on, and is saved to
`herdr-web-ui:settings`. Korean, Japanese and Chinese strings were
added, and `i18n.test.ts` passes.
* feat(terminal): a tab out of use watches its pane read-only instead of pausing (#700)
## Change
**Problem.** #699 made #660's release opt-in: with the setting on, a
desktop tab out of use detaches its pane so that herdr's own TUI gets
the pane's size back, and then the tab freezes until it is clicked.
People who run herdr's TUI next to the browser had to pick one of two
problems: a frozen web terminal, or a TUI cut off at the browser's size.
**Fix.** herdr ≥ 0.7.2 has a read-only observer, `herdr terminal session
observe <pane> --cols --rows`. Unlike `terminal attach`, it does not
take ownership of the pane's size. I measured this on 0.9.3 with a real
TUI client and a split pane at 44 columns:
| state | pane size (`stty size`) |
|---|---|
| TUI only | 37 44 |
| browser tab in use (attach) | 37 145 |
| browser tab out of use, **watching** | **37 44** |
| browser back in use | 37 145 |
With the setting on, the tab now detaches as before, then watches the
pane instead of pausing. The terminal shows "View only while you use
another window · click to type". Focus or a pointerdown unwatches and
attaches again at the tab's size. A grid change starts the view again at
the new grid. A released tab that moves to another pane watches that
one.
- **Wire** (`shared/protocol.ts`, feature `"watch"`): client `watch
{pane_id, cols, rows}` / `unwatch {pane_id}`; server `watch-data
{pane_id, data}` / `watch-end {pane_id}`. A watch is not an attach:
there is no input, resize, ACK or input-ready, and it never touches
`attachments`, claims or `pane-geometry`, so a phone can keep its attach
while a desktop watches.
- **Server** (`server/watch.ts`): one observer per (connection, pane),
started with plain `Bun.spawn`. It is not the PTY sidecar.
`HERDR_SOCKET_PATH` is pinned the same way the attach does it. Frames
are NDJSON; `bytes` is base64 of ANSI, and herdr labels it
`encoding:"ansi"`. They are decoded with a streaming UTF-8 decoder. The
observer is killed on unwatch, a replacing watch, WS close, a
slow-client stop, device revoke and `stop()`.
- **Fallbacks:** an older bridge has no `"watch"` feature, and a
`watch-end` can arrive when the pane is gone or herdr has no observer.
In both cases the tab stays paused on its last screen, as before. The
site's demo answers `watch` with `watch-end`; its page never releases
anyway.
- **Setting renamed** to describe the situation rather than the
mechanism: **Use alongside herdr's own window** (still off by default,
same `releasePaneAway` key, not yet released).
- The herdr API has no read-only way to detect a TUI client, so this
stays a setting. `herdr api schema --json` has no client listing, and
`no_foreground_client` only comes from `client.window_title.set/clear`,
which change the user's title.
## Validation
- `server/watch.contract.test.ts` (new, live herdr via `bun run check
run`), 6/6 passing: the `"watch"` feature is advertised; an unattached
pane streams real command output to an observe-role client while no
`terminal attach` process runs; unwatch kills the observer without a
`watch-end`; WS close kills it; a missing pane gets `watch-end`; invalid
geometry gets `invalid_geometry`.
- `server/watch.test.ts` (new, unit): NDJSON across chunk boundaries, a
UTF-8 character split across two frames, `terminal.closed`, unknown
records.
- `src/lib/ws.test.ts`: a watch goes out after the snapshot and again
after a reconnect; it is not re-sent after `watch-end`; a reconnect to a
bridge without the feature ends it.
- `scripts/chat-size-regression.ts` against `bun run check run --build`,
21/21 PASS. The release checks now expect the view-only banner and a
`watch` frame. New: once the phone leaves and no attach runs, the window
still receives the pane's output through `watch-data`.
- End to end, by hand, with a real herdr TUI in tmux on a throwaway
session: the sizes in the table above, and `echo LIVE-WHILE-WATCHING`
showed up in the browser while it watched.
- `bun run check fast`: all ok. New strings are in ko/ja/zh.
* fix(claude): certify background counts against scanned file versions (#701)
## Change
Fix R1 from the independent v0.4.4 review: a concurrent parent append
could be certified under the newer post-scan signature, freezing a stale
count. Cache only identical pre/post scan versions and dependencies that
were quiet before the scan. Parent identity and folder replacement now
participate in invalidation.
## Validation
Reproduced the race 3/3 before the fix; all 3 after-fix probes converge.
Focused public behavior regressions cover replacement and stable reads.
The complete remediation rebased on current main passes `bun run check
full`: 2,259 unit passes (7 platform skips), 323 integration passes, all
20 browser scripts and the demo build. This PR also requires its own CI
before merge.
## Ownership
owner: devswha via Amp
scope: R1 count-cache certification only
branch/worktree: fix/claude-count-scan / dedicated
release-review-remediation worktree
start SHA: 3a5b05068a5a2c623cb9dacd372e351a61ad2470
dependencies: none
verification: local combined full check passed; PR CI pending
status: ready for review
No code adapted from another project.
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): retry incomplete process identity at bounded intervals (#702)
## Change
Fix verified R3 from the v0.4.4 review: an initial missing PID or
process-start lookup used to stay unknown forever. Retry incomplete
identity at the existing bounded interval, including resolver-only
callers. A recovered process boundary correctly marks tasks from the
previous process lost; no timestamp is invented.
## Validation
Reproduced the sticky unknown identity before fixing. Public status
tests cover a missing PID, missing start and resolver-only recovery.
Focused claude-subagents tests pass on current main. The combined
remediation full run on the latest terminal changes and metadata
identity fix passed 2,259 unit tests, 323 integration tests and all 20
browser scripts plus demo build. This PR requires its own CI and native
gates.
## Ownership
owner: devswha via Amp
scope: R3 process identity recovery
branch/worktree: fix/claude-process-identity / dedicated remediation
worktree
start SHA: 36209a0 (main after #701)
dependencies: none; based on latest main
verification: focused tests passed; PR CI pending
status: ready for review
Changelog PR links will be finalized in the release preparation PR. No
externally adapted code.
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): retain observed rest across delayed transcript discovery (#703)
## Change
Fix verified R2: delayed transcript discovery used to erase the observed
working-to-rest transition, leaving a turn with background work at DONE
instead of BG. Associate independently observed rest times with the
discovered prompt; keep the first eligible deadline across automatic
resumes. A positively replaced session/process clears the old wait
evidence, including equal-count transitions. Initial
discovery/refinement does not reset it.
This does not suppress or retract an initial DONE notification emitted
before discovery; that remains a separate product policy.
## Validation
Reproduced early vs delayed discovery before fixing. State/concurrency
design was checked with Oracle. Tests cover delayed discovery,
first-deadline preservation, initially resting panes and positive
session replacement. Existing live contract tests check exact frames and
one final push; their injected clock and transcript timestamps now share
one time domain. All 9 contract tests and the complete combined full
check passed; focused unit checks pass again on latest main. This PR
must pass its own CI/native/remote gates.
## Ownership
owner: devswha via Amp
scope: R2 delayed BG state
branch/worktree: fix/claude-delayed-background / dedicated remediation
worktree
start SHA: d4fa866 (main after #702)
dependencies: integrated process-identity recovery
verification: local focused and combined full checks passed; PR CI
pending
status: ready for review
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(licenses): include the bundled TanStack Highlight license (#704)
## Change
Fix verified R4 from the v0.4.4 independent review: the distributed
notices omitted the bundled TanStack Highlight MIT text. Include the
exact installed upstream license and a focused regression comparing it
with the dependency license. No runtime behavior changes.
## Validation
The license test passes on latest main. Production and demo builds were
checked byte-for-byte against THIRD_PARTY_NOTICES.md. The combined
remediation with review corrections and the readline fixture input-order
fix passes the full local suite: 2,260 unit tests, 323 integration
tests, all 20 browser scripts plus demo build. This PR also requires its
own CI.
## Ownership
owner: devswha via Amp
scope: bundled Highlight license only
branch/worktree: fix/highlight-license / dedicated remediation worktree
start SHA: c824aab (main after #703)
dependencies: none
verification: focused license test and combined full suite passed; PR CI
pending
status: ready for review
The added text is the MIT license distributed by @tanstack/highlight;
implementation code is not adapted.
Co-authored-by: Amp <amp@ampcode.com>
* fix(deps): update source-map-js to patched 1.2.2 (#705)
## Change
Resolve the inherited high-severity source-map-js advisory identified in
the v0.4.4 release review. This is a lockfile-only 1.2.1 → 1.2.2 patch;
no dependency range or unrelated package changes.
## Validation
The original audit reported one high advisory. After the patch, frozen
install succeeds and bun audit reports zero vulnerabilities across 159
packages. The combined remediation full suite and production/demo builds
pass. Native CI and remote bundle/SSH validation must pass on this PR
before merge.
## Ownership
owner: devswha via Amp
scope: source-map-js patch only
branch/worktree: fix/source-map-security / dedicated remediation
worktree
start SHA: 814754d (main after #704)
dependencies: none
verification: frozen install, audit and combined full suite passed; PR
CI pending
status: ready for review
Co-authored-by: Amp <amp@ampcode.com>
* fix(claude): invalidate background counts at task age boundaries (#706)
## Change
Fix verified R6: an unchanged file signature bypassed the existing
one-day task age rule, so a warm badge kept a task that a direct read
had already expired. Include the age eligibility boundary in the watched
signature using the same clock as the poll. No new age policy or
configurable expiry is introduced.
## Validation
Before/after probes reproduced a warm count of 1 vs direct count 0;
after the fix both are 0. Public status regression covers both sides of
the age boundary without any file write. Focused claude-subagents tests
pass on latest main; the combined full suite with review corrections
passes 2,260 unit tests, 323 integration tests and 20 browser scripts
plus demo build. PR CI must pass separately.
## Ownership
owner: devswha via Amp
scope: R6 age-boundary invalidation
branch/worktree: fix/claude-task-age / dedicated remediation worktree
start SHA: 56d9ae0 (main after #705)
dependencies: integrated count-cache certification
verification: focused and combined full checks passed; PR CI pending
status: ready for review
---------
Co-authored-by: Amp <amp@ampcode.com>
* test(ci): gate releases on native session identity regressions (#709)
## Changes
Run existing Windows-native identity tests in reusable CI and add native
macOS Claude-store/process-start coverage. Release and Website callers
inherit the checks. Preserve existing platform coverage and all
assertions.
Also fix the verified readline fixture input race: prior browser
keystrokes could prefix the direct-RPC launch command. Send cleanup and
launch through the same browser input stream. Separate commits preserve
the independent changes.
## Validation and CI timing
Typecheck passed on this branch. Combined full local validation passed
(2260 unit, 323 integration, 20 browser scripts). Native behavior must
pass on the corresponding GitHub runners before merge.
Measured integration job queue 2s versus execution 485s; browser lane
462.9s and integration lane 208.7s already overlap. No test deletion,
relaxed deadline, new cache or gate reuse. Structural sharding remains
follow-up work.
## Ownership
- owner: devswha / Amp
- scope: native validation registration and verified browser fixture
race
- branch/worktree: test/native-release-validation / dedicated
release-review-remediation
- start SHA: d12a77c
- dependencies: none
- verification: local typecheck, full combined run, native and browser
PR CI
- status: ready for review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(chat): retain page-scoped Claude completion identities (#708)
## Change
Keep completion identities across settled/live transcript boundaries
within the requested page. Reparse live suffixes with a copy, so later
polls and resumed completions remain visible. Do not deduplicate across
pages.
## Validation
Regression reproduced before the fix. Restacked conversation suite: 78
pass. Combined remediation check full passed; required PR checks run on
this exact revision.
## Ownership
- owner: devswha / Amp
- scope: Claude completion identity cache and regression
- branch/worktree: fix/claude-completion-dedup in dedicated
release-review-remediation worktree
- start SHA: d12a77c (latest origin/main)
- dependencies: none
- verification: focused unit suite, required CI
- status: ready for independent review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* fix(chat): preserve trailing code whitespace in manual selections (#710)
## Change
Normalize CRLF only; preserve trailing source newlines in rendered code
for manual selection with highlighting on or off. Copy code still uses
the original source.
## Validation
Focused unit tests passed on this branch. The combined full check
passed, including file-viewer DOM/Range assertions for both highlighting
settings. Synthetic rendered screenshots were inspected:
- On:
https://ampcode.com/user-content/artifacts/d1a195977397e5703819cda7bdd33f742c8d4cf6dd62c538e9a6ccb498a01be0-file.png
- Off:
https://ampcode.com/user-content/artifacts/c3bca89c20ccadc70690e0984208a6274b8314387aec34c2b189d801e45060bc-file.png
Required CI runs again for this PR.
## Ownership
- owner: devswha / Amp
- scope: source whitespace fidelity and regression coverage
- branch/worktree: fix/code-selection-whitespace / dedicated
release-review-remediation
- start SHA: d12a77c
- dependencies: none
- verification: unit tests, browser DOM/Range and visual inspection, PR
CI
- status: ready for review and CI
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* chore(release): prepare v0.4.5 and remote runtime v24 (#711)
## Release
Prepare patch v0.4.5 with matching package/plugin versions, dated
changelog, four-language update summaries and remote bundle version 24.
Includes independent review remediation #701–#706, #708–#710, plus
already merged #699/#700. All product PRs passed required CI on their
current base; native Windows and macOS validation is now inherited by
Release. All review threads are resolved.
Remote-v24 MUST publish all five checked archives and manifest before
dispatching Release for v0.4.5. Remote PCs will require Update bridge…
once. Never push the app v-tag manually.
## Validation
Metadata generation and frozen install/audit passed. A fresh complete
local check and required PR CI run on this integrated revision. Previous
combined check: 2260 unit, 323 integration, 20 browser scripts plus demo
build. Actual native jobs: 11 Windows and 37 macOS tests passed.
## CI investigation
Queue versus execution measured separately: integration/browser 2s/485s
before, 13s/493s after fixture correction. No speedup claimed. Official
ECR Debian mirror avoids verified Docker Hub pull throttling; browser
fixture now orders cleanup and launch with earlier input. Tests, support
platforms and exact-SHA release/deployment gates are retained. Browser
sharding or validation reuse are separate follow-up proposals.
## Limitations
Initial alerts emitted before transcript discovery cannot be retracted;
status events lack process identity. Completion dedup is page-scoped.
Real phone/Safari/PWA naming, live paid agents and delivered production
push timing remain unverified. Optional architecture cleanup is
excluded.
## Ownership
- owner: devswha / Amp
- scope: v0.4.5 release metadata and remote-v24
- branch/worktree: chore/release-v0.4.5 / dedicated
release-review-remediation
- start SHA: e6a7f81
- dependencies: all product remediation PRs merged
- verification: fresh check full, native CI, five platform bundle
checks, Release exact-SHA gate
- status: release candidate; publish only after gates pass
Amp-Thread-ID:
https://ampcode.com/threads/T-01a12235-af16-73b1-ad0c-625b633f2697
---------
Co-authored-by: Amp <amp@ampcode.com>
* docs(agents): drop the Session panel row, whose component is gone (#693)
The `Session panel` row in the WHERE TO LOOK table named four things:
- `components/SessionPanel.tsx`
- `lib/activity.ts`
- an `onActivity` prop on ChatView
- a `herdr-web-ui:session-panel` storage key
None of them exist. `grep -rn SessionPanel src/` matches this row and
nothing else; `onActivity` and `session-panel` likewise appear nowhere
but here.
`components/BackgroundTasks.tsx` is **not** a rename of that surface —
it is omo task runs polled from the server (`lib/omoTasks.ts`,
`OmoTask`), where the row described turns derived client-side inside
ChatView. Repointing the row at it would have been a plausible-looking
lie, so the row is deleted.
No behaviour change. Found by the V8 finding of the 2026-10-06
accessibility/visual audit.
Co-authored-by: Hako <devswha@gmail.com>
* ci: typecheck three more entry-point scripts (#696)
No code changes. `tsconfig.tools.json` covers six entry points; three
more that CI actually runs typecheck clean under it today and are simply
not listed:
- `scripts/build-remote-bundle.ts` (`build:remote`)
- `scripts/ssh-integration.ts` (`test:ssh`)
- `scripts/file-viewer-regression.ts` (the browser lane — AGENTS.md
notes `test:ui` skips it but CI runs it)
Adding them makes `bun run typecheck` fail on a missing or mistyped
import in any of the three, which is the failure class that let a bad
commit reach `main` green before.
## What is still uncovered, and why it is not in this PR
The one I most wanted is `scripts/ui-regression.ts` — the main browser
entry point. Its transitive closure carries 17 errors across five files:
```
7 scripts/notification-startup-regression.ts
4 scripts/mobile-viewport-regression.ts
3 scripts/alert-sound-regression.ts
2 scripts/ui-regression.ts
1 scripts/csp-violations.ts
```
Two of those are worth your eye regardless of the typecheck question:
- `ui-regression.ts:304` uses `Array.prototype.findLast`, which needs
`lib: ES2023`; the root tsconfig is `target: ES2022`. That is a config
gap rather than a script bug, and bumping `lib` is a project-wide call I
did not want to make inside a hygiene PR.
- `csp-violations.ts:72` — `Object is possibly undefined`.
`generate-protocol-types.ts` is also clean-except-for-one (an unused
local at :114), so I left it out rather than bundle a code change into a
PR promising none.
Fixing the 17 is a small, mechanical follow-up if you want the browser
lane covered — say so and I will do it as its own PR, or fold the `lib`
bump in separately if you would rather have the coverage than the
smaller diff now.
Co-authored-by: Hako <devswha@gmail.com>
* fix(a11y): never read a pane id out as the terminal's name (#695)
Follow-up to #633. That PR labels the terminal region with the pane's
title and falls back to the pane id:
```ts
paneId === null ? t("Terminal") : t("Terminal for {title}", { title: title ?? paneId })
```
`title` is null until the snapshot that names the pane arrives, so a
pane opened on that gap announces itself as **"Terminal for
p_1a2b3c4d"** — a screen reader reads the id aloud as the name of the
product's central surface.
A pane with nothing to call it now takes the grid's own kind, which is
the name the idle grid (`paneId === null`) already uses.
The rule is pulled into `src/lib/terminalLabel.ts` so it can be pinned
by a test — this is the one a11y surface in the app with no unit
coverage at all, and the failure mode (a fallback string) is exactly
what a test should hold.
```
bun test ./src/lib/terminalLabel.test.ts 2 pass
bun run typecheck clean
bun run test:unit 1720 pass / 0 fail
```
No new translation keys: both strings already ship for #633.
**Scope note, not fixed here.** `displayPaneTitle()`
(src/components/Sidebar.tsx:55) itself falls back to `pane.pane_id` when
a pane has no label and no derivable title, so a genuinely untitled pane
can still carry an id here — and it shows one in the sidebar today as
well. Distinguishing "no title yet" from "no title to be had" needs a
nullable accessor beside `displayPaneTitle`, which changes that
function's contract and is a wider change than this. Happy to do it as a
follow-up if you want it; say the word.
---------
Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(security): a session cookie with no Origin is cross-site (#697)
The S6 finding of the 2026-10-06 audit. `sameOrigin()` returns `true`
when the `Origin` header is absent, so a cookie-bearing request whose
`Origin` went missing — or was stripped by something in front — passes
the origin gate.
A browser always attaches `Origin` to a cross-origin state-changing
request, and the WS upgrade already carries the device cookie, so this
is not a live browser hole: `SameSite=Strict` covers the case. It is
defence in depth, and the audit rated it Low for that reason.
**What I am not claiming:** I did not find an exploit chain, and I am
not asking you to treat this as one.
## The three paths that make this hard
This is the second attempt. The first was reverted in `e755194` because
tightening `sameOrigin` broke the documented wire contract. These three
carry a session cookie and state no `Origin` in exactly the cases that
need them:
| Path | Why it cannot state one |
|---|---|
| `GET /ws` upgrade | a browser cannot attach the custom mutation header
to a WebSocket handshake |
| `DELETE /api/auth` | sign-out; it manages the sender's own session |
| `POST /api/push/subscribe` | the same session's own push subscription
|
So each is exempted by path, and each is now pinned:
- `server/auth.test.ts` — 11 cases: the three exempted, the two cookies
alone, the `x-herdr-machine` proof, a CLI with no cookie, and a browser
that does state an origin.
- `server/open-access.test.ts` — the same thing through the HTTP seam: a
watch device with no `Origin` is refused `invalid_origin`, and with the
header it gets past the origin gate and is refused `read_only` instead —
i.e. the origin check stopped being the thing answering.
```
bun test ./server/auth.test.ts ./server/open-access.test.ts 21 pass / 0 fail
```
I also ran a throwaway probe of those 11 cases against `main` without
this change: 5 of them flip to `true`, so the tests are not passing
vacuously.
## Notes
- `machine-security.ts` is byte-identical to the copy this was written
against, so the change carried over without a conflict.
- `pty-compare.ts` was in the same working tree and is deliberately not
included — unrelated.
- If you would rather this sat as a recorded observation than a change
(the audit already lists S6 as `"recorded"`), close it and I will drop
it. I would rather ask than assume.
---------
Co-authored-by: Hako <devswha@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(machines): show an unreachable PC as reconnecting after a failed bridge update (#716)
## Problem
A registered remote PC that is switched off kept showing **Bridge update
needed** / "needs a bridge update to reconnect" with an **Update
bridge** button that cannot succeed.
## Root cause
`MachineManager.setup()`'s catch put every failed bridge update of a
registered PC (other than a `MachineActionRequired`) into `state:
"error"`, `action_required: "update_bridge"`, including a failure that
came from SSH not reaching the PC at all. Nothing re-probes an `error`
state, so the prompt stayed until the user acted.
The likely path: after an app update raises `REMOTE_BUNDLE_VERSION`, the
reconnect finds the old bridge and queues the automatic update. That
update opens a new SSH connection. If the MacBook has gone to sleep or
been switched off by then (or drops out mid-update), the connect fails
and the PC is parked on the update prompt for good.
## Fix
- An update job that could not reach a registered PC no longer parks it.
It goes back on the normal reconnect backoff (`retryLater`, factored out
of `lost()`), shows **Reconnecting…** with ssh's error, and has
`action_required: null`. When the PC answers, `reconnect()` re-runs the
version check, so `update_bridge` (and the automatic update) comes back
only when the bridge really is out of date.
- "Could not reach" means one of two things. SSH never came up and ssh's
error is not a refusal (`Permission denied`, host-key failures). Or SSH
came up and the master connection then died (new
`SshConnection.connected()`, read before `disconnect` closes it).
- Behaviour that stays the same: a PC that refuses the saved key keeps
**Update bridge** / **Sign in and update…**, as the `updateBridge`
comment requires. An update that fails on a PC that still answers
(download, install) also keeps the button and its reason. The job's own
`action_required`, which the dialog reads, is unchanged.
- No new UI strings. CHANGELOG (Unreleased → Fixed) and
`docs/remote-pcs.md` are updated.
## Tests
`server/machines.test.ts` has four new cases on a registered PC with SSH
faked:
- connect timeout → `reconnecting`, no action
- connection dropped mid-update → `reconnecting`, no action
- `Permission denied (publickey)` → keeps `update_bridge`
- a failure on a PC that still answers → keeps `update_bridge`
The first two fail without the fix. `bun run check fast` passes.
## Second path: a cancelled update (182bdd8)
On the user's live server, the MacBook record read `state:
"disconnected"`, `action_required: "update_bridge"`, with the error
`mux_client_request_session: read from master failed: Broken pipe …
Connection timed out`. That record has two steps behind it:
1. An earlier update failed after the SSH master died. The old catch
stored that error, `update_bridge` and `state: "error"`. The first
commit now turns this into reconnecting.
2. A later update job was **cancelled** (Cancel update, a closed dialog,
or the 600 s `job.timer`). The catch calls `disconnect()`, which sets
`"disconnected"`, then skips everything else for a cancelled phase. The
stale `action_required`/`error` stay, and no retry is scheduled, so the
PC stays on the prompt for good.
The new test `reconnects after a cancelled update, and asks again only
once the PC answers` reproduces that exact record without the fix.
The fix: a cancelled update of a registered, enabled PC (owning the
runtime, server not stopping) clears both fields and goes back on the
reconnect backoff. When the PC answers with an old bridge, it shows the
**Update bridge** button. The cancelled automatic update does not
restart by itself (`updateCancelled`) until the PC connects or the user
starts an update.
**Restarts and long outages:** `machines.json` stores no state, error or
action, so a restart starts every PC `disconnected` and reconnects it.
While a PC stays off, `reconnect()` fails at `ssh.start` and `lost()`
keeps it reconnecting. `action_required` is set in only two places: when
`prepare`/`verify` throw `MachineActionRequired` (SSH up and the PC
answering), or in the setup catch for a refusal or a failure with the
master still alive (the PC answered).
## Not covered / not verified
- Not tried against a real Mac that goes offline; the SSH failures are
faked at `SshConnection`.
- A PC whose update failed while it was still reachable, and which was
turned off afterwards, still shows the update prompt, because nothing
re-probes an `error` state. A periodic SSH liveness probe could cover
that case. It would change how the prompt behaves, so I left it for a
separate decision.
- Telling a refusal apart from an unreachable PC before SSH comes up
relies on ssh's stderr wording (OpenSSH's standard messages).
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(omo): read no DONE for a turn that ended on a model error (#715)
## What was reproduced
An OmO pane's status comes from its session file
(`server/omo-status.ts`). An assistant answer with `stopReason: "error"`
— what OmO writes when the provider gives up after its retries or a
request times out — ended the turn exactly like a normal `stop`.
`CompletionTracker.settle()` then saw "idle after work" and latched the
pane as `done`, so it read DONE and web push / the browser alerted "work
finished" while the agent was still alive.
The new test in `server/omo-status.test.ts` replays the issue's sequence
as OmO records it: work, an error answer ("…could not complete the
request after 5 retries…"), the stop-state record, a `model_change`
fallback, more work on the fallback model, a second error ("Request
timed out."). Before the fix it read `working, done, working, done`.
## What changed
- `omoTurnAfter` marks a rest that came from an `error` answer as
`failed`. The pane still reads READY (not RUN: nothing OmO writes tells
a retry from giving up, as before).
- `OmoStatus.poll` tells that rest as `rest: "failed"` on `onChange`;
`omoChanged` passes it to `CompletionTracker.observe(…, rest)`, which
drops the pane's "worked" mark and any DONE kept from an earlier turn:
READY, no DONE, no alert. Snapshots, resync and restarts then agree,
since nothing is latched.
- A later answer after a failed rest (a retry that got through, or a new
prompt answered within one poll) is told as `rest: "answered"`: the
tracker marks the work itself and the pane goes straight to DONE. No
synthetic RUN is sent first, since that would make the turn 0 ms long
and the default "long turn" alerts would skip it. With no turn start
seen, push and the browser treat the length as unknown and alert.
- The failed rest is kept with what the pane read when its session could
not be told for a while, so a retry that answered meanwhile is still a
finish.
- Kept conservative (a missed DONE rather than a made-up or repeated
one): the remembered failure belongs to its session file; only an answer
timestamped after the failure counts as its retry; the tracker accepts
it only while nothing (work, DONE) was told of the pane since the failed
rest; an error read from a file found anew (restart, `/resume`) is
`failed-before` and leaves a DONE kept from another turn alone.
- Known limit: a retry's DONE that never read RUN has an unknown length
to both alert paths, so the default "long turn" setting alerts it even
after a short retry. Carrying the real duration needs turn timing in the
pane-status frames; left for a follow-up.
- Other agents are untouched: the flag is only set by OmO's session
reader and `observe` defaults to `failed = false`.
Not changed: `stopReason: "aborted"` (user interrupt) still ends as
DONE; an OmO pane whose session file cannot be told keeps herdr's status
as before. Wrong DONEs already saved in `completions.json` clear on the
pane's next turn or focus.
## How it was verified
- New unit tests: the issue's sequence reads `working, idle, working,
idle`, READY in every snapshot after; a retry's answer then reads
`done`; a normal turn afterwards still reads `working, done`. A DONE
kept from an earlier turn is dropped by a failed rest, and a retry that
answered while the session could not be told is still DONE.
- `bun run typecheck`, `HERDR_TEST_MODE=unit bun test` for `completion`,
`omo-status`, `omo`, `omo-tasks`.
- `bun run check run bun test ./server/omo.contract.test.ts
./server/omo-prompt.contract.test.ts` on an isolated herdr: 10 pass.
- `bun run check fast`: all ok (2261 pass, 0 fail).
- Alerts: web push (`server/push.ts`) and the browser's alerts
(`shouldNotifyStatus` in `shared/notify-policy.ts`) fire only on
`done`/`blocked`, so a READY rest alerts nothing on either path.
Closes #687
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* fix(chat): match Windows Codex store paths and refuse ambiguous Claude stores (#714)
## What was reproduced
No Windows PC here, so every case was reproduced with Win32 path
semantics (`path.win32`) and an in-memory SQLite on Linux:
- **#587 case 1 (prefixed `CODEX_HOME`)**:
`path.win32.relative("\\?\D:\codex\sessions",
"D:\codex\sessions\2026\x.jsonl")` returns the absolute file path, so
the containment check in `codexRolloutPath` rejected a rollout inside
the store whenever only one side kept `\\?\`. The reverse (plain root,
prefixed file) fails the same way.
- **#587 case 2 (case-different cwd)**: `cwd IN ('D:\work\app',
'\\?\D:\work\app')` returns no row for a stored `\\?\d:\work\app`.
- **#586**: with records of the same live process in `~/.claude` and
`~/.claude-copy`, `windowsProcessStore` returned `null`, the same as "no
record", and `claudeTranscriptPath` fell back to `~/.claude`.
## Already fixed on main (#518)
- Point 1 (rollout paths with `\\?\`) and the prefix spelling of point 2
(`cwd IN (?, ?)` with both spellings) were fixed in c04c760 (#582,
released in v0.4.1). Point 3 (picker resume) is #717.
## What changed
- `server/codex.ts`:
- `rolloutInsideStore(root, file, paths)` strips `\\?\` on **both**
sides before `relative()`.
- A Windows cwd is one directory under the `\\?\` prefix and either
drive-letter case, and is otherwise compared exactly.
- `storedCwdCondition(cwd)` gives the three thread queries a binary `cwd
IN (…)` over those spellings, so the queries stay on Codex's `(archived,
cwd, …)` index.
- `sameDirectory` / `codexPeersIn` apply the same rule to the peer
checks (`claimedByOtherPanes`, `resumedElsewhere`, the short-thread
guard), so the peer checks and the queries agree.
- POSIX cwds are exact as before.
- `server/claude-store.ts`:
- `windowsProcessStore` throws `AmbiguousClaudeStore` when more than one
store holds the process's live PID record. Like a miss, it is not
cached; "no record" still returns `null` and keeps the default-store
fallback.
- Stores are told apart by `claudeStoreKey`: volume + file id where
there is one, else the path without `\\?\` and without case. So
`CLAUDE_CONFIG_DIR=\\?\C:\…\.claude`, a junction or a link to
`~/.claude` is one store, not an ambiguous pair.
- `server/conversation.ts` (one hunk): `AmbiguousClaudeStore` becomes
`ConversationUnavailable("no_session_id")` instead of `~/.claude`.
### Scope narrowed after review
A cwd stored in another letter case **past the drive letter**
(`\\?\d:\Work\app` for `D:\work\app`) is deliberately **not** matched:
the chat stays unavailable.
- A Windows directory can be case-sensitive (`app` and `App` are then
two directories), and only the file system can tell.
- The identity-based version took a stat per spelling, a discovery scan
with a limit and a cache. Two adversarial review passes showed each of
those could fail open: another directory's or another pane's
conversation.
- Fail-closed was chosen instead. #587 therefore stays open for that
case.
## How it was verified
- Unit cases, each failing before its fix:
- `server/codex.test.ts`: a prefixed root or rollout counts as inside
the store, with traversal, another drive and UNC cases.
- `server/codex.test.ts`: the drive-letter and prefix spellings are
found; a case-sensitive sibling with 33 newer threads neither joins nor
crowds out the 32; POSIX case stays exact; the query plan is a SEARCH on
`(archived=? AND cwd=?)`; peers follow the same rule.
- `server/claude-store.test.ts`: two stores that both claim the process
reject with `AmbiguousClaudeStore`; a `CLAUDE_CONFIG_DIR` link to
`~/.claude` is one store; `claudeStoreKey` drops `\\?\` and case without
a file id.
- `bun run typecheck`, `HERDR_TEST_MODE=unit bun test
./server/codex.test.ts ./server/claude-store.test.ts
./server/codex-processes.test.ts ./server/conversation.test.ts
./server/windows-native.test.ts`, `bun run check fast` (all ok).
- Not run on a Windows PC.
## Issues
Closes #586
Refs #587: case 1 and the drive-letter part of case 2 are fixed; a cwd
differing in case past the drive letter stays unavailable by design (see
above).
Refs #518: points 1–2 fixed in #582, point 3 in #717.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(server): use pane.get for single-pane lookups (#724)
## Change
Fix audit finding H6 by replacing whole-session snapshots used only to
inspect one pane.
- Add a typed `paneGet(paneId, socketPath?)` wrapper over `pane.get`
with `{ pane_id }`, returning the generated `PaneInfo`; reuse it for
scroll metadata.
- Use it for pane context, chat input checks, collapsed Codex queue
checks, image uploads, push title lookup and on-demand Claude
background-task discovery.
- Keep the previous optional-pane behavior in input checks. Herdr 0.9.3
returns `pane_not_found` with the existing message for unknown panes, so
the same HTTP helpers and envelopes continue to apply.
- Add unknown-pane HTTP contract cases for commands and files; the
existing image unknown-pane contract also passes.
`terminalInfoFor` still needs snapshot layout rects. Conversation
readers still need peers to disambiguate Devin, Codex and OmO transcript
identities, so `server/conversation.ts` remains unchanged. Global
collector, completion normalization, agent name allocation and cache
recovery retain their snapshots.
No HTTP/WS shapes, endpoints, browser source, generated types or agent
settings changed.
## Validation
All checks ran in
`/home/devswha/workspace/herdr-web-ui-wt/refactor-pane-get-lookups`.
- PASS: `bun install`.
- PASS: `bun run check fast` - workflow syntax, generated types,
typecheck and build passed; 2,285 unit tests passed, 7 existing
platform/fixture skips, 0 failures.
- PASS: `bun run check run bun test --timeout 15000
./server/api.contract.test.ts ./server/submit.contract.test.ts
./server/pending-input.contract.test.ts ./server/herdr/client.test.ts` -
180 tests passed, 0 failures.
- PASS: `bun run check run bun test --timeout 15000
./server/codex.contract.test.ts ./server/omp.contract.test.ts
./server/opencode.contract.test.ts
./server/claude-session.contract.test.ts` - 19 tests passed, 0 failures.
- PASS: `git diff --check`.
The source-state verification reports and full logs are preserved under:
- `/tmp/parity-qa-H6/fast/report.json`
- `/tmp/parity-qa-H6/contracts/report.json`
- `/tmp/parity-qa-H6/conversation-contracts/report.json`
### Isolated real-surface smoke
Built client served on port 7457 with a temporary app state directory
and isolated Herdr session `herdr-web-ui-qa-H6`.
- Opened the real chat pane in Playwright/Chromium at 1280 x 800 and 390
x 844; screenshots visually inspected and phone page had no horizontal
overflow.
- Sent a command from web chat while Herdr's TUI was concurrently
attached in a PTY; exact output appeared in the TUI and socket output
event.
- Typed a command through the Herdr TUI; the same output appeared in web
chat.
- `curl -sS -i -X POST http://127.0.0.1:7457/api/pane/image -H
'Content-Type: application/json' --data
'{"pane_id":"w1:p1","content_type":"image/png","data_base64":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="}'`
returned HTTP 200 and a project-local PNG path.
- No Playwright page errors.
Screenshots:
- `/tmp/parity-qa-H6/desktop-chat.png`
- `/tmp/parity-qa-H6/phone-chat.png`
- `/tmp/parity-qa-H6/phone-reverse.png`
Supporting evidence:
- `/tmp/parity-qa-H6/image-curl.txt`
- `/tmp/parity-qa-H6/tui-web-to-herdr.txt`
- `/tmp/parity-qa-H6/tui-herdr-to-web.txt`
- `/tmp/parity-qa-H6/output-events.json`
- `/tmp/parity-qa-H6/pane-list.json`
- `/tmp/parity-qa-H6/session-snapshot.json`
- `/tmp/parity-qa-H6/pane-get-schema.json`
- `/tmp/parity-qa-H6/qa-report.md`
Herdr RPCs used: `pane.get` for converted metadata reads; retained
`session.snapshot` for layout/global/peer needs. QA used
`workspace.create`, `pane.read`, `pane.send_text`/`pane.send_keys`,
`agent.prompt` with the existing shell fallback, `events.subscribe` with
exact output matches, plus CLI `pane list` and `api snapshot`.
### Cleanup and limits
Browsers, web service, both owned TUI processes, QA Herdr session and
persistent service monitors stopped. `server stop` exited 0; the
temporary QA root and uploads were removed. Evidence under
`/tmp/parity-qa-H6/` was retained. Successful check runners cleaned
their isolated sessions.
No browser source changed, so no UI regression suite was required beyond
the requested smoke. The large `server/index.ts` LSP diagnostic request
timed out, but full TypeScript checking passed. Optional gate-reviewer
spawning was unavailable at the task nesting limit; the implementer
reviewed the full diff. No product failures or open behavior issues
found.
* feat(settings): show herdr's agent integrations read-only (#722)
## What changed
- **`GET /api/integrations`** wraps herdr's `integration.list` RPC
(herdr 0.9.3) via `integrationList()` in `server/herdr/client.ts`, one
connection per call. It answers `{ integrations: AgentIntegration[] }`
in herdr's order; any other method is `400 method_not_allowed`.
`AgentIntegration` / `IntegrationsResponse` are hand-written in
`shared/protocol.ts` (the saved schema predates the RPC; `state` is
widened with `(string & {})`), with the route catalogued there.
- **Remote PCs:** it is a plain GET like `/api/agents`, so it joins the
bridge path list and `MACHINE_PROXY_PATH` (`machines.test.ts` asserts
it). The client is `fetchIntegrations(machineId)` in `src/lib/api.ts`,
bound in `useMachineApi()`. The Settings dialog renders outside a
`MachineContext`, so the page shows the local PC; a remote PC's list is
reachable through the proxy but no UI picks a PC yet.
- **Settings → Agent integrations** (new page, `Plug` icon, before
About) built from `SettingsGroup`/`SettingsRow`: agents found on the
PC's PATH first, then a "Not found on this PC" group. Installed rows
show a check. Missing or outdated rows show `herdr integration install
<label>` (the label is the CLI name, e.g. `antigravity-cli`) with a Copy
button using `copyText` and the select-the-text fallback.
- **Read-only by design:** nothing calls
`integration.install`/`integration.uninstall`, and there is no
install/uninstall button; installing writes into an agent's own
hooks/settings, which stays the user's call in a terminal.
- i18n ko/ja/zh, demo answer in `site/demo/transport.ts` (one of each
state), the page added to `scripts/settings-pages-demo-regression.ts`,
CHANGELOG line under Unreleased → Added.
## How it was checked
- `bun run check fast` → PASS on 81becc7 (workflow syntax, generated
types, typecheck, build, unit tests: 2285 pass, 0 fail)
- `bun run check run bun test --timeout 15000
./server/api.contract.test.ts` → PASS (99 pass, 0 fail) (new case:
`/api/integrations` equals `integration.list` from the isolated herdr;
`claude` present; states in the known set; POST refused)
- `bun scripts/settings-pages-demo-regression.ts` → PASS (every page
incl. Agent integrations fits 390px/320px in en/ko/ja/zh; Back/history
cases pass)
- Manual QA, isolated session `herdr-web-ui-qa-D` with
`HOME`/`XDG_CONFIG_HOME` in a temp dir, web server on :7455, Playwright
Chromium at 1280px and 390px:
- before: 17 rows, all "Not installed", 0 install/uninstall buttons,
Copy put `herdr integration install pi` on the clipboard, no page
errors, no sideways scroll; only `GET /api/integrations` requests.
- acted in herdr: Enter in herdr's own TUI integrations tab (TUI client
attached to the same session) installed claude + codex; `herdr
integration install opencode` from the CLI; codex's temp hook marked v1
to get `outdated`.
- after reopening the page: claude/opencode "Installed" with a check,
codex "Installed, but older than this herdr" + its command, matching
`herdr integration status` (`claude: current (v10)`, `codex: outdated
(v1 < v8)`, `opencode: current (v13)`).
- Screenshots (local): `/tmp/parity-qa-D/before-desktop.png`,
`before-phone.png`, `after-desktop.png`, `after-phone.png`;
TUI/CLI/snapshot evidence beside them.
## herdr RPCs used
`integration.list` only.
## Known limits
- herdr's `integration.list` omits Letta (experimental in `herdr
integration status`), so the page does too.
- The page reads once per opening; it does not poll. Reopen it after
installing.
- The Settings page shows the local PC only (see Remote PCs above).
Remote PCs: an SSH PC whose bridge predates this endpoint shows a plain
"not offered yet" message; it gets the page with the next remote runtime
release (no bundle is published here).
* fix(tabs): preserve numeric custom names (#723)
## Change
A tab label is automatic only when empty or equal to its current place,
so a numeric custom label survives closing an earlier tab. Adds unit
coverage and an Unreleased Fixed entry.
## Validation
- bun run check fast — PASS (workflow syntax, generated types,
typecheck, build, unit suite).
- bun test ./src/lib/tabName.test.ts — PASS (5 tests).
- Isolated herdr session herdr-web-ui-qa-H5, 1280px Chromium capture:
/tmp/parity-qa-H5/tab-strip-desktop.png shows label 3 after the earlier
tab closed.
- herdr api schema --json confirms tab fields include label and stable
number, without a custom-label marker. Repro used tab.create, tab.list,
tab.close, and pane.split; remaining state was w1:t2, number:2, label:3.
No known limitations.
* feat(palette): list and run herdr plugin actions (#720)
## What changed
The web UI can list and run the manifest actions of the herdr plugins
installed on a PC, as herdr's own TUI does.
- **Server**: `GET /api/plugins/actions` (`plugin.list` +
`plugin.action.list`, grouped by plugin with its enabled state; actions
for another platform are left out, command lines stay on the server) and
`POST /api/plugin/action { plugin_id, action_id, pane_id? }`
(`plugin.action.invoke`). Wrappers in `server/herdr/client.ts`, shapes
and route catalogue in `shared/protocol.ts`, both routes added to the
remote-PC proxy allowlist and to the bridge-token paths, answers in
`site/demo/transport.ts`.
- **Palette**: a **Plugin actions** group runs an action against the
selected pane. The row shows "Running…" and the palette stays open until
the server answers; a failed command says why above the list
(`role="alert"`); a pane the action opened and focused is selected.
- **Settings → About**: a small read-only "herdr plugins" group (name,
version, actions, Enabled/Disabled). It is not drawn when no plugin is
installed.
## What herdr does, measured on 0.9.3
- `plugin.action.invoke` takes each `PluginInvocationContext` field as
given and fills a missing one from **its own focus**, not from the pane
named: `focused_pane_id` alone came back with the focused workspace's ID
and label, and an unknown pane ID was accepted. So the server validates
the pane against a snapshot and sends workspace, tab, labels, cwd, agent
and status itself.
- Invoke answers while the command is still `running`. The route polls
`plugin.log.list` for that log entry for up to 5 s, so the browser gets
`succeeded`, `failed` (exit code and the end of stderr) or `running`.
- **Plugin panes**: the invoke answer names no pane, but an action can
open one by calling `herdr plugin pane open` (this repository's own
`phone` action does). `opened_pane_id` is a pane that did not exist
before the invoke and that herdr focuses after it; the palette selects
it.
- `plugin.action.list` includes a disabled plugin's actions and other
platforms' actions; herdr refuses them with `plugin_disabled` /
`platform_unsupported`. The palette offers neither.
herdr RPCs used: `plugin.list`, `plugin.action.list`,
`plugin.action.invoke`, `plugin.log.list`, `session.snapshot`.
## How it was checked
| Command | Result |
|---|---|
| `bun run check fast` | PASS (2288 pass, 7 skip, 0 fail; typecheck
included) |
| `bun run check run bun test --timeout 15000
./server/plugins.contract.test.ts` | PASS (8 pass, 0 fail) |
| `bun run check run bun scripts/file-viewer-regression.ts` (drives the
palette over Settings and a preview) | PASS |
| Browser + TUI QA driver on an isolated herdr session (own
`XDG_CONFIG_HOME`), Chromium via playwright-core | PASS, 18 of 18 checks
|
The contract test links a plugin from a temp directory into the run's
own herdr config and unlinks it afterwards. herdr keeps linked plugins
per user, not per session, so the suite skips itself when the herdr
under test uses the user's own `~/.config/herdr` (the shared
`herdr-web-ui-test` session); `bun run check` and CI give it a config
directory of its own.
QA evidence (local paths on the build PC, not committed):
`/tmp/parity-qa-C/`
- `01-desktop-palette-group.png`, `02-desktop-in-flight.png`,
`04-desktop-failed-action.png`, `05-desktop-opened-pane-selected.png`,
`06-desktop-settings-plugins.png` at 1280px
- `09-phone-palette-group.png`, `10-phone-in-flight.png`,
`11-phone-failed-action.png` at 390px
- Web → herdr: `marker-from-web.json` (the context the plugin command
received: the web's selected pane, `invocation_source: herdr-web-ui`),
`tui-02-after-web-marker.txt` and
`socket-02-pane-list-after-web-marker.json` (the pane the action
renamed), `tui-03-after-web-open-board.txt` and
`socket-03-snapshot-after-web-open-board.json` (the plugin pane,
focused)
- herdr → web: `herdr-side-invoke.json` +
`07-desktop-after-herdr-side-invoke.png` (a plugin pane opened from
herdr's CLI appears in the web),
`08-desktop-plugin-disabled-in-herdr.png` (a plugin disabled in herdr
leaves the palette)
- `qa-results.txt`, `qa-driver.ts`, `qa-plugin.toml`
## Known limits
- Where an action's pane opens is the plugin's choice: `herdr plugin
pane open` uses herdr's focus unless the command passes `--target-pane`
or `--workspace`. The context is correct either way (`HERDR_PANE_ID` and
the context JSON name the web's pane).
- A popup-placement plugin pane has no pane ID and shows only in a herdr
TUI client; the web cannot select it.
- An action whose only context is `selection` is not offered: the web
has no selected text to hand over. Without a selected pane only `global`
(or context-less) actions are offered.
- A command still running after 5 s is reported as `running` and the
palette closes; its later failure is only in `herdr plugin log list`.
- Installing, enabling, disabling and removing plugins stay with herdr.
A remote PC on an older bridge answers 404 and the group is simply
absent there.
- Remote PCs get the routes with the next `remote-vN` bundle; none was
built here.
* fix(collector): refresh sessions on herdr lifecycle events (#725)
## Change
Fix audit H4: structural changes made in herdr's TUI now invalidate the
web roster/layout through the existing `session-changed` frame instead
of waiting for five-second polls.
- Subscribe to workspace, worktree, tab, pane and layout lifecycle
invalidations.
- Coalesce event bursts in the existing 500 ms window; snapshot retry
timers cannot delay browser invalidation.
- Reconcile authoritative pane IDs, closing and reopening the complete
per-pane status subscription after moves.
- Retain browser, machine and collector polls and recovery/health
checks. No UI or wire-shape changes.
## Validation
- PASS `bun install` (Bun 1.4.2).
- PASS `bun run check fast`: workflow syntax, generated types,
typecheck, build and unit suite, exit 0.
- PASS `bun run check run bun test --timeout 15000
./server/collector.contract.test.ts`: 3 tests, 0 failures; socket
`workspace.rename`, `tab.rename`, `pane.zoom` each produced WS
`session-changed` within 591-601 ms. Isolated herdr 0.9.3.
- PASS `bun run check run --build bun scripts/ui-regression.ts`: built
real client and existing desktop/mobile regression suite, exit 0. Only
lock refusals were retried, in bounded 30-second intervals; no test
failure was retried.
- PASS concurrent real browser and TUI on isolated `herdr-web-ui-qa-H4`,
bridge port 7456, temporary state: web tab rename appeared in TUI and
socket snapshot; TUI workspace rename painted in 703 ms; TUI tab rename
push arrived in 546 ms and painted in 650 ms.
- APPROVE read-only Codex `gpt-6.1-sol`, `xhigh`, fast-tier review under
an 8 GB memory cap: no must-fix findings
(`/tmp/parity-qa-H4/review.txt`).
## Evidence
Local evidence root: `/tmp/parity-qa-H4/`.
Screenshots: `desktop-web-to-tui.png`,
`desktop-tui-workspace-rename.png`, `desktop-tui-tab-rename.png`
(1280x800), `phone-tui-tab-rename.png`, `phone-tui-workspace-rename.png`
(390x844).
Timings/state/receipts: `timings.json`, `snapshot.json`,
`pane-list.json`, `tui-screen.txt`, `tui.typescript`, `qa-report.md`,
`cleanup.txt`, `fast-report.json`, `contract-report.json`,
`browser-report.json`.
QA setup RPCs: `workspace.create` and `tab.create` with `focus:false`,
`workspace.focus`, `session.snapshot`. Reverse actions used actual TUI
Ctrl+B, Shift+W/Shift+T rename dialogs, not API substitutes.
## Limits and cleanup
Screenshots are local paths, not hosted attachments. The first QA bridge
startup used an unset process environment and briefly reached the
default socket; attach was refused as `attach_held`, without takeover.
It was stopped; all retained captures came from a fresh owned browser
context against the explicitly isolated socket.
The browser, port-7456 bridge, TUI and QA herdr were stopped; temporary
QA state was removed. Both check runners cleaned their owned sessions.
No merge, main push or tag. Remote bridge deployment and GitHub CI are
not claimed by these local checks.
* feat(terminal): add native pane history search (#729)
## Change
Add **Find in terminal** to the header's More menu and Mod+Shift+F. The
find bar searches
herdr's stored pane history, shows the native match ordinal/count and
supports next/previous,
Enter, Shift+Enter and Escape. Keyboard focus remains in Find across
Chat transitions and
deferred upload completion; find text is never delivered to the
terminal.
`POST /api/pane/find` owns the HTTP contract in `shared/protocol.ts`,
validates requests and
uses fresh herdr RPC connections. Remote-PC routing is allowlisted and
machine-bound. Add
ko/ja/zh translations, explicit demo unavailability, a changelog entry
and native/browser
regression coverage. xterm scrollback remains zero; native attach
redraws display the match.
## Validation
- `bun install` — PASS.
- `bun run check fast` — PASS: workflow syntax, generated-type
freshness, typecheck, build
and unit tests. Report:
`node_modules/.cache/check/fb6199c3-9334-4764-b95e-97dbf943080b/report.json`.
- `bun run check run bun test --timeout 15000
./server/find.contract.test.ts` — PASS,
8 tests: found, next/previous, not found, punctuation, malformed query,
unknown pane,
navigation validation and a real native revision race with no stale
scroll. Report:
`node_modules/.cache/check/73624152-7932-4eb7-a90c-8f068b246879/report.json`.
- `bun run check run --build bun scripts/ui-regression.ts` — PASS,
including the new
find regression, real keyboard focus, Chat-to-Find, touch-menu access
and a delayed real
image upload. Report:
`node_modules/.cache/check/bef52677-5a76-4903-9af4-9973b4c51b86/report.json`.
- `UI_EVIDENCE_DIR=/tmp/parity-qa-G bun run check run --build bun
scripts/pane-find-regression.ts`
— PASS as a focused check before the full suite.
- Codex `gpt-6.1-sol`, xhigh, fast tier — final APPROVE after focus/race
findings were
fixed and regression-tested.
`/tmp/parity-qa-G/codex-review-upload-followup.txt`.
Final manual QA used owned `herdr-web-ui-qa-G`, port 7454 and a
temporary app state directory.
Print `FIND_QA_G_UNIQUE` followed by 320 lines: GUI search finds 1 of 1
and displays it.
A concurrently attached herdr TUI shows the same match; typing
`TUI_REVERSE_QA_G` there
updates the web terminal. Desktop 1280px and phone 390px captures in
both themes:
- `/tmp/parity-qa-G/desktop-find.png`
- `/tmp/parity-qa-G/desktop-find-light.png`
- `/tmp/parity-qa-G/phone-find.png`
- `/tmp/parity-qa-G/phone-find-light.png`
RPC/TUI evidence: `/tmp/parity-qa-G/reviewed-evidence.json`,
`reviewed-tui-found.txt`,
`reviewed-tui-reverse.txt`, `native-pane-list.txt`,
`native-snapshot.json`, `history-read.json`
and `search-schema.json`. Cleanup receipts:
`/tmp/parity-qa-G/cleanup-receipts.json`.
## Native API and limits
Use `pane.get`, `pane.copy_motion`, `pane.copy_search` and
`pane.scroll`. Copy mode ne…
Part of #518: this fixes its first two points (the
\\?\prefix on rollout paths and onthreads.cwd). The third point, a pane resumed through thecodex resumepicker, is left for a separate PR, so this does not close the issue.Change
On Windows, Codex stores its paths in
state_5.sqlitewith the\\?\prefix, as Rust's canonical Windows paths carry it. herdr reports the plain form. Measured on Windows 10 with herdr 0.9.3 and codex-cli 0.160.1, against a real store: 504 of 505threadsrows have a prefixedcwd, and 13 have a prefixedrollout_path.That breaks both ways the server finds a Codex pane's rollout:
rollout_pathis\\?\C:\Users\…\.codex\sessions\…\rollout-….jsonl.realpathSynckeeps the prefix, sorelative()against the store'sC:\Users\…\.codex\sessionsputs the file outside the store, andcodexRolloutPathrefuses it.D:\workand the row says\\?\D:\work, soWHERE cwd = ?finds none of them.The pane then ends at
no_session_pathand Chat shows Conversation unavailable.The fix, in
server/codex.ts:withoutVerbatimPrefixturns\\?\D:\xintoD:\xand\\?\UNC\host\shareinto\\host\share. Any other path is returned unchanged.codexRolloutPathstrips the prefix beforerealpathSync, then applies the same checks as before: canonical path, inside<codexHome>/sessions, a.jsonlfile, and not a subagent's rollout. A prefixed path outside the store is still refused.newerThreadsand the candidate list incodexTranscriptPath) matchcwd IN (?, ?)with both stored forms fromstoredCwds. A POSIX cwd is passed twice unchanged, so on Linux and macOS the queries return the same rows as before.Nothing new reaches the browser, there is no contract change and no new dependency.
Validation
Live, on Windows (a real Codex pane in herdr, server environment as
plugin.ps1sets it). The pane was started ascodex resume <id>, and herdr reports that thread as itsagent_session:mainpaneConversationno_session_path→ Conversation unavailablecodex-transcript, 45 turnsLinux (Ubuntu 24.04 under WSL, Bun 1.4.2, Node 24, a clean clone of this branch):
bun run generate:types --check,bun run typecheckandbun run buildpass.bun run test:unit: 1717 pass, 6 skip, 0 fail.HERDR_TEST_MODE=unit bun testoncodex.test.ts,codex-processes.test.ts,conversation.test.ts,windows-native.test.tsandrelease-notes.test.ts: 148 pass, 5 skip, 0 fail.main'sserver/codex.ts, the newcodex-processes.test.tscase fails, and the other 29 in that file pass.Windows (Bun 1.4.2):
windows-native.test.ts: 5/5, including the new case.main'sserver/codex.ts, the new case fails withnull, the same error as the live pane.codex.test.ts: the new case passes. The one failure there is the existing symlink case (EPERMwithout Developer Mode), which fails the same way onmain.Tests added:
server/codex.test.ts:withoutVerbatimPrefixandstoredCwdsfor drive, UNC, POSIX, relative and device paths.server/codex-processes.test.ts: a pane whose cwd isD:\work\appfinds the thread Codex stored under\\?\D:\work\app. This runs on any OS, through the realcodexTranscriptPathwith the existing fake herdr socket.server/windows-native.test.ts(real Windows only, like the other tests there): a\\?\-prefixed rollout path inside the store resolves to the plain canonical path. A prefixed path outside the store, or one that climbs out with.., is still refused.I did not run
test:integrationortest:uilocally.🤖 Generated with Claude Code