Continue a chat after the terminal agent exits (#33759)

* fix(native-chat): a chat sends again once the terminal's agent exits on macOS

The terminal-writer check required the pane's root process to be a shell,
but macOS panes spawn under login(1), which stays as the shell's parent,
so the check refused every send until the terminal closed. Census the
shell login started instead.

* Reconcile current terminal writer evidence before continuation

* Share fresh foreground confirmation and prove hosted login census

* Restore shell process identity import in foreground tracker

* Correct process fixture identity and polling callback contracts

* Align decisive Windows inspection fixture expectations

* Complete foreground inspection Session fixture
This commit is contained in:
Brennan Benson
2026-10-26 13:47:20 -07:00
committed by GitHub
parent 1df8501246
commit 5709037f4c
17 changed files with 834 additions and 75 deletions
+10 -1
View File
@@ -15,6 +15,10 @@ on:
- 'src/main/window/main-window-state-lifecycle*'
- 'src/main/window/dashboard-popout-window*'
- 'src/packages/process-host/**'
- 'src/main/runtime/structured-agent-cli-writers*'
- 'src/main/providers/macos-tcc-login-shell*'
- 'src/shared/process-table-snapshot*'
- 'src/shared/darwin-process-table-capture*'
- 'src/shared/update-status-types.ts'
- 'pnpm-lock.yaml'
workflow_dispatch:
@@ -29,7 +33,7 @@ concurrency:
jobs:
updater:
if: github.event_name != 'pull_request' || github.event.pull_request.draft != true
runs-on: blacksmith-6vcpu-macos-15
runs-on: macos-15
timeout-minutes: 20
steps:
- uses: actions/checkout@v6
@@ -55,3 +59,8 @@ jobs:
src/main/startup/main-process-quit-update-veto.test.ts
src/main/window/main-window-state-lifecycle.test.ts
src/main/window/dashboard-popout-window.test.ts
- name: Prove login-backed terminal writer census on the disposable runner
env:
ORCA_MACOS_LOGIN_CENSUS_CI: '1'
ORCA_BACKGROUND_LAUNCH: '1'
run: pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/structured-agent-cli-writers.test.ts
@@ -123,6 +123,28 @@ describe('daemon pty foreground degraded-scan handling', () => {
return { proc, handle }
}
it('keeps raw confirmation separate from a known detached writer decision', async () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude', processId: 999 })
.mockResolvedValue({ available: true, processName: 'powershell.exe' })
readConptyMock.mockReturnValue(new Set([12345, 999]))
const { handle } = await spawnWindowsShell()
await readForegroundAt(handle, 0)
if (!handle.confirmForegroundProcess) {
throw new Error('fixture confirmer missing')
}
vi.setSystemTime(BASE_TIME_MS + 40_000)
resolveAgentForegroundProcessMock.mockClear()
expect(await handle.confirmForegroundProcess({ decision: true })).toBe('claude')
expect(resolveAgentForegroundProcessMock).toHaveBeenCalledTimes(1)
expect(await handle.confirmForegroundProcess()).toBe('powershell.exe')
expect(resolveAgentForegroundProcessMock).toHaveBeenLastCalledWith(
12345,
'powershell.exe',
expect.not.objectContaining({ anchorProcessId: 999 })
)
})
it('keeps a cached agent across a degraded (timed-out) scan', async () => {
resolveAgentForegroundProcessMock
.mockResolvedValueOnce({ available: true, processName: 'claude' })
@@ -267,7 +267,7 @@ it('uses Windows job membership and the native process table for ordinary childr
const handle = createHandle()
expect(await handle.confirmForegroundProcess?.()).toBe('vim.exe')
expect(await inspect(handle)).toMatchObject({
foregroundProcess: 'vim.exe',
foregroundProcess: 'zsh',
hasChildProcesses: true
})
expect(readStrict).not.toHaveBeenCalled()
@@ -277,8 +277,9 @@ it('keeps missing Windows job membership unverifiable', async () => {
vi.spyOn(process, 'platform', 'get').mockReturnValue('win32')
resolveWindows.mockResolvedValue({ available: true, processName: null })
members.mockReturnValue(null)
expect(await createHandle().confirmForegroundProcess?.()).toBeNull()
expect(await inspect(createHandle())).toMatchObject({
foregroundProcess: null,
foregroundProcess: 'zsh',
hasChildProcesses: true,
childProcessEvidence: 'unverifiable'
})
@@ -1,5 +1,6 @@
import type * as pty from 'node-pty'
import { ptyShellProcessId } from '../../windows/windows-pty-job'
import * as foreground from '../../providers/pty-foreground-confirmation'
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation'
import {
@@ -11,7 +12,6 @@ import {
readWindowsPtyJobProcessIds
} from '../../providers/windows-pty-job-membership'
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess,
@@ -43,7 +43,7 @@ export type PtyForegroundProcessTracker = {
markDead(): void
/** rawFallback bypasses cached identity and process-table refreshes for cheap polling. */
getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null
confirmForegroundProcess(): Promise<string | null>
confirmForegroundProcess(context?: { decision?: boolean }): Promise<string | null>
confirmShellForeground(): Promise<boolean>
}
@@ -266,45 +266,44 @@ export function createPtyForegroundProcessTracker(args: {
return null
}
},
confirmForegroundProcess: async () => {
confirmForegroundProcess: async (context) => {
if (args.isDead() || !proc.pid) {
return null
}
const decision = process.platform === 'win32' && context?.decision === true
const anchor = cachedAgentForeground
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
process.platform !== 'win32' &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
(process.platform !== 'win32' && !shouldInspectFallback(fallbackProcess))
) {
if (!foreground.shouldConfirmPtyForeground(fallbackProcess, decision)) {
return fallbackProcess
}
const resolution = await resolveForeground(proc.pid, fallbackProcess, {
const { resolution, processName: name } = await foreground.confirmFreshPtyForeground({
process: proc,
fallbackProcess,
contextPaths,
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
decision,
anchor,
anchorAt: anchor?.refreshedAt
})
if (args.isDead() || !resolution.available) {
if (args.isDead()) {
return null
}
const processName =
recognizeAgentProcess(resolution.processName)?.processName ?? resolution.processName
if (shouldCachePtyForeground(processName, staticName)) {
cacheAgentForeground(processName, resolution.processId ?? null)
return processName
if (!resolution.available && !decision) {
return null
}
cachedAgentForeground = null
startupAgentForeground = null
return resolution.processName
const recognized = recognizeAgentProcess(resolution.processName)
const processName = decision ? name : (recognized?.processName ?? resolution.processName)
const cacheName = decision ? (recognized?.processName ?? null) : processName
if (
resolution.available &&
shouldCachePtyForeground(cacheName, decision ? false : staticName)
) {
cacheAgentForeground(cacheName, resolution.processId ?? null)
} else if (!decision || processName !== anchor?.processName) {
cachedAgentForeground = null
startupAgentForeground = null
}
return processName
} catch {
return null
}
+1 -1
View File
@@ -14,7 +14,7 @@ export type SubprocessHandle = {
/** cachedOnly must neither query the process nor schedule inspection. */
getForegroundProcess(options?: { rawFallback?: boolean; cachedOnly?: boolean }): string | null
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
confirmForegroundProcess?(context?: { decision?: boolean }): Promise<string | null>
/** Proves a fresh post-boundary PTY process tree contains only the shell. */
confirmShellForeground?(): Promise<boolean>
/** Skip stdin command delivery when shell arguments already delivered it. */
+4 -2
View File
@@ -243,8 +243,10 @@ export class Session {
return this.subprocess.getForegroundProcess(options)
}
async confirmForegroundProcess(): Promise<string | null> {
return this.subprocess.confirmForegroundProcess?.() ?? this.subprocess.getForegroundProcess()
async confirmForegroundProcess(context?: { decision?: boolean }): Promise<string | null> {
return (
this.subprocess.confirmForegroundProcess?.(context) ?? this.subprocess.getForegroundProcess()
)
}
confirmShellForeground(): Promise<boolean> {
@@ -47,6 +47,7 @@ async function inspect(rawName: string, command: string | null) {
pid: 100,
incarnationId: 'incarnation-1',
isAlive: true,
confirmForegroundProcess: async () => rawName,
inspectChildProcesses: () => 'unverifiable' as const,
getForegroundProcess: () => rawName
} as unknown as Session,
@@ -3,6 +3,35 @@ import { describe, expect, it, vi } from 'vitest'
import type { SubprocessHandle } from './session-subprocess-handle'
import { TerminalHost } from './terminal-host'
import type { IPty } from 'node-pty'
import type { WindowsProcessRow } from '../windows/windows-process-table'
import { createPtyForegroundProcessTracker } from './pty-subprocess/foreground-process-tracker'
import { inspectSpawnFileWindowsChildProcesses } from './pty-subprocess/spawn-file-child-processes'
import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone'
import {
assertCliConversationWritersExited,
type CliWriterHost
} from '../runtime/structured-agent-cli-writers'
const { fullTable, identityTable, jobMembers, consoleMembers } = vi.hoisted(() => ({
fullTable: vi.fn(),
identityTable: vi.fn(),
jobMembers: vi.fn(),
consoleMembers: vi.fn()
}))
vi.mock('../windows/windows-process-table', () => ({
readWindowsProcessTable: fullTable,
readWindowsProcessTableFresh: fullTable,
readWindowsProcessIdentityTable: identityTable,
readWindowsProcessIdentityTableFresh: identityTable
}))
vi.mock('../providers/windows-pty-job-membership', () => ({
readWindowsPtyJobProcessIds: jobMembers,
isWindowsPtyJobReadable: () => true
}))
vi.mock('../providers/windows-console-attached-processes', () => ({
readWindowsConsoleAttachedProcessIds: consoleMembers
}))
function createSubprocess(): SubprocessHandle {
let onExit: ((code: number) => void) | null = null
return {
@@ -71,6 +100,177 @@ describe('TerminalHost process inspection', () => {
}
})
it.each([false, true])(
'uses fresh anchored decisions through Session (static=%s)',
async (staticName) => {
const platform = Object.getOwnPropertyDescriptor(process, 'platform')
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
vi.useFakeTimers({ toFake: ['Date'] })
const nativeRow = (
pid: number,
ppid: number,
name: string,
command = name
): WindowsProcessRow => ({ pid, ppid, name, command })
const shell = nativeRow(100, 1, 'powershell.exe')
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
const cases: [string, boolean, WindowsProcessRow[], boolean, string?][] = [
['D1', false, [shell, daemon], true],
['known young', true, [shell, orphan], false],
['known old', true, [shell, orphan], false, 'old'],
['foreign anchor', true, [shell, { ...daemon, pid: 120 }], true],
['anchor absent', true, [shell, daemon], true],
['anchor exited', true, [shell], true],
['degraded incomplete', false, [shell, daemon], false, 'both'],
['Session exits during foreground read', false, [shell, daemon], false, 'exit']
]
try {
for (const [name, anchored, rows, admits, variant] of cases) {
vi.setSystemTime(1000)
let dead = false
const base = createSubprocess()
const proc: IPty = {
pid: 100,
cols: 80,
rows: 24,
process: 'powershell.exe',
handleFlowControl: false,
onData: () => ({ dispose() {} }),
onExit: () => ({ dispose() {} }),
resize: vi.fn(),
clear: vi.fn(),
write: vi.fn(),
kill: vi.fn(),
pause: vi.fn(),
resume: vi.fn()
}
Object.assign(proc, { processNameIsSpawnFile: staticName })
const tracker = createPtyForegroundProcessTracker({
process: proc,
shellPath: 'powershell.exe',
sessionId: 'decision',
startupAgentRecognition: null,
isDead: () => dead
})
const getForegroundProcess = vi.fn(tracker.getForegroundProcess)
const host = new TerminalHost({
spawnSubprocess: () => ({
...base,
pid: 100,
processNameIsSpawnFile: staticName,
getForegroundProcess,
confirmForegroundProcess: tracker.confirmForegroundProcess,
inspectChildProcesses: (fresh, ignoreDetached) =>
inspectSpawnFileWindowsChildProcesses(proc, shell.name, fresh, ignoreDetached),
onExit: (callback) =>
base.onExit((code) => {
dead = true
tracker.markDead()
callback(code)
})
})
})
try {
const created = await host.createOrAttach({
sessionId: 'decision',
cols: 80,
rows: 24,
streamClient: { onData: vi.fn(), onExit: vi.fn() }
})
fullTable
.mockReset()
.mockResolvedValue([
shell,
{ ...orphan, ppid: 100 },
...(anchored ? [] : [nativeRow(121, 100, 'claude.exe')])
])
consoleMembers.mockResolvedValue(new Set([100, 120, 121]))
expect(await host.confirmForegroundProcess('decision')).toBe('claude')
vi.setSystemTime(variant === 'old' ? 41_000 : 2000)
fullTable.mockReset().mockImplementation(async () => {
if (variant === 'both') {
throw new Error('denied')
}
if (variant === 'exit') {
base.kill()
}
return rows
})
identityTable.mockReset().mockResolvedValue(variant === 'both' ? [shell] : rows)
jobMembers.mockReturnValue(new Set(rows.map((row) => row.pid)))
consoleMembers.mockResolvedValue(new Set([100]))
getForegroundProcess.mockClear()
const terminal = () => ({
pane: {
ptyId: 'decision',
incarnationId: created.incarnationId,
identity: 'decision'
}
})
let alone: boolean | undefined
const writer: CliWriterHost = {
rows: () => [],
saved: () => [],
observation: () => null,
sleeping: () => false,
terminal,
links: () => [
{
agent: 'claude',
id: 'conversation',
paneOwned: true,
worktreeId: 'workspace',
terminal
}
],
shellAlone: async () => {
const inspected = await host.inspectProcess('decision', {
expectedIncarnationId: created.incarnationId,
scanChildProcesses: true,
ignoreDetached: true
})
alone = inspectionShowsShellAlone(inspected)
return alone
}
}
const run = () => assertCliConversationWritersExited(writer, 'claude', 'conversation')
const result = expect(run())
await (admits
? result.resolves.toBeUndefined()
: result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }))
expect(alone, name).toBe(admits)
expect(fullTable, name).toHaveBeenCalledTimes(1)
expect(getForegroundProcess, name).not.toHaveBeenCalled()
if (name === 'D1') {
for (const count of [2, 20]) {
fullTable.mockClear()
identityTable.mockClear()
for (let i = 0; i < count; i++) {
await run()
}
expect(fullTable).toHaveBeenCalledTimes(count)
expect(identityTable).toHaveBeenCalledTimes(count)
}
expect(
inspectionShowsShellAlone(
await host.inspectProcess('decision', { ignoreDetached: false })
)
).toBe(false)
}
} finally {
await host.dispose()
}
}
} finally {
vi.useRealTimers()
if (platform) {
Object.defineProperty(process, 'platform', platform)
}
}
}
)
it('returns unverifiable when the expected incarnation is stale', async () => {
const host = new TerminalHost({ spawnSubprocess: () => createSubprocess() })
try {
@@ -80,7 +80,8 @@ export async function inspectTerminalHostProcess(args: {
}
args.onTier?.('full')
let foregroundProcess = session.getForegroundProcess()
const windowsDecision = process.platform === 'win32' && args.steadyState !== true
let foregroundProcess = windowsDecision ? null : session.getForegroundProcess()
let childProcessEvidence: PtyChildProcessVerdict | undefined = session.processNameIsSpawnFile
? 'unverifiable'
: undefined
@@ -88,8 +89,10 @@ export async function inspectTerminalHostProcess(args: {
if (!incarnationMatches) {
evidence = unverifiableEvidence(args, session, 'incarnation_mismatch')
} else if (process.platform === 'win32') {
if (session.processNameIsSpawnFile) {
foregroundProcess = await session.confirmForegroundProcess()
if (windowsDecision || session.processNameIsSpawnFile) {
foregroundProcess = await session.confirmForegroundProcess(
windowsDecision ? { decision: true } : undefined
)
}
// A steady-state poll never decides adoption or close, so it may read the cached process table.
childProcessEvidence = await session.inspectChildProcesses(
@@ -6,6 +6,7 @@ import {
getStrictProcessTableSnapshotWithAge
} from '../../shared/process-table-snapshot-reader'
import { confirmShellForegroundProcess } from './agent-foreground-process'
import { confirmFreshPtyForeground } from './pty-foreground-confirmation'
import {
createPtyForegroundResolver,
ptyProcessNameIsSpawnFile
@@ -30,7 +31,6 @@ import {
canRevalidateCachedAgentWithoutScan,
judgeCachedAgentJobEvidence
} from './windows-cached-agent-revalidation'
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
/** Retired masters still report the spawn name; inspect the descriptor first so unreadability cannot prove idle. */
@@ -241,34 +241,47 @@ async function readPosixSteadyState(
}
}
export async function confirmLocalPtyForegroundProcess(id: string): Promise<string | null> {
export async function confirmLocalPtyForegroundProcess(
id: string,
decision = false
): Promise<string | null> {
const proc = ptyProcesses.get(id)
if (!proc) {
return null
}
try {
const resolution = await createPtyForegroundResolver(proc)(
proc.pid,
resolveForegroundFallbackProcess(
const windowsDecision = decision && process.platform === 'win32'
const cached = ptyLastRecognizedForeground.get(id)
const anchor = cached ? { processName: cached.name, pid: cached.pid } : null
const { resolution, processName } = await confirmFreshPtyForeground({
process: proc,
fallbackProcess: resolveForegroundFallbackProcess(
ptyProcessNameIsSpawnFile(proc) ? (getPtyShellName(id) ?? null) : proc.process || null,
getPtyShellName(id)
),
{
contextPaths: ptyAgentForegroundContextPaths.get(id),
fresh: true,
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
}
)
contextPaths: ptyAgentForegroundContextPaths.get(id),
decision: windowsDecision,
anchor,
anchorAt: cached?.at
})
// Why: a fresh scan can outlive this PTY id; never publish identity from an exited or same-id-reusing session.
if (ptyProcesses.get(id) !== proc) {
return null
}
if (windowsDecision) {
const recognized = recognizeAgentProcessFromCommandLine(resolution.processName ?? '')
if (resolution.available && recognized) {
ptyLastRecognizedForeground.set(id, {
name: recognized.processName,
pid: resolution.processId ?? null,
at: Date.now(),
steady: null
})
} else if (processName !== anchor?.processName) {
ptyLastRecognizedForeground.delete(id)
}
return processName
}
return resolution.available ? resolution.processName : null
} catch {
return null
@@ -1,7 +1,22 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { assert, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as MacosTccLoginShell from './macos-tcc-login-shell'
import type * as ForegroundProcess from './agent-foreground-process'
import type { WindowsProcessRow } from '../windows/windows-process-table'
import {
ptyLastRecognizedForeground,
ptyProcesses,
ptyIncarnations
} from './local-pty-provider-state'
import { inspectionShowsShellAlone } from '../runtime/run-terminal-shell-alone'
import {
assertCliConversationWritersExited,
type CliWriterHost
} from '../runtime/structured-agent-cli-writers'
const {
fullTable,
identityTable,
consoleMembers,
existsSyncMock,
statSyncMock,
accessSyncMock,
@@ -16,6 +31,9 @@ const {
wslUncDirectoryExistsAsyncMock,
createShellPromptReadinessProbeMock
} = vi.hoisted(() => ({
fullTable: vi.fn(),
identityTable: vi.fn(),
consoleMembers: vi.fn(),
existsSyncMock: vi.fn(),
statSyncMock: vi.fn(),
accessSyncMock: vi.fn(),
@@ -76,7 +94,8 @@ vi.mock('./windows-powershell-executable', () => ({
getWindowsCmdPath: () => CMD_ABS
}))
vi.mock('./agent-foreground-process', () => ({
vi.mock('./agent-foreground-process', async (importOriginal) => ({
...(await importOriginal<typeof ForegroundProcess>()),
resolveAgentForegroundProcessWithAvailability: (...args: unknown[]) =>
resolveAgentForegroundProcessMock(...args)
}))
@@ -86,6 +105,16 @@ vi.mock('./windows-pty-job-membership', () => ({
isWindowsPtyJobReadable: () => true
}))
vi.mock('../windows/windows-process-table', () => ({
readWindowsProcessTable: fullTable,
readWindowsProcessTableFresh: fullTable,
readWindowsProcessIdentityTable: identityTable,
readWindowsProcessIdentityTableFresh: identityTable
}))
vi.mock('./windows-console-attached-processes', () => ({
readWindowsConsoleAttachedProcessIds: consoleMembers
}))
vi.mock('../wsl', () => ({
parseWslPath: (path: string) => {
const match = path.match(/^\\\\wsl\.localhost\\([^\\]+)(.*)$/)
@@ -187,6 +216,193 @@ describe('LocalPtyProvider', () => {
})
})
it.each([false, true])(
'reconciles real Windows decision evidence (static=%s)',
async (staticName) => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
const actual = await vi.importActual<typeof ForegroundProcess>('./agent-foreground-process')
resolveAgentForegroundProcessMock.mockImplementation(
actual.resolveAgentForegroundProcessWithAvailability
)
mockProc.pid = 100
mockProc.process = 'powershell.exe'
Object.assign(mockProc, { processNameIsSpawnFile: staticName })
const { id, incarnationId } = await provider.spawn({
cols: 80,
rows: 24,
shellOverride: 'powershell.exe'
})
assert(incarnationId, 'fixture incarnation missing')
const nativeRow = (
pid: number,
ppid: number,
name: string,
command = name
): WindowsProcessRow => ({ pid, ppid, name, command, creationTimeMs: pid })
const shell = nativeRow(100, 1, 'powershell.exe')
const orphan = nativeRow(120, 999, 'claude.exe', 'claude.exe --resume conversation')
const daemon = nativeRow(130, 1, 'ssh-agent.exe', 'ssh-agent.exe -D')
type EvidenceOptions = {
age?: number
detailed?: WindowsProcessRow[]
identities?: WindowsProcessRow[]
scanFailed?: boolean
jobFailed?: boolean
consoleFailed?: boolean
childFailed?: boolean
}
const cases: [string, number | null, WindowsProcessRow[], boolean, EvidenceOptions?][] = [
['C1', null, [shell, daemon], true],
['live young anchor', 120, [shell, orphan], false],
['live old anchor', 120, [shell, orphan], false, { age: 40_000 }],
['anchor identity missing', 120, [shell, orphan], false, { detailed: [shell] }],
['anchor query denied', 120, [shell, nativeRow(120, 999, 'unknown.exe', '')], false],
['anchored scan failed', 120, [shell, orphan], false, { scanFailed: true }],
['job unavailable', 120, [shell, orphan], false, { jobFailed: true }],
['anchor absent', 120, [shell, daemon], true],
['anchor just exited', 120, [shell], true],
[
'foreign anchor',
120,
[shell, nativeRow(120, 999, 'ssh-agent.exe', 'ssh-agent.exe -D')],
true
],
['attached agent', null, [shell, { ...orphan, ppid: 100 }], false],
['ordinary background job', null, [shell, nativeRow(130, 100, 'sleep.exe')], false],
['incomplete identity', null, [shell, daemon], false, { identities: [shell] }],
['console unavailable', null, [shell, daemon], false, { consoleFailed: true }],
['child read failed', null, [shell, daemon], false, { childFailed: true }],
['unanchored degraded complete', null, [shell, daemon], true, { scanFailed: true }],
[
'unanchored degraded incomplete',
null,
[shell, daemon],
false,
{ scanFailed: true, identities: [shell] }
]
]
vi.useFakeTimers({ toFake: ['Date'] })
try {
const seed = (pid: number | null) =>
ptyLastRecognizedForeground.set(id, { name: 'claude', pid, at: 1000, steady: null })
for (const [name, anchor, rows, admits, input = {}] of cases) {
vi.setSystemTime(1000 + (input.age ?? 1000))
seed(anchor)
readWindowsPtyJobProcessIdsMock.mockReturnValue(
input.jobFailed ? null : new Set(rows.map((row) => row.pid))
)
fullTable.mockReset().mockResolvedValue(input.detailed ?? rows)
if (input.scanFailed) {
fullTable.mockRejectedValue(new Error('denied'))
}
identityTable.mockReset().mockResolvedValue(input.identities ?? rows)
if (input.childFailed) {
identityTable.mockRejectedValue(new Error('denied'))
}
consoleMembers.mockResolvedValue(input.consoleFailed ? null : new Set([100]))
let alone: boolean | undefined
const host: CliWriterHost = {
rows: () => [
{
paneKey: 'source',
connectionId: null,
worktreeId: 'workspace',
agentType: 'claude',
state: 'done',
prompt: '',
receivedAt: 1,
stateStartedAt: 1,
providerSession: { key: 'session_id', id: 'conversation' }
}
],
saved: () => [],
links: () => [],
sleeping: () => false,
observation: () => ({
current: () => true,
topology: 'pane-owned',
presence: async () => null
}),
terminal: () => ({ pane: { ptyId: id, incarnationId, identity: id + incarnationId } }),
shellAlone: async () => {
const observed = await provider.inspectProcess(id, {
expectedIncarnationId: incarnationId,
scanChildProcesses: true,
ignoreDetached: true
})
alone = inspectionShowsShellAlone(observed)
return alone
}
}
const result = expect(assertCliConversationWritersExited(host, 'claude', 'conversation'))
await (admits
? result.resolves.toBeUndefined()
: result.rejects.toMatchObject({ refusal: { code: 'agent_session_conflict' } }))
expect(alone, name).toBe(admits)
expect(fullTable, name).toHaveBeenCalledTimes(1)
expect(identityTable.mock.calls.length, name).toBe(
input.jobFailed || rows.length === 1 ? 0 : 1
)
}
fullTable.mockReset().mockResolvedValue([shell, daemon])
identityTable.mockReset().mockResolvedValue([shell, daemon])
consoleMembers.mockResolvedValue(new Set([100]))
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 130]))
for (const count of [2, 20]) {
fullTable.mockClear()
identityTable.mockClear()
for (let i = 0; i < count; i++) {
seed(null)
expect(
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: true }))
).toBe(true)
}
expect(fullTable).toHaveBeenCalledTimes(count)
expect(identityTable).toHaveBeenCalledTimes(count)
}
seed(null)
fullTable.mockClear()
expect(await provider.getForegroundProcess(id)).toBe('claude')
await expect(
provider.inspectProcess(id, { steadyState: true, ignoreDetached: true })
).resolves.toMatchObject({ foregroundProcess: 'claude' })
expect(fullTable).not.toHaveBeenCalled()
expect(
inspectionShowsShellAlone(await provider.inspectProcess(id, { ignoreDetached: false }))
).toBe(false)
seed(120)
fullTable.mockResolvedValue([shell, orphan])
identityTable.mockResolvedValue([shell, orphan])
readWindowsPtyJobProcessIdsMock.mockReturnValue(new Set([100, 120]))
expect(await provider.confirmForegroundProcess(id)).not.toBe('claude')
for (const replacement of ['object', 'incarnation']) {
const original = ptyProcesses.get(id)
if (!original) {
throw new Error('fixture PTY missing')
}
fullTable.mockImplementationOnce(async () => {
if (replacement === 'object') {
ptyProcesses.set(id, { ...original })
} else {
ptyIncarnations.set(id, 'replacement')
}
return [shell]
})
await expect(
provider.inspectProcess(id, {
expectedIncarnationId: incarnationId,
ignoreDetached: true
})
).resolves.toMatchObject({ childProcessEvidence: 'unverifiable' })
ptyProcesses.set(id, original)
ptyIncarnations.set(id, incarnationId)
}
} finally {
vi.useRealTimers()
}
}
)
describe('getForegroundProcess', () => {
it('returns the process name', async () => {
const { id } = await provider.spawn({ cols: 80, rows: 24 })
+3 -1
View File
@@ -157,7 +157,9 @@ export class LocalPtyProvider implements IPtyProvider {
childProcessEvidence: 'unverifiable'
}
}
const foregroundProcess = await getLocalPtyForegroundProcess(id)
const foregroundProcess = await (process.platform === 'win32' && options?.steadyState !== true
? confirmLocalPtyForegroundProcess(id, true)
: getLocalPtyForegroundProcess(id))
// A steady-state poll never decides adoption or close, so it may read the cached process table.
const childProcessEvidence = await inspectLocalPtyChildProcesses(
id,
+1 -1
View File
@@ -9,7 +9,7 @@ import {
export type { LoginPreflightOutcome } from './macos-login-session-pty-probe'
const MACOS_LOGIN_PATH = '/usr/bin/login'
export const MACOS_LOGIN_PATH = '/usr/bin/login'
const MACOS_BASH_PATH = '/bin/bash'
const MACOS_PRINTF_PATH = '/usr/bin/printf'
const LOGIN_SHELL_TRAMPOLINE = 'export SHELL="$1"; shift; exec -l -- "$@"'
@@ -0,0 +1,87 @@
import type { IPty } from 'node-pty'
import { recognizeAgentProcess } from '../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent'
import { ptyShellProcessId } from '../windows/windows-pty-job'
import { createPtyForegroundResolver } from '../daemon/pty-subprocess/spawn-file-foreground-process'
import {
resolveAgentForegroundProcessWithAvailability,
type AgentForegroundResolutionOptions,
type AgentForegroundProcessResolution
} from './agent-foreground-process'
import {
judgeCachedAgentJobEvidence,
reconcileWindowsAgentDecision,
type WindowsCachedAgentJobVerdict
} from './windows-cached-agent-revalidation'
import { readWindowsConsoleAttachedProcessIds } from './windows-console-attached-processes'
import { isWindowsPtyJobReadable, readWindowsPtyJobProcessIds } from './windows-pty-job-membership'
/** Execute fresh confirmation; owners retain cache publication and lifetime fences. */
export async function confirmFreshPtyForeground(args: {
process: IPty
fallbackProcess: string | null
contextPaths: AgentForegroundResolutionOptions['contextPaths']
decision: boolean
anchor: { processName: string; pid: number | null } | null
anchorAt?: number
}): Promise<{ resolution: AgentForegroundProcessResolution; processName: string | null }> {
const proc = args.process
const shellPid = ptyShellProcessId(proc) ?? proc.pid
let verdict: WindowsCachedAgentJobVerdict = 'unavailable'
if (args.decision) {
try {
verdict = judgeCachedAgentJobEvidence({
jobProcessIds: readWindowsPtyJobProcessIds(proc),
jobSupported: isWindowsPtyJobReadable(),
shellPid,
anchorProcessId: args.anchor?.pid ?? null,
identityAgeMs: Date.now() - (args.anchorAt ?? 0)
})
} catch {
/* A failed job read cannot release a known anchor. */
}
}
let resolution: AgentForegroundProcessResolution
try {
const resolve = args.decision
? resolveAgentForegroundProcessWithAvailability
: createPtyForegroundResolver(proc)
resolution = await resolve(proc.pid, args.fallbackProcess, {
contextPaths: args.contextPaths,
fresh: true,
...(args.decision && args.anchor?.pid != null
? { anchorProcessId: args.anchor.pid, anchorProcessName: args.anchor.processName }
: {}),
...(process.platform === 'win32'
? {
forceProcessScan: true,
readWindowsConsoleAttachedProcessIds: () =>
readWindowsConsoleAttachedProcessIds(proc.pid)
}
: {})
})
} catch {
resolution = { available: false, processName: null }
}
return {
resolution,
processName: args.decision
? reconcileWindowsAgentDecision(resolution, verdict, args.anchor, shellPid)
: resolution.available
? resolution.processName
: null
}
}
/** Known direct agent names need no POSIX scan; Windows decisions still require fresh evidence. */
export function shouldConfirmPtyForeground(fallback: string | null, decision: boolean): boolean {
if (!fallback) {
return decision
}
const recognized = recognizeAgentProcess(fallback)
return (
process.platform === 'win32' ||
recognized === null ||
shouldInspectOuterWrapperForegroundProcess(recognized)
)
}
@@ -1,4 +1,6 @@
import { isShellProcess } from '../../shared/shell-process-detection'
import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition'
import type { AgentForegroundProcessResolution } from './agent-foreground-process'
/** Unanchored job evidence may outlive console agents; cap it at five renderer confirm ladders (30s).
* Refresh on every positive recognition so the limit never times out a confirmed live agent. */
@@ -58,3 +60,28 @@ export function judgeCachedAgentJobEvidence(args: {
}
return withinAgeBound ? 'unproven' : 'expired'
}
/** Decision authority comes from fresh recognition or a known anchor not yet proved gone. */
export function reconcileWindowsAgentDecision(
resolution: AgentForegroundProcessResolution,
verdict: WindowsCachedAgentJobVerdict,
anchor: { processName: string; pid: number | null } | null,
shellPid: number
): string | null {
if (resolution.available && recognizeAgentProcessFromCommandLine(resolution.processName ?? '')) {
return resolution.processName
}
if (
anchor?.pid != null &&
anchor.pid !== shellPid &&
recognizeAgentProcessFromCommandLine(anchor.processName) &&
!resolution.anchorPidForeign &&
(verdict === 'confirmed' ||
verdict === 'recheck' ||
verdict === 'unavailable' ||
verdict === 'unsupported')
) {
return anchor.processName
}
return resolution.available ? resolution.processName : null
}
@@ -1,3 +1,4 @@
import type * as ProcessTableReader from '../../shared/process-table-snapshot-reader'
import { hostSleepKey } from './terminal/stop/worktree-terminal-sleep-key'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentStatusIpcPayload } from '../../shared/agent-status-types'
@@ -7,13 +8,14 @@ import type { SleepingAgentSessionRecord } from '../../shared/agent-session-resu
import type { EnrichedAgentHookEventPayload } from '../agent-hooks/server/server-types'
import {
assertCliConversationWritersExited,
posixCliWriterShellAlone,
type CliWriterHost
} from './structured-agent-cli-writers'
import { OrcaRuntimeService } from './orca-runtime'
import type { RuntimeStore } from './runtime-store-contract'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch'
import { getDefaultWorkspaceSession } from '../../shared/constants'
import { bindPaneFirst } from './bind-first-pane.test-fixture'
import { asProfileSessionStore } from './profile-session-store.test-fixture'
import {
OPEN_CODE_WRITER_TOPOLOGY,
tmuxCliWriterEvent
@@ -95,6 +97,108 @@ function writerLayoutStore(): RuntimeStore {
beforeEach(() => vi.clearAllMocks())
describe('execution-host conversation writer inspection', () => {
it('looks through the macOS login wrapper to the shell it started', async () => {
// Captured from a real macOS pane: login(1) stays as the shell's parent.
const wrapped = [
{
pid: 10,
ppid: 1,
stat: 'Ss',
command: '/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login'
},
{ pid: 11, ppid: 10, stat: 'S+', command: '-/bin/zsh -l' }
]
snapshot.mockResolvedValue(wrapped)
await expect(posixCliWriterShellAlone(10)).resolves.toBe(true)
for (const rows of [
[wrapped[0]],
[...wrapped, { ...wrapped[1], pid: 13 }],
[wrapped[0], { ...wrapped[1], command: 'claude' }],
[wrapped[0], { ...wrapped[1], stat: 'Ss' }]
]) {
snapshot.mockResolvedValue(rows)
await expect(posixCliWriterShellAlone(10)).resolves.toBe(false)
}
})
it.skipIf(
process.platform !== 'darwin' ||
process.env.GITHUB_ACTIONS !== 'true' ||
process.env.RUNNER_ENVIRONMENT !== 'github-hosted' ||
process.env.ORCA_MACOS_LOGIN_CENSUS_CI !== '1'
)(
'censuses a real login PTY on a disposable hosted Mac',
async () => {
expect(process.env.ORCA_DISABLE_MACOS_LOGIN_SHELL).toBeUndefined()
const login = await import('../providers/macos-tcc-login-shell')
expect(await login.prepareMacosTccLoginShell()).toMatchObject({
ok: true,
reason: 'accepted'
})
const wrapped = login.wrapShellSpawnForMacosTccAttribution('/bin/bash', [
'--noprofile',
'--norc',
'-i'
])
expect(wrapped.file).toBe('/usr/bin/login')
const real = await vi.importActual<typeof ProcessTableReader>(
'../../shared/process-table-snapshot-reader'
)
snapshot.mockImplementation(real.getFreshShellForegroundSnapshot)
const { spawn } = await import('node-pty')
const proc = spawn(wrapped.file, wrapped.args, {
name: 'xterm-256color',
cols: 80,
rows: 24,
env: { PATH: '/usr/bin:/bin', TERM: 'xterm-256color' }
})
let exited = false
const subscription = proc.onExit(() => {
exited = true
})
const idle = () => posixCliWriterShellAlone(proc.pid)
const rows = () => real.getFreshShellForegroundSnapshot()
try {
await expect.poll(idle, { timeout: 15000 }).toBe(true)
const initial = await rows()
expect(initial.find((row) => row.pid === proc.pid)?.command).toMatch(/^\/usr\/bin\/login /)
const children = initial.filter((row) => row.ppid === proc.pid)
expect(children).toHaveLength(1)
expect(children[0].command).toMatch(/bash/)
expect(children[0].stat).toContain('+')
for (const kind of ['foreground', 'background', 'suspended']) {
proc.write(kind === 'foreground' ? '/bin/sleep 30\r' : '/bin/sleep 30 & job=$!\r')
const jobObserved = async (stopped = false) =>
(await rows()).some(
(row) =>
row.ppid === children[0].pid &&
row.command === '/bin/sleep 30' &&
(!stopped || row.stat.includes('T'))
)
await expect.poll(() => jobObserved(), { timeout: 15000 }).toBe(true)
if (kind === 'suspended') {
proc.write('kill -STOP "$job"\r')
await expect.poll(() => jobObserved(true), { timeout: 15000 }).toBe(true)
}
expect(await idle()).toBe(false)
proc.write(
kind === 'foreground'
? '\x03'
: 'kill -CONT "$job"; kill "$job"; wait "$job"; unset job\r'
)
await expect.poll(idle, { timeout: 15000 }).toBe(true)
}
} finally {
// Only this shell's recorded job is signalled; login exits through its own shell.
proc.write('\x03')
proc.write('if [ -n "$job" ]; then kill -CONT "$job"; kill "$job"; wait "$job"; fi; exit\r')
await expect.poll(() => exited, { timeout: 15000 }).toBe(true)
subscription.dispose()
}
},
120000
)
it('accepts exact exited or unchanged shell-only proof, never loss of evidence', async () => {
await expect(inspection('unverifiable').run()).rejects.toMatchObject(refusal)
for (const cause of ['missing-row', 'sleeping', 'failed-scan', 'unknown-topology'] as const) {
@@ -403,11 +507,26 @@ describe('execution-host conversation writer inspection', () => {
await expect(noRows.run()).rejects.toMatchObject(refusal)
})
it('inspects the terminal a conversation leaves, whatever its rows now say', async () => {
it.each([
['darwin', true],
['darwin', false],
['linux', false]
] as const)('inspects %s CLI consumers (login=%s)', async (platform, wrapped) => {
const leaf = '11111111-1111-4111-8111-111111111111'
const pane = `tab-a:${leaf}`
let rows: AgentStatusIpcPayload[] = []
const store = ORCA_LAYOUT_OWNER_IS_MAIN ? writerLayoutStore() : null
const store = writerLayoutStore()
const sessions = asProfileSessionStore(store, 'local')
// The hook shell captures $PPID, the CLI, before starting its helper shell.
const cliProcess = {
pid: 82801,
platform,
startTime:
platform === 'darwin'
? 'Mon Oct 26 03:00:00 2026'
: '11111111-2222-4333-8444-555555555555:123456'
}
probe.mockResolvedValue('exited')
class WriterRuntime extends OrcaRuntimeService {
readonly sleepStates = this.terminalSleepStateByHostWorktree
}
@@ -416,7 +535,7 @@ describe('execution-host conversation writer inspection', () => {
readHookCliWriterObservation: () => ({
current: () => true,
topology: 'pane-owned',
presence: async () => null
presence: async () => probe(cliProcess)
})
})
const register = (tabId: string, incarnationId?: string, isWsl?: boolean) => {
@@ -428,7 +547,9 @@ describe('execution-host conversation writer inspection', () => {
register('tab-a', 'i1')
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the census reads only listProcesses.
runtime.setPtyController({
listProcesses: async () => [{ id: 'pty', rootProcessId: 10, incarnationId: 'i1' }]
listProcesses: async () => [
{ id: 'pty', rootProcessId: wrapped ? 82718 : 82760, incarnationId: 'i1' }
]
} as never)
const event = (agentType: string, extra?: Partial<EnrichedAgentHookEventPayload>) => ({
paneKey: pane,
@@ -437,6 +558,9 @@ describe('execution-host conversation writer inspection', () => {
stateStartedAt: 1,
providerSession: { key: 'session_id' as const, id: 'conversation' },
payload: { state: 'done' as const, prompt: '', agentType },
...(agentType === 'claude'
? { agentPresence: { agent: 'claude' as const, process: cliProcess } }
: {}),
...extra
})
const report = (agentType: string, extra?: Partial<EnrichedAgentHookEventPayload>) =>
@@ -445,16 +569,40 @@ describe('execution-host conversation writer inspection', () => {
agent: 'claude' | 'opencode',
source?: { workspaceId: string; paneKey: string }
) => runtime.assertStructuredAdoptionTerminalWritersExited(agent, 'conversation', source)
const shell = { pid: 10, ppid: 1, stat: 'Ss+', command: '/bin/zsh' }
const shell = {
pid: 82760,
ppid: wrapped ? 82718 : 1,
pgid: 82760,
tpgid: 82760,
stat: 'S+',
command: platform === 'darwin' ? '-/bin/zsh -l' : '/bin/bash'
}
const launch = wrapped
? [
{
pid: 82718,
ppid: 46400,
pgid: 82718,
tpgid: 82760,
stat: 'Ss',
command:
'/usr/bin/login -flpq user /bin/bash --noprofile --norc -p -c orca-tcc-login /bin/zsh /bin/zsh -l'
}
]
: []
const census = async (run: () => Promise<void>) => {
// A prompt's zpty worker left the session; any suspended or background job counts, whatever its name.
const worker = [
...launch,
shell,
{ pid: 20, ppid: 10, stat: 'Ss+', command: '-zsh -i' },
{ pid: 20, ppid: 82760, stat: 'Ss+', command: '-zsh -i' },
{ pid: 21, ppid: 20, stat: 'S+', command: 'git status' }
]
for (const stat of ['T', 'SN']) {
snapshot.mockResolvedValue([...worker, { pid: 22, ppid: 10, stat, command: 'agent-alias' }])
for (const stat of ['S+', 'T', 'SN']) {
snapshot.mockResolvedValue([
...worker,
{ pid: 82801, ppid: 82760, stat, command: 'agent-alias' }
])
await expect(run()).rejects.toMatchObject(refusal)
}
snapshot.mockResolvedValue(worker)
@@ -480,18 +628,40 @@ describe('execution-host conversation writer inspection', () => {
runtime.sleepStates.delete(hostSleepKey('local', 'workspace'))
}
runtime.sleepStates.clear()
// A row restored after restart has no observed identity; the pane's current PTY decides.
// The current row rechecks the exited CLI identity before inspecting its PTY.
rows = [{ ...row, paneKey: pane, agentType: 'claude' }]
await census(() => check('claude'))
const saved: SleepingAgentSessionRecord = {
paneKey: pane,
worktreeId: 'workspace',
agent: 'claude',
providerSession: { key: 'session_id', id: 'conversation' },
prompt: '',
state: 'done',
origin: 'live',
capturedAt: 1,
updatedAt: 1
}
sessions.set(
{ ...sessions.get('local'), sleepingAgentSessionsByPaneKey: { [pane]: saved } },
'local'
)
await census(() => check('claude'))
rows = []
await census(() => check('claude'))
sessions.set({ ...sessions.get('local'), sleepingAgentSessionsByPaneKey: {} }, 'local')
expect(probe).toHaveBeenCalledWith(cliProcess)
// Paused, then a second run overwrote the row and the pane moved: the link is the execution's.
report('claude')
rows = [{ ...row, paneKey: pane, agentType: 'claude' }]
await census(() => check('claude'))
rows = [{ ...rows[0], providerSession: { key: 'session_id', id: 'later-run' } }]
report('claude', { providerSession: { key: 'session_id', id: 'later-run' } })
register('tab-b', 'i1')
await census(() => check('claude'))
// The agent's own process can outlive the terminal's tree, as in detached tmux or nohup.
report('claude', {
agentPresence: { agent: 'claude', process: { pid: 12, platform: 'darwin', startTime: 's' } }
agentPresence: { agent: 'claude', process: cliProcess }
})
for (const verdict of ['live', 'unverifiable', 'exited'] as const) {
probe.mockResolvedValueOnce(verdict)
@@ -10,6 +10,7 @@ import type { CliWriterObservation } from '../agent-hooks/server/cli-writer-obse
import { getFreshShellForegroundSnapshot } from '../../shared/process-table-snapshot-reader'
import { collectDescendantsFromIndex, getProcessTableIndex } from '../../shared/process-table-index'
import { isShellProcess } from '../../shared/shell-process-detection'
import { MACOS_LOGIN_PATH } from '../providers/macos-tcc-login-shell'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type { AgentProcessIdentity } from '../../shared/agent-process-presence'
import { probeAgentProcessPresence } from '../../shared/agent-process-presence-probe'
@@ -48,14 +49,20 @@ export type CliWriterHost = {
/** Unlike foreground ownership, any job left in the shell's session refuses, background and suspended too. */
export async function posixCliWriterShellAlone(rootPid: number): Promise<boolean> {
const index = getProcessTableIndex(await getFreshShellForegroundSnapshot())
const executable = (command: string) => command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? ''
const root = index.byPid.get(rootPid)
const executable = root?.command.trim().replace(/^-/, '').split(/\s+/, 1)[0] ?? ''
// macOS panes spawn under login(1), which stays as the shell's parent; it is launch plumbing.
const launched =
root && executable(root.command) === MACOS_LOGIN_PATH
? (index.childrenByPpid.get(rootPid) ?? [])
: undefined
const shell = launched ? (launched.length === 1 ? launched[0] : undefined) : root
return (
!!root &&
isShellProcess(executable) &&
root.stat.includes('+') &&
!!shell &&
isShellProcess(executable(shell.command)) &&
shell.stat.includes('+') &&
// A session leader below the shell has left this terminal (a prompt's zpty worker); its jobs never do.
collectDescendantsFromIndex(index, rootPid, (row) => row.stat.includes('s')).length === 0
collectDescendantsFromIndex(index, shell.pid, (row) => row.stat.includes('s')).length === 0
)
}