fix(pi): settle OMP status from the agent-end contract

OMP never idles in ctx.isIdle() and has no agent_settled hook, so Orca's
idle-recheck left the sidebar running after a finished turn. Honor
willContinue for configured and runtime-routed OMP, and keep leaked
nested-shell OSC 133;D from retiring hook authority while the execution
host still shows the agent in the foreground.

Fixes #14879
STA-4521

Co-authored-by: Bing.Z <zzb@gxsmjx.com>
This commit is contained in:
Brennan Benson
2026-08-23 17:51:39 -07:00
co-authored by Bing.Z
parent 5bcbafff53
commit 2ddfa13fe2
9 changed files with 540 additions and 201 deletions
@@ -0,0 +1,88 @@
import { describe, expect, it, vi } from 'vitest'
import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness'
function postedHookNames(fetchMock: ReturnType<typeof vi.fn>): string[] {
return fetchMock.mock.calls.map(
(call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name as string
)
}
const OMP_RUNTIME_CASES = [
['configured OMP', { kind: 'omp' as const }],
['title-routed OMP', { kind: 'pi' as const, title: 'omp' }],
['argv-routed OMP', { kind: 'pi' as const, argv: ['node', '/usr/local/bin/omp'] }]
] as const
describe('OMP agent_end contract', () => {
it.each(OMP_RUNTIME_CASES)(
'keeps %s working when agent_end will continue',
async (_name, args) => {
vi.useFakeTimers()
try {
const harness = createAgentStatusExtensionHarness(args)
const context = { isIdle: vi.fn(() => true) }
await harness.callHook('agent_start')
await harness.callHook('agent_end', { willContinue: true }, context)
await vi.advanceTimersByTimeAsync(1_000)
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
expect(context.isIdle).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
}
)
it.each(OMP_RUNTIME_CASES)(
'settles a completed %s turn without waiting for ctx.isIdle',
async (_name, args) => {
for (const event of [{ willContinue: false }, {}]) {
const harness = createAgentStatusExtensionHarness(args)
const context = { isIdle: vi.fn(() => false) }
await harness.callHook('agent_start')
await harness.callHook('agent_end', event, context)
await vi.waitFor(() =>
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
)
expect(context.isIdle).not.toHaveBeenCalled()
}
}
)
it('settles a later terminal OMP agent_end after a continuation', async () => {
const harness = createAgentStatusExtensionHarness({ kind: 'omp' })
const context = { isIdle: vi.fn(() => false) }
await harness.callHook('agent_start')
await harness.callHook('agent_end', { willContinue: true }, context)
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start'])
await harness.callHook('agent_end', { willContinue: false }, context)
await vi.waitFor(() =>
expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end'])
)
})
it('does not apply the OMP contract to Pi or Prime', async () => {
vi.useFakeTimers()
try {
for (const kind of ['pi', 'prime-agent'] as const) {
const harness = createAgentStatusExtensionHarness({ kind })
const context = { isIdle: vi.fn(() => false) }
await harness.callHook('agent_end', { willContinue: false }, context)
await vi.advanceTimersByTimeAsync(1_000)
expect(postedHookNames(harness.fetchMock)).toEqual([])
expect(context.isIdle).toHaveBeenCalled()
}
} finally {
vi.useRealTimers()
}
})
})
@@ -1,193 +1,9 @@
import { runInNewContext } from 'node:vm'
// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API.
import ts from 'typescript-api'
import { describe, expect, it, vi } from 'vitest'
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
type HookContext = {
isIdle?: () => boolean
sessionManager?: {
getSessionId?: () => unknown
getSessionFile?: () => unknown
}
}
type HookHandler = (event?: unknown, context?: HookContext) => Promise<void> | void
type FakeCurlChild = {
on: ReturnType<typeof vi.fn>
stdin: {
on: ReturnType<typeof vi.fn>
end: ReturnType<typeof vi.fn>
}
}
type Harness = {
fetchMock: ReturnType<typeof vi.fn>
spawnMock: ReturnType<typeof vi.fn>
spawnedChildren: FakeCurlChild[]
fsMock: {
existsSync: ReturnType<typeof vi.fn>
readFileSync: ReturnType<typeof vi.fn>
statSync: ReturnType<typeof vi.fn>
}
handlers: Record<string, HookHandler>
processEnv: Record<string, string | undefined>
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
// Re-invoke the extension factory in the same process (as Pi does on an
// in-process extension reload), swapping in the freshly registered handlers.
reload: () => void
}
const BASE_ENV = {
ORCA_PANE_KEY: 'pane-1',
ORCA_AGENT_LAUNCH_TOKEN: 'launch-1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'tree-1',
ORCA_AGENT_HOOK_PORT: '4321',
ORCA_AGENT_HOOK_TOKEN: 'token-1',
ORCA_AGENT_HOOK_ENV: 'env-1',
ORCA_AGENT_HOOK_VERSION: '1.2.3'
} satisfies Record<string, string>
// Why: ownership keys on process.pid, so reload and child-process tests need
// stable, distinct identities.
const SELF_PID = 4242
function createHarness(args: {
kind: 'pi' | 'omp' | 'prime-agent'
env?: Record<string, string | undefined>
pid?: number
title?: string
argv?: string[]
existsSync?: (path: string) => boolean
readFileSync?: (path: string, encoding: string) => string
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
}): Harness {
const fetchMock = vi.fn(
args.fetchImpl ??
(async () => ({
ok: true
}))
)
const spawnedChildren: FakeCurlChild[] = []
const spawnMock = vi.fn(() => {
const child: FakeCurlChild = {
on: vi.fn(),
stdin: {
on: vi.fn(),
end: vi.fn()
}
}
spawnedChildren.push(child)
return child
})
const fsMock = {
existsSync: vi.fn(args.existsSync ?? (() => false)),
statSync: vi.fn(
args.statSync ??
((path: string) => {
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
})
),
readFileSync: vi.fn(
args.readFileSync ??
((path: string) => {
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
})
)
}
const module = {
exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void }
}
const requireMock = vi.fn((specifier: string) => {
if (specifier === 'fs') {
return fsMock
}
if (specifier === 'child_process') {
return { spawn: spawnMock }
}
throw new Error(`unexpected require(${specifier})`)
})
const processMock = {
env: {
...BASE_ENV,
...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}),
...args.env
},
pid: args.pid ?? SELF_PID,
title: args.title ?? 'node',
argv: args.argv ?? ['node', '/usr/bin/orca']
}
const context = {
module,
exports: module.exports,
require: requireMock,
process: processMock,
fetch: fetchMock,
console: {
warn: vi.fn(),
error: vi.fn(),
log: vi.fn()
},
Promise,
Buffer,
URL,
AbortController,
setTimeout,
clearTimeout
} as Record<string, unknown>
context.globalThis = context
const source = getPiAgentStatusExtensionSource(args.kind)
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020
}
}).outputText
runInNewContext(output, context)
const register = module.exports.default
if (!register) {
throw new Error('expected default export from generated source')
}
const handlers: Record<string, HookHandler> = {}
const registerInto = (target: Record<string, HookHandler>): void => {
register({
on(name: string, handler: HookHandler) {
target[name] = handler
}
})
}
registerInto(handlers)
return {
fetchMock,
spawnMock,
spawnedChildren,
fsMock,
handlers,
processEnv: processMock.env,
callHook: async (name, event, hookContext) => {
await handlers[name]?.(event, hookContext)
},
reload: () => {
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
registerInto(handlers)
}
}
}
import {
AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID,
createAgentStatusExtensionHarness as createHarness
} from './agent-status-extension-test-harness'
describe('getPiAgentStatusExtensionSource', () => {
it('registers Prime hooks only in the event-emitting daemon worker', () => {
@@ -826,10 +642,10 @@ describe('getPiAgentStatusExtensionSource', () => {
}
})
it('keeps reporting Pi-compatible agents once their agent_end handlers settle', async () => {
it('keeps polling Pi and Prime until their agent_end handlers settle', async () => {
vi.useFakeTimers()
try {
for (const kind of ['pi', 'omp', 'prime-agent'] as const) {
for (const kind of ['pi', 'prime-agent'] as const) {
const harness = createHarness({ kind })
let idle = false
const context = { isIdle: vi.fn(() => idle) }
@@ -0,0 +1,190 @@
import { runInNewContext } from 'node:vm'
// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API.
import ts from 'typescript-api'
import { vi } from 'vitest'
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
export type HookContext = {
isIdle?: () => boolean
sessionManager?: {
getSessionId?: () => unknown
getSessionFile?: () => unknown
}
}
export type HookHandler = (event?: unknown, context?: HookContext) => Promise<void> | void
type FakeCurlChild = {
on: ReturnType<typeof vi.fn>
stdin: {
on: ReturnType<typeof vi.fn>
end: ReturnType<typeof vi.fn>
}
}
export type AgentStatusExtensionHarness = {
fetchMock: ReturnType<typeof vi.fn>
spawnMock: ReturnType<typeof vi.fn>
spawnedChildren: FakeCurlChild[]
fsMock: {
existsSync: ReturnType<typeof vi.fn>
readFileSync: ReturnType<typeof vi.fn>
statSync: ReturnType<typeof vi.fn>
}
handlers: Record<string, HookHandler>
processEnv: Record<string, string | undefined>
callHook: (name: string, event?: unknown, context?: HookContext) => Promise<void>
// Re-invoke the extension factory in the same process (as Pi does on an
// in-process extension reload), swapping in the freshly registered handlers.
reload: () => void
}
const BASE_ENV = {
ORCA_PANE_KEY: 'pane-1',
ORCA_AGENT_LAUNCH_TOKEN: 'launch-1',
ORCA_TAB_ID: 'tab-1',
ORCA_WORKTREE_ID: 'tree-1',
ORCA_AGENT_HOOK_PORT: '4321',
ORCA_AGENT_HOOK_TOKEN: 'token-1',
ORCA_AGENT_HOOK_ENV: 'env-1',
ORCA_AGENT_HOOK_VERSION: '1.2.3'
} satisfies Record<string, string>
// Why: ownership keys on process.pid, so reload and child-process tests need
// stable, distinct identities.
export const AGENT_STATUS_EXTENSION_SELF_PID = 4242
export function createAgentStatusExtensionHarness(args: {
kind: 'pi' | 'omp' | 'prime-agent'
env?: Record<string, string | undefined>
pid?: number
title?: string
argv?: readonly string[]
existsSync?: (path: string) => boolean
readFileSync?: (path: string, encoding: string) => string
statSync?: (path: string) => { mtimeMs: number; size: number; ino: number }
fetchImpl?: (...params: Parameters<typeof fetch>) => Promise<unknown>
}): AgentStatusExtensionHarness {
const fetchMock = vi.fn(
args.fetchImpl ??
(async () => ({
ok: true
}))
)
const spawnedChildren: FakeCurlChild[] = []
const spawnMock = vi.fn(() => {
const child: FakeCurlChild = {
on: vi.fn(),
stdin: {
on: vi.fn(),
end: vi.fn()
}
}
spawnedChildren.push(child)
return child
})
const fsMock = {
existsSync: vi.fn(args.existsSync ?? (() => false)),
statSync: vi.fn(
args.statSync ??
((path: string) => {
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
})
),
readFileSync: vi.fn(
args.readFileSync ??
((path: string) => {
throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' })
})
)
}
const module = {
exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void }
}
const requireMock = vi.fn((specifier: string) => {
if (specifier === 'fs') {
return fsMock
}
if (specifier === 'child_process') {
return { spawn: spawnMock }
}
throw new Error(`unexpected require(${specifier})`)
})
const processMock = {
env: {
...BASE_ENV,
...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}),
...args.env
},
pid: args.pid ?? AGENT_STATUS_EXTENSION_SELF_PID,
title: args.title ?? 'node',
argv: args.argv ?? ['node', '/usr/bin/orca']
}
const context = {
module,
exports: module.exports,
require: requireMock,
process: processMock,
fetch: fetchMock,
console: {
warn: vi.fn(),
error: vi.fn(),
log: vi.fn()
},
Promise,
Buffer,
URL,
AbortController,
setTimeout,
clearTimeout
} as Record<string, unknown>
context.globalThis = context
const source = getPiAgentStatusExtensionSource(args.kind)
const output = ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020
}
}).outputText
runInNewContext(output, context)
const register = module.exports.default
if (!register) {
throw new Error('expected default export from generated source')
}
const handlers: Record<string, HookHandler> = {}
const registerInto = (target: Record<string, HookHandler>): void => {
register({
on(name: string, handler: HookHandler) {
target[name] = handler
}
})
}
registerInto(handlers)
return {
fetchMock,
spawnMock,
spawnedChildren,
fsMock,
handlers,
processEnv: processMock.env,
callHook: async (name, event, hookContext) => {
await handlers[name]?.(event, hookContext)
},
reload: () => {
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
registerInto(handlers)
}
}
}
+9 -2
View File
@@ -115,7 +115,8 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' })',
'',
' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,',
' // while legacy Pi/OMP becomes idle after its final agent_end handlers.',
' // while legacy Pi becomes idle after its final agent_end handlers.',
' // OMP instead marks non-terminal agent_end events with willContinue.',
' const AGENT_END_IDLE_RECHECK_MS = 25',
' const AGENT_END_IDLE_RECHECK_MAX_MS = 250',
' let agentSettledSupported = false',
@@ -167,8 +168,14 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[]
' postAgentEndOnce()',
' })',
'',
" pi.on('agent_end', (_event, ctx) => {",
" pi.on('agent_end', (event, ctx) => {",
...captureSessionMetadata,
' if (isOmpRuntime()) {',
' clearPendingAgentEndCheck()',
' if (event?.willContinue === true) return',
' postAgentEndOnce()',
' return',
' }',
' if (agentSettledSupported) return',
" if (!ctx || typeof ctx.isIdle !== 'function') {",
' postAgentEndOnce()',
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest'
import { shouldRetireLaunchAuthorityOnCommandFinished } from './command-finished-launch-authority'
describe('shouldRetireLaunchAuthorityOnCommandFinished', () => {
it('does not retire while a recognized agent is still foreground', () => {
expect(shouldRetireLaunchAuthorityOnCommandFinished('omp')).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('omp.exe')).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('/usr/local/bin/omp')).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('pi')).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('codex')).toBe(false)
})
it('retires only when the execution host shows a shell in the foreground', () => {
expect(shouldRetireLaunchAuthorityOnCommandFinished('zsh')).toBe(true)
expect(shouldRetireLaunchAuthorityOnCommandFinished('bash')).toBe(true)
expect(shouldRetireLaunchAuthorityOnCommandFinished('powershell.exe')).toBe(true)
expect(shouldRetireLaunchAuthorityOnCommandFinished('pwsh')).toBe(true)
})
it('treats a missing foreground read as unverifiable, not exited', () => {
expect(shouldRetireLaunchAuthorityOnCommandFinished(null)).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('')).toBe(false)
})
it('does not treat an unknown non-shell process as agent exit', () => {
expect(shouldRetireLaunchAuthorityOnCommandFinished('node')).toBe(false)
expect(shouldRetireLaunchAuthorityOnCommandFinished('python3')).toBe(false)
})
})
@@ -0,0 +1,25 @@
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import { isShellProcess } from '../../shared/shell-process-detection'
// Why: match the renderer nested-shell settle window so a leaked OSC 133;D
// from an agent's bash/tool child is not treated as the launched agent exiting.
export const COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS = 350
/**
* True when a command-finished OSC should retire launched-agent hook authority.
*
* Nested tool shells leak OSC 133;D onto the main PTY while the agent is still
* foreground. Loss of contact (null) is unverifiable, not exited, so it must
* not retire a live pane.
*/
export function shouldRetireLaunchAuthorityOnCommandFinished(
foregroundProcess: string | null
): boolean {
if (!foregroundProcess) {
return false
}
if (recognizeAgentProcess(foregroundProcess)) {
return false
}
return isShellProcess(foregroundProcess)
}
+130 -7
View File
@@ -64,6 +64,7 @@ import {
resolveWorktreeScanCacheTtlMs,
type RuntimeTerminalAgentStatusEvent
} from './orca-runtime'
import { COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS } from './command-finished-launch-authority'
import { RUNTIME_GRAPH_RELOAD_TIMEOUT_MS } from './runtime-graph-reload-lifecycle'
import {
appendRecentPtyPathCandidates,
@@ -13182,7 +13183,8 @@ describe('OrcaRuntimeService', () => {
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null
getForegroundProcess: async () => 'zsh',
confirmForegroundProcess: async () => 'zsh'
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
@@ -13220,7 +13222,14 @@ describe('OrcaRuntimeService', () => {
})
).toBeDefined()
runtime.onPtyData('pty-authority', '\x1b]133;D;0\x07', 100)
vi.useFakeTimers()
try {
runtime.onPtyData('pty-authority', '\x1b]133;D;0\x07', 100)
expect(retireAuthority).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
} finally {
vi.useRealTimers()
}
expect(retireAuthority).toHaveBeenCalledWith(spawnEnv.ORCA_PANE_KEY)
expect(runtime.verifyOrchestrationCompatibilityCaller(evidence)).toBeNull()
@@ -13231,11 +13240,118 @@ describe('OrcaRuntimeService', () => {
).toBeUndefined()
})
it('retires only receipted restored PTY authority on command completion and exit', () => {
it('keeps launched-agent authority when command-finished still shows OMP foreground', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-omp-nested', incarnationId: 'process-omp' })
const retireAuthority = vi.fn()
const runtime = new OrcaRuntimeService(store, undefined, {
attestAgentHookCompatibilityAuthority: (candidate) => ({
paneKey: candidate.paneKey,
source: 'current_hook'
}),
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'omp',
confirmForegroundProcess: async () => 'omp'
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-omp-nested' }),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'omp',
launchConfig: { agentCommand: 'omp', agentArgs: '', agentEnv: {} }
})
vi.useFakeTimers()
try {
runtime.onPtyData('pty-omp-nested', '\x1b]133;D;0\x07', 100)
await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
} finally {
vi.useRealTimers()
}
expect(retireAuthority).not.toHaveBeenCalled()
})
it('does not treat an unverifiable command-finished foreground read as agent exit', async () => {
const spawn = vi.fn().mockResolvedValue({ id: 'pty-unverified', incarnationId: 'process-u' })
const retireAuthority = vi.fn()
const runtime = new OrcaRuntimeService(store, undefined, {
attestAgentHookCompatibilityAuthority: (candidate) => ({
paneKey: candidate.paneKey,
source: 'current_hook'
}),
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn,
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
confirmForegroundProcess: async () => null
})
runtime.setNotifier({
worktreesChanged: vi.fn(),
reposChanged: vi.fn(),
activateWorktree: vi.fn(),
createTerminal: vi.fn(),
revealTerminalSession: vi.fn().mockResolvedValue({ tabId: 'tab-unverified' }),
splitTerminal: vi.fn(),
renameTerminal: vi.fn(),
focusTerminal: vi.fn(),
closeTerminal: vi.fn(),
sleepWorktree: vi.fn(),
terminalFitOverrideChanged: vi.fn(),
terminalDriverChanged: vi.fn()
})
runtime.attachWindow(1)
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
command: 'omp',
launchConfig: { agentCommand: 'omp', agentArgs: '', agentEnv: {} }
})
vi.useFakeTimers()
try {
runtime.onPtyData('pty-unverified', '\x1b]133;D;0\x07', 100)
await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
} finally {
vi.useRealTimers()
}
expect(retireAuthority).not.toHaveBeenCalled()
})
it('retires only receipted restored PTY authority on command completion and exit', async () => {
const retireAuthority = vi.fn()
const runtime = new OrcaRuntimeService(store, undefined, {
retireAgentHookCompatibilityAuthority: retireAuthority
})
runtime.setPtyController({
spawn: vi.fn(),
write: () => true,
kill: () => true,
getForegroundProcess: async () => 'zsh',
confirmForegroundProcess: async () => 'zsh'
})
const internals = runtime as unknown as {
recordPtyWorktree: (ptyId: string, worktreeId: string, state: Record<string, unknown>) => void
restoredOrchestrationAuthorityByPtyId: Map<string, Record<string, unknown>>
@@ -13277,10 +13393,17 @@ describe('OrcaRuntimeService', () => {
hostScope: { kind: 'local', hostId: 'local' }
})
runtime.emitDaemonPtyTransientFact('pty-restored-command', {
kind: 'command-finished',
exitCode: 0
})
vi.useFakeTimers()
try {
runtime.emitDaemonPtyTransientFact('pty-restored-command', {
kind: 'command-finished',
exitCode: 0
})
expect(retireAuthority).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
} finally {
vi.useRealTimers()
}
runtime.onPtyExit('pty-restored-exit', 0, 'restored-exit')
runtime.onPtyExit('pty-ordinary-shell', 0, 'ordinary-shell')
+43 -2
View File
@@ -43,6 +43,10 @@ import type {
TerminalSideEffectBatch,
TerminalSideEffectFact
} from '../../shared/terminal-side-effect-facts'
import {
COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS,
shouldRetireLaunchAuthorityOnCommandFinished
} from './command-finished-launch-authority'
import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector'
import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker'
import { parseTerminalKittyKeyboardFlags } from '../../shared/terminal-kitty-keyboard-flags'
@@ -3661,6 +3665,7 @@ export class OrcaRuntimeService {
string,
RestoredOrchestrationAuthorityReceipt
>()
private launchAuthorityCommandFinishedGenerationByPtyId = new Map<string, number>()
private ptyControllerInventorySequence = 0
private ptyControllerAggregateInventoryGeneration = 0
private ptyControllerInventoryGenerationByProvider = new Map<string, number>()
@@ -11459,7 +11464,7 @@ export class OrcaRuntimeService {
this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' })
return
case 'command-finished':
this.retirePtyAgentLaunchAuthority(ptyId)
void this.maybeRetireLaunchAuthorityAfterCommandFinished(ptyId)
this.recordTerminalSideEffectFact(ptyId, {
kind: 'command-finished',
exitCode: fact.exitCode
@@ -11748,7 +11753,7 @@ export class OrcaRuntimeService {
this.confirmPtyAgentExit(ptyId)
},
onCommandFinished: (exitCode: number | null) => {
this.retirePtyAgentLaunchAuthority(ptyId)
void this.maybeRetireLaunchAuthorityAfterCommandFinished(ptyId)
this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode })
},
onBell: () => {
@@ -13963,6 +13968,7 @@ export class OrcaRuntimeService {
}
private retirePtyAgentLaunchAuthority(ptyId: string): void {
this.launchAuthorityCommandFinishedGenerationByPtyId.delete(ptyId)
const pty = this.ptysById.get(ptyId)
if (!pty) {
return
@@ -13991,6 +13997,41 @@ export class OrcaRuntimeService {
}
}
// Why: full-screen agents leak nested-shell OSC 133;D onto the main PTY.
// Retire launched-agent hook authority only after the execution host shows
// a shell in the foreground; a failed/null read is unverifiable, not exited.
private async maybeRetireLaunchAuthorityAfterCommandFinished(ptyId: string): Promise<void> {
const generation = (this.launchAuthorityCommandFinishedGenerationByPtyId.get(ptyId) ?? 0) + 1
this.launchAuthorityCommandFinishedGenerationByPtyId.set(ptyId, generation)
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, COMMAND_FINISHED_LAUNCH_AUTHORITY_SETTLE_MS)
if (typeof timer.unref === 'function') {
timer.unref()
}
})
if (this.launchAuthorityCommandFinishedGenerationByPtyId.get(ptyId) !== generation) {
return
}
if (!this.ptysById.get(ptyId)) {
return
}
let foregroundProcess: string | null = null
try {
foregroundProcess =
(await (this.ptyController?.confirmForegroundProcess?.(ptyId) ??
this.ptyController?.getForegroundProcess(ptyId))) ?? null
} catch {
return
}
if (this.launchAuthorityCommandFinishedGenerationByPtyId.get(ptyId) !== generation) {
return
}
if (!shouldRetireLaunchAuthorityOnCommandFinished(foregroundProcess)) {
return
}
this.retirePtyAgentLaunchAuthority(ptyId)
}
async resolveTerminalCwd(handle: string): Promise<string | null> {
const ptyId = this.resolveLeafForHandle(handle)?.ptyId
if (!ptyId) {
@@ -308,6 +308,25 @@ describe('shared agent-hook-listener', () => {
toolInput: 'pnpm test'
})
expect(tool?.payload.interactivePrompt).toBeUndefined()
const done = normalizeHookPayload(
state,
'omp',
{
paneKey: PANE_KEY,
tabId: 'tab-1',
worktreeId: 'wt',
env: 'production',
version: '1',
payload: { hook_event_name: 'agent_end' }
},
'production'
)
expect(done?.payload).toMatchObject({
state: 'done',
prompt: 'wire omp status',
agentType: 'omp'
})
})
it('maps OMP ask to blocked without publishing a native prompt', () => {