diff --git a/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md new file mode 100644 index 00000000..2500cded --- /dev/null +++ b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md @@ -0,0 +1,686 @@ +# Post-merge regression audit — 2026-09-08 + +Scope: `git diff 3256e06a~1..HEAD` on `main`, covering PR #834 +(quota-independent provider switch), #836 (agent names), #838 (agent terminal +follow) and #840 (API key vault), plus whatever the four reported symptoms +turned out to actually be. + +Written after four parallel read-only investigations. Everything below is +either CONFIRMED (a concrete input traced to a wrong output) or explicitly +labelled as suspicion. Fixed items say which commit. Unfixed items say why. + +--- + +## The four reported symptoms + +### 1. "The key vault has no padding" — FIXED + +Two independent causes in `KeyVaultModal.tsx`. + +`DialogContent` carries **no padding by design**. The primitive owns layout +only; `DialogHeader` and `DialogFooter` each supply `px-4 py-3`, and every +other feature modal pads its own body — `ViewPromptsModal` and +`RewindToPromptModal` both use `min-h-0 flex-1 overflow-y-auto px-4 py-3`. +The vault modal used `DialogHeader` and then hung the provider list, key rows, +warning banners and the footnote directly off `DialogContent`, so only the +header was ever padded. + +Separately, the header passed `flex-row items-center justify-between gap-4` +**without `flex`**. `DialogHeader`'s base class list is a plain block, so all +four of those classes were inert and "Lock now" stacked under the description +instead of sitting opposite the title. + +Three further things were wrong inside the same blast radius and were fixed +with it: banners, key rows and the key form used `bg-surface`, which is the +dialog's own background, so they rendered as invisible fills; the two-column +row had `overflow-y-auto` on itself *and* both children, scrolling the +provider list away with the key list; and the banners used bare `rounded` +instead of the `rounded-slab` token. + +### 2. "All my Claude sessions came up as Error" — EXPLAINED, diagnosis FIXED + +**Not a regression from any of the four merges.** The spawn/recover/rehydrate +path is not in the diff range at all — `src/main/sessionManager.ts` and +`hook/persistence/rehydrate.ts` do not appear in `git diff --name-only +3256e06a~1..HEAD`. + +The incident journal for that launch +(`~/.config/agent-code/incidents/runs/2026-09-08T13-52-08-243Z-…`) shows +`rehydrate.complete ok:true` for both windows, then 20 × `wake.result +{ok:false, code:"start-failed", durationMs:~80}` starting two minutes later as +agents were opened from Dispatch. Per session: `provider.start.end ok:true` in +15ms, then `gate.eval {gate:"terminal", reason:"exited", elapsedMs:0}`. +`feed-debug` shows `session exited code=1` on every retry. + +All six failing sessions resolved to three directories — +`~/Desktop/Development/bringdown-engine-{settlement,cli-first,fixtures}` — +none of which exist. `git worktree list` reports every `bringdown-engine-*` +worktree as prunable, and `~/Desktop/Development` has an mtime 33 minutes +before the app launched. + +**Mechanism:** node-pty performs the chdir *inside the forked child* +(`node_modules/node-pty/src/unix/pty.cc`: `if (chdir(cwd_) == -1) _exit(1)`). +A deleted directory therefore produces a successful PTY creation followed by +an immediate exit(1), and every layer above reports good news on the way up. +The failure only surfaced later as the readiness wait giving up with "agent +exited before it became ready for input (start-failed)", which named neither +the cause nor the folder. + +There was no cwd existence check anywhere on the spawn path. There is now, in +`spawnWithId` — the one funnel both `spawn()` and `recover()` pass through — +and `recover()` surfaces it as `Workspace folder is missing: `, marked +non-retryable so Dispatch stops re-spawning it. + +This will keep happening, because worktree-per-branch is the standing +workflow: **34 of the 73 persisted rows in the current `workspace.json` are +detached records.** The fix makes it legible, not impossible. + +### 3. "Jump to latest does not work for OpenCode" — DIAGNOSED, NOT FIXED + +Needs a decision. See "Open decisions" below. + +The command palette entry **does** work for OpenCode; the `End` key does not. +`jump-latest-message` is the app's only `context: 'feed'` binding +(`features/command-keybindings/defaults.ts:182`), and both halves of the +`feedFocused` predicate at `tile-tree/useKeybinds.ts:697-708` are false on an +OpenCode terminal pane: + +- `renderedAgentSurfaceIsVisible` delegates to `getEffectiveAgentSurface`, + which returns `'terminal'` unconditionally for `providerRuntime === + 'terminal'` (`agentDisplayMode.ts:70`). OpenCode Terminal can never be on + the rendered surface, so this is false 100% of the time for that provider. +- `isTextEditingTarget` returns true for any `HTMLTextAreaElement`, and + xterm's focused element is `.xterm-helper-textarea`. + +It hits any raw agent terminal, but Claude and Codex default to the rendered +feed, so OpenCode Terminal is the only session type that is *always* on the +excluded surface. + +PR #838 fixed the palette-admission half (dropped `renderedViewPolicy` from +`paneCommands.ts`) and the scroll half (`agentTerminalFollow.ts` consumes +`scrollToLatestRequest`). It never touched the keybinding router — the plan +doc lists five files and neither `useKeybinds.ts` nor `defaults.ts` is among +them, and the PR's own test file says "This task does not touch `when`." +Issue #837 promised the opposite: "Scope is Claude/Codex raw views and +OpenCode Terminal." + +`toggle-tail` already reaches raw terminals because it is `Alt+F` in +`context: 'global'`, which is the shape of one of the two options. + +**Unverified caveat:** if the OpenCode TUI runs on xterm's alternate screen, +`term.scrollToBottom()` is a no-op regardless of keybindings, and neither +route will ever work. `packages/opencode-headless/research/07-tui-and-screen- +surface.md:55` describes OpenTUI as rendering into the alternate screen, but +`vendor/in_progress/opencode/.../tui/app.tsx:73` sets `externalOutputMode: +"passthrough"`, which points the other way. **Disambiguate by scrolling that +pane with the mouse wheel:** if scrollback works, it is the normal buffer and +the keybinding gate is the whole story. + +### 4. Terminal-view rendering corruption — ONE OF THREE CAUSES FIXED + +Three independent contributors. Only one is from a recent merge. + +**(a) The agent-name row resized every pane after mount — FIXED, and this one +is ours.** A name arrives over IPC well after the pane mounts. +`AgentTitleHeader` keyed the row's *existence* on the name, so with Agent +names on, every named pane mounted with no row, fitted tall, told the PTY +`rows: N`, then grew a ~23px row when the reply landed, refitted, and sent +`rows: N-1` as a SIGWINCH into a live, mid-output TUI. Ink and the Claude Code +TUI erase a line count computed for the pre-resize frame, so the redraw lands +on the wrong region and leaves garbled fragments in the scrollback +permanently. New behaviour from `2b529300`: before it, the row existed only +for explicitly-titled agents, so an untitled agent never changed height. The +row now reserves its box from first paint whenever names are enabled for an +agent-kind session. + +**(b) The WebGL texture atlas — the likeliest dominant cause. NOT FIXED, +needs a decision.** See "Open decisions". + +`@xterm/addon-webgl` is pinned at `0.19.0` and was switched on for +`AgentTerminalLeaf` in `3b885068` on 2026-09-04. Issue #789 was filed the next +day describing near-identical symptoms, and its fix `082d845f` is an admitted +workaround whose own comment cites upstream xterm.js #5883/#6038 and ends +"The exact reported screenshot still needs user confirmation after +deployment." #789 was closed without that confirmation. + +Upstream #5883 (merged 2026-05-21) fixes two bugs, and its description is the +reported symptom verbatim: "garbled or garbage characters", "characters +sampling from incorrect texture pages", "ghost glyphs and misplaced text +during heavy streaming workloads". + +1. Stale texture binding after an atlas page merge: a fresh page replaces the + old one **at the same index**, and per-page version counters made + same-index swaps undetectable. +2. Stale vertex buffer after a mid-update merge: `_requestClearModel` was set + but never reset. + +The local workaround subscribes to `onAddTextureAtlasCanvas` / +`onRemoveTextureAtlasCanvas` and calls `invalidateTextureBindings()` + +`refresh()`. That can address bug 1's symptom but cannot replicate bug 2's fix +or the bounded retry loop upstream added inside `renderRows()`, because both +live below the addon's public surface. This is consistent with the corruption +still being reported. + +The fix ships in `@xterm/addon-webgl@0.20.0-beta.219` and later. **There is +still no stable 0.20.0** — latest published is `0.20.0-beta.300`. + +Character-level evidence favours this over any dimension mismatch: the +screenshot has junk substituted at single space positions +("Nowvgatheringcthe#recent-changeecontext0") and single characters replaced +mid-word ("the .uck? theretis like noopadding"). A PTY geometry mismatch +cannot punch one character out of the middle of a word — a TUI writes whole +strings. Stale texture coordinates render whatever glyph now occupies that +atlas slot, which is exactly why fragments of nearby text reappear scattered. + +**(c) Attach replay is parsed at 80×24 before the first fit — NOT FIXED, +pre-existing, tracked as #766.** `AgentTerminalOwnership.tsx:107-108` starts +`handoffComplete` false, so the first commit renders the leaf inside a +`hidden` div. `dimensionActive` is therefore false when the mount effect runs +and `scheduleFitAndResizeBackend()` is skipped, so `term.open(container)` +measures a hidden box and xterm stays at its default 80×24. `attachAgentPty` +resolves a few ms later and `forwarder.replay(...)` writes up to 512 KiB +immediately, while the first real `fit.fit()` only runs from a later +`requestAnimationFrame`. The raw PTY history is therefore normally parsed at +80 columns and then reflowed mid-parse. Absolute cursor-positioning sequences +in the replay land on the wrong cells. + +Minimal fix: gate `forwarder.replay(...)` on a "has been fitted at least once" +latch, queueing the buffer the way `backlogQueue` already does. Issue #766 +proposes removing the raw replay entirely, which subsumes it. + +--- + +## Also fixed in this branch + +These were found while auditing and are not among the reported symptoms. + +- **Seven agent-name reconciler tests had never passed.** + `createSettingsStorage`'s "storage unavailable" guard only caught a *thrown* + access. Under `happy-dom`, `localStorage` is defined-but-undefined, so the + assignment succeeded, a live adapter was returned, and every store write + died inside Zustand's persist middleware with "storage.setItem is not a + function". The agent-name reconciliation those tests were written to protect + has therefore never actually been verified. The guard now checks the object + is a usable `Storage`. + +- **The first insertion into any pane that needed waking always failed.** + `deliverTextToSession` and both prompt-template paths use the session-meta + object's identity as their "is my target still the same pane?" token across + the await. `ensureSessionLive` replaced that object on *every* wake, even a + no-op one, so the guard read "the pane changed" and cancelled. The retry + worked because no wake was needed by then. Now identity-preserving on a + genuine no-op. + +- **Plain terminal panes rendered no pane toast at all.** `TerminalLeaf` + called `showPaneToast` but never rendered `PaneToast`. #840 made terminal + panes valid insertion targets and routed all of that feature's feedback + through pane toasts, so on a shell pane a failed insertion produced nothing + observable — and the palette does not close on failure. + +- **A bulk provider-switch return destroyed the batch even when nothing + returned.** The modal is the only return affordance in the app. Arrival + compaction is on by default for large conversations and blocks returns for + minutes per pane, so a return attempted in that window returned zero agents + and binned the record. Now trimmed to the unreturned agents and cleared only + when empty. This module had no test file at all; it has one now. + +--- + +## Decisions taken + +### D1. Jump to Latest on OpenCode — RESOLVED, and it was not a keybinding + +The keybinding analysis was correct but beside the point. A follow-up +investigation established that an OpenCode Terminal pane runs OpenTUI with +`screenMode` defaulting to `alternate-screen`, and renders its transcript into +an internal scrollbox with its own paging keybinds. Nothing is ever evicted +upward, so `viewportY === baseY` always holds and `term.scrollToBottom()` — the +entire jump implementation — is a guaranteed no-op there. No chord could have +fixed it. + +`externalOutputMode: "passthrough"` in OpenCode's TUI setup looks like an +opt-out from alt-screen but is an orthogonal axis: it is the only value legal +with alternate-screen and is its default. + +Claude Code and Codex are different, which is why jump works for them in the +same pane type: both render inline on the normal buffer and push history into +real xterm scrollback (Codex's `insert_history_lines`; Claude's AlternateScreen +component is documented as being for transient ctrl-o style overlays only). + +**A fix was written and reverted on review.** Sending the TUI its own +scroll-to-bottom chord works — ESC + 0x07 is Ctrl+Alt+G, which OpenCode binds +to `messages_last`, and two independent passes confirmed both the binding and +the encoding. What cannot be guaranteed is what that chord MEANS on a given +machine. OpenCode keybinds are user-configurable, and a supported +configuration can move `messages_last` elsewhere and put `messages_undo` — +which aborts the session and reverts history — on Ctrl+Alt+G. A command +labelled "Jump to Latest Message" must not be able to do that, and documenting +the exposure is not mitigating it. + +Reading the effective binding would mean reimplementing OpenCode's config +loader: JSONC, global plus per-project plus every `.opencode` directory up to +home, variable substitution, a legacy migration, a win32 special case and +plugin-registered binds. That reimplementation would drift. + +The rebinding-immune route exists — OpenCode's server exposes +`POST /tui/execute-command`, whose alias table dispatches `session.last` below +the keybind layer — but it needs a known server URL, which means running +`opencode serve` and attaching the TUI to it rather than spawning the TUI +directly. That is a transport change for this runtime and the right place for +this to land. + +What DID ship: the command's description no longer claims a behaviour it +cannot deliver, and the limitation is recorded where the jump is implemented. + +The command's description, which claimed "in a raw terminal view this scrolls +the TUI viewport to the bottom", was false for OpenCode and is corrected. The +system test's alternate-screen case was vacuous — on the alt screen viewportY +and baseY are both 0, so its bottom assertion was `0 === 0` — and now proves +both mechanisms. + +**Not done, deliberately:** no new chord was added. `End` stays feed-only. The +palette command now works on every provider, which is what was actually broken, +and the keybinding router's exclusion of raw terminal surfaces is a separate +pre-existing design choice that `reservations.ts` documents on purpose. Note +for anyone revisiting it: `Alt+End` is NOT free — it is reserved for +directional split resize, because macOS turns Fn+Option+Arrow into it. `Alt+G` +was verified free across defaults, reservations, the blocked-chord sets, the +three provider TUIs, and macOS. + +### D2. WebGL — RESOLVED by turning it off + +Upgrading is not available: the fix ships only in +`@xterm/addon-webgl@0.20.0-beta.219+`, there is still no stable 0.20.0, and +that beta's peer dependency is `@xterm/xterm: ^6.1.0-beta.304`. Taking it would +drag the CORE terminal — the heart of every pane — onto a beta to fix one +renderer bug. That trade is clearly wrong, so the renderer is disabled behind a +single constant with the exact upgrade condition written next to it. + +The DOM renderer is xterm's default, is correct, and was already the tested +fallback every failure path in that file lands on. The two structural halves of +the perf work that introduced WebGL — routing raw PTY channels once per +renderer, and coalescing inline grid resizes — are untouched. VS Code ships the +same escape hatch for the same symptom class. + +The gate is a parameter defaulting to the constant rather than a hard-coded +read, so the fifteen existing cases keep proving the attach, fallback, +context-loss and atlas-repair machinery still works for the day it flips back. + +## Confirmed findings — fixed, except where noted + +Kept as the record of what each defect actually was, since the fixes are only +legible against it. Two carry a partial remainder, called out inline: + +- **Finding 1** (return forcing arrival compaction) is fixed for the CONSENT + half — the batch now records what the user agreed to and the return reuses + it. The other half of the recommendation, capping the arrival wait far below + 300s, is NOT done: `COMPACTION_TIMEOUT_MS` is still 300_000 and the progress + toasts still 305_000. Shortening it changes when a legitimately slow + compaction is abandoned, which is a product decision about a destructive + operation, not a cleanup. +- **Finding 4** (identity length bound) mirrors main's per-item limit through a + shared constant, so one over-long identity can no longer reject the batch. + Chunking the request is NOT done, and is not needed for the bug: the batch + cap is 10,000 identities and nothing else can now fail validation. + +1. **Return forces arrival compaction on without consent, locking N composers + for up to 5.5 minutes.** `bulkProviderSwitch.ts:61-67` hard-codes + `compactOnArrival: targetKind === 'claude'` on the return path. + `ComposerInput.tsx:241` disables the composer for the whole + `providerSwitchMessage` lifetime — a 30s readiness wait plus a 300s + compaction wait, with no cancel. The forward flow has an explicit checkbox + and a quota disclosure ("spends Claude quota, not Codex's"); one `Return + 20` click has neither. *Fix:* carry the forward batch's choice on + `ProviderSwitchBatch`, and cap the arrival wait far below 300s. + +2. **`switchingModel` is invisible to every close guard, so `/model` can + fan out twice over the same panes.** `BulkProviderSwitchModal.tsx` guards + on `busy` only (`:504-507`, `:518-520`, `:521-527`) while `runModelSwitch` + sets only `switchingModel` (`:451-483`), and the open-reset effect clears + both. Escape mid-loop, reopen, click again, and a second sequential loop + interleaves PTY writes on panes that already got `/model sonnet` — the race + the comment at `:463-466` says the loop exists to prevent. *Fix:* `const + locked = busy || switchingModel` in all three guards. + +3. **Every replaceSession / reload / resume / rewind permanently burns a name + from the 100-entry pool.** `replaceSession` registers the successor with no + `agentNameId` (deliberately), then awaits `killSessionBackendIfOwned` — a + full IPC round trip, so React flushes in between. The reconciler sees the + identity-less successor, claims one, and main allocates and **commits to + disk**, advancing `nextIndex`. Only then does `session.ts:1153` overwrite + with the carried identity. The allocated name is referenced by nothing and + is never recycled. The pool drains at the rate of *replacements*, so ~100 + reloads and every new agent is "Apollo 2". Multi-pane Undo Close burns up + to N−1 per restored tab. *Fix:* `pendingReplacementSuccessorsRef` already + exists; expose it through `refs` and have `claimMissingIdentities` skip + those ids. + +4. **One over-long `agentNameId` kills naming for the entire window.** + `reconcile.ts:30-31` validates identities as non-empty strings with no + upper bound; `main/agentNames/ipc.ts:19` is `z.string().min(1).max(200)`. + One identity over 200 chars in a user-editable `workspace.json` passes the + renderer check, enters the array, and `requestSchema.parse` rejects the + whole batch. `useAgentNameReconciler.ts:140` swallows it silently. This is + verbatim the failure `reconcile.ts:12-28` claims to have fixed — only the + type half of the contract was mirrored, not the length half. *Fix:* mirror + the length bound, and chunk the request. + +5. **The `{{key:…}}` "collect all failures" path is dead code.** + `keyReferences.ts:60-70` branches on `value === null`, but the production + resolver is `window.api.keyVaultResolveReference`, typed + `Promise`, and `VaultService.resolveReference` *throws* on every + failure mode. The first bad reference escapes the loop, the aggregation + never runs, and the documented "one error message tells the user everything + that needs fixing" is false. Behaviour is still safe — it aborts rather + than inserting a literal. *Fix:* `await resolve(ref).catch(() => null)`, or + delete the aggregation and its comment. + +6. **Bulk switch discards every failure message and shrink summary that + `cc0e908d` went out of its way to produce.** `bulkProviderSwitch.ts:132-143` + drops `result.message` and `result.shrinkSummary`. The poisoned-carrier + abort names the exact remedy and is replaced by `Switched 0 agents to + Claude (12 failed)`; the shrink disclosure, added so "no lossy step is + silent", prints as `12 raw`. The single-pane path surfaces both, and the + sibling function in the same file already argues the case ("A count alone + is unactionable"). + +7. **Mid-turn agents are reported as `failed`, contradicting the modal's own + footer**, which promises they "will be skipped until idle". The forward + summary has no `skipped` counter; the return path does. + +8. **"Ask once" confirmation is armed against a set that can grow.** + `runSwitch` arms on the first click and reads `matchingRows` live on the + second. Disarm handlers cover every manual change but not `agentRows` + changing on its own — confirm for 3 agents, a fourth goes idle, and 4 get + their history rewritten under a confirmation that named 3. *Fix:* snapshot + the confirmed `sessionId[]` when arming. + +9. **The agent-name registry grows without bound and rewrites the whole file + per allocation.** No prune path exists. At 10k assignments it rebuilds a + `Set` over every value per allocation and re-serialises the entire file on + the promise tail every window queues behind. Amplified directly by + finding 3. "Never recycle" only requires `nextIndex` to be monotonic, not + full retention. + +10. **The default quota-independent switch wakes the source provider it + provably never uses.** `providerSwitchCore.ts:310` calls + `ensureSessionLive` unconditionally, but `planWithoutSourceTurns` — the + default — never touches the live source; that is the entire point. For a + hibernated pane this is a real spawn plus a 30s readiness wait that + `replaceSession` then kills, serialised N times across a bulk switch. + +11. **`deliverTextToSession`'s refusal is an exception, not a result.** + `encodeTerminalPaste` throws from inside `paste()`, and + `DeliverTextResult` has no refusal variant. Combined with finding "plain + terminal panes render no toast" (now fixed), a multiline template into a + non-bracketed-paste program was a total silent no-op. *Fix:* add a + `refused` variant. + +12. **Unmatchable key references are pasted literally and silently.** + `KEY_REF_PATTERN` excludes `/` from both capture groups, so + `{{key:A/B/C}}` and `{{key:Provider}}` never match and survive + `body.replace` untouched — contradicting the header's "resolution aborts + loudly". + +13. **Secret sinks beyond the ones the disclosure comment names.** The comment + names drafts, scrollback and the provider transcript. It omits: + `useAutoSave.ts:97-99` writing `draftInput` to `workspace.json` in + plaintext; `draft.ts:109` keeping a cleared draft recoverable via undo; + `KeyVaultModal.tsx` putting revealed plaintext in a DOM `title=` + attribute; and — most significantly — the proxy dumps. + `packages/claude-code-headless/src/proxy/mitmAddon.py:490` base64-encodes + outbound request bodies into the proxy events JSONL. That directory is + already 3.9 GB on this machine and never rotates, so submitting a prompt + containing a vault key writes that key to disk in trivially recoverable + form somewhere nothing prunes. + +14. **`providerSwitchesInFlight.add` sits outside its try block.** A throw + from the intervening `setRuntimes` leaks the entry permanently in a + module-scoped Set, and that pane answers "Provider switch already in + progress" until the window reloads. Separately, if + `window.api.switchProvider` throws *synchronously*, + `.finally(unsubscribeProgress)` is never attached and the progress + listener survives for the life of the renderer. + `startArrivalCompaction` guards exactly this; the transaction path does + not. + +15. **Unhandled rejections at three `BulkProviderSwitchModal` call sites**, + and `runModelSwitch` has `try/finally` with no `catch` — an IPC rejection + aborts the batch mid-way and the `finally` still toasts success with + `failed === 0` for agents never touched. + +16. **`largestSourceEstimate` re-walks every matching pane's entry window on + every runtime tick.** The memo's own comment names `workspace.runtimes` as + "one of the highest-churn references in the app" and then depends on it — + O(rows × up to 2000 entries) per streaming tick while the modal is open. + +17. **The vault fails on Windows/Linux with a raw TypeError.** `main/index.ts` + wires `promptAuth` to `systemPreferences.promptTouchID` while + `canPromptAuth` correctly gates on darwin, but `ensureUnlocked` does not + consult the flag. Off-macOS the user gets "promptTouchID is not a + function" instead of the honest platform message. Fails closed, so + security is fine; the UX is not. + +18. **`tailEngagedRef` is not reset when the terminal detaches.** + `agentTerminalFollow.ts:109-114` disposes the marker but leaves the flag + true, so a remount under the same sessionId silently loses the saved + reading position on the next disengage. + +19. **The reconciler's stated invariant is false.** It claims "on failure no + dep changed at all — so a broken registry cannot become a hot loop", but + `identities` is a `useMemo` over `state` and `agentNameIdentities` returns + a fresh array every call. With an unreadable `agent-names.json` + (deliberately never cached), every focus change, title edit, pin, split + and close fires another failing IPC round trip forever, with no + user-visible signal. + +--- + +## Suspicions (stated as such, with what would confirm) + +- **`isLimitIdle`'s `turnStartedAt === null` branch may green-light a switch + over a live turn.** `turnStartedAt` is null in a fresh `emptyRuntime()`, and + with `processActive` true from an adopted backend the guard reads "parked" + and `replaceSession` kills a live turn. *Confirm by:* reloading the renderer + while a Claude pane whose recent transcript holds a `rate_limit` carrier is + mid-turn. Cheap hardening: refuse when `turnStartedAt === null && + processActive === true`. +- **`answerResumePrompt` presses UP a guessed number of times** (`selectedIndex + ?? 1`) and then Enter. If the cursor was elsewhere, Enter lands on a + different option; if that option discards history the imported transcript is + silently lost, the wait burns its full 300s, and the switch is still + reported successful. +- **The resume-prompt branch may be unable to satisfy its own wait** — it + waits for a compaction whose fingerprint differs from baseline, having just + answered "Resume from summary", which resumes *from* the existing carrier. +- **Nothing enforces one-identity-per-live-session.** `agentNameId` has no + runtime validation at any persistence boundary. The registry guarantees + identity→name uniqueness; nothing guarantees session→identity uniqueness. + Every in-app path was traced and none produces a duplicate, so the invariant + is simply unguarded against a copied or edited `workspace.json`. +- **The `__proto__` structured-clone round trip is untested.** + `registry.ts:218` creates a real own `__proto__` data property and the + renderer reads it back through Electron's structured clone, which is only + ever exercised with a mocked `window.api`. + +--- + +## Explicitly clean + +The most dangerous question asked — **can a multi-line template auto-submit?** +— gets a clean answer. `encodeTerminalPaste` normalises `\r\n?` to `\n` first, +then rejects `[\x00-\x08\x0b-\x1f\x7f-\x9f]`, which catches embedded `ESC` +(so `\x1b[201~` cannot be forged to close the bracket early) and every bare +`\r` that survived normalisation. Multiline is wrapped only when +`term.modes.bracketedPasteMode` is genuinely true, read live, and is refused +with a message otherwise. Nothing appends `\r`. + +Also verified clean: the `textPasteTarget` registry (no leaked registrations, +no cross-window collision, correct re-check after pane replacement); +`templateBusy` cannot deadlock (the palette unmounts and discards the ref); +the command-palette dep arrays; `keyReferences` regex and injection handling +(function replacer, so `$&`/`$1` in a secret are not interpreted; the two +placeholder grammars cannot collide); `main/ipc/keyVault.ts` (key ids +validated before any path join, unlock gate correctly fenced against a +concurrent lock, handlers registered once); the paste-target `isActive` +gating; and the agent-name hard parts — vocabulary exhaustion, cross-window +allocation serialisation with temp-file-plus-rename, Undo Close identity carry +on every path, the default-off toggle, and the prototype-pollution hardening, +which is genuinely thorough. + +--- + +## Where the coverage holes are + +Three of the highest-severity findings live in code with no adequate test, and +that is not a coincidence. + +- `deliverTextToSession.renderer.test.ts` stubs `ensureSessionLive` as a no-op + over frozen session objects, so the no-op-wake bug was structurally + untestable there. Its one cancel test flips the validity flag by hand — it + encodes the bug's shape as intended behaviour. +- `bulkProviderSwitch.ts` had **no test file at all**, and three findings live + in it, one of them two-click data loss. It has one now, covering the return + path's batch bookkeeping. +- `agentNameContinuity.renderer.test.tsx` mounts `useSessionActions` without + the reconciler, so the interim render that burns a name cannot occur. The + test asserts the final identity, which is correct; the leak is invisible + to it. +- Seven `reconciler.renderer.test.tsx` tests never ran green at all, so + nothing in agent-name reconciliation was actually verified before merge. + +## Pre-existing test failures on `main` (not caused by this branch) + +Verified by running the same files at `origin/main`: + +- `providers/shared/renderer/protocols/media/imageAttachment.test.ts` — cites + a missing local session file. This is open issue #839. +- `workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx` — a + `waitFor` timeout. +- `main/workflows/control.system.test.ts` — a 5s test timeout. + +--- + +## Deliberately deferred + +**Attach replay is parsed at 80x24 before the first fit.** Pre-existing, +structural, and tracked as issue #766. `AgentTerminalOwnership` renders the +leaf inside a `hidden` div on its first commit, so `dimensionActive` is false +when the mount effect runs and the initial fit is skipped; `term.open` then +measures a hidden box and xterm stays at its default 80x24. `attachAgentPty` +resolves a few milliseconds later and up to 512 KiB of raw PTY history is +replayed immediately, while the first real `fit()` only runs from a later +animation frame — reflowing the buffer mid-parse, so absolute cursor-positioning +sequences in the replay land on the wrong cells. + +NOT fixed here, on purpose. Every available shape of the fix has a real cost: + +- Deferring the whole attach until the first fit means a pane that is never + dimension-active never attaches, so a long-hidden pane can fall off the far + end of main's bounded 512 KiB buffer and lose output it would have kept. +- Deferring only the replay leaves live PTY chunks writing to the terminal + ahead of the buffered history, which produces the very interleaving the + change is meant to remove, unless the forwarder's replay latch is also + restructured. + +This is the most delicate path in the application, it cannot be exercised +without running the app, and it is not one of the four reported symptoms nor +caused by any of the four merges. Issue #766 proposes replacing the raw replay +with a serialized screen, which removes the ordering problem entirely rather +than sequencing around it. That is the right place for it. + +The two corruption causes that COULD be resolved safely — the WebGL atlas bug +and the agent-name row resizing every pane after mount — both were. + +--- + +## Later additions (same branch) + +Two more symptoms were reported while this branch was open, plus a two-agent +merge review. Recorded here because both turned out to share a root cause with +what was already being fixed. + +### The mouse wheel does nothing in an OpenCode terminal pane — DIAGNOSED, NOT FIXED + +Same family as the Jump to Latest bug and a different mechanism. **Nothing +swallows the wheel** — that was checked exhaustively: exactly one `wheel` +handler exists in the renderer and it belongs to the feed, there is no +capture-phase listener, no `attachCustomWheelEventHandler`, and the terminal +container's parent is `overflow-hidden` so nothing above can consume it. + +The modes that make the wheel work are thrown away. `attachAgentPty` replays +the trailing bytes of a CAPPED buffer that evicts the OLDEST data, and a TUI +writes its mode preamble exactly once at startup: `1049` (alternate screen), +`1000`/`1002`/`1003` (mouse button, drag, any-event including wheel) and `1006` +(SGR encoding). A TUI repainting at 60fps blows through the 512 KiB cap +quickly, so on any session with real activity that preamble is long gone before +a renderer attaches, and nothing reconstructs it. + +A freshly-constructed xterm therefore sits on the NORMAL buffer with no mouse +tracking while the application believes the opposite. xterm attaches its +wheel-to-mouse-report listener only when the application has asked for wheel +events, and its fallback path returns early on a normal buffer, so the wheel +reaches nobody. Meanwhile the TUI paints absolutely-addressed full frames that +never push a line into scrollback, so native viewport scrolling has nothing to +scroll either. The pane still LOOKS correct, because a full-screen repaint +renders identically on either buffer — which is why this was hard to see. + +OpenCode enables mouse capture by default and Agent Code never disables it; the +installed binary's renderer setup block contains exactly those DECSETs. Claude +Code and Codex are unaffected because they render inline and push real +scrollback. + +**A fix was written, reviewed, and reverted.** Tracking the five modes as +chunks pass and prepending the active ones ahead of the replay fails in two +ways that were reproduced against real xterm: + +1. **Current modes cannot precede historical output.** If the retained replay + contains bytes written on the normal buffer and only later switches to the + alternate screen, prefixing the current `1049h` moves that earlier content + onto the alternate buffer, where the following `1049l` discards it. The + correct input is the mode state at the replay's STARTING boundary, which + means feeding a tracker the bytes the cap EVICTS, not the bytes it keeps. +2. **A set of independent flags is not xterm's model.** Mouse protocols are + mutually exclusive — `1000h`, `1003h`, `1003l` leaves reporting disabled, + and `1003h` then `1000h` leaves VT200, not ANY. `ESC c` and the + `1047`/`1048`/`1049` aliases matter too. + +Doing it properly is a real terminal state machine and cannot be validated +without running the app. It belongs next to issue #766, which proposes +replacing the raw replay with a serialized screen and would remove the ordering +problem entirely rather than sequencing around it. + +### Pane paths were truncated from the wrong end + +Every pane in a workspace shares the leading path segments, so +`text-overflow: ellipsis` — which always clips the END — removed the only part +that identifies the agent. A narrow pane showed `…/Desktop/Developme…` for all +of them. `shortenCwd` was already producing the right string; only the clipping +end was wrong. + +### Merge review + +One Claude and one Codex reviewer, both read-only, both returned BLOCK, and +between them they found seven things worth fixing. The most valuable was one +both the author and the Claude reviewer reached independently: the wake's +no-op detection compared `builtInMcpDomains` by reference, and that array is +rebuilt on every wake, so the fix was inert for exactly the agent panes it +existed to protect and worked only for plain terminals. + +The rest: the identity-carry reservation leaked when spawn itself threw and its +gate was narrower than the carry predicate; the bulk modal's double-run lock +covered the close paths but not the run paths; the widened `{{key:…}}` pattern +captured ordinary JSX and broke templates that had always worked; catching +every resolver throw re-prompted for authentication once per reference; the +vault's provider column could not be scrolled to in a narrow window; and three +comments described the code beside them inaccurately. + +Both reviewers confirmed the OpenCode jump chord's default binding and byte +encoding are correct, and Codex reproduced the JSX and re-prompt regressions +against the real modules rather than reasoning about them. + +Three changes were WITHDRAWN rather than defended once the reviewer's +reproductions showed they were wrong: the terminal mode tracker, the OpenCode +jump chord, and a widened `{{key:…}}` pattern that captured ordinary JSX. Each +is written up above with the evidence that killed it, so the next attempt +starts from the failure instead of repeating it. diff --git a/src/main/agentNames/ipc.ts b/src/main/agentNames/ipc.ts index 78d83989..8b508128 100644 --- a/src/main/agentNames/ipc.ts +++ b/src/main/agentNames/ipc.ts @@ -2,6 +2,10 @@ import { ipcMain } from 'electron' import { join } from 'node:path' import { z } from 'zod' +import { + AGENT_NAME_IDENTITY_MAX_LENGTH, + AGENT_NAME_IDENTITY_REQUEST_MAX, +} from '@shared/types/agentNames.js' import { AgentNameRegistry } from '@main/agentNames/registry.js' import { STATE_DIR } from '@main/storage/paths.js' import { getBrowserWindow, windowIdFor } from '@main/window/windowRegistry.js' @@ -14,9 +18,12 @@ import { getBrowserWindow, windowIdFor } from '@main/window/windowRegistry.js' // address — so the two must not be able to take each other down. export const AGENT_NAMES_FILE = join(STATE_DIR, 'agent-names.json') -// Bounded so a malformed or hostile renderer cannot make the allocator walk a -// huge list under the serialization tail. 10k is far past any real workspace. -const requestSchema = z.array(z.string().min(1).max(200)).max(10_000) +// Both bounds come from the shared contract, because the renderer's +// `identityOf` has to refuse exactly what this refuses — see +// shared/types/agentNames.ts for the batch-wide failure that drift caused. +const requestSchema = z + .array(z.string().min(1).max(AGENT_NAME_IDENTITY_MAX_LENGTH)) + .max(AGENT_NAME_IDENTITY_REQUEST_MAX) /** * The ONLY consumer of AgentNameRegistry. diff --git a/src/main/agentNames/registry.ts b/src/main/agentNames/registry.ts index 47d030cd..f186198c 100644 --- a/src/main/agentNames/registry.ts +++ b/src/main/agentNames/registry.ts @@ -98,6 +98,30 @@ function adoptAssignments(source: Record): Record | undefined + // WHY one promise tail rather than a mutex or a per-identity lock: every // allocation reads the whole counter and writes the whole file, so the // critical section is the entire operation. Two windows starting agents in @@ -129,6 +153,7 @@ export class AgentNameRegistry { throw new Error(`Agent name registry at ${this.path} is unreadable; refusing to overwrite it`, { cause: error }) } this.state = { version: 1, nextIndex: 0, assignments: emptyAssignments() } + this.usedNames = new Set() return this.state } @@ -167,7 +192,13 @@ export class AgentNameRegistry { // name makes every later lookup ambiguous with no evidence for choosing // between them. const spoken = Object.values(assignments).map(normalizeAgentName) - if (new Set(spoken).size !== spoken.length) throw new Error('Two identities share one spoken name') + const distinct = new Set(spoken) + if (distinct.size !== spoken.length) throw new Error('Two identities share one spoken name') + // Assigned here rather than after the try, so a file that fails + // validation leaves BOTH `state` and this cache unset — the refusal has + // to be all-or-nothing or a later allocation would consult a set that + // describes a registry we refused to load. + this.usedNames = distinct } catch (error) { // WHY this is not cached and not repaired: leaving `this.state` unset // means every later call re-reads and re-fails, so the user gets a @@ -188,7 +219,14 @@ export class AgentNameRegistry { // we mutated in place, a write error would leave this process believing it // had published names that are not on disk. const draft: RegistryState = { ...loaded, assignments: adoptAssignments(loaded.assignments) } - const used = new Set(Object.values(draft.assignments).map(normalizeAgentName)) + // `load` guarantees this alongside `state`; the fallback keeps the type + // honest without pretending an unloaded registry is an empty one. + const used = this.usedNames ?? new Set() + // Every name this call adds, so a failed commit can undo its effect on the + // shared cache. The draft's assignments are already a copy and roll back + // for free; this set is not, because copying it per allocation is the cost + // being removed. + const added: string[] = [] let changed = false for (const identity of identities) { @@ -210,11 +248,24 @@ export class AgentNameRegistry { // would have to reconcile two divergent counters with no evidence. while (used.has(normalizeAgentName(name))) name = agentNameAt(draft.nextIndex++) draft.assignments[identity] = name - used.add(normalizeAgentName(name)) + const normalized = normalizeAgentName(name) + used.add(normalized) + added.push(normalized) changed = true } - if (changed) await this.commit(draft) + if (changed) { + try { + await this.commit(draft) + } catch (error) { + // Same contract as the draft copy above: a write that did not land + // must leave this process believing nothing was published. Leaving the + // names in the cache would make the retry skip past them and burn the + // vocabulary for allocations that never happened. + for (const name of added) used.delete(name) + throw error + } + } return Object.fromEntries(identities.map(identity => [identity, draft.assignments[identity]])) } diff --git a/src/main/index.ts b/src/main/index.ts index b2e97955..4d5d51cf 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -205,7 +205,22 @@ const caffeinateController = new CaffeinateController() // no async boot step — only the per-run unlock boolean. const vaultService = new VaultService({ store: createFileVaultStore(join(STATE_DIR, 'key-vault'), createSafeStorageCodec()), - promptAuth: reason => systemPreferences.promptTouchID(reason), + promptAuth: async reason => { + // WHY the platform check lives HERE and not in ensureUnlocked: the service + // deliberately attempts the prompt rather than pre-gating on + // canPromptAuth, because that flag once reported biometric capability only + // and pre-gating locked out every password-only Mac from the login-password + // path this feature promises. That reasoning is right for capability. It is + // NOT right for a platform that has no promptTouchID at all: there, + // "attempt it" meant calling undefined, and the user got a raw + // "systemPreferences.promptTouchID is not a function" TypeError instead of + // the honest platform message. Fails closed either way; only the wording + // was broken. + if (process.platform !== 'darwin' || typeof systemPreferences.promptTouchID !== 'function') { + throw new Error('The API key vault needs macOS Touch ID or login-password authentication, which this platform does not provide.') + } + await systemPreferences.promptTouchID(reason) + }, // canPromptTouchID checks biometrics, not user-presence/password auth. // Electron 43's promptTouchID uses SecAccessControlUserPresence; attempt // that supported macOS API and let rejection keep the vault locked. diff --git a/src/main/sessionManager.codexReplacement.test.ts b/src/main/sessionManager.codexReplacement.test.ts index 2eb9bdf9..633b2571 100644 --- a/src/main/sessionManager.codexReplacement.test.ts +++ b/src/main/sessionManager.codexReplacement.test.ts @@ -21,6 +21,21 @@ const { createSession, resolveTranscriptPath } = vi.hoisted(() => ({ resolveTranscriptPath: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree') + // that intentionally do not exist on disk. The real spawn-path guard stats the + // cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard + // itself, and sessionManager.recover.test.ts overrides this mock to prove the + // manager surfaces a missing folder. + MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } + }, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) + vi.mock('@providers/registry.main.js', () => ({ getMainProvider: () => ({ createSession, resolveTranscriptPath }), })) diff --git a/src/main/sessionManager.lifecycle.test.ts b/src/main/sessionManager.lifecycle.test.ts index 77e1b8b7..07a4e221 100644 --- a/src/main/sessionManager.lifecycle.test.ts +++ b/src/main/sessionManager.lifecycle.test.ts @@ -13,6 +13,21 @@ const { createSession, deliverPrompt } = vi.hoisted(() => ({ deliverPrompt: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree') + // that intentionally do not exist on disk. The real spawn-path guard stats the + // cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard + // itself, and sessionManager.recover.test.ts overrides this mock to prove the + // manager surfaces a missing folder. + MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } + }, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) + vi.mock('@providers/registry.main.js', () => ({ getMainProvider: () => ({ createSession, deliverPrompt }), })) diff --git a/src/main/sessionManager.recover.test.ts b/src/main/sessionManager.recover.test.ts index 6676008f..d415763b 100644 --- a/src/main/sessionManager.recover.test.ts +++ b/src/main/sessionManager.recover.test.ts @@ -12,6 +12,21 @@ const terminalControl = vi.hoisted(() => ({ stop: vi.fn(async (): Promise => {}), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree') + // that intentionally do not exist on disk. The real spawn-path guard stats the + // cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard + // itself, and the missing-folder case below overrides this mock to prove the + // manager surfaces it. + MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } + }, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) + vi.mock('@providers/registry.main.js', () => ({ getMainProvider: () => ({ createSession, deliverPrompt }), })) @@ -92,6 +107,42 @@ describe('SessionManager recover', () => { terminalControl.stop.mockClear() }) + it('reports the missing folder instead of a generic start failure', async () => { + // The 2026-09-08 regression report: six panes came up as ERROR after their + // git worktrees were deleted, and the only text the user ever saw was + // "agent exited before it became ready for input (start-failed)". node-pty + // chdirs inside the forked child, so without the spawn-path guard the PTY + // is created successfully and the process is dead microseconds later — + // recover() returns ok:true and the failure masquerades as a readiness + // timeout. This asserts the guard converts that into a message naming the + // folder, and marks it non-retryable so Dispatch stops re-spawning it. + const { assertWorkspaceDirectoryExists } = await import('@main/workspaceDirectory.js') + const { MissingWorkspaceDirectoryError } = await import('@main/workspaceDirectory.js') + vi.mocked(assertWorkspaceDirectoryExists).mockRejectedValueOnce( + new MissingWorkspaceDirectoryError('/tmp/deleted-worktree'), + ) + const { SessionManager } = await import('./sessionManager') + const manager = new SessionManager() + + const result = await manager.recover({ + sessionId: 'gone-folder-session', + kind: 'claude', + cwd: '/tmp/deleted-worktree', + }) + + expect(result).toMatchObject({ + ok: false, + code: 'start-failed', + retryable: false, + message: 'Workspace folder is missing: /tmp/deleted-worktree', + }) + // No backend was constructed, and nothing was left half-claimed: the guard + // runs before the spawn reservation precisely so a retry is not fenced out + // by a session that never existed. + expect(createSession).not.toHaveBeenCalled() + expect(manager.getBackendSnapshot('gone-folder-session')).toBeNull() + }) + it('adopts a matching live backend without constructing another provider', async () => { const { SessionManager } = await import('./sessionManager') const manager = new SessionManager() diff --git a/src/main/sessionManager.screenGate.test.ts b/src/main/sessionManager.screenGate.test.ts index 1d5c936a..c08b8635 100644 --- a/src/main/sessionManager.screenGate.test.ts +++ b/src/main/sessionManager.screenGate.test.ts @@ -6,6 +6,21 @@ const { createSession, createTerminalSession } = vi.hoisted(() => ({ createTerminalSession: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree') + // that intentionally do not exist on disk. The real spawn-path guard stats the + // cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard + // itself, and the missing-folder case below overrides this mock to prove the + // manager surfaces it. + MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } + }, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) + vi.mock('@providers/registry.main.js', () => ({ getMainProvider: () => ({ name: 'Claude', diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 90abc879..f400fec8 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -34,6 +34,10 @@ import type { SessionRecoverResult, } from '@shared/types/session.js' import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' +import { + MissingWorkspaceDirectoryError, + assertWorkspaceDirectoryExists, +} from '@main/workspaceDirectory.js' import { performanceService } from '@main/performance/PerformanceService.js' import { getToolPath, refreshToolchainFromState } from '@main/setup/toolchain.js' import { resolveToolPath } from '@main/setup/binaryResolver.js' @@ -1836,12 +1840,20 @@ export class SessionManager extends EventEmitter { return { ok: false, code: 'start-failed', - retryable: true, + // A deleted folder is not retryable: every retry re-runs the same + // stat and fails identically. The incident journal for 2026-09-08 + // shows the old path retrying these spawns on each Dispatch select, + // which is pure noise once the cause is known. + retryable: !(error instanceof MissingWorkspaceDirectoryError), // WHY the raw provider exception stays out of IPC: binary launch // errors can contain environment values, proxy URLs, or scoped MCP // tokens. Main records the typed code and internal performance error; // renderer receives one stable, actionable message with no payload. - message: 'Session failed to start. Check provider setup and retry.', + // The missing-directory case is the deliberate exception — its + // message is curated and its payload is a path the UI already shows. + message: error instanceof MissingWorkspaceDirectoryError + ? error.message + : 'Session failed to start. Check provider setup and retry.', } } finally { if (this.recoveriesInFlight.get(options.sessionId) === claim) { @@ -2422,6 +2434,13 @@ export class SessionManager extends EventEmitter { } const kind: SessionKind = options.kind ?? DEFAULT_PROVIDER const providerRuntime = resolveProviderRuntime(kind, options.providerRuntime) + // Fail here rather than in the forked child. See + // MissingWorkspaceDirectoryError for why a deleted cwd is otherwise + // invisible until the readiness wait times out. This runs before the + // spawn reservation so a missing folder leaves no half-claimed session + // behind, and it is in spawnWithId rather than in spawn()/recover() + // separately because this is the one funnel both of them pass through. + await assertWorkspaceDirectoryExists(options.cwd) if ( this.sessions.has(sessionId) || this.spawningSessionGenerations.has(sessionId) || diff --git a/src/main/sessionManager.wake.test.ts b/src/main/sessionManager.wake.test.ts index adbe3a7e..5188b515 100644 --- a/src/main/sessionManager.wake.test.ts +++ b/src/main/sessionManager.wake.test.ts @@ -6,6 +6,21 @@ const { createSession, createTerminalSession } = vi.hoisted(() => ({ createTerminalSession: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // These suites spawn into synthetic paths ('/tmp/project', '/recorded/worktree') + // that intentionally do not exist on disk. The real spawn-path guard stats the + // cwd, so it is stubbed here; workspaceDirectory.test.ts covers the guard + // itself, and sessionManager.recover.test.ts overrides this mock to prove the + // manager surfaces a missing folder. + MissingWorkspaceDirectoryError: class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } + }, + assertWorkspaceDirectoryExists: vi.fn(async () => {}), +})) + vi.mock('@providers/registry.main.js', () => ({ getMainProvider: () => ({ name: 'OpenCode', diff --git a/src/main/workspaceDirectory.test.ts b/src/main/workspaceDirectory.test.ts new file mode 100644 index 00000000..195b1ea6 --- /dev/null +++ b/src/main/workspaceDirectory.test.ts @@ -0,0 +1,77 @@ +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { + MissingWorkspaceDirectoryError, + assertWorkspaceDirectoryExists, +} from './workspaceDirectory.js' + +// WHY this suite touches the real filesystem instead of mocking node:fs: the +// entire point of the guard is to predict whether a forked child's chdir will +// succeed. A mocked stat would only assert that we call stat, which is the one +// thing that cannot regress silently. Real temp directories test the property +// we actually care about, including the symlink case that a naive lstat +// implementation would get wrong. +const made: string[] = [] + +async function tempDir(): Promise { + const dir = await mkdtemp(path.join(tmpdir(), 'agent-code-wsdir-')) + made.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(made.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) +}) + +describe('assertWorkspaceDirectoryExists', () => { + it('accepts a directory that exists', async () => { + const dir = await tempDir() + await expect(assertWorkspaceDirectoryExists(dir)).resolves.toBeUndefined() + }) + + it('names the resolved path when the directory is gone', async () => { + const dir = await tempDir() + const missing = path.join(dir, 'deleted-worktree') + await expect(assertWorkspaceDirectoryExists(missing)) + .rejects.toThrow(MissingWorkspaceDirectoryError) + // The path is the whole value of this error — a message that says only + // "folder is missing" reproduces the failure it was written to replace. + await expect(assertWorkspaceDirectoryExists(missing)) + .rejects.toThrow(missing) + }) + + it('rejects a path that exists but is a file', async () => { + const dir = await tempDir() + const file = path.join(dir, 'not-a-directory') + await writeFile(file, '') + await expect(assertWorkspaceDirectoryExists(file)) + .rejects.toThrow(MissingWorkspaceDirectoryError) + }) + + it('follows symlinks, accepting a link to a live directory', async () => { + const dir = await tempDir() + const target = path.join(dir, 'target') + const link = path.join(dir, 'link') + await mkdir(target) + await symlink(target, link) + await expect(assertWorkspaceDirectoryExists(link)).resolves.toBeUndefined() + }) + + it('rejects a dangling symlink, which is what a deleted worktree leaves behind', async () => { + const dir = await tempDir() + const link = path.join(dir, 'link') + await symlink(path.join(dir, 'never-existed'), link) + // stat (not lstat) is the deliberate choice: chdir follows the link too, + // so the child would fail here exactly as this guard does. + await expect(assertWorkspaceDirectoryExists(link)) + .rejects.toThrow(MissingWorkspaceDirectoryError) + }) + + it('resolves a relative path before reporting it', async () => { + await expect(assertWorkspaceDirectoryExists('definitely-not-here')) + .rejects.toThrow(path.resolve('definitely-not-here')) + }) +}) diff --git a/src/main/workspaceDirectory.ts b/src/main/workspaceDirectory.ts new file mode 100644 index 00000000..2678406e --- /dev/null +++ b/src/main/workspaceDirectory.ts @@ -0,0 +1,62 @@ +import { stat } from 'node:fs/promises' +import path from 'node:path' + +/** + * The session's workspace directory is gone from disk. + * + * WHY this is a typed error carrying a quotable message, when every other + * spawn failure is deliberately flattened to "Session failed to start. Check + * provider setup and retry.": that flattening exists to keep provider launch + * exceptions — which can contain environment values, proxy URLs and scoped + * MCP tokens — off IPC. A missing cwd carries none of that. The path is + * already rendered in the pane header, and this is the one start failure the + * user can actually act on. + */ +export class MissingWorkspaceDirectoryError extends Error { + constructor(readonly cwd: string) { + super(`Workspace folder is missing: ${cwd}`) + this.name = 'MissingWorkspaceDirectoryError' + } +} + +/** + * Resolve-and-stat a spawn cwd before any backend is started. + * + * WHY this check exists at all: node-pty performs the chdir INSIDE the forked + * child (`node_modules/node-pty/src/unix/pty.cc`: `if (chdir(cwd_) == -1) + * _exit(1)`). A deleted directory therefore produces a *successful* PTY + * creation followed by an immediate exit(1), and every layer above reports + * good news on the way up: the provider start resolves ok, `recover()` + * returns `ok: true` with disposition 'spawned', and the failure only + * surfaces much later as the readiness wait giving up with "Agent exited + * before it became ready for input (start-failed)". + * + * That is exactly what the 2026-09-08 incident journal shows for six sessions + * whose git worktrees had been deleted 33 minutes before the app launched. + * The message named neither the directory nor the cause, so the failure read + * as a mysterious provider fault. + * + * Trading one stat() per spawn for an error that says what is actually wrong + * is worth it here in particular, because worktree-per-branch is the standing + * workflow in this repo: panes routinely outlive the directory they were + * opened in. + * + * WHY stat and not lstat: symlinked worktrees are common, and the question is + * only whether the child's chdir will succeed. chdir follows symlinks, so a + * dangling symlink must fail here for the same reason a deleted directory + * does, and reporting one message for both is the correct answer. + */ +export async function assertWorkspaceDirectoryExists(cwd: string): Promise { + const resolved = path.resolve(cwd) + let isDirectory: boolean + try { + isDirectory = (await stat(resolved)).isDirectory() + } catch { + // Anything that makes the directory unusable from here (ENOENT, ENOTDIR, + // a dangling symlink, EACCES on a parent) would fail the child's chdir + // for the same practical reason. One message keeps the user pointed at + // the folder rather than at an errno. + throw new MissingWorkspaceDirectoryError(resolved) + } + if (!isDirectory) throw new MissingWorkspaceDirectoryError(resolved) +} diff --git a/src/renderer/src/app-state/settings/storage.renderer.test.ts b/src/renderer/src/app-state/settings/storage.renderer.test.ts new file mode 100644 index 00000000..68b6ea65 --- /dev/null +++ b/src/renderer/src/app-state/settings/storage.renderer.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createSettingsStorage } from './storage' + +// WHY this suite exists at all: +// +// createSettingsStorage's "storage is unavailable" guard only caught a THROWN +// access, which is the browser storage-denied case. It did not catch storage +// being merely absent — which is exactly what the renderer test environment +// provides. The adapter was therefore handed out live, and every store write +// died inside Zustand's persist middleware with "storage.setItem is not a +// function". Seven agent-name reconciler tests failed that way from the day +// they landed, and any future renderer test that writes a setting would have +// joined them. These cases pin the guard so that cannot silently return. + +const original = Object.getOwnPropertyDescriptor(window, 'localStorage') + +function stubStorage(value: unknown): void { + Object.defineProperty(window, 'localStorage', { configurable: true, value }) +} + +afterEach(() => { + if (original) Object.defineProperty(window, 'localStorage', original) + else Reflect.deleteProperty(window as unknown as Record, 'localStorage') +}) + +describe('createSettingsStorage', () => { + it('returns undefined when storage is absent, rather than a broken adapter', () => { + stubStorage(undefined) + expect(createSettingsStorage()).toBeUndefined() + }) + + it('returns undefined when storage exists but is not a usable Storage', () => { + // The shape that actually shipped: present enough to pass a truthiness + // check, useless the moment persist calls it. + stubStorage({}) + expect(createSettingsStorage()).toBeUndefined() + }) + + it('returns undefined when only some Storage methods are present', () => { + stubStorage({ getItem: () => null, setItem: () => {} }) + expect(createSettingsStorage()).toBeUndefined() + }) + + it('returns undefined when the access itself throws', () => { + Object.defineProperty(window, 'localStorage', { + configurable: true, + get() { throw new Error('denied by policy') }, + }) + expect(createSettingsStorage()).toBeUndefined() + }) + + it('writes through to a usable Storage', () => { + const setItem = vi.fn() + stubStorage({ getItem: vi.fn(() => null), setItem, removeItem: vi.fn() }) + const adapter = createSettingsStorage() + expect(adapter).toBeDefined() + + adapter?.setItem('agent-code', { version: 1, state: { settings: { a: 1 } } } as never) + expect(setItem).toHaveBeenCalledTimes(1) + expect(setItem.mock.calls[0][0]).toBe('agent-code') + }) + + it('skips a repeat write of the same settings object', () => { + // The whole reason this adapter exists instead of createJSONStorage: + // persist runs after EVERY action, including stream ticks that never + // touch settings. + const setItem = vi.fn() + stubStorage({ getItem: vi.fn(() => null), setItem, removeItem: vi.fn() }) + const adapter = createSettingsStorage() + const settings = { a: 1 } + + adapter?.setItem('agent-code', { version: 1, state: { settings } } as never) + adapter?.setItem('agent-code', { version: 1, state: { settings } } as never) + expect(setItem).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/app-state/settings/storage.ts b/src/renderer/src/app-state/settings/storage.ts index 7ade9f19..28eb2b38 100644 --- a/src/renderer/src/app-state/settings/storage.ts +++ b/src/renderer/src/app-state/settings/storage.ts @@ -21,7 +21,36 @@ type PersistedSettings = { settings: Settings } export function createSettingsStorage(): PersistStorage | undefined { let storage: Storage try { - storage = localStorage + // WHY this checks the METHODS and not just that the access succeeded: + // + // The original guard only caught a THROWN access, which is the browser + // "storage denied" case. It did not catch storage being merely absent. + // Under the renderer test environment (`happy-dom`) `localStorage` is + // defined-but-undefined, so the assignment succeeded, this function + // returned a live adapter, and every store write then died inside + // Zustand's persist middleware with "storage.setItem is not a function". + // That silently broke all seven agent-name reconciler tests the moment + // they touched `useAppStore.setState`, and it would do the same to any + // future renderer test that writes a setting. + // + // Returning undefined here is the documented contract for "storage is + // unavailable" — Zustand then skips persistence entirely, which is the + // correct behavior in a test or on a surface with no storage, rather than + // throwing on an unrelated store action. + // + // Deliberately the BARE global rather than `window.localStorage`: the + // migration suites drive this through `vi.stubGlobal('localStorage', …)`, + // which replaces the global binding and not a `window` property. In every + // real surface (Electron renderer, phone bundle) the two are the same + // object anyway. + const candidate: Storage | undefined = localStorage + if ( + !candidate || + typeof candidate.getItem !== 'function' || + typeof candidate.setItem !== 'function' || + typeof candidate.removeItem !== 'function' + ) return undefined + storage = candidate } catch { // Match Zustand's createJSONStorage behavior when storage is unavailable // during SSR/test bootstrap or denied by the browser environment. diff --git a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx index 1d30e52d..66ad4341 100644 --- a/src/renderer/src/features/command-palette/ui/CommandPalette.tsx +++ b/src/renderer/src/features/command-palette/ui/CommandPalette.tsx @@ -1343,6 +1343,10 @@ function OpenCommandPalette({ if (result.delivered) { workspace.showPaneToast(sessionId, `Inserted template: ${template.title}`) onClose() + } else if (result.reason === 'refused') { + // The terminal's own words: which rule the text broke. A multiline + // template into a program without bracketed paste is the common one. + workspace.showPaneToast(sessionId, result.message) } else if (result.reason === 'write-rejected') { workspace.showPaneToast(sessionId, 'Terminal write was rejected — pane is not ready') } else { @@ -1515,6 +1519,8 @@ function OpenCommandPalette({ if (result.delivered) { workspace.showPaneToast(sessionId, `Inserted template: ${fill.template.title}`) onClose() + } else if (result.reason === 'refused') { + workspace.showPaneToast(sessionId, result.message) } else if (result.reason === 'write-rejected') { workspace.showPaneToast(sessionId, 'Terminal write was rejected — pane is not ready') } else { diff --git a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx index 953b29cf..f7e3645d 100644 --- a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx +++ b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog' @@ -22,11 +23,24 @@ import type { KeyVaultKey, KeyVaultStatus } from '@shared/types/keyVault' // is what triggers Touch ID / the login-password prompt. // // DISCLOSURE (review finding): once a key is INSERTED, it leaves the -// vault's protection by design — a composer draft autosaves to -// workspace.json in plaintext until sent or cleared, and a PTY paste -// lands in scrollback/tmux history. Submitting the prompt puts the key -// in the provider transcript, plaintext, exactly like a manual paste. -// The vault encrypts STORAGE, not the prompt pipeline. +// vault's protection by design. The vault encrypts STORAGE, not the +// prompt pipeline. The full list of places an inserted key comes to +// rest, which is longer than this comment used to admit: +// +// 1. The composer draft, autosaved to workspace.json in PLAINTEXT +// (useAutoSave writes runtime.draftInput for every session with +// one), until the prompt is sent or the draft is cleared. +// 2. "Clear draft" does not end that — the cleared text is retained +// for undo (draft.ts's clearedDrafts), so it stays recoverable. +// 3. A PTY paste lands in xterm scrollback and in tmux history. +// 4. Submitting puts it in the provider transcript, plaintext, +// exactly like a manual paste. +// 5. If proxy streaming is on, the mitm addon base64-encodes outbound +// request bodies into the proxy events journal under +// ~/.config/agent-code/proxy, which nothing prunes or rotates. +// +// Anything meant to stay secret should be given to the agent by a path +// that does not go through a prompt at all. type KeyForm = { id?: string; name: string; value: string; note: string } | null @@ -189,6 +203,10 @@ export function KeyVaultModal() { if (result.delivered) { workspace.showPaneToast(sessionId, `Inserted key: ${key.name}`) closeKeyVault() + } else if (result.reason === 'refused') { + // The terminal's own words: which rule the text broke, not a generic + // failure. A key with a stray control byte is worth naming exactly. + setError(result.message) } else if (result.reason === 'write-rejected') { setError('Terminal write was rejected — pane is not ready; try again') } else { @@ -206,8 +224,12 @@ export function KeyVaultModal() { return ( { if (!nextOpen) closeKeyVault() }}> - - + + {/* `flex` has to accompany `flex-row` here. DialogHeader's base class + list is a plain block, so flex-row/items-center/justify-between + were all inert and "Lock now" stacked underneath the description + instead of sitting opposite the title. */} +
API Key Vault @@ -222,6 +244,7 @@ export function KeyVaultModal() { } - {status?.unlocked &&
-
- {providers.map(provider => ( - - ))} - setNewProviderName(e.target.value)} - onKeyDown={e => { if (e.key === 'Enter') addProvider() }} - /> -
+ {!status?.unlocked && ( + + )} -
- {!selectedProvider && ( -
Create a provider to get started.
- )} - {selectedProvider && ( - <> -
- {selectedProvider.name} -
- - - -
-
+ {/* Only the two columns scroll. Scrolling the ROW as well (as it did) + meant the provider list slid out of view with the key list and + produced a second nested scrollbar on the same axis. */} + {status?.unlocked && ( +
+ {/* `sm:shrink-0`, NOT `shrink-0`, and `min-h-0` on both axes' + worth of layout: below the sm breakpoint this row stacks as a + COLUMN, and a non-shrinking child there takes its full content + height. With the outer scroller removed, a long provider list + then grew past the dialog and the new overflow-hidden clipped + the bottom of it — including "New provider…" — with no + scrollbar able to reach it, because the column's own + overflow-y-auto cannot help an element that was never + constrained. Shrinking only in the row direction keeps the + fixed 12rem sidebar the wide layout wants. */} +
+ {providers.map(provider => ( + + ))} + setNewProviderName(e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') addProvider() }} + /> +
- {providerRename && providerRename.id === selectedProvider.id && ( -
- setProviderRename({ ...providerRename, name: e.target.value })} - onKeyDown={e => { - if (e.key !== 'Enter') return - const name = providerRename.name.trim() - if (!name) return - const id = providerRename.id - setProviderRename(null) - void runVaultAction(() => window.api.keyVaultRenameProvider(id, name)) - }} - /> - - -
+
+ {!selectedProvider && ( +
Create a provider to get started.
)} - - {selectedKeys.map(key => ( -
-
- {key.name} - ••••{key.hint} - {revealed.has(key.id) && ( - - {revealed.get(key.id)} - - )} - - - - - - + > + Rename + + + +
- {key.note &&
{key.note}
} -
- ))} - {keyForm && ( -
-
{keyForm.id ? 'Edit key' : 'New key'}
- setKeyForm({ ...keyForm, name: e.target.value })} - /> - setKeyForm({ ...keyForm, value: e.target.value })} - /> - setKeyForm({ ...keyForm, note: e.target.value })} - /> -
- - -
-
+ {providerRename && providerRename.id === selectedProvider.id && ( +
+ setProviderRename({ ...providerRename, name: e.target.value })} + onKeyDown={e => { + if (e.key !== 'Enter') return + const name = providerRename.name.trim() + if (!name) return + const id = providerRename.id + setProviderRename(null) + void runVaultAction(() => window.api.keyVaultRenameProvider(id, name)) + }} + /> + + +
+ )} + + {selectedKeys.map(key => ( +
+
+ {key.name} + ••••{key.hint} + {revealed.has(key.id) && ( + // WHY no `title` attribute here, and why it wraps + // instead of truncating: a `title` puts the + // plaintext secret into an OS tooltip and into the + // accessibility tree, where it is readable by + // anything that can query the DOM and is rendered + // by the window server outside this surface's + // control. Truncating created the need for that + // tooltip, so the fix is to let the value wrap and + // be fully visible in the row instead. + + {revealed.get(key.id)} + + )} + + + + + + +
+ {key.note &&
{key.note}
} +
+ ))} + + {keyForm && ( +
+
{keyForm.id ? 'Edit key' : 'New key'}
+ setKeyForm({ ...keyForm, name: e.target.value })} + /> + setKeyForm({ ...keyForm, value: e.target.value })} + /> + setKeyForm({ ...keyForm, note: e.target.value })} + /> +
+ + +
+
+ )} + )} - - )} -
+
+
+ )}
- } -
+ Reference keys from prompt templates with {'{{key:Provider/Key}}'} · Encrypted with the OS keyring · One unlock per app launch · An inserted key sits in the saved draft (or terminal scrollback) until sent or cleared -
+
) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.test.ts b/src/renderer/src/features/prompt-templates/keyReferences.test.ts index e071d721..34460db3 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.test.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.test.ts @@ -52,3 +52,78 @@ describe('resolveKeyReferences', () => { ).rejects.toThrow(/Brave\/a.*OpenAI\/b/) }) }) + +describe('what the grammar deliberately does NOT capture', () => { + it('leaves ordinary JSX alone, including a slash inside the value', () => { + // A widened pattern that tried to diagnose typos captured all of these and + // aborted insertion on templates that had always worked, with no way to + // escape the syntax — not even inside a code fence. Breaking text nobody + // intended as syntax is worse than failing to diagnose a typo. + const bodies = [ + '', + '', + '', + 'log line: {{key:A/B/C}} from the pasted output', + 'use {{key:Brave}} now', + ] + for (const body of bodies) { + expect(collectKeyReferences(body)).toEqual([]) + } + }) + + it('still resolves a real reference sitting next to such text', async () => { + await expect(resolveKeyReferences( + ' {{key:P/K}}', + async () => 'secret', + )).resolves.toBe(' secret') + }) + + it('leaves an ordinary variable placeholder alone', async () => { + // The two grammars cannot collide: the placeholder pattern is + // [A-Za-z0-9_]+ and cannot contain a colon. + await expect(resolveKeyReferences('{{goal}} {{key:P/K}}', async () => 'secret')) + .resolves.toBe('{{goal}} secret') + }) +}) + +describe('failing references', () => { + it('reports a thrown resolution failure with the service message', async () => { + // The production adapter is typed Promise and VaultService throws + // on every failure mode, so the `value === null` branch this module was + // built around is unreachable. Without a catch the first bad reference + // escaped and the failure list was never built. + await expect(resolveKeyReferences('{{key:P/bad}}', async () => { + throw new Error('No such key') + })).rejects.toThrow('No such key') + }) + + it('asks the vault ONCE when the first reference fails', async () => { + // The reason the loop stops rather than continuing: one failure mode is a + // cancelled unlock, and ensureUnlocked clears its pending promise on + // cancellation — so carrying on to the next reference opens another OS + // authentication prompt. Three references would ask three times. A user + // who just cancelled must not be re-asked. + const asked: string[] = [] + const resolve = async (ref: { providerName: string; keyName: string }) => { + asked.push(`${ref.providerName}/${ref.keyName}`) + throw new Error('Vault unlock was cancelled') + } + + await expect(resolveKeyReferences('{{key:P/a}} {{key:P/b}} {{key:P/c}}', resolve)) + .rejects.toThrow('Vault unlock was cancelled') + expect(asked).toEqual(['P/a']) + }) + + it('keeps the service message, which distinguishes locked from missing', async () => { + await expect(resolveKeyReferences('{{key:P/K}}', async () => { + throw new Error('Vault is locked') + })).rejects.toThrow('Vault is locked') + }) + + it('does not interpret a substitution pattern inside a secret', async () => { + // A function replacer, never a string: `$&` in a secret would otherwise be + // expanded into the matched text. + await expect(resolveKeyReferences('{{key:P/K}}', async () => 'sk-$&-$1')) + .resolves.toBe('sk-$&-$1') + }) +}) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.ts b/src/renderer/src/features/prompt-templates/keyReferences.ts index 1d46da22..99937226 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.ts @@ -31,14 +31,50 @@ export function prepareTemplateText( export type KeyReference = { providerName: string; keyName: string } +/** + * A well-formed vault reference: exactly one separator, neither half + * containing another. + * + * WHY this pattern is NARROW, after an attempt to widen it was reverted: + * + * The narrowness has a real cost — a typo like `{{key:Brave}}` or + * `{{key:A/B/C}}` matches nothing and is pasted into the prompt verbatim, + * which is the silent failure the header paragraph above says this grammar + * exists to avoid. Widening it to catch those looked obviously right and was + * wrong: `{{key:…}}` with arbitrary contents is ordinary text. + * `` is everyday JSX. So is + * `` and `{{key: /abc/}}`, which a + * separator requirement does not exclude either — a slash does not establish + * that the author meant a vault reference. Templates that had always worked + * began aborting insertion outright, with no way to escape the syntax, not + * even inside a code fence. + * + * Breaking text nobody intended as syntax is worse than failing to diagnose a + * typo. Catching typos properly needs an escape mechanism this grammar does + * not have, so it is not attempted here. + */ const KEY_REF_PATTERN = /\{\{\s*key:([^/{}]+?)\/([^/{}]+?)\s*\}\}/g +function referenceKey(ref: KeyReference): string { + // NUL separator so two different (provider, key) pairs cannot produce the + // same map key. Unreachable through this pattern, which forbids a slash in + // either half, but the map is also written from the replace callback. + return `${ref.providerName}\u0000${ref.keyName}` +} + +function parseAll(body: string): KeyReference[] { + return [...body.matchAll(KEY_REF_PATTERN)].map(match => ({ + providerName: match[1].trim(), + keyName: match[2].trim(), + })) +} + +/** Well-formed references, in first-appearance order, deduped. */ export function collectKeyReferences(body: string): KeyReference[] { const seen = new Set() const ordered: KeyReference[] = [] - for (const match of body.matchAll(KEY_REF_PATTERN)) { - const ref = { providerName: match[1].trim(), keyName: match[2].trim() } - const dedupeKey = `${ref.providerName}\u0000${ref.keyName}` + for (const ref of parseAll(body)) { + const dedupeKey = referenceKey(ref) if (seen.has(dedupeKey)) continue seen.add(dedupeKey) ordered.push(ref) @@ -54,21 +90,53 @@ export async function resolveKeyReferences( // String.replace callback cannot await, and each ref may cross the // vault gate), collecting ALL failures so one error message tells the // user everything that needs fixing. - const refs = collectKeyReferences(body) const values = new Map() const failures: string[] = [] - for (const ref of refs) { - const value = await resolve(ref) + const seen = new Set() + + for (const ref of parseAll(body)) { + const dedupeKey = referenceKey(ref) + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + + // WHY the call is wrapped AND why the loop stops on the first throw: + // + // Wrapped, because the aggregation this function is built around was dead + // code in production. The real adapter is + // `window.api.keyVaultResolveReference`, typed `Promise`, and + // VaultService throws on every failure mode, so `value === null` was + // unreachable and the first bad reference escaped the loop entirely. + // + // Stopping, because continuing is worse than the bug it fixed. One of + // those failure modes is a CANCELLED unlock, and `ensureUnlocked` clears + // its pending promise on cancellation — so carrying on to the next + // reference opens another OS authentication prompt. A template with three + // references asked once before, and would ask three times if this + // continued. A user who just cancelled must not be re-asked. + // + // Whatever was collected before the failure is still reported alongside + // it, and the service's own message is kept because it is written for + // direct display and distinguishes "no such key" from "vault is locked". + let value: string | null + try { + value = await resolve(ref) + } catch (error) { + const detail = error instanceof Error && error.message.length > 0 ? error.message : null + failures.push(`{{key:${ref.providerName}/${ref.keyName}}}${detail ? ` (${detail})` : ''}`) + break + } if (value === null || value.length === 0) { failures.push(`{{key:${ref.providerName}/${ref.keyName}}}`) continue } - values.set(`${ref.providerName}\u0000${ref.keyName}`, value) + values.set(dedupeKey, value) } + if (failures.length > 0) { throw new Error(`Unresolved key reference: ${failures.join(', ')}`) } - return body.replace(KEY_REF_PATTERN, (_match, rawProvider: string, rawKey: string) => { - return values.get(`${rawProvider.trim()}\u0000${rawKey.trim()}`) ?? '' - }) + // A function replacer, never a string: `$&` or `$1` inside a SECRET would + // otherwise be interpreted as a substitution pattern. + return body.replace(KEY_REF_PATTERN, (_match, rawProvider: string, rawKey: string) => + values.get(referenceKey({ providerName: rawProvider.trim(), keyName: rawKey.trim() })) ?? '') } diff --git a/src/renderer/src/features/session-text-delivery/deliverTextToSession.ts b/src/renderer/src/features/session-text-delivery/deliverTextToSession.ts index d1e1d0e6..e1bf54b6 100644 --- a/src/renderer/src/features/session-text-delivery/deliverTextToSession.ts +++ b/src/renderer/src/features/session-text-delivery/deliverTextToSession.ts @@ -25,17 +25,42 @@ import type { Workspace } from '@renderer/workspace/workspaceStore' // not enabled bracketed paste receives single-line text only. Multiline // input is refused rather than risking accidental command execution. // -// SECRET DISCLOSURE (review finding): text inserted into a composer -// draft follows the SAME persistence rules as any draft — it autosaves -// to workspace.json in plaintext until sent or cleared. Inserted into a -// PTY it lands in scrollback (and tmux history). That is inherent to -// insertion itself, not this helper: once the user submits, the secret -// reaches the provider transcript in plaintext anyway. The VAULT's -// encryption contract covers storage, not the prompt pipeline. +// SECRET DISCLOSURE (review finding): inserted text follows the SAME +// persistence rules as anything else the user could have typed, which is +// inherent to insertion rather than to this helper. The complete list of +// resting places, because an earlier version of this comment named only +// the first and third and that understated it: +// +// 1. A composer draft autosaves to workspace.json in PLAINTEXT +// (useAutoSave persists runtime.draftInput for every session that +// has one), until the prompt is sent or the draft is cleared. +// 2. Clearing the draft does not end that: the cleared text is kept +// for undo (draft.ts's clearedDrafts), so it stays recoverable. +// 3. A PTY paste lands in xterm scrollback and in tmux history. +// 4. Submitting puts it in the provider transcript, plaintext. +// 5. With proxy streaming on, the mitm addon base64-encodes outbound +// request bodies into the proxy events journal under +// ~/.config/agent-code/proxy, which nothing prunes or rotates. +// +// The VAULT's encryption contract covers storage, not the prompt +// pipeline, and this helper is the boundary where that stops applying. export type DeliverTextResult = | { delivered: true; surface: 'composer' | 'pty' } | { delivered: false; reason: 'no-session' | 'write-rejected' | 'cancelled' } + /** + * The terminal refused this exact text, with a reason worth showing. + * + * WHY a result variant and not the exception it used to be: `encodeTerminalPaste` + * throws for control characters and for multiline input into a program that + * has not enabled bracketed paste. That threw straight out of a function + * whose result union claimed to describe every outcome, so whether the user + * ever saw the reason depended on each caller happening to wrap the call in + * try/catch — and on a plain terminal pane, which rendered no toast at all + * until recently, a multiline template was a total silent no-op. A refusal + * is an ANSWER, so it is returned like one. + */ + | { delivered: false; reason: 'refused'; message: string } export async function deliverTextToSession( workspace: Workspace, @@ -95,8 +120,22 @@ async function deliverPtyText( if (isCurrent && !isCurrent()) return { delivered: false, reason: 'cancelled' } // Do not follow a changed/mirrored target after wake or retry into a // replacement process. A refused write keeps the picker open for the user. - if (getTerminalPasteTarget(sessionId) === target && await target.paste(text)) { - return { delivered: true, surface: 'pty' } + if (getTerminalPasteTarget(sessionId) !== target) return { delivered: false, reason: 'write-rejected' } + let accepted: boolean + try { + accepted = await target.paste(text) + } catch (error) { + // encodeTerminalPaste's refusals are written for direct display and say + // exactly which rule the text broke. + return { + delivered: false, + reason: 'refused', + message: error instanceof Error && error.message.length > 0 + ? error.message + : 'The terminal refused this text.', + } } - return { delivered: false, reason: 'write-rejected' } + return accepted + ? { delivered: true, surface: 'pty' } + : { delivered: false, reason: 'write-rejected' } } diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index a453cd80..57667750 100644 --- a/src/renderer/src/features/workspace/commands/paneCommands.ts +++ b/src/renderer/src/features/workspace/commands/paneCommands.ts @@ -561,7 +561,7 @@ export const paneCommands: CommandDef[] = [ category: 'navigate', surface: 'session', title: 'Jump to Latest Message', - description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only — in a raw terminal view this scrolls the TUI viewport to the bottom.', + description: '**What it does:** Scrolls to the **latest agent message**.\n\n**Use when:** You are far up in the feed and want to return to the bottom.\n\n**Notes:** Agent panes only. In a raw terminal view this scrolls the xterm viewport, which works for providers that render inline (Claude, Codex). A TUI that owns its own transcript on the alternate screen (OpenCode Terminal) keeps its history outside the viewport, so there is nothing here to scroll — use that TUI\'s own scroll keys.', // NO `renderedViewPolicy` — the xterm viewport answers jump requests too // (useAgentTerminalFollow); gating on a rendered feed would hide this on // the surface where returning to the bottom is most often needed. diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index 511da178..47d68d58 100644 --- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx @@ -159,6 +159,26 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { const [projectFilter, setProjectFilter] = useState('') const [busy, setBusy] = useState(false) const [switchingModel, setSwitchingModel] = useState(false) + // Refs so the open-reset effect can see the live values without depending on + // them — depending on them would re-run the reset the moment a loop ended. + const busyRef = useRef(busy) + busyRef.current = busy + const switchingModelRef = useRef(switchingModel) + switchingModelRef.current = switchingModel + /** + * The exact panes the source-compaction confirmation named. + * + * WHY a snapshot instead of re-reading `matchingRows` on the confirmed + * click: the confirmation is armed on the first click and consumed on the + * second, and every MANUAL way of changing the set (direction, scope, + * project toggle, select-all, clear, filter) disarms it. But `matchingRows` + * is a memo over live workspace state and changes on its own — an agent + * spawning, or one that was mid-turn going idle, silently joins the set + * between the two clicks. The user then confirms "compact 3 agents on Codex + * first" and four agents get their live history rewritten. Confirming a set + * has to mean confirming THAT set. + */ + const [confirmedSessionIds, setConfirmedSessionIds] = useState(null) // Live status (mid-turn) can change while the modal sits open. Re-tick every // 10s so the ⚠ skip count stays honest, matching Close Old Agents. const [nowTick, setNowTick] = useState(0) @@ -209,10 +229,19 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { useEffect(() => { if (!open) return + // Never reset over a loop that is still running. `open` is store state, so + // `closeBulkProviderSwitch` from a command or another surface can close + // this modal even while the guards above refuse Escape; reopening then ran + // this effect and cleared the very flag that single-flights the loop, + // letting a second batch start against the same panes. Both flags are + // cleared by their own `finally`, so skipping the reset here cannot strand + // them. + if (busyRef.current || switchingModelRef.current) return setDirectionChoice(null) setCompactOnArrivalChoice(null) setCompactOnSourceChoice(false) setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setScopeMode('all') setSelectedProjects(new Set()) setProjectFilter('') @@ -336,22 +365,49 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // Biggest conversation in the batch, not the sum: arrival compaction runs // per agent, so the question is whether ANY single pane will land oversized. // - // The runtimes map is swapped for a frozen empty one while closed so the memo - // is not merely early-returning on a dependency that changes on every runtime - // tick — it stops being invalidated at all. This modal is a permanently - // mounted surface (see the usage hook gate above), and `workspace.runtimes` - // is one of the highest-churn references in the app. - const runtimesForEstimate = open ? workspace.runtimes : NO_RUNTIMES + // This modal is a permanently mounted surface (see the usage hook gate + // above), and `workspace.runtimes` is one of the highest-churn references in + // the app — which is what makes the dependency choice below load-bearing + // rather than cosmetic. + // WHY this is keyed on the session ids and reads runtimes through a REF: + // + // Gating on `open` stopped the walk while the modal is closed, but while it + // is OPEN the number is derived from `workspace.runtimes` — which the + // comment directly above names "one of the highest-churn references in the + // app". Every streaming tick from any pane re-walked up to 2000 entries for + // every matching row, to produce a single threshold comparison that defaults + // one checkbox. + // + // Depending on `matchingRows` instead is NOT sufficient and a first attempt + // that did only that changed nothing: `agentRows` lists `workspace.runtimes` + // in its own deps, so `matchingRows` is a fresh array on every tick too. The + // dependency has to be the thing that actually decides the answer, which is + // WHICH sessions match — not the identity of the array listing them, and not + // the identity of the runtime map. Joining the ids is O(rows) per render + // against O(rows x entries) for the walk. + // + // The estimate can therefore lag a pane's growth within one open session. + // That is acceptable and deliberate: it only picks the default state of a + // checkbox the user can see and toggle, and it is re-derived every time the + // modal opens. + const runtimesRef = useRef(workspace.runtimes) + runtimesRef.current = workspace.runtimes + const matchingRowsRef = useRef(matchingRows) + matchingRowsRef.current = matchingRows + const matchingSessionKey = matchingRows.map(row => row.sessionId).join('\u0000') const largestSourceEstimate = useMemo(() => { + if (!open) return 0 + const runtimes = runtimesRef.current let largest = 0 - for (const row of matchingRows) { - const runtime = runtimesForEstimate[row.sessionId] + for (const row of matchingRowsRef.current) { + const runtime = runtimes[row.sessionId] if (!runtime) continue const estimate = estimateLiveEntriesBytes(runtime.entries) if (estimate > largest) largest = estimate } return largest - }, [matchingRows, runtimesForEstimate]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchingSessionKey, open]) // Claude is the only target with a compaction the renderer can drive // (compactAfterSwitch reports every other kind as a no-op), so the checkbox @@ -383,6 +439,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // in the confirmation. const toggleProject = useCallback((cwd: string) => { setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setSelectedProjects(prev => { const next = new Set(prev) if (next.has(cwd)) next.delete(cwd) @@ -393,16 +450,19 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { const selectAllProjects = useCallback(() => { setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setSelectedProjects(new Set(projects.map(project => project.cwd))) }, [projects]) const clearProjects = useCallback(() => { setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setSelectedProjects(new Set()) }, []) const changeScopeMode = useCallback((mode: ScopeMode) => { setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setScopeMode(mode) }, []) @@ -411,11 +471,16 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // scope it hides rows the user is choosing from; disarm either way rather // than depend on that distinction staying true. setSourceConfirmArmed(false) + setConfirmedSessionIds(null) setProjectFilter(value) }, []) const runSwitch = useCallback(async () => { - if (matchingRows.length === 0 || busy) return + // `locked`, not `busy`: runModelSwitch sets only `switchingModel`, so + // guarding on `busy` alone let a Switch start on top of an in-flight + // /model fan-out over the same panes — the very race the sequential loop + // exists to prevent, and the one the close guards below already cover. + if (matchingRows.length === 0 || lockedRef.current) return // One confirmation for the whole batch, in the modal, replacing main's // per-agent native dialog (spec §Renderer). It is required only on the // opt-in source path: that is the branch that rewrites live history and @@ -423,12 +488,20 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // nothing and is confirmed by the button press itself. if (compactOnSource && !sourceConfirmArmed) { setSourceConfirmArmed(true) + setConfirmedSessionIds(matchingRows.map(row => row.sessionId)) return } + // The confirmed set wins over the live one whenever a confirmation was + // required. Panes that closed in between are skipped by the action itself, + // which re-reads meta per iteration, so a stale id is harmless — an + // UNCONFIRMED id is not. + const sessionIds = compactOnSource && confirmedSessionIds + ? confirmedSessionIds + : matchingRows.map(row => row.sessionId) setBusy(true) try { await workspace.switchAgentsToProvider( - matchingRows.map(row => row.sessionId), + sessionIds, target, { allowSourceTurns: compactOnSource, @@ -446,10 +519,10 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { } finally { setBusy(false) } - }, [busy, compactOnArrival, compactOnSource, matchingRows, onClose, sourceConfirmArmed, target, workspace]) + }, [busy, compactOnArrival, compactOnSource, confirmedSessionIds, matchingRows, onClose, sourceConfirmArmed, target, workspace]) const runModelSwitch = useCallback(async () => { - if (matchingRows.length === 0 || switchingModel || busy) return + if (matchingRows.length === 0 || lockedRef.current) return setSwitchingModel(true) let delivered = 0 let failed = 0 @@ -473,6 +546,25 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { if (firstFailure === null) firstFailure = result.message } } + } catch (error) { + // WHY this catch exists: the loop had try/finally and no catch, so a + // REJECTED deliverPrompt (a dead IPC channel, a preload shape mismatch) + // aborted the batch mid-way while the `finally` still ran. The count was + // not wrong about what it claimed — `delivered` only ever incremented + // after `result.ok` — but the toast said "Sent /model … to 3 agents" + // with no failure note at all, so every agent the loop never reached + // simply vanished from the report. Silence about an agent reads as + // "nothing to say", not as "never attempted". + // Clamped so the report can never claim more agents than the batch had: + // the loop aborted, so everything not already counted is unattempted, + // and at minimum the one that rejected must show up. + const remaining = matchingRows.length - delivered - failed + failed += remaining > 0 ? remaining : 1 + if (firstFailure === null) { + firstFailure = error instanceof Error && error.message.length > 0 + ? error.message + : 'Prompt delivery failed' + } } finally { setSwitchingModel(false) const failureNote = failed > 0 @@ -483,7 +575,9 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { }, [busy, matchingRows, showToast, switchingModel]) const runReturn = useCallback(async () => { - if (busy) return + // Same reason as runSwitch: a Return must not start under an in-flight + // /model fan-out. + if (lockedRef.current) return setBusy(true) try { // Intentionally NOT closing the modal: the banner clears itself when @@ -501,10 +595,25 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // mid-loop, a second bulk operation could start concurrently against the same // replaceSession mutation paths. Refusing to close while busy keeps `busy` the // authoritative single-flight guard without lifting it into workspace state. + // WHY `switchingModel` counts as locked too: + // + // `runModelSwitch` sets only `switchingModel`, and every close guard keyed on + // `busy` alone. Escape during the sequential /model loop was therefore + // allowed, the reset effect below cleared `switchingModel` on reopen, and a + // second click started a second loop that interleaved PTY writes on panes + // that had already received the prompt. That is precisely the race the + // sequential loop exists to prevent. `open` is store-owned, so + // `closeBulkProviderSwitch` from anywhere else bypasses this guard for + // `busy` as well — see the reset effect, which is the second half of the fix. + const locked = busy || switchingModel + // Read by the run guards, which must see the live value without taking + // `locked` as a dependency and re-creating every callback on each toggle. + const lockedRef = useRef(locked) + lockedRef.current = locked const requestClose = useCallback(() => { - if (busy) return + if (locked) return onClose() - }, [busy, onClose]) + }, [locked, onClose]) return ( { - if (busy) event.preventDefault() + if (locked) event.preventDefault() }} onPointerDownOutside={event => { // WHY an in-flight batch cannot be dismissed: the old overlay kept // this single-flight operation visible until it settled. Preventing // Radix's outside close preserves that contract while still letting // the primitive own all normal dismissal behavior. - if (busy) event.preventDefault() + if (locked) event.preventDefault() }} >
@@ -538,7 +647,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) {
{/* TAIL pill styling copied from ScrollIndicator so both surfaces diff --git a/src/renderer/src/workspace/tile-tree/AgentTitleHeader.tsx b/src/renderer/src/workspace/tile-tree/AgentTitleHeader.tsx index 2cfdb59a..6285d18b 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTitleHeader.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTitleHeader.tsx @@ -1,4 +1,7 @@ -import { useAgentName } from '@renderer/workspace/agentNames/useAgentName' +import { + useAgentName, + useAgentNameRowReserved, +} from '@renderer/workspace/agentNames/useAgentName' import type { SessionId } from '@renderer/workspace/types' // One visual contract for explicit agent titles and spoken agent names across @@ -12,13 +15,27 @@ import type { SessionId } from '@renderer/workspace/types' // call sites sit on the hot pane-render path and already thread a dozen props; // see useAgentName for why a primitive subscription is cheaper than widening // them. +// +// WHY this row's HEIGHT is constant from first paint rather than appearing +// with the name: +// +// A name arrives over IPC well after the pane mounts. Keying the row's +// existence on the name meant every named agent pane grew ~23px mid-life on +// every window load, which shrank the terminal box, triggered a refit, and +// sent a second PTY resize as a SIGWINCH into a live, mid-output TUI. Ink and +// the Claude Code TUI erase a line count computed for the frame before that +// resize, so the redraw lands on the wrong region and leaves garbled +// fragments behind permanently. Reserving the space removes the layout change, +// so there is no second resize to race. See agentNameRowIsReserved. export function AgentTitleHeader({ sessionId, title }: { sessionId: SessionId; title?: string }) { const agentName = useAgentName(sessionId) + const reserveNameRow = useAgentNameRowReserved(sessionId) const visibleTitle = title?.trim() - // WHY the guard now checks BOTH: this row used to exist only for a title, so + // WHY the guard checks all three: this row used to exist only for a title, so // an untitled agent rendered nothing. With names on, that would hide the only - // address a voice operator can use while the operator can still reach it. - if (!visibleTitle && !agentName) return null + // address a voice operator can use while the operator can still reach it — + // and the reservation keeps the row's box stable while the name is in flight. + if (!visibleTitle && !agentName && !reserveNameRow) return null return (
- {agentName && ( + {agentName ? ( // Fixed width contribution, never truncated: the name is the thing a // user says out loud, so it must survive a narrow Tiled Dispatch lane // even when the title does not. @@ -36,7 +53,21 @@ export function AgentTitleHeader({ sessionId, title }: { sessionId: SessionId; t > {agentName} - )} + ) : reserveNameRow ? ( + // The placeholder carries the badge's exact box (border + px-1 + + // leading-[14px]) so the row's height cannot change when the real name + // replaces it. `invisible` rather than omitting it: an empty flex row + // collapses to its padding and would resize the terminal anyway. + // aria-hidden and no data-agent-name-badge, so nothing reads or + // queries it as a name. + + ) : null} {visibleTitle &&
{visibleTitle}
}
) diff --git a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx index ce693d94..933a2f42 100644 --- a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx @@ -7,6 +7,7 @@ import { FitAddon } from '@xterm/addon-fit' import type { SessionId } from '@renderer/workspace/types' import type { Workspace } from '@renderer/workspace/workspaceStore' import { useAppStore } from '@renderer/app-state/hooks' +import { PaneToast } from '@renderer/workspace/tile-tree/TileLeaf/PaneToast' import { useComposerDictation } from '@renderer/workspace/tile-tree/TileLeaf/useComposerDictation' import { THEME_CHANGED_EVENT, @@ -99,6 +100,31 @@ export function TerminalLeaf({ onMessage: message => workspace.showPaneToast(sessionId, message), }) + // WHY this leaf renders a pane toast at all, and why it subscribes to the + // STRING rather than taking the runtime as a prop: + // + // Plain terminal panes were the one leaf kind that called showPaneToast + // (dictation at the top of this file, and the resume/backend messages + // below) without ever rendering PaneToast, so every one of those messages + // was written into the store and shown to nobody. #840 made that a dead + // command rather than a missing nicety: it opened terminal panes up as + // prompt-template and vault-key insertion targets, and routed ALL of that + // feature's feedback — success, "pane is not ready", "target pane is gone" — + // through showPaneToast. On a shell pane a failed insertion produced no + // toast, and the palette does not close on failure, so nothing happened at + // all. + // + // A primitive selector, not the whole runtime: this leaf deliberately does + // not re-render on runtime ticks (the xterm instance owns its own output + // path), and threading the runtime in as a prop would re-render it on every + // PTY chunk. Subscribing to the toast string re-renders on exactly the one + // transition that changes what is painted. + // Optional chain on the MAP as well as the entry: several renderer specs and + // the phone bundle mock the store with only the keys they use, and the + // repository's standing rule is that a keyless store must degrade, never + // throw (see agentNames/selectors.ts and PaneHeader.phoneCoupling). + const paneToast = useAppStore(state => state.workspaceRuntimes?.[sessionId]?.paneToast ?? null) + const acknowledgeSession = workspace.acknowledgeSession const ensureSessionLiveRef = useRef(workspace.ensureSessionLive) ensureSessionLiveRef.current = workspace.ensureSessionLive @@ -563,6 +589,16 @@ export function TerminalLeaf({ ref={containerRef} className="flex-1 min-h-0 min-w-0 overflow-hidden relative" /> + {/* Same slot and ordering as AgentTerminalLeaf: below the terminal box. + Note what this DOES cost, since the obvious reading is wrong — the + slot is a non-shrinking flex sibling, so while a toast is on screen + the xterm box really is shorter, its ResizeObserver fires, and the + PTY is resized down and then back up when the toast clears. That is + the same shape as AgentTerminalLeaf and is accepted for the same + reason: pane feedback that is never rendered is worse than a + transient reflow. It is also why the toast lives here rather than + overlaying the terminal, where it would hide output. */} + ) } diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx index df1aa244..82bd8a62 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx @@ -121,8 +121,13 @@ export function PaneHeader({ {paneLabel} )} - - {shortenCwd(projectDir)} + {/* truncate-START: every pane shares the leading path segments, so + clipping the end hid the one part that identifies this agent. */} + + {/* The inner dir="ltr" is required, not decorative: the outer + element's rtl direction picks WHICH edge clips, and without + this the path's own characters are reordered with it. */} + {shortenCwd(projectDir)} diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts index c2bf6770..bdd1c8a7 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts @@ -84,6 +84,7 @@ it('follows and restores real xterm content across trimming and buffer switches' await write('\\x1b[?1049h') tailActive = false; render() check(term.buffer.active.type === 'alternate' && bottom(), 'Alternate buffer was scrolled with a normal-buffer anchor') + await write('\\x1b[?1049l') // Marker registration/disposal is public; only this diagnostic // enumeration requires proposed APIs. Keep them off for all behavior. diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 4b1b7cc3..aed549cb 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -52,6 +52,16 @@ export function useAgentTerminalFollow({ } if (scrollToLatestRequest === jumpBaselineRef.current) return jumpBaselineRef.current = scrollToLatestRequest + // KNOWN LIMITATION, and not fixable from here: a provider whose TUI runs + // on the ALTERNATE SCREEN owns its transcript internally and never evicts + // a line into xterm scrollback, so viewportY === baseY always holds and + // this call does nothing. OpenCode Terminal is exactly that case. Sending + // the TUI's own scroll-to-bottom chord was tried and reverted: the binding + // is user-configurable, so a user who has moved a destructive action onto + // it would have Jump to Latest abort and revert their session. The + // rebinding-immune route is OpenCode's POST /tui/execute-command, which + // needs a served transport this runtime does not use yet. See the audit + // doc for the full evidence. termRef.current?.scrollToBottom() }, [scrollToLatestRequest, sessionId, termRef]) @@ -111,6 +121,16 @@ export function useAgentTerminalFollow({ subscription.dispose() savedLineRef.current?.dispose() savedLineRef.current = null + // WHY the engaged flag is reset too: detaching disposes the anchor, so + // there is nothing left to restore, but this flag used to survive. The + // xterm instance can be torn down and rebuilt under the SAME sessionId + // — the effect that clears per-session state is keyed on sessionId and + // does not re-run — so the next disengage found tailEngaged true and + // saved null, took the restore branch, and silently dropped the user + // back to wherever the fresh terminal happened to be instead of the + // line they were reading. Engagement describes a live terminal, so it + // has to end with one. + tailEngagedRef.current = false } }, }), [termRef]) diff --git a/src/renderer/src/workspace/types.ts b/src/renderer/src/workspace/types.ts index 9cc8fc8d..2bb7026b 100644 --- a/src/renderer/src/workspace/types.ts +++ b/src/renderer/src/workspace/types.ts @@ -585,6 +585,22 @@ export type ProviderSwitchBatch = { sourceKind: AgentProviderKind targetKind: AgentProviderKind agents: ProviderSwitchBatchAgent[] + /** + * Whether the user agreed to compaction-on-arrival for THIS batch, captured + * from the modal that asked. + * + * WHY the return path needs it rather than deciding for itself: arrival + * compaction spends the destination provider's quota and locks every + * affected composer for the arrival wait plus the compaction wait — minutes + * per pane, with no cancel. The forward flow puts that behind an explicit + * checkbox and a quota disclosure. The return flow had no modal at all and + * hard-coded it on for any Claude destination, so a single "Return 20" click + * spent Claude quota twenty times and locked twenty composers with nothing + * asked and nothing disclosed. Returning is the mirror of the switch the + * user consented to, so it reuses that consent instead of inventing new + * consent on the user's behalf. + */ + compactOnArrival: boolean } export const RATIO_MIN = 0.1 diff --git a/src/shared/types/agentNames.ts b/src/shared/types/agentNames.ts new file mode 100644 index 00000000..235aaf73 --- /dev/null +++ b/src/shared/types/agentNames.ts @@ -0,0 +1,28 @@ +/** + * Limits shared by the renderer's identity validation and main's IPC schema. + * + * WHY these live in `shared` rather than being written twice: they are one + * contract with two halves, and the halves silently drifted. `identityOf` + * (renderer) checked only that an identity was a non-empty string, while + * `agent-names:resolve` (main) validates `z.string().min(1).max(200)` over + * the whole array. A single hand-edited `workspace.json` carrying one + * over-long `agentNameId` therefore passed the renderer, entered the request + * array, and made `requestSchema.parse` reject the ENTIRE batch — which the + * reconciler swallows silently, so no agent in that window ever received a + * name and nothing said why. + * + * The renderer's own comment already named main's schema verbatim, so the + * intent was to mirror it; only the type half was actually mirrored, not the + * length half. Importing the numbers makes the next divergence impossible. + */ + +/** Longest accepted `agentNameId`. Mirrors main's per-item schema. */ +export const AGENT_NAME_IDENTITY_MAX_LENGTH = 200 + +/** + * Most identities one resolve request may carry. + * + * Bounded so a malformed or hostile renderer cannot make the allocator walk a + * huge list under the serialization tail. Far past any real workspace. + */ +export const AGENT_NAME_IDENTITY_REQUEST_MAX = 10_000