From 2fee04a05bdf2a36d6af0e860acbec3985cf1f4e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 20 May 2026 18:14:24 -0400 Subject: [PATCH] Fix tui-idle waits for background PTY agents (#2449) Co-authored-by: Orca --- src/cli/format.ts | 8 +- src/main/runtime/orca-runtime.test.ts | 246 +++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 304 ++++++++++++++++++++++++-- src/shared/runtime-types.ts | 2 + 4 files changed, 538 insertions(+), 22 deletions(-) diff --git a/src/cli/format.ts b/src/cli/format.ts index 1ce69469db5..6e50d14b5a7 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -212,13 +212,17 @@ export function formatTerminalClose(result: { close: RuntimeTerminalClose }): st } export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string { - return [ + const lines = [ `handle: ${result.wait.handle}`, `condition: ${result.wait.condition}`, `satisfied: ${result.wait.satisfied}`, `status: ${result.wait.status}`, `exitCode: ${result.wait.exitCode ?? 'null'}` - ].join('\n') + ] + if (result.wait.blockedReason) { + lines.push(`blockedReason: ${result.wait.blockedReason}`) + } + return lines.join('\n') } export function formatWorktreePs(result: RuntimeWorktreePsResult): string { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index bf6d999a01b..cf79c6bd080 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -1814,6 +1814,252 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle for adopted background PTY handles from the renderer title', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-bg', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex ready', + activeLeafId: 'pane-bg', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-bg', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane-bg', + paneRuntimeId: 1, + ptyId: 'pty-bg', + paneTitle: null + } + ] + }) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('does not treat a Codex launch title as tui-idle readiness', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { + tabs: [ + { + tabId: 'tab-bg', + worktreeId: TEST_WORKTREE_ID, + title: 'Codex YOLO', + activeLeafId: 'pane-bg', + layout: null + } + ], + leaves: [ + { + tabId: 'tab-bg', + worktreeId: TEST_WORKTREE_ID, + leafId: 'pane-bg', + paneRuntimeId: 1, + ptyId: 'pty-bg', + paneTitle: null + } + ] + }) + + const waitPromise = runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + const timeoutAssertion = expect(waitPromise).rejects.toThrow('timeout') + + await vi.advanceTimersByTimeAsync(2_000) + + await timeoutAssertion + } finally { + vi.useRealTimers() + } + }) + + it('resolves tui-idle from a Codex ready prompt preview', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + ' >_ OpenAI Codex (v0.131.0)\n', + ' model: gpt-5.5 high /model to change\n', + ' directory: ~/orca/workspaces/orca/cli-debug\n', + ' permissions: YOLO mode\n' + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('resolves tui-idle from a Codex ready prompt even when stale startup lines remain', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + 'Booting MCP server: computer-use(0s esc to interrupt)\n', + ' >_ OpenAI Codex (v0.132.0)\n', + ' model: gpt-5.5 high /model to change\n', + ' directory: ~/orca/workspaces/orca/cli-debug\n', + ' permissions: YOLO mode\n', + [ + 'Starting MCP servers (0/2): codex_apps, computer-use (2s esc to interrupt)', + 'Run /review on my current changes gpt-5.5 high ~/orca/workspaces/orca/cli-debug', + 'Run /review on my current changes gpt-5.5 high ~/orca/workspaces/orca/cli-debug', + 'Run /review on my current changes gpt-5.5 high ~/orca/workspaces/orca/cli-debug', + 'Run /review on my current changes gpt-5.5 high ~/orca/workspaces/orca/cli-debug\n' + ].join('') + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: true, + status: 'running' + }) + }) + + it('returns a blocked wait result for Codex update prompts', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + [ + 'Update available! 0.131.0 -> 0.132.0\n', + '1. Update now\n', + '2. Skip\n', + 'Press enter to continue\n' + ].join(''), + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: false, + status: 'running', + blockedReason: 'codex-update-prompt' + }) + }) + + it('returns a blocked wait result for Codex workspace trust prompts', async () => { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => null + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData( + 'pty-bg', + 'Do you trust this workspace directory?\n1. Yes\n2. No\n', + Date.now() + ) + + await expect( + runtime.waitForTerminal(handle, { condition: 'tui-idle', timeoutMs: 1_000 }) + ).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + satisfied: false, + status: 'running', + blockedReason: 'codex-trust-workspace' + }) + }) + + it('resolves tui-idle for quiet background PTY agents without OSC titles', async () => { + vi.useFakeTimers() + try { + const runtime = new OrcaRuntimeService(store) + runtime.setPtyController({ + spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }), + write: () => true, + kill: () => true, + getForegroundProcess: async () => 'codex' + }) + const { handle } = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`) + runtime.onPtyData('pty-bg', 'OpenAI Codex\n', Date.now()) + + const waitPromise = runtime.waitForTerminal(handle, { + condition: 'tui-idle', + timeoutMs: 10_000 + }) + const waitAssertion = expect(waitPromise).resolves.toMatchObject({ + handle, + condition: 'tui-idle', + status: 'running' + }) + + await vi.advanceTimersByTimeAsync(6_000) + + await waitAssertion + } finally { + vi.useRealTimers() + } + }) + it('splits text and enter writes for background terminal handles', async () => { const writes: string[] = [] const runtime = new OrcaRuntimeService(store) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 3ac993ef532..fd451bc3f23 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -83,6 +83,7 @@ import type { RuntimeTerminalState, RuntimeStatus, RuntimeTerminalWait, + RuntimeTerminalWaitBlockedReason, RuntimeTerminalWaitCondition, RuntimeWorktreePsSummary, RuntimeWorktreeStatus, @@ -4315,9 +4316,25 @@ export class OrcaRuntimeService { if (condition === 'exit' && !pty.pty.connected) { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } + const ptyWaitText = buildTerminalWaitText( + pty.pty.tailBuffer, + pty.pty.tailPartialLine, + pty.pty.preview + ) + const ptyBlockedReason = detectTerminalWaitBlockedReason(ptyWaitText) + if (condition === 'tui-idle' && ptyBlockedReason) { + return buildPtyTerminalWaitBlockedResult(handle, condition, pty.pty, ptyBlockedReason) + } if (condition === 'tui-idle' && pty.pty.lastAgentStatus === 'idle') { return buildPtyTerminalWaitResult(handle, condition, pty.pty) } + if ( + condition === 'tui-idle' && + (this.getAdoptedPtyExplicitIdleStatus(pty.pty) === 'idle' || + isCodexReadyPromptPreview(ptyWaitText)) + ) { + return buildPtyTerminalWaitResult(handle, condition, pty.pty) + } return await new Promise((resolve, reject) => { const effectiveTimeoutMs = typeof options?.timeoutMs === 'number' && options.timeoutMs > 0 @@ -4356,8 +4373,28 @@ export class OrcaRuntimeService { reject(new Error('terminal_handle_stale')) } else if (condition === 'exit' && !live.pty.connected) { this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) - } else if (condition === 'tui-idle' && live.pty.lastAgentStatus === 'idle') { - this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if (condition === 'tui-idle') { + const livePtyWaitText = buildTerminalWaitText( + live.pty.tailBuffer, + live.pty.tailPartialLine, + live.pty.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(livePtyWaitText) + if (blockedReason) { + this.resolveWaiter( + waiter, + buildPtyTerminalWaitBlockedResult(handle, condition, live.pty, blockedReason) + ) + } else if (live.pty.lastAgentStatus === 'idle') { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else if ( + this.getAdoptedPtyExplicitIdleStatus(live.pty) === 'idle' || + isCodexReadyPromptPreview(livePtyWaitText) + ) { + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(handle, condition, live.pty)) + } else { + this.startPtyTuiIdleFallbackPoll(waiter, live.pty) + } } }) } @@ -4367,6 +4404,12 @@ export class OrcaRuntimeService { return buildTerminalWaitResult(handle, condition, leaf) } + const leafWaitText = buildTerminalWaitText(leaf.tailBuffer, leaf.tailPartialLine, leaf.preview) + const leafBlockedReason = detectTerminalWaitBlockedReason(leafWaitText) + if (condition === 'tui-idle' && leafBlockedReason) { + return buildTerminalWaitBlockedResult(handle, condition, leaf, leafBlockedReason) + } + // Why: if the agent already transitioned to idle (or permission) before the // waiter was registered, resolve immediately. This uses the same OSC title // detection that powers the renderer's "Task complete" notifications. @@ -4375,6 +4418,15 @@ export class OrcaRuntimeService { if (condition === 'tui-idle' && leaf.lastAgentStatus === 'idle') { return buildTerminalWaitResult(handle, condition, leaf) } + if (condition === 'tui-idle') { + const fastPathTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if ( + (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || + isCodexReadyPromptPreview(leafWaitText) + ) { + return buildTerminalWaitResult(handle, condition, leaf) + } + } return await new Promise((resolve, reject) => { // Why: tui-idle depends on OSC title transitions from a recognized agent. @@ -4423,21 +4475,37 @@ export class OrcaRuntimeService { const live = this.getLiveLeafForHandle(handle) if (getTerminalState(live.leaf) === 'exited') { this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === 'idle') { - // Why: don't clear lastAgentStatus here. It's a factual record of the - // last detected OSC state, not a one-shot signal. Clearing it causes - // subsequent tui-idle waiters to hang even though the agent is idle — - // the first waiter consumes the status and all later ones see null. - this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) - } else if (condition === 'tui-idle' && live.leaf.lastAgentStatus === null) { - // Why: for daemon-hosted terminals, lastAgentStatus stays null because - // PTY data doesn't flow through onPtyData. Check the renderer-synced - // title as a fast path before falling back to polling. - const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title - if (fastPathTitle && detectAgentStatusFromTitle(fastPathTitle) === 'idle') { + } else if (condition === 'tui-idle') { + const liveLeafWaitText = buildTerminalWaitText( + live.leaf.tailBuffer, + live.leaf.tailPartialLine, + live.leaf.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(liveLeafWaitText) + if (blockedReason) { + this.resolveWaiter( + waiter, + buildTerminalWaitBlockedResult(handle, condition, live.leaf, blockedReason) + ) + } else if (live.leaf.lastAgentStatus === 'idle') { + // Why: don't clear lastAgentStatus here. It's a factual record of the + // last detected OSC state, not a one-shot signal. Clearing it causes + // subsequent tui-idle waiters to hang even though the agent is idle — + // the first waiter consumes the status and all later ones see null. this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) } else { - this.startTuiIdleFallbackPoll(waiter, live.leaf) + // Why: renderer-synced previews can show Codex's ready prompt even + // while the last OSC title is still "working"; keep polling the + // preview/title until the waiter resolves or hits its timeout. + const fastPathTitle = live.leaf.paneTitle ?? this.tabs.get(live.leaf.tabId)?.title + if ( + (fastPathTitle && detectExplicitIdleStatusFromTitle(fastPathTitle) === 'idle') || + isCodexReadyPromptPreview(liveLeafWaitText) + ) { + this.resolveWaiter(waiter, buildTerminalWaitResult(handle, condition, live.leaf)) + } else { + this.startTuiIdleFallbackPoll(waiter, live.leaf) + } } } } catch (error) { @@ -9058,20 +9126,19 @@ export class OrcaRuntimeService { private startTuiIdleFallbackPoll(waiter: TerminalWaiter, leaf: RuntimeLeafRecord): void { waiter.pollInterval = setInterval(async () => { try { - // If OSC detection via onPtyData kicked in, stop — the primary path - // will handle (or has already handled) resolution. - if (leaf.lastAgentStatus !== null) { + if (leaf.lastAgentStatus === 'idle') { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) waiter.pollInterval = null } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) return } // Why: check the renderer-synced title. For daemon-hosted terminals, // this is the only path where OSC titles are visible to the runtime. const pollTitle = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title if (pollTitle) { - const titleStatus = detectAgentStatusFromTitle(pollTitle) + const titleStatus = detectExplicitIdleStatusFromTitle(pollTitle) if (titleStatus === 'idle') { if (waiter.pollInterval) { clearInterval(waiter.pollInterval) @@ -9081,9 +9148,34 @@ export class OrcaRuntimeService { return } } + const leafWaitText = buildTerminalWaitText( + leaf.tailBuffer, + leaf.tailPartialLine, + leaf.preview + ) + const blockedReason = detectTerminalWaitBlockedReason(leafWaitText) + if (blockedReason) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter( + waiter, + buildTerminalWaitBlockedResult(waiter.handle, 'tui-idle', leaf, blockedReason) + ) + return + } + if (isCodexReadyPromptPreview(leafWaitText)) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildTerminalWaitResult(waiter.handle, 'tui-idle', leaf)) + return + } // Foreground process fallback: if the daemon/local provider can report // the process and it's a non-shell with quiet output, treat as idle. - if (leaf.ptyId && this.ptyController) { + if (leaf.lastAgentStatus === null && leaf.ptyId && this.ptyController) { const fg = await this.ptyController.getForegroundProcess(leaf.ptyId) if (fg && !isShellProcess(fg)) { const quietMs = leaf.lastOutputAt ? Date.now() - leaf.lastOutputAt : 0 @@ -9102,6 +9194,79 @@ export class OrcaRuntimeService { }, TUI_IDLE_POLL_INTERVAL_MS) } + private startPtyTuiIdleFallbackPoll(waiter: TerminalWaiter, pty: RuntimePtyWorktreeRecord): void { + waiter.pollInterval = setInterval(async () => { + try { + if (pty.lastAgentStatus === 'idle') { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + return + } + const ptyWaitText = buildTerminalWaitText(pty.tailBuffer, pty.tailPartialLine, pty.preview) + const blockedReason = detectTerminalWaitBlockedReason(ptyWaitText) + if (blockedReason) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter( + waiter, + buildPtyTerminalWaitBlockedResult(waiter.handle, 'tui-idle', pty, blockedReason) + ) + return + } + // Why: background PTY handles can later be adopted by the renderer. + // Use that live xterm title as the same readiness signal as leaf handles. + if ( + this.getAdoptedPtyExplicitIdleStatus(pty) === 'idle' || + isCodexReadyPromptPreview(ptyWaitText) + ) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + return + } + if (pty.lastAgentStatus === null && this.ptyController) { + const fg = await this.ptyController.getForegroundProcess(pty.ptyId) + if (fg && !isShellProcess(fg)) { + const quietMs = pty.lastOutputAt ? Date.now() - pty.lastOutputAt : 0 + if (quietMs >= TUI_IDLE_QUIESCENCE_MS) { + if (waiter.pollInterval) { + clearInterval(waiter.pollInterval) + waiter.pollInterval = null + } + this.resolveWaiter(waiter, buildPtyTerminalWaitResult(waiter.handle, 'tui-idle', pty)) + } + } + } + } catch { + // Swallow transient PTY inspection errors and keep polling. + } + }, TUI_IDLE_POLL_INTERVAL_MS) + } + + private getAdoptedPtyExplicitIdleStatus(pty: RuntimePtyWorktreeRecord): AgentStatus | null { + for (const leaf of this.leaves.values()) { + if (leaf.ptyId !== pty.ptyId) { + continue + } + const title = leaf.paneTitle ?? this.tabs.get(leaf.tabId)?.title + if (!title) { + continue + } + const status = detectExplicitIdleStatusFromTitle(title) + if (status !== null) { + return status + } + } + return null + } + // Why: push-on-idle delivery — when an agent transitions working→idle, check // for unread orchestration messages addressed to that terminal and inject them // into the PTY. This is event-driven (no polling) because the runtime owns @@ -9823,6 +9988,16 @@ function buildPreview(lines: string[], partialLine: string): string { : preview } +function buildTerminalWaitText(lines: string[], partialLine: string, preview: string): string { + const waitText = buildTailLines(lines, partialLine) + .map((line) => line.trim()) + .filter(Boolean) + .join('\n') + // Why: the user-facing preview is intentionally short, but wait readiness + // needs the retained terminal tail so Codex headers are not truncated away. + return waitText.length > 0 ? waitText : preview +} + function appendToTailBuffer( previousLines: string[], previousPartialLine: string, @@ -9908,6 +10083,10 @@ const TUI_IDLE_DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 const TUI_IDLE_POLL_INTERVAL_MS = 2000 const TUI_IDLE_QUIESCENCE_MS = 3000 const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 +const EXPLICIT_IDLE_TITLE_RE = /(^|\s)(ready|idle|done)(\s|$|[.!?])/i +const CLAUDE_IDLE_PREFIX = '\u2733' +const GEMINI_IDLE_PREFIX = '\u25c7' +const PI_IDLE_PREFIX = '\u03c0 - ' // Clamp range for the user-facing mobileAutoRestoreFitMs preference. // MIN floor: a couple of seconds is the smallest useful auto-restore @@ -9917,6 +10096,59 @@ const MESSAGE_WAIT_DEFAULT_TIMEOUT_MS = 2 * 60 * 1000 const MOBILE_AUTO_RESTORE_FIT_MIN_MS = 5_000 const MOBILE_AUTO_RESTORE_FIT_MAX_MS = 60 * 60 * 1000 +function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { + const status = detectAgentStatusFromTitle(title) + if (status !== 'idle') { + return null + } + // Why: user-supplied launch titles like "Codex YOLO" contain an agent name + // but are not readiness signals. terminal.wait needs explicit idle evidence. + if ( + EXPLICIT_IDLE_TITLE_RE.test(title) || + title.startsWith(CLAUDE_IDLE_PREFIX) || + title.startsWith('* ') || + title.includes(GEMINI_IDLE_PREFIX) || + title.startsWith(PI_IDLE_PREFIX) + ) { + return 'idle' + } + return null +} + +function isCodexReadyPromptPreview(preview: string): boolean { + const normalized = preview.toLowerCase() + if (!normalized.includes('openai codex')) { + return false + } + if (detectTerminalWaitBlockedReason(preview) !== null) { + return false + } + return ( + normalized.includes('model:') && + normalized.includes('directory:') && + normalized.includes('permissions:') + ) +} + +function detectTerminalWaitBlockedReason(preview: string): RuntimeTerminalWaitBlockedReason | null { + const normalized = preview.toLowerCase() + if (normalized.includes('update available') && normalized.includes('press enter to continue')) { + return 'codex-update-prompt' + } + if ( + (normalized.includes('do you trust') || + normalized.includes('trust this') || + normalized.includes('trusted workspace')) && + (normalized.includes('workspace') || + normalized.includes('folder') || + normalized.includes('directory') || + normalized.includes('repo')) + ) { + return 'codex-trust-workspace' + } + return null +} + function buildTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, @@ -9931,6 +10163,22 @@ function buildTerminalWaitResult( } } +function buildTerminalWaitBlockedResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + leaf: RuntimeLeafRecord, + blockedReason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWait { + return { + handle, + condition, + satisfied: false, + status: getTerminalState(leaf), + exitCode: leaf.lastExitCode, + blockedReason + } +} + function buildPtyTerminalWaitResult( handle: string, condition: RuntimeTerminalWaitCondition, @@ -9945,6 +10193,22 @@ function buildPtyTerminalWaitResult( } } +function buildPtyTerminalWaitBlockedResult( + handle: string, + condition: RuntimeTerminalWaitCondition, + pty: RuntimePtyWorktreeRecord, + blockedReason: RuntimeTerminalWaitBlockedReason +): RuntimeTerminalWait { + return { + handle, + condition, + satisfied: false, + status: pty.connected ? 'running' : pty.lastExitCode !== null ? 'exited' : 'unknown', + exitCode: pty.lastExitCode, + blockedReason + } +} + function branchSelectorMatches(branch: string, selector: string): boolean { // Why: Git worktree data can report local branches as either `refs/heads/foo` // or `foo` depending on which plumbing path produced the record. Orca's diff --git a/src/shared/runtime-types.ts b/src/shared/runtime-types.ts index 0f7e55a0179..058e90d9ca7 100644 --- a/src/shared/runtime-types.ts +++ b/src/shared/runtime-types.ts @@ -351,6 +351,7 @@ export type RuntimeTerminalClose = { } export type RuntimeTerminalWaitCondition = 'exit' | 'tui-idle' +export type RuntimeTerminalWaitBlockedReason = 'codex-update-prompt' | 'codex-trust-workspace' export type RuntimeTerminalWait = { handle: string @@ -358,6 +359,7 @@ export type RuntimeTerminalWait = { satisfied: boolean status: RuntimeTerminalState exitCode: number | null + blockedReason?: RuntimeTerminalWaitBlockedReason } export type RuntimeWorktreePsSummary = {