wip(daemon): terminal-host inspectProcess coercion — in-progress evidence plumbing

Snapshot of a stalled lane so the work survives. Not reviewed, not complete.
This commit is contained in:
Brennan Benson
2026-08-28 01:13:18 -07:00
parent 9772f8a741
commit 31ab6a60ee
47 changed files with 714 additions and 212 deletions
@@ -37,6 +37,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write() {},
resize() {},
kill() {},
+4
View File
@@ -29,6 +29,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write() {},
resize() {},
kill() {},
@@ -80,6 +80,10 @@ function createMockSubprocess(): SubprocessHandle & { exit(code: number): void }
return {
pid: 9345,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
+4
View File
@@ -95,6 +95,10 @@ function createMockSubprocess() {
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
@@ -19,6 +19,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
confirmForegroundProcess: vi.fn(async () => null),
write: vi.fn(),
resize: vi.fn(),
@@ -39,6 +39,10 @@ export function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle
// Why: getCwd falls back to OS pid lookup; an implausibly-high fake pid can't collide with a real process' cwd.
pid: 999_999_999,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
confirmShellForeground: vi.fn(async () => false),
write: vi.fn(),
resize: vi.fn(),
@@ -8,6 +8,7 @@ import {
type SessionInfo
} from './types'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { PtyProcessInspectionEvidence } from '../../shared/pty-process-inspection-evidence'
export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshots {
// Why: daemon-backed PTYs can host long-lived agents while detached; cleanup prompts must not treat them as idle shells.
@@ -42,6 +43,12 @@ export abstract class DaemonPtyProcessInspection extends DaemonPtyBufferSnapshot
return this.client.request<{
foregroundProcess: string | null
hasChildProcesses: boolean
// Why optional: daemons survive in-place app updates, so a pre-evidence
// daemon omits the field and readers fall back to the legacy collapse
// (readPtyProcessInspectionEvidence). The composed pre-v27 result above
// stays evidence-less for the same reason: an old daemon's published
// values are the only answer it can give.
processEvidence?: PtyProcessInspectionEvidence
}>('inspectProcess', { sessionId: id })
}
@@ -19,6 +19,10 @@ function createMockSubprocess(): SubprocessHandle & {
return {
pid: 999_999_999,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
@@ -18,6 +18,10 @@ function createFixtureSubprocess(pid: number): FixtureSubprocess {
return {
pid,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
pause: vi.fn(),
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) =
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
@@ -44,6 +44,10 @@ function createMockSubprocess(): SubprocessHandle & {
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
@@ -12,6 +12,10 @@ function fixtureSubprocess(): SubprocessHandle {
return {
pid: process.pid,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: () => {},
resize: () => {},
kill: () => queueMicrotask(() => onExit?.(0)),
@@ -12,6 +12,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
@@ -14,6 +14,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55_555,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 44444,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write() {},
resize() {},
kill: exit,
+4
View File
@@ -27,6 +27,10 @@ function createMockSubprocess(): SubprocessHandle & {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
confirmForegroundProcess: confirmForegroundProcessMock,
write: vi.fn(),
resize: vi.fn(),
@@ -42,6 +42,10 @@ describe('daemon session scrollback window', () => {
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
@@ -24,6 +24,10 @@ function createMockSubprocess(): SubprocessHandle & { emitData: (data: string) =
return {
pid: 4243,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExit?.(0), 1)),
+4
View File
@@ -34,6 +34,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 88888,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
@@ -33,6 +33,10 @@ function createMockSubprocess(): MockSubprocess {
return {
pid: 55_555,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
@@ -19,6 +19,10 @@ function fixtureSubprocess(): SubprocessHandle {
return {
pid: 1234,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: () => {},
resize: () => {},
kill: () => {
@@ -22,6 +22,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write() {},
resize() {},
kill() {},
@@ -0,0 +1,230 @@
import type * as pty from 'node-pty'
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
import {
judgeCachedAgentJobEvidence,
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
} from '../../providers/windows-cached-agent-revalidation'
import {
isWindowsPtyJobReadable,
readWindowsPtyJobProcessIds
} from '../../providers/windows-pty-job-membership'
import {
isAgentForegroundWrapperProcess,
recognizeAgentProcess
} from '../../../shared/agent-process-recognition'
import { shouldInspectOuterWrapperForegroundProcess } from '../../../shared/foreground-wrapper-agent'
import { isShellProcess } from '../../../shared/shell-process-detection'
export const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
// Why 30s: comfortably above the slowest idle refresh cadence (15s on Windows)
// so a healthy idle pane always holds corroboration, while a pane whose scans
// stopped settling loses shell-exit authority within seconds.
const SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS = 30_000
// `pid` anchors the identity to the row that proved it (null when ambiguous).
export type CachedAgentForeground = {
processName: string
pid: number | null
refreshedAt: number
}
/** Outcome of the most recently settled identity scan. `sawAgent` is only
* meaningful when `available` — an unavailable scan observed nothing. */
export type ForegroundScanSettlement = { at: number; available: boolean; sawAgent: boolean }
export type ForegroundIdentityState = {
cachedAgentForeground: CachedAgentForeground | null
startupAgentForeground: { processName: string; expiresAt: number } | null
lastScanSettlement: ForegroundScanSettlement | null
refreshInFlight: boolean
lastRefreshStartedAt: number
lastOutputAt: number
}
export function getActiveStartupAgent(
state: ForegroundIdentityState,
now = Date.now()
): { processName: string; expiresAt: number } | null {
if (!state.startupAgentForeground) {
return null
}
if (now > state.startupAgentForeground.expiresAt) {
state.startupAgentForeground = null
return null
}
return state.startupAgentForeground
}
/**
* A shell-shaped title is exit evidence only when a completed scan agrees the
* pane is idle. node-pty's POSIX title read silently falls back to the spawned
* shell file when the native read fails, so under the same distress that
* degrades the scan, "title == shell" observes nothing — and a scan that last
* saw the agent cannot corroborate its absence either.
*/
export function isShellTitleCorroborated(
settlement: ForegroundScanSettlement | null,
now: number
): boolean {
return (
settlement !== null &&
settlement.available &&
!settlement.sawAgent &&
now - settlement.at <= SHELL_TITLE_SCAN_CORROBORATION_MAX_AGE_MS
)
}
/**
* The throttled async identity scan behind the tracker's synchronous reads:
* resolves the pane's real foreground agent through the process table, keeps
* or retires the cached identity, and records how its last scan settled so
* the sync read can tell an observed idle shell from a degraded read.
*/
export function createForegroundIdentityRefresh(args: {
process: pty.IPty
state: ForegroundIdentityState
contextPaths: string[]
isDead: () => boolean
getFallbackProcess: () => string | null
shouldInspectFallback: (fallbackProcess: string | null) => boolean
}): (fallbackProcess: string | null) => void {
const proc = args.process
const state = args.state
return (fallbackProcess) => {
if (args.isDead() || !proc.pid) {
return
}
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
!args.shouldInspectFallback(fallbackProcess)
) {
return
}
const now = Date.now()
const idleNoEvidenceShell =
fallbackIsShell && !getActiveStartupAgent(state, now) && !state.cachedAgentForeground
const retryMs = !idleNoEvidenceShell
? FOREGROUND_AGENT_CACHE_TTL_MS
: process.platform === 'win32' &&
now - state.lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
: SHELL_FOREGROUND_REFRESH_RETRY_MS
if (state.refreshInFlight || now - state.lastRefreshStartedAt < retryMs) {
return
}
state.refreshInFlight = true
state.lastRefreshStartedAt = now
const identityOlderThan = (ms: number): boolean =>
state.cachedAgentForeground !== null &&
Date.now() - state.cachedAgentForeground.refreshedAt > ms
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
const currentFallbackProcess = args.getFallbackProcess()
if (
fallbackIsShell &&
!getActiveStartupAgent(state) &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess) &&
(!onlyWhenAged || identityOlderThan(WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS))
) {
state.cachedAgentForeground = null
state.startupAgentForeground = null
} else if (
identityOlderThan(FOREGROUND_AGENT_CACHE_TTL_MS) &&
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
state.cachedAgentForeground = null
}
}
const anchor = state.cachedAgentForeground
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
contextPaths: args.contextPaths,
...(anchor?.pid != null
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
: {})
})
.then<string | void>(({ processName, processId, available, anchorPidForeign }) => {
state.lastScanSettlement = {
at: Date.now(),
available,
sawAgent: available && recognizeAgentProcess(processName) !== null
}
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (
process.platform === 'win32' &&
fallbackIsShell &&
state.cachedAgentForeground !== null
) {
// Job, not console: needs no console attachment, so no fork (#10857).
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: readWindowsPtyJobProcessIds(proc),
jobSupported: isWindowsPtyJobReadable(),
shellPid: proc.pid,
anchorProcessId: state.cachedAgentForeground.pid,
identityAgeMs: Date.now() - state.cachedAgentForeground.refreshedAt
})
// Unverifiable is never exit proof (ssh-execution-boundary.md): hold.
if (verdict === 'unavailable') {
return
}
if (verdict === 'unsupported') {
// No job to consult on this build, and the scan that got here was
// available and found no agent. Trust it, as every other platform
// does, rather than holding a dead name forever (#16059).
retireStaleForegroundIdentity()
return
}
if (verdict === 'confirmed' || verdict === 'recheck') {
if (anchorPidForeign === true) {
// The scan proved the pid recycled to a non-agent: retire now.
retireStaleForegroundIdentity()
return
}
// The anchor pid is still in the job: the scan lost the row, not
// the agent. Restamp so a live agent never ages out (#9258).
state.cachedAgentForeground = {
...state.cachedAgentForeground,
refreshedAt: Date.now()
}
return
}
if (verdict === 'exited' || verdict === 'anchor-exited') {
// Safe mid-restart: an available scan already found no agent.
retireStaleForegroundIdentity()
return
}
// Unanchored superset evidence cannot tell a working agent from a
// leftover; the age bound settles it.
retireStaleForegroundIdentity({ onlyWhenAged: true })
return
}
retireStaleForegroundIdentity()
return
}
state.cachedAgentForeground = {
processName,
pid: processId ?? null,
refreshedAt: Date.now()
}
state.startupAgentForeground = null
return processName
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
state.lastScanSettlement = { at: Date.now(), available: false, sawAgent: false }
})
.finally(() => {
state.refreshInFlight = false
})
}
}
@@ -2,15 +2,6 @@ import type * as pty from 'node-pty'
import { getAgentForegroundContextPaths } from '../../providers/agent-foreground-context-paths'
import { resolveAgentForegroundProcessWithAvailability } from '../../providers/agent-foreground-process'
import { confirmPtyShellForeground } from './pty-shell-foreground-confirmation'
import {
judgeCachedAgentJobEvidence,
WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS
} from '../../providers/windows-cached-agent-revalidation'
import {
isWindowsPtyJobReadable,
readWindowsPtyJobProcessIds
} from '../../providers/windows-pty-job-membership'
import { readWindowsConsoleAttachedProcessIds } from '../../providers/windows-console-attached-processes'
import {
isAgentForegroundWrapperProcess,
@@ -24,19 +15,22 @@ import {
import { isShellProcess } from '../../../shared/shell-process-detection'
import { resolveFallbackForegroundProcess } from './foreground-fallback-process'
import { parsePtySessionId } from '../pty-session-id'
import type { ForegroundProcessObservation } from '../session-subprocess-handle'
import {
createForegroundIdentityRefresh,
FOREGROUND_AGENT_CACHE_TTL_MS,
getActiveStartupAgent,
isShellTitleCorroborated,
type ForegroundIdentityState
} from './foreground-identity-refresh'
const FOREGROUND_AGENT_CACHE_TTL_MS = 1000
const SHELL_FOREGROUND_REFRESH_RETRY_MS = 5_000
const WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS = 15_000
const SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS = 10_000
const STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS = 5_000
type CachedAgentForeground = { processName: string; pid: number | null; refreshedAt: number }
export type PtyForegroundProcessTracker = {
recordOutput(data: string): void
markDead(): void
getForegroundProcess(): string | null
observeForegroundProcess(): ForegroundProcessObservation
confirmForegroundProcess(): Promise<string | null>
confirmShellForeground(): Promise<boolean>
}
@@ -50,214 +44,121 @@ export function createPtyForegroundProcessTracker(args: {
isDead: () => boolean
}): PtyForegroundProcessTracker {
const proc = args.process
let lastOutputAt = 0
// `pid` anchors the identity to the row that proved it (null when ambiguous).
let cachedAgentForeground: CachedAgentForeground | null = null
const contextPaths = getAgentForegroundContextPaths({
cwd: args.cwd,
worktreeId: parsePtySessionId(args.sessionId).worktreeId
})
let startupAgentForeground: { processName: string; expiresAt: number } | null =
args.startupAgentRecognition
const state: ForegroundIdentityState = {
cachedAgentForeground: null,
startupAgentForeground: args.startupAgentRecognition
? {
processName: args.startupAgentRecognition.processName,
expiresAt: Date.now() + STARTUP_AGENT_FOREGROUND_BOOTSTRAP_MS
}
: null
let foregroundRefreshInFlight = false
let lastForegroundRefreshStartedAt = 0
: null,
lastScanSettlement: null,
refreshInFlight: false,
lastRefreshStartedAt: 0,
lastOutputAt: 0
}
const getFallbackProcess = (): string | null =>
resolveFallbackForegroundProcess(proc.process, args.shellPath)
const getActiveStartupAgent = (
now = Date.now()
): { processName: string; expiresAt: number } | null => {
if (!startupAgentForeground) {
return null
}
if (now > startupAgentForeground.expiresAt) {
startupAgentForeground = null
return null
}
return startupAgentForeground
}
const shouldInspectFallback = (fallbackProcess: string | null): boolean =>
fallbackProcess !== null &&
(isShellProcess(fallbackProcess) ||
isAgentForegroundWrapperProcess(fallbackProcess) ||
shouldInspectOuterWrapperForegroundName(fallbackProcess) ||
process.platform !== 'win32')
const scheduleRefresh = createForegroundIdentityRefresh({
process: proc,
state,
contextPaths,
isDead: args.isDead,
getFallbackProcess,
shouldInspectFallback
})
const scheduleRefresh = (fallbackProcess: string | null): void => {
if (args.isDead() || !proc.pid) {
return
const observed = (processName: string | null): ForegroundProcessObservation => ({
processName,
evidence: { verdict: 'observed', processName }
})
const unverifiable = (
processName: string | null,
reason: string
): ForegroundProcessObservation => ({
processName,
evidence: { verdict: 'unverifiable', reason }
})
const observeForegroundProcess = (): ForegroundProcessObservation => {
if (args.isDead()) {
// The pty's own exit marked death: a host observation, not a failed read.
return observed(null)
}
const fallbackIsShell = fallbackProcess !== null && isShellProcess(fallbackProcess)
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
if (
!fallbackProcess ||
(fallbackRecognition !== null &&
!shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)) ||
!shouldInspectFallback(fallbackProcess)
) {
return
}
const now = Date.now()
const idleNoEvidenceShell =
fallbackIsShell && !getActiveStartupAgent(now) && !cachedAgentForeground
const retryMs = !idleNoEvidenceShell
? FOREGROUND_AGENT_CACHE_TTL_MS
: process.platform === 'win32' && now - lastOutputAt > SHELL_FOREGROUND_OUTPUT_HOT_WINDOW_MS
? WINDOWS_IDLE_SHELL_FOREGROUND_REFRESH_RETRY_MS
: SHELL_FOREGROUND_REFRESH_RETRY_MS
if (foregroundRefreshInFlight || now - lastForegroundRefreshStartedAt < retryMs) {
return
}
foregroundRefreshInFlight = true
lastForegroundRefreshStartedAt = now
const identityOlderThan = (ms: number): boolean =>
cachedAgentForeground !== null && Date.now() - cachedAgentForeground.refreshedAt > ms
const retireStaleForegroundIdentity = ({ onlyWhenAged = false } = {}): void => {
const currentFallbackProcess = getFallbackProcess()
if (
fallbackIsShell &&
!getActiveStartupAgent() &&
currentFallbackProcess !== null &&
isShellProcess(currentFallbackProcess) &&
(!onlyWhenAged || identityOlderThan(WINDOWS_DETACHED_DESCENDANT_IDENTITY_MAX_AGE_MS))
) {
cachedAgentForeground = null
startupAgentForeground = null
} else if (
identityOlderThan(FOREGROUND_AGENT_CACHE_TTL_MS) &&
currentFallbackProcess !== null &&
isAgentForegroundWrapperProcess(currentFallbackProcess)
) {
cachedAgentForeground = null
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
const inspectOuterWrapper =
fallbackRecognition !== null &&
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
state.cachedAgentForeground = {
processName: fallbackProcess,
pid: null,
refreshedAt: Date.now()
}
state.startupAgentForeground = null
return observed(fallbackProcess)
}
scheduleRefresh(fallbackProcess)
const now = Date.now()
if (
state.cachedAgentForeground &&
now - state.cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return observed(state.cachedAgentForeground.processName)
}
if (
state.cachedAgentForeground &&
fallbackProcess !== null &&
(isAgentForegroundWrapperProcess(fallbackProcess) ||
inspectOuterWrapper ||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
) {
return observed(state.cachedAgentForeground.processName)
}
const activeStartupAgentForeground = getActiveStartupAgent(state, now)
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
return observed(activeStartupAgentForeground.processName)
}
if (fallbackProcess === null) {
// A successful read that named nothing usable observed nothing.
return unverifiable(null, 'pty reported no usable foreground title')
}
if (
isShellProcess(fallbackProcess) &&
!isShellTitleCorroborated(state.lastScanSettlement, now)
) {
return unverifiable(fallbackProcess, 'shell title without a corroborating foreground scan')
}
return observed(fallbackProcess)
} catch {
return unverifiable(null, 'foreground title read threw')
}
const anchor = cachedAgentForeground
void resolveAgentForegroundProcessWithAvailability(proc.pid, fallbackProcess, {
contextPaths,
...(anchor?.pid != null
? { anchorProcessId: anchor.pid, anchorProcessName: anchor.processName }
: {})
})
.then<string | void>(({ processName, processId, available, anchorPidForeign }) => {
if (args.isDead() || !available) {
return
}
if (!processName || !recognizeAgentProcess(processName)) {
if (process.platform === 'win32' && fallbackIsShell && cachedAgentForeground !== null) {
// Job, not console: needs no console attachment, so no fork (#10857).
const verdict = judgeCachedAgentJobEvidence({
jobProcessIds: readWindowsPtyJobProcessIds(proc),
jobSupported: isWindowsPtyJobReadable(),
shellPid: proc.pid,
anchorProcessId: cachedAgentForeground.pid,
identityAgeMs: Date.now() - cachedAgentForeground.refreshedAt
})
// Unverifiable is never exit proof (ssh-execution-boundary.md): hold.
if (verdict === 'unavailable') {
return
}
if (verdict === 'unsupported') {
// No job to consult on this build, and the scan that got here was
// available and found no agent. Trust it, as every other platform
// does, rather than holding a dead name forever (#16059).
retireStaleForegroundIdentity()
return
}
if (verdict === 'confirmed' || verdict === 'recheck') {
if (anchorPidForeign === true) {
// The scan proved the pid recycled to a non-agent: retire now.
retireStaleForegroundIdentity()
return
}
// The anchor pid is still in the job: the scan lost the row, not
// the agent. Restamp so a live agent never ages out (#9258).
cachedAgentForeground = { ...cachedAgentForeground, refreshedAt: Date.now() }
return
}
if (verdict === 'exited' || verdict === 'anchor-exited') {
// Safe mid-restart: an available scan already found no agent.
retireStaleForegroundIdentity()
return
}
// Unanchored superset evidence cannot tell a working agent from a
// leftover; the age bound settles it.
retireStaleForegroundIdentity({ onlyWhenAged: true })
return
}
retireStaleForegroundIdentity()
return
}
cachedAgentForeground = { processName, pid: processId ?? null, refreshedAt: Date.now() }
startupAgentForeground = null
return processName
})
.catch(() => {
// Best-effort only: foreground enrichment must never affect PTY health.
})
.finally(() => {
foregroundRefreshInFlight = false
})
}
return {
recordOutput: (data) => {
if (data.length > 0) {
lastOutputAt = Date.now()
state.lastOutputAt = Date.now()
}
},
markDead: () => {
cachedAgentForeground = null
startupAgentForeground = null
},
getForegroundProcess: () => {
if (args.isDead()) {
return null
}
try {
const fallbackProcess = getFallbackProcess()
const fallbackRecognition = recognizeAgentProcess(fallbackProcess)
const inspectOuterWrapper =
fallbackRecognition !== null &&
shouldInspectOuterWrapperForegroundProcess(fallbackRecognition)
if (fallbackProcess && fallbackRecognition && !inspectOuterWrapper) {
cachedAgentForeground = {
processName: fallbackProcess,
pid: null,
refreshedAt: Date.now()
}
startupAgentForeground = null
return fallbackProcess
}
scheduleRefresh(fallbackProcess)
const now = Date.now()
if (
cachedAgentForeground &&
now - cachedAgentForeground.refreshedAt <= FOREGROUND_AGENT_CACHE_TTL_MS
) {
return cachedAgentForeground.processName
}
if (
cachedAgentForeground &&
fallbackProcess !== null &&
(isAgentForegroundWrapperProcess(fallbackProcess) ||
inspectOuterWrapper ||
(process.platform === 'win32' && isShellProcess(fallbackProcess)))
) {
return cachedAgentForeground.processName
}
const activeStartupAgentForeground = getActiveStartupAgent(now)
if (fallbackProcess && isShellProcess(fallbackProcess) && activeStartupAgentForeground) {
return activeStartupAgentForeground.processName
}
return fallbackProcess
} catch {
return null
}
state.cachedAgentForeground = null
state.startupAgentForeground = null
},
getForegroundProcess: () => observeForegroundProcess().processName,
observeForegroundProcess,
confirmForegroundProcess: async () => {
if (args.isDead() || !proc.pid) {
return null
@@ -294,16 +195,16 @@ export function createPtyForegroundProcessTracker(args: {
}
const recognized = recognizeAgentProcess(resolution.processName)
if (recognized) {
cachedAgentForeground = {
state.cachedAgentForeground = {
processName: recognized.processName,
pid: resolution.processId ?? null,
refreshedAt: Date.now()
}
startupAgentForeground = null
state.startupAgentForeground = null
return recognized.processName
}
cachedAgentForeground = null
startupAgentForeground = null
state.cachedAgentForeground = null
state.startupAgentForeground = null
return resolution.processName
} catch {
return null
@@ -70,6 +70,7 @@ export function createDaemonPtySubprocessHandle(args: {
? { startupCommandDeliveredInShellArgs: true }
: {}),
getForegroundProcess: foreground.getForegroundProcess,
observeForegroundProcess: foreground.observeForegroundProcess,
confirmForegroundProcess: foreground.confirmForegroundProcess,
confirmShellForeground: foreground.confirmShellForeground,
write: (data) => {
@@ -12,6 +12,10 @@ function createMockSubprocess(): SubprocessHandle & {
return {
pid: 42,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
@@ -50,6 +50,10 @@ function createFakeSubprocess(foregroundProcess: string) {
forceKilled: false,
pid: 4242,
getForegroundProcess: () => foregroundProcess,
observeForegroundProcess: () => ({
processName: foregroundProcess,
evidence: { verdict: 'observed' as const, processName: foregroundProcess }
}),
write: (data: string) => void written.push(data),
resize: () => {},
kill: () => {},
@@ -66,6 +66,10 @@ function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } {
return {
pid: 4242,
getForegroundProcess: () => 'bench',
observeForegroundProcess: () => ({
processName: 'bench',
evidence: { verdict: 'observed' as const, processName: 'bench' }
}),
write: () => {},
resize: () => {},
kill: () => {},
@@ -10,6 +10,10 @@ function createMockSubprocess() {
return {
pid: 12345,
getForegroundProcess: (): string | null => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write(_data: string) {},
resize(_cols: number, _rows: number) {},
kill() {},
@@ -1,12 +1,26 @@
import type { TerminalExitCause } from '../../shared/terminal-exit-cause'
import type { PtyForegroundProcessEvidence } from '../../shared/pty-process-inspection-evidence'
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
/** A foreground read plus the evidence verdict behind it. `processName` keeps
* the exact legacy collapse every existing caller sees; the evidence says
* whether anything actually observed the pane, so a degraded read is never
* exit evidence (docs/reference/ssh-execution-boundary.md). */
export type ForegroundProcessObservation = {
processName: string | null
evidence: PtyForegroundProcessEvidence
}
export type SubprocessHandle = {
pid: number
/** Live foreground process name of the PTY (node-pty's `.process`), e.g.
* 'claude' / 'codex' / 'zsh'. Null once the child has exited. */
getForegroundProcess(): string | null
/** getForegroundProcess plus the evidence verdict behind the read. Required,
* not optional: an omitting handle would silently recreate the coercion of
* degraded reads into exit evidence. */
observeForegroundProcess(): ForegroundProcessObservation
/** Await process-table evidence captured after this confirmation request. */
confirmForegroundProcess?(): Promise<string | null>
/** Proves a fresh post-boundary PTY process tree contains only the shell. */
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session } from './session'
import type { ForegroundProcessObservation } from './session-subprocess-handle'
import { PRODUCER_PAUSE_FAILSAFE_MS } from './session-producer-pause'
function createRecordingSubprocess() {
@@ -30,6 +31,10 @@ function createRecordingSubprocess() {
getForegroundProcess(): string | null {
return this.foregroundProcess
},
observeForegroundProcess(): ForegroundProcessObservation {
const processName = this.foregroundProcess
return { processName, evidence: { verdict: 'observed', processName } }
},
write(data: string) {
written.push(data)
},
+5
View File
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Session } from './session'
import type { ForegroundProcessObservation } from './session-subprocess-handle'
import { SESSION_FORCE_KILL_RETRY_MS } from './session-termination-controller'
import { HeadlessEmulator } from './headless-emulator'
import type { SessionState, ShellReadyState } from './types'
@@ -41,6 +42,10 @@ function createMockSubprocess() {
getForegroundProcess(): string | null {
return this.foregroundProcess
},
observeForegroundProcess(): ForegroundProcessObservation {
const processName = this.foregroundProcess
return { processName, evidence: { verdict: 'observed', processName } }
},
confirmShellForeground: vi.fn(async () => true),
write(data: string) {
written.push(data)
+3 -3
View File
@@ -8,7 +8,7 @@ import {
SessionTerminationController,
IMMEDIATE_KILL_PHYSICAL_EXIT_TIMEOUT_MS
} from './session-termination-controller'
import type { SubprocessHandle } from './session-subprocess-handle'
import type { ForegroundProcessObservation, SubprocessHandle } from './session-subprocess-handle'
import type { JobTerminationOutcome } from '../windows/windows-pty-job'
import type { SessionOptions } from './session-options'
import type { TuiAgent } from '../../shared/tui-agent'
@@ -252,8 +252,8 @@ export class Session {
return this.output.getCwd()
}
getForegroundProcess(): string | null {
return this.subprocess.getForegroundProcess()
observeForegroundProcess(): ForegroundProcessObservation {
return this.subprocess.observeForegroundProcess()
}
async confirmForegroundProcess(): Promise<string | null> {
@@ -25,6 +25,10 @@ function createMockSubprocess(): SubprocessHandle {
return {
pid: 55555,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)),
@@ -11,6 +11,10 @@ function createClaimedSubprocess(): SubprocessHandle & {
return {
pid: 99_999,
getForegroundProcess: () => 'codex',
observeForegroundProcess: () => ({
processName: 'codex',
evidence: { verdict: 'observed' as const, processName: 'codex' }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
@@ -14,6 +14,10 @@ describe('TerminalHost attach-only sessions', () => {
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
@@ -17,6 +17,10 @@ function mockSubprocess(
return {
pid: 4242,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
...(options.confirmShellForeground
? { confirmShellForeground: options.confirmShellForeground }
: {}),
@@ -0,0 +1,42 @@
import { isShellProcess } from '../../shared/agent-detection'
import type { PtyChildProcessesEvidence } from '../../shared/pty-process-inspection-evidence'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import type { ForegroundProcessObservation } from './session-subprocess-handle'
/**
* Wire result for the daemon 'inspectProcess' request. The legacy fields keep
* the exact pre-evidence collapse (a degraded read still publishes the shell
* title it fell back to); only the optional `processEvidence` distinguishes
* "observed idle" from "could not ask", so a degraded read is never exit
* evidence (docs/reference/ssh-execution-boundary.md). The field is additive:
* older app clients ignore it and see byte-identical content.
*/
export function buildDaemonInspectProcessResult(
observation: ForegroundProcessObservation
): PtyProcessInspection {
const foregroundProcess = observation.processName
return {
foregroundProcess,
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess),
processEvidence: {
foreground: observation.evidence,
children: deriveChildProcessesEvidence(observation)
}
}
}
/** A daemon pane's only child signal IS the foreground observation, so the
* children verdict can never outrank the foreground's. Observed null means
* the host itself watched the pane die — positive absence, not a failure. */
function deriveChildProcessesEvidence(
observation: ForegroundProcessObservation
): PtyChildProcessesEvidence {
if (observation.evidence.verdict !== 'observed') {
return { verdict: 'unverifiable', reason: observation.evidence.reason }
}
const processName = observation.evidence.processName
if (processName !== null && !isShellProcess(processName)) {
return { verdict: 'live' }
}
return { verdict: 'exited' }
}
@@ -18,6 +18,10 @@ function createSubprocess(shellPath: string): TestSubprocess {
pid: 99_999,
shellPath,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
@@ -15,6 +15,10 @@ function createSubprocess(): SubprocessHandle & { exit: () => void } {
pid: 4242,
shellPath: '/opt/homebrew/bin/zsh',
getForegroundProcess: () => 'zsh',
observeForegroundProcess: () => ({
processName: 'zsh',
evidence: { verdict: 'observed' as const, processName: 'zsh' }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
@@ -28,6 +28,10 @@ function createMockSubprocess(): SubprocessHandle & {
return {
pid: 99999,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
@@ -6,6 +6,10 @@ function mockSubprocess(): SubprocessHandle {
return {
pid: 1,
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(),
@@ -15,6 +15,10 @@ function createSubprocess(): SubprocessHandle {
return {
pid: 123,
getForegroundProcess: () => null,
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => onExit?.(0)),
+4
View File
@@ -25,6 +25,10 @@ function createMockSubprocess(
: {}),
...(options.shellPath ? { shellPath: options.shellPath } : {}),
getForegroundProcess: vi.fn(() => null),
observeForegroundProcess: () => ({
processName: null,
evidence: { verdict: 'observed' as const, processName: null }
}),
write: vi.fn(),
resize: vi.fn(),
kill: vi.fn(() => {
+7 -11
View File
@@ -19,7 +19,8 @@ import { resolveTerminalHostSessionCwd } from './terminal-host-session-cwd'
import { TerminalHostTombstones } from './terminal-host-tombstones'
import { listLiveTerminalHostSessions } from './terminal-host-session-listing'
import { createOrAttachTerminalSession } from './terminal-host-session-create'
import { isShellProcess } from '../../shared/agent-detection'
import { buildDaemonInspectProcessResult } from './terminal-host-process-evidence'
import type { PtyProcessInspection } from '../providers/pty-process-inspection'
import { TerminalAttachCanceledError } from './daemon-errors'
export type { CreateOrAttachOptions, CreateOrAttachResult } from './terminal-host-create-contract'
@@ -211,18 +212,13 @@ export class TerminalHost {
if (!session || !session.isAlive) {
return null
}
return session.getForegroundProcess()
return session.observeForegroundProcess().processName
}
inspectProcess(sessionId: string): {
foregroundProcess: string | null
hasChildProcesses: boolean
} {
const foregroundProcess = this.getAliveSession(sessionId).getForegroundProcess()
return {
foregroundProcess,
hasChildProcesses: foregroundProcess !== null && !isShellProcess(foregroundProcess)
}
inspectProcess(sessionId: string): PtyProcessInspection {
return buildDaemonInspectProcessResult(
this.getAliveSession(sessionId).observeForegroundProcess()
)
}
async confirmForegroundProcess(sessionId: string): Promise<string | null> {
@@ -33,6 +33,10 @@ function createControlledSubprocess(): ControlledSubprocess {
return {
pid: 41_000,
getForegroundProcess: () => (exited ? null : 'claude'),
observeForegroundProcess: () => ({
processName: exited ? null : 'claude',
evidence: { verdict: 'observed' as const, processName: exited ? null : 'claude' }
}),
write: vi.fn(),
resize: vi.fn(),
kill: () => exit(0),
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { rmSync } from 'node:fs'
import type * as pty from 'node-pty'
const { execFileMock, execFileSyncMock, killWithDescendantSweepMock, forceKillPosixMock } =
vi.hoisted(() => ({
execFileMock: vi.fn(),
execFileSyncMock: vi.fn(),
killWithDescendantSweepMock: vi.fn(),
forceKillPosixMock: vi.fn()
}))
// The identity scan's `ps -axo` fork; scripted to fail so no scan can settle.
vi.mock('child_process', () => ({
execFile: execFileMock,
execFileSync: execFileSyncMock
}))
vi.mock('../../../src/main/pty-descendant-termination', () => ({
killWithDescendantSweep: killWithDescendantSweepMock
}))
// Teardown escalation must never signal a real process group from a test.
vi.mock('../../../src/main/pty/posix-pty-process-groups', () => ({
forceKillPosixPtyProcessGroups: forceKillPosixMock
}))
import { createDaemonPtySubprocessHandle } from '../../../src/main/daemon/pty-subprocess/subprocess-handle'
import {
startDaemonAdapterHarness,
type DaemonAdapterHarness
} from '../../../src/main/daemon/daemon-pty-adapter-test-harness'
import { inspectPtyProviderProcessForRenderer } from '../../../src/main/providers/pty-process-inspection'
/**
* The daemon inspection evidence must survive the REAL wire: DaemonServer →
* request router → JSON socket → DaemonPtyAdapter. The sibling suite proves
* the monitor consequences in-process; this one proves the `processEvidence`
* field actually crosses the daemon protocol, beside byte-identical legacy
* fields, on the exact socket a macOS daemon pane uses.
*/
describe('daemon inspection evidence over the daemon socket', () => {
const SHELL_PID = 999_999_311
const AGENT_SESSION_ID = 'repo-daemon-wire::/tmp/wt@@wire0001'
const SHELL_SESSION_ID = 'repo-daemon-wire::/tmp/wt@@wire0002'
type MockNodePty = pty.IPty & { process: string; _fireExit: (exitCode: number) => void }
function createMockNodePtyProcess(): MockNodePty {
const exitListeners: ((e: { exitCode: number; signal?: number }) => void)[] = []
const proc = {
pid: SHELL_PID,
cols: 80,
rows: 24,
process: 'codex',
handleFlowControl: false,
onData: vi.fn(() => ({ dispose: vi.fn() })),
onExit: vi.fn((cb: (e: { exitCode: number; signal?: number }) => void) => {
exitListeners.push(cb)
return { dispose: vi.fn() }
}),
write: vi.fn(),
resize: vi.fn(),
clear: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
kill: vi.fn(() => {
setTimeout(() => proc._fireExit(0), 5)
}),
_fireExit: (exitCode: number) => {
exitListeners.forEach((cb) => cb({ exitCode, signal: 0 }))
}
}
return proc as unknown as MockNodePty
}
let harness: DaemonAdapterHarness
let procsBySession: Map<string, MockNodePty>
beforeEach(async () => {
execFileMock.mockReset()
execFileMock.mockImplementation(
(_command: string, _args: string[], _opts: unknown, cb: unknown) => {
;(cb as (err: Error | null, result: { stdout: string; stderr: string }) => void)(
new Error('ps unavailable in wire test'),
{ stdout: '', stderr: '' }
)
}
)
killWithDescendantSweepMock.mockReset()
killWithDescendantSweepMock.mockResolvedValue(undefined)
forceKillPosixMock.mockReset()
procsBySession = new Map()
forceKillPosixMock.mockImplementation(() => {
setTimeout(() => procsBySession.forEach((proc) => proc._fireExit(137)), 1)
})
harness = await startDaemonAdapterHarness((opts) => {
const proc = createMockNodePtyProcess()
proc.process = opts.sessionId === AGENT_SESSION_ID ? 'codex' : 'zsh'
procsBySession.set(opts.sessionId, proc)
return createDaemonPtySubprocessHandle({
process: proc,
shellPath: '/bin/zsh',
spawnCwd: '/tmp/wt',
env: { PATH: '/usr/bin' },
startupCommandDeliveredInShellArgs: false,
reportsChildExitStatus: true,
requestedCwd: '/tmp/wt',
sessionId: opts.sessionId,
startupAgentRecognition: null
})
})
})
afterEach(async () => {
harness.adapter?.dispose()
await harness.server?.shutdown()
rmSync(harness.dir, { recursive: true, force: true })
vi.restoreAllMocks()
})
it('carries observed and unverifiable evidence across the socket, legacy fields intact', async () => {
const agentPane = await harness.adapter.spawn({
sessionId: AGENT_SESSION_ID,
cols: 80,
rows: 24
})
const shellPane = await harness.adapter.spawn({
sessionId: SHELL_SESSION_ID,
cols: 80,
rows: 24
})
// A recognized agent title is a direct observation — no scan involved.
expect(await inspectPtyProviderProcessForRenderer(harness.adapter, agentPane.id)).toEqual({
foregroundProcess: 'codex',
hasChildProcesses: true,
processEvidence: {
foreground: { verdict: 'observed', processName: 'codex' },
children: { verdict: 'live' }
}
})
// A shell-shaped title with no settled scan: node-pty's silent shell
// fallback makes it indistinguishable from a degraded read. The legacy
// fields keep the exact pre-evidence collapse the monitor used to read as
// exit proof; the evidence now says "could not ask".
expect(await inspectPtyProviderProcessForRenderer(harness.adapter, shellPane.id)).toEqual({
foregroundProcess: 'zsh',
hasChildProcesses: false,
processEvidence: {
foreground: { verdict: 'unverifiable', reason: expect.any(String) },
children: { verdict: 'unverifiable', reason: expect.any(String) }
}
})
})
})