diff --git a/CODEX_ICON_DEBUG.diff b/CODEX_ICON_DEBUG.diff new file mode 100644 index 00000000000..3021cd74924 --- /dev/null +++ b/CODEX_ICON_DEBUG.diff @@ -0,0 +1,1200 @@ +diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts +index 85366a137..b23c83656 100644 +--- a/src/main/daemon/pty-subprocess.test.ts ++++ b/src/main/daemon/pty-subprocess.test.ts +@@ -367,6 +367,81 @@ describe('createPtySubprocess', () => { + } + }) + ++ it('serves the resolved agent identity past the cache TTL while a wrapper holds the foreground', async () => { ++ vi.useFakeTimers() ++ vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z')) ++ const proc = mockPtyProcess() ++ proc.process = 'node' ++ spawnMock.mockReturnValue(proc) ++ const platform = Object.getOwnPropertyDescriptor(process, 'platform') ++ Object.defineProperty(process, 'platform', { value: 'darwin' }) ++ resolveAgentForegroundProcessMock.mockResolvedValue('grok') ++ ++ try { ++ const handle = createPtySubprocess({ ++ sessionId: 'test', ++ cols: 80, ++ rows: 24 ++ }) ++ ++ expect(handle.getForegroundProcess()).toBe('node') ++ await Promise.resolve() ++ await Promise.resolve() ++ expect(handle.getForegroundProcess()).toBe('grok') ++ ++ // Why: renderer reads poll slower than the 1s cache TTL — an expired ++ // cache must keep answering with the resolved identity, not the wrapper. ++ vi.advanceTimersByTime(1_500) ++ expect(handle.getForegroundProcess()).toBe('grok') ++ } finally { ++ vi.useRealTimers() ++ if (platform) { ++ Object.defineProperty(process, 'platform', platform) ++ } ++ } ++ }) ++ ++ it('clears an expired identity when the wrapper tree no longer resolves to an agent', async () => { ++ vi.useFakeTimers() ++ vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z')) ++ const proc = mockPtyProcess() ++ proc.process = 'node' ++ spawnMock.mockReturnValue(proc) ++ const platform = Object.getOwnPropertyDescriptor(process, 'platform') ++ Object.defineProperty(process, 'platform', { value: 'darwin' }) ++ resolveAgentForegroundProcessMock.mockResolvedValueOnce('grok').mockResolvedValue('node') ++ ++ try { ++ const handle = createPtySubprocess({ ++ sessionId: 'test', ++ cols: 80, ++ rows: 24 ++ }) ++ ++ expect(handle.getForegroundProcess()).toBe('node') ++ await Promise.resolve() ++ await Promise.resolve() ++ expect(handle.getForegroundProcess()).toBe('grok') ++ // Flush the first refresh's finally so the next read can revalidate. ++ await Promise.resolve() ++ await Promise.resolve() ++ ++ // An unrelated wrapper (e.g. npm) now owns the pane: the stale-served ++ // identity is revalidated and dropped once the refresh finds no agent. ++ vi.advanceTimersByTime(1_500) ++ expect(handle.getForegroundProcess()).toBe('grok') ++ await Promise.resolve() ++ await Promise.resolve() ++ await Promise.resolve() ++ expect(handle.getForegroundProcess()).toBe('node') ++ } finally { ++ vi.useRealTimers() ++ if (platform) { ++ Object.defineProperty(process, 'platform', platform) ++ } ++ } ++ }) ++ + it('serves daemon Windows wrapper agent foreground from an async cache', async () => { + const proc = mockPtyProcess() + proc.process = 'node.exe' +diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts +index 6dd0554bb..09f66a987 100644 +--- a/src/main/daemon/pty-subprocess.ts ++++ b/src/main/daemon/pty-subprocess.ts +@@ -937,6 +937,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl + ) { + cachedAgentForeground = null + startupAgentForeground = null ++ } else if ( ++ cachedAgentForeground !== null && ++ Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && ++ currentFallbackProcess !== null && ++ isAgentForegroundWrapperProcess(currentFallbackProcess) ++ ) { ++ // Why: the wrapper's tree no longer resolves to an agent — an expired ++ // identity must not transfer to an unrelated wrapper (e.g. npm right ++ // after an agent exit). Fresh identities survive one-off scan hiccups. ++ cachedAgentForeground = null + } + return + } +@@ -994,6 +1004,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl + ) { + return cachedAgentForeground.processName + } ++ // Why: a wrapper foreground (node/python) can never identify itself, and ++ // readers poll slower than the cache TTL — returning the raw wrapper here ++ // would hide the resolved identity forever. Serve the last resolved agent ++ // while the scheduled refresh revalidates; exit truth is safe because an ++ // exited agent's foreground falls back to the shell, not a wrapper. ++ if ( ++ cachedAgentForeground && ++ fallbackProcess !== null && ++ isAgentForegroundWrapperProcess(fallbackProcess) ++ ) { ++ return cachedAgentForeground.processName ++ } + const activeStartupAgentForeground = getActiveStartupAgentForeground(now) + if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { + return activeStartupAgentForeground.processName +diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts +index 860e3f27a..ce1a67da7 100644 +--- a/src/main/daemon/shell-ready.test.ts ++++ b/src/main/daemon/shell-ready.test.ts +@@ -556,6 +556,34 @@ describePosix('daemon shell-ready launch config', () => { + } + ) + ++ itWithBash( ++ 'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt', ++ async () => { ++ const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady() ++ // Minimal bash-preexec imitation (iTerm2/starship setups): re-arms its own ++ // DEBUG trap from PROMPT_COMMAND at the first prompt — silencing Orca's ++ // trap — and dispatches preexec_functions with the command as $1. ++ writeFileSync( ++ join(userDataPath, '.bash_profile'), ++ [ ++ 'preexec_functions=()', ++ '__bp_preexec_invoke_exec() {', ++ ' [[ -n "${__bp_interactive_mode:-}" ]] || return', ++ ' __bp_interactive_mode=""', ++ ' local f', ++ ' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done', ++ '}', ++ "__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", ++ 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' ++ ].join('\n') ++ ) ++ ++ const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) ++ ++ expectBashOsc133Lifecycle(output) ++ } ++ ) ++ + itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady() + writeFileSync( +diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts +index 7ad5a686b..e31949e5b 100644 +--- a/src/main/daemon/shell-ready.ts ++++ b/src/main/daemon/shell-ready.ts +@@ -195,6 +195,23 @@ if [[ -n "$__orca_debug_trap_spec" ]]; then + fi + unset __orca_debug_trap_spec __orca_debug_trap_command + unset -f __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command ++# Why: bash-preexec (pulled in by iTerm2/starship setups) re-arms its own DEBUG ++# trap at the first prompt, silencing ours. Register with its preexec_functions ++# array too; both paths gate on __orca_in_command, so whichever mechanism ends ++# up owning the trap, C is emitted exactly once per command. ++__orca_osc133_preexec_bp() { ++ [[ -z "\${__orca_in_command:-}" ]] || return ++ [[ -z "\${__orca_in_prompt_command:-}" ]] || return ++ case "\${1:-}" in ++ *__orca_osc133_precmd*|*__orca_osc133_prompt_done*|*__orca_prompt_mark*) return ;; ++ esac ++ printf "\\033]133;C\\007" ++ __orca_in_command=1 ++} ++case " \${preexec_functions[*]:-} " in ++ *" __orca_osc133_preexec_bp "*) ;; ++ *) preexec_functions+=(__orca_osc133_preexec_bp) ;; ++esac + # Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile + # commands as a foreground command and emits a fake C/D before the first prompt. + trap '__orca_osc133_preexec' DEBUG +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 5a2e8c258..ab5aaa03b 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 +@@ -5,12 +5,14 @@ import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracke + import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + + const COMMAND_SETTLE_MS = 350 ++const VISIBLE_PTY_SETTLE_MS = 350 + const WRAPPER_RESOLVE_RETRY_MS = 1200 + const SECOND_WRAPPER_RETRY_MS = 3500 + + describe('createPaneForegroundAgentTracker', () => { + const readForegroundProcess = vi.fn<(ptyId: string) => Promise>() + const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() ++ const onConfirmedShellForeground = vi.fn<() => void>() + let ptyId: string | null = 'pty-1' + + function makeTracker(): ReturnType { +@@ -18,7 +20,8 @@ describe('createPaneForegroundAgentTracker', () => { + getPtyId: () => ptyId, + isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), + readForegroundProcess, +- publish ++ publish, ++ onConfirmedShellForeground + }) + } + +@@ -30,6 +33,7 @@ describe('createPaneForegroundAgentTracker', () => { + vi.useFakeTimers() + readForegroundProcess.mockReset() + publish.mockReset() ++ onConfirmedShellForeground.mockReset() + ptyId = 'pty-1' + }) + +@@ -52,6 +56,72 @@ describe('createPaneForegroundAgentTracker', () => { + expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + }) + ++ it('reads the foreground for a visible PTY so restored running Codex panes regain identity', async () => { ++ readForegroundProcess.mockResolvedValue('codex') ++ const tracker = makeTracker() ++ ++ tracker.onVisiblePtyBound() ++ expect(readForegroundProcess).not.toHaveBeenCalled() ++ ++ await flushSettleRead(VISIBLE_PTY_SETTLE_MS) ++ ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ }) ++ ++ it('does not retry or publish visible PTY reads for an idle shell foreground', async () => { ++ readForegroundProcess.mockResolvedValue('zsh') ++ const tracker = makeTracker() ++ ++ tracker.onVisiblePtyBound() ++ await flushSettleRead( ++ VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000 ++ ) ++ ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).not.toHaveBeenCalled() ++ }) ++ ++ it('lets command-start sampling supersede a pending visible PTY read', async () => { ++ readForegroundProcess.mockResolvedValue('codex') ++ const tracker = makeTracker() ++ ++ tracker.onVisiblePtyBound() ++ tracker.onCommandStarted() ++ await flushSettleRead(VISIBLE_PTY_SETTLE_MS) ++ ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ }) ++ ++ it('does not let visible PTY sampling downgrade pending command-start sampling', async () => { ++ readForegroundProcess.mockResolvedValueOnce('bash').mockResolvedValueOnce('codex') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ tracker.onVisiblePtyBound() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(readForegroundProcess).toHaveBeenCalledTimes(1) ++ ++ await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) ++ expect(readForegroundProcess).toHaveBeenCalledTimes(2) ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ }) ++ ++ it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => { ++ readForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('codex') ++ const tracker = makeTracker() ++ ++ tracker.onVisiblePtyBound() ++ await flushSettleRead(VISIBLE_PTY_SETTLE_MS) ++ expect(readForegroundProcess).toHaveBeenCalledTimes(1) ++ expect(publish).not.toHaveBeenCalled() ++ ++ await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) ++ expect(readForegroundProcess).toHaveBeenCalledTimes(2) ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ }) ++ + it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => { + // Why: daemon shell/helper→agent ancestry resolution has been observed to + // take >1.5s for real node-wrapped CLIs, so the ladder gets two re-reads. +@@ -124,18 +194,161 @@ describe('createPaneForegroundAgentTracker', () => { + expect(readForegroundProcess).not.toHaveBeenCalled() + }) + +- it('cancels a pending read when the command finishes first', async () => { ++ it('confirms a rapid command start->finish instead of trusting the D', async () => { ++ // Why: a leaked nested-shell 133;C->133;D pair (or a fast real command) ++ // cancels the command-start read; confirm the foreground so a still-running ++ // manual agent with no launchAgent/hook keeps its identity. + readForegroundProcess.mockResolvedValue('claude') + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onCommandFinished() +- await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) ++ await flushSettleRead(COMMAND_SETTLE_MS) + ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) ++ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('confirms duplicate 133;D pairs instead of fast-pathing past the pending confirmation', async () => { ++ // Why: user shell integrations (iTerm/VS Code) double up Orca's OSC 133, so ++ // every D arrives twice ~50ms apart. The second D cancels the first D's ++ // confirming read; it must reschedule the confirmation, not trust the D. ++ readForegroundProcess.mockResolvedValue('grok') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ tracker.onCommandStarted() ++ tracker.onCommandFinished() ++ await flushSettleRead(50) ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'grok', shellForeground: false }) ++ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('still marks shell without a read for a duplicate D pair on an idle pane', async () => { ++ const tracker = makeTracker() ++ ++ tracker.onCommandFinished() ++ await flushSettleRead(50) ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ // Why: with no command read in flight the first D takes the no-RPC path, ++ // so its duplicate has nothing to re-confirm and stays RPC-free too. + expect(readForegroundProcess).not.toHaveBeenCalled() ++ expect(publish).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + }) + ++ it('marks shell on a rapid command start->finish when the foreground is a shell', async () => { ++ readForegroundProcess.mockResolvedValue('zsh') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('confirms the foreground before clearing a pane an agent has owned', async () => { ++ readForegroundProcess.mockResolvedValue('codex') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ ++ publish.mockClear() ++ readForegroundProcess.mockClear() ++ tracker.onCommandFinished() ++ // Why: a leaked nested-shell 133;D must not clear Codex before the read. ++ expect(publish).not.toHaveBeenCalled() ++ ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('marks shell foreground when the confirming read shows the agent is gone', async () => { ++ readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ publish.mockClear() ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ expect(readForegroundProcess).toHaveBeenLastCalledWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('signals confirmed shell foreground only when the read proves the agent exited', async () => { ++ // Reads: command-start=codex, first finish=codex (still running), second finish=zsh (exited). ++ readForegroundProcess ++ .mockResolvedValueOnce('codex') ++ .mockResolvedValueOnce('codex') ++ .mockResolvedValueOnce('zsh') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ // A leaked nested-shell 133;D while Codex still owns the foreground: no signal. ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(onConfirmedShellForeground).not.toHaveBeenCalled() ++ ++ // Genuine exit -> read sees a shell -> signal fires exactly once. ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(onConfirmedShellForeground).toHaveBeenCalledTimes(1) ++ }) ++ ++ it('returns to the no-RPC finished path after the agent is confirmed gone', async () => { ++ readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') ++ const tracker = makeTracker() ++ ++ tracker.onCommandStarted() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ tracker.onCommandFinished() ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ ++ publish.mockClear() ++ readForegroundProcess.mockClear() ++ tracker.onCommandFinished() ++ ++ expect(readForegroundProcess).not.toHaveBeenCalled() ++ expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) ++ }) ++ ++ it('confirms a command finished for a launch-known agent pane before any read', async () => { ++ readForegroundProcess.mockResolvedValue('codex') ++ const tracker = createPaneForegroundAgentTracker({ ++ getPtyId: () => ptyId, ++ isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), ++ readForegroundProcess, ++ publish, ++ hasKnownAgentIdentity: () => true ++ }) ++ ++ tracker.onCommandFinished() ++ // Why: launchAgent/hook identity means the 133;D is confirmed, not trusted. ++ expect(publish).not.toHaveBeenCalled() ++ ++ await flushSettleRead(COMMAND_SETTLE_MS) ++ expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') ++ expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) ++ }) ++ + it('never reads or publishes for remote or ssh panes', async () => { + ptyId = 'remote:web-env-1@@terminal-1' + const tracker = makeTracker() +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 1af2f78fd..8e9d37327 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,4 +1,7 @@ +-import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' ++import { ++ isAgentForegroundWrapperProcess, ++ recognizeAgentProcess ++} from '../../../../shared/agent-process-recognition' + import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + + // Why: the read must land after the shell has exec'd the command; and when it +@@ -6,7 +9,9 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag + // asynchronously — observed to take >1.5s for real node-wrapped CLIs — so + // give its cache two bounded re-reads, not an open-ended retry loop. + const COMMAND_SETTLE_MS = 350 ++const VISIBLE_PTY_SETTLE_MS = 350 + const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const ++type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' + + type PaneForegroundAgentTrackerDeps = { + getPtyId: () => string | null +@@ -15,22 +20,39 @@ type PaneForegroundAgentTrackerDeps = { + isTrackablePtyId: (ptyId: string) => boolean + readForegroundProcess: (ptyId: string) => Promise + publish: (entry: PaneForegroundAgentEntry) => 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. */ ++ hasKnownAgentIdentity?: () => boolean ++ /** 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?: () => void + } + + /** + * Publishes process-table identity for a pane at OSC 133 command boundaries: + * one foreground read when a command starts (that is when the foreground +- * changes), and a no-RPC shell-foreground mark when it finishes — 133;D is +- * the ONLY source of shell-foreground proof; reads never produce it. ++ * changes), and a shell-foreground mark when it finishes. A 133;D is normally ++ * the shell-foreground proof; for a pane an agent has owned it is confirmed by a ++ * foreground read first, because a full-screen agent's nested command shells ++ * leak their own 133;D onto the main PTY. + */ + export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): { ++ onVisiblePtyBound: () => void + onCommandStarted: () => void + onCommandFinished: () => void + dispose: () => void + } { + let disposed = false +- let readTimer: number | null = null ++ let readTimer: ReturnType | null = null ++ let scheduledReadReason: ForegroundReadReason | null = null ++ let activeReadReason: ForegroundReadReason | null = null + let readGeneration = 0 ++ // Why: a full-screen agent (Codex, etc.) runs nested command shells whose own ++ // OSC 133;D leaks onto the main PTY. For a pane an agent has owned, that D is ++ // not proof the prompt returned, so confirm the foreground before clearing. ++ let hasForegroundAgentEvidence = false + + const trackablePtyId = (): string | null => { + const ptyId = deps.getPtyId() +@@ -40,30 +62,53 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke + const cancelPendingRead = (): void => { + readGeneration += 1 + if (readTimer !== null) { +- window.clearTimeout(readTimer) ++ clearTimeout(readTimer) + readTimer = null + } ++ scheduledReadReason = null ++ activeReadReason = null + } + +- const scheduleRead = (delayMs: number, retryIndex: number): void => { ++ const scheduleRead = ( ++ delayMs: number, ++ retryIndex: number, ++ reason: ForegroundReadReason ++ ): void => { + const generation = readGeneration +- readTimer = window.setTimeout(() => { ++ scheduledReadReason = reason ++ readTimer = setTimeout(() => { + readTimer = null +- void readForeground(generation, retryIndex) ++ scheduledReadReason = null ++ activeReadReason = reason ++ void readForeground(generation, retryIndex, reason).finally(() => { ++ if (generation === readGeneration && activeReadReason === reason) { ++ activeReadReason = null ++ } ++ }) + }, delayMs) + } + +- async function readForeground(generation: number, retryIndex: number): Promise { ++ async function readForeground( ++ generation: number, ++ retryIndex: number, ++ reason: ForegroundReadReason ++ ): Promise { + const ptyId = trackablePtyId() + if (disposed || generation !== readGeneration || !ptyId) { + return + } +- const processName = await deps.readForegroundProcess(ptyId).catch(() => null) ++ let processName: string | null = null ++ try { ++ processName = await deps.readForegroundProcess(ptyId) ++ } catch { ++ processName = null ++ } + if (disposed || generation !== readGeneration) { + return + } + const recognized = recognizeAgentProcess(processName) + if (recognized) { ++ hasForegroundAgentEvidence = true + deps.publish({ agent: recognized.agent, shellForeground: false }) + return + } +@@ -72,14 +117,50 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke + // a nested one (sh/bash without integration); marking shell-foreground + // would suppress live title identity. Only 133;D proves the prompt. + const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] +- if (retryDelay !== undefined && processName) { +- scheduleRead(retryDelay, retryIndex + 1) ++ const shouldRetry = ++ retryDelay !== undefined && ++ processName && ++ (reason === 'command' || isAgentForegroundWrapperProcess(processName)) ++ if (shouldRetry) { ++ scheduleRead(retryDelay, retryIndex + 1, reason) ++ return ++ } ++ if (reason === 'command') { ++ deps.publish({ agent: null, shellForeground: false }) + return + } +- deps.publish({ agent: null, shellForeground: false }) ++ if (reason === 'command-finished') { ++ // 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 ++ 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). ++ deps.onConfirmedShellForeground?.() ++ } + } + + return { ++ onVisiblePtyBound() { ++ // 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' ++ ) { ++ return ++ } ++ cancelPendingRead() ++ if (!trackablePtyId()) { ++ return ++ } ++ // Why: restored/manual agent panes can become visible while Codex is ++ // already foreground, so no OSC 133 command-start event will seed the tab icon. ++ scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') ++ }, + onCommandStarted() { + cancelPendingRead() + if (!trackablePtyId()) { +@@ -88,14 +169,39 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke + // Why: the foreground left the prompt the moment C fired — stale + // shell-foreground evidence must not clear the command that just started. + deps.publish({ agent: null, shellForeground: false }) +- scheduleRead(COMMAND_SETTLE_MS, 0) ++ scheduleRead(COMMAND_SETTLE_MS, 0, 'command') + }, + onCommandFinished() { ++ // Why: a rapid 133;C→133;D pair cancels the command-start read before it ++ // can identify the foreground — that pair is exactly a leaked nested-shell ++ // command under a full-screen agent (or a fast real shell command), so on a ++ // no-identity pane confirm it rather than trusting the D as a prompt return. ++ // A pending confirming read counts too: user shell integrations double up ++ // Orca's OSC 133, and the duplicate D must re-confirm, not fast-path past ++ // the in-flight confirmation it just cancelled. ++ const commandReadWasPending = ++ scheduledReadReason === 'command' || ++ activeReadReason === 'command' || ++ scheduledReadReason === 'command-finished' || ++ activeReadReason === 'command-finished' + cancelPendingRead() + if (!trackablePtyId()) { + return + } +- deps.publish({ agent: null, shellForeground: true }) ++ // Why: trust the 133;D and mark shell without an RPC only when nothing hints ++ // at an agent — no prior agent evidence, no launch/hook identity, and no ++ // command-start read racing this finish. ++ if ( ++ !hasForegroundAgentEvidence && ++ deps.hasKnownAgentIdentity?.() !== true && ++ !commandReadWasPending ++ ) { ++ deps.publish({ agent: null, shellForeground: true }) ++ return ++ } ++ // Why: confirm the foreground before clearing — if the agent still owns it, ++ // the read republishes its identity; only a genuine shell result clears it. ++ scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished') + }, + dispose() { + disposed = true +diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts +index 383d5d141..8d42ebb39 100644 +--- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts ++++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts +@@ -21,6 +21,7 @@ import { + beginAgentStartupDeliveryAttempt, + resetAgentStartupDelayedDeliveryForTests + } from '@/lib/agent-startup-delayed-delivery' ++import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' + + // Repro command: + // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" +@@ -169,6 +170,7 @@ type StoreState = { + consumePendingSnapshot: ReturnType + runtimePaneTitlesByTabId: Record> + agentStatusByPaneKey: Record ++ paneForegroundAgentByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + agentLaunchConfigByPaneKey: Record + getAgentLaunchConfigForStatusEntry: ReturnType +@@ -737,6 +739,7 @@ describe('connectPanePty', () => { + consumePendingSnapshot: vi.fn(() => null), + runtimePaneTitlesByTabId: {}, + agentStatusByPaneKey: {}, ++ paneForegroundAgentByPaneKey: {}, + sleepingAgentSessionsByPaneKey: {}, + agentLaunchConfigByPaneKey: {}, + getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { +@@ -771,8 +774,12 @@ describe('connectPanePty', () => { + ), + removeAgentStatus: vi.fn(), + dropAgentStatus: vi.fn(), +- setPaneForegroundAgent: vi.fn(), +- clearPaneForegroundAgent: vi.fn(), ++ setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => { ++ mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry ++ }), ++ clearPaneForegroundAgent: vi.fn((paneKey: string) => { ++ delete mockStoreState.paneForegroundAgentByPaneKey[paneKey] ++ }), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + markAgentCompletionPaneUnread: vi.fn() +@@ -13949,6 +13956,240 @@ describe('connectPanePty', () => { + }) + }) + ++ describe('visible foreground agent sampling (perf)', () => { ++ const VISIBLE_PTY_SETTLE_MS = 350 ++ ++ // Why: every connectPanePty binding in this file shares the tab-1/LEAF_1 pane ++ // key, and an undisposed reattach binding elsewhere can resolve a foreground ++ // read into this test's store slice. Give each sampling case its own tabId so ++ // no other test's publish can pollute the pane identity it asserts on. ++ async function connectRestoredPaneForForegroundSampling(args: { ++ ptyId?: string ++ tabId?: string ++ isVisibleRef?: { current: boolean } ++ } = {}): Promise<{ ++ binding: { noteVisibilityResume: () => void } ++ deps: ReturnType ++ transport: MockTransport ++ cacheKey: string ++ }> { ++ const { connectPanePty } = await import('./pty-connection') ++ const ptyId = args.ptyId ?? 'tab-pty' ++ const tabId = args.tabId ?? `tab-${ptyId}` ++ const transport = createMockTransport(ptyId) ++ transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { ++ return sessionId ? { id: sessionId } : null ++ }) ++ transportFactoryQueue.push(transport) ++ const deps = createDeps({ ++ tabId, ++ restoredLeafId: LEAF_1, ++ restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, ++ ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) ++ }) ++ const binding = connectPanePty( ++ createPane(1) as never, ++ createManager(1) as never, ++ deps as never ++ ) as unknown as { noteVisibilityResume: () => void } ++ await vi.advanceTimersByTimeAsync(20) ++ await flushAsyncTicks(20) ++ return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } ++ } ++ ++ async function advanceVisibleForegroundRead(): Promise { ++ await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) ++ await flushAsyncTicks() ++ } ++ ++ function foregroundReadCallsFor(ptyId: string): unknown[][] { ++ return vi ++ .mocked(window.api.pty.getForegroundProcess) ++ .mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId) ++ } ++ ++ it('does not inspect foreground process for a fresh visible spawn', async () => { ++ vi.useFakeTimers() ++ const { connectPanePty } = await import('./pty-connection') ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-fresh-visible-no-sample' ++ transportFactoryQueue.push(createMockTransport(ptyId)) ++ mockStoreState = { ++ ...mockStoreState, ++ tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, ++ ptyIdsByTabId: { 'tab-1': [] }, ++ terminalLayoutsByTabId: { ++ 'tab-1': { ++ root: { type: 'leaf', leafId: LEAF_1 }, ++ activeLeafId: LEAF_1, ++ expandedLeafId: null, ++ ptyIdsByLeafId: {} ++ } ++ } ++ } as StoreState ++ ++ connectPanePty( ++ createPane(1) as never, ++ createManager(1) as never, ++ createDeps() as never ++ ) ++ await vi.advanceTimersByTimeAsync(20) ++ await flushAsyncTicks(20) ++ const spawnHandler = createdTransportOptions[0]?.onPtySpawn as ++ | ((ptyId: string) => void) ++ | undefined ++ spawnHandler?.(ptyId) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('samples exactly one visible restored PTY with no stronger identity signal', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-restored-visible-sample' ++ ++ const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId }) ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) ++ expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { ++ agent: 'codex', ++ shellForeground: false ++ }) ++ }) ++ ++ it('does not sample hidden restored PTYs', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-hidden-restored-no-sample' ++ ++ await connectRestoredPaneForForegroundSampling({ ++ ptyId, ++ isVisibleRef: { current: false } ++ }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('samples once when an identityless hidden pane resumes visible', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const isVisibleRef = { current: false } ++ const ptyId = 'pty-hidden-then-visible-sample' ++ const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef }) ++ await advanceVisibleForegroundRead() ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ ++ isVisibleRef.current = true ++ binding.noteVisibilityResume() ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) ++ }) ++ ++ it('does not sample when launch metadata already supplies tab identity', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-launch-identity-no-sample' ++ const tabId = `tab-${ptyId}` ++ mockStoreState.tabsByWorktree = { ++ 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] ++ } ++ ++ await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('does not sample when a live hook row already supplies pane identity', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-hook-identity-no-sample' ++ const tabId = `tab-${ptyId}` ++ mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = { ++ state: 'working', ++ agentType: 'codex' ++ } ++ ++ await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('does not sample when process identity is already known', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-process-identity-no-sample' ++ const tabId = `tab-${ptyId}` ++ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { ++ agent: 'codex', ++ shellForeground: false ++ } ++ ++ await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => { ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-shell-foreground-no-sample' ++ const tabId = `tab-${ptyId}` ++ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { ++ agent: null, ++ shellForeground: true ++ } ++ ++ await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) ++ }) ++ ++ it('re-samples a shell-marked pane a launch agent still owns', async () => { ++ // Why: a reattach or a full-screen agent's leaked nested-shell 133;D leaves ++ // shellForeground on a launchAgent pane, suppressing its icon. The launch ++ // metadata means an agent is expected, so re-read to recover its identity. ++ vi.useFakeTimers() ++ const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) ++ getForegroundProcess.mockResolvedValue('codex') ++ const ptyId = 'pty-shell-foreground-launch-agent-sample' ++ const tabId = `tab-${ptyId}` ++ mockStoreState.tabsByWorktree = { ++ 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] ++ } ++ mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { ++ agent: null, ++ shellForeground: true ++ } ++ ++ const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) ++ await advanceVisibleForegroundRead() ++ ++ expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) ++ expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { ++ agent: 'codex', ++ shellForeground: false ++ }) ++ }) ++ }) ++ + describe('terminal input liveness IPC gating (perf)', () => { + // Why (perf regression guard): listSessions() is a renderer→main→daemon + // round-trip over every live session. Terminal input must never start that +diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts +index dffa30c47..31713d1e9 100644 +--- a/src/renderer/src/components/terminal-pane/pty-connection.ts ++++ b/src/renderer/src/components/terminal-pane/pty-connection.ts +@@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm' + import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' + import { + detectAgentStatusFromTitle, ++ agentTypeToIconAgent, + isGeminiTerminalTitle, + isClaudeAgent + } from '@/lib/agent-status' +@@ -164,6 +165,7 @@ import { + normalizeCompatibleAgentTitleForOwner, + resolveCompatibleAgentTypeForOwner + } from '../../../../shared/agent-title-owner' ++import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' + import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +@@ -666,6 +668,10 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 + type PanePtyBinding = IDisposable & { + syncProcessTracking: () => void + noteVisibilityResume: () => void ++ /** Re-sample process identity when the pane gains intra-tab focus: the tab ++ * icon follows the active leaf, and a shell-marked entry on a still-running ++ * agent pane has no OSC boundary left to correct it. */ ++ sampleForegroundAgentOnFocus: () => void + reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void + reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void + } +@@ -1751,12 +1757,81 @@ export function connectPanePty( + } + return pendingWrite.then(() => interruptInference.flushPending()) + } ++ // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a ++ // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, ++ // even on a restore where no command-start read has recorded evidence yet. ++ const paneHasKnownAgentIdentity = (): boolean => { ++ const state = useAppStore.getState() ++ const entry = state.agentStatusByPaneKey[cacheKey] ++ if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) { ++ return true ++ } ++ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( ++ (candidate) => candidate.id === deps.tabId ++ ) ++ return Boolean( ++ tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ++ ) ++ } ++ // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints ++ // it on exit, so a confirmed return-to-shell must clear a title that still ++ // names an agent — otherwise the tab reads "grok" over a bare prompt. Only ++ // reset an agent-named title; user/shell-set titles are left untouched. ++ const clearStaleAgentTabTitleOnConfirmedShell = (): void => { ++ const state = useAppStore.getState() ++ const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] ++ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( ++ (entry) => entry.id === deps.tabId ++ ) ++ const title = currentTitle ?? tab?.title ++ if (!title || resolveExplicitTerminalTitleAgentType(title) === null) { ++ return ++ } ++ const neutralTitle = neutralTerminalTitle() ++ deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) ++ if (manager.getActivePane()?.id === pane.id) { ++ deps.updateTabTitle(deps.tabId, neutralTitle) ++ } ++ } + const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ + getPtyId: () => transport.getPtyId(), + isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null, + readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), +- publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry) ++ publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry), ++ hasKnownAgentIdentity: paneHasKnownAgentIdentity, ++ onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell + }) ++ const sampleVisiblePaneForegroundAgent = (): void => { ++ if (!deps.isVisibleRef.current) { ++ return ++ } ++ const state = useAppStore.getState() ++ const foreground = state.paneForegroundAgentByPaneKey[cacheKey] ++ // Why: live process identity already paints the icon — nothing to read. ++ if (foreground?.agent) { ++ return ++ } ++ const entry = state.agentStatusByPaneKey[cacheKey] ++ if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) { ++ return ++ } ++ const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( ++ (candidate) => candidate.id === deps.tabId ++ ) ++ const expectsAgent = Boolean( ++ tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent ++ ) ++ // Why: with no shell mark yet, launchAgent bootstrap already paints the icon, ++ // so a read is pointless. Otherwise probe the foreground: a shell mark (from a ++ // reattach or a full-screen agent's leaked nested-shell 133;D) can hide a ++ // still-running agent — including a manually started one with no launchAgent or ++ // hook, which no later 133;C will reseed. A genuinely idle shell just returns a ++ // shell name and publishes nothing, so the probe is self-limiting. ++ if (!foreground?.shellForeground && expectsAgent) { ++ return ++ } ++ paneForegroundAgentTracker.onVisiblePtyBound() ++ } + const commandLifecycle = createTerminalCommandLifecycle({ + onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), + onCommandFinished: () => { +@@ -1829,6 +1904,14 @@ export function connectPanePty( + ) { + return + } ++ // Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's ++ // OSC 133;C hook, so a manually launched agent produces no command-start ++ // signal at all. Enter at a shell-foreground prompt is the user-side ++ // equivalent; the sample is gated to panes with no live agent identity ++ // and publishes nothing for an idle shell. ++ if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) { ++ sampleVisiblePaneForegroundAgent() ++ } + deps.clearTerminalTabUnread(deps.tabId) + deps.clearTerminalPaneUnread(cacheKey) + deps.clearWorktreeUnread(deps.worktreeId) +@@ -2225,7 +2308,11 @@ export function connectPanePty( + } + const bindActivePanePty = ( + ptyId: string, +- options: { seedInitialAgentStatus?: boolean; updateTabPtyId?: 'always' | 'if-missing' } = {} ++ options: { ++ seedInitialAgentStatus?: boolean ++ updateTabPtyId?: 'always' | 'if-missing' ++ sampleVisibleForegroundAgent?: boolean ++ } = {} + ): void => { + if (activePanePtyBinding && activePanePtyBinding !== ptyId) { + reportPanePtyVisibility(activePanePtyBinding, false) +@@ -2248,6 +2335,11 @@ export function connectPanePty( + // frame-level sync often runs before that async result arrives. + scheduleRuntimeGraphSync() + agentCompletionCoordinator.startProcessTracking() ++ // Why: fresh spawns receive future OSC command-start events; only adopted or ++ // restored PTYs may already be inside Codex with no new foreground signal. ++ if (options.sampleVisibleForegroundAgent === true) { ++ sampleVisiblePaneForegroundAgent() ++ } + } + + const onPtySpawn = (ptyId: string): void => { +@@ -3657,7 +3749,10 @@ export function connectPanePty( + ) { + // Why: daemon createOrAttach can turn an apparent fresh spawn into + // a reattach; the transport skips onPtySpawn there to preserve recency. +- bindActivePanePty(resolvedPtyId, { updateTabPtyId: 'if-missing' }) ++ bindActivePanePty(resolvedPtyId, { ++ updateTabPtyId: 'if-missing', ++ sampleVisibleForegroundAgent: true ++ }) + } + if (resolvedPtyId) { + reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) +@@ -5223,6 +5318,7 @@ export function connectPanePty( + deps.syncPanePtyLayoutBinding(pane.id, ptyId) + deps.updateTabPtyId(deps.tabId, ptyId) + agentCompletionCoordinator.startProcessTracking() ++ sampleVisiblePaneForegroundAgent() + + // Why: mobile terminal streaming needs the exact screen state from + // xterm.js. The shared helper installs both the SerializeAddon-backed +@@ -5784,7 +5880,10 @@ export function connectPanePty( + onError: reportError + } + }) +- bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' }) ++ bindActivePanePty(attachPtyId, { ++ updateTabPtyId: 'if-missing', ++ sampleVisibleForegroundAgent: true ++ }) + if (attachPtyId === eagerLivePtyId) { + registerPaneSerializerFor(attachPtyId) + } +@@ -5838,7 +5937,10 @@ export function connectPanePty( + }) + // Why: this path reuses a PTY spawned by an earlier mount, so no + // later spawn event will bind this remounted pane's DOM/container. +- bindActivePanePty(spawnedPtyId, { updateTabPtyId: 'if-missing' }) ++ bindActivePanePty(spawnedPtyId, { ++ updateTabPtyId: 'if-missing', ++ sampleVisibleForegroundAgent: true ++ }) + }) + .catch((err) => { + reportError(err instanceof Error ? err.message : String(err)) +@@ -5948,6 +6050,10 @@ export function connectPanePty( + noteVisibilityResume() { + ptySizeReassertion.request({ fit: false }) + consumeHibernatedAgentWake() ++ sampleVisiblePaneForegroundAgent() ++ }, ++ sampleForegroundAgentOnFocus() { ++ sampleVisiblePaneForegroundAgent() + }, + reconcileIfSessionDead, + reconcileIfSessionMissing, +diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +index e037e1e7f..bcf5835a6 100644 +--- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts ++++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +@@ -1281,6 +1281,13 @@ export function useTerminalPaneLifecycle({ + persistLayoutSnapshot() + } + reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id) ++ // Why: the tab icon resolves from the active leaf's process identity; ++ // focusing a shell-marked pane whose agent is still running must ++ // re-sample it, since no further OSC boundary will. ++ const focusedBinding = panePtyBindings.get(pane.id) as ++ | (IDisposable & { sampleForegroundAgentOnFocus?: () => void }) ++ | undefined ++ focusedBinding?.sampleForegroundAgentOnFocus?.() + // Why: when the user switches focus between split panes, update the + // tab title to the newly active pane's last-known title so the tab + // label reflects the focused agent — not a stale title from the diff --git a/CODEX_ICON_DEBUG_HANDOFF.md b/CODEX_ICON_DEBUG_HANDOFF.md new file mode 100644 index 00000000000..9e6ba6c7831 --- /dev/null +++ b/CODEX_ICON_DEBUG_HANDOFF.md @@ -0,0 +1,82 @@ +# RESOLVED: Codex/agent terminal tab icon not showing + +Branch: `fix-codex-icon-not-showing-up-on-terminal`. All fixes validated live +end-to-end on 2026-07-05 in an isolated dev instance (fresh profile + fresh +daemon, real clicks via playwright-cli, visible-icon screenshots). + +## Root causes found (three, stacked) + +1. **bash-preexec swallows Orca's OSC 133;C** (the original bug on this + machine). The user's bash setup (iTerm2 shell integration) loads + bash-preexec, which re-arms its own DEBUG trap at the first prompt, + silencing Orca's `trap '__orca_osc133_preexec' DEBUG`. Result: typing + `codex`/`grok` emits NO command-start signal, so foreground detection never + begins. D (from PROMPT_COMMAND precmd) still fires at prompts, so panes get + marked shell-foreground, which also suppresses the title-layer icon. + Verified live: `trap -p DEBUG` showed `__bp_preexec_invoke_exec`. + **Fix**: `shell-ready.ts` bash wrapper also registers + `__orca_osc133_preexec_bp` in bash-preexec's `preexec_functions` array + (gated on `__orca_in_command` so C fires exactly once either way). + +2. **Daemon foreground cache TTL vs renderer retry cadence.** For node-wrapped + CLIs (grok's `node /opt/homebrew/bin/grok` trampoline), the daemon resolves + node→grok asynchronously into a 1000ms-TTL cache, but the renderer's wrapper + retries fire at +1200/+3500ms — always after expiry — so every read returned + raw `"node"` and detection failed. Verified live: reads alternated + node/grok/grok/node at 700ms intervals. + **Fix**: `pty-subprocess.ts` serves the last resolved identity while the + foreground is still an unidentifiable wrapper (stale-while-revalidate), and + clears an expired identity when a refresh proves the wrapper tree has no + agent (so `npm` after an agent exit can't inherit the icon). + +3. **Duplicate OSC 133;D defeats the confirming read.** User shell + integrations double up Orca's 133 sequences: every D arrives twice ~50ms + apart (verified via a PTY data tap: `D;0 … 53ms … D;0`). The second D saw no + pending 'command' read (only the first D's pending 'command-finished' read), + took the no-RPC fast path, published `{null, shellForeground: true}`, and + cancelled the in-flight confirmation. + **Fix**: `pane-foreground-agent-tracker.ts` counts a pending/in-flight + confirming read as "read pending", so duplicate Ds re-confirm. + +Plus two renderer resilience fixes for "focused pane changes" (user report): + +4. **Enter-triggered sampling** (`pty-connection.ts` onTerminalKeyDown): Enter + at a shell-foreground pane triggers the existing self-limiting foreground + sample — covers any setup where no 133;C arrives at all. Publishes nothing + for idle shells; skipped when an agent identity is already live. +5. **Focus-change resampling** (`use-terminal-pane-lifecycle.ts` + onActivePaneChange → `sampleForegroundAgentOnFocus`): the tab icon follows + the active leaf, so focusing a shell-marked pane whose agent is still + running re-samples it (recovers poisoned entries in splits). + +## Validated live (fresh build, new tabs, real clicks) + +- Typed `grok` in an already-focused pane → entry `{agent:'grok'}` + grok icon + on the tab within ~6s. +- Split right + typed `codex` → both panes' entries correct + (`{agent:'grok'}` / `{agent:'codex'}`); tab icon follows the focused pane + (grok icon ↔ codex icon on click), screenshots confirmed. +- Quit codex → entry cleared, tab shows plain terminal icon for the shell + pane; grok icon returns when its pane regains focus. + +## Tests + +- `shell-ready.test.ts` 23/23 (new: bash-preexec re-arm regression test that + fails without fix 1). +- `pty-subprocess.test.ts` 85/85 (new: stale-serve past TTL + expired-identity + clear). +- `pane-foreground-agent-tracker.test.ts` 23/23 (new: duplicate-D pair + confirm + idle-pane duplicate-D no-RPC path). +- `tsgo` web + node configs clean, oxlint clean. +- KNOWN: `pty-connection.test.ts` cannot load on this branch (pre-existing + vite resolution error for `pane-terminal-foreground-render-settle`; rebase + onto main to run its added tests). + +## Notes for review/merge + +- `CODEX_ICON_DEBUG.diff` is stale (pre-dates these fixes); use `git diff`. +- The daemon is a long-lived process: users (and dev instances) keep running + the OLD daemon code until it restarts. The bash rcfile is rewritten at + daemon startup, so fix 1 also needs a daemon restart to take effect. +- SSH panes are unaffected: the tracker's reads are gated by isTrackablePtyId + (local only); the shell-side C fix benefits every OSC 133 consumer. diff --git a/fresh-launch.png b/fresh-launch.png new file mode 100644 index 00000000000..a2c1407aabb Binary files /dev/null and b/fresh-launch.png differ diff --git a/grok-detected.png b/grok-detected.png new file mode 100644 index 00000000000..8e784123f43 Binary files /dev/null and b/grok-detected.png differ diff --git a/src/main/daemon/pty-subprocess.test.ts b/src/main/daemon/pty-subprocess.test.ts index 85366a1377b..b23c83656b1 100644 --- a/src/main/daemon/pty-subprocess.test.ts +++ b/src/main/daemon/pty-subprocess.test.ts @@ -367,6 +367,81 @@ describe('createPtySubprocess', () => { } }) + it('serves the resolved agent identity past the cache TTL while a wrapper holds the foreground', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z')) + const proc = mockPtyProcess() + proc.process = 'node' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + resolveAgentForegroundProcessMock.mockResolvedValue('grok') + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24 + }) + + expect(handle.getForegroundProcess()).toBe('node') + await Promise.resolve() + await Promise.resolve() + expect(handle.getForegroundProcess()).toBe('grok') + + // Why: renderer reads poll slower than the 1s cache TTL — an expired + // cache must keep answering with the resolved identity, not the wrapper. + vi.advanceTimersByTime(1_500) + expect(handle.getForegroundProcess()).toBe('grok') + } finally { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + + it('clears an expired identity when the wrapper tree no longer resolves to an agent', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-16T12:00:00.000Z')) + const proc = mockPtyProcess() + proc.process = 'node' + spawnMock.mockReturnValue(proc) + const platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { value: 'darwin' }) + resolveAgentForegroundProcessMock.mockResolvedValueOnce('grok').mockResolvedValue('node') + + try { + const handle = createPtySubprocess({ + sessionId: 'test', + cols: 80, + rows: 24 + }) + + expect(handle.getForegroundProcess()).toBe('node') + await Promise.resolve() + await Promise.resolve() + expect(handle.getForegroundProcess()).toBe('grok') + // Flush the first refresh's finally so the next read can revalidate. + await Promise.resolve() + await Promise.resolve() + + // An unrelated wrapper (e.g. npm) now owns the pane: the stale-served + // identity is revalidated and dropped once the refresh finds no agent. + vi.advanceTimersByTime(1_500) + expect(handle.getForegroundProcess()).toBe('grok') + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + expect(handle.getForegroundProcess()).toBe('node') + } finally { + vi.useRealTimers() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + } + }) + it('serves daemon Windows wrapper agent foreground from an async cache', async () => { const proc = mockPtyProcess() proc.process = 'node.exe' diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index 6dd0554bb87..09f66a98739 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -937,6 +937,16 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ) { cachedAgentForeground = null startupAgentForeground = null + } else if ( + cachedAgentForeground !== null && + Date.now() - cachedAgentForeground.refreshedAt > FOREGROUND_AGENT_CACHE_TTL_MS && + currentFallbackProcess !== null && + isAgentForegroundWrapperProcess(currentFallbackProcess) + ) { + // Why: the wrapper's tree no longer resolves to an agent — an expired + // identity must not transfer to an unrelated wrapper (e.g. npm right + // after an agent exit). Fresh identities survive one-off scan hiccups. + cachedAgentForeground = null } return } @@ -994,6 +1004,18 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl ) { return cachedAgentForeground.processName } + // Why: a wrapper foreground (node/python) can never identify itself, and + // readers poll slower than the cache TTL — returning the raw wrapper here + // would hide the resolved identity forever. Serve the last resolved agent + // while the scheduled refresh revalidates; exit truth is safe because an + // exited agent's foreground falls back to the shell, not a wrapper. + if ( + cachedAgentForeground && + fallbackProcess !== null && + isAgentForegroundWrapperProcess(fallbackProcess) + ) { + return cachedAgentForeground.processName + } const activeStartupAgentForeground = getActiveStartupAgentForeground(now) if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) { return activeStartupAgentForeground.processName diff --git a/src/main/daemon/shell-ready.test.ts b/src/main/daemon/shell-ready.test.ts index 860e3f27a9e..ce1a67da7ca 100644 --- a/src/main/daemon/shell-ready.test.ts +++ b/src/main/daemon/shell-ready.test.ts @@ -556,6 +556,34 @@ describePosix('daemon shell-ready launch config', () => { } ) + itWithBash( + 'still emits 133;C when bash-preexec re-arms the DEBUG trap at first prompt', + async () => { + const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady() + // Minimal bash-preexec imitation (iTerm2/starship setups): re-arms its own + // DEBUG trap from PROMPT_COMMAND at the first prompt — silencing Orca's + // trap — and dispatches preexec_functions with the command as $1. + writeFileSync( + join(userDataPath, '.bash_profile'), + [ + 'preexec_functions=()', + '__bp_preexec_invoke_exec() {', + ' [[ -n "${__bp_interactive_mode:-}" ]] || return', + ' __bp_interactive_mode=""', + ' local f', + ' for f in "${preexec_functions[@]}"; do "$f" "$BASH_COMMAND"; done', + '}', + "__bp_arm() { __bp_interactive_mode=1; trap '__bp_preexec_invoke_exec' DEBUG; }", + 'PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND;}__bp_arm"' + ].join('\n') + ) + + const output = runInteractiveBashRcfile(getDaemonBashShellReadyRcfileContent(), userDataPath) + + expectBashOsc133Lifecycle(output) + } + ) + itWithBash('normalizes array PROMPT_COMMAND hooks so bash 3.2 still runs cleanup', async () => { const { getDaemonBashShellReadyRcfileContent } = await importFreshShellReady() writeFileSync( diff --git a/src/main/daemon/shell-ready.ts b/src/main/daemon/shell-ready.ts index 7ad5a686bb7..169be2f53fc 100644 --- a/src/main/daemon/shell-ready.ts +++ b/src/main/daemon/shell-ready.ts @@ -195,6 +195,23 @@ if [[ -n "$__orca_debug_trap_spec" ]]; then fi unset __orca_debug_trap_spec __orca_debug_trap_command unset -f __orca_normalize_prompt_command __orca_prepend_prompt_command __orca_append_prompt_command +# Why: bash-preexec (pulled in by iTerm2/starship setups) re-arms its own DEBUG +# trap at the first prompt, silencing ours. Register with its preexec_functions +# array too. Bash holds exactly one DEBUG trap, so only one dispatch path is +# ever live and C cannot double-emit; the __orca_in_command gate is a backstop. +__orca_osc133_preexec_bp() { + [[ -z "\${__orca_in_command:-}" ]] || return + [[ -z "\${__orca_in_prompt_command:-}" ]] || return + case "\${1:-}" in + *__orca_osc133_precmd*|*__orca_osc133_prompt_done*|*__orca_prompt_mark*) return ;; + esac + printf "\\033]133;C\\007" + __orca_in_command=1 +} +case " \${preexec_functions[*]:-} " in + *" __orca_osc133_preexec_bp "*) ;; + *) preexec_functions+=(__orca_osc133_preexec_bp) ;; +esac # Why: arm DEBUG after wrapper setup; otherwise bash treats our own rcfile # commands as a foreground command and emits a fake C/D before the first prompt. trap '__orca_osc133_preexec' DEBUG 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 5a2e8c25840..ab5aaa03b8c 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 @@ -5,12 +5,14 @@ import { createPaneForegroundAgentTracker } from './pane-foreground-agent-tracke import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' const COMMAND_SETTLE_MS = 350 +const VISIBLE_PTY_SETTLE_MS = 350 const WRAPPER_RESOLVE_RETRY_MS = 1200 const SECOND_WRAPPER_RETRY_MS = 3500 describe('createPaneForegroundAgentTracker', () => { const readForegroundProcess = vi.fn<(ptyId: string) => Promise>() const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>() + const onConfirmedShellForeground = vi.fn<() => void>() let ptyId: string | null = 'pty-1' function makeTracker(): ReturnType { @@ -18,7 +20,8 @@ describe('createPaneForegroundAgentTracker', () => { getPtyId: () => ptyId, isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), readForegroundProcess, - publish + publish, + onConfirmedShellForeground }) } @@ -30,6 +33,7 @@ describe('createPaneForegroundAgentTracker', () => { vi.useFakeTimers() readForegroundProcess.mockReset() publish.mockReset() + onConfirmedShellForeground.mockReset() ptyId = 'pty-1' }) @@ -52,6 +56,72 @@ describe('createPaneForegroundAgentTracker', () => { expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) }) + it('reads the foreground for a visible PTY so restored running Codex panes regain identity', async () => { + readForegroundProcess.mockResolvedValue('codex') + const tracker = makeTracker() + + tracker.onVisiblePtyBound() + expect(readForegroundProcess).not.toHaveBeenCalled() + + await flushSettleRead(VISIBLE_PTY_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + }) + + it('does not retry or publish visible PTY reads for an idle shell foreground', async () => { + readForegroundProcess.mockResolvedValue('zsh') + const tracker = makeTracker() + + tracker.onVisiblePtyBound() + await flushSettleRead( + VISIBLE_PTY_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS + SECOND_WRAPPER_RETRY_MS + 10_000 + ) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).not.toHaveBeenCalled() + }) + + it('lets command-start sampling supersede a pending visible PTY read', async () => { + readForegroundProcess.mockResolvedValue('codex') + const tracker = makeTracker() + + tracker.onVisiblePtyBound() + tracker.onCommandStarted() + await flushSettleRead(VISIBLE_PTY_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + }) + + it('does not let visible PTY sampling downgrade pending command-start sampling', async () => { + readForegroundProcess.mockResolvedValueOnce('bash').mockResolvedValueOnce('codex') + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onVisiblePtyBound() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(1) + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + }) + + it('retries visible PTY reads only while a foreground wrapper may resolve to an agent', async () => { + readForegroundProcess.mockResolvedValueOnce('node').mockResolvedValueOnce('codex') + const tracker = makeTracker() + + tracker.onVisiblePtyBound() + await flushSettleRead(VISIBLE_PTY_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(1) + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(WRAPPER_RESOLVE_RETRY_MS) + expect(readForegroundProcess).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + }) + it('re-reads on a bounded ladder while the read still sees an interpreter wrapper', async () => { // Why: daemon shell/helper→agent ancestry resolution has been observed to // take >1.5s for real node-wrapped CLIs, so the ladder gets two re-reads. @@ -124,16 +194,159 @@ describe('createPaneForegroundAgentTracker', () => { expect(readForegroundProcess).not.toHaveBeenCalled() }) - it('cancels a pending read when the command finishes first', async () => { + it('confirms a rapid command start->finish instead of trusting the D', async () => { + // Why: a leaked nested-shell 133;C->133;D pair (or a fast real command) + // cancels the command-start read; confirm the foreground so a still-running + // manual agent with no launchAgent/hook keeps its identity. readForegroundProcess.mockResolvedValue('claude') const tracker = makeTracker() tracker.onCommandStarted() tracker.onCommandFinished() - await flushSettleRead(COMMAND_SETTLE_MS + WRAPPER_RESOLVE_RETRY_MS) + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'claude', shellForeground: false }) + expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + }) + + it('confirms duplicate 133;D pairs instead of fast-pathing past the pending confirmation', async () => { + // Why: user shell integrations (iTerm/VS Code) double up Orca's OSC 133, so + // every D arrives twice ~50ms apart. The second D cancels the first D's + // confirming read; it must reschedule the confirmation, not trust the D. + readForegroundProcess.mockResolvedValue('grok') + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onCommandStarted() + tracker.onCommandFinished() + await flushSettleRead(50) + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'grok', shellForeground: false }) + expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + }) + + it('still marks shell without a read for a duplicate D pair on an idle pane', async () => { + const tracker = makeTracker() + + tracker.onCommandFinished() + await flushSettleRead(50) + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + + // Why: with no command read in flight the first D takes the no-RPC path, + // so its duplicate has nothing to re-confirm and stays RPC-free too. + expect(readForegroundProcess).not.toHaveBeenCalled() + expect(publish).toHaveBeenCalledTimes(2) + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + }) + + it('marks shell on a rapid command start->finish when the foreground is a shell', async () => { + readForegroundProcess.mockResolvedValue('zsh') + const tracker = makeTracker() + + tracker.onCommandStarted() + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + }) + + it('confirms the foreground before clearing a pane an agent has owned', async () => { + readForegroundProcess.mockResolvedValue('codex') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + + publish.mockClear() + readForegroundProcess.mockClear() + tracker.onCommandFinished() + // Why: a leaked nested-shell 133;D must not clear Codex before the read. + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) + expect(publish).not.toHaveBeenCalledWith({ agent: null, shellForeground: true }) + }) + + it('marks shell foreground when the confirming read shows the agent is gone', async () => { + readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + + publish.mockClear() + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + + expect(readForegroundProcess).toHaveBeenLastCalledWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + }) + + it('signals confirmed shell foreground only when the read proves the agent exited', async () => { + // Reads: command-start=codex, first finish=codex (still running), second finish=zsh (exited). + readForegroundProcess + .mockResolvedValueOnce('codex') + .mockResolvedValueOnce('codex') + .mockResolvedValueOnce('zsh') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + + // A leaked nested-shell 133;D while Codex still owns the foreground: no signal. + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(onConfirmedShellForeground).not.toHaveBeenCalled() + + // Genuine exit -> read sees a shell -> signal fires exactly once. + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + expect(onConfirmedShellForeground).toHaveBeenCalledTimes(1) + }) + + it('returns to the no-RPC finished path after the agent is confirmed gone', async () => { + readForegroundProcess.mockResolvedValueOnce('codex').mockResolvedValueOnce('zsh') + const tracker = makeTracker() + + tracker.onCommandStarted() + await flushSettleRead(COMMAND_SETTLE_MS) + tracker.onCommandFinished() + await flushSettleRead(COMMAND_SETTLE_MS) + + publish.mockClear() + readForegroundProcess.mockClear() + tracker.onCommandFinished() expect(readForegroundProcess).not.toHaveBeenCalled() - expect(publish).toHaveBeenLastCalledWith({ agent: null, shellForeground: true }) + expect(publish).toHaveBeenCalledExactlyOnceWith({ agent: null, shellForeground: true }) + }) + + it('confirms a command finished for a launch-known agent pane before any read', async () => { + readForegroundProcess.mockResolvedValue('codex') + const tracker = createPaneForegroundAgentTracker({ + getPtyId: () => ptyId, + isTrackablePtyId: (id) => !id.startsWith('remote:') && !id.startsWith('ssh:'), + readForegroundProcess, + publish, + hasKnownAgentIdentity: () => true + }) + + tracker.onCommandFinished() + // Why: launchAgent/hook identity means the 133;D is confirmed, not trusted. + expect(publish).not.toHaveBeenCalled() + + await flushSettleRead(COMMAND_SETTLE_MS) + expect(readForegroundProcess).toHaveBeenCalledExactlyOnceWith('pty-1') + expect(publish).toHaveBeenLastCalledWith({ agent: 'codex', shellForeground: false }) }) it('never reads or publishes for remote or ssh panes', 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 1af2f78fdd7..8e9d3732780 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,4 +1,7 @@ -import { recognizeAgentProcess } from '../../../../shared/agent-process-recognition' +import { + isAgentForegroundWrapperProcess, + recognizeAgentProcess +} from '../../../../shared/agent-process-recognition' import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' // Why: the read must land after the shell has exec'd the command; and when it @@ -6,7 +9,9 @@ import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-ag // asynchronously — observed to take >1.5s for real node-wrapped CLIs — so // give its cache two bounded re-reads, not an open-ended retry loop. const COMMAND_SETTLE_MS = 350 +const VISIBLE_PTY_SETTLE_MS = 350 const WRAPPER_RESOLVE_RETRY_DELAYS_MS = [1200, 3500] as const +type ForegroundReadReason = 'command' | 'visible-pty' | 'command-finished' type PaneForegroundAgentTrackerDeps = { getPtyId: () => string | null @@ -15,22 +20,39 @@ type PaneForegroundAgentTrackerDeps = { isTrackablePtyId: (ptyId: string) => boolean readForegroundProcess: (ptyId: string) => Promise publish: (entry: PaneForegroundAgentEntry) => 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. */ + hasKnownAgentIdentity?: () => boolean + /** 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?: () => void } /** * Publishes process-table identity for a pane at OSC 133 command boundaries: * one foreground read when a command starts (that is when the foreground - * changes), and a no-RPC shell-foreground mark when it finishes — 133;D is - * the ONLY source of shell-foreground proof; reads never produce it. + * changes), and a shell-foreground mark when it finishes. A 133;D is normally + * the shell-foreground proof; for a pane an agent has owned it is confirmed by a + * foreground read first, because a full-screen agent's nested command shells + * leak their own 133;D onto the main PTY. */ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTrackerDeps): { + onVisiblePtyBound: () => void onCommandStarted: () => void onCommandFinished: () => void dispose: () => void } { let disposed = false - let readTimer: number | null = null + let readTimer: ReturnType | null = null + let scheduledReadReason: ForegroundReadReason | null = null + let activeReadReason: ForegroundReadReason | null = null let readGeneration = 0 + // Why: a full-screen agent (Codex, etc.) runs nested command shells whose own + // OSC 133;D leaks onto the main PTY. For a pane an agent has owned, that D is + // not proof the prompt returned, so confirm the foreground before clearing. + let hasForegroundAgentEvidence = false const trackablePtyId = (): string | null => { const ptyId = deps.getPtyId() @@ -40,30 +62,53 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke const cancelPendingRead = (): void => { readGeneration += 1 if (readTimer !== null) { - window.clearTimeout(readTimer) + clearTimeout(readTimer) readTimer = null } + scheduledReadReason = null + activeReadReason = null } - const scheduleRead = (delayMs: number, retryIndex: number): void => { + const scheduleRead = ( + delayMs: number, + retryIndex: number, + reason: ForegroundReadReason + ): void => { const generation = readGeneration - readTimer = window.setTimeout(() => { + scheduledReadReason = reason + readTimer = setTimeout(() => { readTimer = null - void readForeground(generation, retryIndex) + scheduledReadReason = null + activeReadReason = reason + void readForeground(generation, retryIndex, reason).finally(() => { + if (generation === readGeneration && activeReadReason === reason) { + activeReadReason = null + } + }) }, delayMs) } - async function readForeground(generation: number, retryIndex: number): Promise { + async function readForeground( + generation: number, + retryIndex: number, + reason: ForegroundReadReason + ): Promise { const ptyId = trackablePtyId() if (disposed || generation !== readGeneration || !ptyId) { return } - const processName = await deps.readForegroundProcess(ptyId).catch(() => null) + let processName: string | null = null + try { + processName = await deps.readForegroundProcess(ptyId) + } catch { + processName = null + } if (disposed || generation !== readGeneration) { return } const recognized = recognizeAgentProcess(processName) if (recognized) { + hasForegroundAgentEvidence = true deps.publish({ agent: recognized.agent, shellForeground: false }) return } @@ -72,14 +117,50 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // a nested one (sh/bash without integration); marking shell-foreground // would suppress live title identity. Only 133;D proves the prompt. const retryDelay = WRAPPER_RESOLVE_RETRY_DELAYS_MS[retryIndex] - if (retryDelay !== undefined && processName) { - scheduleRead(retryDelay, retryIndex + 1) + const shouldRetry = + retryDelay !== undefined && + processName && + (reason === 'command' || isAgentForegroundWrapperProcess(processName)) + if (shouldRetry) { + scheduleRead(retryDelay, retryIndex + 1, reason) return } - deps.publish({ agent: null, shellForeground: false }) + if (reason === 'command') { + deps.publish({ agent: null, shellForeground: false }) + return + } + if (reason === 'command-finished') { + // 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 + 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). + deps.onConfirmedShellForeground?.() + } } return { + onVisiblePtyBound() { + // 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' + ) { + return + } + cancelPendingRead() + if (!trackablePtyId()) { + return + } + // Why: restored/manual agent panes can become visible while Codex is + // already foreground, so no OSC 133 command-start event will seed the tab icon. + scheduleRead(VISIBLE_PTY_SETTLE_MS, 0, 'visible-pty') + }, onCommandStarted() { cancelPendingRead() if (!trackablePtyId()) { @@ -88,14 +169,39 @@ export function createPaneForegroundAgentTracker(deps: PaneForegroundAgentTracke // Why: the foreground left the prompt the moment C fired — stale // shell-foreground evidence must not clear the command that just started. deps.publish({ agent: null, shellForeground: false }) - scheduleRead(COMMAND_SETTLE_MS, 0) + scheduleRead(COMMAND_SETTLE_MS, 0, 'command') }, onCommandFinished() { + // Why: a rapid 133;C→133;D pair cancels the command-start read before it + // can identify the foreground — that pair is exactly a leaked nested-shell + // command under a full-screen agent (or a fast real shell command), so on a + // no-identity pane confirm it rather than trusting the D as a prompt return. + // A pending confirming read counts too: user shell integrations double up + // Orca's OSC 133, and the duplicate D must re-confirm, not fast-path past + // the in-flight confirmation it just cancelled. + const commandReadWasPending = + scheduledReadReason === 'command' || + activeReadReason === 'command' || + scheduledReadReason === 'command-finished' || + activeReadReason === 'command-finished' cancelPendingRead() if (!trackablePtyId()) { return } - deps.publish({ agent: null, shellForeground: true }) + // Why: trust the 133;D and mark shell without an RPC only when nothing hints + // at an agent — no prior agent evidence, no launch/hook identity, and no + // command-start read racing this finish. + if ( + !hasForegroundAgentEvidence && + deps.hasKnownAgentIdentity?.() !== true && + !commandReadWasPending + ) { + deps.publish({ agent: null, shellForeground: true }) + return + } + // Why: confirm the foreground before clearing — if the agent still owns it, + // the read republishes its identity; only a genuine shell result clears it. + scheduleRead(COMMAND_SETTLE_MS, 0, 'command-finished') }, dispose() { disposed = true diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 383d5d141f4..975554d5a33 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -21,6 +21,7 @@ import { beginAgentStartupDeliveryAttempt, resetAgentStartupDelayedDeliveryForTests } from '@/lib/agent-startup-delayed-delivery' +import type { PaneForegroundAgentEntry } from '@/store/slices/pane-foreground-agent' // Repro command: // pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection.test.ts -t "OpenTUI-style small ANSI redraw" @@ -169,6 +170,7 @@ type StoreState = { consumePendingSnapshot: ReturnType runtimePaneTitlesByTabId: Record> agentStatusByPaneKey: Record + paneForegroundAgentByPaneKey: Record sleepingAgentSessionsByPaneKey: Record agentLaunchConfigByPaneKey: Record getAgentLaunchConfigForStatusEntry: ReturnType @@ -737,6 +739,7 @@ describe('connectPanePty', () => { consumePendingSnapshot: vi.fn(() => null), runtimePaneTitlesByTabId: {}, agentStatusByPaneKey: {}, + paneForegroundAgentByPaneKey: {}, sleepingAgentSessionsByPaneKey: {}, agentLaunchConfigByPaneKey: {}, getAgentLaunchConfigForStatusEntry: vi.fn((entry: { paneKey: string }) => { @@ -771,8 +774,12 @@ describe('connectPanePty', () => { ), removeAgentStatus: vi.fn(), dropAgentStatus: vi.fn(), - setPaneForegroundAgent: vi.fn(), - clearPaneForegroundAgent: vi.fn(), + setPaneForegroundAgent: vi.fn((paneKey: string, entry: PaneForegroundAgentEntry) => { + mockStoreState.paneForegroundAgentByPaneKey[paneKey] = entry + }), + clearPaneForegroundAgent: vi.fn((paneKey: string) => { + delete mockStoreState.paneForegroundAgentByPaneKey[paneKey] + }), markTerminalTabUnread: vi.fn(), markTerminalPaneUnread: vi.fn(), markAgentCompletionPaneUnread: vi.fn() @@ -13949,6 +13956,238 @@ describe('connectPanePty', () => { }) }) + describe('visible foreground agent sampling (perf)', () => { + const VISIBLE_PTY_SETTLE_MS = 350 + + // Why: every connectPanePty binding in this file shares the tab-1/LEAF_1 pane + // key, and an undisposed reattach binding elsewhere can resolve a foreground + // read into this test's store slice. Give each sampling case its own tabId so + // no other test's publish can pollute the pane identity it asserts on. + async function connectRestoredPaneForForegroundSampling( + args: { + ptyId?: string + tabId?: string + isVisibleRef?: { current: boolean } + } = {} + ): Promise<{ + binding: { noteVisibilityResume: () => void } + deps: ReturnType + transport: MockTransport + cacheKey: string + }> { + const { connectPanePty } = await import('./pty-connection') + const ptyId = args.ptyId ?? 'tab-pty' + const tabId = args.tabId ?? `tab-${ptyId}` + const transport = createMockTransport(ptyId) + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { + return sessionId ? { id: sessionId } : null + }) + transportFactoryQueue.push(transport) + const deps = createDeps({ + tabId, + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: ptyId }, + ...(args.isVisibleRef ? { isVisibleRef: args.isVisibleRef } : {}) + }) + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) as unknown as { noteVisibilityResume: () => void } + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + return { binding, deps, transport, cacheKey: makePaneKey(tabId, LEAF_1) } + } + + async function advanceVisibleForegroundRead(): Promise { + await vi.advanceTimersByTimeAsync(VISIBLE_PTY_SETTLE_MS) + await flushAsyncTicks() + } + + function foregroundReadCallsFor(ptyId: string): unknown[][] { + return vi + .mocked(window.api.pty.getForegroundProcess) + .mock.calls.filter(([calledPtyId]) => calledPtyId === ptyId) + } + + it('does not inspect foreground process for a fresh visible spawn', async () => { + vi.useFakeTimers() + const { connectPanePty } = await import('./pty-connection') + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-fresh-visible-no-sample' + transportFactoryQueue.push(createMockTransport(ptyId)) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] }, + terminalLayoutsByTabId: { + 'tab-1': { + root: { type: 'leaf', leafId: LEAF_1 }, + activeLeafId: LEAF_1, + expandedLeafId: null, + ptyIdsByLeafId: {} + } + } + } as StoreState + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await vi.advanceTimersByTimeAsync(20) + await flushAsyncTicks(20) + const spawnHandler = createdTransportOptions[0]?.onPtySpawn as + | ((ptyId: string) => void) + | undefined + spawnHandler?.(ptyId) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('samples exactly one visible restored PTY with no stronger identity signal', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-restored-visible-sample' + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId }) + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: 'codex', + shellForeground: false + }) + }) + + it('does not sample hidden restored PTYs', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-hidden-restored-no-sample' + + await connectRestoredPaneForForegroundSampling({ + ptyId, + isVisibleRef: { current: false } + }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('samples once when an identityless hidden pane resumes visible', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const isVisibleRef = { current: false } + const ptyId = 'pty-hidden-then-visible-sample' + const { binding } = await connectRestoredPaneForForegroundSampling({ ptyId, isVisibleRef }) + await advanceVisibleForegroundRead() + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + + isVisibleRef.current = true + binding.noteVisibilityResume() + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + }) + + it('does not sample when launch metadata already supplies tab identity', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-launch-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not sample when a live hook row already supplies pane identity', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-hook-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.agentStatusByPaneKey[makePaneKey(tabId, LEAF_1)] = { + state: 'working', + agentType: 'codex' + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not sample when process identity is already known', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-process-identity-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: 'codex', + shellForeground: false + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('does not re-sample once 133;D proved the pane is at a shell prompt', async () => { + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-shell-foreground-no-sample' + const tabId = `tab-${ptyId}` + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: null, + shellForeground: true + } + + await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toHaveLength(0) + }) + + it('re-samples a shell-marked pane a launch agent still owns', async () => { + // Why: a reattach or a full-screen agent's leaked nested-shell 133;D leaves + // shellForeground on a launchAgent pane, suppressing its icon. The launch + // metadata means an agent is expected, so re-read to recover its identity. + vi.useFakeTimers() + const getForegroundProcess = vi.mocked(window.api.pty.getForegroundProcess) + getForegroundProcess.mockResolvedValue('codex') + const ptyId = 'pty-shell-foreground-launch-agent-sample' + const tabId = `tab-${ptyId}` + mockStoreState.tabsByWorktree = { + 'wt-1': [{ id: tabId, ptyId, launchAgent: 'codex' }] + } + mockStoreState.paneForegroundAgentByPaneKey[makePaneKey(tabId, LEAF_1)] = { + agent: null, + shellForeground: true + } + + const { cacheKey } = await connectRestoredPaneForForegroundSampling({ ptyId, tabId }) + await advanceVisibleForegroundRead() + + expect(foregroundReadCallsFor(ptyId)).toEqual([[ptyId]]) + expect(mockStoreState.setPaneForegroundAgent).toHaveBeenCalledWith(cacheKey, { + agent: 'codex', + shellForeground: false + }) + }) + }) + describe('terminal input liveness IPC gating (perf)', () => { // Why (perf regression guard): listSessions() is a renderer→main→daemon // round-trip over every live session. Terminal input must never start that diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index dffa30c47d8..31713d1e92b 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -5,6 +5,7 @@ import type { IBuffer, IDisposable } from '@xterm/xterm' import { resolveCursorAgentImeAnchor } from '@/lib/pane-manager/terminal-ime-anchor' import { detectAgentStatusFromTitle, + agentTypeToIconAgent, isGeminiTerminalTitle, isClaudeAgent } from '@/lib/agent-status' @@ -164,6 +165,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner' +import { resolveExplicitTerminalTitleAgentType } from '../../../../shared/terminal-title-agent-type' import { isExpectedAgentProcess, recognizeAgentProcessFromCommandLine @@ -666,6 +668,10 @@ let inactiveForegroundImmediateBudgetWindowStart = 0 type PanePtyBinding = IDisposable & { syncProcessTracking: () => void noteVisibilityResume: () => void + /** Re-sample process identity when the pane gains intra-tab focus: the tab + * icon follows the active leaf, and a shell-marked entry on a still-running + * agent pane has no OSC boundary left to correct it. */ + sampleForegroundAgentOnFocus: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void } @@ -1751,12 +1757,81 @@ export function connectPanePty( } return pendingWrite.then(() => interruptInference.flushPending()) } + // Why: a launched/hook-known agent pane must confirm — not trust — a 133;D so a + // full-screen agent's leaked nested-shell 133;D can't clear its tab identity, + // even on a restore where no command-start read has recorded evidence yet. + const paneHasKnownAgentIdentity = (): boolean => { + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) { + return true + } + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + return Boolean( + tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent + ) + } + // Why: a plain `codex`/`grok` sets its OSC title and the shell never repaints + // it on exit, so a confirmed return-to-shell must clear a title that still + // names an agent — otherwise the tab reads "grok" over a bare prompt. Only + // reset an agent-named title; user/shell-set titles are left untouched. + const clearStaleAgentTabTitleOnConfirmedShell = (): void => { + const state = useAppStore.getState() + const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id] + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (entry) => entry.id === deps.tabId + ) + const title = currentTitle ?? tab?.title + if (!title || resolveExplicitTerminalTitleAgentType(title) === null) { + return + } + const neutralTitle = neutralTerminalTitle() + deps.setRuntimePaneTitle(deps.tabId, pane.id, neutralTitle) + if (manager.getActivePane()?.id === pane.id) { + deps.updateTabTitle(deps.tabId, neutralTitle) + } + } const paneForegroundAgentTracker = createPaneForegroundAgentTracker({ getPtyId: () => transport.getPtyId(), isTrackablePtyId: (id) => !isRemoteRuntimePtyId(id) && parseAppSshPtyId(id) === null, readForegroundProcess: (id) => window.api.pty.getForegroundProcess(id), - publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry) + publish: (entry) => useAppStore.getState().setPaneForegroundAgent(cacheKey, entry), + hasKnownAgentIdentity: paneHasKnownAgentIdentity, + onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell }) + const sampleVisiblePaneForegroundAgent = (): void => { + if (!deps.isVisibleRef.current) { + return + } + const state = useAppStore.getState() + const foreground = state.paneForegroundAgentByPaneKey[cacheKey] + // Why: live process identity already paints the icon — nothing to read. + if (foreground?.agent) { + return + } + const entry = state.agentStatusByPaneKey[cacheKey] + if (entry?.state !== 'done' && agentTypeToIconAgent(entry?.agentType)) { + return + } + const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find( + (candidate) => candidate.id === deps.tabId + ) + const expectsAgent = Boolean( + tab?.launchAgent ?? paneStartup?.launchAgent ?? paneStartup?.initialAgentStatus?.agent + ) + // Why: with no shell mark yet, launchAgent bootstrap already paints the icon, + // so a read is pointless. Otherwise probe the foreground: a shell mark (from a + // reattach or a full-screen agent's leaked nested-shell 133;D) can hide a + // still-running agent — including a manually started one with no launchAgent or + // hook, which no later 133;C will reseed. A genuinely idle shell just returns a + // shell name and publishes nothing, so the probe is self-limiting. + if (!foreground?.shellForeground && expectsAgent) { + return + } + paneForegroundAgentTracker.onVisiblePtyBound() + } const commandLifecycle = createTerminalCommandLifecycle({ onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), onCommandFinished: () => { @@ -1829,6 +1904,14 @@ export function connectPanePty( ) { return } + // Why: user shell frameworks (bash-preexec/iTerm2) can replace Orca's + // OSC 133;C hook, so a manually launched agent produces no command-start + // signal at all. Enter at a shell-foreground prompt is the user-side + // equivalent; the sample is gated to panes with no live agent identity + // and publishes nothing for an idle shell. + if (event.key === 'Enter' && !event.metaKey && !event.ctrlKey && !event.altKey) { + sampleVisiblePaneForegroundAgent() + } deps.clearTerminalTabUnread(deps.tabId) deps.clearTerminalPaneUnread(cacheKey) deps.clearWorktreeUnread(deps.worktreeId) @@ -2225,7 +2308,11 @@ export function connectPanePty( } const bindActivePanePty = ( ptyId: string, - options: { seedInitialAgentStatus?: boolean; updateTabPtyId?: 'always' | 'if-missing' } = {} + options: { + seedInitialAgentStatus?: boolean + updateTabPtyId?: 'always' | 'if-missing' + sampleVisibleForegroundAgent?: boolean + } = {} ): void => { if (activePanePtyBinding && activePanePtyBinding !== ptyId) { reportPanePtyVisibility(activePanePtyBinding, false) @@ -2248,6 +2335,11 @@ export function connectPanePty( // frame-level sync often runs before that async result arrives. scheduleRuntimeGraphSync() agentCompletionCoordinator.startProcessTracking() + // Why: fresh spawns receive future OSC command-start events; only adopted or + // restored PTYs may already be inside Codex with no new foreground signal. + if (options.sampleVisibleForegroundAgent === true) { + sampleVisiblePaneForegroundAgent() + } } const onPtySpawn = (ptyId: string): void => { @@ -3657,7 +3749,10 @@ export function connectPanePty( ) { // Why: daemon createOrAttach can turn an apparent fresh spawn into // a reattach; the transport skips onPtySpawn there to preserve recency. - bindActivePanePty(resolvedPtyId, { updateTabPtyId: 'if-missing' }) + bindActivePanePty(resolvedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) } if (resolvedPtyId) { reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) @@ -5223,6 +5318,7 @@ export function connectPanePty( deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() + sampleVisiblePaneForegroundAgent() // Why: mobile terminal streaming needs the exact screen state from // xterm.js. The shared helper installs both the SerializeAddon-backed @@ -5784,7 +5880,10 @@ export function connectPanePty( onError: reportError } }) - bindActivePanePty(attachPtyId, { updateTabPtyId: 'if-missing' }) + bindActivePanePty(attachPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) if (attachPtyId === eagerLivePtyId) { registerPaneSerializerFor(attachPtyId) } @@ -5838,7 +5937,10 @@ export function connectPanePty( }) // Why: this path reuses a PTY spawned by an earlier mount, so no // later spawn event will bind this remounted pane's DOM/container. - bindActivePanePty(spawnedPtyId, { updateTabPtyId: 'if-missing' }) + bindActivePanePty(spawnedPtyId, { + updateTabPtyId: 'if-missing', + sampleVisibleForegroundAgent: true + }) }) .catch((err) => { reportError(err instanceof Error ? err.message : String(err)) @@ -5948,6 +6050,10 @@ export function connectPanePty( noteVisibilityResume() { ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() + sampleVisiblePaneForegroundAgent() + }, + sampleForegroundAgentOnFocus() { + sampleVisiblePaneForegroundAgent() }, reconcileIfSessionDead, reconcileIfSessionMissing, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index e037e1e7f83..bcf5835a687 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -1281,6 +1281,13 @@ export function useTerminalPaneLifecycle({ persistLayoutSnapshot() } reportActiveRendererPtyForPane(paneTransportsRef.current, pane.id) + // Why: the tab icon resolves from the active leaf's process identity; + // focusing a shell-marked pane whose agent is still running must + // re-sample it, since no further OSC boundary will. + const focusedBinding = panePtyBindings.get(pane.id) as + | (IDisposable & { sampleForegroundAgentOnFocus?: () => void }) + | undefined + focusedBinding?.sampleForegroundAgentOnFocus?.() // Why: when the user switches focus between split panes, update the // tab title to the newly active pane's last-known title so the tab // label reflects the focused agent — not a stale title from the