Fix tui-idle waits for background PTY agents (#2449)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-20 18:14:24 -04:00 committed by GitHub
parent 6abf031d66
commit 2fee04a05b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 538 additions and 22 deletions

View File

@ -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 {

View File

@ -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)

View File

@ -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<RuntimeTerminalWait>((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<RuntimeTerminalWait>((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

View File

@ -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 = {