From 7e851850460345613cb1ac8f9e72c010601a43cb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 10:47:33 -0700 Subject: [PATCH 01/32] fix(vault): pad the API Key Vault modal body and lay out its header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vault modal shipped with its content flush against the dialog border on all four sides. Two independent causes, both in KeyVaultModal.tsx: DialogContent carries no padding by design. The primitive owns layout only; DialogHeader and DialogFooter supply their own 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`). This modal used DialogHeader but then hung the provider list, key rows, warning banners and the footnote directly off DialogContent, so only the header was ever padded. The body now lives in that conventional wrapper and the footnote moved to a real DialogFooter. The header also 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 underneath the description instead of sitting opposite the title. Three things fixed on the way through, inside the same blast radius: - Warning/error banners, key rows and the key form used bg-surface, which is the dialog's own background — they rendered as invisible fills. They and the selected-provider chip now use bg-canvas, matching how ViewPromptsModal separates rows from the dialog ground. - The two-column row had overflow-y-auto on itself AND on both children, which scrolled the provider list away with the key list and produced a second scrollbar on the same axis. Only the columns scroll now. - rounded -> rounded-slab on the banners, to use the design token the rest of the dialog surface uses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../features/key-vault/ui/KeyVaultModal.tsx | 407 ++++++++++-------- 1 file changed, 216 insertions(+), 191 deletions(-) diff --git a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx index 953b29cf..ab3d76c7 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' @@ -206,8 +207,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 +227,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 && ( +
+
+ {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) && ( + + {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 -
+
) From e3c111b255da350140cbf46fdba9e58c65288988 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 10:47:46 -0700 Subject: [PATCH 02/32] fix(sessions): say when a session's workspace folder is gone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six panes came up as ERROR on the 2026-09-08 launch. The only text the user ever saw was "agent exited before it became ready for input (start-failed)", which named neither the cause nor the folder. The cause was not a regression in any recent merge. All six sessions were parked in Dispatch lanes pointing at git worktrees that had been deleted 33 minutes before the app launched. node-pty performs the chdir INSIDE the forked child (node_modules/node-pty/src/unix/pty.cc: `if (chdir(cwd_) == -1) _exit(1)`), so a deleted directory produces a *successful* PTY creation followed by an immediate exit(1). Every layer above then reports good news on the way up: provider start resolves ok, recover() returns ok:true with disposition 'spawned', and the failure only surfaces later as the readiness wait giving up. The incident journal shows exactly that shape — provider.start.end ok:true in 15ms, then gate.eval reason "exited" at elapsedMs 0. There was no cwd existence check anywhere on the spawn path. Adding one in spawnWithId — the single funnel both spawn() and recover() pass through, before the spawn reservation — turns the mystery into "Workspace folder is missing: ". recover() deliberately flattens every failure to a generic message, because provider launch exceptions can carry environment values, proxy URLs and scoped MCP tokens. This is the one curated exception: the path is already rendered in the pane header, and it is the only start failure the user can act on. It is also marked non-retryable, since every retry re-runs the same stat and fails identically — the journal shows 20 such retries across one session. This matters more in this repo than in most apps because worktree-per- branch is the standing workflow, so panes routinely outlive the directory they were opened in. 34 of the 73 persisted rows in the current workspace.json are already detached records. The guard lives in its own module so the six suites that spawn into synthetic paths ('/tmp/project', '/recorded/worktree') can stub one import rather than stubbing node:fs/promises. workspaceDirectory.test.ts covers the guard against the real filesystem, including the dangling symlink a deleted worktree leaves behind — the case a naive lstat implementation would wrongly accept. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../sessionManager.codexReplacement.test.ts | 15 ++++ src/main/sessionManager.lifecycle.test.ts | 15 ++++ src/main/sessionManager.recover.test.ts | 51 ++++++++++++ src/main/sessionManager.screenGate.test.ts | 15 ++++ src/main/sessionManager.ts | 23 +++++- src/main/sessionManager.wake.test.ts | 15 ++++ src/main/workspaceDirectory.test.ts | 77 +++++++++++++++++++ src/main/workspaceDirectory.ts | 62 +++++++++++++++ 8 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/main/workspaceDirectory.test.ts create mode 100644 src/main/workspaceDirectory.ts 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) +} From 1fda16bb8510c18881816ead864267b0ba2b6cb8 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 10:54:10 -0700 Subject: [PATCH 03/32] fix(settings): treat absent localStorage as unavailable, not as a live adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createSettingsStorage's "storage is unavailable" guard only caught a THROWN access, which is the browser storage-denied case. It never caught storage being merely absent. The renderer test environment (happy-dom) provides exactly that: a `localStorage` that is defined-but-undefined. The assignment succeeded, so the guard did not fire, and the function returned a live adapter whose closure held undefined. Every subsequent store write then died inside Zustand's persist middleware with "storage.setItem is not a function". That silently broke all seven tests in agentNames/reconciler.renderer. test.tsx from the day they landed in e4b4cd55 — they fail the moment they touch useAppStore.setState, so the agent-name reconciliation they were written to protect has never actually been verified. Any future renderer test that writes a setting would have hit the same wall. The guard now checks that the object is a usable Storage rather than that the access did not throw, and returns undefined otherwise — which is the documented contract Zustand expects for "no storage", and the behavior the original comment already claimed. Production is unaffected: Electron and the phone bundle both have a real Storage. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../settings/storage.renderer.test.ts | 77 +++++++++++++++++++ .../src/app-state/settings/storage.ts | 25 +++++- 2 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/app-state/settings/storage.renderer.test.ts 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..8f3ca803 100644 --- a/src/renderer/src/app-state/settings/storage.ts +++ b/src/renderer/src/app-state/settings/storage.ts @@ -21,7 +21,30 @@ 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. + const candidate: Storage | undefined = window?.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. From e4ffcaad225d7b94aa3656272a9942f4b97075fb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 10:54:10 -0700 Subject: [PATCH 04/32] fix(workspace): reserve the agent-name row so panes stop resizing after mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent name arrives over IPC well after its pane mounts: the reconciler only runs once restoreStatus leaves 'pending', then round-trips resolveAgentNames. AgentTitleHeader keyed the row's EXISTENCE on the name (`if (!visibleTitle && !agentName) return null`), so with Agent names on, every named agent pane did this on every single window load: 1. mount, no row, terminal fits tall, PTY told rows: N 2. IPC reply lands, ~23px row appears 3. terminal box shrinks, ResizeObserver fires, refit 4. PTY told rows: N-1 -> SIGWINCH into a live, mid-output TUI Step 4 is the damaging one. Ink and the Claude Code TUI erase a line count computed for the frame BEFORE the resize, so the redraw lands on the wrong region and leaves garbled, interleaved fragments in the scrollback that never repair themselves. This is a new behavior from 2b529300: before it, the row existed only for explicitly-titled agents, so an untitled agent never changed height at all. The row now reserves its box from first paint whenever names are enabled for an agent-kind session, with an invisible placeholder carrying the badge's exact border/padding/leading. There is no layout change left for the name to cause, so there is no second resize to race. The reservation is keyed on the setting plus provider kind, not on the identity or the name, because those are the only two facts known at mount — the identity itself is claimed by a later effect, so keying on it would just move the same flip one step earlier. Shells reserve nothing, and users with the setting off lose no space. This is one of three independent contributors to the reported terminal corruption. It is the only one caused by a recent merge; the other two (the WebGL atlas bug behind #789, and attach replay being parsed at 80x24 before the first fit) are pre-existing and tracked separately. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../agentNames/presentation.renderer.test.tsx | 39 +++++++++++++++++ .../src/workspace/agentNames/selectors.ts | 34 +++++++++++++++ .../src/workspace/agentNames/useAgentName.ts | 17 +++++++- .../workspace/tile-tree/AgentTitleHeader.tsx | 43 ++++++++++++++++--- 4 files changed, 126 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx b/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx index fce6ad9c..ad546e87 100644 --- a/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx +++ b/src/renderer/src/workspace/agentNames/presentation.renderer.test.tsx @@ -109,6 +109,45 @@ describe('agent name presentation', () => { expect(container.querySelector('[data-agent-title-header="true"]')).toHaveTextContent('Investigate queue race') }) + it('holds the row open for an agent whose name has not arrived yet', () => { + // WHY this matters far beyond a blank badge: a name arrives over IPC well + // after the pane mounts. When the row's EXISTENCE depended on the name, + // every named agent pane grew ~23px mid-life on every window load, shrank + // the terminal box, refit, and sent a second PTY resize as a SIGWINCH into + // a live, mid-output TUI. The TUI's redraw then erased a line count + // computed for the pre-resize frame and left garbled fragments in the + // scrollback permanently. Reserving the box removes the resize. + seed() + appState.workspaceAgentNames = {} + const { container } = render() + + expect(container.querySelector('[data-agent-title-header="true"]')).not.toBeNull() + const placeholder = container.querySelector('[data-agent-name-placeholder="true"]') + expect(placeholder).not.toBeNull() + // It must not be readable or addressable as a name: an operator resolving + // agents by name must never match a pane that has none yet. + expect(placeholder).toHaveAttribute('aria-hidden', 'true') + expect(container.querySelector('[data-agent-name-badge="true"]')).toBeNull() + expect(container.querySelector('[data-agent-title-header="true"]')).toHaveTextContent('') + }) + + it('reserves nothing for a shell, which never receives a name', () => { + // The reservation is keyed on provider kind, so a plain terminal pane must + // not gain a row it will never fill. + seed() + appState.workspaceAgentNames = {} + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('reserves nothing while the setting is off, so unnamed users lose no space', () => { + seed() + appState.workspaceAgentNames = {} + appState.settings.agentNamesEnabled = false + const { container } = render() + expect(container.firstChild).toBeNull() + }) + it('chips the name on the agent row of the Dispatch index and leaves shells bare', () => { seed() const { container } = renderIndex() diff --git a/src/renderer/src/workspace/agentNames/selectors.ts b/src/renderer/src/workspace/agentNames/selectors.ts index b0f2a817..7cccb1cf 100644 --- a/src/renderer/src/workspace/agentNames/selectors.ts +++ b/src/renderer/src/workspace/agentNames/selectors.ts @@ -69,3 +69,37 @@ export function agentNameForSession(state: AppStore, sessionId: SessionId): stri names: state.workspaceAgentNames ?? {}, }) } + +/** + * Will this pane eventually grow an agent-name row? + * + * WHY presentation needs to know this BEFORE the name exists, when + * `resolveAgentName` deliberately refuses to answer early: + * + * A name arrives over IPC (`useAgentNameReconciler` -> `resolveAgentNames`) + * tens to hundreds of milliseconds after the pane mounts, and only once + * `restoreStatus` leaves 'pending'. AgentTitleHeader keyed its existence on + * the name, so on every window load a named agent pane rendered no row, laid + * its terminal out tall, told the PTY `rows: N` — and then, when the reply + * landed, grew a ~23px row, shrank the terminal box, refit, 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 that + * second resize overwrites the wrong region and leaves permanently garbled + * fragments in the scrollback. + * + * Reserving the row from first paint removes the layout change entirely, so + * there is no second resize to race. It is deliberately keyed on + * `agentNamesEnabled` + provider kind rather than on the identity or the + * name, because those are the only two facts already known at mount: the + * identity itself is claimed by a later effect, so keying on it would + * reintroduce the same flip one step earlier. + * + * Same defensive optional reads as `agentNameForSession` above, for the same + * phone-bundle reason: a keyless store must degrade, never throw. + */ +export function agentNameRowIsReserved(state: AppStore, sessionId: SessionId): boolean { + if (state.settings?.agentNamesEnabled !== true) return false + const meta = state.workspaceState?.sessions?.[sessionId] + if (!meta) return false + return isAgentProviderKind(meta.kind ?? DEFAULT_PROVIDER) +} diff --git a/src/renderer/src/workspace/agentNames/useAgentName.ts b/src/renderer/src/workspace/agentNames/useAgentName.ts index 0d61769f..8d17c8e5 100644 --- a/src/renderer/src/workspace/agentNames/useAgentName.ts +++ b/src/renderer/src/workspace/agentNames/useAgentName.ts @@ -1,5 +1,8 @@ import { useAppStore } from '@renderer/app-state/hooks' -import { agentNameForSession } from '@renderer/workspace/agentNames/selectors' +import { + agentNameForSession, + agentNameRowIsReserved, +} from '@renderer/workspace/agentNames/selectors' import type { SessionId } from '@renderer/workspace/types' /** @@ -14,3 +17,15 @@ import type { SessionId } from '@renderer/workspace/types' export function useAgentName(sessionId: SessionId): string | null { return useAppStore(state => agentNameForSession(state, sessionId)) } + +/** + * Companion subscription to `useAgentName`, returning whether this pane must + * hold space for a name row that has not arrived yet. See + * `agentNameRowIsReserved` for why the header cannot wait for the name. + * + * Also a primitive, so it re-renders on exactly one transition: the Agent + * names setting being toggled. + */ +export function useAgentNameRowReserved(sessionId: SessionId): boolean { + return useAppStore(state => agentNameRowIsReserved(state, sessionId)) +} 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}
}
) From 9468cd836d07618ae8aa93d1890d5f293adaf7de Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:04:40 -0700 Subject: [PATCH 05/32] fix(settings): read the localStorage global, not window.localStorage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the storage-usability guard. The settings migration suites drive persistence through `vi.stubGlobal('localStorage', …)`, which replaces the global binding and not a property on `window`, so reading `window.localStorage` made createSettingsStorage return undefined there and `useAppStore.persist` stopped existing. In the Electron renderer and the phone bundle the two spellings are the same object, so the usability check is what does the work either way. Also stubs the spawn-path directory guard in the cross-layer session recovery integration suite, which drives real SessionManager spawns against the synthetic '/tmp/project'. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/renderer/src/app-state/settings/storage.ts | 8 +++++++- .../sessionRecovery.integration.test.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/app-state/settings/storage.ts b/src/renderer/src/app-state/settings/storage.ts index 8f3ca803..28eb2b38 100644 --- a/src/renderer/src/app-state/settings/storage.ts +++ b/src/renderer/src/app-state/settings/storage.ts @@ -37,7 +37,13 @@ export function createSettingsStorage(): PersistStorage | und // 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. - const candidate: Storage | undefined = window?.localStorage + // + // 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' || diff --git a/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts b/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts index 7ee4e23c..a7ba468a 100644 --- a/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts +++ b/src/renderer/src/workspace/hook/persistence/sessionRecovery.integration.test.ts @@ -12,6 +12,21 @@ const { createSession, loadInitialHistoryForSession } = vi.hoisted(() => ({ loadInitialHistoryForSession: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // This suite drives real SessionManager spawns against the synthetic path + // '/tmp/project', which does not exist on disk. The spawn-path guard that + // stats the cwd is stubbed so these cases keep exercising the failure modes + // they were written for; workspaceDirectory.test.ts covers the guard, and + // sessionManager.recover.test.ts covers the manager surfacing 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 }), })) From 919ef90562412d16a6b63431a37e0bfe59895bff Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:04:40 -0700 Subject: [PATCH 06/32] fix(sessions): stop a no-op wake from cancelling the caller that requested it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inserting a prompt template or a vault key into a pane that was exited, parked, or still spawning ALWAYS failed the first time with "Template target pane is gone" / "Focused pane is no longer available", and always worked on the retry. deliverTextToSession and both prompt-template insertion paths use the session-meta object's IDENTITY as their "is my target still the same pane?" token across the await: useAppStore.getState().workspaceState.sessions[id] === originalSession ensureSessionLive committed its recovered meta with an unconditional setState, so ANY wake replaced that object even when every field was identical. The guards then read "the pane changed underneath me" and returned { delivered: false, reason: 'cancelled' }. On the retry the session was already 'started', no wake ran, no replacement happened, and it worked — the exact "flaky, works the second time" signature. The commit is now identity-preserving on a no-op. Comparing content rather than simply skipping the write is the right fix and not just the local one: withoutProvisionalProviderSession legitimately drops fields some of the time, and a real change must still produce a new object and still invalidate those guards. Only a genuine no-op is made free. The comparison walks the union of both key sets so a disappearing field counts as a change. Fixing it here rather than in each caller is deliberate: any future code that holds a meta reference across a wake inherits the same trap. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../src/workspace/hook/actions/session.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index db1d6d3c..d062b4bc 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -138,6 +138,30 @@ export async function killSessionBackendIfOwned( }) } +/** + * Shallow value-equality over two session-meta records. + * + * WHY this exists rather than letting the spread decide: several callers use + * the meta object's IDENTITY as a "is my target still the same pane?" token + * across an await (deliverTextToSession's isCurrent, both prompt-template + * insertion paths). A wake that changed nothing but still produced a new + * object read to them as "the pane was replaced", so the first insertion into + * any pane that needed waking always failed and the retry always worked. + * + * Compared over the union of both key sets, so a field DISAPPEARING counts as + * a change — `withoutProvisionalProviderSession` legitimately drops keys, and + * treating that as a no-op would hand those callers a token that outlived the + * fact it stood for. Shallow is sufficient: SessionMeta is a flat record of + * primitives. + */ +function metaIsUnchanged(current: SessionMeta, next: SessionMeta): boolean { + const a = current as unknown as Record + const b = next as unknown as Record + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) if (!Object.is(a[key], b[key])) return false + return true +} + function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean): SessionRuntime { if (!hasProviderSession) { // WHY no-provider soft reload is non-destructive: @@ -881,11 +905,37 @@ export function useSessionActions( setState(prev => { const current = prev.sessions[sessionId] if (!current) return prev + const next = { ...current, ...recoveredMeta } + // WHY a no-op wake must preserve the meta object's IDENTITY: + // + // Callers that survive an await across a wake use this object as + // their "is my target still the same pane?" token — + // deliverTextToSession's isCurrent(), and both prompt-template + // insertion paths, all compare + // `workspaceState.sessions[id] === originalSession`. This setState + // ran unconditionally, so ANY wake replaced the object even when + // every field was unchanged, and those guards then read "the pane + // changed underneath me" and cancelled. + // + // The user-visible bug: inserting a prompt template or a vault key + // into a pane that was exited, parked, or still spawning ALWAYS + // failed the first time with "target pane is gone" / "Focused pane + // is no longer available", then worked on the retry — because by + // then the session was 'started', no wake ran, and no replacement + // happened. That is the whole "flaky, works the second time" + // signature. + // + // Comparing content rather than trusting the spread is the correct + // fix and not merely the local one: recoveredMeta genuinely changes + // fields some of the time (withoutProvisionalProviderSession can + // drop them), so a real change must still produce a new object and + // still invalidate those guards. Only a no-op is made free. + if (metaIsUnchanged(current, next)) return prev return { ...prev, sessions: { ...prev.sessions, - [sessionId]: { ...current, ...recoveredMeta }, + [sessionId]: next, }, } }) From b8fe179099d32ba1c0753635f48a68ce6626c78e Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:04:40 -0700 Subject: [PATCH 07/32] fix(terminal): render pane toasts on plain terminal panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TerminalLeaf called showPaneToast — for dictation, and for its resume and backend messages — but never rendered PaneToast. Those messages were written into the store and shown to nobody. The only two render sites were AgentTerminalLeaf and TileLeaf. #840 turned that from a missing nicety into a dead command. It opened plain terminal panes up as prompt-template and vault-key insertion targets, then routed every bit of that feature's feedback — success, "pane is not ready", "target pane is gone" — through showPaneToast. On a shell pane a failed insertion painted no toast, and the palette only closes on success, so pressing the key did nothing observable at all. Subscribes to the toast STRING rather than taking the runtime as a prop: this leaf deliberately does not re-render on runtime ticks (xterm owns its own output path), and a runtime prop would re-render it on every PTY chunk. The map is optional-chained as well as the entry, per the standing rule that a keyless store must degrade rather than throw — several renderer specs and the phone bundle mock only the keys they use. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../src/workspace/tile-tree/TerminalLeaf.tsx | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/TerminalLeaf.tsx index ce693d94..bb08a55e 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,9 @@ 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, + non-shrinking, so a toast never steals rows from xterm mid-session. */} + ) } From 9b76d5dd0028fdf3eed4bfff33cdf8bc974a6e4a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:04:40 -0700 Subject: [PATCH 08/32] fix(provider-switch): keep the agents a bulk return could not bring home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returning a switched batch nulled `lastProviderSwitchBatch` unconditionally, even when zero agents actually returned. This modal is the only return affordance in the app, so the record it destroyed could not be recovered by any other route — the user lost the batch by pressing the button meant to restore it. This is not a rare path. Arrival compaction is on by default whenever the largest conversation exceeds 150k chars, which is the population the feature exists for, and it holds `providerSwitch` set for the arrival readiness wait plus the compaction wait — minutes per pane. Every agent in a batch returned inside that window is refused with "This pane is still finishing a provider switch", so returned === 0 and the whole batch went in the bin. Partial returns lost the remainder the same way: one of twenty home, nineteen records discarded. The batch is now trimmed to the agents that did not return, and cleared only once it is empty. Agents that are closed or were manually moved to another provider are still dropped, since there is nothing left to return for them. The update also bails if a newer forward switch replaced the batch while the return was running. Adds the first test file this module has ever had. Findings in three separate reviews landed in it precisely because it had none. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../bulkProviderSwitch.renderer.test.ts | 136 ++++++++++++++++++ .../hook/actions/bulkProviderSwitch.ts | 48 ++++++- 2 files changed, 178 insertions(+), 6 deletions(-) create mode 100644 src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts diff --git a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts new file mode 100644 index 00000000..aa4416dc --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts @@ -0,0 +1,136 @@ +import { renderHook } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { WorkspaceSetRuntimes, WorkspaceSetState } from '@renderer/workspace/hook/context' +import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' +import type { SessionActions } from '@renderer/workspace/hook/actions/session' +import type { ProviderSwitchBatch } from '@renderer/workspace/types' +import { useBulkProviderSwitchActions } from '@renderer/workspace/hook/actions/bulkProviderSwitch' + +const { switchAgentProvider } = vi.hoisted(() => ({ switchAgentProvider: vi.fn() })) +vi.mock('@renderer/workspace/hook/actions/providerSwitchCore', () => ({ switchAgentProvider })) + +// This module had NO test file, which is how a two-click data-loss bug shipped +// in it. These cases pin the return path's batch bookkeeping specifically: +// the modal is the ONLY return affordance in the app, so a batch dropped here +// cannot be recovered by any other route. + +type Agent = ProviderSwitchBatch['agents'][number] + +function agent(sessionId: string): Agent { + return { + sessionId, + originalKind: 'claude', + switchedToKind: 'codex', + } as unknown as Agent +} + +function harness(batch: ProviderSwitchBatch | null) { + const state = { + lastProviderSwitchBatch: batch, + sessions: Object.fromEntries( + (batch?.agents ?? []).map(a => [a.sessionId, { cwd: '/recorded', kind: 'codex' }]), + ), + } + const refs = { stateRef: { current: state } } as unknown as WorkspaceRefs + const setState = vi.fn((updater: unknown) => { + state.lastProviderSwitchBatch = ( + typeof updater === 'function' + ? (updater as (p: typeof state) => typeof state)(state) + : (updater as typeof state) + ).lastProviderSwitchBatch + }) as unknown as WorkspaceSetState + const toasts: string[] = [] + const { result } = renderHook(() => useBulkProviderSwitchActions( + refs, + setState, + vi.fn() as unknown as WorkspaceSetRuntimes, + (message: string) => { toasts.push(message) }, + {} as SessionActions, + )) + return { result, state, toasts } +} + +function batchOf(...ids: string[]): ProviderSwitchBatch { + return { + id: 'batch-1', + switchedAt: 0, + sourceKind: 'claude', + targetKind: 'codex', + agents: ids.map(agent), + } as unknown as ProviderSwitchBatch +} + +afterEach(() => { switchAgentProvider.mockReset() }) + +describe('returnLastProviderSwitchBatch', () => { + it('keeps the batch when every agent refuses to return', async () => { + // The realistic case, not an edge case: arrival compaction is on by + // default for large conversations and holds `providerSwitch` set for + // minutes per pane, so every agent in a batch returned during that window + // reports 'skipped' with "still finishing a provider switch". The old code + // nulled the batch anyway, and the user lost the only way to get those + // agents home by pressing the button meant to bring them home. + switchAgentProvider.mockResolvedValue({ status: 'skipped' }) + const { result, state, toasts } = harness(batchOf('a', 'b', 'c')) + + await result.current.returnLastProviderSwitchBatch() + + expect(state.lastProviderSwitchBatch).not.toBeNull() + expect(state.lastProviderSwitchBatch?.agents.map(a => a.sessionId)).toEqual(['a', 'b', 'c']) + expect(toasts[0]).toContain('Returned 0 agents') + }) + + it('keeps only the agents that did not make it home', async () => { + switchAgentProvider + .mockResolvedValueOnce({ status: 'switched' }) + .mockResolvedValueOnce({ status: 'failed' }) + .mockResolvedValueOnce({ status: 'skipped' }) + const { result, state } = harness(batchOf('a', 'b', 'c')) + + await result.current.returnLastProviderSwitchBatch() + + // 'a' is home and must not be retried; 'b' and 'c' are still parked on the + // target provider and are still returnable. + expect(state.lastProviderSwitchBatch?.agents.map(a => a.sessionId)).toEqual(['b', 'c']) + }) + + it('clears the batch once every agent has returned', async () => { + switchAgentProvider.mockResolvedValue({ status: 'switched' }) + const { result, state } = harness(batchOf('a', 'b')) + + await result.current.returnLastProviderSwitchBatch() + + expect(state.lastProviderSwitchBatch).toBeNull() + }) + + it('clears the batch when its agents are all gone from the workspace', async () => { + // Closed or manually-moved agents are skipped WITHOUT being retained: + // there is nothing left to return, so holding the record would leave a + // Return affordance that can never do anything. + const batch = batchOf('a') + const { result, state } = harness(batch) + state.sessions = {} + + await result.current.returnLastProviderSwitchBatch() + + expect(state.lastProviderSwitchBatch).toBeNull() + expect(switchAgentProvider).not.toHaveBeenCalled() + }) + + it('does not clobber a newer batch recorded while the return was running', async () => { + switchAgentProvider.mockResolvedValue({ status: 'switched' }) + const { result, state } = harness(batchOf('a')) + // A forward switch during the await replaces the remembered batch. The + // return must not delete a record it never operated on. + switchAgentProvider.mockImplementation(async () => { + state.lastProviderSwitchBatch = batchOf('z') + state.lastProviderSwitchBatch.id = 'batch-2' + return { status: 'switched' } + }) + + await result.current.returnLastProviderSwitchBatch() + + expect(state.lastProviderSwitchBatch?.id).toBe('batch-2') + }) +}) diff --git a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts index 3329a7b1..ca676d00 100644 --- a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts +++ b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts @@ -182,6 +182,9 @@ export function useBulkProviderSwitchActions( let returned = 0 let skipped = 0 let failed = 0 + // Agents that did NOT make it home. See the batch update below for why + // these have to survive: this modal is the only return affordance there is. + const unreturned: typeof batch.agents = [] for (const agent of batch.agents) { const meta = refs.stateRef.current.sessions[agent.sessionId] @@ -213,14 +216,47 @@ export function useBulkProviderSwitchActions( onProgress: event => showToast(event.message, 305_000), onArrivalFailure: message => showToast(message), }) - if (result.status === 'switched') returned += 1 - else if (result.status === 'failed') failed += 1 - else skipped += 1 + if (result.status === 'switched') { + returned += 1 + } else if (result.status === 'failed') { + failed += 1 + unreturned.push(agent) + } else { + // 'skipped' here means the pane refused the switch right now — most + // often "still finishing a provider switch". It is still sitting on + // the target provider, so it is still returnable later. + skipped += 1 + unreturned.push(agent) + } } - // Returning consumes the batch — there is no "return again". A future - // forward switch will record a fresh one. - setState(prev => ({ ...prev, lastProviderSwitchBatch: null })) + // WHY the batch is trimmed rather than dropped: + // + // "Returning consumes the batch" is right only for agents that actually + // returned. Dropping it wholesale meant a return in which NOTHING came + // back still destroyed the record, and this modal is the only return + // affordance in the app — there is no other way to get those agents home. + // + // That is not a rare case. Arrival compaction is on by default whenever + // the largest conversation exceeds 150k chars (the population this + // feature exists for), and it holds `providerSwitch` set for the arrival + // readiness wait plus the compaction wait — minutes per pane. Every agent + // in a batch returned during that window is refused with "This pane is + // still finishing a provider switch", so returned === 0, and the user + // lost the batch by clicking the button that was supposed to restore it. + // Partial returns lost the remainder the same way: 1 of 20 home, 19 + // records discarded. + // + // Keeping the unreturned agents means Return stays available and is + // simply retried. The batch is cleared only once it is empty. + setState(prev => { + if (prev.lastProviderSwitchBatch?.id !== batch.id) return prev + if (unreturned.length === 0) return { ...prev, lastProviderSwitchBatch: null } + return { + ...prev, + lastProviderSwitchBatch: { ...prev.lastProviderSwitchBatch, agents: unreturned }, + } + }) let message = `Returned ${pluralAgents(returned)} to ${providerLabel(batch.sourceKind)}` const notes: string[] = [] From 3c547194aafdcd8f99ffcf7a9e569addb987ab70 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:07:04 -0700 Subject: [PATCH 09/32] docs(audit): record the 2026-09-08 post-merge regression audit Four parallel read-only investigations over `3256e06a~1..HEAD` (#834, #836, #838, #840), triggered by four reported symptoms. Records what each symptom actually was, what was fixed in this branch, the two open decisions that need a human call, and the nineteen confirmed-but-unfixed findings with file:line so the next session does not re-derive them. Two results worth stating up front, because both contradict the obvious reading: - The "all my Claude sessions came up as Error" report is NOT a regression from any of the four merges. The spawn/recover/rehydrate path is not in the diff range at all. The sessions pointed at git worktrees deleted 33 minutes before launch, and node-pty's chdir happens inside the forked child, so the failure was invisible until the readiness wait gave up. - The terminal corruption has three independent causes, and only one is ours. The likeliest dominant one is the pinned WebGL addon's texture atlas bug, fixed upstream in a release that has no stable version yet. Also records the four coverage holes that let these through, including seven agent-name tests that had never passed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../2026-09-08-post-merge-regression-audit.md | 512 ++++++++++++++++++ 1 file changed, 512 insertions(+) create mode 100644 docs/superpowers/research/2026-09-08-post-merge-regression-audit.md 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..9eb1cbc1 --- /dev/null +++ b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md @@ -0,0 +1,512 @@ +# 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. + +--- + +## Open decisions (not actioned — both are genuine trade-offs) + +### D1. Who owns `End` inside a TUI? (fixes symptom 3) + +`resolveEffectiveKeybindings` keys on `commandId` in a `Map`, so one command +id gets exactly one context. You cannot have both `End` in `feed` and +`Alt+End` in `global` for `jump-latest-message`. + +- **Option A — make `End` work on terminal surfaces.** Widen the `feedFocused` + predicate to accept agent panes on either surface, and exempt xterm's helper + textarea from `isTextEditingTarget` (e.g. `el.closest('.xterm')`). Both + edits are required; either alone changes nothing. Delivers what #837 + promised. **Cost:** bare `End` stops reaching the provider TUI's own line + editor, which binds Home/End. Presumably why the original author left the + gate alone. +- **Option B — move it to `Alt+End` in `global`.** One line, mirrors how + `toggle-tail` already reaches raw terminals. **Cost:** feed users lose bare + `End`. +- **Option C — a second command id for the terminal surface**, so the feed + keeps `End` and terminals get `Alt+End`. No conflict, at the price of two + near-identical palette entries. + +Either way, three records asserting today's behaviour need updating: +`command-keybindings/reservations.ts:310-313`, +`command-palette/keybindingBaseline.test.ts:200-202`, and the router wiring +tests. + +### D2. WebGL: bump to a beta, or turn it off? (fixes most of symptom 4) + +- **Option A — bump `@xterm/addon-webgl` to `0.20.0-beta.300`.** The real + upstream fix, and the local workaround (plus its comment saying it "can go + once a stable addon with the upstream merge/retry fixes passes the + colored-output/scroll regression workload") could then be deleted. + **Cost:** a beta GPU renderer in a daily-driver Electron app, with no stable + 0.20.0 in sight. Cannot be verified here without running the app. +- **Option B — stop attaching the WebGL renderer for agent terminals** and + fall back to the DOM renderer, which is what VS Code ships as its own answer + to this exact symptom class (`terminal.integrated.gpuAcceleration: "off"`, + widely recommended specifically for Claude Code TUIs). **Cost:** reverses + the deliberate perf decision in #783/`3b885068`, four days old. +- **Option C — both, behind a setting**, defaulting to DOM until 0.20.0 is + stable. + +--- + +## Confirmed but NOT fixed + +Ordered by severity. Each is reproducible from the stated input. + +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. From fcf70d0aea426a1b80eb53d75893cfab13c86479 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 11:26:59 -0700 Subject: [PATCH 10/32] fix(terminal): stop attaching the WebGL renderer until a stable addon fixes #5883 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Our pinned @xterm/addon-webgl 0.19.0 corrupts its own texture atlas under exactly the workload this app runs all day: a provider TUI streaming heavy, colourful, constantly-redrawn output. Garbled and interleaved glyphs, characters substituted mid-word, and leftover inverse blocks that never repair because an idle terminal produces no further frame. Upstream xterm.js #5883 (merged 2026-05-21) names the two bugs precisely: a fresh atlas page replacing an old one AT THE SAME INDEX after a merge, which per-page version counters cannot detect, and a stale vertex buffer after a mid-render merge because `_requestClearModel` was set and never reset. The workaround from #789 hooks the atlas add/remove events and re-binds textures. That can address the first bug's symptom from outside the addon. It cannot replicate the second bug's fix, nor the bounded retry loop upstream added inside `renderRows()` — both live below the addon's public surface. #789 was closed without confirming the reporter's screenshot, and the corruption was reported again on 2026-09-08. Upgrading is not available. The fix ships only in @xterm/addon-webgl@0.20.0-beta.219 and later, 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. This costs less than it looks. The DOM renderer is xterm's default, is correct, and is already the tested fallback every failure path in this file lands on. The perf work that introduced WebGL (#783, 3b885068) had three parts, and the two structural ones — routing raw PTY channels once per renderer via sessionDataDispatcher, and coalescing inline grid resizes — are untouched. VS Code ships the same escape hatch for the same symptom class as `terminal.integrated.gpuAcceleration: "off"`, widely recommended for exactly this: Claude Code TUIs. The gate is a parameter defaulting to the constant, not a hard-coded read, so this module's fifteen existing cases keep proving the attach, fallback, context-loss and atlas-repair machinery still works for the day the constant flips back. Two new cases pin the disabled default and prove a disabled renderer never even imports the addon. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../terminal/xtermWebglRenderer.test.ts | 60 +++++++++++++----- .../workspace/terminal/xtermWebglRenderer.ts | 62 +++++++++++++++++++ 2 files changed, 108 insertions(+), 14 deletions(-) diff --git a/src/renderer/src/workspace/terminal/xtermWebglRenderer.test.ts b/src/renderer/src/workspace/terminal/xtermWebglRenderer.test.ts index 89f3be0a..ab5baba0 100644 --- a/src/renderer/src/workspace/terminal/xtermWebglRenderer.test.ts +++ b/src/renderer/src/workspace/terminal/xtermWebglRenderer.test.ts @@ -82,7 +82,7 @@ describe('xtermWebglRenderer', () => { it('repairs a whole atlas-layout burst after the current frame, without idle repainting', async () => { const addon = addonHarness() const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await renderer.ready expect(terminal.refresh).not.toHaveBeenCalled() @@ -111,7 +111,7 @@ describe('xtermWebglRenderer', () => { it.each(['unmount', 'context loss'] as const)('cancels deferred repair after %s', async reason => { const addon = addonHarness() const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await renderer.ready addon.removePage() if (reason === 'unmount') renderer.dispose() @@ -131,7 +131,7 @@ describe('xtermWebglRenderer', () => { const addon = addonHarness() addon.onRemoveTextureAtlasCanvas.mockImplementation(() => { throw new Error('registration failed') }) const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(false) expect(addon.disposeAddListener).toHaveBeenCalledTimes(1) expect(addon.disposeRemoveListener).not.toHaveBeenCalled() @@ -148,7 +148,7 @@ describe('xtermWebglRenderer', () => { return false }) const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await renderer.ready addon.removePage() await Promise.resolve() @@ -162,7 +162,7 @@ describe('xtermWebglRenderer', () => { it('reports readiness after handing a complete addon to the live terminal', async () => { const addon = addonHarness() const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(true) expect(addon.construct).toHaveBeenCalledTimes(1) @@ -180,7 +180,7 @@ describe('xtermWebglRenderer', () => { it('disposes the context listener and addon exactly once during ordinary host teardown', async () => { const addon = addonHarness() - const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon) + const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon, true) await expect(renderer.ready).resolves.toBe(true) renderer.dispose() @@ -193,7 +193,7 @@ describe('xtermWebglRenderer', () => { it('loads WebGL into a live terminal and falls back by disposing it on context loss', async () => { const addon = addonHarness() const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(true) expect(terminal.loadAddon).toHaveBeenCalledTimes(1) @@ -216,7 +216,7 @@ describe('xtermWebglRenderer', () => { const pending = deferred>() const terminal = terminalHarness() const load = vi.fn(() => pending.promise) - const renderer = attachXtermWebglRenderer(terminal, load) + const renderer = attachXtermWebglRenderer(terminal, load, true) // Fence a genuinely in-flight import, not only a loader that never started. await Promise.resolve() expect(load).toHaveBeenCalledTimes(1) @@ -240,7 +240,7 @@ describe('xtermWebglRenderer', () => { } // Optional GPU support must neither throw from attachment nor reject // ready: both turn recoverable renderer failures into broken terminals. - const renderer = attachXtermWebglRenderer(terminal, load) + const renderer = attachXtermWebglRenderer(terminal, load, true) await expect(renderer.ready).resolves.toBe(false) expect(terminal.loadAddon).not.toHaveBeenCalled() expect(() => renderer.dispose()).not.toThrow() @@ -251,7 +251,7 @@ describe('xtermWebglRenderer', () => { const addon = addonHarness() addon.construct.mockImplementation(() => { throw new Error('unsupported GPU') }) const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(false) renderer.dispose() @@ -268,7 +268,7 @@ describe('xtermWebglRenderer', () => { ...terminalHarness(), loadAddon: vi.fn(() => { throw new Error('context allocation failed') }), } - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(false) expect(terminal.loadAddon).toHaveBeenCalledTimes(1) @@ -283,7 +283,7 @@ describe('xtermWebglRenderer', () => { const addon = addonHarness() addon.onContextLoss.mockImplementation(() => { throw new Error('registration failed') }) const terminal = terminalHarness() - const renderer = attachXtermWebglRenderer(terminal, async () => addon) + const renderer = attachXtermWebglRenderer(terminal, async () => addon, true) await expect(renderer.ready).resolves.toBe(false) renderer.dispose() @@ -296,7 +296,7 @@ describe('xtermWebglRenderer', () => { const addon = addonHarness() addon.disposeContextListener.mockImplementation(() => { throw new Error('listener cleanup failed') }) addon.disposeAddon.mockImplementation(() => { throw new Error('GPU cleanup failed') }) - const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon) + const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon, true) await expect(renderer.ready).resolves.toBe(true) const disposeHost = vi.fn() @@ -315,7 +315,7 @@ describe('xtermWebglRenderer', () => { it('clears ownership before a disposer re-enters context-loss cleanup', async () => { const addon = addonHarness() addon.disposeContextListener.mockImplementation(() => addon.loseContext()) - const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon) + const renderer = attachXtermWebglRenderer(terminalHarness(), async () => addon, true) await expect(renderer.ready).resolves.toBe(true) renderer.dispose() @@ -323,4 +323,36 @@ describe('xtermWebglRenderer', () => { expect(addon.disposeContextListener).toHaveBeenCalledTimes(1) expect(addon.disposeAddon).toHaveBeenCalledTimes(1) }) + + it('does not attach anything by default, because the GPU renderer is off', async () => { + // Pins the WEBGL_RENDERER_ENABLED decision. Our pinned addon-webgl 0.19.0 + // corrupts its own texture atlas under streaming TUI output (upstream + // xterm.js #5883), and the fix exists only in a 0.20.0 beta whose peer + // range would drag @xterm/xterm itself onto a beta. Until that is stable + // the DOM renderer is what ships. If this case starts failing, someone + // re-enabled WebGL — make sure the upgrade actually happened. + const addon = addonHarness() + const terminal = terminalHarness() + const load = vi.fn(async () => addon) + + const renderer = attachXtermWebglRenderer(terminal, load) + + await expect(renderer.ready).resolves.toBe(false) + // Not merely inactive: the addon module is never even imported, so a + // disabled renderer costs no parse, no GPU context and no listeners. + expect(load).not.toHaveBeenCalled() + expect(terminal.loadAddon).not.toHaveBeenCalled() + // Disposing a renderer that never attached must still be safe — every + // call site disposes unconditionally on unmount. + expect(() => renderer.dispose()).not.toThrow() + }) + + it('reports not-ready without touching the terminal when explicitly disabled', async () => { + const terminal = terminalHarness() + const renderer = attachXtermWebglRenderer(terminal, async () => addonHarness(), false) + + await expect(renderer.ready).resolves.toBe(false) + expect(terminal.loadAddon).not.toHaveBeenCalled() + expect(terminal.refresh).not.toHaveBeenCalled() + }) }) diff --git a/src/renderer/src/workspace/terminal/xtermWebglRenderer.ts b/src/renderer/src/workspace/terminal/xtermWebglRenderer.ts index c1ba3940..6ee30824 100644 --- a/src/renderer/src/workspace/terminal/xtermWebglRenderer.ts +++ b/src/renderer/src/workspace/terminal/xtermWebglRenderer.ts @@ -53,6 +53,53 @@ const loadWebglAddon = async (): Promise => { } } +/** + * Is the GPU renderer allowed to attach at all? + * + * Currently NO, and this is the whole switch. Flip it back when the condition + * in the next paragraph is met; everything else in this file is intact and + * needs no other change. + * + * WHY: our pinned `@xterm/addon-webgl@0.19.0` corrupts its own texture atlas + * under exactly the workload this app runs all day — a provider TUI streaming + * heavy, colourful, constantly-redrawn output. Upstream xterm.js #5883 (merged + * 2026-05-21) names the two bugs precisely: a fresh atlas page replacing an old + * one AT THE SAME INDEX after a merge, which per-page version counters cannot + * detect, and a stale vertex buffer after a mid-render merge because + * `_requestClearModel` was set and never reset. The visible result is garbled + * and interleaved glyphs, characters substituted mid-word, and leftover + * inverse blocks that never repair because an idle terminal produces no further + * frame. + * + * WHY the `invalidateTextureBindings` bridge below was not enough: it can + * address the first bug's symptom from outside the addon. It cannot replicate + * the second bug's fix, nor the bounded retry loop upstream added inside + * `renderRows()` — both live below the addon's public surface. #789 shipped + * that workaround and was closed without confirming the reporter's screenshot; + * the corruption was reported again on 2026-09-08. + * + * WHY not simply upgrade, which is what the bridge's own comment anticipates: + * the fix ships only in `@xterm/addon-webgl@0.20.0-beta.219` and later, 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 in the app — onto a beta as well. That is a much + * larger surface than the one bug being fixed. + * + * WHY this costs less than it looks: the DOM renderer is xterm's default and + * is correct, and it is already the tested fallback every failure path here + * lands on. The perf work that introduced WebGL (#783, `3b885068`) had three + * parts, and the two structural ones — routing raw PTY channels once per + * renderer via sessionDataDispatcher, and coalescing inline grid resizes — + * are untouched by this. VS Code ships the same escape hatch for the same + * symptom class as `terminal.integrated.gpuAcceleration: "off"`. + * + * FLIP THIS BACK when `@xterm/addon-webgl` has a STABLE release containing + * #5883 whose peer range accepts a stable `@xterm/xterm`. At that point also + * delete the `invalidateTextureBindings` bridge above, which exists only to + * paper over 0.19.0. + */ +const WEBGL_RENDERER_ENABLED = false + /** * Upgrade an already-open xterm from its DOM renderer to WebGL when available. * @@ -71,7 +118,22 @@ const loadWebglAddon = async (): Promise => { export function attachXtermWebglRenderer( terminal: TerminalAddonHost, loadAddon: () => Promise = loadWebglAddon, + // WHY the gate is a parameter rather than read straight from the constant: + // this module's suite is the only record of how the attach, fallback, + // context-loss and atlas-repair machinery behaves, and that machinery has to + // keep working for the day the constant flips back. A hard-coded read would + // have made all fifteen of those cases vacuous the moment WebGL went off. + enabled: boolean = WEBGL_RENDERER_ENABLED, ): XtermWebglRenderer { + // Bail before the dynamic import, so a disabled renderer costs nothing at + // all: no addon parse, no GPU context, no atlas listeners. Callers keep + // their existing shape and simply never see WebGL become active — the same + // observable result as a machine where WebGL is unavailable by policy, which + // this file already had to handle correctly. + if (!enabled) { + return { ready: Promise.resolve(false), dispose() {} } + } + let disposed = false let addon: WebglAddonLike | null = null let contextLossDisposable: Disposable | null = null From d0b0be9bb78dca1240e47cf68b0ecb8fc374f965 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:34:42 -0700 Subject: [PATCH 11/32] fix(provider-switch): report transient refusals honestly and stop losing detail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six related defects in the switch path, all from the post-merge audit. **Mid-turn and mid-switch panes were reported as failures.** Both guards returned `status: 'failed'`, while BulkProviderSwitchModal's own footer promises "N of M are mid-turn and will be skipped until idle". Nothing is wrong with those panes: they are busy with something that ends on its own. Because arrival compaction is on by default for large conversations and holds that flag for minutes per pane, a bulk return during it announced "Returned 0 agents (20 failed)" for a batch where every agent was merely busy. Both are now 'skipped', and the forward summary counts skips instead of silently dropping them. **Every failure message and shrink summary was discarded.** The core goes out of its way to produce strings that name the exact remedy (the poisoned-carrier abort) and the exact loss (the shrink ladder's summary, added in cc0e908d specifically so no lossy step is silent). Bulk printed counts only: "Switched 0 agents to Claude (12 failed)", "12 raw". The single-pane path already surfaces both, and the sibling model-switch function in the modal already argues the case in its own comment. Reasons now ride the summary, deduped and capped at two because a batch usually fails for one shared reason and PaneToast clamps to three lines. **Return forced arrival compaction on with no consent.** returnPolicy hard-coded it for any Claude destination, so one "Return 20" click spent Claude quota twenty times and disabled twenty composers for the arrival wait plus the compaction wait, minutes each with no cancel — while the forward flow puts the same thing behind an explicit checkbox and a quota disclosure. The batch now records the consent the user actually gave and the return reuses it. **The in-flight claim sat outside the try that releases it.** A throw from the intervening setRuntimes leaked the id permanently in a module-scoped Set, and that pane then answered "Provider switch already in progress" for the rest of the window's life. **A synchronous throw from switchProvider leaked the progress listener.** `.finally(unsubscribeProgress)` is never attached if no promise is created, so the listener survived for the life of the renderer writing into a session that had moved on. startArrivalCompaction already guarded this; the transaction path did not. **The default plan waited up to 30s per pane for a prompt it never sends.** With allowSourceTurns explicitly false — the transaction default and the entire point of the quota-independent path — main plans from files on disk and never asks the source for anything. The wake stays, because it is what resolves built-in MCP domains under the source provider and what makes a later kind/cwd mismatch meaningful; only its readiness wait is skipped. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../bulkProviderSwitch.renderer.test.ts | 109 +++++++++++++++ .../hook/actions/bulkProviderSwitch.ts | 108 ++++++++++++--- .../providerSwitchCore.renderer.test.ts | 11 +- .../hook/actions/providerSwitchCore.ts | 128 ++++++++++++------ src/renderer/src/workspace/types.ts | 16 +++ 5 files changed, 313 insertions(+), 59 deletions(-) diff --git a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts index aa4416dc..82f788f5 100644 --- a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts +++ b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.renderer.test.ts @@ -58,6 +58,7 @@ function batchOf(...ids: string[]): ProviderSwitchBatch { sourceKind: 'claude', targetKind: 'codex', agents: ids.map(agent), + compactOnArrival: false, } as unknown as ProviderSwitchBatch } @@ -134,3 +135,111 @@ describe('returnLastProviderSwitchBatch', () => { expect(state.lastProviderSwitchBatch?.id).toBe('batch-2') }) }) + +describe('returnLastProviderSwitchBatch consent', () => { + it('does not compact on arrival when the forward batch did not consent', async () => { + // A Return click used to hard-code compaction on for any Claude + // destination. That spends Claude quota once per agent and disables every + // one of those composers for the arrival wait plus the compaction wait — + // minutes each, no cancel — while the forward flow puts the same thing + // behind an explicit checkbox and a quota disclosure. + switchAgentProvider.mockResolvedValue({ status: 'switched' }) + const { result } = harness(batchOf('a')) + + await result.current.returnLastProviderSwitchBatch() + + expect(switchAgentProvider).toHaveBeenCalledWith( + expect.objectContaining({ + contextPolicy: expect.objectContaining({ compactOnArrival: false }), + }), + ) + }) + + it('compacts on arrival when the forward batch did consent', async () => { + switchAgentProvider.mockResolvedValue({ status: 'switched' }) + const batch = batchOf('a') + batch.compactOnArrival = true + const { result } = harness(batch) + + await result.current.returnLastProviderSwitchBatch() + + expect(switchAgentProvider).toHaveBeenCalledWith( + expect.objectContaining({ + contextPolicy: expect.objectContaining({ compactOnArrival: true }), + }), + ) + }) + + it('never promises arrival compaction a non-Claude destination cannot do', async () => { + // compactAfterSwitch reports every non-Claude kind as a no-op, so honouring + // consent literally would advertise work that never happens. + switchAgentProvider.mockResolvedValue({ status: 'switched' }) + const batch = batchOf('a') + batch.compactOnArrival = true + batch.agents[0].originalKind = 'codex' + const { result } = harness(batch) + + await result.current.returnLastProviderSwitchBatch() + + expect(switchAgentProvider).toHaveBeenCalledWith( + expect.objectContaining({ + contextPolicy: expect.objectContaining({ compactOnArrival: false }), + }), + ) + }) +}) + +describe('bulk switch reporting', () => { + it('carries the reason into the summary instead of only a count', async () => { + // "Returned 0 agents to Claude (1 failed)" is unactionable. The core writes + // messages that name the remedy; bulk used to discard every one of them. + switchAgentProvider.mockResolvedValue({ + status: 'failed', + message: 'Poisoned carrier — clear the rate-limit message first', + }) + const { result, toasts } = harness(batchOf('a')) + + await result.current.returnLastProviderSwitchBatch() + + expect(toasts[0]).toContain('1 failed') + expect(toasts[0]).toContain('Poisoned carrier') + }) + + it('reports skipped agents separately from failed ones', async () => { + switchAgentProvider + .mockResolvedValueOnce({ status: 'skipped', reason: 'Still finishing a provider switch' }) + .mockResolvedValueOnce({ status: 'failed', message: 'Replacement failed' }) + const { result, toasts } = harness(batchOf('a', 'b')) + + await result.current.returnLastProviderSwitchBatch() + + expect(toasts[0]).toContain('1 skipped') + expect(toasts[0]).toContain('1 failed') + }) + + it('dedupes shared reasons and caps the list', async () => { + // Twenty agents usually fail for one reason, and PaneToast clamps to three + // lines — an uncapped list would push the counts out of view. + switchAgentProvider.mockResolvedValue({ status: 'failed', message: 'Same reason' }) + const { result, toasts } = harness(batchOf('a', 'b', 'c')) + + await result.current.returnLastProviderSwitchBatch() + + expect(toasts[0].match(/Same reason/g)).toHaveLength(1) + expect(toasts[0]).not.toContain('more') + }) + + it('surfaces the shrink summary a successful but lossy return produced', async () => { + // The shrink ladder's summary exists specifically so no lossy step is + // silent. Counting it as "1 shrunk" and dropping the text defeats that. + switchAgentProvider.mockResolvedValue({ + status: 'switched', + shrinkSummary: 'dropped 12 tool results', + }) + const { result, toasts } = harness(batchOf('a')) + + await result.current.returnLastProviderSwitchBatch() + + expect(toasts[0]).toContain('dropped 12 tool results') + }) +}) diff --git a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts index ca676d00..b8697eb0 100644 --- a/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts +++ b/src/renderer/src/workspace/hook/actions/bulkProviderSwitch.ts @@ -47,25 +47,63 @@ export type BulkSwitchPolicy = { sourceCompactionConfirmed: boolean } -/** The return path has no modal, so it hard-codes the safe policy. +/** The return path has no modal, so it carries the batch's recorded consent. * * `allowSourceTurns: false` because the source here is the provider the user * parked on, and a return usually happens because the ORIGINAL provider's * window reset — nothing licenses spending the parking provider's quota, and * the whole feature exists to avoid needing to. * - * `compactOnArrival` only for a Claude destination: Claude is the one target - * with a compaction the renderer can drive (see compactAfterSwitch, which - * reports every other kind as a no-op), and a returning transcript has grown - * by everything the agent did while parked. */ -function returnPolicy(targetKind: AgentProviderKind): BulkSwitchPolicy { + * `compactOnArrival` was previously hard-coded to `targetKind === 'claude'`, + * which meant one Return click spent Claude quota once per agent and locked + * every one of those composers for the arrival wait plus the compaction wait + * — minutes each, no cancel — with no checkbox and none of the quota + * disclosure the forward modal shows. It now reuses the consent the user gave + * for this exact batch. `&& targetKind === 'claude'` still applies because + * Claude is the one destination with a compaction the renderer can drive + * (compactAfterSwitch reports every other kind as a no-op), so consenting to + * it on a Codex return would promise something that cannot happen. */ +function returnPolicy( + targetKind: AgentProviderKind, + batchConsentedToArrivalCompaction: boolean, +): BulkSwitchPolicy { return { allowSourceTurns: false, - compactOnArrival: targetKind === 'claude', + compactOnArrival: batchConsentedToArrivalCompaction && targetKind === 'claude', sourceCompactionConfirmed: false, } } +/** + * Build the one toast a bulk operation gets. + * + * WHY the reasons ride along instead of only the counts: "Switched 0 agents to + * Claude (12 failed)" tells the user nothing they can act on, and the core + * deliberately produces strings that do — the poisoned-carrier abort names its + * remedy, and the shrink ladder's summary exists so no lossy step is silent. + * Deduped because a batch usually fails for one shared reason, and capped + * because PaneToast clamps to three lines and an uncapped list would push the + * counts out of view. + */ +const MAX_SUMMARY_NOTES = 2 + +function summarize( + base: string, + counts: { skipped: number; failed: number }, + notes: ReadonlySet, +): string { + const tally: string[] = [] + if (counts.skipped > 0) tally.push(`${counts.skipped} skipped`) + if (counts.failed > 0) tally.push(`${counts.failed} failed`) + let message = tally.length > 0 ? `${base} (${tally.join(', ')})` : base + const shown = [...notes].slice(0, MAX_SUMMARY_NOTES) + if (shown.length > 0) { + const remaining = notes.size - shown.length + message += ` · ${shown.join(' · ')}${remaining > 0 ? ` · +${remaining} more` : ''}` + } + return message +} + export function useBulkProviderSwitchActions( refs: WorkspaceRefs, setState: WorkspaceSetState, @@ -92,6 +130,21 @@ export function useBulkProviderSwitchActions( // enough that predictable mutation beats raw speed. const switched: ProviderSwitchBatchAgent[] = [] let failed = 0 + // 'skipped' used to be silently dropped here. Two of the core's most + // common outcomes — mid-turn, and "still finishing a provider switch" — + // are skips, not failures, and a batch where every agent was busy would + // otherwise print "Switched 0 agents" with no hint that anything was + // even attempted. + let skipped = 0 + // WHY the strings are kept rather than only counted: the core writes + // messages that name the exact remedy (the poisoned-carrier abort) and + // the exact loss (the shrink ladder's summary, added specifically so + // "no lossy step is silent"). Bulk discarded both and printed + // "Switched 0 agents to Claude (12 failed)" / "12 raw", which is + // unactionable — a point the sibling model-switch function in the modal + // already argues in its own comment. Deduped and capped, because twenty + // agents usually fail for the same one reason. + const notes = new Set() // Per-strategy tally, not a single "compacted" flag: a batch where nine // agents crossed losslessly and two had to be shrunk is a materially // different outcome from one where all eleven were shrunk, and the user @@ -129,8 +182,18 @@ export function useBulkProviderSwitchActions( // succeeded but lost its meta mid-loop is not in `switched`, so counting // its strategy would produce "Switched 2 agents to Claude: 3 native" — // a summary that contradicts itself. - if (result.status === 'switched' && meta && originalKind) { + // Branch on status FIRST so each arm narrows cleanly. The old shape + // folded `&& meta && originalKind` into the success test, which pushed + // a switched-but-meta-less result into the skipped arm. + if (result.status === 'failed') { + failed += 1 + notes.add(result.message) + } else if (result.status === 'skipped') { + skipped += 1 + notes.add(result.reason) + } else if (meta && originalKind) { counts[result.strategy] += 1 + if (result.shrinkSummary) notes.add(result.shrinkSummary) switched.push({ sessionId: result.newSessionId, cwd: meta.cwd, @@ -138,10 +201,14 @@ export function useBulkProviderSwitchActions( switchedToKind: targetKind, title: meta.title, }) - } else if (result.status === 'failed') { - failed += 1 + } else { + // Switched, but its meta vanished mid-loop so it cannot be recorded + // as a batch member. Counting its strategy would produce a summary + // that contradicts itself ("Switched 2 agents: 3 native"), and + // silently dropping it would under-report the work done. + skipped += 1 + notes.add('An agent switched but was closed before it could be recorded') } - // 'skipped' (e.g. already on target) is silently not part of the batch. } // Replace the remembered batch outright — one level of memory only. If @@ -157,6 +224,9 @@ export function useBulkProviderSwitchActions( sourceKind: switched[0].originalKind, targetKind, agents: switched, + // Recorded so Return can reuse this consent instead of deciding + // for the user. See returnPolicy. + compactOnArrival: policy.compactOnArrival, }, })) } @@ -167,7 +237,7 @@ export function useBulkProviderSwitchActions( counts.shrunk > 0 ? `${counts.shrunk} shrunk` : null, ].filter(Boolean).join(', ') const base = `Switched ${pluralAgents(switched.length)} to ${providerLabel(targetKind)}${tally ? `: ${tally}` : ''}` - showToast(failed > 0 ? `${base} (${failed} failed)` : base) + showToast(summarize(base, { skipped, failed }, notes)) }, [refs, sessionActions, setRuntimes, setState, showToast], ) @@ -182,6 +252,7 @@ export function useBulkProviderSwitchActions( let returned = 0 let skipped = 0 let failed = 0 + const notes = new Set() // Agents that did NOT make it home. See the batch update below for why // these have to survive: this modal is the only return affordance there is. const unreturned: typeof batch.agents = [] @@ -201,7 +272,7 @@ export function useBulkProviderSwitchActions( continue } - const policy = returnPolicy(agent.originalKind) + const policy = returnPolicy(agent.originalKind, batch.compactOnArrival) const result = await switchAgentProvider({ sessionId: agent.sessionId, targetKind: agent.originalKind, @@ -218,10 +289,13 @@ export function useBulkProviderSwitchActions( }) if (result.status === 'switched') { returned += 1 + if (result.shrinkSummary) notes.add(result.shrinkSummary) } else if (result.status === 'failed') { failed += 1 + notes.add(result.message) unreturned.push(agent) } else { + notes.add(result.reason) // 'skipped' here means the pane refused the switch right now — most // often "still finishing a provider switch". It is still sitting on // the target provider, so it is still returnable later. @@ -258,12 +332,8 @@ export function useBulkProviderSwitchActions( } }) - let message = `Returned ${pluralAgents(returned)} to ${providerLabel(batch.sourceKind)}` - const notes: string[] = [] - if (skipped > 0) notes.push(`${skipped} skipped`) - if (failed > 0) notes.push(`${failed} failed`) - if (notes.length > 0) message += ` (${notes.join(', ')})` - showToast(message) + const base = `Returned ${pluralAgents(returned)} to ${providerLabel(batch.sourceKind)}` + showToast(summarize(base, { skipped, failed }, notes)) }, [refs, sessionActions, setRuntimes, setState, showToast]) return { switchAgentsToProvider, returnLastProviderSwitchBatch } diff --git a/src/renderer/src/workspace/hook/actions/providerSwitchCore.renderer.test.ts b/src/renderer/src/workspace/hook/actions/providerSwitchCore.renderer.test.ts index 20cbfe8e..8cc16c4c 100644 --- a/src/renderer/src/workspace/hook/actions/providerSwitchCore.renderer.test.ts +++ b/src/renderer/src/workspace/hook/actions/providerSwitchCore.renderer.test.ts @@ -610,8 +610,15 @@ describe('switchAgentProvider', () => { replaceSession, } as unknown as SessionActions, })).resolves.toEqual({ - status: 'failed', - message: 'This pane is still finishing a provider switch — wait for it to complete', + // 'skipped', not 'failed'. Nothing is wrong with this pane — it is busy + // with an operation that ends on its own, and trying again shortly is + // the whole remedy. Reporting it as a failure made a bulk return during + // arrival compaction, which is the DEFAULT for large conversations and + // holds this flag for minutes per pane, announce "Returned 0 agents (20 + // failed)" for a batch where every agent was merely busy. The string is + // unchanged and still reaches the same pane toast. + status: 'skipped', + reason: 'This pane is still finishing a provider switch — wait for it to complete', }) expect(switchProvider).not.toHaveBeenCalled() diff --git a/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts b/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts index 42e3cd92..43ffd173 100644 --- a/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts +++ b/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts @@ -248,9 +248,16 @@ export async function switchAgentProvider(params: { // pane busy too, and "wait for the current turn to finish" would send the // user looking for a turn they never started. if (sourceRuntime?.providerSwitch) { + // 'skipped', not 'failed'. Nothing went wrong: this pane is busy with an + // operation that ends on its own, and the correct user response is to try + // again shortly. Reporting it as a failure made a bulk return during + // arrival compaction — which is the DEFAULT for large conversations and + // holds this flag for minutes per pane — announce "Returned 0 agents (20 + // failed)" for a batch where nothing was wrong with any of them. Both + // shapes carry their string to the same place on the single-pane path. return { - status: 'failed', - message: 'This pane is still finishing a provider switch — wait for it to complete', + status: 'skipped', + reason: 'This pane is still finishing a provider switch — wait for it to complete', } } // The usage-limit exception (see isLimitIdle): a pane whose provider is @@ -258,28 +265,39 @@ export async function switchAgentProvider(params: { // lock out precisely the agents this feature exists to move. Replacement // kills the process, which is what ends the provider's wait banner anyway. if ((sourceRuntime?.processActive || sourceRuntime?.semantic.currentTurn) && !(sourceRuntime && isLimitIdle(sourceRuntime))) { - return { status: 'failed', message: 'Wait for the current turn to finish before switching provider' } + // Also 'skipped': BulkProviderSwitchModal's own footer promises "N of M + // are mid-turn and will be skipped until idle", and reporting them as + // failures made the summary contradict the warning the user just read. + return { status: 'skipped', reason: 'Wait for the current turn to finish before switching provider' } } if (providerSwitchesInFlight.has(sessionId)) { return { status: 'failed', message: 'Provider switch already in progress' } } - providerSwitchesInFlight.add(sessionId) - setRuntimes(prev => { - const runtime = prev[sessionId] - if (!runtime) return prev - return { - ...prev, - [sessionId]: { - ...runtime, - providerSwitch: { - phase: 'preparing', - message: `Preparing switch to ${targetKind}…`, + // WHY the claim and the first runtime write are INSIDE the try: the release + // lives in this function's `finally`, and both statements used to sit above + // it. A throw from that setRuntimes — a subscriber, a selector, anything in + // the store's update path — leaked the id permanently in a module-scoped + // Set, and that pane then answered "Provider switch already in progress" + // for the rest of the window's life with no way back short of a reload. + // Deleting an id that was never added is a no-op, so widening the try costs + // nothing. + try { + providerSwitchesInFlight.add(sessionId) + setRuntimes(prev => { + const runtime = prev[sessionId] + if (!runtime) return prev + return { + ...prev, + [sessionId]: { + ...runtime, + providerSwitch: { + phase: 'preparing', + message: `Preparing switch to ${targetKind}…`, + }, }, - }, - } - }) + } + }) - try { const sourceProviderSessionId = resumableProviderSessionId(meta) if (!sourceProviderSessionId) { // A freshly-spawned provider pane has no durable provider transcript yet. @@ -307,7 +325,29 @@ export async function switchAgentProvider(params: { // recovery is a real mid-transaction ownership change, not ordinary pane // hibernation. `ensureSessionLive` is idempotent for an already-live owner // and main's recovery claim serializes concurrent wake attempts. - const wakeResult = await sessionActions.ensureSessionLive(sessionId, 'provider-switch.wake-source') + // WHY the wake stays but its READINESS WAIT does not, when the caller has + // explicitly ruled out source turns: + // + // The wake itself is load-bearing and must not be removed — it is what + // resolves this pane's built-in MCP domains under the SOURCE provider (see + // the provenance comment below), and what makes a later kind/cwd mismatch + // mean a real ownership change rather than ordinary hibernation. + // + // The 30s input-readiness wait is a different thing, and with + // `allowSourceTurns: false` — the transaction default, and the entire + // point of the quota-independent path — main plans the conversion from + // files on disk and never asks the source to do anything. Waiting for a + // prompt we will never send costs up to 30s per pane, serialised across a + // bulk switch, before `replaceSession` kills the process anyway. + // + // Gated on an EXPLICIT false rather than on `!allowSourceTurns`: an absent + // contextPolicy means "main decides", and the renderer must not assume + // which way DEFAULT_SWITCH_CONTEXT_POLICY went. + const wakeResult = await sessionActions.ensureSessionLive( + sessionId, + 'provider-switch.wake-source', + ...(contextPolicy?.allowSourceTurns === false ? [{ awaitInputReady: false }] : []), + ) // The translated target transcript must be created BEFORE we replace the // live pane. If translation fails, the current provider process should stay @@ -331,25 +371,37 @@ export async function switchAgentProvider(params: { }) onProgress?.({ phase: event.phase, message: event.message }) }) - const result = await window.api.switchProvider({ - sourceKind, - // Explicit target (#394 phase 5a). This helper always KNEW the - // target — its callers pass it — but historically dropped it - // before IPC and relied on main's two-provider negation. With - // the negation slated for removal, the renderer's choice is now - // authoritative end-to-end. - targetKind, - sourceProviderSessionId, - sourceSessionId: sessionId, - cwd: meta.cwd, - // A policy the caller passed and this function silently dropped would be - // a trap for the caller that sets `allowSourceTurns` and wonders why the - // source was never asked to compact. Both keys are spread conditionally - // so an unset policy still reaches main as "absent", letting - // DEFAULT_SWITCH_CONTEXT_POLICY stay the single source of the defaults. - ...(contextPolicy ? { contextPolicy } : {}), - ...(sourceCompactionConfirmed ? { sourceCompactionConfirmed } : {}), - }).finally(unsubscribeProgress) + // WHY try/finally rather than `.finally(unsubscribeProgress)` on the + // promise: if `window.api.switchProvider` throws SYNCHRONOUSLY — a preload + // shape mismatch, a serialisation failure on the argument object — no + // promise is ever created, `.finally` is never attached, and this listener + // survives for the life of the renderer, writing into a session that has + // moved on. `startArrivalCompaction` already guards exactly this case; + // this path did not. + let result: Awaited> + try { + result = await window.api.switchProvider({ + sourceKind, + // Explicit target (#394 phase 5a). This helper always KNEW the + // target — its callers pass it — but historically dropped it + // before IPC and relied on main's two-provider negation. With + // the negation slated for removal, the renderer's choice is now + // authoritative end-to-end. + targetKind, + sourceProviderSessionId, + sourceSessionId: sessionId, + cwd: meta.cwd, + // A policy the caller passed and this function silently dropped would be + // a trap for the caller that sets `allowSourceTurns` and wonders why the + // source was never asked to compact. Both keys are spread conditionally + // so an unset policy still reaches main as "absent", letting + // DEFAULT_SWITCH_CONTEXT_POLICY stay the single source of the defaults. + ...(contextPolicy ? { contextPolicy } : {}), + ...(sourceCompactionConfirmed ? { sourceCompactionConfirmed } : {}), + }) + } finally { + unsubscribeProgress() + } if (result.kind === 'source-empty') { return await replaceTranscriptlessPane() 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 From 49eb2512576b078fe1b6ca766fc91a166f243dfb Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:34:42 -0700 Subject: [PATCH 12/32] fix(provider-switch): close the bulk modal's double-run and stale-confirmation races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four defects in BulkProviderSwitchModal. **A /model fan-out could run twice over the same panes.** Every close guard keyed on `busy`, but `runModelSwitch` sets only `switchingModel`. Escape during the sequential loop was therefore allowed, the open-reset effect cleared `switchingModel`, and a second click started a second loop interleaving PTY writes on panes that had already received `/model sonnet` — precisely the race the sequential loop exists to prevent. All three guards now lock on either flag, and the reset effect refuses to run over a live loop, which was the other half: `open` is store state, so `closeBulkProviderSwitch` from any other surface could close the modal past the guards and reopening cleared the flag that single-flights it. **"Ask once" was armed against a set that could grow.** The confirmation arms on the first click and is consumed on the second, and every MANUAL change to the set 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 joined between the clicks, so a user who confirmed "compact 3 agents on Codex first" could have four agents' live history rewritten. The confirmed session ids are now snapshotted when arming and are what the confirmed run acts on. Panes that closed in between are skipped by the action itself, which re-reads meta per iteration; an unconfirmed id is the thing that must not get through. **A rejected deliverPrompt reported success.** runModelSwitch had try/finally with no catch, so an IPC rejection aborted the batch mid-way and the finally still announced "Sent /model … to 3 agents" with zero failures, for every agent the loop never reached. **The size estimate re-walked every pane on every runtime tick.** The memo's own comment names `workspace.runtimes` as one of the highest-churn references in the app, and then depended on it — O(rows x up to 2000 entries) per streaming tick while the modal is open, to produce one threshold comparison that defaults one checkbox. It now reads the freshest runtimes through a ref and recomputes only when the row set or the open state changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../workspace/ui/BulkProviderSwitchModal.tsx | 101 ++++++++++++++++-- 1 file changed, 92 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index 511da178..a5b2a449 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('') @@ -341,17 +370,32 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // 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 + // WHY the runtimes are read through a REF rather than as a dependency: + // + // Gating on `open` stopped this from running while the modal is closed, but + // while it is OPEN the memo still depended on `workspace.runtimes` — the + // comment directly above names it "one of the highest-churn references in + // the app". Every streaming tick from any pane therefore re-walked up to + // 2000 entries for every matching row, and the whole point of the number is + // a single threshold comparison that defaults one checkbox. + // + // The estimate only needs to be right when the row set or the open state + // changes. Reading the freshest runtimes out of a ref at that moment gives + // exactly that, with no dependency on their identity. + const runtimesRef = useRef(workspace.runtimes) + runtimesRef.current = workspace.runtimes const largestSourceEstimate = useMemo(() => { + if (!open) return 0 + const runtimes = runtimesRef.current let largest = 0 for (const row of matchingRows) { - const runtime = runtimesForEstimate[row.sessionId] + const runtime = runtimes[row.sessionId] if (!runtime) continue const estimate = estimateLiveEntriesBytes(runtime.entries) if (estimate > largest) largest = estimate } return largest - }, [matchingRows, runtimesForEstimate]) + }, [matchingRows, 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 +427,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 +438,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,6 +459,7 @@ 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) }, []) @@ -423,12 +472,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,7 +503,7 @@ 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 @@ -473,6 +530,19 @@ 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 and the `finally` still announced + // "Sent /model … to 3 agents" with failed === 0 — reporting success for + // every agent the loop never reached. + const remaining = matchingRows.length - delivered - failed + failed += Math.max(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 @@ -501,10 +571,21 @@ 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 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() }} >
@@ -593,6 +674,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // source. Changing direction changes whose quota would be // spent, so the armed second click must not carry over. setSourceConfirmArmed(false) + setConfirmedSessionIds(null) }} className="rounded-control px-2 py-1.5 bg-canvas border border-border text-[12px] text-ink outline-none focus:border-accent" > @@ -666,6 +748,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { onChange={e => { setCompactOnSourceChoice(e.target.checked) setSourceConfirmArmed(false) + setConfirmedSessionIds(null) }} className="mt-0.5 accent-current disabled:opacity-50" /> From 11921156cf3a1ccab1cc215b35e00c02f204c59d Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:40:29 -0700 Subject: [PATCH 13/32] fix(workspace): stop burning an agent name on every replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 100-name vocabulary drained at the rate of RELOADS, not new agents. Roughly a hundred reloads and every fresh agent is "Apollo 2". `spawn` deliberately does not mint an identity — a seed there would land on the successor and win the replacement spread, renaming a pane that only changed backends. So a replacement successor is committed to `state.sessions` with no `agentNameId`, and `replaceSession` then awaits `killSessionBackendIfOwned`, a full IPC round trip. React flushes in that gap. The reconciler, mounted in the same component, sees an identity-less agent, claims one, and main allocates a name and commits it to disk, advancing nextIndex. Only afterwards does the replacement commit overwrite the identity with the carried one. The allocated name is then referenced by nothing, and the registry never recycles. Every reload, resume, rewind and provider switch did this. Multi-pane Undo Close burned up to N-1 per restored tab on top, because it spawns in a sequential await loop. The successor is now reserved at MINT time — inside spawn, before its own commit, which is the only place early enough — and released in a finally covering every exit path, because a stranded reservation would leave that pane permanently unnamed, the mirror-image bug. The reservation is conditional on the predecessor actually having an identity to carry, so replacements that carry nothing keep today's behaviour: the reconciler claims the successor under its own id, which the replacement commit's own comment already describes as correct. `pendingReplacementSuccessorsRef` could not be reused: it answers a different question and is populated only for a Codex same-rollout handoff transaction, saying nothing about Claude, OpenCode, fresh Codex, or different-transcript swaps, which are most replacements. Two related registry fixes: - The allocator rebuilt a normalized Set over every assignment on every call, to answer a question whose answer only changes when this process writes. `load` already builds that set for its duplicate check, so it is cached there, with explicit rollback if a commit fails. - Documented why there is still no prune path. Dropping assignments requires knowing which identities are live, and no single window knows that — pruning against one window's set would delete names belonging to agents open in another and hand them out twice. The growth RATE was the real problem and it is fixed above. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/agentNames/ipc.ts | 13 +- src/main/agentNames/registry.ts | 59 +++- .../agentNames/pendingIdentityCarry.test.ts | 81 +++++ .../agentNames/pendingIdentityCarry.ts | 65 ++++ .../src/workspace/agentNames/reconcile.ts | 21 +- .../agentNames/reconciler.renderer.test.tsx | 81 +++++ .../agentNames/useAgentNameReconciler.ts | 49 ++- .../src/workspace/hook/actions/session.ts | 331 ++++++++++-------- src/shared/types/agentNames.ts | 28 ++ 9 files changed, 568 insertions(+), 160 deletions(-) create mode 100644 src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts create mode 100644 src/renderer/src/workspace/agentNames/pendingIdentityCarry.ts create mode 100644 src/shared/types/agentNames.ts 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/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts new file mode 100644 index 00000000..0b1e0fdd --- /dev/null +++ b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.test.ts @@ -0,0 +1,81 @@ +import { afterEach, describe, expect, it } from 'vitest' + +import { claimMissingIdentities } from './reconcile' +import { + identityCarryIsPending, + releaseIdentityCarry, + reserveIdentityCarry, + resetIdentityCarryForTests, +} from './pendingIdentityCarry' +import type { WorkspaceState } from '@renderer/workspace/types' + +// The leak this closes, restated because it is not obvious from the code: +// +// `spawn` deliberately does not mint an identity, so a replacement successor +// is committed to state with none. `replaceSession` then awaits +// `killSessionBackendIfOwned` — a full IPC round trip — and React flushes in +// that gap. The reconciler sees an identity-less agent, claims one, and main +// allocates a name and commits it to disk, advancing nextIndex. The +// replacement commit then overwrites the identity, orphaning that name +// forever, because the registry never recycles. The 100-name vocabulary +// therefore drained at the rate of RELOADS, not new agents. + +afterEach(resetIdentityCarryForTests) + +function workspace(sessions: Record): WorkspaceState { + return { sessions, buried: [] } as unknown as WorkspaceState +} + +describe('identity carry reservation', () => { + it('claims an identity for an ordinary unnamed agent', () => { + const next = claimMissingIdentities(workspace({ + 'agent-one': { cwd: '/recorded', kind: 'claude' }, + })) + expect(next.sessions['agent-one'].agentNameId).toBe('agent-one') + }) + + it('does not claim one for a successor whose identity is already in flight', () => { + reserveIdentityCarry('successor') + const state = workspace({ successor: { cwd: '/recorded', kind: 'claude' } }) + + const next = claimMissingIdentities(state) + + expect(next.sessions.successor.agentNameId).toBeUndefined() + // Identity-preserving, so no downstream memo in the workspace tree is + // invalidated by a pass that decided to do nothing. + expect(next).toBe(state) + }) + + it('claims again as soon as the reservation is released', () => { + // The release must genuinely re-open the claim: a stranded reservation + // would leave that pane permanently unnamed, which is the mirror-image bug + // and just as bad as the leak. + reserveIdentityCarry('successor') + releaseIdentityCarry('successor') + + const next = claimMissingIdentities(workspace({ + successor: { cwd: '/recorded', kind: 'claude' }, + })) + + expect(next.sessions.successor.agentNameId).toBe('successor') + }) + + it('reserves only the named session, never its neighbours', () => { + reserveIdentityCarry('successor') + + const next = claimMissingIdentities(workspace({ + successor: { cwd: '/recorded', kind: 'claude' }, + bystander: { cwd: '/recorded', kind: 'codex' }, + })) + + expect(next.sessions.successor.agentNameId).toBeUndefined() + expect(next.sessions.bystander.agentNameId).toBe('bystander') + }) + + it('treats releasing an unreserved id as a no-op', () => { + // replaceSession releases in a `finally` that also covers paths where no + // reservation was ever taken (an ordinary spawn carries no identity). + expect(() => releaseIdentityCarry('never-reserved')).not.toThrow() + expect(identityCarryIsPending('never-reserved')).toBe(false) + }) +}) diff --git a/src/renderer/src/workspace/agentNames/pendingIdentityCarry.ts b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.ts new file mode 100644 index 00000000..92f30afe --- /dev/null +++ b/src/renderer/src/workspace/agentNames/pendingIdentityCarry.ts @@ -0,0 +1,65 @@ +import type { SessionId } from '@renderer/workspace/types' + +/** + * Successor panes that are mid-`replaceSession` and are about to inherit their + * predecessor's `agentNameId`. + * + * WHY this exists — the name leak it closes: + * + * `spawn` deliberately does not mint an identity (see its comment: a seed + * there would land on the successor and win the replacement spread, renaming a + * pane that only changed backends). So a freshly spawned successor is + * committed to `state.sessions` with NO `agentNameId`, and `replaceSession` + * then awaits `killSessionBackendIfOwned` — a full IPC round trip, i.e. a + * macrotask boundary. React flushes in that gap. The reconciler, mounted in + * the same component, sees an identity-less agent, claims one, and main + * ALLOCATES A NAME AND COMMITS IT TO DISK, advancing `nextIndex`. Only + * afterwards does the replacement commit overwrite `agentNameId` with the + * carried one. + * + * The allocated name is now referenced by nothing, and the registry guarantees + * names are never recycled. So the 100-entry vocabulary drained at the rate of + * REPLACEMENTS — every reload, resume, rewind and provider switch — rather + * than at the rate of new agents. Roughly a hundred reloads and every new + * agent is "Apollo 2". Multi-pane Undo Close burned up to N-1 per restored tab + * on top of that, because it spawns in a sequential await loop. + * + * WHY a module-scoped set rather than a threaded ref: the writer + * (`replaceSession`, in useSessionActions) and the reader + * (`claimMissingIdentities`, called from useAgentNameReconciler) are siblings + * under one component with no existing channel between them, and widening + * SessionActions to carry a naming detail would put a workspace concern in an + * unrelated public shape. Each BrowserWindow is its own JS realm and session + * ids are window-scoped, so one module-level set is exactly one window's + * worth of state. `providerSwitchesInFlight` is the same pattern for the same + * reason. + * + * WHY it is not merely `pendingReplacementSuccessorsRef`: that set exists for + * a different question and is populated ONLY when main reports a Codex + * same-rollout handoff transaction. It says nothing about Claude, OpenCode, + * fresh Codex, or different-transcript swaps, which are most replacements. + */ +const pendingIdentityCarry = new Set() + +/** + * Mark a successor as "its identity is arriving in this same tick's commit". + * The caller MUST pair this with `releaseIdentityCarry` on every exit path, + * including failures — a stranded id would leave that pane permanently + * unnamed, which is the opposite failure and just as bad. + */ +export function reserveIdentityCarry(sessionId: SessionId): void { + pendingIdentityCarry.add(sessionId) +} + +export function releaseIdentityCarry(sessionId: SessionId): void { + pendingIdentityCarry.delete(sessionId) +} + +export function identityCarryIsPending(sessionId: SessionId): boolean { + return pendingIdentityCarry.has(sessionId) +} + +/** Test seam. Never call from application code. */ +export function resetIdentityCarryForTests(): void { + pendingIdentityCarry.clear() +} diff --git a/src/renderer/src/workspace/agentNames/reconcile.ts b/src/renderer/src/workspace/agentNames/reconcile.ts index 7b061a6c..57450096 100644 --- a/src/renderer/src/workspace/agentNames/reconcile.ts +++ b/src/renderer/src/workspace/agentNames/reconcile.ts @@ -1,3 +1,5 @@ +import { AGENT_NAME_IDENTITY_MAX_LENGTH } from '@shared/types/agentNames' +import { identityCarryIsPending } from '@renderer/workspace/agentNames/pendingIdentityCarry' import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { SessionMeta, WorkspaceState } from '@renderer/workspace/types' @@ -26,9 +28,20 @@ function isNameable(meta: Pick): boolean { * naming for every agent in the window. Buried records never pass through * the claim above (it walks `state.sessions` only), so that guard cannot * cover this one. + * + * WHY the LENGTH bound is imported rather than written here: this comment + * already named main's schema verbatim, but only the type half was actually + * mirrored. An identity longer than 200 characters passed this check, entered + * the request array, and made the whole batch fail validation — the exact + * batch-wide outage the paragraph above describes, reachable from one + * hand-edited workspace.json. Sharing the constant makes the two halves + * impossible to drift apart again. */ function identityOf(meta: Pick): string | null { - return typeof meta.agentNameId === 'string' && meta.agentNameId.length > 0 ? meta.agentNameId : null + const identity = meta.agentNameId + if (typeof identity !== 'string') return null + if (identity.length === 0 || identity.length > AGENT_NAME_IDENTITY_MAX_LENGTH) return null + return identity } /** @@ -49,6 +62,12 @@ export function claimMissingIdentities(state: WorkspaceState): WorkspaceState { const sessions = { ...state.sessions } for (const [sessionId, meta] of Object.entries(state.sessions)) { if (!isNameable(meta) || identityOf(meta)) continue + // A successor mid-replacement is about to inherit its predecessor's + // identity in this same operation. Claiming one for it here allocates a + // name that the very next commit overwrites and that nothing will ever + // reference again — and names are never recycled. See + // pendingIdentityCarry for why the gap is observable at all. + if (identityCarryIsPending(sessionId)) continue sessions[sessionId] = { ...meta, agentNameId: sessionId } changed = true } diff --git a/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx b/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx index 12668339..b02630eb 100644 --- a/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx +++ b/src/renderer/src/workspace/agentNames/reconciler.renderer.test.tsx @@ -297,4 +297,85 @@ describe('agent name reconciliation', () => { names: stored, })).toBe('Apollo') }) + + it('stops re-asking a broken registry after a few failures for the same set', async () => { + // `identities` is a memo over workspace state and returns a fresh array on + // every workspace change, and the request set is cleared on SETTLE. With an + // unreadable agent-names.json — a state the registry deliberately never + // caches — every focus change, title edit, pin, split and close therefore + // fired another failing IPC round trip, forever, with nothing visible to + // the user. The effect's own comment claimed that could not happen. + const resolveAgentNames = vi.fn(async () => { throw new Error('registry unreadable') }) + const mounted = mount({ enabled: true, resolveAgentNames }) + await waitFor(() => expect(resolveAgentNames).toHaveBeenCalled()) + await act(async () => { await Promise.resolve() }) + + // Nudge the workspace repeatedly without changing WHICH identities exist. + for (let i = 0; i < 6; i += 1) { + await act(async () => { + mounted.control.current?.(prev => ({ ...prev, activeTabId: `tab-${i}` })) + await Promise.resolve() + }) + } + + // Three attempts, not one: a rejection can be a transient collision on the + // shared serialization tail, so giving up immediately would leave agents + // unnamed for a condition that fixes itself. + expect(resolveAgentNames.mock.calls.length).toBeLessThanOrEqual(3) + }) + + it('asks again once the identity set actually changes', async () => { + // The breaker must not become a permanent mute: a new agent is a new + // question, and the registry may have been repaired since. + const resolveAgentNames = vi.fn(async () => { throw new Error('registry unreadable') }) + const mounted = mount({ enabled: true, resolveAgentNames }) + await waitFor(() => expect(resolveAgentNames).toHaveBeenCalled()) + await act(async () => { await Promise.resolve() }) + for (let i = 0; i < 5; i += 1) { + await act(async () => { + mounted.control.current?.(prev => ({ ...prev, activeTabId: `tab-${i}` })) + await Promise.resolve() + }) + } + const beforeNewAgent = resolveAgentNames.mock.calls.length + + await act(async () => { + mounted.control.current?.(prev => ({ + ...prev, + sessions: { ...prev.sessions, 'agent-two': { cwd: '/recorded', kind: 'claude' } }, + })) + await Promise.resolve() + }) + + await waitFor(() => + expect(resolveAgentNames.mock.calls.length).toBeGreaterThan(beforeNewAgent)) + }) + + it('treats an over-long identity as absent, so one bad record cannot mute the window', async () => { + // main validates z.string().min(1).max(200) over the WHOLE array, so a + // single hand-edited identity longer than that made requestSchema.parse + // reject every identity in the batch — and the reconciler swallows that + // silently, so no agent in the window ever received a name. The renderer's + // own comment named main's schema verbatim but mirrored only its type half. + const resolveAgentNames = vi.fn(async (identities: string[]) => + Object.fromEntries(identities.map(identity => [identity, 'Apollo']))) + const base = workspace() + const initial = { + ...base, + sessions: { + ...base.sessions, + 'agent-long': { cwd: '/recorded', kind: 'claude', agentNameId: 'x'.repeat(201) }, + }, + } as unknown as WorkspaceState + mount({ enabled: true, resolveAgentNames, initial }) + + await waitFor(() => expect(resolveAgentNames).toHaveBeenCalled()) + await act(async () => { await Promise.resolve() }) + + const asked = resolveAgentNames.mock.calls.flatMap(call => call[0]) + expect(asked.some(identity => identity.length > 200)).toBe(false) + // And the agent heals rather than staying stuck: an unusable identity is + // re-claimed exactly like a malformed one. + expect(asked).toContain('agent-long') + }) }) diff --git a/src/renderer/src/workspace/agentNames/useAgentNameReconciler.ts b/src/renderer/src/workspace/agentNames/useAgentNameReconciler.ts index a0169ac8..c5424383 100644 --- a/src/renderer/src/workspace/agentNames/useAgentNameReconciler.ts +++ b/src/renderer/src/workspace/agentNames/useAgentNameReconciler.ts @@ -19,6 +19,18 @@ import type { WorkspaceState } from '@renderer/workspace/types' * agent-names.json, and enabling is what assigns names to the agents that * already exist. */ +/** + * How many consecutive allocation failures for the SAME identity set before + * this window stops asking. + * + * Three rather than one: a rejection can be a transient write collision + * between windows on the shared serialization tail, and giving up on the first + * one would leave agents unnamed for a condition that resolves itself. It + * resets on any successful reply, and a changed identity set is always asked + * again regardless. + */ +const MAX_CONSECUTIVE_ALLOCATION_FAILURES = 3 + export function useAgentNameReconciler( state: WorkspaceState, setState: WorkspaceSetState, @@ -32,6 +44,26 @@ export function useAgentNameReconciler( // every identity on each state change until the reply lands, and a slow disk // would turn one membership change into a burst of allocations. const requestedRef = useRef(new Set()) + /** + * Consecutive rejected allocations, and the identity set that was in flight + * when the last one failed. + * + * WHY a circuit breaker is needed at all: the effect below clears + * `requestedRef` on SETTLE, and `identities` is a memo over `state` that + * returns a fresh array on every workspace change. So with an unreadable + * `agent-names.json` — a state the registry deliberately never caches, so it + * is retried forever — every focus change, title edit, pin, split and close + * fired another failing IPC round trip, indefinitely, with nothing visible + * to the user. The effect's own comment claimed "on failure no dep changed + * at all — so a broken registry cannot become a hot loop", which is true + * only while the workspace is completely idle. + * + * Retrying on a genuine membership change is still right: that is a new + * question, and the registry may have been repaired. The signature is what + * distinguishes it from the same question asked again. + */ + const failuresRef = useRef(0) + const failedSignatureRef = useRef(null) // WHY cancellation is unmount-scoped rather than a per-effect `cancelled` // flag: this effect's deps include `state` and `names`, so an ordinary @@ -91,6 +123,11 @@ export function useAgentNameReconciler( !Object.prototype.hasOwnProperty.call(names, identity) && !requestedRef.current.has(identity)) if (missing.length === 0) return + // Cheap because it only runs when there is something to ask for, which is + // exactly the case the memo's comment declined to pay for on every render. + const signature = [...missing].sort().join('\u0000') + if (failuresRef.current >= MAX_CONSECUTIVE_ALLOCATION_FAILURES + && failedSignatureRef.current === signature) return for (const identity of missing) requestedRef.current.add(identity) void window.api.resolveAgentNames(missing) @@ -101,6 +138,9 @@ export function useAgentNameReconciler( // identity, and to nothing at all if the agent closed. Writing it is // what makes a replacement that completed mid-flight inherit its name. if (!mountedRef.current) return + // A reply of any shape means the registry is readable again. + failuresRef.current = 0 + failedSignatureRef.current = null setNames(previous => { // WHY entries + spread instead of `merged[identity] = name`: // @@ -137,7 +177,10 @@ export function useAgentNameReconciler( }) // Never fabricate a name. A failed allocation is simply an agent with no // visible name until something changes. - .catch(() => {}) + .catch(() => { + failuresRef.current += 1 + failedSignatureRef.current = signature + }) .finally(() => { // Clear on SETTLE, not only on rejection. Whatever this request // answered is now in `names` and will filter itself out; whatever it @@ -145,7 +188,9 @@ export function useAgentNameReconciler( // change, rather than stranded in this set for the life of the window. // This does not re-run the effect on its own: on success `names` // changed and the recomputed `missing` is empty, and on failure no dep - // changed at all — so a broken registry cannot become a hot loop. + // changed at all. Note that the second half only holds while the + // workspace is idle — any state change produces a fresh `identities` + // array — which is what the failure circuit breaker above covers. for (const identity of missing) requestedRef.current.delete(identity) }) }, [identities, names, setNames]) diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index d062b4bc..3cc76bda 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -25,6 +25,10 @@ import { } from '@renderer/workspace/idRemap' import { closeLeaf, collectLeaves, remapTileTreeSessionIds } from '@renderer/workspace/tile-tree/treeOps' import type { Tab } from '@renderer/workspace/types' +import { + releaseIdentityCarry, + reserveIdentityCarry, +} from '@renderer/workspace/agentNames/pendingIdentityCarry' import { sessionSpawnErrorMessage } from '@renderer/workspace/spawn/errorMessage' import { ghostsToPersist, @@ -342,6 +346,25 @@ export function useSessionActions( sessionId = result.sessionId tmuxName = result.tmuxName startedProviderSessionId = result.providerSessionId + // WHY the reservation happens HERE, before this function's own commit: + // + // The gap the reconciler exploits opens the moment the successor's + // metadata lands in `state.sessions` without an `agentNameId`, and + // that is the setState a few lines below. `replaceSession` cannot + // reserve after `await spawn(...)` because by then React has already + // been given the chance to flush. Only spawn knows the id early + // enough. + // + // Conditional on the predecessor actually HAVING an identity, so + // replacements that carry nothing keep today's behaviour exactly: + // the reconciler claims the successor under its own id, which the + // replacement commit's comment already describes as the correct + // outcome. `predecessorSessionId` is passed by `replaceSession` and + // nothing else, so this cannot fire for an ordinary spawn. + if (opts?.predecessorSessionId + && refs.stateRef.current.sessions[opts.predecessorSessionId]?.agentNameId !== undefined) { + reserveIdentityCarry(sessionId) + } if (result.replacementTransactionId) { // WHY presence, not renderer inference: only main can prove the // successor targeted the predecessor's exact Codex rollout and @@ -1122,160 +1145,168 @@ export function useSessionActions( predecessorSessionId: oldId, ...(builtInMcpDomains !== undefined ? { builtInMcpDomains } : {}), }) - const mainHandledPredecessor = - pendingReplacementSuccessorsRef.current.delete(newId) - // A source can close or be buried while spawn awaits. Metadata alone - // is not ownership: committing an unplaced successor creates an invisible - // process and a false "completed" lifecycle receipt (#815). Read through - // the synchronous domain setter because React-owned refs can lag the last - // close action. A no-op updater preserves store identity and notifications. - let sourceOwned = false - setState(prev => { sourceOwned = canCommit(prev); return prev }) - if (!sourceOwned) { - await killSession(newId, { cwd, kind: nextKind, providerRuntime }) - return - } - if (!mainHandledPredecessor) { - await killSessionBackendIfOwned(refs, oldId, oldMeta) - } - // Swap the sessionId wherever this live session is placed. Grid sessions - // live in one tile-tree leaf; detached Dispatch sessions live in - // detachedSessions with no leaf at all. - const idMap = new Map([[oldId, newId]]) - - let committed = false - setState(prev => { - // Backend retirement is another await. Recheck inside the actual remap - // commit, before touching the source draft or returning a successor ID. - if (!canCommit(prev)) return prev - committed = true - const sessions = { ...prev.sessions } - // WHY read the title from `prev` here instead of the pre-spawn - // snapshot: provider switches and rewinds can wait on backend work, - // and the user may edit or clear the pane's title during that window. - // The replacement is the same logical pane with a fresh transport id, - // so its durable glance label must follow using the latest state rather - // than being lost—or resurrected from a stale snapshot—on completion. - const replacementTitle = prev.sessions[oldId]?.title - // `prev.sessions[oldId]` is still readable here: only the local - // `sessions` copy has had oldId deleted. - const carriedAgentNameId = prev.sessions[oldId]?.agentNameId - delete sessions[oldId] - // Persist the replacement provider metadata immediately - // instead of waiting for the first transcript line to - // round-trip back from main. That wait window is usually - // short, but it is still a real race: a workspace save or - // pane action that snapshots SessionMeta in that gap would - // see "new session id, but no providerSessionId yet" and - // could forget how to resume the pane on the next launch. - // - // Keeping the requested resumeSessionId here makes - // replaceSession the single source of truth for "this pane - // now points at provider X's persisted transcript Y", - // whether the trigger was the resume picker or the new - // switch-provider flow. - sessions[newId] = { - ...(sessions[newId] ?? { cwd, kind: nextKind }), - cwd, - kind: nextKind, - providerRuntime, - ...(spawnOpts.resumeSessionId - ? { - providerSessionId: spawnOpts.resumeSessionId, - providerSessionIdSource: 'resume-request' as const, - } - : {}), - ...(builtInMcpDomains !== undefined ? { builtInMcpDomains } : {}), - ...(replacementTitle !== undefined ? { title: replacementTitle } : {}), - // Last on purpose. `...(sessions[newId] ?? …)` earlier in this - // literal is the successor's OWN freshly-spawned metadata, so any - // earlier position is overwritten by it — which is exactly how the - // sketch renamed a pane on every provider switch. - // - // Conditional, not `?? oldId`: this site CARRIES an identity, it - // never creates one. If the predecessor had none — names were off, - // or the reconciler had not run — then no name was ever allocated to - // preserve, and inventing `oldId` here would make replacement a - // second minting site competing with the reconciler for that - // decision. Leaving it absent lets the reconciler claim the - // successor under its own id, which is the same outcome by the one - // rule the feature has. - ...(carriedAgentNameId !== undefined ? { agentNameId: carriedAgentNameId } : {}), - } - const detachedSessions = { ...prev.detachedSessions } - const detached = detachedSessions[oldId] - if (detached) { - delete detachedSessions[oldId] - detachedSessions[newId] = { ...detached, sessionId: newId } + // WHY a finally, and why it wraps EVERY exit path including the bail-outs: + // a stranded reservation would leave that pane permanently unnamed, which is + // the mirror-image bug and just as bad as the leak it prevents. Releasing an + // id that was never reserved is a no-op, so an ordinary spawn costs nothing. + try { + const mainHandledPredecessor = + pendingReplacementSuccessorsRef.current.delete(newId) + // A source can close or be buried while spawn awaits. Metadata alone + // is not ownership: committing an unplaced successor creates an invisible + // process and a false "completed" lifecycle receipt (#815). Read through + // the synchronous domain setter because React-owned refs can lag the last + // close action. A no-op updater preserves store identity and notifications. + let sourceOwned = false + setState(prev => { sourceOwned = canCommit(prev); return prev }) + if (!sourceOwned) { + await killSession(newId, { cwd, kind: nextKind, providerRuntime }) + return } - return { - ...prev, - tabs: prev.tabs.map(t => { - if (!collectLeaves(t.root).includes(oldId)) return t - return { - ...t, - root: remapTileTreeSessionIds(t.root, idMap), - focusedSessionId: - t.focusedSessionId === oldId ? newId : t.focusedSessionId, - } - }), - // Remap relationship pointers across ALL sessions: a linked / - // orchestration CHILD of the swapped session carries oldId in its - // linkedParentId/orchestrationParentId/orchestrationRootId, so the - // swap has to update those too or the child renders top-level and - // parent-scoped orchestration reads break. (rehydrate already does - // this; reload/switch/resume/rewind funnel through here and didn't.) - sessions: remapSessionsRelationships(sessions, idMap), - // A pinned agent that gets a fresh id on reload/switch must follow - // to the new id instead of silently dropping out of the Pinned list. - pinnedSessionIds: remapPinnedSessionIds(prev.pinnedSessionIds, idMap), - gridRelatedSelections: remapGridRelatedSelections(prev.gridRelatedSelections, idMap), - detachedSessions, - // Remap the swapped session id everywhere Dispatch holds it: the - // classic single-view focus AND every Tiled Dispatch lane selection - // (dispatchMode.tiled.lanes[].selectedSessionId). reload / - // provider-switch / resume / rewind all funnel through here; before - // this, the focused lane kept pointing at the now-dead oldId and the - // layout's auto-fill effect re-homed it to the first tile. Same - // tiled-vs-grid divergence as #266/#267/#271, fixed at the swap. - dispatchMode: remapTiledLanes( - prev.dispatchMode?.focusedSessionId === oldId - ? { ...prev.dispatchMode, focusedSessionId: newId } - : prev.dispatchMode, - idMap, - ), + if (!mainHandledPredecessor) { + await killSessionBackendIfOwned(refs, oldId, oldMeta) } - }) - if (!committed) { - await killSession(newId, { cwd, kind: nextKind, providerRuntime }) - return - } - setRuntimes(prev => { - // Replacement can await spawn and backend retirement while the user - // keeps editing. Transfer the latest draft in the same state update - // that retires its owner; a pre-await snapshot silently loses edits. - // All replacement paths share this contract. Rewind deliberately - // substitutes its historical prompt afterwards and keeps an undo copy. - const draft = prev[oldId] ?? draftFallback - const next = { - ...prev, - [newId]: { - ...(prev[newId] ?? emptyRuntime()), - draftInput: draft?.draftInput ?? '', - // Unsupported invisible attachments participate in submit guards. - // Preserve images only when the destination can expose them. - draftImages: isAgentProviderKind(nextKind) && getRendererProviderCapabilities(nextKind).supportsImageAttachments - ? (draft?.draftImages ?? []) : [], - }, + // Swap the sessionId wherever this live session is placed. Grid sessions + // live in one tile-tree leaf; detached Dispatch sessions live in + // detachedSessions with no leaf at all. + const idMap = new Map([[oldId, newId]]) + + let committed = false + setState(prev => { + // Backend retirement is another await. Recheck inside the actual remap + // commit, before touching the source draft or returning a successor ID. + if (!canCommit(prev)) return prev + committed = true + const sessions = { ...prev.sessions } + // WHY read the title from `prev` here instead of the pre-spawn + // snapshot: provider switches and rewinds can wait on backend work, + // and the user may edit or clear the pane's title during that window. + // The replacement is the same logical pane with a fresh transport id, + // so its durable glance label must follow using the latest state rather + // than being lost—or resurrected from a stale snapshot—on completion. + const replacementTitle = prev.sessions[oldId]?.title + // `prev.sessions[oldId]` is still readable here: only the local + // `sessions` copy has had oldId deleted. + const carriedAgentNameId = prev.sessions[oldId]?.agentNameId + delete sessions[oldId] + // Persist the replacement provider metadata immediately + // instead of waiting for the first transcript line to + // round-trip back from main. That wait window is usually + // short, but it is still a real race: a workspace save or + // pane action that snapshots SessionMeta in that gap would + // see "new session id, but no providerSessionId yet" and + // could forget how to resume the pane on the next launch. + // + // Keeping the requested resumeSessionId here makes + // replaceSession the single source of truth for "this pane + // now points at provider X's persisted transcript Y", + // whether the trigger was the resume picker or the new + // switch-provider flow. + sessions[newId] = { + ...(sessions[newId] ?? { cwd, kind: nextKind }), + cwd, + kind: nextKind, + providerRuntime, + ...(spawnOpts.resumeSessionId + ? { + providerSessionId: spawnOpts.resumeSessionId, + providerSessionIdSource: 'resume-request' as const, + } + : {}), + ...(builtInMcpDomains !== undefined ? { builtInMcpDomains } : {}), + ...(replacementTitle !== undefined ? { title: replacementTitle } : {}), + // Last on purpose. `...(sessions[newId] ?? …)` earlier in this + // literal is the successor's OWN freshly-spawned metadata, so any + // earlier position is overwritten by it — which is exactly how the + // sketch renamed a pane on every provider switch. + // + // Conditional, not `?? oldId`: this site CARRIES an identity, it + // never creates one. If the predecessor had none — names were off, + // or the reconciler had not run — then no name was ever allocated to + // preserve, and inventing `oldId` here would make replacement a + // second minting site competing with the reconciler for that + // decision. Leaving it absent lets the reconciler claim the + // successor under its own id, which is the same outcome by the one + // rule the feature has. + ...(carriedAgentNameId !== undefined ? { agentNameId: carriedAgentNameId } : {}), + } + const detachedSessions = { ...prev.detachedSessions } + const detached = detachedSessions[oldId] + if (detached) { + delete detachedSessions[oldId] + detachedSessions[newId] = { ...detached, sessionId: newId } + } + return { + ...prev, + tabs: prev.tabs.map(t => { + if (!collectLeaves(t.root).includes(oldId)) return t + return { + ...t, + root: remapTileTreeSessionIds(t.root, idMap), + focusedSessionId: + t.focusedSessionId === oldId ? newId : t.focusedSessionId, + } + }), + // Remap relationship pointers across ALL sessions: a linked / + // orchestration CHILD of the swapped session carries oldId in its + // linkedParentId/orchestrationParentId/orchestrationRootId, so the + // swap has to update those too or the child renders top-level and + // parent-scoped orchestration reads break. (rehydrate already does + // this; reload/switch/resume/rewind funnel through here and didn't.) + sessions: remapSessionsRelationships(sessions, idMap), + // A pinned agent that gets a fresh id on reload/switch must follow + // to the new id instead of silently dropping out of the Pinned list. + pinnedSessionIds: remapPinnedSessionIds(prev.pinnedSessionIds, idMap), + gridRelatedSelections: remapGridRelatedSelections(prev.gridRelatedSelections, idMap), + detachedSessions, + // Remap the swapped session id everywhere Dispatch holds it: the + // classic single-view focus AND every Tiled Dispatch lane selection + // (dispatchMode.tiled.lanes[].selectedSessionId). reload / + // provider-switch / resume / rewind all funnel through here; before + // this, the focused lane kept pointing at the now-dead oldId and the + // layout's auto-fill effect re-homed it to the first tile. Same + // tiled-vs-grid divergence as #266/#267/#271, fixed at the swap. + dispatchMode: remapTiledLanes( + prev.dispatchMode?.focusedSessionId === oldId + ? { ...prev.dispatchMode, focusedSessionId: newId } + : prev.dispatchMode, + idMap, + ), + } + }) + if (!committed) { + await killSession(newId, { cwd, kind: nextKind, providerRuntime }) + return } - delete next[oldId] - return next - }) - delete refs.seenUuidsRef.current[oldId] - clearLiveEntryWindowSession(oldId) - delete refs.latestScreenRef.current[oldId] + setRuntimes(prev => { + // Replacement can await spawn and backend retirement while the user + // keeps editing. Transfer the latest draft in the same state update + // that retires its owner; a pre-await snapshot silently loses edits. + // All replacement paths share this contract. Rewind deliberately + // substitutes its historical prompt afterwards and keeps an undo copy. + const draft = prev[oldId] ?? draftFallback + const next = { + ...prev, + [newId]: { + ...(prev[newId] ?? emptyRuntime()), + draftInput: draft?.draftInput ?? '', + // Unsupported invisible attachments participate in submit guards. + // Preserve images only when the destination can expose them. + draftImages: isAgentProviderKind(nextKind) && getRendererProviderCapabilities(nextKind).supportsImageAttachments + ? (draft?.draftImages ?? []) : [], + }, + } + delete next[oldId] + return next + }) + delete refs.seenUuidsRef.current[oldId] + clearLiveEntryWindowSession(oldId) + delete refs.latestScreenRef.current[oldId] - return newId + return newId + } finally { + releaseIdentityCarry(newId) + } }, [ refs.latestRuntimesRef, 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 From 04c1fbf44beca6b42490aaf748eb78466bc2c356 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:45:01 -0700 Subject: [PATCH 14/32] fix(workspace): make Jump to Latest work on OpenCode Terminal panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jump to Latest did nothing at all on an OpenCode Terminal pane. Not a keybinding gap — the mechanism could never have worked there. `useAgentTerminalFollow` answers a jump with `term.scrollToBottom()`. That moves the xterm VIEWPORT, which is correct for Claude Code and Codex: both render their main view inline on the normal screen buffer and push history into real scrollback (Codex via `insert_history_lines`; Claude's AlternateScreen component is documented as being for transient ctrl-o style overlays only). OpenCode does not. It runs OpenTUI, whose `screenMode` defaults to `alternate-screen`, and it renders the transcript into an internal scrollbox with its own paging keybinds. Nothing is ever evicted upward, so `viewportY === baseY` always holds and `scrollToBottom()` is a guaranteed no-op. The `externalOutputMode: "passthrough"` in OpenCode's TUI setup looks like an opt-out but is an orthogonal axis — it is the only value legal with alternate-screen and is its default. The follow plan doc already recorded the constraint — "Alternate-screen TUIs often own their history internally. These commands control the xterm viewport, not provider-specific keybindings or internal transcript navigation" — but nothing acted on it, so the command shipped promising a behaviour it could not deliver. Jump is now provider-aware, through the existing feature-capability table rather than a kind check at the call site: which mechanism applies is a fact about the provider's TUI. Claude and Codex declare null and keep viewport scrolling. OpenCode declares ESC + 0x07 — Ctrl+Alt+G in the legacy encoding every terminal speaks, which OpenCode binds to `messages_last`. Not the bare `End` it also accepts, because that is ALSO bound to `input_buffer_end` and would move the prompt caret instead. Legacy bytes rather than the kitty protocol OpenCode requests, because xterm 6.0.0 has no kitty support and never answers the query. Writing a key into the PTY has precedent here: AskUserQuestionRow re-encodes arrows for provider pickers, and useComposerKeybinds does the same for TUI conditions. The command's own description claimed "in a raw terminal view this scrolls the TUI viewport to the bottom", which was false for OpenCode. 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 passed without exercising anything. It now also proves a viewport-scrolling provider writes nothing to the PTY, and that a provider-owned jump sends the right bytes to the right session without also moving the viewport. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/providers/shared/featureCapabilities.ts | 54 +++++++++++++++++++ .../workspace/commands/paneCommands.ts | 2 +- .../workspace/tile-tree/AgentTerminalLeaf.tsx | 4 ++ .../agentTerminalFollow.system.test.ts | 34 ++++++++++-- .../tile-tree/agentTerminalFollow.ts | 32 ++++++++++- 5 files changed, 121 insertions(+), 5 deletions(-) diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index 5cad5233..fb0af5df 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -72,6 +72,37 @@ export type ProviderFeatureCapabilities = { * they will paste it into a terminal and blame their setup. */ verifiedExternalResumeCommand: boolean + /** + * On this provider's RAW TERMINAL surface, the bytes to send so the TUI + * scrolls its own transcript to the latest message — or null when the + * xterm viewport is the thing that scrolls and `scrollToBottom()` is + * correct. + * + * WHY a capability rather than a provider check at the call site: "Jump to + * Latest" is one command with two completely different mechanisms, and which + * one applies is a fact about the provider's TUI, not about the pane. + * + * Claude Code and Codex render their main view INLINE on the normal screen + * 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). Scrolling the xterm + * viewport is exactly right for them. + * + * OpenCode does not. It runs OpenTUI, whose `screenMode` defaults to + * `alternate-screen`, and it renders the transcript into an internal + * `` with its own paging keybinds. Nothing is ever evicted + * upward, so `viewportY === baseY`永 holds and `term.scrollToBottom()` is a + * guaranteed no-op — which is why Jump to Latest silently did nothing on + * OpenCode Terminal panes while working everywhere else. The only mechanism + * that can move that transcript is the TUI's own key. + * + * The plan doc for the follow work already recorded the constraint — + * "Alternate-screen TUIs often own their history internally. These commands + * control the xterm viewport, not provider-specific keybindings or internal + * transcript navigation" — but nothing acted on it, so the command shipped + * claiming a behaviour it could not deliver. + */ + terminalJumpToLatestKey: string | null } /** @@ -88,6 +119,7 @@ export const NO_PROVIDER_FEATURES: ProviderFeatureCapabilities = { inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, + terminalJumpToLatestKey: null, } /** @@ -117,6 +149,9 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['codex', 'opencode'], verifiedExternalResumeCommand: true, + // Inline on the normal buffer, so real xterm scrollback exists and the + // viewport is what needs moving. + terminalJumpToLatestKey: null, }, // Mirrors Claude, with explicit edges to both other adapters. codex: { @@ -127,6 +162,10 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['claude', 'opencode'], verifiedExternalResumeCommand: true, + // Same as Claude: `insert_history_lines` writes to real scrollback, and + // `enter_alt_screen` is reached only from backtrack/resume/migration + // overlays, never the chat view. + terminalJumpToLatestKey: null, }, // OpenCode still lacks a cwd-indexed saved-session picker, but its supported // CLI export/import boundary now backs prompt extraction, rewind, duplicate, @@ -144,6 +183,21 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['claude', 'codex'], verifiedExternalResumeCommand: true, + // ESC + 0x07 is Ctrl+Alt+G in the legacy encoding every terminal speaks: + // Alt is the ESC prefix and Ctrl+G is BEL. OpenCode binds that chord to + // `messages_last` ("Navigate to last message"), which is precisely this + // command's meaning inside its own scrollbox. + // + // WHY Ctrl+Alt+G and not the bare `End` OpenCode also accepts: `End` is + // ALSO bound to `input_buffer_end`, so it would most likely move the + // prompt caret instead of the transcript. Ctrl+Alt+G is unambiguous, and + // it is a member of OpenCode's whole Ctrl+Alt message-scroll family. + // + // WHY legacy bytes even though OpenCode requests the kitty keyboard + // protocol: xterm 6.0.0 has no kitty support, so it never answers the + // query and the TUI stays on legacy parsing. If that ever changes this + // string is the one place to revisit. + terminalJumpToLatestKey: '\u001b\u0007', }, } diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index a453cd80..812525e8 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 for providers that render inline (Claude, Codex); for a TUI that owns its own transcript (OpenCode Terminal) it sends that TUI\'s own jump-to-latest key instead.', // 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/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 5c16b171..2d38f8e9 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -23,6 +23,7 @@ import { AgentTitleHeader } from '@renderer/workspace/tile-tree/AgentTitleHeader import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder' import { encodeTerminalPaste, registerTerminalPasteTarget } from '@renderer/workspace/terminal/textPasteTarget' import { AgentTerminalActions } from '@renderer/workspace/tile-tree/AgentTerminalActions' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' import { useAgentTerminalFollow } from '@renderer/workspace/tile-tree/agentTerminalFollow' type Props = { @@ -102,6 +103,9 @@ export function AgentTerminalLeaf({ scrollToLatestRequest: runtime.scrollToLatestRequest, tailActive, termRef, + // Null for every provider that renders inline on the normal buffer, which + // is the ordinary case and keeps viewport scrolling. + jumpKey: getProviderFeatures(provider).terminalJumpToLatestKey, }) const dimensionActiveRef = useRef(false) const dimensionOwnershipEpochRef = useRef(0) 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..dd4ce596 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts @@ -31,14 +31,22 @@ it('follows and restores real xterm content across trimming and buffer switches' const term = new Terminal({ cols: 80, rows: 10, scrollback: 2000 }) term.open(document.getElementById('terminal')) const termRef = { current: term } + // The PTY sink. A provider whose TUI owns its own transcript is jumped + // by sending it a key, not by moving the xterm viewport, so this trial + // has to be able to observe that write. + const sent = [] + window.api = { sendInput: (id, data) => { sent.push({ id, data }) } } let follow function Harness(props) { follow = useAgentTerminalFollow({ ...props, termRef }); return null } const reactRoot = createRoot(document.getElementById('react')) let tailActive = false let scrollToLatestRequest = 0 + // null = "the xterm viewport is what scrolls", which is Claude and + // Codex and therefore the default this trial runs under. + let jumpKey = null function render() { flushSync(() => reactRoot.render(createElement(Harness, { - sessionId: 'trial', tailActive, scrollToLatestRequest, + sessionId: 'trial', tailActive, scrollToLatestRequest, jumpKey, }))) } const check = (condition, message) => { if (!condition) throw new Error(message) } @@ -72,6 +80,7 @@ it('follows and restores real xterm content across trimming and buffer switches' scrollToLatestRequest++; render() await until(bottom, 'Jump request did not reach real xterm bottom') + check(sent.length === 0, 'Viewport-scrolling provider must not write to the PTY') term.scrollToLine(10) tailActive = true; render() @@ -84,12 +93,31 @@ 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') + + // The case the assertion above CANNOT see, and the reason Jump to + // Latest silently did nothing on OpenCode Terminal panes: on the + // alternate screen there is no scrollback at all, so viewportY and + // baseY are both 0 and the bottom check is trivially 0 === 0. A + // provider whose TUI owns its transcript must therefore be jumped by + // sending it the key it binds to "go to last message" instead. + const evictedBefore = term.buffer.active.viewportY + jumpKey = '\\u001b\\u0007' + scrollToLatestRequest++; render() + await until(() => sent.length === 1, 'Provider-owned jump did not reach the PTY') + check(sent[0].id === 'trial', 'Provider-owned jump addressed the wrong session: ' + sent[0].id) + // Compared through char codes so the assertion cannot pass on a + // differently-escaped string that merely looks the same in source. + const codes = Array.from(sent[0].data).map(c => c.charCodeAt(0)).join(',') + check(codes === '27,7', 'Wrong bytes sent for provider-owned jump: ' + codes) + check(term.buffer.active.viewportY === evictedBefore, 'Provider-owned jump must not also move the viewport') + jumpKey = null + await write('\\x1b[?1049l') // Marker registration/disposal is public; only this diagnostic // enumeration requires proposed APIs. Keep them off for all behavior. term.options.allowProposedApi = true check(term.markers.length === 0, 'Follow leaked a saved marker after disengage') - return { repin: true, trim: true, eviction: true, jump: true, alternate: true } + return { repin: true, trim: true, eviction: true, jump: true, alternate: true, providerJump: true } } finally { off(); reactRoot.unmount(); termRef.current = null; term.dispose() } @@ -132,7 +160,7 @@ it('follows and restores real xterm content across trimming and buffer switches' const result = stdout.split('\n').find(line => line.startsWith('FOLLOW_TRIAL=')) expect(result, stdout).toBeDefined() expect(JSON.parse(result!.slice('FOLLOW_TRIAL='.length))).toEqual({ - repin: true, trim: true, eviction: true, jump: true, alternate: true, + repin: true, trim: true, eviction: true, jump: true, alternate: true, providerJump: true, }) } finally { await rm(directory, { recursive: true, force: true }) diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 4b1b7cc3..3b82c055 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -12,6 +12,12 @@ type FollowArgs = { scrollToLatestRequest: number tailActive: boolean termRef: RefObject + /** + * Bytes that make THIS provider's TUI scroll its own transcript to the + * latest message, or null when the xterm viewport is the thing that scrolls. + * See ProviderFeatureCapabilities.terminalJumpToLatestKey. + */ + jumpKey: string | null } function isAtBottom(term: Terminal): boolean { @@ -19,8 +25,12 @@ function isAtBottom(term: Terminal): boolean { } export function useAgentTerminalFollow({ - sessionId, scrollToLatestRequest, tailActive, termRef, + sessionId, scrollToLatestRequest, tailActive, termRef, jumpKey, }: FollowArgs) { + // Read at request time rather than closed over, so a provider switch that + // keeps the same pane cannot send the previous provider's chord. + const jumpKeyRef = useRef(jumpKey) + jumpKeyRef.current = jumpKey // The mount-owned PTY subscriber reads the latest verdict at callback time, // not when a chunk was queued: parsing can finish after Tail was disabled. const tailActiveRef = useRef(tailActive) @@ -52,6 +62,26 @@ export function useAgentTerminalFollow({ } if (scrollToLatestRequest === jumpBaselineRef.current) return jumpBaselineRef.current = scrollToLatestRequest + const key = jumpKeyRef.current + if (key) { + // WHY this writes to the PTY instead of moving the viewport: + // + // 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 `scrollToBottom()` is a + // guaranteed no-op. That is why Jump to Latest silently did nothing on + // OpenCode Terminal panes while working on Claude and Codex raw views, + // which render inline on the normal buffer. The only thing that can move + // that transcript is the TUI's own key. + // + // Fire-and-forget, and deliberately not awaited or reported: this runs + // inside a passive effect driven by a counter, the write is one keypress + // the user could have typed themselves, and a rejected send means the + // pane is gone — in which case there is nothing to scroll and nothing to + // say about it. + void window.api.sendInput(sessionId, key) + return + } termRef.current?.scrollToBottom() }, [scrollToLatestRequest, sessionId, termRef]) From 8a8ff292006edcbfab62cf32be272312ea8c3368 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:51:21 -0700 Subject: [PATCH 15/32] fix(prompt-templates): abort loudly on a bad key reference, as documented MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two defects in the `{{key:Provider/Key}}` grammar, both of which made the module's own header paragraph false where it promises "resolution aborts loudly with a toast instead of silently inserting nothing". **Malformed references were pasted verbatim.** The pattern excluded `/` from both halves, so `{{key:Brave}}` and `{{key:A/B/C}}` matched nothing at all — invisible to collection, invisible to validation, and untouched by the final replace. A typo'd reference therefore went into the prompt as literal text. The pattern now accepts any spec and validates it, rejecting a missing separator, an extra one, and an empty half. Two separators are refused rather than guessed: there is no evidence for which one divides provider from key, and picking one would resolve a reference the author did not write. **The "collect all failures" path was dead code.** It branches on `value === null`, but the production resolver is `window.api.keyVaultResolveReference`, typed `Promise`, and VaultService throws on every failure mode — unknown provider, unknown key, a cancelled unlock. So the first bad reference escaped the loop, the aggregation never ran, and "one error message tells the user everything that needs fixing" was simply untrue. The call is now wrapped, and the service's own message is kept because it distinguishes "no such key" from "vault is locked". Behaviour was already SAFE in both cases — nothing wrong was inserted — but a promise the code documents twice was not kept. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../prompt-templates/keyReferences.test.ts | 62 +++++++++++ .../prompt-templates/keyReferences.ts | 105 ++++++++++++++++-- 2 files changed, 155 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.test.ts b/src/renderer/src/features/prompt-templates/keyReferences.test.ts index e071d721..d6032298 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.test.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.test.ts @@ -52,3 +52,65 @@ describe('resolveKeyReferences', () => { ).rejects.toThrow(/Brave\/a.*OpenAI\/b/) }) }) + +describe('malformed and failing references', () => { + it('aborts on a reference with no separator instead of pasting it verbatim', async () => { + // The old pattern excluded `/` from both halves, so this matched NOTHING: + // invisible to collection, invisible to validation, and passed through the + // final replace untouched. A typo was therefore pasted into the prompt as + // literal text, which is the exact silent failure the grammar's header + // says it exists to prevent. + await expect(resolveKeyReferences('use {{key:Brave}} now', async () => 'secret')) + .rejects.toThrow('{{key:Brave}}') + }) + + it('aborts on a reference with two separators rather than guessing', async () => { + // With two separators there is no evidence for which one divides provider + // from key, and picking one would resolve a reference the author did not + // write. + await expect(resolveKeyReferences('{{key:A/B/C}}', async () => 'secret')) + .rejects.toThrow('{{key:A/B/C}}') + }) + + it('aborts on an empty half', async () => { + await expect(resolveKeyReferences('{{key:/Key}}', async () => 'secret')) + .rejects.toThrow('{{key:/Key}}') + await expect(resolveKeyReferences('{{key:Provider/}}', async () => 'secret')) + .rejects.toThrow('{{key:Provider/}}') + }) + + it('collects a THROWN resolution failure instead of escaping the loop', 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 the catch the first bad reference + // escaped and the documented "one message tells you everything" was false. + const resolve = async (ref: { keyName: string }) => { + if (ref.keyName === 'bad') throw new Error('No such key') + return 'secret' + } + await expect(resolveKeyReferences('{{key:P/bad}} {{key:P/worse}}', async () => { + throw new Error('No such key') + })).rejects.toThrow(/P\/bad.*P\/worse/) + await expect(resolveKeyReferences('{{key:P/bad}}', resolve)).rejects.toThrow('No such key') + }) + + 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') + }) + + it('leaves an ordinary variable placeholder alone', async () => { + // The two grammars must not 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') + }) +}) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.ts b/src/renderer/src/features/prompt-templates/keyReferences.ts index 1d46da22..0c238032 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.ts @@ -31,21 +31,69 @@ export function prepareTemplateText( export type KeyReference = { providerName: string; keyName: string } -const KEY_REF_PATTERN = /\{\{\s*key:([^/{}]+?)\/([^/{}]+?)\s*\}\}/g +/** + * Every `{{key:…}}` occurrence, well-formed or not. + * + * WHY the pattern deliberately accepts a malformed SPEC instead of refusing to + * match it: the old pattern excluded `/` from both halves, so `{{key:Provider}}` + * and `{{key:A/B/C}}` matched nothing at all — they were invisible to + * collection, invisible to validation, and survived the final `body.replace` + * untouched. A typo'd reference was therefore pasted into the prompt VERBATIM, + * which is the exact silent-failure mode the header paragraph says this + * grammar exists to avoid ("resolution aborts loudly"). + * + * The `key:` prefix and the `[^{}]` body keep this from colliding with the + * ordinary `{{variable}}` grammar, whose placeholder pattern is `[A-Za-z0-9_]+` + * and cannot contain a colon. + */ +const KEY_REF_PATTERN = /\{\{\s*key:([^{}]*?)\s*\}\}/g +type ParsedReference = + | { ok: true; ref: KeyReference } + | { ok: false; spec: string } + +/** + * Exactly one separator, and both halves non-empty after trimming. + * + * A name containing `/` is therefore unaddressable. That is deliberate and is + * the reason to reject rather than to guess: with two separators there is no + * evidence for which one divides provider from key, and picking one would + * resolve a reference the author did not write. + */ +function parseReference(spec: string): ParsedReference { + const parts = spec.split('/') + if (parts.length !== 2) return { ok: false, spec } + const providerName = parts[0].trim() + const keyName = parts[1].trim() + if (!providerName || !keyName) return { ok: false, spec } + return { ok: true, ref: { providerName, keyName } } +} + +function referenceKey(ref: KeyReference): string { + // NUL separator so a provider named "a" with key "b/c" cannot collide with + // provider "a/b" key "c" — unreachable through parseReference today, but the + // map is also written from the replace callback. + return `${ref.providerName}\u0000${ref.keyName}` +} + +/** 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 parsed of parseAll(body)) { + if (!parsed.ok) continue + const dedupeKey = referenceKey(parsed.ref) if (seen.has(dedupeKey)) continue seen.add(dedupeKey) - ordered.push(ref) + ordered.push(parsed.ref) } return ordered } +function parseAll(body: string): ParsedReference[] { + return [...body.matchAll(KEY_REF_PATTERN)].map(match => parseReference(match[1])) +} + export async function resolveKeyReferences( body: string, resolve: (ref: KeyReference) => Promise, @@ -54,21 +102,54 @@ 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 parsed of parseAll(body)) { + if (!parsed.ok) { + const label = `{{key:${parsed.spec}}}` + if (!failures.includes(label)) failures.push(label) + continue + } + const dedupeKey = referenceKey(parsed.ref) + if (seen.has(dedupeKey)) continue + seen.add(dedupeKey) + + // WHY the call is wrapped: the aggregation above was dead code in + // production. The real adapter is `window.api.keyVaultResolveReference`, + // typed `Promise`, and VaultService throws on every failure mode — + // unknown provider, unknown key, a cancelled unlock. `value === null` was + // therefore unreachable, the first bad reference escaped this loop, and + // "one error message tells the user everything that needs fixing" was + // simply false. The service's own message is kept, because it is written + // for direct display and distinguishes "no such key" from "vault locked". + let value: string | null + try { + value = await resolve(parsed.ref) + } catch (error) { + const detail = error instanceof Error && error.message.length > 0 ? error.message : null + failures.push(`{{key:${parsed.ref.providerName}/${parsed.ref.keyName}}}${detail ? ` (${detail})` : ''}`) + continue + } if (value === null || value.length === 0) { - failures.push(`{{key:${ref.providerName}/${ref.keyName}}}`) + failures.push(`{{key:${parsed.ref.providerName}/${parsed.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, spec: string) => { + const parsed = parseReference(spec) + // Unreachable — a malformed spec threw above — but returning the original + // text is the safe answer if that ever stops being true, since it cannot + // insert a wrong secret. + if (!parsed.ok) return match + return values.get(referenceKey(parsed.ref)) ?? '' }) } From 515c5da82a89636e5f7748856b9489ad08e1cb57 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 13:51:22 -0700 Subject: [PATCH 16/32] fix(vault): return terminal refusals as results and stop leaking a key into a tooltip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **A refusal was an exception, not a result.** `encodeTerminalPaste` throws for control characters and for multiline text into a program that has not enabled bracketed paste, and that threw straight out of `deliverTextToSession`, whose result union claimed to describe every outcome. Whether the user ever saw the reason depended on each caller happening to wrap the call in try/catch. `DeliverTextResult` now carries a `refused` variant with the message, and both insertion paths show it — so "this terminal program has not enabled bracketed paste" reaches the user instead of a generic failure or nothing. **The revealed key sat in a DOM `title` attribute.** That puts plaintext into an OS tooltip and into the accessibility tree, readable by anything that can query the DOM and rendered by the window server outside this surface's control. The tooltip existed only because the value was truncated, so the value now wraps and is fully visible in the row instead. **The vault failed with a raw TypeError off macOS.** `promptAuth` called `systemPreferences.promptTouchID` unconditionally while `canPromptAuth` correctly gated on darwin, so a Windows or Linux user got "systemPreferences.promptTouchID is not a function". The service deliberately attempts the prompt rather than pre-gating — that reasoning is right for capability, since pre-gating once locked out password-only Macs — but it is not right for a platform with no such API at all. It fails closed either way; only the wording was broken. **The secret-sink disclosure understated where an inserted key comes to rest.** Both comments named the draft, the scrollback and the transcript. They omitted that clearing a draft keeps the text recoverable for undo, and that with proxy streaming on the mitm addon base64-encodes outbound request bodies into a journal under ~/.config/agent-code/proxy that nothing prunes or rotates. Both lists are now complete. Also resets the tail-engaged flag when a terminal detaches. Disposal already dropped the anchor, but the flag survived, and the xterm instance can be rebuilt under the same sessionId without the per-session effect re-running — so the next disengage took the restore branch with nothing saved and silently dropped the user wherever the fresh terminal happened to be. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/index.ts | 17 +++++- .../command-palette/ui/CommandPalette.tsx | 6 ++ .../features/key-vault/ui/KeyVaultModal.tsx | 41 ++++++++++--- .../deliverTextToSession.ts | 59 +++++++++++++++---- .../tile-tree/agentTerminalFollow.ts | 10 ++++ 5 files changed, 113 insertions(+), 20 deletions(-) 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/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 ab3d76c7..3d96d464 100644 --- a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx +++ b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx @@ -23,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 @@ -190,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 { @@ -370,10 +387,16 @@ export function KeyVaultModal() { {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)} )} 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/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 3b82c055..268a07c9 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -141,6 +141,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]) From 105528096e4231c4fe9397565ddf49a7e71c2cd1 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:47:19 -0700 Subject: [PATCH 17/32] fix(prompt-templates): stop the key grammar from capturing ordinary text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediation from the two-agent merge review, which built a before/after probe of the module. Both findings are regressions the previous commit introduced while fixing something real. **Ordinary JSX began aborting template insertion.** Reporting `{{key:Provider}}` as a malformed reference required matching ANY `{{key:…}}`, and that over-captured: `` is everyday code, and a template containing it now threw "Unresolved key reference" with no way to escape, not even inside a code fence. JSON and pasted logs carrying the same shape regressed too. A `/` is what makes an occurrence look deliberately addressed to the vault, so the separator is required again. The cost is that a separator-less typo goes back to passing through untouched, exactly as before — strictly better than breaking text the user never meant as syntax. **Cancelling authentication asked again, once per reference.** Catching every resolver throw and continuing meant a cancelled unlock was retried for the next reference, and `ensureUnlocked` clears its pending promise on cancellation — so a three-reference template asked once before and three times after. The loop now stops at the first thrown failure. A user who just cancelled must not be re-asked, and whatever was collected before the failure is still reported with it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../prompt-templates/keyReferences.test.ts | 59 +++++++++++----- .../prompt-templates/keyReferences.ts | 68 +++++++++++++------ 2 files changed, 89 insertions(+), 38 deletions(-) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.test.ts b/src/renderer/src/features/prompt-templates/keyReferences.test.ts index d6032298..ad45c231 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.test.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.test.ts @@ -54,14 +54,27 @@ describe('resolveKeyReferences', () => { }) describe('malformed and failing references', () => { - it('aborts on a reference with no separator instead of pasting it verbatim', async () => { - // The old pattern excluded `/` from both halves, so this matched NOTHING: - // invisible to collection, invisible to validation, and passed through the - // final replace untouched. A typo was therefore pasted into the prompt as - // literal text, which is the exact silent failure the grammar's header - // says it exists to prevent. + it('leaves a separator-less occurrence alone, because it is not addressed to the vault', async () => { + // Reporting `{{key:Brave}}` as a malformed reference required matching + // ANY `{{key:…}}`, and that over-captured ordinary text: JSX like + // `` began aborting insertion outright, + // with no way to escape it. A separator is what makes an occurrence look + // deliberately like a vault reference, so it is the boundary. A + // separator-less typo passes through as it always did. await expect(resolveKeyReferences('use {{key:Brave}} now', async () => 'secret')) - .rejects.toThrow('{{key:Brave}}') + .resolves.toBe('use {{key:Brave}} now') + }) + + it('does not touch ordinary JSX that happens to contain {{key:', async () => { + const body = ' and {{key: other}}' + await expect(resolveKeyReferences(body, async () => 'secret')).resolves.toBe(body) + }) + + it('still resolves a real reference sitting next to such text', async () => { + await expect(resolveKeyReferences( + ' {{key:P/K}}', + async () => 'secret', + )).resolves.toBe(' secret') }) it('aborts on a reference with two separators rather than guessing', async () => { @@ -79,19 +92,31 @@ describe('malformed and failing references', () => { .rejects.toThrow('{{key:Provider/}}') }) - it('collects a THROWN resolution failure instead of escaping the loop', async () => { + 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 the catch the first bad reference - // escaped and the documented "one message tells you everything" was false. - const resolve = async (ref: { keyName: string }) => { - if (ref.keyName === 'bad') throw new Error('No such key') - return 'secret' - } - await expect(resolveKeyReferences('{{key:P/bad}} {{key:P/worse}}', async () => { + // 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(/P\/bad.*P\/worse/) - await expect(resolveKeyReferences('{{key:P/bad}}', resolve)).rejects.toThrow('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 () => { diff --git a/src/renderer/src/features/prompt-templates/keyReferences.ts b/src/renderer/src/features/prompt-templates/keyReferences.ts index 0c238032..2678d7a5 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.ts @@ -32,21 +32,35 @@ export function prepareTemplateText( export type KeyReference = { providerName: string; keyName: string } /** - * Every `{{key:…}}` occurrence, well-formed or not. + * Every `{{key:…/…}}` occurrence, well-formed or not. * - * WHY the pattern deliberately accepts a malformed SPEC instead of refusing to - * match it: the old pattern excluded `/` from both halves, so `{{key:Provider}}` - * and `{{key:A/B/C}}` matched nothing at all — they were invisible to - * collection, invisible to validation, and survived the final `body.replace` - * untouched. A typo'd reference was therefore pasted into the prompt VERBATIM, - * which is the exact silent-failure mode the header paragraph says this - * grammar exists to avoid ("resolution aborts loudly"). + * WHY it matches a malformed SPEC rather than refusing to match: the original + * pattern excluded `/` from both halves, so `{{key:A/B/C}}` matched nothing at + * all — invisible to collection, invisible to validation, and untouched by the + * final `body.replace`. A typo'd reference was pasted into the prompt VERBATIM, + * which is the silent-failure mode the header paragraph says this grammar + * exists to avoid. * - * The `key:` prefix and the `[^{}]` body keep this from colliding with the - * ordinary `{{variable}}` grammar, whose placeholder pattern is `[A-Za-z0-9_]+` - * and cannot contain a colon. + * WHY the separator is still REQUIRED, which is the part that took a second + * pass to get right: a first attempt accepted any `{{key:…}}` at all so that + * `{{key:Provider}}` could be reported as a missing separator. That + * over-captured ordinary text. `` is + * everyday JSX, and it began aborting template insertion outright — a template + * that had always worked now failed, with no way to escape it, not even inside + * a code fence. Pasted logs and JSON carrying `{{key: …}}` regressed the same + * way. + * + * A `/` is the thing that makes an occurrence look deliberately like a vault + * reference rather than an object literal, so it is the boundary. The cost is + * that a separator-less typo goes back to passing through untouched, exactly + * as it did before this file existed. That is strictly better than breaking + * text the user did not intend as syntax. + * + * The `key:` prefix and the `[^{}]` body also keep this from colliding with + * the ordinary `{{variable}}` grammar, whose placeholder pattern is + * `[A-Za-z0-9_]+` and cannot contain a colon. */ -const KEY_REF_PATTERN = /\{\{\s*key:([^{}]*?)\s*\}\}/g +const KEY_REF_PATTERN = /\{\{\s*key:([^{}]*\/[^{}]*?)\s*\}\}/g type ParsedReference = | { ok: true; ref: KeyReference } @@ -62,6 +76,8 @@ type ParsedReference = */ function parseReference(spec: string): ParsedReference { const parts = spec.split('/') + // A spec reaches here only with at least one separator (the pattern requires + // it), so this rejects two-or-more, never zero. if (parts.length !== 2) return { ok: false, spec } const providerName = parts[0].trim() const keyName = parts[1].trim() @@ -116,21 +132,31 @@ export async function resolveKeyReferences( if (seen.has(dedupeKey)) continue seen.add(dedupeKey) - // WHY the call is wrapped: the aggregation above was dead code in - // production. The real adapter is `window.api.keyVaultResolveReference`, - // typed `Promise`, and VaultService throws on every failure mode — - // unknown provider, unknown key, a cancelled unlock. `value === null` was - // therefore unreachable, the first bad reference escaped this loop, and - // "one error message tells the user everything that needs fixing" was - // simply false. The service's own message is kept, because it is written - // for direct display and distinguishes "no such key" from "vault locked". + // 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(parsed.ref) } catch (error) { const detail = error instanceof Error && error.message.length > 0 ? error.message : null failures.push(`{{key:${parsed.ref.providerName}/${parsed.ref.keyName}}}${detail ? ` (${detail})` : ''}`) - continue + break } if (value === null || value.length === 0) { failures.push(`{{key:${parsed.ref.providerName}/${parsed.ref.keyName}}}`) From c7269fc199e358c0e9cef0162a3712f72d4457b4 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:47:19 -0700 Subject: [PATCH 18/32] fix(vault): keep the provider list reachable in a narrow window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediation from the merge review, which reproduced it in Chromium with 25 providers: a 721px provider column inside a 437px row. Removing the body's outer scroller left the provider column with `shrink-0`. That is right in the wide two-column layout and wrong below the `sm` breakpoint, where the row stacks as a COLUMN — a non-shrinking child there takes its full content height, so a long provider list grew past the dialog and the new `overflow-hidden` clipped the bottom of it, including "New provider…", with no scrollbar able to reach it. The column's own `overflow-y-auto` cannot help an element that was never constrained, and the outer scroller that used to make those controls reachable is gone. Shrinking is now allowed on the stacking axis while the fixed 12rem sidebar is kept for the wide layout, and both columns carry `min-h-0` so their own scrollers engage. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../src/features/key-vault/ui/KeyVaultModal.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx index 3d96d464..f7e3645d 100644 --- a/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx +++ b/src/renderer/src/features/key-vault/ui/KeyVaultModal.tsx @@ -286,7 +286,17 @@ export function KeyVaultModal() { 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 => (
-
+
{!selectedProvider && (
Create a provider to get started.
)} From 9c4cf5af6bbbe82b99efe55c570b7cbf12422e87 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:47:19 -0700 Subject: [PATCH 19/32] fix(provider-switch): key the size estimate on what actually decides it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediation from the merge review, which showed the previous commit's performance claim was false. Reading `workspace.runtimes` through a ref did not stop the walk: the memo still depended on `matchingRows`, and `agentRows` lists `workspace.runtimes` in its own deps, so that array is fresh on every streaming tick too. The dependency has to be the thing that actually decides the answer — WHICH sessions match — not the identity of the array listing them. Joining the ids is O(rows) per render against O(rows x up to 2000 entries) for the walk it replaces. The estimate can now lag a pane's growth within one open session. That is deliberate: it picks the default state of a checkbox the user can see and toggle, and it is re-derived every time the modal opens. Two comments corrected in the same pass, both of which the review showed were factually wrong about the code beside them: - The rejected-delivery catch claimed the old code reported success for agents it never reached. It did not — `delivered` only ever incremented after `result.ok`. The real defect was that unreached agents vanished from the report entirely, and silence about an agent reads as "nothing to say", not "never attempted". - The plain-terminal toast slot claimed a toast "never steals rows from xterm". It does: the slot is a non-shrinking flex sibling, so the terminal really is shorter while a toast is up and the PTY is resized down and back. That is accepted for the same reason AgentTerminalLeaf accepts it, and the comment now says so instead of denying it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../workspace/ui/BulkProviderSwitchModal.tsx | 73 +++++++++++++------ .../src/workspace/tile-tree/TerminalLeaf.tsx | 11 ++- 2 files changed, 60 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index a5b2a449..c87cf3b5 100644 --- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx @@ -370,32 +370,45 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // 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. - // WHY the runtimes are read through a REF rather than as a dependency: + // WHY this is keyed on the session ids and reads runtimes through a REF: // - // Gating on `open` stopped this from running while the modal is closed, but - // while it is OPEN the memo still depended on `workspace.runtimes` — the - // comment directly above names it "one of the highest-churn references in - // the app". Every streaming tick from any pane therefore re-walked up to - // 2000 entries for every matching row, and the whole point of the number is - // a single threshold comparison that defaults one checkbox. + // 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. // - // The estimate only needs to be right when the row set or the open state - // changes. Reading the freshest runtimes out of a ref at that moment gives - // exactly that, with no dependency on their identity. + // 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) { + 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, open]) + // 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 @@ -464,7 +477,11 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { }, []) 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 @@ -506,7 +523,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { }, [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 @@ -533,11 +550,17 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { } 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 and the `finally` still announced - // "Sent /model … to 3 agents" with failed === 0 — reporting success for - // every agent the loop never reached. + // 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 += Math.max(remaining, 1) + failed += remaining > 0 ? remaining : 1 if (firstFailure === null) { firstFailure = error instanceof Error && error.message.length > 0 ? error.message @@ -553,7 +576,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 @@ -582,6 +607,10 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) { // `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 (locked) return onClose() @@ -619,7 +648,7 @@ export function BulkProviderSwitchModal({ open, workspace, onClose }: Props) {
) From 7c91741d6baca2379c7af3873099e0c931605c74 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:47:19 -0700 Subject: [PATCH 20/32] fix(workspace): truncate pane paths from the start, not the end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every pane in a workspace shares the leading path segments, so `text-overflow: ellipsis` — which always clips the END of a line — cut away the one part that identifies the agent. A narrow pane showed ".../Desktop/Developme…" for every session, and with several open the header stopped distinguishing them at all. The project directory is the answer to "which agent is this", and it was the first thing to go. `shortenCwd` was already producing the right string; only the clipping end was wrong. The new `truncate-start` class reverses the paragraph direction so the overflow edge lands on the left and `text-overflow` does its normal job there, with `unicode-bidi: plaintext` keeping the ASCII path itself rendering left to right so only the OVERFLOW is taken from the front. Applied to both surfaces that show it: the structured pane header and the raw agent terminal header. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../2026-09-08-post-merge-regression-audit.md | 160 +++++-- src/providers/providerFeatures.test.ts | 15 + src/providers/shared/featureCapabilities.ts | 2 +- src/renderer/src/styles.css | 29 ++ .../src/workspace/hook/actions/session.ts | 413 ++++++++++-------- .../sessionMetaIdentity.renderer.test.ts | 101 +++++ .../workspace/tile-tree/AgentTerminalLeaf.tsx | 4 +- .../tile-tree/TileLeaf/PaneHeader.tsx | 4 +- 8 files changed, 497 insertions(+), 231 deletions(-) create mode 100644 src/renderer/src/workspace/hook/actions/sessionMetaIdentity.renderer.test.ts 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 index 9eb1cbc1..f75a30c0 100644 --- a/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md +++ b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md @@ -226,54 +226,85 @@ These were found while auditing and are not among the reported symptoms. --- -## Open decisions (not actioned — both are genuine trade-offs) - -### D1. Who owns `End` inside a TUI? (fixes symptom 3) - -`resolveEffectiveKeybindings` keys on `commandId` in a `Map`, so one command -id gets exactly one context. You cannot have both `End` in `feed` and -`Alt+End` in `global` for `jump-latest-message`. - -- **Option A — make `End` work on terminal surfaces.** Widen the `feedFocused` - predicate to accept agent panes on either surface, and exempt xterm's helper - textarea from `isTextEditingTarget` (e.g. `el.closest('.xterm')`). Both - edits are required; either alone changes nothing. Delivers what #837 - promised. **Cost:** bare `End` stops reaching the provider TUI's own line - editor, which binds Home/End. Presumably why the original author left the - gate alone. -- **Option B — move it to `Alt+End` in `global`.** One line, mirrors how - `toggle-tail` already reaches raw terminals. **Cost:** feed users lose bare - `End`. -- **Option C — a second command id for the terminal surface**, so the feed - keeps `End` and terminals get `Alt+End`. No conflict, at the price of two - near-identical palette entries. - -Either way, three records asserting today's behaviour need updating: -`command-keybindings/reservations.ts:310-313`, -`command-palette/keybindingBaseline.test.ts:200-202`, and the router wiring -tests. - -### D2. WebGL: bump to a beta, or turn it off? (fixes most of symptom 4) - -- **Option A — bump `@xterm/addon-webgl` to `0.20.0-beta.300`.** The real - upstream fix, and the local workaround (plus its comment saying it "can go - once a stable addon with the upstream merge/retry fixes passes the - colored-output/scroll regression workload") could then be deleted. - **Cost:** a beta GPU renderer in a daily-driver Electron app, with no stable - 0.20.0 in sight. Cannot be verified here without running the app. -- **Option B — stop attaching the WebGL renderer for agent terminals** and - fall back to the DOM renderer, which is what VS Code ships as its own answer - to this exact symptom class (`terminal.integrated.gpuAcceleration: "off"`, - widely recommended specifically for Claude Code TUIs). **Cost:** reverses - the deliberate perf decision in #783/`3b885068`, four days old. -- **Option C — both, behind a setting**, defaulting to DOM until 0.20.0 is - stable. - ---- - -## Confirmed but NOT fixed - -Ordered by severity. Each is reproducible from the stated input. +## 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). + +Fixed by making jump provider-aware through the existing feature-capability +table — which mechanism applies is a fact about the provider's TUI, not about +the pane. OpenCode declares ESC + 0x07, which is Ctrl+Alt+G in the legacy +encoding and is what it binds to `messages_last`. Not the bare `End` it also +accepts, because that is also bound to `input_buffer_end` and would move the +prompt caret. Legacy bytes rather than the kitty protocol OpenCode requests, +because xterm 6.0.0 has no kitty support and never answers the query. + +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 @@ -510,3 +541,36 @@ Verified by running the same files at `origin/main`: - `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. diff --git a/src/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts index db956cb9..bd180598 100644 --- a/src/providers/providerFeatures.test.ts +++ b/src/providers/providerFeatures.test.ts @@ -36,6 +36,9 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['codex', 'opencode'], verifiedExternalResumeCommand: true, + // Inline on the normal screen buffer, so real xterm scrollback exists + // and Jump to Latest moves the viewport. + terminalJumpToLatestKey: null, }, codex: { savedSessionListing: true, @@ -45,6 +48,7 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['claude', 'opencode'], verifiedExternalResumeCommand: true, + terminalJumpToLatestKey: null, }, opencode: { savedSessionListing: false, @@ -58,6 +62,14 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['claude', 'codex'], verifiedExternalResumeCommand: true, + // The one row that is NOT null, and the reason this capability exists. + // OpenCode runs OpenTUI on the ALTERNATE SCREEN and owns its + // transcript internally, so nothing is ever evicted into xterm + // scrollback and scrollToBottom is a guaranteed no-op — which is why + // Jump to Latest silently did nothing on OpenCode Terminal panes. + // ESC + 0x07 is Ctrl+Alt+G in the legacy encoding, which OpenCode + // binds to `messages_last`. + terminalJumpToLatestKey: '\u001b\u0007', }, }) }) @@ -72,6 +84,9 @@ describe('provider feature matrix', () => { inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, + // A plain shell has no TUI transcript of its own, so the xterm viewport + // is the only thing there is to scroll. + terminalJumpToLatestKey: null, }) }) diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index fb0af5df..8f31e11b 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -91,7 +91,7 @@ export type ProviderFeatureCapabilities = { * OpenCode does not. It runs OpenTUI, whose `screenMode` defaults to * `alternate-screen`, and it renders the transcript into an internal * `` with its own paging keybinds. Nothing is ever evicted - * upward, so `viewportY === baseY`永 holds and `term.scrollToBottom()` is a + * upward, so `viewportY === baseY` always holds and `term.scrollToBottom()` is a * guaranteed no-op — which is why Jump to Latest silently did nothing on * OpenCode Terminal panes while working everywhere else. The only mechanism * that can move that transcript is the TUI's own key. diff --git a/src/renderer/src/styles.css b/src/renderer/src/styles.css index d9989b35..97a0759c 100644 --- a/src/renderer/src/styles.css +++ b/src/renderer/src/styles.css @@ -1063,3 +1063,32 @@ body, *::-webkit-scrollbar-thumb:hover { background: var(--theme-muted); } + +/* Truncate a path from its START, so the ellipsis eats the least useful end. + * + * WHY this exists rather than Tailwind's `truncate`: `text-overflow: ellipsis` + * always clips the END of the line. For a project path that is exactly + * backwards — every pane in a workspace shares the same first segments, so a + * narrow pane showed ".../Desktop/Developme…" for all of them and the one part + * that identifies the agent, the project directory, was the first thing cut. + * With several panes open the header stopped distinguishing them at all. + * + * WHY direction + unicode-bidi rather than measuring and slicing in JS: the + * ellipsis has to land wherever the box happens to end, which only the layout + * engine knows. Reversing the paragraph direction moves the overflow edge to + * the left and lets `text-overflow` do its normal job there. + * + * `unicode-bidi: plaintext` is what keeps the text itself readable: it derives + * the run's direction from its first strong character, so an ASCII path still + * renders left-to-right and only the OVERFLOW is taken from the front. Without + * it, `direction: rtl` alone would also reorder the leading "…/" and the + * separators. + */ +.truncate-start { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + direction: rtl; + unicode-bidi: plaintext; + text-align: left; +} diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 3cc76bda..9c4ea621 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -152,17 +152,53 @@ export async function killSessionBackendIfOwned( * object read to them as "the pane was replaced", so the first insertion into * any pane that needed waking always failed and the retry always worked. * - * Compared over the union of both key sets, so a field DISAPPEARING counts as - * a change — `withoutProvisionalProviderSession` legitimately drops keys, and - * treating that as a no-op would hand those callers a token that outlived the - * fact it stood for. Shallow is sufficient: SessionMeta is a flat record of - * primitives. + * Compared over the union of both key sets rather than over `next` alone. + * That is defensive rather than load-bearing AT THIS CALL SITE: the caller + * builds `next` as `{...current, ...recoveredMeta}`, which is always a + * superset of `current`'s keys, so nothing can actually disappear here. It is + * written this way so the function stays correct for a caller that composes a + * meta object differently — a field vanishing must count as a change, or a + * holder gets a token that outlived the fact it stood for. + * + * (Worth knowing, and NOT introduced here: that superset property also means + * the wake's `withoutProvisionalProviderSession` is inert. It strips + * `providerSessionId`/`providerSessionIdSource` from `restoredMeta` and the + * spread puts them straight back from `current`. The same helper does take + * effect where it is spread into a FRESH object instead. Pre-existing, out of + * scope for a comparison function, but this is where someone will next look + * for it.) + * + * Shallow is sufficient for the VALUES, with one exception that + * `metaValuesEqual` handles: `builtInMcpDomains` is an array and is rebuilt on + * every wake. Everything else on SessionMeta is a primitive. */ +function metaValuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + // WHY arrays need an element-wise pass rather than reference equality: + // + // `builtInMcpDomains` is the one array field on SessionMeta, and the wake + // path REBUILDS it every time — `resolveSessionBuiltInMcpDomains` ends in a + // filter, so it returns a fresh array even when the contents are identical. + // A pure `Object.is` comparison therefore reported "changed" on every wake + // of every agent pane, which is precisely the population this whole + // comparison exists to protect, and would have left the bug fixed only for + // plain terminals. + // + // Elements are a string union, so a shallow pass is exact. Nothing else on + // SessionMeta is an object or array; if that ever changes, this function is + // where the new shape has to be answered rather than silently compared by + // reference. + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => Object.is(value, b[index])) + } + return false +} + function metaIsUnchanged(current: SessionMeta, next: SessionMeta): boolean { const a = current as unknown as Record const b = next as unknown as Record const keys = new Set([...Object.keys(a), ...Object.keys(b)]) - for (const key of keys) if (!Object.is(a[key], b[key])) return false + for (const key of keys) if (!metaValuesEqual(a[key], b[key])) return false return true } @@ -329,191 +365,208 @@ export function useSessionActions( let sessionId: SessionId let tmuxName: string | undefined let startedProviderSessionId: string | undefined + // What this spawn reserved, if anything. `replaceSession` owns the + // release for every path AFTER spawn resolves, but it never sees an id + // when spawn THROWS — and by then the successor may already be + // committed to state.sessions, so a stranded reservation would leave a + // real pane permanently unnamed. That is the mirror-image bug of the + // name leak this reservation exists to stop, so spawn owns its own + // failure path. + let reservedIdentityCarry: SessionId | null = null try { - const result = await window.api.spawnSession({ + try { + const result = await window.api.spawnSession({ + kind, + providerRuntime: opts?.providerRuntime, + cwd, + resumeSessionId: opts?.resumeSessionId, + ...(opts?.predecessorSessionId + ? { predecessorSessionId: opts.predecessorSessionId } + : {}), + dangerousMode, + useProxy, + recoverTmuxName: opts?.recoverTmuxName, + builtInMcpDomains, + }) + sessionId = result.sessionId + tmuxName = result.tmuxName + startedProviderSessionId = result.providerSessionId + // WHY the reservation happens HERE, before this function's own commit: + // + // The gap the reconciler exploits opens the moment the successor's + // metadata lands in `state.sessions` without an `agentNameId`, and + // that is the setState a few lines below. `replaceSession` cannot + // reserve after `await spawn(...)` because by then React has already + // been given the chance to flush. Only spawn knows the id early + // enough. + // + // Conditional on the predecessor actually HAVING an identity, so + // replacements that carry nothing keep today's behaviour exactly: + // the reconciler claims the successor under its own id, which the + // replacement commit's comment already describes as the correct + // outcome. `predecessorSessionId` is passed by `replaceSession` and + // nothing else, so this cannot fire for an ordinary spawn. + if (opts?.predecessorSessionId + && refs.stateRef.current.sessions[opts.predecessorSessionId]?.agentNameId !== undefined) { + reserveIdentityCarry(sessionId) + reservedIdentityCarry = sessionId + } + if (result.replacementTransactionId) { + // WHY presence, not renderer inference: only main can prove the + // successor targeted the predecessor's exact Codex rollout and + // therefore consumed the destructive handoff. replaceSession uses + // this marker to suppress its legacy predecessor kill; workspace + // persistence later commits the still-pending main transaction. + pendingReplacementSuccessorsRef.current.add(sessionId) + } + } catch (err) { + throw new Error(sessionSpawnErrorMessage(kind, err, useProxy === true)) + } + const previousMeta = refs.stateRef.current.sessions[sessionId] + const requestedProviderSessionId = opts?.resumeSessionId ?? startedProviderSessionId + // WHY spawn does NOT mint agentNameId: replaceSession spawns through this + // same function, so a seed here lands on the SUCCESSOR and then wins the + // spread in the replacement commit, renaming a pane that only changed + // backends. Minting lives in one place — the reconciler — which sees the + // restored workspace and can tell a new agent from a recovered one. + const meta: SessionMeta = { + ...(previousMeta ?? {}), + cwd, kind, + // Write the field even when absent so a pathological reused id cannot + // inherit an alternate runtime from stale metadata. providerRuntime: opts?.providerRuntime, - cwd, - resumeSessionId: opts?.resumeSessionId, - ...(opts?.predecessorSessionId - ? { predecessorSessionId: opts.predecessorSessionId } + ...(tmuxName ? { tmuxName } : {}), + ...(kind !== 'terminal' && requestedProviderSessionId + ? { + providerSessionId: requestedProviderSessionId, + providerSessionIdSource: opts?.resumeSessionId + ? 'resume-request' as const + : 'runtime-start' as const, + } + : {}), + ...(isAgentProviderKind(kind) && builtInMcpDomains !== undefined + ? { builtInMcpDomains } : {}), - dangerousMode, - useProxy, - recoverTmuxName: opts?.recoverTmuxName, - builtInMcpDomains, - }) - sessionId = result.sessionId - tmuxName = result.tmuxName - startedProviderSessionId = result.providerSessionId - // WHY the reservation happens HERE, before this function's own commit: - // - // The gap the reconciler exploits opens the moment the successor's - // metadata lands in `state.sessions` without an `agentNameId`, and - // that is the setState a few lines below. `replaceSession` cannot - // reserve after `await spawn(...)` because by then React has already - // been given the chance to flush. Only spawn knows the id early - // enough. - // - // Conditional on the predecessor actually HAVING an identity, so - // replacements that carry nothing keep today's behaviour exactly: - // the reconciler claims the successor under its own id, which the - // replacement commit's comment already describes as the correct - // outcome. `predecessorSessionId` is passed by `replaceSession` and - // nothing else, so this cannot fire for an ordinary spawn. - if (opts?.predecessorSessionId - && refs.stateRef.current.sessions[opts.predecessorSessionId]?.agentNameId !== undefined) { - reserveIdentityCarry(sessionId) - } - if (result.replacementTransactionId) { - // WHY presence, not renderer inference: only main can prove the - // successor targeted the predecessor's exact Codex rollout and - // therefore consumed the destructive handoff. replaceSession uses - // this marker to suppress its legacy predecessor kill; workspace - // persistence later commits the still-pending main transaction. - pendingReplacementSuccessorsRef.current.add(sessionId) } - } catch (err) { - throw new Error(sessionSpawnErrorMessage(kind, err, useProxy === true)) - } - const previousMeta = refs.stateRef.current.sessions[sessionId] - const requestedProviderSessionId = opts?.resumeSessionId ?? startedProviderSessionId - // WHY spawn does NOT mint agentNameId: replaceSession spawns through this - // same function, so a seed here lands on the SUCCESSOR and then wins the - // spread in the replacement commit, renaming a pane that only changed - // backends. Minting lives in one place — the reconciler — which sees the - // restored workspace and can tell a new agent from a recovered one. - const meta: SessionMeta = { - ...(previousMeta ?? {}), - cwd, - kind, - // Write the field even when absent so a pathological reused id cannot - // inherit an alternate runtime from stale metadata. - providerRuntime: opts?.providerRuntime, - ...(tmuxName ? { tmuxName } : {}), - ...(kind !== 'terminal' && requestedProviderSessionId - ? { - providerSessionId: requestedProviderSessionId, - providerSessionIdSource: opts?.resumeSessionId - ? 'resume-request' as const - : 'runtime-start' as const, - } - : {}), - ...(isAgentProviderKind(kind) && builtInMcpDomains !== undefined - ? { builtInMcpDomains } - : {}), - } - setState(prev => ({ - ...prev, - sessions: { - ...prev.sessions, - // Persist tmuxName when main returns one — that's the - // signal that this terminal got tmux backing and is - // eligible for cross-restart recovery on next launch. - [sessionId]: meta, - }, - })) - setRuntimes(prev => { - const current = prev[sessionId] - const base = emptyRuntime() - return { + setState(prev => ({ ...prev, - [sessionId]: { - ...base, - ...(kind !== 'terminal' && opts?.providerRuntime !== 'terminal' - ? seedResumedRuntimeFields(current, meta) - : { - hasOlderHistory: false, - transcriptStatus: 'ready' as const, - transcriptError: null, - processStatus: 'started' as const, - processError: null, - inputReady: current?.inputReady ?? false, - inputReadinessRevision: current?.inputReadinessRevision ?? -1, - }), - exited: current?.exited ?? null, + sessions: { + ...prev.sessions, + // Persist tmuxName when main returns one — that's the + // signal that this terminal got tmux backing and is + // eligible for cross-restart recovery on next launch. + [sessionId]: meta, }, - } - }) - if (kind !== 'terminal' && meta.providerRuntime !== 'terminal' && meta.providerSessionId) { - void loadInitialHistoryForSession({ - sessionId, - meta, - refs, - setRuntimes, + })) + setRuntimes(prev => { + const current = prev[sessionId] + const base = emptyRuntime() + return { + ...prev, + [sessionId]: { + ...base, + ...(kind !== 'terminal' && opts?.providerRuntime !== 'terminal' + ? seedResumedRuntimeFields(current, meta) + : { + hasOlderHistory: false, + transcriptStatus: 'ready' as const, + transcriptError: null, + processStatus: 'started' as const, + processError: null, + inputReady: current?.inputReady ?? false, + inputReadinessRevision: current?.inputReadinessRevision ?? -1, + }), + exited: current?.exited ?? null, + }, + } }) - } + if (kind !== 'terminal' && meta.providerRuntime !== 'terminal' && meta.providerSessionId) { + void loadInitialHistoryForSession({ + sessionId, + meta, + refs, + setRuntimes, + }) + } - // Ghost log bootstrap — fire-and-forget, no await. If a prior - // run of Agent Code persisted ghosts for this sessionId, replay - // them through the atp reducer and merge into the runtime's - // ghost map. The renderer then sees the same merged feed after - // reload as it saw before. A missing file is not an error. - // - // WHY behind a setTimeout 0: spawnSession above set the fresh - // runtime via setRuntimes(prev => ...) — that update is queued - // and will land on the next tick. Reading the ghost log and - // applying it synchronously would run against the PREVIOUS - // runtime snapshot and its setRuntimes would clobber the - // fresh empty runtime. Deferring by one tick lets the empty - // runtime land first, then the bootstrap merge runs on top. - setTimeout(() => { - void window.api - .ghostRead(sessionId) - .then(rawEntries => { - if (!rawEntries || rawEntries.length === 0) return - const bootstrapped = reduceGhostLog(rawEntries as never[]) - if (bootstrapped.size === 0) return - setRuntimes(prev => { - const current = prev[sessionId] - if (!current) return prev - // Merge — disk ghosts only fill slots the runtime - // hasn't already produced in this session. If a ghost - // for the same uuid exists in-memory (rare; would mean - // a live event beat the bootstrap read), prefer the - // in-memory one because it's strictly fresher. - let merged = new Map(current.ghosts) - for (const [uuid, ghost] of bootstrapped) { - if (!merged.has(uuid)) merged.set(uuid, ghost) - } - // Reconcile against whatever JSONL entries already - // landed during the initial bootstrap burst. Without - // this, ghosts for turns that already have committed - // entries in `current.entries` would stay - // un-superseded forever: the live JSONL ingest already - // ran `reconcileUpstream` against the PREVIOUS (empty) - // ghost map and found no matches; now that the real - // ghosts are landing, nothing re-checks the - // already-ingested entries. This pass fixes the - // "crashed mid-turn, resumed with an orphan ghost that - // actually got committed" case. See Task 7 of the - // 2026-04-20 rendering-fixes plan. - for (const entry of current.entries) { - merged = reconcileUpstream(entry, merged) - } - // Persist any supersedes we just produced so the next - // resume reads the ghosts already in their reconciled - // state. `ghostsToPersist` diffs by updatedAt so it - // only emits ghosts whose state actually changed in - // this pass. - for (const ghost of ghostsToPersist(current.ghosts, merged)) { - window.api.ghostAppend(sessionId, ghost) - } - return { - ...prev, - [sessionId]: { ...current, ghosts: merged }, - } + // Ghost log bootstrap — fire-and-forget, no await. If a prior + // run of Agent Code persisted ghosts for this sessionId, replay + // them through the atp reducer and merge into the runtime's + // ghost map. The renderer then sees the same merged feed after + // reload as it saw before. A missing file is not an error. + // + // WHY behind a setTimeout 0: spawnSession above set the fresh + // runtime via setRuntimes(prev => ...) — that update is queued + // and will land on the next tick. Reading the ghost log and + // applying it synchronously would run against the PREVIOUS + // runtime snapshot and its setRuntimes would clobber the + // fresh empty runtime. Deferring by one tick lets the empty + // runtime land first, then the bootstrap merge runs on top. + setTimeout(() => { + void window.api + .ghostRead(sessionId) + .then(rawEntries => { + if (!rawEntries || rawEntries.length === 0) return + const bootstrapped = reduceGhostLog(rawEntries as never[]) + if (bootstrapped.size === 0) return + setRuntimes(prev => { + const current = prev[sessionId] + if (!current) return prev + // Merge — disk ghosts only fill slots the runtime + // hasn't already produced in this session. If a ghost + // for the same uuid exists in-memory (rare; would mean + // a live event beat the bootstrap read), prefer the + // in-memory one because it's strictly fresher. + let merged = new Map(current.ghosts) + for (const [uuid, ghost] of bootstrapped) { + if (!merged.has(uuid)) merged.set(uuid, ghost) + } + // Reconcile against whatever JSONL entries already + // landed during the initial bootstrap burst. Without + // this, ghosts for turns that already have committed + // entries in `current.entries` would stay + // un-superseded forever: the live JSONL ingest already + // ran `reconcileUpstream` against the PREVIOUS (empty) + // ghost map and found no matches; now that the real + // ghosts are landing, nothing re-checks the + // already-ingested entries. This pass fixes the + // "crashed mid-turn, resumed with an orphan ghost that + // actually got committed" case. See Task 7 of the + // 2026-04-20 rendering-fixes plan. + for (const entry of current.entries) { + merged = reconcileUpstream(entry, merged) + } + // Persist any supersedes we just produced so the next + // resume reads the ghosts already in their reconciled + // state. `ghostsToPersist` diffs by updatedAt so it + // only emits ghosts whose state actually changed in + // this pass. + for (const ghost of ghostsToPersist(current.ghosts, merged)) { + window.api.ghostAppend(sessionId, ghost) + } + return { + ...prev, + [sessionId]: { ...current, ghosts: merged }, + } + }) }) - }) - .catch(err => { - // Ghost bootstrap failures are non-fatal — the session - // still works, we just lose crash-recovered provisional - // state. Log and move on. - console.warn('[ghost] bootstrap read failed:', err) - }) - }, 0) + .catch(err => { + // Ghost bootstrap failures are non-fatal — the session + // still works, we just lose crash-recovered provisional + // state. Log and move on. + console.warn('[ghost] bootstrap read failed:', err) + }) + }, 0) - return sessionId + return sessionId + } catch (error) { + // Release ONLY on failure. On success the reservation must survive + // until replaceSession has committed the carried identity, which is + // the entire point of it. + if (reservedIdentityCarry) releaseIdentityCarry(reservedIdentityCarry) + throw error + } }, [refs.dangerousAgentsRef, refs.useProxyStreamingRef, setRuntimes, setState], ) diff --git a/src/renderer/src/workspace/hook/actions/sessionMetaIdentity.renderer.test.ts b/src/renderer/src/workspace/hook/actions/sessionMetaIdentity.renderer.test.ts new file mode 100644 index 00000000..ddc1c764 --- /dev/null +++ b/src/renderer/src/workspace/hook/actions/sessionMetaIdentity.renderer.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' + +// The behaviour under test is a private helper, so this exercises the SHAPE +// rule it enforces rather than importing it: after a wake that changed +// nothing, the session-meta object must keep its identity. +// +// WHY that matters at all — it is not an optimisation: +// +// deliverTextToSession's isCurrent(), and both prompt-template insertion +// paths, use the meta object's IDENTITY as their "is my target still the same +// pane?" token across an await. ensureSessionLive committed its recovered meta +// unconditionally, so ANY wake replaced that object even when every field was +// identical, and those guards read it as "the pane changed underneath me" and +// cancelled. Inserting a template or a vault key into a pane that was exited, +// parked, or still spawning therefore ALWAYS failed the first time with +// "target pane is gone", and always worked on the retry. +// +// The trap this file exists to pin: `builtInMcpDomains` is rebuilt on every +// wake (resolveSessionBuiltInMcpDomains ends in a filter, so it returns a +// fresh array with identical contents). A reference-equality comparison +// reports "changed" for it every single time, which would leave the bug fixed +// only for plain terminals — the one population that does not carry the field. + +type Meta = Record + +function metaValuesEqual(a: unknown, b: unknown): boolean { + if (Object.is(a, b)) return true + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((value, index) => Object.is(value, b[index])) + } + return false +} + +function metaIsUnchanged(current: Meta, next: Meta): boolean { + const keys = new Set([...Object.keys(current), ...Object.keys(next)]) + for (const key of keys) if (!metaValuesEqual(current[key], next[key])) return false + return true +} + +describe('session meta no-op detection', () => { + it('treats a rebuilt but identical domain array as unchanged', () => { + const current = { cwd: '/recorded', kind: 'claude', builtInMcpDomains: ['workflows'] } + // A different array object with the same contents — exactly what every + // wake produces for an agent pane. + const next = { cwd: '/recorded', kind: 'claude', builtInMcpDomains: ['workflows'] } + + expect(next.builtInMcpDomains).not.toBe(current.builtInMcpDomains) + expect(metaIsUnchanged(current, next)).toBe(true) + }) + + it('sees a real change to the domain list', () => { + expect(metaIsUnchanged( + { builtInMcpDomains: ['workflows'] }, + { builtInMcpDomains: ['workflows', 'ping'] }, + )).toBe(false) + expect(metaIsUnchanged( + { builtInMcpDomains: ['workflows'] }, + { builtInMcpDomains: ['ping'] }, + )).toBe(false) + // Order is content: the list is passed to a provider launch, so a + // reordering is a different launch even if the set matches. + expect(metaIsUnchanged( + { builtInMcpDomains: ['workflows', 'ping'] }, + { builtInMcpDomains: ['ping', 'workflows'] }, + )).toBe(false) + }) + + it('sees a scalar change', () => { + expect(metaIsUnchanged( + { cwd: '/recorded', providerRuntime: 'headless' }, + { cwd: '/recorded', providerRuntime: 'terminal' }, + )).toBe(false) + }) + + it('counts a DISAPPEARING field as a change', () => { + // withoutProvisionalProviderSession legitimately drops keys. Treating that + // as a no-op would hand a caller a token that outlived the fact it stood + // for, which is the opposite of the bug but just as wrong. + expect(metaIsUnchanged( + { cwd: '/recorded', providerSessionId: 'abc' }, + { cwd: '/recorded' }, + )).toBe(false) + }) + + it('counts an APPEARING field as a change', () => { + expect(metaIsUnchanged( + { cwd: '/recorded' }, + { cwd: '/recorded', tmuxName: 'agent-1' }, + )).toBe(false) + }) + + it('does not treat two different objects as equal just because neither is an array', () => { + // Nothing on SessionMeta is a plain object today. If that changes, the + // comparison must be taught the new shape rather than quietly reporting a + // difference forever — this case is the tripwire for that. + expect(metaIsUnchanged( + { nested: { a: 1 } } as Meta, + { nested: { a: 1 } } as Meta, + )).toBe(false) + }) +}) diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 2d38f8e9..623bc23c 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -572,7 +572,9 @@ export function AgentTerminalLeaf({ )} raw {provider} - + {/* truncate-START, matching PaneHeader: keep the project directory + visible and drop the shared prefix instead. */} + {shortenCwd(projectDir)}
diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx index df1aa244..0d47b356 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx @@ -121,7 +121,9 @@ export function PaneHeader({ {paneLabel} )} - + {/* truncate-START: every pane shares the leading path segments, so + clipping the end hid the one part that identifies this agent. */} + {shortenCwd(projectDir)}
From e560e8860626ab8485679aa28ffc307a86eb0af8 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:48:32 -0700 Subject: [PATCH 21/32] fix(workspace): reserve the identity carry for every replacement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remediation from the merge review. The reservation gate was narrower than the predicate it protects. Reserving was conditional on the predecessor already HAVING an `agentNameId` at spawn time, but the carry reads that field at COMMIT time, which is later. A predecessor that is still unnamed when spawn runs can be claimed by the reconciler during the await — at which point there IS an identity to carry — and the narrower gate left the successor claimable in that same window: it allocates a name, the commit overwrites it, and the name is orphaned forever. That is the exact leak the reservation exists to close, one step narrower. Reserving for every replacement costs nothing when there is nothing to carry: the successor simply claims after release, under its own id, which is the outcome the replacement commit already documents as correct. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../src/workspace/hook/actions/session.ts | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 9c4ea621..63606d36 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -400,14 +400,27 @@ export function useSessionActions( // been given the chance to flush. Only spawn knows the id early // enough. // - // Conditional on the predecessor actually HAVING an identity, so - // replacements that carry nothing keep today's behaviour exactly: - // the reconciler claims the successor under its own id, which the - // replacement commit's comment already describes as the correct - // outcome. `predecessorSessionId` is passed by `replaceSession` and - // nothing else, so this cannot fire for an ordinary spawn. - if (opts?.predecessorSessionId - && refs.stateRef.current.sessions[opts.predecessorSessionId]?.agentNameId !== undefined) { + // WHY this is NOT also gated on the predecessor already HAVING an + // identity, which a first version tried: + // + // The gate has to match the predicate the CARRY uses, and the carry + // reads `prev.sessions[oldId]?.agentNameId` at COMMIT time, which is + // later. A predecessor that is still unnamed when spawn runs can be + // claimed by the reconciler during the await — at which point there + // IS an identity to carry — and the narrower gate had left the + // successor claimable in that same window. It allocates a name, the + // commit overwrites it, and that name is orphaned forever. That is + // the exact leak this reservation exists to close, one step + // narrower. + // + // Reserving for every replacement costs nothing when there is + // nothing to carry: the successor simply claims after release, on + // the next state change, under its own id — the outcome the + // replacement commit's comment already describes as correct. + // + // `predecessorSessionId` is passed by `replaceSession` and nothing + // else, so this cannot fire for an ordinary spawn. + if (opts?.predecessorSessionId) { reserveIdentityCarry(sessionId) reservedIdentityCarry = sessionId } From 7618be2c11661a63ee93d23a7e5de98b05236786 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:55:54 -0700 Subject: [PATCH 22/32] fix(terminal): restore the TUI's screen and mouse modes when a pane attaches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mouse wheel does nothing at all in an OpenCode raw terminal pane. Nothing swallows it — the modes that make it work were thrown away. attachAgentPty replays the trailing bytes of a CAPPED buffer that evicts the oldest data. A TUI writes its mode preamble exactly once, at startup: alternate screen, then mouse button/drag/any-event tracking, then 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 by the time a renderer attaches, and nothing anywhere reconstructs it. The freshly-constructed xterm therefore sits on the NORMAL buffer with no mouse tracking while the application believes the opposite. xterm only attaches its wheel-to-mouse-report listener 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 the same on either buffer, which is why this was hard to see. OpenCode enables mouse capture by default (`mouse: true`, and Agent Code never sets OPENCODE_DISABLE_MOUSE), and the installed binary's renderer setup block contains exactly those DECSETs. Claude Code and Codex are unaffected: they render inline and push real scrollback, so their wheel scrolling needs no mode at all. Main now watches the five modes whose loss is silent and unrecoverable — 1049, 1000, 1002, 1003, 1006 — as chunks go past, and prepends the active ones ahead of the replay. Colours, cursor shape and window title are NOT tracked, because the next repaint re-asserts them; screen buffer and mouse tracking are, because the application sets them once and never again. A mode the application later turned off is dropped, so a TUI that suspended for $EDITOR does not get put back on a buffer it left. A hand-rolled scanner rather than a second emulator: the replay buffer is a byte stream and the question is only which of five one-shot modes are on. A regex per chunk answers that in microseconds and cannot desynchronise the way an emulator fed a truncated stream could. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/sessionManager.ts | 26 ++++- src/main/sessions/terminalModeTracker.test.ts | 94 ++++++++++++++++ src/main/sessions/terminalModeTracker.ts | 102 ++++++++++++++++++ 3 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/main/sessions/terminalModeTracker.test.ts create mode 100644 src/main/sessions/terminalModeTracker.ts diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index f400fec8..526ee19b 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -33,6 +33,7 @@ import type { SessionRecoverOptions, SessionRecoverResult, } from '@shared/types/session.js' +import { TerminalModeTracker } from '@main/sessions/terminalModeTracker.js' import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' import { MissingWorkspaceDirectoryError, @@ -649,6 +650,11 @@ export class SessionManager extends EventEmitter { // only after an attach, and let the renderer replay the buffer before // draining live bytes. private readonly agentPtyBuffers = new Map() + // Parallel to agentPtyBuffers, and the reason it exists: that buffer is + // capped and evicts the OLDEST bytes, which is where a TUI's one-shot + // alternate-screen and mouse-tracking preamble lives. See + // TerminalModeTracker for what that costs a renderer that attaches later. + private readonly agentPtyModes = new Map() private readonly agentPtyAttachCounts = new Map() private readonly agentPtyRestoreSizes = new Map() @@ -882,6 +888,7 @@ export class SessionManager extends EventEmitter { this.terminalAttached.delete(sessionId) } else { this.agentPtyBuffers.delete(sessionId) + this.agentPtyModes.delete(sessionId) this.agentPtyAttachCounts.delete(sessionId) this.agentPtyRestoreSizes.delete(sessionId) if (revokeAgentMcp) this.builtInMcpHost?.revokeSession(sessionId) @@ -2655,6 +2662,7 @@ export class SessionManager extends EventEmitter { this.sessionSizes.set(sessionId, initialSize) this.agentPtyBuffers.set(sessionId, new CappedTextBuffer(AGENT_PTY_BUFFER_CAP)) + this.agentPtyModes.set(sessionId, new TerminalModeTracker()) session.on('started', ({ projectDir }) => { if (!ownsEntry()) return this.markActivity(sessionId) @@ -2697,6 +2705,15 @@ export class SessionManager extends EventEmitter { this.agentPtyBuffers.set(sessionId, replay) } replay.append(data) + // Observed on the way past, NOT reconstructed from the buffer later: + // by the time a renderer attaches, the bytes that set these modes have + // usually been evicted, which is the entire point. + let modes = this.agentPtyModes.get(sessionId) + if (!modes) { + modes = new TerminalModeTracker() + this.agentPtyModes.set(sessionId, modes) + } + modes.observe(data) if ((this.agentPtyAttachCounts.get(sessionId) ?? 0) > 0) { this.emit('agent-pty-data', { sessionId, data }) } @@ -3277,7 +3294,14 @@ export class SessionManager extends EventEmitter { ) return null } - const buffer = this.agentPtyBuffers.get(sessionId)?.read() ?? '' + // The mode preamble goes FIRST, ahead of the replayed bytes. A fresh + // xterm starts on the normal buffer with no mouse tracking, and the bytes + // that would have told it otherwise were evicted long ago — so without + // this the attaching pane silently disagrees with the application about + // which screen it is on and whether the wheel is reportable. Re-setting a + // mode that is already set is a no-op, so prepending is unconditional. + const modePreamble = this.agentPtyModes.get(sessionId)?.preamble() ?? '' + const buffer = modePreamble + (this.agentPtyBuffers.get(sessionId)?.read() ?? '') const attachCount = this.agentPtyAttachCounts.get(sessionId) ?? 0 if (attachCount === 0) { const currentSize = this.sessionSizes.get(sessionId) diff --git a/src/main/sessions/terminalModeTracker.test.ts b/src/main/sessions/terminalModeTracker.test.ts new file mode 100644 index 00000000..7a420e10 --- /dev/null +++ b/src/main/sessions/terminalModeTracker.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest' + +import { TerminalModeTracker } from './terminalModeTracker' + +// The bug these pin, restated because it is invisible from the code alone: +// +// attachAgentPty replays the trailing bytes of a CAPPED buffer that evicts the +// oldest data. A TUI writes its alternate-screen and mouse-tracking preamble +// exactly once, at startup, so on any busy session those bytes are long gone +// by the time a renderer attaches. The fresh xterm then sits on the normal +// buffer with no mouse tracking while the application believes otherwise — +// which is why the mouse wheel did nothing at all in an OpenCode terminal +// pane, even though nothing was swallowing it. + +const ESC = '\x1b' + +describe('TerminalModeTracker', () => { + it('restores nothing for a stream that set nothing', () => { + const tracker = new TerminalModeTracker() + tracker.observe('plain output with no escapes\r\n') + expect(tracker.preamble()).toBe('') + }) + + it('captures a TUI startup preamble written as one sequence per mode', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1000h${ESC}[?1002h${ESC}[?1003h${ESC}[?1006h`) + expect(tracker.activeModes()).toEqual([1049, 1000, 1002, 1003, 1006]) + }) + + it('captures modes combined into one semicolon-separated sequence', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1000;1002;1006h`) + expect(tracker.activeModes()).toEqual([1000, 1002, 1006]) + }) + + it('puts the screen switch first regardless of the order it saw them', () => { + // 1049 has to lead, or everything the replay paints lands on the normal + // buffer and is abandoned the moment the switch happens. + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1006h${ESC}[?1049h`) + expect(tracker.preamble()).toBe(`${ESC}[?1049h${ESC}[?1006h`) + }) + + it('forgets a mode the application turned back off', () => { + // A TUI that suspends for $EDITOR leaves the alternate screen. Replaying a + // stale 1049h would put the pane on a buffer the application is not using. + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) + tracker.observe(`${ESC}[?1049l`) + expect(tracker.activeModes()).toEqual([1003]) + }) + + it('handles a reset that names several modes at once', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1000h${ESC}[?1002h${ESC}[?1003h${ESC}[?1006h`) + tracker.observe(`${ESC}[?1003l${ESC}[?1002l${ESC}[?1000l${ESC}[?1006l`) + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('ignores private modes it is not responsible for', () => { + // Cursor visibility, bracketed paste and focus reporting are re-asserted + // by the next repaint, so restoring them would be noise at best. + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?25l${ESC}[?2004h${ESC}[?1004h${ESC}[?1049h`) + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('is not fooled by the digits appearing in ordinary output', () => { + const tracker = new TerminalModeTracker() + tracker.observe('the value is 1049h and the mode is [?1049h-ish\r\n') + expect(tracker.preamble()).toBe('') + }) + + it('survives being fed the same preamble twice', () => { + // Reconnects and provider restarts re-emit it; the set must not grow or + // reorder. + const tracker = new TerminalModeTracker() + const preamble = `${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h` + tracker.observe(preamble) + tracker.observe(preamble) + expect(tracker.preamble()).toBe(preamble) + }) + + it('keeps state across many chunks, which is how a real stream arrives', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h`) + for (let i = 0; i < 500; i += 1) tracker.observe(`frame ${i}\r\n`) + tracker.observe(`${ESC}[?1003h`) + for (let i = 0; i < 500; i += 1) tracker.observe(`frame ${i}\r\n`) + // This is exactly the case the capped replay buffer loses: the first + // sequence is thousands of bytes back and would have been evicted. + expect(tracker.preamble()).toBe(`${ESC}[?1049h${ESC}[?1003h`) + }) +}) diff --git a/src/main/sessions/terminalModeTracker.ts b/src/main/sessions/terminalModeTracker.ts new file mode 100644 index 00000000..6c842599 --- /dev/null +++ b/src/main/sessions/terminalModeTracker.ts @@ -0,0 +1,102 @@ +/** + * Tracks the DEC private modes a provider TUI has turned on, so an attaching + * renderer can be put back into them. + * + * WHY this is needed at all - the bug it fixes: + * + * `attachAgentPty` hands a freshly-constructed xterm the trailing bytes of a + * capped replay buffer, and `CappedTextBuffer` evicts the OLDEST bytes. A TUI + * writes its mode preamble EXACTLY ONCE, at startup: + * + * ESC [ ? 1049 h alternate screen + * ESC [ ? 1000 h mouse: button events + * ESC [ ? 1002 h mouse: button + drag + * ESC [ ? 1003 h mouse: any event, including the WHEEL + * ESC [ ? 1006 h mouse reports in SGR encoding + * + * A TUI repainting at 60fps blows through the 512 KiB cap quickly, so on any + * session with real activity that preamble has already been evicted. The new + * terminal then never enters the alternate screen and never enables mouse + * tracking, and nothing anywhere reconstructs the modes. + * + * The user-visible result for OpenCode, whose OpenTUI enables mouse capture by + * default: the mouse WHEEL does nothing at all in a raw terminal pane. Not + * because anything swallows it - nothing does - but because xterm only + * attaches its wheel-to-mouse-report listener when the application has asked + * for wheel events, and its fallback path returns early on a normal buffer. + * 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 + * the same on either buffer, which is why this was hard to see. + * + * Claude Code and Codex are unaffected: they render inline and push real + * scrollback, so their wheel scrolling needs no mode at all. + * + * WHY a hand-rolled scanner rather than a second terminal emulator: main + * already runs a headless terminal for screen snapshots, but the replay buffer + * is a BYTE stream and the question here is only "which of five one-shot modes + * are currently on". A regex over each appended chunk answers exactly that in + * microseconds, and cannot desynchronise the way an emulator fed a truncated + * stream could. + * + * WHY only these modes: they are the ones whose loss is silent and + * unrecoverable from a truncated stream. Colours, cursor shape and window + * title are re-asserted by the TUI's next repaint. Screen buffer and mouse + * tracking are not, because the application sets them once and never again. + */ + +/** DEC private modes worth restoring, in the order a TUI sets them. */ +const TRACKED_MODES = [1049, 1000, 1002, 1003, 1006] as const + +type TrackedMode = (typeof TRACKED_MODES)[number] + +/** + * One or more semicolon-separated DEC private parameters followed by the + * set/reset final byte, e.g. ESC [ ? 1000 ; 1002 ; 1006 h. + * + * Deliberately scans a CHUNK rather than parsing a stream: a sequence split + * across two PTY writes is missed. That is acceptable because a TUI emits its + * preamble in a single write, and because a missed mode leaves exactly today's + * behaviour rather than a wrong one. + */ +const DEC_PRIVATE_MODE = /\x1b\[\?([0-9;]+)([hl])/g + +export class TerminalModeTracker { + private readonly active = new Set() + + /** Feed a raw PTY chunk. Cheap enough for the hot path. */ + observe(chunk: string): void { + // Fast reject: the overwhelming majority of chunks carry no mode change. + if (!chunk.includes('\x1b[?')) return + for (const match of chunk.matchAll(DEC_PRIVATE_MODE)) { + const set = match[2] === 'h' + for (const raw of match[1].split(';')) { + const mode = Number(raw) as TrackedMode + if (!TRACKED_MODES.includes(mode)) continue + if (set) this.active.add(mode) + else this.active.delete(mode) + } + } + } + + /** + * The preamble to write BEFORE a replay buffer, so a fresh terminal starts + * in the modes the application believes it is already in. + * + * Emitted in TRACKED_MODES order rather than observation order: 1049 has to + * come first, or everything the replay paints lands on the normal buffer and + * is then abandoned when the switch happens. Re-setting a mode a terminal is + * already in is a no-op, so this is safe to prepend unconditionally. + */ + preamble(): string { + return TRACKED_MODES + .filter(mode => this.active.has(mode)) + .map(mode => `\x1b[?${mode}h`) + .join('') + } + + /** Test and diagnostic view. Never for control flow. */ + activeModes(): TrackedMode[] { + return TRACKED_MODES.filter(mode => this.active.has(mode)) + } +} From 8f3c02b42aad5273f0dfad4b2c69abe63feedd30 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 14:56:41 -0700 Subject: [PATCH 23/32] docs(audit): record the scroll root cause and the merge review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two symptoms reported while the branch was open, and the two-agent review. The wheel bug turned out to share a root cause with the attach replay rather than with the alternate-screen finding: nothing swallows the wheel, the modes that make it work were evicted from a capped buffer. Written down with the negative result too — the exhaustive check that found no interception — because that is the expensive part to redo. The review section records what two independent reviewers found, and names the one objection that is answered by reasoning rather than by code: the injected OpenCode chord is safe under stock config, reading the user's effective binding would mean reimplementing their config loader, and the rebinding-immune route needs a served transport this runtime does not use yet. That reasoning also now sits beside the constant itself. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../2026-09-08-post-merge-regression-audit.md | 84 +++++++++++++++++++ src/providers/shared/featureCapabilities.ts | 24 ++++++ 2 files changed, 108 insertions(+) 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 index f75a30c0..f54c0e27 100644 --- a/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md +++ b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md @@ -574,3 +574,87 @@ 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 + +Same family as the Jump to Latest bug, different mechanism, and this one is not +about the alternate screen owning the transcript. **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 were 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 ever attaches, and nothing reconstructed 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. + +Fixed by tracking those five modes as chunks go past and prepending the active +ones ahead of the replay. Colours, cursor shape and window title are +deliberately NOT tracked: the next repaint re-asserts them. Screen buffer and +mouse tracking are, because the application sets them once and never again. + +**This is adjacent to the deferred attach-replay ordering problem below, and +does not fix it.** Restoring modes says nothing about the terminal's DIMENSIONS +at replay time. + +### 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. + +The one objection NOT resolved by a code change is the rebinding hazard on the +injected chord, and the reasoning is recorded next to the constant in +`featureCapabilities.ts`: under stock config no byte we send can reach a +destructive action, reading the user's effective binding would mean +reimplementing OpenCode's config loader, and the rebinding-immune route needs a +served transport this runtime does not use yet. diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index 8f31e11b..afc8ffdb 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -197,6 +197,30 @@ const FEATURES_BY_KIND: Record = // protocol: xterm 6.0.0 has no kitty support, so it never answers the // query and the TUI stays on legacy parsing. If that ever changes this // string is the one place to revisit. + // + // KNOWN RESIDUAL RISK, stated because it is real and was raised in + // review: OpenCode keybinds are user-configurable through tui.json, so a + // user who has rebound this chord gets whatever they bound it to. Under + // STOCK config that cannot reach anything destructive — the only + // destructive action nearby is `messages_undo`, which aborts the session + // and reverts history, and it is bound to `u`, i.e. Ctrl+X then + // `u`. No byte sequence sent from here can produce that, because it + // requires 0x18 first. The exposure is narrow and deliberate: a user who + // moves a destructive action onto Ctrl+Alt+G. + // + // Reading their effective binding to be certain is NOT cheap and would be + // unreliable — it means reimplementing OpenCode's loader: JSONC, global + // plus per-project plus every .opencode directory up to home, variable + // substitution, a legacy-config migration, a win32 special case, and + // plugin-registered binds. That reimplementation would drift. + // + // The rebinding-immune path exists and is the right long-term answer: + // OpenCode's server exposes POST /tui/execute-command, whose alias table + // maps `messages_last` to the stable command `session.last` and dispatches + // it below the keybind layer. It needs a known server URL, which means + // spawning `opencode serve` and using `opencode attach` instead of running + // the TUI directly — a topology change, not a one-line swap. Take that + // route when the OpenCode runtime moves to a served transport. terminalJumpToLatestKey: '\u001b\u0007', }, } From 1ac7a1a6691710eb5dc838452eaf9030a3df273a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:00:54 -0700 Subject: [PATCH 24/32] fix(terminal): carry a partial mode sequence across PTY chunk boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught by the re-review, and the previous version's own comment had the reasoning backwards. It claimed a sequence split across two PTY writes is "missed, which is acceptable because a missed mode leaves exactly today's behaviour". That holds for a turn-ON. It is false for a turn-OFF, and the two directions are not symmetric: a missed turn-off leaves the mode in the set, so the next attach asserts a mode the application has already left — putting a pane back on the alternate screen after the TUI suspended for an editor, or telling it to report mouse events to a program that stopped listening. That is strictly worse than the bug being fixed. PTY chunks are split by pipe boundaries, not by escape sequences, so this is ordinary rather than exotic. The tracker now carries an unfinished sequence into the next chunk. It can only ever hold an INCOMPLETE one, so nothing is applied twice, and a fragment that turns out not to be a mode sequence — or that grows past any realistic length — is dropped rather than held. A split at the escape byte itself is still missed, because a lone ESC is not yet a marker. That is the benign direction and the new tests say so explicitly rather than leaving it to be rediscovered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/sessions/terminalModeTracker.test.ts | 66 +++++++++++++++++++ src/main/sessions/terminalModeTracker.ts | 49 +++++++++++--- 2 files changed, 107 insertions(+), 8 deletions(-) diff --git a/src/main/sessions/terminalModeTracker.test.ts b/src/main/sessions/terminalModeTracker.test.ts index 7a420e10..b7ba212f 100644 --- a/src/main/sessions/terminalModeTracker.test.ts +++ b/src/main/sessions/terminalModeTracker.test.ts @@ -92,3 +92,69 @@ describe('TerminalModeTracker', () => { expect(tracker.preamble()).toBe(`${ESC}[?1049h${ESC}[?1003h`) }) }) + +describe('sequences split across chunk boundaries', () => { + it('applies a turn-ON split between two chunks', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?10`) + tracker.observe('49h') + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('applies a turn-OFF split between two chunks', () => { + // The asymmetry that makes carrying state mandatory: missing a turn-ON + // leaves today's behaviour, but missing a turn-OFF is WORSE than today — + // the mode survives here and the next attach asserts a mode the + // application has already left, putting a pane back on the alternate + // screen after the TUI suspended for an editor. + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) + tracker.observe(`${ESC}[?104`) + tracker.observe('9l') + expect(tracker.activeModes()).toEqual([1003]) + }) + + it('handles a split at the escape byte itself', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`output${ESC}`) + tracker.observe('[?1006h') + // A lone ESC is not yet a mode marker, so this one is genuinely missed — + // and missing a turn-ON is the benign direction. + expect(tracker.activeModes()).toEqual([]) + }) + + it('applies a multi-mode sequence split mid-parameter-list', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1000;10`) + tracker.observe('02;1006h') + expect(tracker.activeModes()).toEqual([1000, 1002, 1006]) + }) + + it('never applies a carried sequence twice', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049`) + tracker.observe('h') + tracker.observe('ordinary output') + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('drops a carried fragment that turns out not to be a sequence', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049`) + // A final byte that is not h or l ends the sequence as something else. + tracker.observe('r rest of the line') + expect(tracker.activeModes()).toEqual([]) + // And the tracker is not left holding anything. + tracker.observe(`${ESC}[?1003h`) + expect(tracker.activeModes()).toEqual([1003]) + }) + + it('does not accumulate an unbounded fragment from a stream of digits', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?` + '1'.repeat(200)) + // Far past any real sequence, so it is dropped rather than carried, and a + // following final byte must not resurrect it. + tracker.observe('h') + expect(tracker.activeModes()).toEqual([]) + }) +}) diff --git a/src/main/sessions/terminalModeTracker.ts b/src/main/sessions/terminalModeTracker.ts index 6c842599..8bf4393a 100644 --- a/src/main/sessions/terminalModeTracker.ts +++ b/src/main/sessions/terminalModeTracker.ts @@ -53,22 +53,49 @@ type TrackedMode = (typeof TRACKED_MODES)[number] /** * One or more semicolon-separated DEC private parameters followed by the * set/reset final byte, e.g. ESC [ ? 1000 ; 1002 ; 1006 h. - * - * Deliberately scans a CHUNK rather than parsing a stream: a sequence split - * across two PTY writes is missed. That is acceptable because a TUI emits its - * preamble in a single write, and because a missed mode leaves exactly today's - * behaviour rather than a wrong one. */ const DEC_PRIVATE_MODE = /\x1b\[\?([0-9;]+)([hl])/g +/** A sequence that has begun but not yet reached its final byte. */ +const PARTIAL_SEQUENCE = /\x1b\[\?[0-9;]*$/ +/** A sequence whose final byte has arrived. */ +const COMPLETE_SEQUENCE = /^\x1b\[\?[0-9;]*[hl]/ + +/** + * Longest partial sequence worth carrying to the next chunk. + * + * Comfortably past the realistic maximum — all five tracked modes in one + * sequence is 28 characters — while bounding what a stream of digits could + * otherwise accumulate. Anything longer is not a mode sequence being + * assembled, so it is dropped rather than held. + */ +const MAX_PENDING = 64 + export class TerminalModeTracker { private readonly active = new Set() + /** + * A sequence that began at the end of the previous chunk. + * + * WHY carrying this matters, and why "a split sequence is just missed" was + * NOT an acceptable answer: the two directions are not symmetric. Missing a + * turn-ON leaves today's behaviour, which is what the previous version + * claimed. Missing a turn-OFF is strictly WORSE than today — the mode stays + * in this set, and the next attach asserts a mode the application has + * already left. A pane would be put back on the alternate screen after the + * TUI suspended for an editor, or told to report mouse events to a program + * that stopped listening. PTY chunks are split by pipe boundaries, not by + * escape sequences, so this is ordinary rather than exotic. + */ + private pending = '' /** Feed a raw PTY chunk. Cheap enough for the hot path. */ observe(chunk: string): void { - // Fast reject: the overwhelming majority of chunks carry no mode change. - if (!chunk.includes('\x1b[?')) return - for (const match of chunk.matchAll(DEC_PRIVATE_MODE)) { + // Fast reject, but only when nothing is half-parsed: a chunk with no + // marker of its own can still be the tail of a sequence begun earlier. + if (!this.pending && !chunk.includes('\x1b[?')) return + const scan = this.pending + chunk + this.pending = '' + for (const match of scan.matchAll(DEC_PRIVATE_MODE)) { const set = match[2] === 'h' for (const raw of match[1].split(';')) { const mode = Number(raw) as TrackedMode @@ -77,6 +104,12 @@ export class TerminalModeTracker { else this.active.delete(mode) } } + // Carry only a genuinely unfinished sequence. `pending` can never hold a + // COMPLETE one, so the match above cannot be applied twice. + const partial = PARTIAL_SEQUENCE.exec(scan) + if (partial && !COMPLETE_SEQUENCE.test(partial[0]) && partial[0].length <= MAX_PENDING) { + this.pending = partial[0] + } } /** From 737be88bff68ce0368ef3789171fd8e889d2499c Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:03:29 -0700 Subject: [PATCH 25/32] fix(terminal): carry a partial escape prefix, not just a partial parameter list Follow-up caught by the re-review. The previous carry required the whole `ESC [ ?` marker before it would hold anything, so a chunk ending at just `ESC` or `ESC [` still dropped the sequence that followed. That is the direction that matters. A missed turn-ON leaves today's behaviour; a missed turn-OFF leaves the mode asserted in the tracker after the application has disabled it, and the next attach then puts the pane back on a screen buffer the TUI has left. A chunk can end at ANY byte, so the carry has to begin at the escape byte. The fast-reject moved to the escape byte for the same reason: a chunk that ends mid-marker contains no complete sequence and still has to be carried. Four new cases cover both split points in both directions, plus one proving an escape that begins an unrelated sequence is not carried as though it were a mode change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/sessions/terminalModeTracker.test.ts | 38 +++++++++++++++++-- src/main/sessions/terminalModeTracker.ts | 20 +++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/src/main/sessions/terminalModeTracker.test.ts b/src/main/sessions/terminalModeTracker.test.ts index b7ba212f..eba4a481 100644 --- a/src/main/sessions/terminalModeTracker.test.ts +++ b/src/main/sessions/terminalModeTracker.test.ts @@ -115,12 +115,44 @@ describe('sequences split across chunk boundaries', () => { }) it('handles a split at the escape byte itself', () => { + // A chunk can end at ANY byte. Requiring the whole `ESC [ ?` marker before + // carrying anything meant a reset split here was still missed, which is + // the direction that leaves a stale mode asserted at the next attach. const tracker = new TerminalModeTracker() tracker.observe(`output${ESC}`) tracker.observe('[?1006h') - // A lone ESC is not yet a mode marker, so this one is genuinely missed — - // and missing a turn-ON is the benign direction. - expect(tracker.activeModes()).toEqual([]) + expect(tracker.activeModes()).toEqual([1006]) + }) + + it('handles a split after the control sequence introducer', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`output${ESC}[`) + tracker.observe('?1049h') + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('applies a RESET split at the escape byte, the worse direction', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) + tracker.observe(`frame${ESC}`) + tracker.observe('[?1049l') + expect(tracker.activeModes()).toEqual([1003]) + }) + + it('applies a RESET split after the control sequence introducer', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) + tracker.observe(`frame${ESC}[`) + tracker.observe('?1003l') + expect(tracker.activeModes()).toEqual([1049]) + }) + + it('does not carry an escape that begins some other sequence', () => { + const tracker = new TerminalModeTracker() + tracker.observe(`${ESC}[?1049h`) + tracker.observe(`${ESC}`) + tracker.observe('[2J clear screen') + expect(tracker.activeModes()).toEqual([1049]) }) it('applies a multi-mode sequence split mid-parameter-list', () => { diff --git a/src/main/sessions/terminalModeTracker.ts b/src/main/sessions/terminalModeTracker.ts index 8bf4393a..2d542952 100644 --- a/src/main/sessions/terminalModeTracker.ts +++ b/src/main/sessions/terminalModeTracker.ts @@ -56,8 +56,16 @@ type TrackedMode = (typeof TRACKED_MODES)[number] */ const DEC_PRIVATE_MODE = /\x1b\[\?([0-9;]+)([hl])/g -/** A sequence that has begun but not yet reached its final byte. */ -const PARTIAL_SEQUENCE = /\x1b\[\?[0-9;]*$/ +/** + * A sequence that has begun but not yet reached its final byte. + * + * Deliberately matches from the ESCAPE byte onward, not from the full + * `ESC [ ?` marker: a chunk can end at ANY byte, including after just `ESC` or + * `ESC [`. Requiring the whole marker before carrying anything meant a reset + * split at either of those two points was still missed, which is the direction + * that leaves a stale mode asserted at the next attach. + */ +const PARTIAL_SEQUENCE = /\x1b(?:\[(?:\?[0-9;]*)?)?$/ /** A sequence whose final byte has arrived. */ const COMPLETE_SEQUENCE = /^\x1b\[\?[0-9;]*[hl]/ @@ -90,9 +98,11 @@ export class TerminalModeTracker { /** Feed a raw PTY chunk. Cheap enough for the hot path. */ observe(chunk: string): void { - // Fast reject, but only when nothing is half-parsed: a chunk with no - // marker of its own can still be the tail of a sequence begun earlier. - if (!this.pending && !chunk.includes('\x1b[?')) return + // Fast reject on the ESCAPE byte, not on the full marker, and only when + // nothing is half-parsed: a chunk with no marker of its own can still be + // the tail of a sequence begun earlier, and a chunk that ends mid-marker + // has to be carried even though it contains no complete one. + if (!this.pending && !chunk.includes('\x1b')) return const scan = this.pending + chunk this.pending = '' for (const match of scan.matchAll(DEC_PRIVATE_MODE)) { From 31413977f374522cb2a6486b7681ebed6a75ae06 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:14:37 -0700 Subject: [PATCH 26/32] revert(terminal): drop the mode tracker; its design is wrong, not just its parsing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted after the re-review reproduced two failures against real xterm. **Prepending CURRENT modes ahead of HISTORICAL output loses content.** 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 subsequent `1049l` discards it. The original terminal keeps that transcript; the attached one does not. What is needed is the mode state at the replay's STARTING boundary, not its current ending state — and the tracker had no notion of that boundary at all. **A Set of independent flags is not xterm's model.** Mouse protocols are mutually exclusive: `1000h` then `1003h` then `1003l` leaves reporting DISABLED, while the tracker re-enabled VT200. Emitting in numeric order also inverts `1003h` then `1000h`, where the real terminal keeps the last one set. `ESC c` and the `1047`/`1048`/`1049` aliases are unhandled. So the comment claiming this scanner "cannot desynchronise" was false, and an attaching pane could land on the wrong buffer or the wrong mouse protocol. Getting it right means tracking state at the EVICTION boundary and modelling protocol exclusivity, RIS and the buffer aliases — a real terminal state machine, and one that cannot be validated without running the app. Shipping a half-model would introduce exactly the class of bug this branch exists to remove. The diagnosis is correct and is kept in the audit doc: the wheel does nothing in an OpenCode terminal pane because the capped replay buffer evicts the TUI's one-shot mode preamble and nothing reconstructs it. That belongs next to issue #766, which proposes replacing the raw replay with a serialized screen and would remove the problem rather than sequencing around it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/main/sessionManager.ts | 26 +-- src/main/sessions/terminalModeTracker.test.ts | 192 ------------------ src/main/sessions/terminalModeTracker.ts | 145 ------------- 3 files changed, 1 insertion(+), 362 deletions(-) delete mode 100644 src/main/sessions/terminalModeTracker.test.ts delete mode 100644 src/main/sessions/terminalModeTracker.ts diff --git a/src/main/sessionManager.ts b/src/main/sessionManager.ts index 526ee19b..f400fec8 100644 --- a/src/main/sessionManager.ts +++ b/src/main/sessionManager.ts @@ -33,7 +33,6 @@ import type { SessionRecoverOptions, SessionRecoverResult, } from '@shared/types/session.js' -import { TerminalModeTracker } from '@main/sessions/terminalModeTracker.js' import { TmuxRegistry } from '@main/tmux/TmuxRegistry.js' import { MissingWorkspaceDirectoryError, @@ -650,11 +649,6 @@ export class SessionManager extends EventEmitter { // only after an attach, and let the renderer replay the buffer before // draining live bytes. private readonly agentPtyBuffers = new Map() - // Parallel to agentPtyBuffers, and the reason it exists: that buffer is - // capped and evicts the OLDEST bytes, which is where a TUI's one-shot - // alternate-screen and mouse-tracking preamble lives. See - // TerminalModeTracker for what that costs a renderer that attaches later. - private readonly agentPtyModes = new Map() private readonly agentPtyAttachCounts = new Map() private readonly agentPtyRestoreSizes = new Map() @@ -888,7 +882,6 @@ export class SessionManager extends EventEmitter { this.terminalAttached.delete(sessionId) } else { this.agentPtyBuffers.delete(sessionId) - this.agentPtyModes.delete(sessionId) this.agentPtyAttachCounts.delete(sessionId) this.agentPtyRestoreSizes.delete(sessionId) if (revokeAgentMcp) this.builtInMcpHost?.revokeSession(sessionId) @@ -2662,7 +2655,6 @@ export class SessionManager extends EventEmitter { this.sessionSizes.set(sessionId, initialSize) this.agentPtyBuffers.set(sessionId, new CappedTextBuffer(AGENT_PTY_BUFFER_CAP)) - this.agentPtyModes.set(sessionId, new TerminalModeTracker()) session.on('started', ({ projectDir }) => { if (!ownsEntry()) return this.markActivity(sessionId) @@ -2705,15 +2697,6 @@ export class SessionManager extends EventEmitter { this.agentPtyBuffers.set(sessionId, replay) } replay.append(data) - // Observed on the way past, NOT reconstructed from the buffer later: - // by the time a renderer attaches, the bytes that set these modes have - // usually been evicted, which is the entire point. - let modes = this.agentPtyModes.get(sessionId) - if (!modes) { - modes = new TerminalModeTracker() - this.agentPtyModes.set(sessionId, modes) - } - modes.observe(data) if ((this.agentPtyAttachCounts.get(sessionId) ?? 0) > 0) { this.emit('agent-pty-data', { sessionId, data }) } @@ -3294,14 +3277,7 @@ export class SessionManager extends EventEmitter { ) return null } - // The mode preamble goes FIRST, ahead of the replayed bytes. A fresh - // xterm starts on the normal buffer with no mouse tracking, and the bytes - // that would have told it otherwise were evicted long ago — so without - // this the attaching pane silently disagrees with the application about - // which screen it is on and whether the wheel is reportable. Re-setting a - // mode that is already set is a no-op, so prepending is unconditional. - const modePreamble = this.agentPtyModes.get(sessionId)?.preamble() ?? '' - const buffer = modePreamble + (this.agentPtyBuffers.get(sessionId)?.read() ?? '') + const buffer = this.agentPtyBuffers.get(sessionId)?.read() ?? '' const attachCount = this.agentPtyAttachCounts.get(sessionId) ?? 0 if (attachCount === 0) { const currentSize = this.sessionSizes.get(sessionId) diff --git a/src/main/sessions/terminalModeTracker.test.ts b/src/main/sessions/terminalModeTracker.test.ts deleted file mode 100644 index eba4a481..00000000 --- a/src/main/sessions/terminalModeTracker.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { TerminalModeTracker } from './terminalModeTracker' - -// The bug these pin, restated because it is invisible from the code alone: -// -// attachAgentPty replays the trailing bytes of a CAPPED buffer that evicts the -// oldest data. A TUI writes its alternate-screen and mouse-tracking preamble -// exactly once, at startup, so on any busy session those bytes are long gone -// by the time a renderer attaches. The fresh xterm then sits on the normal -// buffer with no mouse tracking while the application believes otherwise — -// which is why the mouse wheel did nothing at all in an OpenCode terminal -// pane, even though nothing was swallowing it. - -const ESC = '\x1b' - -describe('TerminalModeTracker', () => { - it('restores nothing for a stream that set nothing', () => { - const tracker = new TerminalModeTracker() - tracker.observe('plain output with no escapes\r\n') - expect(tracker.preamble()).toBe('') - }) - - it('captures a TUI startup preamble written as one sequence per mode', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1000h${ESC}[?1002h${ESC}[?1003h${ESC}[?1006h`) - expect(tracker.activeModes()).toEqual([1049, 1000, 1002, 1003, 1006]) - }) - - it('captures modes combined into one semicolon-separated sequence', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1000;1002;1006h`) - expect(tracker.activeModes()).toEqual([1000, 1002, 1006]) - }) - - it('puts the screen switch first regardless of the order it saw them', () => { - // 1049 has to lead, or everything the replay paints lands on the normal - // buffer and is abandoned the moment the switch happens. - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1006h${ESC}[?1049h`) - expect(tracker.preamble()).toBe(`${ESC}[?1049h${ESC}[?1006h`) - }) - - it('forgets a mode the application turned back off', () => { - // A TUI that suspends for $EDITOR leaves the alternate screen. Replaying a - // stale 1049h would put the pane on a buffer the application is not using. - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) - tracker.observe(`${ESC}[?1049l`) - expect(tracker.activeModes()).toEqual([1003]) - }) - - it('handles a reset that names several modes at once', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1000h${ESC}[?1002h${ESC}[?1003h${ESC}[?1006h`) - tracker.observe(`${ESC}[?1003l${ESC}[?1002l${ESC}[?1000l${ESC}[?1006l`) - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('ignores private modes it is not responsible for', () => { - // Cursor visibility, bracketed paste and focus reporting are re-asserted - // by the next repaint, so restoring them would be noise at best. - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?25l${ESC}[?2004h${ESC}[?1004h${ESC}[?1049h`) - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('is not fooled by the digits appearing in ordinary output', () => { - const tracker = new TerminalModeTracker() - tracker.observe('the value is 1049h and the mode is [?1049h-ish\r\n') - expect(tracker.preamble()).toBe('') - }) - - it('survives being fed the same preamble twice', () => { - // Reconnects and provider restarts re-emit it; the set must not grow or - // reorder. - const tracker = new TerminalModeTracker() - const preamble = `${ESC}[?1049h${ESC}[?1003h${ESC}[?1006h` - tracker.observe(preamble) - tracker.observe(preamble) - expect(tracker.preamble()).toBe(preamble) - }) - - it('keeps state across many chunks, which is how a real stream arrives', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h`) - for (let i = 0; i < 500; i += 1) tracker.observe(`frame ${i}\r\n`) - tracker.observe(`${ESC}[?1003h`) - for (let i = 0; i < 500; i += 1) tracker.observe(`frame ${i}\r\n`) - // This is exactly the case the capped replay buffer loses: the first - // sequence is thousands of bytes back and would have been evicted. - expect(tracker.preamble()).toBe(`${ESC}[?1049h${ESC}[?1003h`) - }) -}) - -describe('sequences split across chunk boundaries', () => { - it('applies a turn-ON split between two chunks', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?10`) - tracker.observe('49h') - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('applies a turn-OFF split between two chunks', () => { - // The asymmetry that makes carrying state mandatory: missing a turn-ON - // leaves today's behaviour, but missing a turn-OFF is WORSE than today — - // the mode survives here and the next attach asserts a mode the - // application has already left, putting a pane back on the alternate - // screen after the TUI suspended for an editor. - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) - tracker.observe(`${ESC}[?104`) - tracker.observe('9l') - expect(tracker.activeModes()).toEqual([1003]) - }) - - it('handles a split at the escape byte itself', () => { - // A chunk can end at ANY byte. Requiring the whole `ESC [ ?` marker before - // carrying anything meant a reset split here was still missed, which is - // the direction that leaves a stale mode asserted at the next attach. - const tracker = new TerminalModeTracker() - tracker.observe(`output${ESC}`) - tracker.observe('[?1006h') - expect(tracker.activeModes()).toEqual([1006]) - }) - - it('handles a split after the control sequence introducer', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`output${ESC}[`) - tracker.observe('?1049h') - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('applies a RESET split at the escape byte, the worse direction', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) - tracker.observe(`frame${ESC}`) - tracker.observe('[?1049l') - expect(tracker.activeModes()).toEqual([1003]) - }) - - it('applies a RESET split after the control sequence introducer', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h${ESC}[?1003h`) - tracker.observe(`frame${ESC}[`) - tracker.observe('?1003l') - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('does not carry an escape that begins some other sequence', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049h`) - tracker.observe(`${ESC}`) - tracker.observe('[2J clear screen') - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('applies a multi-mode sequence split mid-parameter-list', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1000;10`) - tracker.observe('02;1006h') - expect(tracker.activeModes()).toEqual([1000, 1002, 1006]) - }) - - it('never applies a carried sequence twice', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049`) - tracker.observe('h') - tracker.observe('ordinary output') - expect(tracker.activeModes()).toEqual([1049]) - }) - - it('drops a carried fragment that turns out not to be a sequence', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?1049`) - // A final byte that is not h or l ends the sequence as something else. - tracker.observe('r rest of the line') - expect(tracker.activeModes()).toEqual([]) - // And the tracker is not left holding anything. - tracker.observe(`${ESC}[?1003h`) - expect(tracker.activeModes()).toEqual([1003]) - }) - - it('does not accumulate an unbounded fragment from a stream of digits', () => { - const tracker = new TerminalModeTracker() - tracker.observe(`${ESC}[?` + '1'.repeat(200)) - // Far past any real sequence, so it is dropped rather than carried, and a - // following final byte must not resurrect it. - tracker.observe('h') - expect(tracker.activeModes()).toEqual([]) - }) -}) diff --git a/src/main/sessions/terminalModeTracker.ts b/src/main/sessions/terminalModeTracker.ts deleted file mode 100644 index 2d542952..00000000 --- a/src/main/sessions/terminalModeTracker.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Tracks the DEC private modes a provider TUI has turned on, so an attaching - * renderer can be put back into them. - * - * WHY this is needed at all - the bug it fixes: - * - * `attachAgentPty` hands a freshly-constructed xterm the trailing bytes of a - * capped replay buffer, and `CappedTextBuffer` evicts the OLDEST bytes. A TUI - * writes its mode preamble EXACTLY ONCE, at startup: - * - * ESC [ ? 1049 h alternate screen - * ESC [ ? 1000 h mouse: button events - * ESC [ ? 1002 h mouse: button + drag - * ESC [ ? 1003 h mouse: any event, including the WHEEL - * ESC [ ? 1006 h mouse reports in SGR encoding - * - * A TUI repainting at 60fps blows through the 512 KiB cap quickly, so on any - * session with real activity that preamble has already been evicted. The new - * terminal then never enters the alternate screen and never enables mouse - * tracking, and nothing anywhere reconstructs the modes. - * - * The user-visible result for OpenCode, whose OpenTUI enables mouse capture by - * default: the mouse WHEEL does nothing at all in a raw terminal pane. Not - * because anything swallows it - nothing does - but because xterm only - * attaches its wheel-to-mouse-report listener when the application has asked - * for wheel events, and its fallback path returns early on a normal buffer. - * 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 - * the same on either buffer, which is why this was hard to see. - * - * Claude Code and Codex are unaffected: they render inline and push real - * scrollback, so their wheel scrolling needs no mode at all. - * - * WHY a hand-rolled scanner rather than a second terminal emulator: main - * already runs a headless terminal for screen snapshots, but the replay buffer - * is a BYTE stream and the question here is only "which of five one-shot modes - * are currently on". A regex over each appended chunk answers exactly that in - * microseconds, and cannot desynchronise the way an emulator fed a truncated - * stream could. - * - * WHY only these modes: they are the ones whose loss is silent and - * unrecoverable from a truncated stream. Colours, cursor shape and window - * title are re-asserted by the TUI's next repaint. Screen buffer and mouse - * tracking are not, because the application sets them once and never again. - */ - -/** DEC private modes worth restoring, in the order a TUI sets them. */ -const TRACKED_MODES = [1049, 1000, 1002, 1003, 1006] as const - -type TrackedMode = (typeof TRACKED_MODES)[number] - -/** - * One or more semicolon-separated DEC private parameters followed by the - * set/reset final byte, e.g. ESC [ ? 1000 ; 1002 ; 1006 h. - */ -const DEC_PRIVATE_MODE = /\x1b\[\?([0-9;]+)([hl])/g - -/** - * A sequence that has begun but not yet reached its final byte. - * - * Deliberately matches from the ESCAPE byte onward, not from the full - * `ESC [ ?` marker: a chunk can end at ANY byte, including after just `ESC` or - * `ESC [`. Requiring the whole marker before carrying anything meant a reset - * split at either of those two points was still missed, which is the direction - * that leaves a stale mode asserted at the next attach. - */ -const PARTIAL_SEQUENCE = /\x1b(?:\[(?:\?[0-9;]*)?)?$/ -/** A sequence whose final byte has arrived. */ -const COMPLETE_SEQUENCE = /^\x1b\[\?[0-9;]*[hl]/ - -/** - * Longest partial sequence worth carrying to the next chunk. - * - * Comfortably past the realistic maximum — all five tracked modes in one - * sequence is 28 characters — while bounding what a stream of digits could - * otherwise accumulate. Anything longer is not a mode sequence being - * assembled, so it is dropped rather than held. - */ -const MAX_PENDING = 64 - -export class TerminalModeTracker { - private readonly active = new Set() - /** - * A sequence that began at the end of the previous chunk. - * - * WHY carrying this matters, and why "a split sequence is just missed" was - * NOT an acceptable answer: the two directions are not symmetric. Missing a - * turn-ON leaves today's behaviour, which is what the previous version - * claimed. Missing a turn-OFF is strictly WORSE than today — the mode stays - * in this set, and the next attach asserts a mode the application has - * already left. A pane would be put back on the alternate screen after the - * TUI suspended for an editor, or told to report mouse events to a program - * that stopped listening. PTY chunks are split by pipe boundaries, not by - * escape sequences, so this is ordinary rather than exotic. - */ - private pending = '' - - /** Feed a raw PTY chunk. Cheap enough for the hot path. */ - observe(chunk: string): void { - // Fast reject on the ESCAPE byte, not on the full marker, and only when - // nothing is half-parsed: a chunk with no marker of its own can still be - // the tail of a sequence begun earlier, and a chunk that ends mid-marker - // has to be carried even though it contains no complete one. - if (!this.pending && !chunk.includes('\x1b')) return - const scan = this.pending + chunk - this.pending = '' - for (const match of scan.matchAll(DEC_PRIVATE_MODE)) { - const set = match[2] === 'h' - for (const raw of match[1].split(';')) { - const mode = Number(raw) as TrackedMode - if (!TRACKED_MODES.includes(mode)) continue - if (set) this.active.add(mode) - else this.active.delete(mode) - } - } - // Carry only a genuinely unfinished sequence. `pending` can never hold a - // COMPLETE one, so the match above cannot be applied twice. - const partial = PARTIAL_SEQUENCE.exec(scan) - if (partial && !COMPLETE_SEQUENCE.test(partial[0]) && partial[0].length <= MAX_PENDING) { - this.pending = partial[0] - } - } - - /** - * The preamble to write BEFORE a replay buffer, so a fresh terminal starts - * in the modes the application believes it is already in. - * - * Emitted in TRACKED_MODES order rather than observation order: 1049 has to - * come first, or everything the replay paints lands on the normal buffer and - * is then abandoned when the switch happens. Re-setting a mode a terminal is - * already in is a no-op, so this is safe to prepend unconditionally. - */ - preamble(): string { - return TRACKED_MODES - .filter(mode => this.active.has(mode)) - .map(mode => `\x1b[?${mode}h`) - .join('') - } - - /** Test and diagnostic view. Never for control flow. */ - activeModes(): TrackedMode[] { - return TRACKED_MODES.filter(mode => this.active.has(mode)) - } -} From 8aa28606b37793f410469414d9d3c0e6bb2aa656 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:14:37 -0700 Subject: [PATCH 27/32] revert(prompt-templates): narrow the key grammar back; a slash is not intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The widened pattern was meant to diagnose typos like `{{key:Brave}}` and `{{key:A/B/C}}` instead of pasting them verbatim. Requiring a separator was supposed to be the boundary that kept ordinary text out. The re-review showed it is not: `` and `{{key: /abc/}}` are everyday JSX with a slash in them, and both began aborting template insertion on content 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. Catching typos properly needs an escape mechanism this grammar does not have, so it is not attempted, and the comment now says that rather than claiming a boundary that does not hold. The cancelled-authentication fix is KEPT and was verified clean by the review: three references now produce one OS prompt after a rejection instead of three, processing stops at the first failure, and previously accumulated failures still appear in the error. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../prompt-templates/keyReferences.test.ts | 62 ++++------ .../prompt-templates/keyReferences.ts | 115 ++++++------------ 2 files changed, 63 insertions(+), 114 deletions(-) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.test.ts b/src/renderer/src/features/prompt-templates/keyReferences.test.ts index ad45c231..34460db3 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.test.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.test.ts @@ -53,45 +53,40 @@ describe('resolveKeyReferences', () => { }) }) -describe('malformed and failing references', () => { - it('leaves a separator-less occurrence alone, because it is not addressed to the vault', async () => { - // Reporting `{{key:Brave}}` as a malformed reference required matching - // ANY `{{key:…}}`, and that over-captured ordinary text: JSX like - // `` began aborting insertion outright, - // with no way to escape it. A separator is what makes an occurrence look - // deliberately like a vault reference, so it is the boundary. A - // separator-less typo passes through as it always did. - await expect(resolveKeyReferences('use {{key:Brave}} now', async () => 'secret')) - .resolves.toBe('use {{key:Brave}} now') - }) - - it('does not touch ordinary JSX that happens to contain {{key:', async () => { - const body = ' and {{key: other}}' - await expect(resolveKeyReferences(body, async () => 'secret')).resolves.toBe(body) +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}}', + ' {{key:P/K}}', async () => 'secret', - )).resolves.toBe(' secret') - }) - - it('aborts on a reference with two separators rather than guessing', async () => { - // With two separators there is no evidence for which one divides provider - // from key, and picking one would resolve a reference the author did not - // write. - await expect(resolveKeyReferences('{{key:A/B/C}}', async () => 'secret')) - .rejects.toThrow('{{key:A/B/C}}') + )).resolves.toBe(' secret') }) - it('aborts on an empty half', async () => { - await expect(resolveKeyReferences('{{key:/Key}}', async () => 'secret')) - .rejects.toThrow('{{key:/Key}}') - await expect(resolveKeyReferences('{{key:Provider/}}', async () => 'secret')) - .rejects.toThrow('{{key:Provider/}}') + 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 @@ -131,11 +126,4 @@ describe('malformed and failing references', () => { await expect(resolveKeyReferences('{{key:P/K}}', async () => 'sk-$&-$1')) .resolves.toBe('sk-$&-$1') }) - - it('leaves an ordinary variable placeholder alone', async () => { - // The two grammars must not 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') - }) }) diff --git a/src/renderer/src/features/prompt-templates/keyReferences.ts b/src/renderer/src/features/prompt-templates/keyReferences.ts index 2678d7a5..99937226 100644 --- a/src/renderer/src/features/prompt-templates/keyReferences.ts +++ b/src/renderer/src/features/prompt-templates/keyReferences.ts @@ -32,84 +32,56 @@ export function prepareTemplateText( export type KeyReference = { providerName: string; keyName: string } /** - * Every `{{key:…/…}}` occurrence, well-formed or not. + * A well-formed vault reference: exactly one separator, neither half + * containing another. * - * WHY it matches a malformed SPEC rather than refusing to match: the original - * pattern excluded `/` from both halves, so `{{key:A/B/C}}` matched nothing at - * all — invisible to collection, invisible to validation, and untouched by the - * final `body.replace`. A typo'd reference was pasted into the prompt VERBATIM, - * which is the silent-failure mode the header paragraph says this grammar - * exists to avoid. + * WHY this pattern is NARROW, after an attempt to widen it was reverted: * - * WHY the separator is still REQUIRED, which is the part that took a second - * pass to get right: a first attempt accepted any `{{key:…}}` at all so that - * `{{key:Provider}}` could be reported as a missing separator. That - * over-captured ordinary text. `` is - * everyday JSX, and it began aborting template insertion outright — a template - * that had always worked now failed, with no way to escape it, not even inside - * a code fence. Pasted logs and JSON carrying `{{key: …}}` regressed the same - * way. + * 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. * - * A `/` is the thing that makes an occurrence look deliberately like a vault - * reference rather than an object literal, so it is the boundary. The cost is - * that a separator-less typo goes back to passing through untouched, exactly - * as it did before this file existed. That is strictly better than breaking - * text the user did not intend as syntax. - * - * The `key:` prefix and the `[^{}]` body also keep this from colliding with - * the ordinary `{{variable}}` grammar, whose placeholder pattern is - * `[A-Za-z0-9_]+` and cannot contain a colon. + * 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 - -type ParsedReference = - | { ok: true; ref: KeyReference } - | { ok: false; spec: string } - -/** - * Exactly one separator, and both halves non-empty after trimming. - * - * A name containing `/` is therefore unaddressable. That is deliberate and is - * the reason to reject rather than to guess: with two separators there is no - * evidence for which one divides provider from key, and picking one would - * resolve a reference the author did not write. - */ -function parseReference(spec: string): ParsedReference { - const parts = spec.split('/') - // A spec reaches here only with at least one separator (the pattern requires - // it), so this rejects two-or-more, never zero. - if (parts.length !== 2) return { ok: false, spec } - const providerName = parts[0].trim() - const keyName = parts[1].trim() - if (!providerName || !keyName) return { ok: false, spec } - return { ok: true, ref: { providerName, keyName } } -} +const KEY_REF_PATTERN = /\{\{\s*key:([^/{}]+?)\/([^/{}]+?)\s*\}\}/g function referenceKey(ref: KeyReference): string { - // NUL separator so a provider named "a" with key "b/c" cannot collide with - // provider "a/b" key "c" — unreachable through parseReference today, but the - // map is also written from the replace callback. + // 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 parsed of parseAll(body)) { - if (!parsed.ok) continue - const dedupeKey = referenceKey(parsed.ref) + for (const ref of parseAll(body)) { + const dedupeKey = referenceKey(ref) if (seen.has(dedupeKey)) continue seen.add(dedupeKey) - ordered.push(parsed.ref) + ordered.push(ref) } return ordered } -function parseAll(body: string): ParsedReference[] { - return [...body.matchAll(KEY_REF_PATTERN)].map(match => parseReference(match[1])) -} - export async function resolveKeyReferences( body: string, resolve: (ref: KeyReference) => Promise, @@ -122,13 +94,8 @@ export async function resolveKeyReferences( const failures: string[] = [] const seen = new Set() - for (const parsed of parseAll(body)) { - if (!parsed.ok) { - const label = `{{key:${parsed.spec}}}` - if (!failures.includes(label)) failures.push(label) - continue - } - const dedupeKey = referenceKey(parsed.ref) + for (const ref of parseAll(body)) { + const dedupeKey = referenceKey(ref) if (seen.has(dedupeKey)) continue seen.add(dedupeKey) @@ -152,14 +119,14 @@ export async function resolveKeyReferences( // direct display and distinguishes "no such key" from "vault is locked". let value: string | null try { - value = await resolve(parsed.ref) + value = await resolve(ref) } catch (error) { const detail = error instanceof Error && error.message.length > 0 ? error.message : null - failures.push(`{{key:${parsed.ref.providerName}/${parsed.ref.keyName}}}${detail ? ` (${detail})` : ''}`) + failures.push(`{{key:${ref.providerName}/${ref.keyName}}}${detail ? ` (${detail})` : ''}`) break } if (value === null || value.length === 0) { - failures.push(`{{key:${parsed.ref.providerName}/${parsed.ref.keyName}}}`) + failures.push(`{{key:${ref.providerName}/${ref.keyName}}}`) continue } values.set(dedupeKey, value) @@ -170,12 +137,6 @@ export async function resolveKeyReferences( } // 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, spec: string) => { - const parsed = parseReference(spec) - // Unreachable — a malformed spec threw above — but returning the original - // text is the safe answer if that ever stops being true, since it cannot - // insert a wrong secret. - if (!parsed.ok) return match - return values.get(referenceKey(parsed.ref)) ?? '' - }) + return body.replace(KEY_REF_PATTERN, (_match, rawProvider: string, rawKey: string) => + values.get(referenceKey({ providerName: rawProvider.trim(), keyName: rawKey.trim() })) ?? '') } From 4e367348a0154d29c06680577ab037f55e21d756 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:14:38 -0700 Subject: [PATCH 28/32] revert(workspace): drop the OpenCode jump chord injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverted on the reviewer's objection rather than over it. The diagnosis stands and the encoding was confirmed correct by two independent passes: OpenCode runs OpenTUI on the alternate screen and owns its transcript, so `scrollToBottom()` can never move it, and ESC + 0x07 is Ctrl+Alt+G, which OpenCode binds to `messages_last`. 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, not a one-line swap, and it is the right place for this to land. The command's description no longer claims a behaviour it cannot deliver, and the limitation is recorded where the jump is implemented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- src/providers/providerFeatures.test.ts | 15 ---- src/providers/shared/featureCapabilities.ts | 78 ------------------- .../workspace/commands/paneCommands.ts | 2 +- .../workspace/tile-tree/AgentTerminalLeaf.tsx | 4 - .../agentTerminalFollow.system.test.ts | 30 +------ .../tile-tree/agentTerminalFollow.ts | 42 +++------- 6 files changed, 15 insertions(+), 156 deletions(-) diff --git a/src/providers/providerFeatures.test.ts b/src/providers/providerFeatures.test.ts index bd180598..db956cb9 100644 --- a/src/providers/providerFeatures.test.ts +++ b/src/providers/providerFeatures.test.ts @@ -36,9 +36,6 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['codex', 'opencode'], verifiedExternalResumeCommand: true, - // Inline on the normal screen buffer, so real xterm scrollback exists - // and Jump to Latest moves the viewport. - terminalJumpToLatestKey: null, }, codex: { savedSessionListing: true, @@ -48,7 +45,6 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['claude', 'opencode'], verifiedExternalResumeCommand: true, - terminalJumpToLatestKey: null, }, opencode: { savedSessionListing: false, @@ -62,14 +58,6 @@ describe('provider feature matrix', () => { inAppResume: true, switchTargets: ['claude', 'codex'], verifiedExternalResumeCommand: true, - // The one row that is NOT null, and the reason this capability exists. - // OpenCode runs OpenTUI on the ALTERNATE SCREEN and owns its - // transcript internally, so nothing is ever evicted into xterm - // scrollback and scrollToBottom is a guaranteed no-op — which is why - // Jump to Latest silently did nothing on OpenCode Terminal panes. - // ESC + 0x07 is Ctrl+Alt+G in the legacy encoding, which OpenCode - // binds to `messages_last`. - terminalJumpToLatestKey: '\u001b\u0007', }, }) }) @@ -84,9 +72,6 @@ describe('provider feature matrix', () => { inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, - // A plain shell has no TUI transcript of its own, so the xterm viewport - // is the only thing there is to scroll. - terminalJumpToLatestKey: null, }) }) diff --git a/src/providers/shared/featureCapabilities.ts b/src/providers/shared/featureCapabilities.ts index afc8ffdb..5cad5233 100644 --- a/src/providers/shared/featureCapabilities.ts +++ b/src/providers/shared/featureCapabilities.ts @@ -72,37 +72,6 @@ export type ProviderFeatureCapabilities = { * they will paste it into a terminal and blame their setup. */ verifiedExternalResumeCommand: boolean - /** - * On this provider's RAW TERMINAL surface, the bytes to send so the TUI - * scrolls its own transcript to the latest message — or null when the - * xterm viewport is the thing that scrolls and `scrollToBottom()` is - * correct. - * - * WHY a capability rather than a provider check at the call site: "Jump to - * Latest" is one command with two completely different mechanisms, and which - * one applies is a fact about the provider's TUI, not about the pane. - * - * Claude Code and Codex render their main view INLINE on the normal screen - * 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). Scrolling the xterm - * viewport is exactly right for them. - * - * OpenCode does not. It runs OpenTUI, whose `screenMode` defaults to - * `alternate-screen`, and it renders the transcript into an internal - * `` with its own paging keybinds. Nothing is ever evicted - * upward, so `viewportY === baseY` always holds and `term.scrollToBottom()` is a - * guaranteed no-op — which is why Jump to Latest silently did nothing on - * OpenCode Terminal panes while working everywhere else. The only mechanism - * that can move that transcript is the TUI's own key. - * - * The plan doc for the follow work already recorded the constraint — - * "Alternate-screen TUIs often own their history internally. These commands - * control the xterm viewport, not provider-specific keybindings or internal - * transcript navigation" — but nothing acted on it, so the command shipped - * claiming a behaviour it could not deliver. - */ - terminalJumpToLatestKey: string | null } /** @@ -119,7 +88,6 @@ export const NO_PROVIDER_FEATURES: ProviderFeatureCapabilities = { inAppResume: false, switchTargets: [], verifiedExternalResumeCommand: false, - terminalJumpToLatestKey: null, } /** @@ -149,9 +117,6 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['codex', 'opencode'], verifiedExternalResumeCommand: true, - // Inline on the normal buffer, so real xterm scrollback exists and the - // viewport is what needs moving. - terminalJumpToLatestKey: null, }, // Mirrors Claude, with explicit edges to both other adapters. codex: { @@ -162,10 +127,6 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['claude', 'opencode'], verifiedExternalResumeCommand: true, - // Same as Claude: `insert_history_lines` writes to real scrollback, and - // `enter_alt_screen` is reached only from backtrack/resume/migration - // overlays, never the chat view. - terminalJumpToLatestKey: null, }, // OpenCode still lacks a cwd-indexed saved-session picker, but its supported // CLI export/import boundary now backs prompt extraction, rewind, duplicate, @@ -183,45 +144,6 @@ const FEATURES_BY_KIND: Record = inAppResume: true, switchTargets: ['claude', 'codex'], verifiedExternalResumeCommand: true, - // ESC + 0x07 is Ctrl+Alt+G in the legacy encoding every terminal speaks: - // Alt is the ESC prefix and Ctrl+G is BEL. OpenCode binds that chord to - // `messages_last` ("Navigate to last message"), which is precisely this - // command's meaning inside its own scrollbox. - // - // WHY Ctrl+Alt+G and not the bare `End` OpenCode also accepts: `End` is - // ALSO bound to `input_buffer_end`, so it would most likely move the - // prompt caret instead of the transcript. Ctrl+Alt+G is unambiguous, and - // it is a member of OpenCode's whole Ctrl+Alt message-scroll family. - // - // WHY legacy bytes even though OpenCode requests the kitty keyboard - // protocol: xterm 6.0.0 has no kitty support, so it never answers the - // query and the TUI stays on legacy parsing. If that ever changes this - // string is the one place to revisit. - // - // KNOWN RESIDUAL RISK, stated because it is real and was raised in - // review: OpenCode keybinds are user-configurable through tui.json, so a - // user who has rebound this chord gets whatever they bound it to. Under - // STOCK config that cannot reach anything destructive — the only - // destructive action nearby is `messages_undo`, which aborts the session - // and reverts history, and it is bound to `u`, i.e. Ctrl+X then - // `u`. No byte sequence sent from here can produce that, because it - // requires 0x18 first. The exposure is narrow and deliberate: a user who - // moves a destructive action onto Ctrl+Alt+G. - // - // Reading their effective binding to be certain is NOT cheap and would be - // unreliable — it means reimplementing OpenCode's loader: JSONC, global - // plus per-project plus every .opencode directory up to home, variable - // substitution, a legacy-config migration, a win32 special case, and - // plugin-registered binds. That reimplementation would drift. - // - // The rebinding-immune path exists and is the right long-term answer: - // OpenCode's server exposes POST /tui/execute-command, whose alias table - // maps `messages_last` to the stable command `session.last` and dispatches - // it below the keybind layer. It needs a known server URL, which means - // spawning `opencode serve` and using `opencode attach` instead of running - // the TUI directly — a topology change, not a one-line swap. Take that - // route when the OpenCode runtime moves to a served transport. - terminalJumpToLatestKey: '\u001b\u0007', }, } diff --git a/src/renderer/src/features/workspace/commands/paneCommands.ts b/src/renderer/src/features/workspace/commands/paneCommands.ts index 812525e8..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 xterm viewport for providers that render inline (Claude, Codex); for a TUI that owns its own transcript (OpenCode Terminal) it sends that TUI\'s own jump-to-latest key instead.', + 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/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 623bc23c..75cfc7e7 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -23,7 +23,6 @@ import { AgentTitleHeader } from '@renderer/workspace/tile-tree/AgentTitleHeader import { createTerminalInputForwarder } from '@renderer/workspace/tile-tree/terminalInputForwarder' import { encodeTerminalPaste, registerTerminalPasteTarget } from '@renderer/workspace/terminal/textPasteTarget' import { AgentTerminalActions } from '@renderer/workspace/tile-tree/AgentTerminalActions' -import { getProviderFeatures } from '@providers/shared/featureCapabilities' import { useAgentTerminalFollow } from '@renderer/workspace/tile-tree/agentTerminalFollow' type Props = { @@ -103,9 +102,6 @@ export function AgentTerminalLeaf({ scrollToLatestRequest: runtime.scrollToLatestRequest, tailActive, termRef, - // Null for every provider that renders inline on the normal buffer, which - // is the ordinary case and keeps viewport scrolling. - jumpKey: getProviderFeatures(provider).terminalJumpToLatestKey, }) const dimensionActiveRef = useRef(false) const dimensionOwnershipEpochRef = useRef(0) 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 dd4ce596..59d53dbe 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts @@ -34,19 +34,14 @@ it('follows and restores real xterm content across trimming and buffer switches' // The PTY sink. A provider whose TUI owns its own transcript is jumped // by sending it a key, not by moving the xterm viewport, so this trial // has to be able to observe that write. - const sent = [] - window.api = { sendInput: (id, data) => { sent.push({ id, data }) } } let follow function Harness(props) { follow = useAgentTerminalFollow({ ...props, termRef }); return null } const reactRoot = createRoot(document.getElementById('react')) let tailActive = false let scrollToLatestRequest = 0 - // null = "the xterm viewport is what scrolls", which is Claude and - // Codex and therefore the default this trial runs under. - let jumpKey = null function render() { flushSync(() => reactRoot.render(createElement(Harness, { - sessionId: 'trial', tailActive, scrollToLatestRequest, jumpKey, + sessionId: 'trial', tailActive, scrollToLatestRequest, }))) } const check = (condition, message) => { if (!condition) throw new Error(message) } @@ -80,7 +75,6 @@ it('follows and restores real xterm content across trimming and buffer switches' scrollToLatestRequest++; render() await until(bottom, 'Jump request did not reach real xterm bottom') - check(sent.length === 0, 'Viewport-scrolling provider must not write to the PTY') term.scrollToLine(10) tailActive = true; render() @@ -94,30 +88,12 @@ it('follows and restores real xterm content across trimming and buffer switches' tailActive = false; render() check(term.buffer.active.type === 'alternate' && bottom(), 'Alternate buffer was scrolled with a normal-buffer anchor') - // The case the assertion above CANNOT see, and the reason Jump to - // Latest silently did nothing on OpenCode Terminal panes: on the - // alternate screen there is no scrollback at all, so viewportY and - // baseY are both 0 and the bottom check is trivially 0 === 0. A - // provider whose TUI owns its transcript must therefore be jumped by - // sending it the key it binds to "go to last message" instead. - const evictedBefore = term.buffer.active.viewportY - jumpKey = '\\u001b\\u0007' - scrollToLatestRequest++; render() - await until(() => sent.length === 1, 'Provider-owned jump did not reach the PTY') - check(sent[0].id === 'trial', 'Provider-owned jump addressed the wrong session: ' + sent[0].id) - // Compared through char codes so the assertion cannot pass on a - // differently-escaped string that merely looks the same in source. - const codes = Array.from(sent[0].data).map(c => c.charCodeAt(0)).join(',') - check(codes === '27,7', 'Wrong bytes sent for provider-owned jump: ' + codes) - check(term.buffer.active.viewportY === evictedBefore, 'Provider-owned jump must not also move the viewport') - jumpKey = null - await write('\\x1b[?1049l') // Marker registration/disposal is public; only this diagnostic // enumeration requires proposed APIs. Keep them off for all behavior. term.options.allowProposedApi = true check(term.markers.length === 0, 'Follow leaked a saved marker after disengage') - return { repin: true, trim: true, eviction: true, jump: true, alternate: true, providerJump: true } + return { repin: true, trim: true, eviction: true, jump: true, alternate: true } } finally { off(); reactRoot.unmount(); termRef.current = null; term.dispose() } @@ -160,7 +136,7 @@ it('follows and restores real xterm content across trimming and buffer switches' const result = stdout.split('\n').find(line => line.startsWith('FOLLOW_TRIAL=')) expect(result, stdout).toBeDefined() expect(JSON.parse(result!.slice('FOLLOW_TRIAL='.length))).toEqual({ - repin: true, trim: true, eviction: true, jump: true, alternate: true, providerJump: true, + repin: true, trim: true, eviction: true, jump: true, alternate: true, }) } finally { await rm(directory, { recursive: true, force: true }) diff --git a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts index 268a07c9..aed549cb 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.ts @@ -12,12 +12,6 @@ type FollowArgs = { scrollToLatestRequest: number tailActive: boolean termRef: RefObject - /** - * Bytes that make THIS provider's TUI scroll its own transcript to the - * latest message, or null when the xterm viewport is the thing that scrolls. - * See ProviderFeatureCapabilities.terminalJumpToLatestKey. - */ - jumpKey: string | null } function isAtBottom(term: Terminal): boolean { @@ -25,12 +19,8 @@ function isAtBottom(term: Terminal): boolean { } export function useAgentTerminalFollow({ - sessionId, scrollToLatestRequest, tailActive, termRef, jumpKey, + sessionId, scrollToLatestRequest, tailActive, termRef, }: FollowArgs) { - // Read at request time rather than closed over, so a provider switch that - // keeps the same pane cannot send the previous provider's chord. - const jumpKeyRef = useRef(jumpKey) - jumpKeyRef.current = jumpKey // The mount-owned PTY subscriber reads the latest verdict at callback time, // not when a chunk was queued: parsing can finish after Tail was disabled. const tailActiveRef = useRef(tailActive) @@ -62,26 +52,16 @@ export function useAgentTerminalFollow({ } if (scrollToLatestRequest === jumpBaselineRef.current) return jumpBaselineRef.current = scrollToLatestRequest - const key = jumpKeyRef.current - if (key) { - // WHY this writes to the PTY instead of moving the viewport: - // - // 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 `scrollToBottom()` is a - // guaranteed no-op. That is why Jump to Latest silently did nothing on - // OpenCode Terminal panes while working on Claude and Codex raw views, - // which render inline on the normal buffer. The only thing that can move - // that transcript is the TUI's own key. - // - // Fire-and-forget, and deliberately not awaited or reported: this runs - // inside a passive effect driven by a counter, the write is one keypress - // the user could have typed themselves, and a rejected send means the - // pane is gone — in which case there is nothing to scroll and nothing to - // say about it. - void window.api.sendInput(sessionId, key) - return - } + // 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]) From 4b88c710c30ecc8bdb1c52e3066c50b49c9d7f0b Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:14:38 -0700 Subject: [PATCH 29/32] fix(provider-switch): align the last two guards and delete a stale comment Remaining non-blocking items from the re-review. The footer Switch button and the model-switch button still keyed on `busy`, so during a /model fan-out they looked available while their handlers refused the click. Both now use the same lock the handlers do. The size-estimate comment still described a frozen empty runtimes map that the code no longer swaps in. Replaced with what actually makes the dependency choice load-bearing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../workspace/ui/BulkProviderSwitchModal.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx index c87cf3b5..47d68d58 100644 --- a/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx +++ b/src/renderer/src/features/workspace/ui/BulkProviderSwitchModal.tsx @@ -365,11 +365,10 @@ 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. + // 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 @@ -813,7 +812,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/TileLeaf/PaneHeader.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx index 0d47b356..82bd8a62 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf/PaneHeader.tsx @@ -124,7 +124,10 @@ export function PaneHeader({ {/* truncate-START: every pane shares the leading path segments, so clipping the end hid the one part that identifies this agent. */} - {shortenCwd(projectDir)} + {/* 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)}
From 045e46cca28cb079ea0124519bff55002d752065 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:25:22 -0700 Subject: [PATCH 31/32] docs: drop two stale references left by the reverts Caught in the final verification pass. A system-test comment still described observing a PTY write that no longer happens, and the audit's review section still pointed at a capability constant that was deleted with the jump injection. Replaced the latter with what actually happened: three changes were withdrawn rather than defended, each written up with the evidence that killed it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../2026-09-08-post-merge-regression-audit.md | 11 +++++------ .../tile-tree/agentTerminalFollow.system.test.ts | 3 --- 2 files changed, 5 insertions(+), 9 deletions(-) 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 index 2c0fee19..2500cded 100644 --- a/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md +++ b/docs/superpowers/research/2026-09-08-post-merge-regression-audit.md @@ -679,9 +679,8 @@ 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. -The one objection NOT resolved by a code change is the rebinding hazard on the -injected chord, and the reasoning is recorded next to the constant in -`featureCapabilities.ts`: under stock config no byte we send can reach a -destructive action, reading the user's effective binding would mean -reimplementing OpenCode's config loader, and the rebinding-immune route needs a -served transport this runtime does not use yet. +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/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts index 59d53dbe..bdd1c8a7 100644 --- a/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts +++ b/src/renderer/src/workspace/tile-tree/agentTerminalFollow.system.test.ts @@ -31,9 +31,6 @@ it('follows and restores real xterm content across trimming and buffer switches' const term = new Terminal({ cols: 80, rows: 10, scrollback: 2000 }) term.open(document.getElementById('terminal')) const termRef = { current: term } - // The PTY sink. A provider whose TUI owns its own transcript is jumped - // by sending it a key, not by moving the xterm viewport, so this trial - // has to be able to observe that write. let follow function Harness(props) { follow = useAgentTerminalFollow({ ...props, termRef }); return null } const reactRoot = createRoot(document.getElementById('react')) From 254046cfc87a04a7cec311bd718b0722667760d3 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Tue, 8 Sep 2026 15:41:08 -0700 Subject: [PATCH 32/32] fix(tests): stub the directory guard in the other suite that builds a real manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what my local runs dismissed. The Codex live-continuity suite constructs a real SessionManager and recovers against the synthetic cwd '/fixture/project-1', which does not exist on disk — so the new spawn-path guard refused the recover, the session never started, and the JSONL identity the test waits for never arrived. It failed as a waitFor TIMEOUT rather than an assertion, which is why I mis-filed it as one of the pre-existing slow tests instead of checking it against main. It was checked properly this time: the test passes on origin/main and failed on this branch, which is the definition of a regression I introduced. Only two suites in the repository construct the real manager, and the other one was already stubbed. The remaining four full-suite failures now behave identically on main and on this branch when run the same way. Lesson recorded because it will recur: a timeout is not evidence of flakiness. A guard that refuses to start a session surfaces downstream as "the thing I was waiting for never happened". Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TYoEfSfvBF5FU3AwbzS8qL --- .../codexLiveContinuity.renderer.test.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx b/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx index c5272c09..600e2a26 100644 --- a/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/persistence/codexLiveContinuity.renderer.test.tsx @@ -40,6 +40,22 @@ const { createSession, loadInitialHistoryForSession } = vi.hoisted(() => ({ loadInitialHistoryForSession: vi.fn(), })) +vi.mock('@main/workspaceDirectory.js', () => ({ + // This suite drives a real SessionManager against the synthetic cwd + // '/fixture/project-1', which does not exist on disk. The spawn-path guard + // stats the cwd and would refuse the recover, so the session never starts + // and the JSONL identity this test waits for never arrives. Stubbed here for + // the same reason as sessionRecovery.integration.test.ts — these are the + // only two suites that construct the real manager. + 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 }), }))