diff --git a/src/main/runtime/orca-runtime-agent-transitions.ts b/src/main/runtime/orca-runtime-agent-transitions.ts index 3eb2e4a8901..c94f962da5c 100644 --- a/src/main/runtime/orca-runtime-agent-transitions.ts +++ b/src/main/runtime/orca-runtime-agent-transitions.ts @@ -64,14 +64,7 @@ export abstract class OrcaRuntimeWithAgentTransitions extends OrcaRuntimeWithMig constructor(...args: ConstructorParameters) { super(...args) - this.agentTransitions.subscribe((change) => { - this.terminalAgentPhonePush.observe(change) - // A hook row can end or reopen a turn under an unchanged program `working` report. - const ptyId = this.getPtyRecordForPaneKey(change.paneKey)?.ptyId - if (ptyId && this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working') { - this.reclassifyPtyStatus(ptyId) - } - }) + this.agentTransitions.subscribe((change) => this.terminalAgentPhonePush.observe(change)) } /** Attach after higher layers subscribe; also drive generated tab titles. */ diff --git a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts index dcee108aab7..c3284e1462e 100644 --- a/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts +++ b/src/main/runtime/orca-runtime-tests/mobile-creation-and-orchestration-part-02.spec.ts @@ -77,7 +77,6 @@ describe('OrcaRuntimeService', () => { coordinator_handle: terminal.handle, coordinator_pane_key: `${terminal.tabId}:${terminal.leafId}` }) - runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) const message = db.insertMessage({ from: 'term_worker', to: 'run:run_mailbox', @@ -182,104 +181,6 @@ describe('OrcaRuntimeService', () => { } }) - it('repoints on the retry edge when live idle won the send race', async () => { - vi.useFakeTimers() - try { - const runtime = new OrcaRuntimeService(store) - const db = new InMemoryOrchestrationMessages() - const write = vi.fn().mockReturnValue(true) - setInMemoryOrchestrationMessages(runtime, db) - runtime.setPtyController({ - write, - writeWithSettlement: settledWriteStub(write), - kill: vi.fn(), - getForegroundProcess: async () => null - }) - syncSinglePty(runtime) - - const [terminal] = (await runtime.listTerminals()).terminals - bindSinglePtyRun(db, terminal.handle) - runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) - db.insertMessage({ - from: 'term_worker', - to: terminal.handle, - subject: 'settled at idle edge', - type: 'worker_done' - }) - runtime.notifyMessageArrived(terminal.handle, 'worker_done') - await Promise.resolve() - expect(write).not.toHaveBeenCalled() - - const leaf = [ - ...( - runtime as unknown as { - leaves: Map< - string, - { - lastAgentStatus: string | null - lastAgentStatusObservedLive: boolean - } - > - } - ).leaves.values() - ][0] - leaf.lastAgentStatus = 'idle' - leaf.lastAgentStatusObservedLive = true - - await vi.advanceTimersByTimeAsync(2_000) - expect(write).toHaveBeenCalledWith( - 'pty-1', - expect.stringContaining('You have 1 orchestration message') - ) - db.close() - } finally { - vi.useRealTimers() - } - }) - - it('leaves later delivery to the idle edge instead of polling a working mailbox', async () => { - vi.useFakeTimers() - try { - const runtime = new OrcaRuntimeService(store) - const db = new InMemoryOrchestrationMessages() - const pendingReads = vi.spyOn(db, 'getUndeliveredUnreadMessages') - const write = vi.fn().mockReturnValue(true) - setInMemoryOrchestrationMessages(runtime, db) - runtime.setPtyController({ - write, - writeWithSettlement: settledWriteStub(write), - kill: vi.fn(), - getForegroundProcess: async () => null - }) - syncSinglePty(runtime) - - const [terminal] = (await runtime.listTerminals()).terminals - bindSinglePtyRun(db, terminal.handle) - runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) - db.insertMessage({ - from: 'term_worker', - to: terminal.handle, - subject: 'wait for idle' - }) - runtime.notifyMessageArrived(terminal.handle, 'status') - await Promise.resolve() - - await vi.advanceTimersByTimeAsync(20_000) - expect(pendingReads).not.toHaveBeenCalled() - expect(pendingMailPointerRepoints(runtime)).toBe(0) - - runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) - expect(pendingReads).toHaveBeenCalledTimes(1) - expect(write).toHaveBeenCalledWith( - 'pty-1', - expect.stringContaining('You have 1 orchestration message') - ) - db.close() - } finally { - vi.useRealTimers() - } - }) - it('points restored mail when a live-idle PTY remounts after the repair edge', async () => { vi.useFakeTimers() try { @@ -477,7 +378,7 @@ describe('OrcaRuntimeService', () => { db.close() }) - it('does not inject pending mail on notify when the recipient is still working', async () => { + it('does not inject pending mail on notify when the recipient awaits permission', async () => { const runtime = new OrcaRuntimeService(store) const db = new InMemoryOrchestrationMessages() const write = vi.fn().mockReturnValue(true) @@ -492,11 +393,11 @@ describe('OrcaRuntimeService', () => { const [terminal] = (await runtime.listTerminals()).terminals bindSinglePtyRun(db, terminal.handle) - runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex waiting for permission\x07', 100) const message = db.insertMessage({ from: 'sender', to: terminal.handle, - subject: 'while working' + subject: 'while asking' }) write.mockClear() @@ -504,7 +405,7 @@ describe('OrcaRuntimeService', () => { await Promise.resolve() expect(write).not.toHaveBeenCalled() - // Busy rows must remain undelivered for later idle; stamping without writing would lose delivery (#12584). + // Held rows must remain undelivered for a later edge; stamping without writing would lose delivery (#12584). expect(message.delivered_at).toBeNull() db.close() }) diff --git a/src/main/runtime/orchestration-headless-mailbox-pointer.test.ts b/src/main/runtime/orchestration-headless-mailbox-pointer.test.ts index 6a462f8d62c..76bcbefcd3a 100644 --- a/src/main/runtime/orchestration-headless-mailbox-pointer.test.ts +++ b/src/main/runtime/orchestration-headless-mailbox-pointer.test.ts @@ -72,17 +72,12 @@ describe('headless orchestration mailbox pointer', () => { db.close() }) - it('points a coordinator at mail that arrived while it worked once it goes idle', async () => { + it('points a coordinator at mail that arrives mid-turn', async () => { const { db, runtime, write, sendRunMail } = await coordinator() runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) await runtime.serializeMainTerminalBuffer(PTY_ID) const message = sendRunMail() await vi.advanceTimersByTimeAsync(0) - expect(pointerCount(write)).toBe(0) - - runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) - await runtime.serializeMainTerminalBuffer(PTY_ID) - await vi.advanceTimersByTimeAsync(0) expect(pointerCount(write)).toBe(1) await vi.advanceTimersByTimeAsync(500) expect(db.getMessageById(message.id)?.delivered_at).toEqual(expect.any(String)) @@ -92,8 +87,7 @@ describe('headless orchestration mailbox pointer', () => { it('waits for a name-only idle pane to go quiet before typing the pointer', async () => { const { db, runtime, write, sendRunMail } = await coordinator() sendRunMail() - runtime.onPtyData(PTY_ID, '\x1b]0;⠋ Codex\x07working\n', Date.now()) - runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07still working\n', Date.now()) + runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07output\n', Date.now()) await runtime.serializeMainTerminalBuffer(PTY_ID) expect(pointerCount(write)).toBe(0) @@ -102,6 +96,23 @@ describe('headless orchestration mailbox pointer', () => { db.close() }) + // A quiet retry armed before the prompt opened must not type into it on the retained working report. + it('does not type into a pane that turned to a permission prompt after a retry was armed', async () => { + const { db, runtime, write, sendRunMail } = await coordinator() + runtime.onPtyData(PTY_ID, '\x1b]0;Codex\x07output\n', Date.now()) + runtime.onPtyData( + PTY_ID, + '\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex waiting for permission\x07', + Date.now() + ) + await runtime.serializeMainTerminalBuffer(PTY_ID) + sendRunMail() + + await vi.advanceTimersByTimeAsync(4_000) + expect(write).not.toHaveBeenCalled() + db.close() + }) + it('types into no PTY while two live PTYs claim the coordinator pane', async () => { const { db, runtime, write, sendRunMail } = await coordinator() runtime.registerPty('pty-mailbox-replacement', WORKTREE_ID, null, { diff --git a/src/main/runtime/orchestration-mailbox-detached-routing.spec.ts b/src/main/runtime/orchestration-mailbox-detached-routing.spec.ts index 2dfd1447934..c8bacc4d485 100644 --- a/src/main/runtime/orchestration-mailbox-detached-routing.spec.ts +++ b/src/main/runtime/orchestration-mailbox-detached-routing.spec.ts @@ -148,13 +148,13 @@ describe('orchestration detached mailbox routing', () => { db.close() }) - it('uses bounded ownership lookup for direct arrivals while the agent is working', async () => { + it('uses bounded ownership lookup for direct arrivals while the agent cannot take input', async () => { vi.useFakeTimers() const db = createDatabase('orca-mailbox-bounded-') const harness = createRuntime(db) - const run = createBoundRun(db, 'Working Run') + const run = createBoundRun(db, 'Permission Run') await harness.runtime.listTerminals() - harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) + harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex needs permission\x07', 1) const fullMailboxScan = vi.spyOn(db, 'getUndeliveredUnreadMessages') const message = insertDirectRunMessage(db, run.id, 'Direct coordinator status') diff --git a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts index 6556bdaa4bb..48c398a8e9b 100644 --- a/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts +++ b/src/main/runtime/orchestration-mailbox-notification-consistency.test.ts @@ -856,14 +856,10 @@ describe('orchestration notification mailbox consistency', () => { `after delivery ${firstDelivery.deliveryId}, which you have not acknowledged. If you no longer have it, run \`${localOrchestrationCliCommand()} orchestration check --run ${run.id}\` to see it again; once it is handled, run \`${localOrchestrationCliCommand()} orchestration check --run ${run.id} --ack ${firstDelivery.deliveryId}\`` ) - // A turn that starts inside the Enter delay keeps the reminder until it ends. + // A turn that starts inside the Enter delay still takes the reminder; the agent queues it. harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 3) await harness.runtime.serializeMainTerminalBuffer(PTY_ID) await vi.advanceTimersByTimeAsync(500) - expect(enters()).toBe(0) - harness.runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 4) - await harness.runtime.serializeMainTerminalBuffer(PTY_ID) - await vi.advanceTimersByTimeAsync(500) expect(enters()).toBe(1) expect(db.getMessageById(newerMessage.id)?.delivered_at).toEqual(expect.any(String)) diff --git a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts index 9846bf884d7..782bef696cf 100644 --- a/src/main/runtime/orchestration-mailbox-notification-test-harness.ts +++ b/src/main/runtime/orchestration-mailbox-notification-test-harness.ts @@ -245,9 +245,8 @@ export function registerSecondPane( export async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise { await runtime.listTerminals() - runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) - runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) - // Await the model's write chain so both chunks are parsed. + runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 1) + // Await the model's write chain so the chunk is parsed. await runtime.serializeMainTerminalBuffer(PTY_ID) } diff --git a/src/main/runtime/orchestration-message-delivery-identity.test.ts b/src/main/runtime/orchestration-message-delivery-identity.test.ts index d456c5541ed..57cfa33553f 100644 --- a/src/main/runtime/orchestration-message-delivery-identity.test.ts +++ b/src/main/runtime/orchestration-message-delivery-identity.test.ts @@ -112,9 +112,8 @@ function createRuntime( async function driveToLiveIdle(runtime: OrcaRuntimeService): Promise { await runtime.listTerminals() - runtime.onPtyData(PTY_ID, '\x1b]0;Codex working\x07', 1) - runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 2) - // Await the model's write chain so both chunks are parsed. + runtime.onPtyData(PTY_ID, '\x1b]0;Codex done\x07', 1) + // Await the model's write chain so the chunk is parsed. await runtime.serializeMainTerminalBuffer(PTY_ID) } diff --git a/src/main/runtime/orchestration/mailbox-owner.ts b/src/main/runtime/orchestration/mailbox-owner.ts index ae315511b15..f4d22bda2a1 100644 --- a/src/main/runtime/orchestration/mailbox-owner.ts +++ b/src/main/runtime/orchestration/mailbox-owner.ts @@ -12,6 +12,17 @@ export type OrchestrationMailboxLeaf = { paneTitle?: string | null } +/** An agent at rest or mid-turn takes typed mail: agents queue or steer text sent during a turn. */ +export function agentStatusTakesTypedInput(status: AgentStatus | null): boolean { + return status === 'idle' || status === 'working' +} + +export function isMailboxLeafTakingInput( + leaf: Pick +): boolean { + return leaf.lastAgentStatusObservedLive && agentStatusTakesTypedInput(leaf.lastAgentStatus) +} + export type RoutedOrchestrationMailbox = { mailboxHandle: string types: string[] diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts index 9716c33cfb3..380df6a6f4b 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery-contract.ts @@ -22,7 +22,7 @@ export type PointerDeliveryDependencies OrchestrationMailboxLeaf | undefined getLeafKey: (tabId: string, leafId: string) => string getLiveLeafForHandle: (handle: string) => OrchestrationMailboxLeaf - /** Whether the pane is settled enough to type the pointer plus Enter into it. */ + /** Whether the pane may take the pointer plus Enter now: at rest, or mid-turn (input queues). */ isAgentSettledForDelivery: (leaf: OrchestrationMailboxLeaf) => boolean isPtyAwaitingUserInput: (ptyId: string) => boolean getMessageWaiters: (mailboxHandle: string) => ReadonlySet | undefined diff --git a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts index bd8bf3b3276..35b730eae1d 100644 --- a/src/main/runtime/orchestration/mailbox-pointer-delivery.ts +++ b/src/main/runtime/orchestration/mailbox-pointer-delivery.ts @@ -4,7 +4,7 @@ import { selectOrchestrationPointerBatch, type OrchestrationMessageWaiter } from './mailbox-pointer-eligibility' -import type { OrchestrationMailboxLeaf } from './mailbox-owner' +import { isMailboxLeafTakingInput, type OrchestrationMailboxLeaf } from './mailbox-owner' import { OrchestrationMailboxPointerState, type OrchestrationMailboxDeliveryFlight @@ -39,7 +39,8 @@ export class OrchestrationMailboxPointerDelivery { }) // Synthetic: Codex's done hook, then a final native spinner that only the timer clears (#19243). - it('delivers mail once the clear confirms a live Codex behind a fresh done hook', async () => { + it('delivers mail to a live Codex behind a fresh done hook and a stale spinner', async () => { vi.useFakeTimers() const db = createDatabase('orca-stale-title-completion-') const hook: AgentStatusIpcPayload = { @@ -50,9 +50,7 @@ describe('stale-working title clear and completion recovery', () => { runtime.onPtyData(PTY_ID, 'composer repaint without a title', 2) insertDirectRunMessage(db, run.id, 'Worker progress') runtime.notifyMessageArrived(`run:${run.id}`, 'status') - await vi.advanceTimersByTimeAsync(1_000) - expect(write).not.toHaveBeenCalled() - await vi.advanceTimersByTimeAsync(4_000) + await vi.advanceTimersByTimeAsync(5_000) expect(write.mock.calls.map(([, data]) => data)).toEqual([ expect.stringContaining('You have 1 orchestration message'), '\r' diff --git a/src/main/runtime/terminal-interactive-wait-visibility.test.ts b/src/main/runtime/terminal-interactive-wait-visibility.test.ts index 2d5a6c8f7e8..4c711e0ad18 100644 --- a/src/main/runtime/terminal-interactive-wait-visibility.test.ts +++ b/src/main/runtime/terminal-interactive-wait-visibility.test.ts @@ -551,7 +551,11 @@ describe('terminal wait ownership', () => { }) it('rechecks current leaf evidence before delivery and coalesces repeated refusals', async () => { - let leaf = makeTuiIdleLeaf({ lastOutputAt: Date.now(), lastAgentStatus: 'working' }) + let leaf = makeTuiIdleLeaf({ + lastOutputAt: Date.now(), + lastAgentStatus: 'idle', + lastOscTitle: 'Codex' + }) const deliver = vi.fn() const runtime = new OrcaRuntimeService() const key = runtime['getLeafKey'](leaf.tabId, leaf.leafId) @@ -569,7 +573,11 @@ describe('terminal wait ownership', () => { }) it('cancels only the delivery recheck belonging to its runtime', async () => { - const leaf = makeTuiIdleLeaf({ lastOutputAt: Date.now(), lastAgentStatus: 'working' }) + const leaf = makeTuiIdleLeaf({ + lastOutputAt: Date.now(), + lastAgentStatus: 'idle', + lastOscTitle: 'Codex' + }) const first = new OrcaRuntimeService() const second = new OrcaRuntimeService() const key = first['getLeafKey'](leaf.tabId, leaf.leafId) diff --git a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts index add2bd4e87e..37124d7a1c2 100644 --- a/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts +++ b/src/main/runtime/terminal/output/terminal-derived-agent-facts.ts @@ -1,4 +1,4 @@ -import { OrcaRuntimeWithApplyPtyProgramActivity } from './terminal-program-activity' +import { OrcaRuntimeWithApplyTrackedPtyTitle } from './terminal-title-observation' import type { RuntimePtyTitleTrackerEntry, RuntimePtyWorktreeRecord @@ -28,7 +28,7 @@ type TerminalAgentStatusTarget = Pick< 'source' | 'paneKey' | 'tabId' | 'worktreeId' | 'connectionId' | 'terminalHandle' > -export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyPtyProgramActivity { +export abstract class OrcaRuntimeWithCreateTerminalSideEffectCommandCodeDetector extends OrcaRuntimeWithApplyTrackedPtyTitle { abstract getAgentStatusTerminalHandleForPaneKey(paneKey: string): string | undefined /** Each live OSC 7501 program's latest row payload; renewed while its PTY keeps a live facet. */ diff --git a/src/main/runtime/terminal/output/terminal-program-activity.ts b/src/main/runtime/terminal/output/terminal-program-activity.ts deleted file mode 100644 index bbfca83840f..00000000000 --- a/src/main/runtime/terminal/output/terminal-program-activity.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AgentStatus } from '../../../../shared/agent-detection' -import { OrcaRuntimeWithApplyTrackedPtyTitle } from './terminal-title-observation' - -/** The program's OSC 7501 activity, and re-classifying the PTY's status when it or a hook row moves. */ -export abstract class OrcaRuntimeWithApplyPtyProgramActivity extends OrcaRuntimeWithApplyTrackedPtyTitle { - /** Re-classifies the PTY under new program activity; on null the title's own glyph decides again. */ - protected applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void { - const entry = this.ptyTitleTrackersByPtyId.get(ptyId) - if (!entry) { - return - } - entry.programActivity = activity - this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity }) - const pty = this.ptysById.get(ptyId) - if (activity === null && pty?.lastExplicitAgentStatus?.programAtRest) { - // The at-rest claim was the program's; a later agent in this pane must earn its own. - const { programAtRest: _ended, ...status } = pty.lastExplicitAgentStatus - pty.lastExplicitAgentStatus = status - } - this.reclassifyPtyStatus(ptyId) - } - - /** Re-offers the last title's status, e.g. when a hook row ends a turn under program `working`. */ - protected reclassifyPtyStatus(ptyId: string): void { - const entry = this.ptyTitleTrackersByPtyId.get(ptyId) - if (!entry?.lastTitle) { - return - } - // A stale-spinner clear stands for the title it replaced, so a dead spinner is not revived. - const raw = this.ptysById.get(ptyId)?.titleDisplayClear?.title ?? entry.lastTitle.raw - const changed = this.applyTrackedPtyTitle(ptyId, raw, entry.lastTitle.normalized, undefined, { - statusOnly: true - }) - // The chunk's session-tab fan-out carries the new status to mobile. - if (changed && entry.applyingChunk) { - entry.chunkTouchedSessionTabs = true - } - } -} diff --git a/src/main/runtime/terminal/output/terminal-title-observation.ts b/src/main/runtime/terminal/output/terminal-title-observation.ts index c303ded470a..2d307c58aab 100644 --- a/src/main/runtime/terminal/output/terminal-title-observation.ts +++ b/src/main/runtime/terminal/output/terminal-title-observation.ts @@ -10,6 +10,7 @@ import { terminalTitleBlocksExplicitAgentStatus } from '../../runtime-worktree-s import type { ClaudeTerminalEvidence } from '../../../../shared/claude-terminal-interrupt' import type { OrchestrationMailboxLeaf } from '../../orchestration/mailbox-owner' import type { PtyLaneMailboxTarget } from '../query/pty-terminal-summary' +import { agentStatusTakesTypedInput } from '../../orchestration/mailbox-owner' export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty { protected abstract collectPaneKeysForPty(ptyId: string): Set @@ -60,15 +61,39 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit } /** The PTY's agent status under this title: the program's own OSC 7501 activity while it - * reports one, else what the title glyph says. A turn end that outlasts a `working` report is - * rest: the program then reports work beyond the turn, such as live teammates. */ + * reports one, else what the title glyph says. */ protected classifyPtyTitleStatus(ptyId: string, rawTitle: string): AgentStatus | null { - const activity = this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity - const agent = activity === 'working' ? this.getPaneAgentForTuiIdle(ptyId) : null - if (agent && this.readTuiIdleHookTurnForPty(ptyId, agent)?.endedUnderProgramWork) { - return 'idle' + return ( + this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity ?? + detectAgentStatusFromTitle(rawTitle) + ) + } + + /** Re-classifies the PTY under new program activity; on null the title's own glyph decides again. */ + protected applyPtyProgramActivity(ptyId: string, activity: AgentStatus | null): void { + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (!entry) { + return + } + entry.programActivity = activity + this.observeClaudeTerminalEvidence(ptyId, { kind: 'activity', status: activity }) + const pty = this.ptysById.get(ptyId) + if (activity === null && pty?.lastExplicitAgentStatus?.programAtRest) { + // The at-rest claim was the program's; a later agent in this pane must earn its own. + const { programAtRest: _ended, ...status } = pty.lastExplicitAgentStatus + pty.lastExplicitAgentStatus = status + } + if (entry.lastTitle) { + // A stale-spinner clear stands for the title it replaced, so a dead spinner is not revived. + const raw = pty?.titleDisplayClear?.title ?? entry.lastTitle.raw + const changed = this.applyTrackedPtyTitle(ptyId, raw, entry.lastTitle.normalized, undefined, { + statusOnly: true + }) + // The chunk's session-tab fan-out carries the new status to mobile. + if (changed && entry.applyingChunk) { + entry.chunkTouchedSessionTabs = true + } } - return activity ?? detectAgentStatusFromTitle(rawTitle) } /** Apply one observed OSC title (raw form) to the PTY and leaf records. @@ -98,17 +123,17 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit // Why before retiring the clear: the lifecycle counts a turn from the status the clear showed. this.recordAgentPromptLifecycleState(ptyId, agentStatus) let ptyRecordChanged = false - let ptyIdleEdge = false + let ptyInputEdge = false const pty = this.ptysById.get(ptyId) if (pty) { const prevStatus = pty.lastAgentStatus const prevTitle = pty.lastOscTitle - // Same delivery edge as the leaf branch below: live idle, or an idle title that changed. - ptyIdleEdge = - agentStatus === 'idle' && - (prevStatus !== 'idle' || + // Same delivery edge as the leaf branch below: live input-taking status, or an idle title that changed. + ptyInputEdge = + agentStatusTakesTypedInput(agentStatus) && + (prevStatus !== agentStatus || !pty.lastAgentStatusObservedLive || - (!statusOnly && prevTitle !== recordedTitle)) + (agentStatus === 'idle' && !statusOnly && prevTitle !== recordedTitle)) const observedAt = this.nextTitleObservationSequence() const observedAtEpochMs = identityOnlyTitle ? null : Date.now() if (!statusOnly) { @@ -189,17 +214,20 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit ) { this.resolveTuiIdleWaiters(leaf) } - // First live idle after seed/respawn authorizes delivery; later explicit-ready titles must also re-offer idle-to-idle upgrades (#12536). + // Mail goes to a pane taking input, at rest or mid-turn, so offer it when the pane starts taking + // input (first live status after seed/respawn included); idle titles re-offer upgrades (#12536). if ( - agentStatus === 'idle' && - (prevStatus !== 'idle' || !prevObservedLive || leafTitleChanged) && + agentStatusTakesTypedInput(agentStatus) && + (prevStatus !== agentStatus || + !prevObservedLive || + (agentStatus === 'idle' && leafTitleChanged)) && this.checkDeliverySettledAndArmRecheck(leaf) ) { this.deliverPendingMessagesForLeaf(leaf) } } // A pane no window publishes (headless serve) has no leaf above; its PTY is the mailbox target. - const lane = ptyIdleEdge ? this.getPtyLaneMailboxTargetForPty(pty) : null + const lane = ptyInputEdge ? this.getPtyLaneMailboxTargetForPty(pty) : null if (lane && this.checkDeliverySettledAndArmRecheck(lane.leaf)) { this.deliverPendingMessagesForLeaf(lane.leaf) } diff --git a/src/main/runtime/terminal/wait/terminal-wait-permission.ts b/src/main/runtime/terminal/wait/terminal-wait-permission.ts index 5728d1896ea..9819ade1b14 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-permission.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-permission.ts @@ -200,7 +200,6 @@ export abstract class OrcaRuntimeWithResolveAuthoritativeTerminalWaitPermission launchToken: pty.launchToken, titleObservedAtEpochMs: pty.lastOscTitleEpochMs, hasExplicitIdleTitle: hasExplicitIdleTitle(pty), - programWorking: this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity === 'working', respawnedAt: this.agentPromptExplicitStatusFloorByPtyId.get(ptyId), lastInputAt: this.terminalRunFacts.readLastInputAt(ptyId), tailIncludes: (text) => retainedTailIncludes(pty.tailBuffer, pty.tailPartialLine, text), diff --git a/src/main/runtime/terminal/wait/terminal-wait-settlements.ts b/src/main/runtime/terminal/wait/terminal-wait-settlements.ts index f77ad8447fc..0ddb2349e64 100644 --- a/src/main/runtime/terminal/wait/terminal-wait-settlements.ts +++ b/src/main/runtime/terminal/wait/terminal-wait-settlements.ts @@ -187,7 +187,9 @@ export abstract class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithB this.deliveryRecheckTimersByLeafKey.set(leafKey, timer) } - /** Pointer plus Enter requires the same readiness ranking as waits; name-only titles can occur mid-turn (#6011). Callers separately require live status evidence. */ + /** Pointer plus Enter takes the waits' readiness ranking, plus a working verdict: agents queue or + * steer text sent mid-turn, so only dialogs, drafts and unsettled panes hold mail. Callers + * separately require live status evidence. */ protected isAgentSettledForDelivery(leaf: { tabId: string; leafId: string }): boolean { const leafKey = this.getLeafKey(leaf.tabId, leaf.leafId) const live = this.leaves.get(leafKey) @@ -208,9 +210,11 @@ export abstract class OrcaRuntimeWithResolveExitWaiters extends OrcaRuntimeWithB } // Why no blocked reader: a refusal here re-arms a recheck that a lingering tail prompt // would spin, so delivery keeps its own, blocked-blind, reading of the same ranking. - return isTuiIdleReadyVerdict( - evaluateTuiIdle({ ...evidence, readTailBlockedReason: () => null }) - ) + const verdict = evaluateTuiIdle({ ...evidence, readTailBlockedReason: () => null }) + // The ranking vetoes overlays only on ready verdicts; an overlay refuses input mid-turn too. + return verdict.kind === 'working' + ? evidence.readScreenInputVeto() !== true + : isTuiIdleReadyVerdict(verdict) } /** An unsent draft would be submitted with the injected text; the cap bounds an abandoned or misread one. */ diff --git a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts index 8d94b3db8df..175d142e7f7 100644 --- a/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts +++ b/src/main/runtime/tui-idle-delivery-and-quiescence.test.ts @@ -16,8 +16,6 @@ const PTY_ID = 'pty-followups' const ESC = String.fromCharCode(27) const BEL = String.fromCharCode(7) const osc = (title: string) => `${ESC}]0;${title}${BEL}` -const agentStatus = (state: string, agentType: string) => - `${ESC}]9999;{"state":"${state}","agentType":"${agentType}"}${BEL}` const GRAPH: RuntimeSyncWindowGraph = { tabs: [ @@ -73,30 +71,26 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => { vi.useRealTimers() }) - it('does not deliver into a pane that is only showing its agent name mid-turn', async () => { + // Agents queue or steer text typed mid-turn, so a busy pane takes mail without waiting for rest. + it('delivers into a pane mid-turn', async () => { const { runtime } = await makeRuntime('codex') const deliver = watchDelivery(runtime) runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) - expect(deliver).not.toHaveBeenCalled() - - // Name-only Codex means idle for display even mid-turn; delivery must not type into that evidence alone. - runtime.onPtyData(PTY_ID, `${osc('Codex')}still working\n`, Date.now()) - expect(deliver).not.toHaveBeenCalled() + expect(deliver).toHaveBeenCalled() }) - it('delivers once the agent states it is done', async () => { + it('does not deliver into a name-only pane that has not gone quiet', async () => { const { runtime } = await makeRuntime('codex') const deliver = watchDelivery(runtime) - runtime.onPtyData(PTY_ID, `${osc('⠋ Codex')}working\n`, Date.now()) - runtime.onPtyData(PTY_ID, `${osc('Codex ready')}done\n`, Date.now()) - expect(deliver).toHaveBeenCalled() + // Name-only Codex is weak idle evidence, which a shell auto-title can also write before the TUI mounts. + runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) + expect(deliver).not.toHaveBeenCalled() }) // Time can lift weak-evidence refusal without any further edge; delivery needs a retry to avoid stranded messages. it('retries a refused delivery once the pane falls quiet', async () => { const { runtime } = await makeRuntime('codex') const deliver = watchDelivery(runtime) - runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) runtime.onPtyData(PTY_ID, `${osc('Codex')}output\n`, Date.now()) expect(deliver).not.toHaveBeenCalled() @@ -117,11 +111,10 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => { expect(deliver).not.toHaveBeenCalled() }) - // A name-only frame consumes working-to-idle; later explicit readiness must still trigger delivery. + // A name-only frame consumes the idle edge; later explicit readiness must still trigger delivery. it('delivers when the ready title arrives after a name-only frame', async () => { const { runtime } = await makeRuntime('codex') const deliver = watchDelivery(runtime) - runtime.onPtyData(PTY_ID, `${osc('\u280b Codex')}working\n`, Date.now()) runtime.onPtyData(PTY_ID, `${osc('Codex')}out\n`, Date.now()) expect(deliver).not.toHaveBeenCalled() @@ -131,27 +124,9 @@ describe('mailbox delivery honours the tui-idle evidence ranking', () => { expect(deliver).toHaveBeenCalled() }) - // First-party done must re-offer delivery after working vetoed idle, without waiting for status expiry. - it('delivers when a done status lands after the idle title was vetoed', async () => { - const { runtime } = await makeRuntime('claude') - const deliver = watchDelivery(runtime) - runtime.onPtyData( - PTY_ID, - `${agentStatus('working', 'claude')}${osc('\u280b Claude')}w\n`, - Date.now() - ) - runtime.onPtyData(PTY_ID, `${osc('claude')}out\n`, Date.now()) - expect(deliver).not.toHaveBeenCalled() - - runtime.onPtyData(PTY_ID, agentStatus('done', 'claude'), Date.now()) - await vi.advanceTimersByTimeAsync(4_500) - expect(deliver).toHaveBeenCalled() - }) - it('still delivers for an agent whose name is its only rest signal', async () => { const { runtime } = await makeRuntime('grok', 'grok') const deliver = watchDelivery(runtime) - runtime.onPtyData(PTY_ID, `${osc('⠋ Grok')}working\n`, Date.now()) runtime.onPtyData(PTY_ID, `${osc('grok')}banner\n`, Date.now()) expect(deliver).toHaveBeenCalled() }) diff --git a/src/main/runtime/tui-idle-hook-lane.test.ts b/src/main/runtime/tui-idle-hook-lane.test.ts index 66eb8f2261e..e4df0c2df23 100644 --- a/src/main/runtime/tui-idle-hook-lane.test.ts +++ b/src/main/runtime/tui-idle-hook-lane.test.ts @@ -178,35 +178,6 @@ describe('readTuiIdleHookTurn', () => { ).toEqual({ state: 'permission', blockedReason: null }) }) - it("marks a live turn end under program work, and joins Agent Teams to claude's row", () => { - const transition = (kind: 'transition' | 'snapshot', launchToken = 'launch-1') => - row({ - agentType: 'claude', - launchToken, - observation: { - origin: 'hook', - authorityId: 'local', - incarnation: 1, - revision: 1, - observedAt: 1, - kind - } - }) - const read = (hookRow: AgentStatusIpcPayload, programWorking = true) => - readTuiIdleHookTurn({ - ...base, - agent: 'claude-agent-teams', - launchToken: 'launch-1', - hookRows: [hookRow], - programWorking - }) - expect(read(transition('transition'))).toEqual({ ...DONE, endedUnderProgramWork: true }) - // A replayed done restates an old turn; another launch's done speaks for its predecessor. - expect(read(transition('snapshot'))).toEqual(DONE) - expect(read(transition('transition', 'launch-0'))).toEqual(DONE) - expect(read(transition('transition'), false)).toEqual(DONE) - }) - it('takes the newest joined row', () => { const now = Date.now() expect( @@ -356,20 +327,6 @@ describe('evaluateTuiIdle hook lane', () => { }) }) -describe('evaluateTuiIdle claude turn end under program work', () => { - it("settles over Claude's working report once its turn end outlasts it (live teammates)", () => { - expect( - evaluateTuiIdle( - input({ - agent: 'claude', - record: record({ lastAgentStatus: 'working' }), - readHookTurn: () => ({ ...DONE, endedUnderProgramWork: true }) - }) - ) - ).toEqual({ kind: 'ready-strong' }) - }) -}) - describe('evaluateTuiIdle turn-end hook lane (codex)', () => { it('settles at once on a done turn, with no title and a streaming pane (headless)', () => { expect(evaluateTuiIdle(input({ agent: 'codex', readHookTurn: () => DONE }))).toEqual({ diff --git a/src/main/runtime/tui-idle-hook-lane.ts b/src/main/runtime/tui-idle-hook-lane.ts index c318e53f3a5..21937df233c 100644 --- a/src/main/runtime/tui-idle-hook-lane.ts +++ b/src/main/runtime/tui-idle-hook-lane.ts @@ -14,9 +14,6 @@ export type TuiIdleHookTurn = { taskWakeupPending?: true /** When a fresh `done`'s turn has had time to paint its last frame. */ finalFrameAt?: number - /** A live turn end while the program still reports `working`, which then covers work beyond - * the lead's turn (e.g. live teammates) rather than the turn itself. */ - endedUnderProgramWork?: true } // Why: Codex runs its Stop hook before it paints the turn's last frame, which can be its plan menu. @@ -24,12 +21,6 @@ const TURN_END_FINAL_FRAME_MS = 750 // Why: Codex opens its plan menu only after a turn that rendered a proposed plan; no hook field says so. const CODEX_PROPOSED_PLAN_HEADER = 'Proposed Plan' -/** The agent whose hooks report for a pane running `agent`. */ -function hookReportingAgent(agent: TuiAgent | null | undefined): TuiAgent | null | undefined { - // Why: Agent Teams runs the same Claude binary, so its hooks report as `claude`. - return agent === 'claude-agent-teams' ? 'claude' : agent -} - /** Scans newest-first so the common hit (this turn's plan) returns early. */ export function retainedTailIncludes( lines: readonly string[], @@ -79,8 +70,6 @@ export type TuiIdleHookTurnRead = { * the turn that input may have started (or the agent it restarted), whose first hook can still * be in flight. */ lastInputAt?: number | null - /** Whether the program currently reports OSC 7501 `working`. */ - programWorking?: boolean /** Whether the pane's retained output holds `text`; read only while a fresh `done` may precede a menu. */ tailIncludes?: (text: string) => boolean /** The existing permission arbiter, given the turn as the pane's explicit status. */ @@ -93,8 +82,7 @@ export type TuiIdleHookTurnRead = { /** Read the freshest pane/handle-joined hook row for this agent and process; unjoinable, stale or foreign rows leave other lanes to decide. */ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn | null { const row = selectFreshExplicitAgentStatusRow(read) - const agent = hookReportingAgent(read.agent) - if (!row || row.agentType !== agent || row.receivedAt < (read.respawnedAt ?? -1)) { + if (!row || row.agentType !== read.agent || row.receivedAt < (read.respawnedAt ?? -1)) { return null } const state = hookLeadTurnState(row) @@ -103,15 +91,10 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn return null } const blockedReason = read.resolveBlockedText(state, row) - // Why: an older host or launch's late hook speaks for its predecessor, not this process. - const ownHook = - row.observation?.origin === 'hook' && - terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) && - (!read.launchToken || row.launchToken === read.launchToken) // Why the hook alone blocks: a question or custom modal paints no dialog text the arbiter knows, // and these hooks report its answer. Input since may have answered it before the hook arrived. const taskWakeupPending = - agent === 'claude' && + read.agent === 'claude' && row.state !== 'done' && (row.claudeTaskWakeupPending === 'notification' || (row.claudeTaskWakeupPending === 'finishing-turn' && @@ -120,28 +103,20 @@ export function readTuiIdleHookTurn(read: TuiIdleHookTurnRead): TuiIdleHookTurn typeof read.titleObservedAtEpochMs === 'number' && read.titleObservedAtEpochMs > (row.turnStartedAt ?? row.receivedAt) ))) && - ownHook && - row.providerSession?.key === 'session_id' + row.observation?.origin === 'hook' && + row.providerSession?.key === 'session_id' && + terminalHostConnectionMatches(row.connectionId, read.connectionId ?? null, read.wslDistro) && + (!read.launchToken || row.launchToken === read.launchToken) const finalFrameAt = row.receivedAt + TURN_END_FINAL_FRAME_MS const finalFramePending = state === 'done' && hookAuthority(read.agent) === 'turn-end' && Date.now() < finalFrameAt && read.tailIncludes?.(CODEX_PROPOSED_PLAN_HEADER) === true - // Why a transition: a replay or snapshot restates an old turn end and cannot prove this one ended. - const endedUnderProgramWork = - state === 'done' && - read.programWorking === true && - ownHook && - row.observation?.kind === 'transition' && - !taskWakeupPending && - !finalFramePending && - blockedReason === null return { state, ...(taskWakeupPending ? { taskWakeupPending: true as const } : {}), ...(finalFramePending ? { finalFrameAt } : {}), - ...(endedUnderProgramWork ? { endedUnderProgramWork: true as const } : {}), blockedReason: blockedReason ?? (state === 'permission' && !predatesInput ? 'agent-interactive-prompt' : null) @@ -161,12 +136,8 @@ export function evaluateHookTurn( readHookTurn: () => TuiIdleHookTurn | null ): TuiIdleHookVerdict | null { // A ready composer can still owe a task wake-up; only that owner-produced fact vetoes Claude rest. - // A turn end that outlasts Claude's `working` report is rest: Claude keeps reporting working while teammates live. - if (hookReportingAgent(agent) === 'claude') { + if (agent === 'claude') { const turn = readHookTurn() - if (turn?.endedUnderProgramWork) { - return { kind: 'ready-strong' } - } return turn?.taskWakeupPending ? turn.blockedReason ? { kind: 'blocked', reason: turn.blockedReason }