From f30bd36d1f063fa182e04527ace893bb1cb645a3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:23:32 -0700 Subject: [PATCH 01/11] fix(sidebar): keep hook-less agent rows while the agent runs, whatever its title Codex retitles its pane to the project name, so the sidebar's title-derived row (which required the title to name an agent) vanished while Codex kept running (#23767). Rows now take identity from the canonical pane resolver over the pane's foreground-process read and launch record, then the title; the title only decides idle/working/needs-input. The row still goes away when the PTY exits, the process tracker proves the shell is back, or the title is a shell or default title. --- ...uild-dashboard-snapshot.rows-cache.test.ts | 22 +++ .../useAgentBucketCounts.gate.test.ts | 7 +- .../dashboard/useAgentBucketCounts.ts | 7 +- .../dashboard/worktree-agent-rows-cache.ts | 11 +- .../title-derived-pane-agent-identity.ts | 91 +++++++++++ .../sidebar/useWorktreeAgentRows.ts | 9 ++ .../components/sidebar/worktree-agent-rows.ts | 2 + .../worktree-card-status-inputs.test.ts | 32 ++++ .../sidebar/worktree-card-status-inputs.ts | 29 ++++ .../worktree-title-derived-agent-rows.test.ts | 143 +++++++++++++++++- .../worktree-title-derived-agent-rows.ts | 93 ++++-------- 11 files changed, 374 insertions(+), 72 deletions(-) create mode 100644 src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts index 3a1254004a64..cdf7bc2c1ea1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts @@ -115,6 +115,28 @@ describe('buildDashboardSnapshot rows cache', () => { expect(cached).toEqual(buildDashboardSnapshot(titleWrite, NOW + 500)) }) + it('recomputes only the worktree whose pane foreground process changed', () => { + const cache = createWorktreeAgentRowsCache() + const state: DashboardSnapshotState = { + ...baseState(), + agentStatusByPaneKey: { [PANE_1]: entry(PANE_1, 'tab1', 'w1') }, + runtimePaneTitlesByTabId: { tab1: { 0: 'shell' }, tab2: { 0: 'demo-repo' } } + } + buildDashboardSnapshot(state, NOW, { rowsCache: cache, rowsGeneration: 1 }) + + const codexStarted: DashboardSnapshotState = { + ...state, + paneForegroundAgentByPaneKey: { [PANE_2]: { agent: 'codex', shellForeground: false } } + } + const cached = buildDashboardSnapshot(codexStarted, NOW + 500, { + rowsCache: cache, + rowsGeneration: 1 + }) + expect(cache.lastComputedWorktreeIds).toEqual(['w2']) + expect(cached.cards.find((card) => card.paneKey === PANE_2)?.agentType).toBe('codex') + expect(cached).toEqual(buildDashboardSnapshot(codexStarted, NOW + 500)) + }) + it('keeps card-level fields fresh (acks, workspace statuses) without recomputing rows', () => { const cache = createWorktreeAgentRowsCache() const state = baseState() diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts index 5f89b90f715f..2c82146f9dcb 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts @@ -23,6 +23,7 @@ function storeState(): AppState { terminalLayoutsByTabId: {}, ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, + paneForegroundAgentByPaneKey: {}, folderWorkspaces: [], acknowledgedAgentsByPaneKey: {}, agentStatusEpoch: 0, @@ -32,7 +33,7 @@ function storeState(): AppState { } // What `useShallow` did before the gate, unwrapped from the hook so it can be -// driven directly: allocate the 14-key object, then `shallow()` it against the +// driven directly: allocate the 15-key object, then `shallow()` it against the // previous one. Kept here as the comparison baseline. let previousShallow: Record | null = null const shallowInputs = (s: AppState): Record => ({ @@ -47,6 +48,7 @@ const shallowInputs = (s: AppState): Record => ({ terminalLayoutsByTabId: s.terminalLayoutsByTabId, ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey, folderWorkspaces: s.folderWorkspaces, acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch @@ -92,7 +94,7 @@ afterEach(() => { }) describe('agent bucket count input gate', () => { - it('allocates nothing on a store write that leaves all fourteen slices alone', () => { + it('allocates nothing on a store write that leaves all fifteen slices alone', () => { const state = storeState() // Prime the gate, then replay the writes an unrelated slice would trigger. selectAgentBucketCountState(state) @@ -149,6 +151,7 @@ describe('agent bucket count input gate', () => { 'terminalLayoutsByTabId', 'ptyIdsByTabId', 'runtimePaneTitlesByTabId', + 'paneForegroundAgentByPaneKey', 'folderWorkspaces', 'acknowledgedAgentsByPaneKey', 'agentStatusEpoch' diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index d003991cecfb..31d210ac3ff9 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -23,6 +23,7 @@ export type AgentBucketCountState = Pick< | 'terminalLayoutsByTabId' | 'ptyIdsByTabId' | 'runtimePaneTitlesByTabId' + | 'paneForegroundAgentByPaneKey' | 'folderWorkspaces' | 'acknowledgedAgentsByPaneKey' | 'agentStatusEpoch' @@ -34,8 +35,8 @@ export type AgentBucketCountState = Pick< // Why module scope rather than useShallow: zustand runs this selector on every // store write, and shallow() on a plain object takes the compareEntries path — -// two Object.entries arrays, 28 tuples and two Maps allocated per write just to -// conclude nothing moved. Fourteen `===` against the previous slices allocates +// two Object.entries arrays, 30 tuples and two Maps allocated per write just to +// conclude nothing moved. Fifteen `===` against the previous slices allocates // nothing on the unchanged path, and the result is a pure function of the state // so one gate can serve every mounted consumer. let previousState: AgentBucketCountState | null = null @@ -60,6 +61,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState previous.terminalLayoutsByTabId === s.terminalLayoutsByTabId && previous.ptyIdsByTabId === s.ptyIdsByTabId && previous.runtimePaneTitlesByTabId === s.runtimePaneTitlesByTabId && + previous.paneForegroundAgentByPaneKey === s.paneForegroundAgentByPaneKey && previous.folderWorkspaces === s.folderWorkspaces && previous.acknowledgedAgentsByPaneKey === s.acknowledgedAgentsByPaneKey && previous.agentStatusEpoch === s.agentStatusEpoch @@ -78,6 +80,7 @@ export function selectAgentBucketCountState(s: AppState): AgentBucketCountState terminalLayoutsByTabId: s.terminalLayoutsByTabId, ptyIdsByTabId: s.ptyIdsByTabId, runtimePaneTitlesByTabId: s.runtimePaneTitlesByTabId, + paneForegroundAgentByPaneKey: s.paneForegroundAgentByPaneKey, folderWorkspaces: s.folderWorkspaces, acknowledgedAgentsByPaneKey: s.acknowledgedAgentsByPaneKey, agentStatusEpoch: s.agentStatusEpoch, diff --git a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts b/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts index a00cae7af195..2dfea45a352f 100644 --- a/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts +++ b/src/renderer/src/components/dashboard/worktree-agent-rows-cache.ts @@ -17,8 +17,10 @@ import { } from '../sidebar/worktree-agent-row-selectors' import { selectLivePtyIdsForWorktree, + selectPaneForegroundAgentsForWorktree, selectRuntimePaneTitlesForWorktree } from '../sidebar/worktree-card-status-inputs' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' type WorktreeAgentRowsCacheEntry = { /** Caller-provided invalidation token for time-based freshness (agentStatusEpoch). */ @@ -31,6 +33,7 @@ type WorktreeAgentRowsCacheEntry = { terminalLayoutsByTabId: Record paneTitlesByTabId: Record> ptyIdsByTabId: Record + paneForegroundAgentByPaneKey: Record rows: DashboardAgentRowWithLineage[] } @@ -88,7 +91,7 @@ export type WorktreeAgentRowsState = Pick< | 'ptyIdsByTabId' | 'runtimePaneTitlesByTabId' > & - Partial> + Partial> // Why not identity: the per-worktree layout/title/ptyId selectors build a fresh top-level // record per call while preserving per-tab value references, so shallow equality is the @@ -122,6 +125,7 @@ export function selectWorktreeAgentRowsCached(args: { const terminalLayoutsByTabId = selectTerminalLayoutsForWorktree(state, worktreeId) const paneTitlesByTabId = selectRuntimePaneTitlesForWorktree(state, worktreeId) const ptyIdsByTabId = selectLivePtyIdsForWorktree(state, worktreeId) + const paneForegroundAgentByPaneKey = selectPaneForegroundAgentsForWorktree(state, worktreeId) const cached = cache?.byWorktree.get(worktreeId) if ( @@ -134,7 +138,8 @@ export function selectWorktreeAgentRowsCached(args: { cached.orchestration === orchestration && shallowRecordEqual(cached.terminalLayoutsByTabId, terminalLayoutsByTabId) && shallowRecordEqual(cached.paneTitlesByTabId, paneTitlesByTabId) && - shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId) + shallowRecordEqual(cached.ptyIdsByTabId, ptyIdsByTabId) && + shallowRecordEqual(cached.paneForegroundAgentByPaneKey, paneForegroundAgentByPaneKey) ) { return cached.rows } @@ -158,6 +163,7 @@ export function selectWorktreeAgentRowsCached(args: { ptyIdsByTabId, terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey: orchestration, + paneForegroundAgentByPaneKey, now }) ) @@ -174,6 +180,7 @@ export function selectWorktreeAgentRowsCached(args: { terminalLayoutsByTabId, paneTitlesByTabId, ptyIdsByTabId, + paneForegroundAgentByPaneKey, rows }) } diff --git a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts new file mode 100644 index 000000000000..025b91332b95 --- /dev/null +++ b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts @@ -0,0 +1,91 @@ +import { titleShowsNoAgent } from '../../../../shared/agent-detection' +import type { AgentType } from '../../../../shared/agent-status-types' +import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { + resolvePaneAgentIdentity, + type PaneAgentEvidence +} from '../../../../shared/pane-agent-identity-resolver' +import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + +const TITLE_AGENT_LABEL_TO_TYPE: Record = { + 'Claude Code': 'claude', + OpenClaude: 'openclaude', + Codex: 'codex', + 'Gemini CLI': 'gemini', + 'GitHub Copilot': 'copilot', + Grok: 'grok', + Devin: 'devin', + Antigravity: 'antigravity', + OpenCode: 'opencode', + Aider: 'aider', + Cursor: 'cursor', + Droid: 'droid', + Hermes: 'hermes', + Pi: 'pi', + OMP: 'omp' +} + +const CLAUDE_AGENT_TOKEN_RE = /(? + +/** + * Which agent a hook-less pane runs, ranked by the canonical resolver: the foreground process, + * then the agent Orca launched, then the title. Null means the pane shows no agent row. + * + * Only facts that die with the agent may keep a row whose title names no agent (Codex retitles + * itself to the project name, #23767): the process read clears on exit, and launch ownership is + * dropped once the process tracker proves the shell is back. A shell or default title retires + * both, matching the tab's launched-agent exit rule. + */ +export function resolveTitleDerivedPaneAgent(args: { + title: string + defaultTitle?: string + titleAgentType: AgentType | null + launchAgentType: AgentType | null + foreground: TitleDerivedPaneForeground | undefined +}): AgentType | null { + const evidence: PaneAgentEvidence[] = [] + // Why: a shell/default title retires process and launch facts, never an agent the title names. + const titleShowsExit = titleShowsNoAgent(args.title, args.defaultTitle) + const processAgent = titleShowsExit ? null : args.foreground?.agent + if (processAgent) { + // Why: OMP's nested pi process must not take an OMP-launched pane from its owner. + const agent = + resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent + evidence.push({ source: 'process', agent }) + } + if (args.launchAgentType && !titleShowsExit && args.foreground?.shellForeground !== true) { + evidence.push({ source: 'launch', agent: args.launchAgentType }) + } + if (args.titleAgentType) { + evidence.push({ source: 'title', agent: args.titleAgentType }) + } + return resolvePaneAgentIdentity({ evidence }).agent +} diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 366bce5e5ed4..1fd1d8af452f 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -6,8 +6,10 @@ import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsuppor import { useAppStore } from '@/store' import { EMPTY_LIVE_PTY_IDS, + EMPTY_PANE_FOREGROUND_AGENTS, EMPTY_RUNTIME_PANE_TITLES, selectLivePtyIdsForWorktree, + selectPaneForegroundAgentsForWorktree, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' import { buildWorktreeAgentRows } from './worktree-agent-rows' @@ -101,6 +103,11 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa : EMPTY_WORKTREE_AGENT_ORCHESTRATION ) ) + const paneForegroundAgentByPaneKey = useAppStore( + useShallow((s) => + active ? selectPaneForegroundAgentsForWorktree(s, worktreeId) : EMPTY_PANE_FOREGROUND_AGENTS + ) + ) const agentFreshnessSignature = useAppStore((s) => active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE ) @@ -131,6 +138,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa ptyIdsByTabId, terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey, + paneForegroundAgentByPaneKey, now }) ) @@ -145,6 +153,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa ptyIdsByTabId, terminalLayoutsByTabId, runtimeAgentOrchestrationByPaneKey, + paneForegroundAgentByPaneKey, agentFreshnessSignature ]) } diff --git a/src/renderer/src/components/sidebar/worktree-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-agent-rows.ts index 009c562447fd..4c8b3fbb77bb 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-rows.ts @@ -20,6 +20,7 @@ import { resolveRuntimePaneTitleLeafId } from '@/lib/runtime-pane-title-leaf-id' import { resolveDecayedAgentRowState } from '@/lib/agent-row-decay-state' import { tabHasLivePty } from '@/lib/tab-has-live-pty' import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows' +import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity' import { buildSubagentChildRows } from './worktree-subagent-child-rows' import { compareWorktreeAgentRows } from './worktree-agent-row-order' import { @@ -146,6 +147,7 @@ export function buildWorktreeAgentRows(args: { ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record + paneForegroundAgentByPaneKey?: Record now: number }): DashboardAgentRow[] { const rows: DashboardAgentRow[] = [] diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index 59675017b2ad..f3c216add911 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -7,9 +7,11 @@ import type { } from '../../../../shared/terminal-tab-types' import { EMPTY_LIVE_PTY_IDS, + EMPTY_PANE_FOREGROUND_AGENTS, EMPTY_RUNTIME_PANE_TITLES, EMPTY_TERMINAL_LAYOUT_ROOTS, selectLivePtyIdsForWorktree, + selectPaneForegroundAgentsForWorktree, selectTerminalLayoutRootsForWorktree, selectTerminalLayoutRootsForWorktrees, selectRuntimePaneTitlesForWorktree @@ -219,4 +221,34 @@ describe('worktree card status input selectors', () => { expect(Object.isFrozen(EMPTY_LIVE_PTY_IDS)).toBe(true) expect(Object.isFrozen(EMPTY_TERMINAL_LAYOUT_ROOTS)).toBe(true) }) + + it("selects only this worktree's pane foreground reads, keeping identity across other writes", () => { + const worktreeId = 'repo1::/path/wt1' + const ownPaneKey = 'tab-1:11111111-1111-4111-8111-111111111111' + const ownEntry = { agent: 'codex' as const, shellForeground: false } + const state: Parameters[0] = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)], + other: [makeTab('tab-2', 'other')] + }, + paneForegroundAgentByPaneKey: { + [ownPaneKey]: ownEntry, + 'tab-2:22222222-2222-4222-8222-222222222222': { agent: 'claude', shellForeground: false } + } + } + const selected = selectPaneForegroundAgentsForWorktree(state, worktreeId) + expect(selected).toEqual({ [ownPaneKey]: ownEntry }) + + const otherWrite = { + ...state, + paneForegroundAgentByPaneKey: { + ...state.paneForegroundAgentByPaneKey, + 'tab-2:22222222-2222-4222-8222-222222222222': { agent: null, shellForeground: true } + } + } + expect(selectPaneForegroundAgentsForWorktree(otherWrite, worktreeId)).toBe(selected) + expect(selectPaneForegroundAgentsForWorktree(otherWrite, 'missing')).toBe( + EMPTY_PANE_FOREGROUND_AGENTS + ) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index 43971728deb4..baf4ae0bc382 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,4 +1,6 @@ import type { AppState } from '@/store/types' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-cache' @@ -9,6 +11,9 @@ import { createWorktreeRecordSelector } from '@/store/worktree-record-selector-c // across worktrees instead of failing locally. export const EMPTY_RUNTIME_PANE_TITLES: Record> = Object.freeze({}) export const EMPTY_LIVE_PTY_IDS: Record = Object.freeze({}) +export const EMPTY_PANE_FOREGROUND_AGENTS: Record = Object.freeze( + {} +) export const EMPTY_TERMINAL_LAYOUT_ROOTS: Record< string, TerminalPaneLayoutNode | null | undefined @@ -18,6 +23,10 @@ type WorktreeCardStatusInputState = Pick } +type WorktreeCardForegroundInputState = Partial> & { + tabsByWorktree: Record +} + type WorktreeCardLayoutRootInputState = Pick & { tabsByWorktree: Record } @@ -58,6 +67,26 @@ export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector< } }) +/** This worktree's pane foreground-process reads, keyed by pane key. */ +export const selectPaneForegroundAgentsForWorktree = createWorktreeRecordSelector< + WorktreeCardForegroundInputState, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.paneForegroundAgentByPaneKey], + empty: EMPTY_PANE_FOREGROUND_AGENTS, + build: (state, worktreeId) => { + const tabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const out: Record = {} + for (const [paneKey, entry] of Object.entries(state.paneForegroundAgentByPaneKey ?? {})) { + const tabId = parsePaneKey(paneKey)?.tabId + if (tabId && tabIds.has(tabId)) { + out[paneKey] = entry + } + } + return out + } +}) + export const selectTerminalLayoutRootsForWorktree = createWorktreeRecordSelector< WorktreeCardLayoutRootInputState, Record diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index e30581af05d1..0792ca0dd5e6 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -242,7 +242,7 @@ describe('buildTitleDerivedAgentRows', () => { ).toEqual([['codex', 'working', 'Codex', '⠼ demo-repo']]) }) - it('keeps explicit title identity over the launched agent', () => { + it('keeps launch identity over a conflicting title', () => { const launchAgent: TuiAgent = 'claude' const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent })], @@ -256,7 +256,7 @@ describe('buildTitleDerivedAgentRows', () => { now: 2000 }) - expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']]) + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['claude', 'working']]) }) it('produces no row for a spinner-only title when the tab has no launch identity', () => { @@ -369,7 +369,11 @@ describe('buildTitleDerivedAgentRows', () => { }) it('still resolves Claude from a title that presents Claude, owner or not', () => { - const rowsFor = (title: string, launchAgent?: TuiAgent) => + const rowsFor = ( + title: string, + launchAgent?: TuiAgent, + foreground?: { agent: TuiAgent | null; shellForeground: boolean } + ) => buildWorktreeAgentRows({ tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})], entries: [], @@ -377,12 +381,21 @@ describe('buildTitleDerivedAgentRows', () => { runtimePaneTitlesByTabId: { 'tab-1': { 1: title } }, ptyIdsByTabId: { 'tab-1': ['pty-agent'] }, terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) }, + ...(foreground + ? { paneForegroundAgentByPaneKey: { [makePaneKey('tab-1', LEAF_ID_1)]: foreground } } + : {}), now: 2000 }) expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude']) - // Pane reuse: the user exited OpenCode and ran claude in the same pane. - expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude']) + // Launch ownership outranks a conflicting title while nothing proves the launched agent left. + expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['opencode']) + // Pane reuse: the user exited OpenCode and ran claude; the process read hands the pane over. + expect( + rowsFor('✳ Claude Code', 'opencode', { agent: 'claude', shellForeground: false }).map( + (row) => row.agentType + ) + ).toEqual(['claude']) // No owner to defend the pane: naming Claude stays the only available identity. expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude']) expect(rowsFor('zsh', 'opencode')).toHaveLength(0) @@ -403,6 +416,126 @@ describe('buildTitleDerivedAgentRows', () => { }) }) +// #23767: Codex retitles its pane to the project name, so a title-gated row vanished while +// Codex kept running. Identity now comes from process and launch facts; the title sets activity. +describe('hook-less agent rows identified by process and launch evidence', () => { + const PANE_KEY = makePaneKey('tab-1', LEAF_ID_1) + + function rowsFor(args: { + title: string + launchAgent?: TuiAgent + foreground?: { agent: TuiAgent | null; shellForeground: boolean } + ptyIds?: string[] + layout?: TerminalLayoutSnapshot + }) { + return buildWorktreeAgentRows({ + tabs: [ + makeTab('tab-1', { + defaultTitle: 'Terminal 1', + ...(args.launchAgent ? { launchAgent: args.launchAgent } : {}) + }) + ], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: args.title } }, + ptyIdsByTabId: { 'tab-1': args.ptyIds ?? ['pty-agent'] }, + terminalLayoutsByTabId: { 'tab-1': args.layout ?? makeSingleLayout(LEAF_ID_1) }, + ...(args.foreground ? { paneForegroundAgentByPaneKey: { [PANE_KEY]: args.foreground } } : {}), + now: 2000 + }) + } + + const summarize = (rows: ReturnType) => + rows.map((row) => [row.agentType, row.state, row.entry.prompt, row.entry.lastAssistantMessage]) + + it('keeps a launched Codex row when Codex retitles the pane to the project name', () => { + expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex' }))).toEqual([ + ['codex', 'idle', 'Codex', 'Idle'] + ]) + expect(summarize(rowsFor({ title: 'demo-repo', launchAgent: 'codex' }))).toEqual([ + ['codex', 'idle', 'Codex', 'Idle'] + ]) + }) + + it('rows a hand-typed agent from its foreground process, whatever its title says', () => { + for (const agent of ['codex', 'claude', 'gemini', 'opencode', 'grok'] as const) { + const rows = rowsFor({ title: 'demo-repo', foreground: { agent, shellForeground: false } }) + expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([ + [PANE_KEY, agent, 'idle'] + ]) + } + }) + + it('scopes process evidence to its own pane inside a split', () => { + const rows = buildWorktreeAgentRows({ + tabs: [makeTab('tab-1', { launchAgent: 'claude' })], + entries: [], + retained: [], + runtimePaneTitlesByTabId: { 'tab-1': { 1: 'demo-repo', 2: 'demo-repo' } }, + ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] }, + terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() }, + paneForegroundAgentByPaneKey: { + [makePaneKey('tab-1', LEAF_ID_2)]: { agent: 'codex', shellForeground: false } + }, + now: 2000 + }) + + expect(rows.map((row) => [row.paneKey, row.agentType])).toEqual([ + [makePaneKey('tab-1', LEAF_ID_2), 'codex'] + ]) + }) + + it('lets the title drive activity without deciding who the agent is', () => { + const foreground = { agent: 'codex' as const, shellForeground: false } + expect(summarize(rowsFor({ title: '⠋ demo-repo', foreground }))).toEqual([ + ['codex', 'working', 'Codex', 'Running'] + ]) + expect(summarize(rowsFor({ title: 'demo-repo', foreground }))).toEqual([ + ['codex', 'idle', 'Codex', 'Idle'] + ]) + // A title naming another agent does not outrank the process that is actually running. + expect(summarize(rowsFor({ title: '⠋ Gemini CLI', foreground }))).toEqual([ + ['codex', 'working', 'Codex', 'Running'] + ]) + }) + + it('drops the row once the agent is really gone', () => { + // The process tracker proved the shell is back, which retires the launch record too. + expect( + rowsFor({ + title: 'demo-repo', + launchAgent: 'codex', + foreground: { agent: null, shellForeground: true } + }) + ).toHaveLength(0) + // A shell or default title outlives neither a process read nor a launch record. + expect( + rowsFor({ + title: 'zsh', + launchAgent: 'codex', + foreground: { agent: 'codex', shellForeground: false } + }) + ).toHaveLength(0) + expect(rowsFor({ title: 'Terminal 1', launchAgent: 'codex' })).toHaveLength(0) + // The PTY exited. + expect( + rowsFor({ + title: 'demo-repo', + launchAgent: 'codex', + foreground: { agent: 'codex', shellForeground: false }, + ptyIds: [] + }) + ).toHaveLength(0) + }) + + it('makes no row from a plain title when nothing identifies an agent', () => { + expect(rowsFor({ title: 'demo-repo' })).toHaveLength(0) + expect( + rowsFor({ title: 'demo-repo', foreground: { agent: null, shellForeground: false } }) + ).toHaveLength(0) + }) +}) + // Why: `runtimePaneTitlesByTabId` mixes two disjoint id spaces — live PaneManager // ids (>= 1) and the `-(leafIndex + 1)` slots a parked tab mints — so attributing a // title by its position in the numerically sorted slot list puts one split pane's diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 341cb0e1ff28..f339538bcfad 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -23,9 +23,13 @@ import { type CompatibleAgentOwnerOptions } from '../../../../shared/agent-title-owner' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' -import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' +import { + resolveTitleDerivedAgentType, + resolveTitleDerivedPaneAgent, + type TitleDerivedPaneForeground +} from './title-derived-pane-agent-identity' -/** Fixed, not per-process: title rows are a pure projection of the current title, so they are +/** Fixed, not per-process: title rows are a pure projection of current pane facts, so they are * comparable across restarts in a way a sequenced authority's rows are not. Ordering against * any other authority's rows is undefined — see agent-status-observation.ts. */ export const TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-title-projection' @@ -33,26 +37,7 @@ export const TITLE_DERIVED_AGENT_ROW_AUTHORITY_ID = 'renderer-title-projection' const EMPTY_RUNTIME_TITLES: Record> = {} const EMPTY_LIVE_PTY_IDS: Record = {} const EMPTY_TERMINAL_LAYOUTS: Record = {} - -const TITLE_AGENT_LABEL_TO_TYPE: Record = { - 'Claude Code': 'claude', - OpenClaude: 'openclaude', - Codex: 'codex', - 'Gemini CLI': 'gemini', - 'GitHub Copilot': 'copilot', - Grok: 'grok', - Devin: 'devin', - Antigravity: 'antigravity', - OpenCode: 'opencode', - Aider: 'aider', - Cursor: 'cursor', - Droid: 'droid', - Hermes: 'hermes', - Pi: 'pi', - OMP: 'omp' -} - -const CLAUDE_AGENT_TOKEN_RE = /(? = {} export function buildTitleDerivedAgentRows(args: { tabs: TerminalTab[] @@ -60,6 +45,7 @@ export function buildTitleDerivedAgentRows(args: { ptyIdsByTabId?: Record terminalLayoutsByTabId?: Record runtimeAgentOrchestrationByPaneKey?: Record + paneForegroundAgentByPaneKey?: Record seenPaneKeys: Set now: number }): DashboardAgentRow[] { @@ -67,6 +53,7 @@ export function buildTitleDerivedAgentRows(args: { const runtimePaneTitlesByTabId = args.runtimePaneTitlesByTabId ?? EMPTY_RUNTIME_TITLES const ptyIdsByTabId = args.ptyIdsByTabId ?? EMPTY_LIVE_PTY_IDS const terminalLayoutsByTabId = args.terminalLayoutsByTabId ?? EMPTY_TERMINAL_LAYOUTS + const paneForegroundAgentByPaneKey = args.paneForegroundAgentByPaneKey ?? EMPTY_PANE_FOREGROUND for (const tab of args.tabs) { if (!tabHasLivePty(ptyIdsByTabId, tab.id)) { @@ -113,6 +100,7 @@ export function buildTitleDerivedAgentRows(args: { leafId, title, ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), + paneForegroundAgentByPaneKey, now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -134,6 +122,7 @@ export function buildTitleDerivedAgentRows(args: { leafId, title: tab.title, ownerAgentType: resolveTitleDerivedPaneOwner(tab, layout, leafId), + paneForegroundAgentByPaneKey, now: args.now, runtimeAgentOrchestrationByPaneKey: args.runtimeAgentOrchestrationByPaneKey }) @@ -156,6 +145,7 @@ function buildTitleDerivedAgentRow(args: { leafId: string title: string ownerAgentType: AgentType | null + paneForegroundAgentByPaneKey: Record now: number runtimeAgentOrchestrationByPaneKey?: Record }): DashboardAgentRow | null { @@ -172,31 +162,36 @@ function buildTitleDerivedAgentRow(args: { // Why (cursor): the native `cursor agent` literal is deliberately status-less so a // redraw cannot stomp hook state — but it still identifies a live pane, so the row // reads idle instead of vanishing (#10258). - const status = isClaudeAgentsTitle + const titleStatus = isClaudeAgentsTitle ? 'idle' : (classifyTitleActivity(title) ?? (isCursorAgentTitle(title) ? 'idle' : null)) const label = isClaudeAgentsTitle ? 'Claude Code' : resolveTitleActivityLabel(title) - if (!status || !label) { - return null - } if (!isTerminalLeafId(args.leafId)) { return null } const paneKey = makePaneKey(args.tab.id, args.leafId) const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey] - const titleAgentType = isClaudeAgentsTitle - ? 'claude' - : resolveTitleDerivedAgentType(title, label, args.ownerAgentType) - // Why: a status frame proves activity, not identity, so the resolver drops it. - // Hook-less agents over SSH (Codex, #8711; OpenCode's '. '/'* ' frames, #8940) - // surface only decorated task titles; fall back to the pane's known owner instead - // of hiding the pane. Safe because the `!status || !label` gate above already - // rejects plain shell titles — this path must never manufacture a row from one. - const agentType = titleAgentType ?? args.ownerAgentType + // Why: a status frame proves activity, not identity (Codex over SSH, #8711; OpenCode's + // '. '/'* ' frames, #8940), so a title names an agent only when it carries both. + const titleAgentType = + !titleStatus || !label + ? null + : isClaudeAgentsTitle + ? 'claude' + : resolveTitleDerivedAgentType(title, label, args.ownerAgentType) + const agentType = resolveTitleDerivedPaneAgent({ + title, + defaultTitle: args.tab.defaultTitle, + titleAgentType, + launchAgentType: args.ownerAgentType, + foreground: args.paneForegroundAgentByPaneKey[paneKey] + }) if (!agentType) { return null } - const rowLabel = titleAgentType ? label : formatAgentTypeLabel(agentType) + // Why: the title sets activity only; a plain title on a process- or launch-identified pane is idle. + const status = titleStatus ?? 'idle' + const rowLabel = agentType === titleAgentType && label ? label : formatAgentTypeLabel(agentType) const rowState = titleStatusToRowState(status) const secondary = status === 'permission' ? 'Needs input' : status === 'working' ? 'Running' : 'Idle' @@ -212,7 +207,7 @@ function buildTitleDerivedAgentRow(args: { terminalTitle: title, lastAssistantMessage: secondary, ...(orchestration ? { orchestration } : {}), - // Why not the renderer sequencer: this row is RE-DERIVED from the pane's title on every + // Why not the renderer sequencer: this row is RE-DERIVED from the pane's facts on every // render, not observed once, so a counter would churn a new revision per frame and break // memoization. Deriving revision from `now` keeps the stamp deterministic in the same clock // the row already publishes as updatedAt, and monotonic for the pane. @@ -245,30 +240,6 @@ function buildTitleDerivedAgentRow(args: { } } -export function resolveTitleDerivedAgentType( - title: string, - label: string, - ownerAgentType?: AgentType | null -): AgentType | null { - const agentType = TITLE_AGENT_LABEL_TO_TYPE[label] ?? 'unknown' - if (agentType !== 'claude') { - return agentType - } - // Why: Claude's task-title spinner heuristic has no provider identity. In - // split panes it can match arbitrary terminal spinners, so sidebar rows only - // accept Claude when the title itself names Claude. - if (!CLAUDE_AGENT_TOKEN_RE.test(title)) { - return null - } - // Why: a "claude" word inside another agent's task text is a mention, not identity. - // Only a title that PRESENTS Claude may take a pane away from its known owner (#8940). - const owner = ownerAgentType && ownerAgentType !== 'unknown' ? ownerAgentType : null - if (owner && owner !== 'claude' && !isClaudeIdentityFrameTitle(title)) { - return null - } - return agentType -} - function resolveTitleDerivedPaneOwner( tab: TerminalTab, layout: TerminalLayoutSnapshot | undefined, From ef78b55f744e2870dc169c5f2d0ed04ad2d654fe Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:29:22 -0700 Subject: [PATCH 02/11] test(dashboard): justify the partial store fixture's type assertion --- .../src/components/dashboard/useAgentBucketCounts.gate.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts index 2c82146f9dcb..8272b66d7f84 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.gate.test.ts @@ -11,6 +11,7 @@ vi.mock('@/store', () => ({ useAppStore: () => undefined })) const STORE_WRITES = 2_000 function storeState(): AppState { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the selector reads only the slices listed here; the rest of AppState is never touched in this test. return { repos: [], worktreesByRepo: {}, From c7a4f2d7427f552639f00dc5664a334440be2433 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:47:32 -0700 Subject: [PATCH 03/11] fix(sidebar): only a live process read keeps a plain-title agent row Review of the previous commit found ghost rows: the tab launch record is a latch nothing clears on WSL, after an SSH exit, or for a launch that never started, and a parked pane's process read went stale because only the mounted tracker re-derives it. - The launch record returns to main's role: a fallback only for titles that show activity, ranked below a title naming another agent (pane reuse), matching the tab icon's order. - A parked pane's command boundary retires its unconfirmable process read, like the mounted ladder's unavailable path; reveal re-reads it. --- .../title-derived-pane-agent-identity.ts | 50 ++++++++----------- .../worktree-title-derived-agent-rows.test.ts | 36 +++++++++---- .../worktree-title-derived-agent-rows.ts | 3 +- .../parked-terminal-command-status.test.ts | 31 +++++++++++- .../parked-terminal-command-status.ts | 7 +++ 5 files changed, 86 insertions(+), 41 deletions(-) diff --git a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts index 025b91332b95..415ff34bf06d 100644 --- a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts +++ b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts @@ -1,10 +1,6 @@ import { titleShowsNoAgent } from '../../../../shared/agent-detection' import type { AgentType } from '../../../../shared/agent-status-types' import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' -import { - resolvePaneAgentIdentity, - type PaneAgentEvidence -} from '../../../../shared/pane-agent-identity-resolver' import { isClaudeIdentityFrameTitle } from '../../../../shared/terminal-title-agent-type' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' @@ -52,40 +48,38 @@ export function resolveTitleDerivedAgentType( return agentType } -/** The foreground-process facts a hook-less row reads; the rest of the entry is routing state. */ +/** The pane's foreground-process read as the tracker publishes it; routing fields are omitted. */ export type TitleDerivedPaneForeground = Pick /** - * Which agent a hook-less pane runs, ranked by the canonical resolver: the foreground process, - * then the agent Orca launched, then the title. Null means the pane shows no agent row. + * Which agent a hook-less pane runs, in the tab icon's order: the foreground process, then the + * title, then the agent Orca launched. Null means the pane shows no agent row. * - * Only facts that die with the agent may keep a row whose title names no agent (Codex retitles - * itself to the project name, #23767): the process read clears on exit, and launch ownership is - * dropped once the process tracker proves the shell is back. A shell or default title retires - * both, matching the tab's launched-agent exit rule. + * Only the process read may keep a row whose title shows no agent activity (Codex retitles itself + * to the project name, #23767): the mounted pane's tracker re-derives it at every command + * boundary and clears it when the shell returns. The launch record is a tab-scoped latch with no + * run id, so it stays a fallback for titles that show activity, and ranks below a title that + * names a different agent (pane reuse). */ export function resolveTitleDerivedPaneAgent(args: { title: string defaultTitle?: string + titleShowsActivity: boolean titleAgentType: AgentType | null launchAgentType: AgentType | null foreground: TitleDerivedPaneForeground | undefined }): AgentType | null { - const evidence: PaneAgentEvidence[] = [] - // Why: a shell/default title retires process and launch facts, never an agent the title names. - const titleShowsExit = titleShowsNoAgent(args.title, args.defaultTitle) - const processAgent = titleShowsExit ? null : args.foreground?.agent - if (processAgent) { - // Why: OMP's nested pi process must not take an OMP-launched pane from its owner. - const agent = - resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent - evidence.push({ source: 'process', agent }) - } - if (args.launchAgentType && !titleShowsExit && args.foreground?.shellForeground !== true) { - evidence.push({ source: 'launch', agent: args.launchAgentType }) - } - if (args.titleAgentType) { - evidence.push({ source: 'title', agent: args.titleAgentType }) - } - return resolvePaneAgentIdentity({ evidence }).agent + // Why: a shell/default title is exit evidence the process read may not have caught up with. + const processAgent = titleShowsNoAgent(args.title, args.defaultTitle) + ? null + : args.foreground?.agent + // Why: OMP's nested pi process must not take an OMP-launched pane from its owner. + const ownedProcessAgent = processAgent + ? (resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent) + : null + return ( + ownedProcessAgent ?? + args.titleAgentType ?? + (args.titleShowsActivity ? args.launchAgentType : null) + ) } diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 0792ca0dd5e6..90079312a35c 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -242,7 +242,7 @@ describe('buildTitleDerivedAgentRows', () => { ).toEqual([['codex', 'working', 'Codex', '⠼ demo-repo']]) }) - it('keeps launch identity over a conflicting title', () => { + it('keeps explicit title identity over the launched agent', () => { const launchAgent: TuiAgent = 'claude' const rows = buildWorktreeAgentRows({ tabs: [makeTab('tab-1', { launchAgent })], @@ -256,7 +256,7 @@ describe('buildTitleDerivedAgentRows', () => { now: 2000 }) - expect(rows.map((row) => [row.agentType, row.state])).toEqual([['claude', 'working']]) + expect(rows.map((row) => [row.agentType, row.state])).toEqual([['codex', 'working']]) }) it('produces no row for a spinner-only title when the tab has no launch identity', () => { @@ -388,9 +388,9 @@ describe('buildTitleDerivedAgentRows', () => { }) expect(rowsFor('⠋ Claude Code').map((row) => row.agentType)).toEqual(['claude']) - // Launch ownership outranks a conflicting title while nothing proves the launched agent left. - expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['opencode']) - // Pane reuse: the user exited OpenCode and ran claude; the process read hands the pane over. + // Pane reuse: the user exited OpenCode and ran claude in the same pane. The launch record is + // a latch with no run id, so the title outranks it even before any process read. + expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude']) expect( rowsFor('✳ Claude Code', 'opencode', { agent: 'claude', shellForeground: false }).map( (row) => row.agentType @@ -417,8 +417,8 @@ describe('buildTitleDerivedAgentRows', () => { }) // #23767: Codex retitles its pane to the project name, so a title-gated row vanished while -// Codex kept running. Identity now comes from process and launch facts; the title sets activity. -describe('hook-less agent rows identified by process and launch evidence', () => { +// Codex kept running. A live process read now identifies the pane; the title sets activity. +describe('hook-less agent rows identified by the foreground process', () => { const PANE_KEY = makePaneKey('tab-1', LEAF_ID_1) function rowsFor(args: { @@ -449,14 +449,28 @@ describe('hook-less agent rows identified by process and launch evidence', () => rows.map((row) => [row.agentType, row.state, row.entry.prompt, row.entry.lastAssistantMessage]) it('keeps a launched Codex row when Codex retitles the pane to the project name', () => { - expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex' }))).toEqual([ + const foreground = { agent: 'codex' as const, shellForeground: false } + expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex', foreground }))).toEqual([ ['codex', 'idle', 'Codex', 'Idle'] ]) - expect(summarize(rowsFor({ title: 'demo-repo', launchAgent: 'codex' }))).toEqual([ + expect(summarize(rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground }))).toEqual([ ['codex', 'idle', 'Codex', 'Idle'] ]) }) + it('never keeps a plain-title row on the launch record alone', () => { + // No process read (WSL, a launch that never started), or a read that found no agent (after an + // SSH exit, or a parked pane's boundary retiring its unconfirmable read): no row, as before. + expect(rowsFor({ title: 'demo-repo', launchAgent: 'codex' })).toHaveLength(0) + expect( + rowsFor({ + title: 'demo-repo', + launchAgent: 'codex', + foreground: { agent: null, shellForeground: false } + }) + ).toHaveLength(0) + }) + it('rows a hand-typed agent from its foreground process, whatever its title says', () => { for (const agent of ['codex', 'claude', 'gemini', 'opencode', 'grok'] as const) { const rows = rowsFor({ title: 'demo-repo', foreground: { agent, shellForeground: false } }) @@ -500,7 +514,7 @@ describe('hook-less agent rows identified by process and launch evidence', () => }) it('drops the row once the agent is really gone', () => { - // The process tracker proved the shell is back, which retires the launch record too. + // The process tracker proved the shell is back. expect( rowsFor({ title: 'demo-repo', @@ -508,7 +522,7 @@ describe('hook-less agent rows identified by process and launch evidence', () => foreground: { agent: null, shellForeground: true } }) ).toHaveLength(0) - // A shell or default title outlives neither a process read nor a launch record. + // A shell or default title outranks a process read that has not caught up yet. expect( rowsFor({ title: 'zsh', diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index f339538bcfad..7e73363f7091 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -182,6 +182,7 @@ function buildTitleDerivedAgentRow(args: { const agentType = resolveTitleDerivedPaneAgent({ title, defaultTitle: args.tab.defaultTitle, + titleShowsActivity: Boolean(titleStatus && label), titleAgentType, launchAgentType: args.ownerAgentType, foreground: args.paneForegroundAgentByPaneKey[paneKey] @@ -189,7 +190,7 @@ function buildTitleDerivedAgentRow(args: { if (!agentType) { return null } - // Why: the title sets activity only; a plain title on a process- or launch-identified pane is idle. + // Why: the title sets activity only; a plain title on a process-identified pane is idle. const status = titleStatus ?? 'idle' const rowLabel = agentType === titleAgentType && label ? label : formatAgentTypeLabel(agentType) const rowState = titleStatusToRowState(status) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index 3a3343003417..f0a3810beda7 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -23,6 +23,7 @@ type MockStoreState = { setAgentStatus: ReturnType dropAgentStatus: ReturnType clearAgentLaunchConfig: ReturnType + setPaneForegroundAgent: ReturnType } let mockStoreState: MockStoreState @@ -53,7 +54,8 @@ function makeMockStoreState(): MockStoreState { runtimePaneTitlesByTabId: { [TAB_ID]: { [PANE_ID]: '✳ Build feature' } }, setAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), - clearAgentLaunchConfig: vi.fn() + clearAgentLaunchConfig: vi.fn(), + setPaneForegroundAgent: vi.fn() } } @@ -94,6 +96,33 @@ describe('createParkedTerminalCommandStatusPolicy', () => { vi.useRealTimers() }) + // #23767: a Codex that exits while parked must not keep its process identity (and sidebar row). + it('retires an unconfirmable process read at a parked command boundary', async () => { + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'codex', + shellForeground: false, + routingTrusted: true + } + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandFinished(0) + + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, { + agent: null, + shellForeground: false + }) + policy.dispose() + }) + + it('leaves a pane with no process identity untouched at a parked command boundary', async () => { + const policy = await createPolicy(PTY_ID_SSH) + + policy.onCommandFinished(0) + + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled() + policy.dispose() + }) + it('seeds a Command Code working row with the current pane title', async () => { const policy = await createPolicy(PTY_ID_LOCAL) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 6c2f20b48910..09abecc2b19e 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -158,6 +158,13 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) + // Why: no tracker can confirm a parked pane's foreground, so a command boundary leaves its + // last process read unverified — retire it like the mounted ladder's unavailable path, or an + // agent that exited while parked keeps its identity. Reveal re-reads the process. + const state = useAppStore.getState() + if (state.paneForegroundAgentByPaneKey[paneKey]?.agent) { + state.setPaneForegroundAgent(paneKey, { agent: null, shellForeground: false }) + } // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a From 6fe546e47da9481f9909247b06ff8755abff94ad Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:27:44 -0700 Subject: [PATCH 04/11] fix(sidebar): confirm before a parked marker retires an agent; read Git Bash prompt titles as the shell - A parked pane's end-of-command marker can be a nested shell's leak under a still-running full-screen agent, so confirm the foreground first (as the mounted ladder does) and retire the process read only on a shell or no answer. SSH/remote parked panes hold no incarnation to fence a host read with, so they still retire. - Git Bash emits no command marks; its `$MSYSTEM:$PWD` prompt title (MINGW64:/c/repo) is now shell evidence, so a stale Codex read there no longer keeps a ghost row after Codex exits. --- .../worktree-title-derived-agent-rows.test.ts | 7 ++ .../parked-terminal-command-status.test.ts | 85 ++++++++++++++++--- .../parked-terminal-command-status.ts | 36 ++++++-- src/shared/shell-process-detection.test.ts | 28 ++++++ src/shared/shell-process-detection.ts | 13 ++- 5 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 src/shared/shell-process-detection.test.ts diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index 90079312a35c..d98215e45ebe 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -531,6 +531,13 @@ describe('hook-less agent rows identified by the foreground process', () => { }) ).toHaveLength(0) expect(rowsFor({ title: 'Terminal 1', launchAgent: 'codex' })).toHaveLength(0) + // Git Bash has no command marks, so its prompt title is what retires a stale process read. + expect( + rowsFor({ + title: 'MINGW64:/c/Users/dev/demo-repo', + foreground: { agent: 'codex', shellForeground: false } + }) + ).toHaveLength(0) // The PTY exited. expect( rowsFor({ diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index f0a3810beda7..b7847f16a8ef 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts @@ -96,22 +96,83 @@ describe('createParkedTerminalCommandStatusPolicy', () => { vi.useRealTimers() }) - // #23767: a Codex that exits while parked must not keep its process identity (and sidebar row). - it('retires an unconfirmable process read at a parked command boundary', async () => { - mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { - agent: 'codex', - shellForeground: false, - routingTrusted: true + // #23767: a Codex that exits while parked must not keep its process identity (and sidebar row), + // but a nested shell's leaked 133;D under a still-running agent must not drop it either. + describe('parked command boundary with a known foreground agent', () => { + const confirmForegroundProcess = vi.fn<(id: string) => Promise>() + const flushMicrotasks = async (): Promise => { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve() + } } - const policy = await createPolicy(PTY_ID_LOCAL) - policy.onCommandFinished(0) + beforeEach(() => { + confirmForegroundProcess.mockReset() + vi.stubGlobal('window', { api: { pty: { confirmForegroundProcess } } }) + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'codex', + shellForeground: false, + routingTrusted: true + } + }) - expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, { - agent: null, - shellForeground: false + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('keeps the agent when the confirmation still sees it (a leaked nested-shell marker)', async () => { + confirmForegroundProcess.mockResolvedValue('codex') + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandFinished(0) + await flushMicrotasks() + + expect(confirmForegroundProcess).toHaveBeenCalledWith(PTY_ID_LOCAL) + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled() + policy.dispose() + }) + + it.each([ + ['a shell', 'zsh'], + ['no answer', null] + ])('retires the agent when the confirmation finds %s', async (_label, processName) => { + confirmForegroundProcess.mockResolvedValue(processName) + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandFinished(0) + await flushMicrotasks() + + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, { + agent: null, + shellForeground: false + }) + policy.dispose() + }) + + it('retires an SSH pane without a fenced read it cannot make while parked', async () => { + const policy = await createPolicy(PTY_ID_SSH) + + policy.onCommandFinished(0) + await flushMicrotasks() + + expect(confirmForegroundProcess).not.toHaveBeenCalled() + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(PANE_KEY, { + agent: null, + shellForeground: false + }) + policy.dispose() + }) + + it('writes nothing once the pane was revealed while the confirmation ran', async () => { + confirmForegroundProcess.mockResolvedValue('zsh') + const policy = await createPolicy(PTY_ID_LOCAL) + + policy.onCommandFinished(0) + policy.dispose() + await flushMicrotasks() + + expect(mockStoreState.setPaneForegroundAgent).not.toHaveBeenCalled() }) - policy.dispose() }) it('leaves a pane with no process identity untouched at a parked command boundary', async () => { diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index 09abecc2b19e..ef780931ec0c 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -7,6 +7,7 @@ import type { AgentStatusEntry } from '../../../../shared/agent-status-types' import { resolvePaneAgentOwner } from '../../../../shared/pane-agent-owner' import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import { isShellProcess } from '../../../../shared/shell-process-detection' import { dispatchTerminalCommandFinishedEvent } from '@/hooks/terminal-command-finished-event' import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-connection-ownership' import { getConnectionIdFromState } from '@/lib/connection-owner-resolution' @@ -18,6 +19,7 @@ import { setCommandCodeDoneSettleExecutor } from './command-code-done-settle' import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' +import { isRemoteExecutionHostPtyId } from './remote-execution-host-pty' export type ParkedTerminalCommandStatusPolicy = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -150,6 +152,32 @@ export function createParkedTerminalCommandStatusPolicy(options: { settleCommandCodeDone ) + // Why: a full-screen agent's nested shells leak 133;D, so confirm like the mounted ladder does + // and retire the process read only on a shell or no answer; reveal re-reads it anyway. + const retireForegroundAgentUnlessConfirmed = async (): Promise => { + const entry = useAppStore.getState().paneForegroundAgentByPaneKey[paneKey] + if (!entry?.agent) { + return + } + // Why: a parked pane holds no remote incarnation to fence host evidence with, so it has no answer. + const remote = isRemoteExecutionHostPtyId(ptyId) || parseAppSshPtyId(ptyId) !== null + let processName: string | null = null + if (!remote) { + try { + processName = await window.api.pty.confirmForegroundProcess(ptyId) + } catch { + processName = null + } + } + const state = useAppStore.getState() + if (disposed || state.paneForegroundAgentByPaneKey[paneKey] !== entry) { + return + } + if (processName === null || isShellProcess(processName)) { + state.setPaneForegroundAgent(paneKey, { agent: null, shellForeground: false }) + } + } + return { onCommandFinished: (bestEffortExitCode: number | null): void => { if (disposed) { @@ -158,13 +186,7 @@ export function createParkedTerminalCommandStatusPolicy(options: { // Why: the finished command may have moved HEAD or the index (an agent running // `git checkout` in a parked worktree); nudge git UI now instead of waiting for a poll. dispatchTerminalCommandFinishedEvent(worktreeId, bestEffortExitCode) - // Why: no tracker can confirm a parked pane's foreground, so a command boundary leaves its - // last process read unverified — retire it like the mounted ladder's unavailable path, or an - // agent that exited while parked keeps its identity. Reveal re-reads the process. - const state = useAppStore.getState() - if (state.paneForegroundAgentByPaneKey[paneKey]?.agent) { - state.setPaneForegroundAgent(paneKey, { agent: null, shellForeground: false }) - } + void retireForegroundAgentUnlessConfirmed() // Why: drop the same-turn status row only for SSH PTYs — exact parity with the mounted // path, whose foreground tracker refuses SSH ids and drops un-probed. Local PTYs need // pty-connection's process-confirm ladder to tell a leaked nested-shell 133;D from a diff --git a/src/shared/shell-process-detection.test.ts b/src/shared/shell-process-detection.test.ts new file mode 100644 index 000000000000..ea28c65d93c2 --- /dev/null +++ b/src/shared/shell-process-detection.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' +import { titleShowsNoAgent } from './shell-process-detection' + +describe('titleShowsNoAgent', () => { + it.each([ + 'MINGW64:/c/Users/dev/repo', + 'MINGW32:/c/repo', + 'MSYS:/home/dev', + 'UCRT64:/c/repo', + 'CLANG64:/c/repo', + 'CLANGARM64:/c/repo' + ])('reads the Git Bash prompt title %s as the shell', (title) => { + expect(titleShowsNoAgent(title)).toBe(true) + }) + + it('still reads shell names and the default title as the shell', () => { + expect(titleShowsNoAgent('zsh')).toBe(true) + expect(titleShowsNoAgent('bash.exe')).toBe(true) + expect(titleShowsNoAgent('Terminal 2', 'Terminal 2')).toBe(true) + }) + + it.each(['demo-repo', 'Codex', '⠋ MINGW64:/c/repo', 'MINGW64 notes', 'mingw64:/c/repo', ''])( + 'does not read %j as the shell', + (title) => { + expect(titleShowsNoAgent(title)).toBe(false) + } + ) +}) diff --git a/src/shared/shell-process-detection.ts b/src/shared/shell-process-detection.ts index fe032f0dffbb..0afb2850283c 100644 --- a/src/shared/shell-process-detection.ts +++ b/src/shared/shell-process-detection.ts @@ -24,10 +24,19 @@ export function isShellProcess(processName: string): boolean { ) } -/** A shell name or the tab's neutral default title; blank titles are no evidence. */ +// Why: Git for Windows' prompt titles the pane `$MSYSTEM:$PWD` (`MINGW64:/c/repo`), and Git Bash +// has no command marks, so that repaint is the only sign an agent there handed back to the shell. +const MSYS_PROMPT_TITLE_RE = /^(?:MINGW(?:32|64)|MSYS|UCRT64|CLANG(?:32|64|ARM64)):\// + +/** A shell name, a Git Bash prompt title, or the tab's neutral default title; blank is no evidence. */ export function titleShowsNoAgent(title: string, defaultTitle?: string): boolean { const trimmed = title.trim() - return trimmed.length > 0 && (isShellProcess(trimmed) || trimmed === defaultTitle?.trim()) + return ( + trimmed.length > 0 && + (isShellProcess(trimmed) || + MSYS_PROMPT_TITLE_RE.test(trimmed) || + trimmed === defaultTitle?.trim()) + ) } // Why: a ConPTY-side buffer clear cannot reach PSReadLine's cached cursor From bfd5959b79a22eec690c2951872444becd9fb101 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:09:56 -0700 Subject: [PATCH 05/11] fix(sidebar): trust only process-read agents for plain-title rows; per-worktree foreground selector groups by tab A daemon reattach seeds the pane's foreground entry with its launch agent, which can outlive the process while Orca is closed. The entry now records where its agent came from (agentEvidence), and the sidebar/dashboard title-derived rows only keep a plain-title row on an actual process read. Routing and the tab icon are unchanged. selectPaneForegroundAgentsForWorktree grouped every pane key per worktree; it now groups by tab once per map identity and skips worktrees with no tabs. --- ...uild-dashboard-snapshot.rows-cache.test.ts | 4 +- .../title-derived-pane-agent-identity.ts | 21 ++++-- .../worktree-card-status-inputs.test.ts | 34 ++++++++++ .../sidebar/worktree-card-status-inputs.ts | 46 +++++++++++-- .../worktree-title-derived-agent-rows.test.ts | 50 ++++++++++---- .../pane-foreground-agent-tracker.test.ts | 67 ++++++++++++++++--- .../pane-foreground-agent-tracker.ts | 1 + .../parked-terminal-command-status.ts | 4 +- ...onnection-command-finished-cleanup.test.ts | 1 + ...onnection-foreground-agent-routing.test.ts | 5 ++ ...nnection-foreground-agent-sampling.test.ts | 7 ++ ...on-foreground-routing-confirmation.test.ts | 1 + .../pty-connection/pane-agent-identity.ts | 3 + .../pty-connection/sleeping-record-access.ts | 1 + .../slices/pane-foreground-agent.test.ts | 20 ++++++ .../src/store/slices/pane-foreground-agent.ts | 7 ++ 16 files changed, 236 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts index cdf7bc2c1ea1..f8c094acd5d1 100644 --- a/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts +++ b/src/renderer/src/components/dashboard/build-dashboard-snapshot.rows-cache.test.ts @@ -126,7 +126,9 @@ describe('buildDashboardSnapshot rows cache', () => { const codexStarted: DashboardSnapshotState = { ...state, - paneForegroundAgentByPaneKey: { [PANE_2]: { agent: 'codex', shellForeground: false } } + paneForegroundAgentByPaneKey: { + [PANE_2]: { agent: 'codex', agentEvidence: 'process-read', shellForeground: false } + } } const cached = buildDashboardSnapshot(codexStarted, NOW + 500, { rowsCache: cache, diff --git a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts index 415ff34bf06d..a49c9b897b8d 100644 --- a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts +++ b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts @@ -49,11 +49,15 @@ export function resolveTitleDerivedAgentType( } /** The pane's foreground-process read as the tracker publishes it; routing fields are omitted. */ -export type TitleDerivedPaneForeground = Pick +export type TitleDerivedPaneForeground = Pick< + PaneForegroundAgentEntry, + 'agent' | 'agentEvidence' | 'shellForeground' +> /** - * Which agent a hook-less pane runs, in the tab icon's order: the foreground process, then the - * title, then the agent Orca launched. Null means the pane shows no agent row. + * Which agent a hook-less pane runs: a live process read (unless the title is a shell's), then the + * title, then the agent Orca launched while the title shows activity. Sidebar-only; the tab icon + * orders its signals differently. Null means the pane shows no agent row. * * Only the process read may keep a row whose title shows no agent activity (Codex retitles itself * to the project name, #23767): the mounted pane's tracker re-derives it at every command @@ -69,10 +73,13 @@ export function resolveTitleDerivedPaneAgent(args: { launchAgentType: AgentType | null foreground: TitleDerivedPaneForeground | undefined }): AgentType | null { - // Why: a shell/default title is exit evidence the process read may not have caught up with. - const processAgent = titleShowsNoAgent(args.title, args.defaultTitle) - ? null - : args.foreground?.agent + // Why: a shell/default title is exit evidence the process read may not have caught up with; a + // reattach's launch record is not a read at all and can name an agent that already exited. + const processAgent = + args.foreground?.agentEvidence !== 'process-read' || + titleShowsNoAgent(args.title, args.defaultTitle) + ? null + : args.foreground.agent // Why: OMP's nested pi process must not take an OMP-launched pane from its owner. const ownedProcessAgent = processAgent ? (resolveCompatibleAgentTypeForOwner(processAgent, args.launchAgentType) ?? processAgent) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index f3c216add911..074ddb7c9ba2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -251,4 +251,38 @@ describe('worktree card status input selectors', () => { EMPTY_PANE_FOREGROUND_AGENTS ) }) + + it('groups foreground reads by tab, including split panes, and skips worktrees with no tabs', () => { + const splitLeft = 'tab-1:11111111-1111-4111-8111-111111111111' + const splitRight = 'tab-1:33333333-3333-4333-8333-333333333333' + const ownSecondTab = 'tab-3:44444444-4444-4444-8444-444444444444' + const state: Parameters[0] = { + tabsByWorktree: { + wt1: [makeTab('tab-1', 'wt1'), makeTab('tab-3', 'wt1')], + wt2: [makeTab('tab-2', 'wt2')], + empty: [] + }, + paneForegroundAgentByPaneKey: { + [splitLeft]: { agent: 'codex', shellForeground: false }, + 'tab-2:22222222-2222-4222-8222-222222222222': { agent: 'claude', shellForeground: false }, + [splitRight]: { agent: null, shellForeground: true }, + [ownSecondTab]: { agent: 'gemini', shellForeground: false }, + 'not-a-pane-key': { agent: 'grok', shellForeground: false } + } + } + + const selected = selectPaneForegroundAgentsForWorktree(state, 'wt1') + expect(selected).toEqual({ + [splitLeft]: state.paneForegroundAgentByPaneKey?.[splitLeft], + [splitRight]: state.paneForegroundAgentByPaneKey?.[splitRight], + [ownSecondTab]: state.paneForegroundAgentByPaneKey?.[ownSecondTab] + }) + expect(Object.keys(selectPaneForegroundAgentsForWorktree(state, 'wt2'))).toEqual([ + 'tab-2:22222222-2222-4222-8222-222222222222' + ]) + expect(selectPaneForegroundAgentsForWorktree(state, 'empty')).toBe(EMPTY_PANE_FOREGROUND_AGENTS) + expect( + selectPaneForegroundAgentsForWorktree({ ...state, paneForegroundAgentByPaneKey: {} }, 'wt1') + ).toBe(EMPTY_PANE_FOREGROUND_AGENTS) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index baf4ae0bc382..e5377a3afea2 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -67,6 +67,41 @@ export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector< } }) +type PaneForegroundAgentsByTabId = ReadonlyMap< + string, + readonly [string, PaneForegroundAgentEntry][] +> + +// Why: grouped once per map identity so each card walks only its own tabs, not every pane key. +const paneForegroundAgentsByTabIdCache = new WeakMap< + Record, + PaneForegroundAgentsByTabId +>() + +function getPaneForegroundAgentsByTabId( + entries: Record +): PaneForegroundAgentsByTabId { + const cached = paneForegroundAgentsByTabIdCache.get(entries) + if (cached) { + return cached + } + const byTabId = new Map() + for (const [paneKey, entry] of Object.entries(entries)) { + const tabId = parsePaneKey(paneKey)?.tabId + if (!tabId) { + continue + } + const group = byTabId.get(tabId) + if (group) { + group.push([paneKey, entry]) + } else { + byTabId.set(tabId, [[paneKey, entry]]) + } + } + paneForegroundAgentsByTabIdCache.set(entries, byTabId) + return byTabId +} + /** This worktree's pane foreground-process reads, keyed by pane key. */ export const selectPaneForegroundAgentsForWorktree = createWorktreeRecordSelector< WorktreeCardForegroundInputState, @@ -75,11 +110,14 @@ export const selectPaneForegroundAgentsForWorktree = createWorktreeRecordSelecto readSources: (state) => [state.tabsByWorktree, state.paneForegroundAgentByPaneKey], empty: EMPTY_PANE_FOREGROUND_AGENTS, build: (state, worktreeId) => { - const tabIds = new Set((state.tabsByWorktree[worktreeId] ?? []).map((tab) => tab.id)) + const tabs = state.tabsByWorktree[worktreeId] + if (!tabs?.length || !state.paneForegroundAgentByPaneKey) { + return EMPTY_PANE_FOREGROUND_AGENTS + } + const byTabId = getPaneForegroundAgentsByTabId(state.paneForegroundAgentByPaneKey) const out: Record = {} - for (const [paneKey, entry] of Object.entries(state.paneForegroundAgentByPaneKey ?? {})) { - const tabId = parsePaneKey(paneKey)?.tabId - if (tabId && tabIds.has(tabId)) { + for (const tab of tabs) { + for (const [paneKey, entry] of byTabId.get(tab.id) ?? []) { out[paneKey] = entry } } diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index d98215e45ebe..c82ec1c947b7 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -4,6 +4,7 @@ import type { TerminalLayoutSnapshot, TerminalTab } from '../../../../shared/ter import type { TuiAgent } from '../../../../shared/tui-agent' import { makePaneKey } from '../../../../shared/stable-pane-id' import { buildWorktreeAgentRows } from './worktree-agent-rows' +import type { TitleDerivedPaneForeground } from './title-derived-pane-agent-identity' const LEAF_ID_1 = '77777777-7777-4777-8777-777777777777' const LEAF_ID_2 = '88888888-8888-4888-8888-888888888888' @@ -43,6 +44,10 @@ function makeSingleLayout(leafId: string): TerminalLayoutSnapshot { } } +function processRead(agent: TuiAgent): TitleDerivedPaneForeground { + return { agent, agentEvidence: 'process-read', shellForeground: false } +} + describe('buildTitleDerivedAgentRows', () => { it('adds title-derived rows for live agent panes that have no hook status yet', () => { const rows = buildWorktreeAgentRows({ @@ -372,7 +377,7 @@ describe('buildTitleDerivedAgentRows', () => { const rowsFor = ( title: string, launchAgent?: TuiAgent, - foreground?: { agent: TuiAgent | null; shellForeground: boolean } + foreground?: TitleDerivedPaneForeground ) => buildWorktreeAgentRows({ tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})], @@ -392,9 +397,7 @@ describe('buildTitleDerivedAgentRows', () => { // a latch with no run id, so the title outranks it even before any process read. expect(rowsFor('✳ Claude Code', 'opencode').map((row) => row.agentType)).toEqual(['claude']) expect( - rowsFor('✳ Claude Code', 'opencode', { agent: 'claude', shellForeground: false }).map( - (row) => row.agentType - ) + rowsFor('✳ Claude Code', 'opencode', processRead('claude')).map((row) => row.agentType) ).toEqual(['claude']) // No owner to defend the pane: naming Claude stays the only available identity. expect(rowsFor('⠋ use Claude Sonnet').map((row) => row.agentType)).toEqual(['claude']) @@ -424,7 +427,7 @@ describe('hook-less agent rows identified by the foreground process', () => { function rowsFor(args: { title: string launchAgent?: TuiAgent - foreground?: { agent: TuiAgent | null; shellForeground: boolean } + foreground?: TitleDerivedPaneForeground ptyIds?: string[] layout?: TerminalLayoutSnapshot }) { @@ -449,7 +452,7 @@ describe('hook-less agent rows identified by the foreground process', () => { rows.map((row) => [row.agentType, row.state, row.entry.prompt, row.entry.lastAssistantMessage]) it('keeps a launched Codex row when Codex retitles the pane to the project name', () => { - const foreground = { agent: 'codex' as const, shellForeground: false } + const foreground = processRead('codex') expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex', foreground }))).toEqual([ ['codex', 'idle', 'Codex', 'Idle'] ]) @@ -471,9 +474,32 @@ describe('hook-less agent rows identified by the foreground process', () => { ).toHaveLength(0) }) + it("does not trust a reattach's launch record as a process read", () => { + // Reattach seeds the daemon's launch agent, which can outlive the process while Orca is + // closed; a background pane is not re-read until it is shown. + const launchSeed: TitleDerivedPaneForeground = { + agent: 'codex', + agentEvidence: 'launch-record', + shellForeground: false + } + expect(rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground: launchSeed })).toEqual( + [] + ) + expect(rowsFor({ title: 'demo-repo', foreground: launchSeed })).toEqual([]) + expect( + rowsFor({ title: 'demo-repo', foreground: { ...launchSeed, agentEvidence: undefined } }) + ).toEqual([]) + // Once a real read confirms the same agent, the row comes back. + expect( + summarize( + rowsFor({ title: 'demo-repo', launchAgent: 'codex', foreground: processRead('codex') }) + ) + ).toEqual([['codex', 'idle', 'Codex', 'Idle']]) + }) + it('rows a hand-typed agent from its foreground process, whatever its title says', () => { for (const agent of ['codex', 'claude', 'gemini', 'opencode', 'grok'] as const) { - const rows = rowsFor({ title: 'demo-repo', foreground: { agent, shellForeground: false } }) + const rows = rowsFor({ title: 'demo-repo', foreground: processRead(agent) }) expect(rows.map((row) => [row.paneKey, row.agentType, row.state])).toEqual([ [PANE_KEY, agent, 'idle'] ]) @@ -489,7 +515,7 @@ describe('hook-less agent rows identified by the foreground process', () => { ptyIdsByTabId: { 'tab-1': ['pty-left', 'pty-right'] }, terminalLayoutsByTabId: { 'tab-1': makeSplitLayout() }, paneForegroundAgentByPaneKey: { - [makePaneKey('tab-1', LEAF_ID_2)]: { agent: 'codex', shellForeground: false } + [makePaneKey('tab-1', LEAF_ID_2)]: processRead('codex') }, now: 2000 }) @@ -500,7 +526,7 @@ describe('hook-less agent rows identified by the foreground process', () => { }) it('lets the title drive activity without deciding who the agent is', () => { - const foreground = { agent: 'codex' as const, shellForeground: false } + const foreground = processRead('codex') expect(summarize(rowsFor({ title: '⠋ demo-repo', foreground }))).toEqual([ ['codex', 'working', 'Codex', 'Running'] ]) @@ -527,7 +553,7 @@ describe('hook-less agent rows identified by the foreground process', () => { rowsFor({ title: 'zsh', launchAgent: 'codex', - foreground: { agent: 'codex', shellForeground: false } + foreground: processRead('codex') }) ).toHaveLength(0) expect(rowsFor({ title: 'Terminal 1', launchAgent: 'codex' })).toHaveLength(0) @@ -535,7 +561,7 @@ describe('hook-less agent rows identified by the foreground process', () => { expect( rowsFor({ title: 'MINGW64:/c/Users/dev/demo-repo', - foreground: { agent: 'codex', shellForeground: false } + foreground: processRead('codex') }) ).toHaveLength(0) // The PTY exited. @@ -543,7 +569,7 @@ describe('hook-less agent rows identified by the foreground process', () => { rowsFor({ title: 'demo-repo', launchAgent: 'codex', - foreground: { agent: 'codex', shellForeground: false }, + foreground: processRead('codex'), ptyIds: [] }) ).toHaveLength(0) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index 50bbb993bce6..3021132c3157 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts @@ -195,7 +195,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(COMMAND_SETTLE_MS) expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('drops a delayed foreground result after the pane rebinds to another PTY', async () => { @@ -215,7 +219,11 @@ describe('createPaneForegroundAgentTracker', () => { await Promise.resolve() await Promise.resolve() - expect(publish).not.toHaveBeenCalledWith({ agent: 'droid', shellForeground: false }) + expect(publish).not.toHaveBeenCalledWith({ + agent: 'droid', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('uses typed-agent text only to await process confirmation', async () => { @@ -230,6 +238,7 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) expect(publish).toHaveBeenLastCalledWith({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -282,7 +291,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(VISIBLE_PTY_SETTLE_MS) expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('does not retry or publish visible PTY reads for an idle shell foreground', async () => { @@ -307,7 +320,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(VISIBLE_PTY_SETTLE_MS) expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('does not let visible PTY sampling downgrade pending command-start sampling', async () => { @@ -321,7 +338,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) expect(readForegroundProcess).toHaveBeenCalledTimes(2) - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => { @@ -335,7 +356,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) expect(readForegroundProcess).toHaveBeenCalledTimes(2) - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => { @@ -357,7 +382,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(SECOND_WRAPPER_RETRY_MS) expect(readForegroundProcess).toHaveBeenCalledTimes(3) - expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('stops after the ladder and publishes no identity for a persistent unknown process', async () => { @@ -398,7 +427,11 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(COMMAND_SETTLE_MS) await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) - expect(publish).toHaveBeenLastCalledWith({ agent: 'gemini', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'gemini', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('marks shell foreground on command finished without any foreground read', () => { @@ -472,6 +505,7 @@ describe('createPaneForegroundAgentTracker', () => { await flushSettleRead(COMMAND_SETTLE_MS) expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', + agentEvidence: 'process-read', shellForeground: false }) @@ -485,6 +519,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -567,6 +602,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -619,6 +655,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).toHaveBeenCalledTimes(2) expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -637,6 +674,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).toHaveBeenCalledTimes(2) expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -676,6 +714,7 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).toHaveBeenCalledTimes(3) expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -749,8 +788,16 @@ describe('createPaneForegroundAgentTracker', () => { resolveFirstRead('claude') await flushSettleRead(COMMAND_SETTLE_MS) - expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) - expect(publish).not.toHaveBeenCalledWith({ agent: 'claude', shellForeground: false }) + expect(publish).toHaveBeenLastCalledWith({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) + expect(publish).not.toHaveBeenCalledWith({ + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + }) }) it('stops publishing after dispose', async () => { diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index d831226267d0..a4881e02e09d 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -169,6 +169,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke hasAgentExpectation = false deps.publish({ agent: recognized.agent, + agentEvidence: 'process-read', shellForeground: false, ...(requiresRoutingConfirmation ? { routingTrusted: true } : {}) }) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts index ef780931ec0c..6a732c8e43d9 100644 --- a/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts +++ b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.ts @@ -152,8 +152,8 @@ export function createParkedTerminalCommandStatusPolicy(options: { settleCommandCodeDone ) - // Why: a full-screen agent's nested shells leak 133;D, so confirm like the mounted ladder does - // and retire the process read only on a shell or no answer; reveal re-reads it anyway. + // Why: a full-screen agent's nested shells leak 133;D, so make one confirming read (no retry + // ladder, unlike the mounted tracker) and retire the process read only on a shell or no answer. const retireForegroundAgentUnlessConfirmed = async (): Promise => { const entry = useAppStore.getState().paneForegroundAgentByPaneKey[paneKey] if (!entry?.agent) { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 45a603f9e7fb..4a1979aa02d5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -286,6 +286,7 @@ describe('connectPanePty', () => { expect(mockStoreState.clearAgentLaunchConfig).not.toHaveBeenCalled() expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts index 8ec26ef0ceac..89a0ece8168e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts @@ -287,6 +287,7 @@ describe('connectPanePty', () => { await vi.advanceTimersByTimeAsync(1200) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -318,6 +319,7 @@ describe('connectPanePty', () => { expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -358,6 +360,7 @@ describe('connectPanePty', () => { expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -394,6 +397,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -440,6 +444,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[paneKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index 50e1b4a0f8b9..f2afed8a81ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts @@ -274,6 +274,7 @@ describe('connectPanePty', () => { expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { agent: 'codex', + agentEvidence: 'process-read', shellForeground: false }) }) @@ -416,6 +417,7 @@ describe('connectPanePty', () => { expect(window.api.pty.confirmForegroundProcess).toHaveBeenCalledWith(ptyId) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -425,6 +427,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -498,6 +501,7 @@ describe('connectPanePty', () => { expect(mockStoreState.registerAgentLaunchConfig).not.toHaveBeenCalled() expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'launch-record', shellForeground: false }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') @@ -505,6 +509,7 @@ describe('connectPanePty', () => { binding.sampleForegroundAgentOnFocus() expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'launch-record', shellForeground: false }) expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('alt-enter') @@ -513,6 +518,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) @@ -809,6 +815,7 @@ describe('connectPanePty', () => { }) expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts index fd46a6232422..5704fc828f0e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts @@ -359,6 +359,7 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'codex', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index 2c86932c499d..b9ea1c9c4c83 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -202,6 +202,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { } useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { agent: foreground.agent, + agentEvidence: foreground.agentEvidence, routingRevoked: true, shellForeground: foreground.shellForeground }) @@ -300,6 +301,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // as a hint, but revoke bytes until one current provider confirmation lands. useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { agent: foreground.agent, + agentEvidence: foreground.agentEvidence, routingRevoked: true, shellForeground: false }) @@ -311,6 +313,7 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { if (session.paneForegroundAgentTracker.hasReadInFlight()) { useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { agent: foreground.agent, + agentEvidence: foreground.agentEvidence, routingRevoked: true, shellForeground: false, routingConfirmationPending: true diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index c756a29e80a5..73cb8e9fc207 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -158,6 +158,7 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi // closed. Use it to request confirmation, never as current byte authority. useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { agent: metadata.launchAgent, + agentEvidence: 'launch-record', shellForeground: false }) } diff --git a/src/renderer/src/store/slices/pane-foreground-agent.test.ts b/src/renderer/src/store/slices/pane-foreground-agent.test.ts index d878c8cef3f1..edbbf9ac8fa0 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.test.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.test.ts @@ -70,6 +70,26 @@ describe('pane foreground agent slice', () => { }) }) + // Why: a reattach seeds the launch agent; the read confirming the same agent must still land, + // or sidebar rows would never treat the pane as process-backed. + it('lands a process read that confirms a launch-record agent', () => { + const store = createTestStore() + store.getState().setPaneForegroundAgent('tab-1:leaf-1', { + agent: 'codex', + agentEvidence: 'launch-record', + shellForeground: false + }) + store.getState().setPaneForegroundAgent('tab-1:leaf-1', { + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) + + expect(store.getState().paneForegroundAgentByPaneKey['tab-1:leaf-1']?.agentEvidence).toBe( + 'process-read' + ) + }) + it('sweeps only the closed tab prefix, not sibling tabs or prefix-share ids', () => { const store = createTestStore() store diff --git a/src/renderer/src/store/slices/pane-foreground-agent.ts b/src/renderer/src/store/slices/pane-foreground-agent.ts index 73685b6b88b6..dcdb4edd3706 100644 --- a/src/renderer/src/store/slices/pane-foreground-agent.ts +++ b/src/renderer/src/store/slices/pane-foreground-agent.ts @@ -2,9 +2,15 @@ import type { StateCreator } from 'zustand' import type { AppState } from '../types' import type { TuiAgent } from '../../../../shared/tui-agent' +/** 'launch-record': the daemon's launch identity on reattach, which can outlive the process. */ +export type PaneForegroundAgentEvidence = 'process-read' | 'launch-record' + export type PaneForegroundAgentEntry = { /** Recognized agent process in the pane's foreground; null when unknown. */ agent: TuiAgent | null + /** Where `agent` came from. Only 'process-read' is a live read; absent counts as unconfirmed, + * so a writer that omits it cannot make a sidebar row look process-backed. */ + agentEvidence?: PaneForegroundAgentEvidence /** True only when fresh provider evidence is safe for input-byte routing. */ routingTrusted?: boolean /** True after exit/input evidence revokes routing until provider confirmation. */ @@ -46,6 +52,7 @@ export const createPaneForegroundAgentSlice: StateCreator< if ( current && current.agent === entry.agent && + current.agentEvidence === entry.agentEvidence && current.routingTrusted === entry.routingTrusted && current.routingRevoked === entry.routingRevoked && current.routingConfirmationPending === entry.routingConfirmationPending && From 2cc137855f219e7c3727c8d2969e1867389afb00 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:20:49 -0700 Subject: [PATCH 06/11] fix(sidebar): a parked pane's reattach keeps its own process read of the same agent The reattach seed marked a returning parked Codex pane as launch-record evidence, over the process read this session already took, so its row blinked out on reveal and stayed hidden if the user left the tab before the visible read landed. Keep the read when it names the same agent; the seed still drops byte-routing trust. --- ...nnection-foreground-agent-sampling.test.ts | 53 +++++++++++++++++++ .../pty-connection/sleeping-record-access.ts | 9 +++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts index f2afed8a81ef..f37de9954046 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-sampling.test.ts @@ -525,6 +525,59 @@ describe('connectPanePty', () => { expect(resolveMockPaneWindowsShiftEnterEncoding(mockStoreState, cacheKey)).toBe('csi-u') }) + it("keeps this session's process read when a parked pane re-attaches to the same agent", async () => { + vi.useFakeTimers() + const ptyId = 'pty-parked-reattach-keeps-read' + const tabId = `tab-${ptyId}` + const cacheKey = makePaneKey(tabId, LEAF_1) + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'codex', + agentEvidence: 'process-read', + routingTrusted: true, + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchAgent: 'codex' + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'codex', + agentEvidence: 'process-read', + shellForeground: false + }) + }) + + it('seeds a launch record when the prior read named a different agent', async () => { + vi.useFakeTimers() + const ptyId = 'pty-parked-reattach-other-agent' + const tabId = `tab-${ptyId}` + const cacheKey = makePaneKey(tabId, LEAF_1) + mockStoreState.tabsByWorktree = { 'wt-1': [{ id: tabId, ptyId }] } + mockStoreState.paneForegroundAgentByPaneKey[cacheKey] = { + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ + ptyId, + tabId, + isVisibleRef: { current: false }, + launchAgent: 'codex' + }) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: 'codex', + agentEvidence: 'launch-record', + shellForeground: false + }) + }) + it('retires stale daemon launch identity when warm reattach finds the shell', async () => { vi.useFakeTimers() vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('powershell.exe') diff --git a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts index 73cb8e9fc207..ee038234a05b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/sleeping-record-access.ts @@ -156,9 +156,14 @@ export function installSleepingRecordAccess(session: ConnectPanePtySession): voi if (metadata?.launchAgent) { // Why: daemon launch identity can outlive the process while Orca is // closed. Use it to request confirmation, never as current byte authority. - useAppStore.getState().setPaneForegroundAgent(session.cacheKey, { + const state = useAppStore.getState() + const current = state.paneForegroundAgentByPaneKey[session.cacheKey] + // Why: re-mounting a parked pane must not demote this session's own read of the same agent. + const keepsRead = + current?.agent === metadata.launchAgent && current.agentEvidence === 'process-read' + state.setPaneForegroundAgent(session.cacheKey, { agent: metadata.launchAgent, - agentEvidence: 'launch-record', + agentEvidence: keepsRead ? 'process-read' : 'launch-record', shellForeground: false }) } From e840cacb5598a98040f94370d4f22677c0e8b906 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:35:39 -0700 Subject: [PATCH 07/11] test(terminal): foreground confirmation publishes process-read evidence --- tests/e2e/terminal-foreground-confirmation.unit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e/terminal-foreground-confirmation.unit.test.ts b/tests/e2e/terminal-foreground-confirmation.unit.test.ts index 04873836a341..90124121f36d 100644 --- a/tests/e2e/terminal-foreground-confirmation.unit.test.ts +++ b/tests/e2e/terminal-foreground-confirmation.unit.test.ts @@ -93,6 +93,7 @@ describe('daemon foreground confirmation composes with pane tracking', () => { expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) From 485c04dbae06cc4e4b7f0df37903eb26d51a9fed Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:48:54 -0700 Subject: [PATCH 08/11] fix(sidebar): a cleared pane title retires the agent's process read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex clears its title when it exits, and the tab then shows its default title. A pane without shell command marks never re-reads its foreground process, so the retained read kept a "Codex · Idle" row after /quit (permanently for a hand-typed Codex; about 15 s while the marked-pane confirm ladder ran). Treat a blank title like the default title it shows. --- .../sidebar/title-derived-pane-agent-identity.ts | 16 ++++++++++------ .../worktree-title-derived-agent-rows.test.ts | 5 +++++ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts index a49c9b897b8d..13b558223425 100644 --- a/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts +++ b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts @@ -54,16 +54,21 @@ export type TitleDerivedPaneForeground = Pick< 'agent' | 'agentEvidence' | 'shellForeground' > +// Why: Codex clears its title on exit (OSC 0 with no text) and the tab then shows its default title. +function titleRetiresProcessRead(args: { title: string; defaultTitle?: string }): boolean { + return args.title.trim().length === 0 || titleShowsNoAgent(args.title, args.defaultTitle) +} + /** * Which agent a hook-less pane runs: a live process read (unless the title is a shell's), then the * title, then the agent Orca launched while the title shows activity. Sidebar-only; the tab icon * orders its signals differently. Null means the pane shows no agent row. * * Only the process read may keep a row whose title shows no agent activity (Codex retitles itself - * to the project name, #23767): the mounted pane's tracker re-derives it at every command - * boundary and clears it when the shell returns. The launch record is a tab-scoped latch with no - * run id, so it stays a fallback for titles that show activity, and ranks below a title that - * names a different agent (pane reuse). + * to the project name, #23767): the mounted pane's tracker re-derives it at command boundaries, + * which a pane without shell command marks never emits, so exit titles must still retire it. The + * launch record is a tab-scoped latch with no run id, so it stays a fallback for titles that show + * activity, and ranks below a title that names a different agent (pane reuse). */ export function resolveTitleDerivedPaneAgent(args: { title: string @@ -76,8 +81,7 @@ export function resolveTitleDerivedPaneAgent(args: { // Why: a shell/default title is exit evidence the process read may not have caught up with; a // reattach's launch record is not a read at all and can name an agent that already exited. const processAgent = - args.foreground?.agentEvidence !== 'process-read' || - titleShowsNoAgent(args.title, args.defaultTitle) + args.foreground?.agentEvidence !== 'process-read' || titleRetiresProcessRead(args) ? null : args.foreground.agent // Why: OMP's nested pi process must not take an OMP-launched pane from its owner. diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts index c82ec1c947b7..e609974512d5 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.test.ts @@ -564,6 +564,11 @@ describe('hook-less agent rows identified by the foreground process', () => { foreground: processRead('codex') }) ).toHaveLength(0) + // Codex clears its title on exit; a pane without command marks never re-reads the process. + expect(rowsFor({ title: '', foreground: processRead('codex') })).toHaveLength(0) + expect( + rowsFor({ title: ' ', launchAgent: 'codex', foreground: processRead('codex') }) + ).toHaveLength(0) // The PTY exited. expect( rowsFor({ From 81a25b8f65609256c3c8bc8bf039648ab8e34e01 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:45:34 -0700 Subject: [PATCH 09/11] fix(sidebar): the pane's process monitor retires an exited agent's process read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hook-less pane keeps its sidebar row from the tracker's foreground-process read, but nothing re-derived that read in a pane without OSC 133 command marks. After Codex exited there, a "Codex · Idle" row stayed: permanently when the shell titles its prompt, or when a killed Codex leaves its last title. The pane's agent-completion process monitor already confirms an agent's exit (no agent and no child processes, held past its settle window). It now reports that exit to the tracker, which retires its own process read and runs the confirmed-shell path the visible-pty read uses. A tracker read that names an agent seeds the monitor, so hidden panes and panes the monitor had not polled yet are watched too. A command read in flight still decides the pane, and launch records or other agents' reads are left alone. --- .../agent-completion-coordinator-types.ts | 4 + .../agent-completion-coordinator.ts | 1 + .../agent-completion-inspection-result.ts | 4 + .../agent-completion-process-monitor.ts | 5 + .../pane-foreground-agent-tracker.ts | 65 ++++-- ...ane-foreground-process-exit-retire.test.ts | 163 +++++++++++++++ ...onnection-command-finished-cleanup.test.ts | 6 +- ...onnection-foreground-agent-routing.test.ts | 2 + ...connection-foreground-process-exit.test.ts | 190 ++++++++++++++++++ .../pty-connection/pane-agent-identity.ts | 7 +- .../pty-connection/terminal-keydown-fit.ts | 3 + 11 files changed, 430 insertions(+), 20 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts index 4e7e07f5c7c7..c56e77b5bc7b 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator-types.ts @@ -42,6 +42,8 @@ export type AgentCompletionCoordinatorOptions = { replacement: RecognizedAgentProcess ) => boolean shouldSuppressConfirmedProcessExitCompletion?: (exited: RecognizedAgentProcess) => boolean + /** Fired once per confirmed exit (settled local absence, or the host's `exited` verdict). */ + onForegroundAgentExited?: (exited: RecognizedAgentProcess) => void isLive: () => boolean shouldPollProcessCadence?: () => boolean // Why: a host that publishes foreground evidence with its inventory lets a @@ -65,6 +67,8 @@ export type AgentCompletionCoordinator = { observeHookStatus: (payload: AgentCompletionStatusSnapshot) => void seedHookStatus: (payload: AgentCompletionStatusSnapshot) => void startProcessTracking: () => void + /** Another reader saw this agent in the foreground; lets the monitor confirm its exit. */ + observeForegroundAgentProcess: (process: RecognizedAgentProcess) => void hasPendingHookDoneCompletion: () => boolean resetCompletionState: (options?: { requireFreshWorking?: boolean }) => void dispose: () => void diff --git a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts index 9b60b37ff1d3..37b4430a9b68 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-coordinator.ts @@ -316,6 +316,7 @@ export function createAgentCompletionCoordinator( observeHookStatus: hookObserver.observeHookStatus, seedHookStatus: hookObserver.seedHookStatus, startProcessTracking: () => processMonitor.start(), + observeForegroundAgentProcess: processMonitor.observeRecognizedProcess, hasPendingHookDoneCompletion: lifecycle.hasPendingHookDoneCompletion, resetCompletionState: lifecycle.resetCompletionState, dispose: lifecycle.dispose diff --git a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts index 1ee528402794..9d47a3f60fa3 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-inspection-result.ts @@ -113,6 +113,9 @@ export function handleAgentCompletionInspectionResult(args: { } state.lastForegroundAgent = null clearAgentRunEvidence() + if (exited) { + options.onForegroundAgentExited?.(exited) + } return false } if (admitted.verdict !== 'live') { @@ -188,6 +191,7 @@ export function handleAgentCompletionInspectionResult(args: { } state.lastForegroundAgent = null clearAgentRunEvidence() + options.onForegroundAgentExited?.(exited) } else { state.lastForegroundAgent = null clearAgentRunEvidence() diff --git a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts index 6382cee82add..fd4d1f448d21 100644 --- a/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts +++ b/src/renderer/src/components/terminal-pane/agent-completion-process-monitor.ts @@ -184,6 +184,11 @@ export function createAgentCompletionProcessMonitor({ requestInspection, scheduleNextPoll, clearPollTimer, + observeRecognizedProcess: (process: RecognizedAgentProcess) => { + if (!state.disposed) { + handleRecognizedProcess(process) + } + }, start: () => { state.pollTrackingStarted = true scheduleNextPoll() diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index a4881e02e09d..26fe1782c06a 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -1,7 +1,9 @@ import { isAgentForegroundWrapperProcess, - recognizeAgentProcess + recognizeAgentProcess, + type RecognizedAgentProcess } from '../../../../shared/agent-process-recognition' +import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' import { isShellProcess } from '../../../../shared/shell-process-detection' import type { TuiAgent } from '../../../../shared/tui-agent' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' @@ -33,6 +35,10 @@ type PaneForegroundAgentTrackerDeps = { isRemotePtyId?: (ptyId: string) => boolean getExpectedIncarnationId?: () => string | null publish: (entry: PaneForegroundAgentEntry) => void + /** The entry currently published for this pane, whoever wrote it. */ + getPublishedEntry?: () => PaneForegroundAgentEntry | undefined + /** A local read recognized an agent; lets the pane's process monitor watch for its exit. */ + onAgentProcessRead?: (process: RecognizedAgentProcess) => void /** True when the pane is otherwise known to run an agent (launchAgent, live * hook status). Lets a restored agent pane confirm — rather than trust — a * 133;D before any command-start read has recorded its own evidence. */ @@ -40,7 +46,7 @@ type PaneForegroundAgentTrackerDeps = { /** Fired when a confirming read proves the foreground genuinely returned to a * shell (agent exited). Lets callers clear a stale agent-named tab title that * the shell never repaints. */ - onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished') => void + onConfirmedShellForeground?: (reason: 'visible-pty' | 'command-finished' | 'process-exit') => void onCommandFinishedUnavailable?: () => void onVisibleForegroundSettled?: (outcome: 'agent' | 'shell' | 'inconclusive') => void } @@ -60,6 +66,8 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke onCommandStarted: (expectedAgent?: TuiAgent | null) => void /** True when pane identity must remain visible until an async shell confirmation. */ onCommandFinished: () => boolean + /** The process monitor confirmed this agent exited (no agent, no children, settled). */ + onProcessExitConfirmed: (process: RecognizedAgentProcess) => void dispose: () => void } { let disposed = false @@ -76,6 +84,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke let hasKnownAgentEvidence = false let hasAgentExpectation = false const readProcess = createPaneForegroundProcessReader(deps) + const clearAgentEvidence = (): void => { + hasForegroundAgentEvidence = false + hasKnownAgentEvidence = false + hasAgentExpectation = false + } const trackablePtyId = (): string | null => { const ptyId = deps.getPtyId() @@ -112,6 +125,11 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } const hasPendingRead = (): boolean => scheduledReadReason !== null || activeReadReason !== null + const hasCommandReadInFlight = (): boolean => + scheduledReadReason === 'command' || + activeReadReason === 'command' || + scheduledReadReason === 'command-finished' || + activeReadReason === 'command-finished' // Why: the store entry outlives this tracker, so any capability a caller retained // pending a read must be released by whichever exit ends that read. A superseded @@ -173,6 +191,10 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke shellForeground: false, ...(requiresRoutingConfirmation ? { routingTrusted: true } : {}) }) + // Why: nothing else re-derives this read in a pane without command marks. + if (!remote) { + deps.onAgentProcessRead?.(recognized) + } if (reason === 'visible-pty') { deps.onVisibleForegroundSettled?.('agent') } @@ -213,9 +235,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke processName !== null && isShellProcess(processName) ) { - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false + clearAgentEvidence() deps.publish({ agent: null, shellForeground: true }) deps.onConfirmedShellForeground?.(reason) deps.onVisibleForegroundSettled?.('shell') @@ -232,9 +252,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke } // Why: client-only unverifiable inspection is not confirmed shell evidence; retire // stale routing after the bounded D ladder without asserting shell truth. - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false + clearAgentEvidence() deps.publish({ agent: null, shellForeground: false }) deps.onCommandFinishedUnavailable?.() return @@ -249,9 +267,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // Why: the 133;D fired AND the foreground shows no agent — together that is // real prompt proof, so the agent truly exited. Reset the evidence so the // pane's ordinary shell commands go back to the no-RPC finished path. - hasForegroundAgentEvidence = false - hasKnownAgentEvidence = false - hasAgentExpectation = false + clearAgentEvidence() deps.publish({ agent: null, shellForeground: true }) // Why: confirmed exit — let callers clear a stale agent title the shell // won't repaint (a plain `codex`/`grok` leaves its OSC title behind). @@ -269,12 +285,7 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke onVisiblePtyBound(expectsAgent = false) { // Why: command-start and command-finished reads own the exit decision; // visibility recovery is lower-authority and must never cancel them. - if ( - scheduledReadReason === 'command' || - activeReadReason === 'command' || - scheduledReadReason === 'command-finished' || - activeReadReason === 'command-finished' - ) { + if (hasCommandReadInFlight()) { return false } const hadReadBeforeVisibleBind = hasPendingRead() @@ -348,6 +359,26 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished') return true }, + onProcessExitConfirmed(process) { + // Why: an in-flight command read owns the pane's next identity, and only a live read is ours. + const published = deps.getPublishedEntry?.() + if ( + disposed || + hasCommandReadInFlight() || + published?.agentEvidence !== 'process-read' || + resolveCompatibleAgentTypeForOwner(process.agent, published.agent) !== published.agent + ) { + return + } + const hadVisibleRead = hasPendingRead() + cancelPendingRead() + clearAgentEvidence() + deps.publish({ agent: null, shellForeground: true }) + deps.onConfirmedShellForeground?.('process-exit') + if (hadVisibleRead) { + deps.onVisibleForegroundSettled?.('shell') + } + }, dispose() { const hadReadAtDispose = hasPendingRead() disposed = true diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts new file mode 100644 index 000000000000..d9304e850af4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveTitleDerivedPaneAgent } from '../sidebar/title-derived-pane-agent-identity' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' +import { createAgentCompletionCoordinator } from './agent-completion-coordinator' +import { + createDeferred, + flushAsyncTicks, + processResult, + useAgentCompletionCoordinatorLifecycle +} from './agent-completion-coordinator-test-harness' +import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracker' + +// Wires the pane's tracker and completion monitor the way pane-agent-identity.ts and +// terminal-keydown-fit.ts do, for a pane whose shell emits no OSC 133 command marks. +function createUnmarkedPane(args: { visible: boolean }) { + let entry: PaneForegroundAgentEntry | undefined + let foreground: string | null = 'codex' + let hasChildren = true + const readForegroundProcess = vi.fn(async (): Promise => foreground) + const confirmForegroundProcess = vi.fn(async (): Promise => foreground) + const onConfirmedShellForeground = vi.fn() + let coordinator: ReturnType | null = null + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => 'pty-1', + isTrackablePtyId: () => true, + readForegroundProcess, + confirmForegroundProcess, + publish: (next) => { + entry = next + }, + getPublishedEntry: () => entry, + onAgentProcessRead: (process) => coordinator?.observeForegroundAgentProcess(process), + onConfirmedShellForeground + }) + coordinator = createAgentCompletionCoordinator({ + paneKey: 'tab-1:leaf-1', + statusLane: 'pty', + getPtyId: () => 'pty-1', + getSettings: () => null, + inspectProcess: vi.fn(async () => processResult(foreground, hasChildren)), + dispatchCompletion: vi.fn(), + isLive: () => true, + shouldPollProcessCadence: () => args.visible, + onForegroundAgentExited: (exited) => tracker.onProcessExitConfirmed(exited) + }) + coordinator.startProcessTracking() + return { + tracker, + onConfirmedShellForeground, + getEntry: () => entry, + setEntry: (next: PaneForegroundAgentEntry) => { + entry = next + }, + setForeground: (processName: string | null, children: boolean) => { + foreground = processName + hasChildren = children + }, + confirmForegroundProcess + } +} + +function sidebarAgent(title: string, entry: PaneForegroundAgentEntry | undefined) { + return resolveTitleDerivedPaneAgent({ + title, + defaultTitle: 'Terminal 2', + titleShowsActivity: false, + titleAgentType: null, + launchAgentType: null, + foreground: entry + }) +} + +describe('process monitor retires a latched foreground read', () => { + useAgentCompletionCoordinatorLifecycle() + + it('drops the row after Codex exits and the shell titles its prompt', async () => { + const pane = createUnmarkedPane({ visible: true }) + pane.tracker.onVisiblePtyBound() + await vi.advanceTimersByTimeAsync(2_500) + expect(sidebarAgent('repo', pane.getEntry())).toBe('codex') + + pane.setForeground('zsh', false) + await vi.advanceTimersByTimeAsync(5_000) + + expect(pane.getEntry()).toEqual({ agent: null, shellForeground: true }) + expect(pane.onConfirmedShellForeground).toHaveBeenCalledWith('process-exit') + expect(sidebarAgent('', pane.getEntry())).toBeNull() + expect(sidebarAgent('user@host: ~/repo', pane.getEntry())).toBeNull() + }) + + it('drops the row when a killed Codex leaves its last title behind', async () => { + const pane = createUnmarkedPane({ visible: true }) + pane.tracker.onVisiblePtyBound() + await vi.advanceTimersByTimeAsync(2_500) + + pane.setForeground('zsh', false) + await vi.advanceTimersByTimeAsync(5_000) + + expect(sidebarAgent('repo', pane.getEntry())).toBeNull() + }) + + it('keeps the row while Codex runs a nested shell', async () => { + const pane = createUnmarkedPane({ visible: true }) + pane.tracker.onVisiblePtyBound() + await vi.advanceTimersByTimeAsync(2_500) + + pane.setForeground('zsh', true) + await vi.advanceTimersByTimeAsync(10_000) + + expect(sidebarAgent('repo', pane.getEntry())).toBe('codex') + expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled() + }) + + it('watches a hidden pane once its process read names the agent', async () => { + const pane = createUnmarkedPane({ visible: false }) + pane.tracker.onVisiblePtyBound() + await vi.advanceTimersByTimeAsync(400) + expect(sidebarAgent('repo', pane.getEntry())).toBe('codex') + + pane.setForeground('zsh', false) + await vi.advanceTimersByTimeAsync(10_000) + + expect(sidebarAgent('repo', pane.getEntry())).toBeNull() + }) + + it('leaves an in-flight command-finished read to decide the pane', async () => { + const pane = createUnmarkedPane({ visible: true }) + pane.tracker.onVisiblePtyBound() + await vi.advanceTimersByTimeAsync(2_500) + const pendingConfirm = createDeferred() + pane.confirmForegroundProcess.mockReturnValueOnce(pendingConfirm.promise) + pane.tracker.onCommandFinished() + await vi.advanceTimersByTimeAsync(400) + + pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' }) + await flushAsyncTicks() + + expect(pane.getEntry()).toMatchObject({ agent: 'codex', agentEvidence: 'process-read' }) + expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled() + }) + + it('does not retire a reattach launch record or another agent', () => { + const pane = createUnmarkedPane({ visible: false }) + const launchRecord: PaneForegroundAgentEntry = { + agent: 'codex', + agentEvidence: 'launch-record', + shellForeground: false + } + pane.setEntry(launchRecord) + pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' }) + expect(pane.getEntry()).toBe(launchRecord) + + const claudeRead: PaneForegroundAgentEntry = { + agent: 'claude', + agentEvidence: 'process-read', + shellForeground: false + } + pane.setEntry(claudeRead) + pane.tracker.onProcessExitConfirmed({ agent: 'codex', processName: 'codex' }) + expect(pane.getEntry()).toBe(claudeRead) + expect(pane.onConfirmedShellForeground).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts index 4a1979aa02d5..ff7503b3012c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-command-finished-cleanup.test.ts @@ -384,7 +384,8 @@ describe('connectPanePty', () => { it('keeps sleeping identity when the foreground check still sees the agent', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('codex') + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('codex') const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-sleeping-leaked-shell-marker' const transport = createMockTransport(ptyId) @@ -624,7 +625,8 @@ describe('connectPanePty', () => { it('keeps armed modes while the agent still owns the foreground after a leaked 133;D', async () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') - vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('droid') + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockResolvedValue('droid') const dataCallbackRef: { current: ((data: string) => void) | null } = { current: null } const ptyId = 'pty-stale-mode-live-agent' const transport = createMockTransport(ptyId) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts index 89a0ece8168e..31cbe3ed444c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-agent-routing.test.ts @@ -409,6 +409,8 @@ describe('connectPanePty', () => { vi.useFakeTimers() const { connectPanePty } = await import('./pty-connection') let foreground = 'bash.exe' + // Why both: the pane's process monitor reads the same foreground the confirm read sees. + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) vi.mocked(window.api.pty.confirmForegroundProcess).mockImplementation(async () => foreground) const pane = createPane(1) const ptyId = 'pty-launched-droid-slow' diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts new file mode 100644 index 000000000000..5ec35c0a5326 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts @@ -0,0 +1,190 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { flushAsyncTicks } from './pty-connection-test-async' +import { + LEAF_1, + createMockTransport, + createPane, + createManager, + type MockTransport +} from './pty-connection-test-pane-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { + resetAndRefreshAllTerminalWebglAtlases, + scheduleTerminalWebglAtlasRecovery, + scheduleRuntimeGraphSync, + shouldSeedCacheTimerOnInitialTitle, + toastInfo, + notifyCodexPaneBoundForStaleSweep +} = vi.hoisted(() => ({ + resetAndRefreshAllTerminalWebglAtlases: vi.fn(), + scheduleTerminalWebglAtlasRecovery: vi.fn(), + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn(), + notifyCodexPaneBoundForStaleSweep: vi.fn() +})) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let createdTransportOptions: Record[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ + scheduleRuntimeGraphSync +})) + +vi.mock('@/lib/pane-manager/pane-manager-registry', async (importOriginal) => ({ + ...(await importOriginal>()), + resetAndRefreshAllTerminalWebglAtlases +})) + +vi.mock('./terminal-webgl-atlas-recovery', () => ({ + scheduleTerminalWebglAtlasRecovery +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ + shouldSeedCacheTimerOnInitialTitle +})) + +vi.mock('sonner', () => ({ + toast: { + info: toastInfo + } +})) + +vi.mock('@/lib/codex-stale-pane-sweep', () => ({ + notifyCodexPaneBoundForStaleSweep +})) + +// Why: the working→idle test invokes the real useNotificationDispatch hook outside React, so useCallback must pass through (safe suite-wide: no test here renders React). +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn((options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +vi.mock('./remote-runtime-pty-transport', () => ({ + createRemoteRuntimePtyTransport: vi.fn( + (_environmentId: string, options: Record) => { + createdTransportOptions.push(options) + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + } + ) +})) + +// Why: stub only getEagerPtyBufferHandle so tests can simulate a live eager buffer (adopt path) without standing up the real IPC dispatcher. +vi.mock('./pty-dispatcher', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + getEagerPtyBufferHandle: vi.fn(() => undefined) + } +}) + +function createDeps(overrides: Record = {}) { + return buildPaneConnectionDeps(() => mockStoreState, overrides) +} + +describe('connectPanePty process-exit retirement', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + createdTransportOptions = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + async function connectRestoredPane(ptyId: string, visible: boolean): Promise { + const { connectPanePty } = await import('./pty-connection') + const tabId = `tab-${ptyId}` + const transport = createMockTransport(ptyId) + transport.getPtyId.mockImplementation(() => ptyId) + transport.connect.mockImplementation(async () => null) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, + isVisibleRef: { current: visible } + }) + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return makePaneKey(tabId, LEAF_1) + } + + it('retires a hand-typed Codex read in a pane without command marks once it exits', async () => { + vi.useFakeTimers() + const ptyId = 'pty-unmarked-codex-exit' + let foreground: string | null = 'codex' + let children = true + vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) + vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => children) + + const cacheKey = await connectRestoredPane(ptyId, true) + await vi.advanceTimersByTimeAsync(3_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ + agent: 'codex', + agentEvidence: 'process-read' + }) + + foreground = 'zsh' + children = false + await vi.advanceTimersByTimeAsync(10_000) + + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ + agent: null, + shellForeground: true + }) + expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index b9ea1c9c4c83..cc5a09fba276 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -167,6 +167,10 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { isRemotePtyId, getExpectedIncarnationId: () => session.remotePtyIncarnationId ?? null, publish: (entry) => useAppStore.getState().setPaneForegroundAgent(session.cacheKey, entry), + getPublishedEntry: () => useAppStore.getState().paneForegroundAgentByPaneKey[session.cacheKey], + // Why lazy: the completion coordinator is installed after this tracker. + onAgentProcessRead: (process) => + session.agentCompletionCoordinator?.observeForegroundAgentProcess(process), hasKnownAgentIdentity: session.paneHasKnownAgentIdentity, onConfirmedShellForeground: (reason) => { // Why: a confirmed local shell proves any hibernation record for this pane is stale; @@ -181,7 +185,8 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // surviving shell then receives pointer moves as typed SGR reports; the // replay guard keeps xterm's auto-replies from leaking to the shell. session.writeInputModeGround(CONFIRMED_SHELL_MODE_RESET) - if (reason === 'visible-pty') { + // Why: no 133;D backs these proofs, so a deferred command-finished drop keeps its own read. + if (reason === 'visible-pty' || reason === 'process-exit') { state.clearAgentLaunchConfig(session.cacheKey) return } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts index f454b247e4f1..f998a1c97fa6 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/terminal-keydown-fit.ts @@ -207,6 +207,9 @@ export function installTerminalKeydownFit(session: ConnectPanePtySession): void currentAgentForExited !== exited.agent ) }, + // Why: the pane's only re-derivation of a process read where the shell emits no command marks. + onForegroundAgentExited: (exited) => + session.paneForegroundAgentTracker?.onProcessExitConfirmed(exited), dispatchCompletion: (title, meta) => { if (meta?.source === 'process-exit') { session.clearSuppressedTitleSideEffects() From 965db7b8ea391bef2894efc0fdfd4ffba498a5c7 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:04:39 -0700 Subject: [PATCH 10/11] fix(sidebar): a monitor-confirmed exit leaves the next agent in an unmarked pane identifiable The process-exit retire published shellForeground:true and left the one-shot visible sample settled; a pane without command marks has no command start to lift either, so a Codex typed again after quitting was never read and lost its row on retitle. Publish shellForeground:false and reopen the sample. --- .../pane-foreground-agent-tracker.ts | 6 +++-- ...ane-foreground-process-exit-retire.test.ts | 2 +- ...connection-foreground-process-exit.test.ts | 27 +++++++++++++++---- .../pty-connection/pane-agent-identity.ts | 5 ++++ 4 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts index 26fe1782c06a..584f82797c2f 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.ts @@ -373,11 +373,13 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const hadVisibleRead = hasPendingRead() cancelPendingRead() clearAgentEvidence() - deps.publish({ agent: null, shellForeground: true }) - deps.onConfirmedShellForeground?.('process-exit') + // Why not shellForeground: a pane without command marks gets no command start to lift that + // latch, so it would block the Enter sample that identifies the next agent typed there. + deps.publish({ agent: null, shellForeground: false }) if (hadVisibleRead) { deps.onVisibleForegroundSettled?.('shell') } + deps.onConfirmedShellForeground?.('process-exit') }, dispose() { const hadReadAtDispose = hasPendingRead() diff --git a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts index d9304e850af4..5eafa29e07e9 100644 --- a/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts +++ b/src/renderer/src/components/terminal-pane/pane-foreground-process-exit-retire.test.ts @@ -82,7 +82,7 @@ describe('process monitor retires a latched foreground read', () => { pane.setForeground('zsh', false) await vi.advanceTimersByTimeAsync(5_000) - expect(pane.getEntry()).toEqual({ agent: null, shellForeground: true }) + expect(pane.getEntry()).toEqual({ agent: null, shellForeground: false }) expect(pane.onConfirmedShellForeground).toHaveBeenCalledWith('process-exit') expect(sidebarAgent('', pane.getEntry())).toBeNull() expect(sidebarAgent('user@host: ~/repo', pane.getEntry())).toBeNull() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts index 5ec35c0a5326..2590ae2a6ff3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts @@ -143,7 +143,10 @@ describe('connectPanePty process-exit retirement', () => { await restoreTerminalTestGlobals() }) - async function connectRestoredPane(ptyId: string, visible: boolean): Promise { + async function connectRestoredPane( + ptyId: string, + visible: boolean + ): Promise<{ cacheKey: string; binding: { sampleForegroundAgentOnFocus: () => void } }> { const { connectPanePty } = await import('./pty-connection') const tabId = `tab-${ptyId}` const transport = createMockTransport(ptyId) @@ -156,10 +159,14 @@ describe('connectPanePty process-exit retirement', () => { restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, isVisibleRef: { current: visible } }) - connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } await vi.advanceTimersByTimeAsync(20) await flushAsyncTicks(20) - return makePaneKey(tabId, LEAF_1) + return { cacheKey: makePaneKey(tabId, LEAF_1), binding } } it('retires a hand-typed Codex read in a pane without command marks once it exits', async () => { @@ -170,7 +177,7 @@ describe('connectPanePty process-exit retirement', () => { vi.mocked(window.api.pty.getForegroundProcess).mockImplementation(async () => foreground) vi.mocked(window.api.pty.hasChildProcesses).mockImplementation(async () => children) - const cacheKey = await connectRestoredPane(ptyId, true) + const { cacheKey, binding } = await connectRestoredPane(ptyId, true) await vi.advanceTimersByTimeAsync(3_000) expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ agent: 'codex', @@ -183,8 +190,18 @@ describe('connectPanePty process-exit retirement', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: null, - shellForeground: true + shellForeground: false }) expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(cacheKey) + + // Codex typed again in the same unmarked pane: the next sample must identify it. + foreground = 'codex' + children = true + binding.sampleForegroundAgentOnFocus() + await vi.advanceTimersByTimeAsync(3_000) + expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toMatchObject({ + agent: 'codex', + agentEvidence: 'process-read' + }) }) }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts index cc5a09fba276..399930ebfa06 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts @@ -186,6 +186,11 @@ export function installPaneAgentIdentity(session: ConnectPanePtySession): void { // replay guard keeps xterm's auto-replies from leaking to the shell. session.writeInputModeGround(CONFIRMED_SHELL_MODE_RESET) // Why: no 133;D backs these proofs, so a deferred command-finished drop keeps its own read. + if (reason === 'process-exit') { + // Why: reopen the one-shot visible sample so the next agent typed here is identified. + session.visibleForegroundSamplePending = false + session.visibleForegroundSampleSettled = false + } if (reason === 'visible-pty' || reason === 'process-exit') { state.clearAgentLaunchConfig(session.cacheKey) return From b7e9362a0bdc995cda3209ed4242da960b83ca4f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:20:05 -0700 Subject: [PATCH 11/11] test(terminal): justify the pane binding cast in the process-exit relaunch test --- .../terminal-pane/pty-connection-foreground-process-exit.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts index 2590ae2a6ff3..719931b6971e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts @@ -159,6 +159,7 @@ describe('connectPanePty process-exit retirement', () => { restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, isVisibleRef: { current: visible } }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: connectPanePty returns the pane binding, which exposes sampleForegroundAgentOnFocus. const binding = connectPanePty( createPane(1) as never, createManager(1) as never,