Files
orca/CODEX_ICON_DEBUG.diff
T
Jinjing 49edd9ae4d Fix terminal tab icon detection for foreground agents
- Register with bash-preexec's preexec_functions array to prevent its
  DEBUG trap re-arming from silencing Orca's command-start signals.
- Serve the last-resolved identity past its cache TTL (stale-while-
  revalidate) while the active foreground process is a wrapper.
- Reschedule confirming reads on duplicate OSC 133;D sequences to
  prevent nested shells from prematurely clearing tab identities.
- Trigger self-limiting foreground sampling on visible PTY binding,
  Enter keystrokes, and pane focus changes.
2026-07-05 19:33:31 -07:00

1201 lines
52 KiB
Diff

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<string | null>>()
const publish = vi.fn<(entry: PaneForegroundAgentEntry) => void>()
+ const onConfirmedShellForeground = vi.fn<() => void>()
let ptyId: string | null = 'pty-1'
function makeTracker(): ReturnType<typeof createPaneForegroundAgentTracker> {
@@ -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<string | null>
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<typeof setTimeout> | 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<void> {
+ async function readForeground(
+ generation: number,
+ retryIndex: number,
+ reason: ForegroundReadReason
+ ): Promise<void> {
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<typeof vi.fn>
runtimePaneTitlesByTabId: Record<string, Record<number, string>>
agentStatusByPaneKey: Record<string, unknown>
+ paneForegroundAgentByPaneKey: Record<string, PaneForegroundAgentEntry>
sleepingAgentSessionsByPaneKey: Record<string, unknown>
agentLaunchConfigByPaneKey: Record<string, { launchConfig: unknown }>
getAgentLaunchConfigForStatusEntry: ReturnType<typeof vi.fn>
@@ -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<typeof createDeps>
+ 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<void> {
+ 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<string>, 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