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 3a1254004a6..f8c094acd5d 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,30 @@ 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', agentEvidence: 'process-read', 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 5f89b90f715..8272b66d7f8 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: {}, @@ -23,6 +24,7 @@ function storeState(): AppState { terminalLayoutsByTabId: {}, ptyIdsByTabId: {}, runtimePaneTitlesByTabId: {}, + paneForegroundAgentByPaneKey: {}, folderWorkspaces: [], acknowledgedAgentsByPaneKey: {}, agentStatusEpoch: 0, @@ -32,7 +34,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 +49,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 +95,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 +152,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 d003991cecf..31d210ac3ff 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 a00cae7af19..2dfea45a352 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 00000000000..13b55822342 --- /dev/null +++ b/src/renderer/src/components/sidebar/title-derived-pane-agent-identity.ts @@ -0,0 +1,96 @@ +import { titleShowsNoAgent } from '../../../../shared/agent-detection' +import type { AgentType } from '../../../../shared/agent-status-types' +import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +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 = /(? + +// 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 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 + defaultTitle?: string + titleShowsActivity: boolean + titleAgentType: AgentType | null + 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; 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' || titleRetiresProcessRead(args) + ? 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/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index 366bce5e5ed..1fd1d8af452 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 009c562447f..4c8b3fbb77b 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 59675017b2a..074ddb7c9ba 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,68 @@ 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 + ) + }) + + 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 43971728deb..e5377a3afea 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,64 @@ 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, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.paneForegroundAgentByPaneKey], + empty: EMPTY_PANE_FOREGROUND_AGENTS, + build: (state, worktreeId) => { + 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 tab of tabs) { + for (const [paneKey, entry] of byTabId.get(tab.id) ?? []) { + 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 e30581af05d..e609974512d 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({ @@ -369,7 +374,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?: TitleDerivedPaneForeground + ) => buildWorktreeAgentRows({ tabs: [makeTab('tab-1', launchAgent ? { launchAgent } : {})], entries: [], @@ -377,12 +386,19 @@ 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. + // 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', 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']) expect(rowsFor('zsh', 'opencode')).toHaveLength(0) @@ -403,6 +419,175 @@ describe('buildTitleDerivedAgentRows', () => { }) }) +// #23767: Codex retitles its pane to the project name, so a title-gated row vanished while +// 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: { + title: string + launchAgent?: TuiAgent + foreground?: TitleDerivedPaneForeground + 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', () => { + const foreground = processRead('codex') + expect(summarize(rowsFor({ title: 'Codex', launchAgent: 'codex', foreground }))).toEqual([ + ['codex', 'idle', 'Codex', 'Idle'] + ]) + 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("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: processRead(agent) }) + 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)]: processRead('codex') + }, + 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 = processRead('codex') + 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. + expect( + rowsFor({ + title: 'demo-repo', + launchAgent: 'codex', + foreground: { agent: null, shellForeground: true } + }) + ).toHaveLength(0) + // A shell or default title outranks a process read that has not caught up yet. + expect( + rowsFor({ + title: 'zsh', + launchAgent: 'codex', + foreground: processRead('codex') + }) + ).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: 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({ + title: 'demo-repo', + launchAgent: 'codex', + foreground: processRead('codex'), + 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 341cb0e1ff2..7e73363f709 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,37 @@ 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, + titleShowsActivity: Boolean(titleStatus && label), + 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-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 +208,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 +241,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, 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 4e7e07f5c7c..c56e77b5bc7 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 9b60b37ff1d..37b4430a9b6 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 1ee52840279..9d47a3f60fa 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 6382cee82ad..fd4d1f448d2 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.test.ts b/src/renderer/src/components/terminal-pane/pane-foreground-agent-tracker.test.ts index 50bbb993bce..3021132c315 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 d831226267d..584f82797c2 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 @@ -169,9 +187,14 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke hasAgentExpectation = false deps.publish({ agent: recognized.agent, + agentEvidence: 'process-read', 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') } @@ -212,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') @@ -231,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 @@ -248,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). @@ -268,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() @@ -347,6 +359,28 @@ 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() + // 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() 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 00000000000..5eafa29e07e --- /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: false }) + 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/parked-terminal-command-status.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-command-status.test.ts index 3a334300341..b7847f16a8e 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,94 @@ describe('createParkedTerminalCommandStatusPolicy', () => { vi.useRealTimers() }) + // #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() + } + } + + beforeEach(() => { + confirmForegroundProcess.mockReset() + vi.stubGlobal('window', { api: { pty: { confirmForegroundProcess } } }) + mockStoreState.paneForegroundAgentByPaneKey[PANE_KEY] = { + agent: 'codex', + shellForeground: false, + routingTrusted: true + } + }) + + 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() + }) + }) + + 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 6c2f20b4891..6a732c8e43d 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 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) { + 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,6 +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) + 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/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 45a603f9e7f..ff7503b3012 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 }) @@ -383,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) @@ -623,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 8ec26ef0cea..31cbe3ed444 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 }) @@ -405,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' @@ -440,6 +446,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 50e1b4a0f8b..f37de995404 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,12 +518,66 @@ describe('connectPanePty', () => { expect(mockStoreState.paneForegroundAgentByPaneKey[cacheKey]).toEqual({ agent: 'droid', + agentEvidence: 'process-read', routingTrusted: true, shellForeground: false }) 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') @@ -809,6 +868,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-process-exit.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts new file mode 100644 index 00000000000..719931b6971 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-foreground-process-exit.test.ts @@ -0,0 +1,208 @@ +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<{ cacheKey: string; binding: { sampleForegroundAgentOnFocus: () => void } }> { + 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 } + }) + // 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, + deps as never + ) as unknown as { sampleForegroundAgentOnFocus: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return { cacheKey: makePaneKey(tabId, LEAF_1), binding } + } + + 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, binding } = 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: 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-foreground-routing-confirmation.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-foreground-routing-confirmation.test.ts index fd46a623242..5704fc828f0 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 2c86932c499..399930ebfa0 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,13 @@ 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 === '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 } @@ -202,6 +212,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 +311,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 +323,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 c756a29e80a..ee038234a05 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,8 +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: keepsRead ? 'process-read' : 'launch-record', shellForeground: false }) } 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 f454b247e4f..f998a1c97fa 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() 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 d878c8cef3f..edbbf9ac8fa 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 73685b6b88b..dcdb4edd370 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 && diff --git a/src/shared/shell-process-detection.test.ts b/src/shared/shell-process-detection.test.ts new file mode 100644 index 00000000000..ea28c65d93c --- /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 fe032f0dffb..0afb2850283 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 diff --git a/tests/e2e/terminal-foreground-confirmation.unit.test.ts b/tests/e2e/terminal-foreground-confirmation.unit.test.ts index 04873836a34..90124121f36 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 })