diff --git a/src/main/daemon/daemon-endpoint-ownership.test.ts b/src/main/daemon/daemon-endpoint-ownership.test.ts index cf6af2e0956..e9cf11a6879 100644 --- a/src/main/daemon/daemon-endpoint-ownership.test.ts +++ b/src/main/daemon/daemon-endpoint-ownership.test.ts @@ -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() {}, diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 7d0d64271fa..aa4b56a6ceb 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -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() {}, diff --git a/src/main/daemon/daemon-idle-shutdown.test.ts b/src/main/daemon/daemon-idle-shutdown.test.ts index 05f4b3aa39f..3892d89fcc6 100644 --- a/src/main/daemon/daemon-idle-shutdown.test.ts +++ b/src/main/daemon/daemon-idle-shutdown.test.ts @@ -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(), diff --git a/src/main/daemon/daemon-main.test.ts b/src/main/daemon/daemon-main.test.ts index ea3884adb14..43948e4398e 100644 --- a/src/main/daemon/daemon-main.test.ts +++ b/src/main/daemon/daemon-main.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-preflight-client-replacement.test.ts b/src/main/daemon/daemon-preflight-client-replacement.test.ts index 7fbb46ebfd5..170329e4c8d 100644 --- a/src/main/daemon/daemon-preflight-client-replacement.test.ts +++ b/src/main/daemon/daemon-preflight-client-replacement.test.ts @@ -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(), diff --git a/src/main/daemon/daemon-pty-adapter-test-harness.ts b/src/main/daemon/daemon-pty-adapter-test-harness.ts index f7676bbec2f..5e93fd422c7 100644 --- a/src/main/daemon/daemon-pty-adapter-test-harness.ts +++ b/src/main/daemon/daemon-pty-adapter-test-harness.ts @@ -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(), diff --git a/src/main/daemon/daemon-pty-process-inspection.ts b/src/main/daemon/daemon-pty-process-inspection.ts index bbc44568501..00f3757153e 100644 --- a/src/main/daemon/daemon-pty-process-inspection.ts +++ b/src/main/daemon/daemon-pty-process-inspection.ts @@ -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 }) } diff --git a/src/main/daemon/daemon-pty-router-history-handoff.test.ts b/src/main/daemon/daemon-pty-router-history-handoff.test.ts index e57375467ef..315102902fe 100644 --- a/src/main/daemon/daemon-pty-router-history-handoff.test.ts +++ b/src/main/daemon/daemon-pty-router-history-handoff.test.ts @@ -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(), diff --git a/src/main/daemon/daemon-pty-upgrade-adoption.test.ts b/src/main/daemon/daemon-pty-upgrade-adoption.test.ts index 99c2a792e1e..a2cb70edd11 100644 --- a/src/main/daemon/daemon-pty-upgrade-adoption.test.ts +++ b/src/main/daemon/daemon-pty-upgrade-adoption.test.ts @@ -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(), diff --git a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts index b2c0bf59cbc..fcddfe90568 100644 --- a/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts +++ b/src/main/daemon/daemon-reattach-checkpoint-isolation.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-restore-scrollback-depth.test.ts b/src/main/daemon/daemon-restore-scrollback-depth.test.ts index b2c2c7c5028..ee43785fac8 100644 --- a/src/main/daemon/daemon-restore-scrollback-depth.test.ts +++ b/src/main/daemon/daemon-restore-scrollback-depth.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-self-retirement-respawn.test.ts b/src/main/daemon/daemon-self-retirement-respawn.test.ts index 7d70d02e70a..4259abdec43 100644 --- a/src/main/daemon/daemon-self-retirement-respawn.test.ts +++ b/src/main/daemon/daemon-self-retirement-respawn.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-server-attach-only.test.ts b/src/main/daemon/daemon-server-attach-only.test.ts index 60e9ee83495..4f2560c209e 100644 --- a/src/main/daemon/daemon-server-attach-only.test.ts +++ b/src/main/daemon/daemon-server-attach-only.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts index 2f4d1b734ac..69f8205e2ef 100644 --- a/src/main/daemon/daemon-server-attachment-lifecycle.test.ts +++ b/src/main/daemon/daemon-server-attachment-lifecycle.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-server-error-handling.test.ts b/src/main/daemon/daemon-server-error-handling.test.ts index 7ab9d1ffa95..766407e8b58 100644 --- a/src/main/daemon/daemon-server-error-handling.test.ts +++ b/src/main/daemon/daemon-server-error-handling.test.ts @@ -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, diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index 27727aa2b3d..a3ac6024b6d 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -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(), diff --git a/src/main/daemon/daemon-session-scrollback-window.test.ts b/src/main/daemon/daemon-session-scrollback-window.test.ts index b2dfb5118c9..05654ffa4b1 100644 --- a/src/main/daemon/daemon-session-scrollback-window.test.ts +++ b/src/main/daemon/daemon-session-scrollback-window.test.ts @@ -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(() => { diff --git a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts index 64ae0f77ba0..5339151bb3f 100644 --- a/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts +++ b/src/main/daemon/daemon-shutdown-final-checkpoint-caller-deadline.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-spawner.test.ts b/src/main/daemon/daemon-spawner.test.ts index a2ad26130ff..f04a2140016 100644 --- a/src/main/daemon/daemon-spawner.test.ts +++ b/src/main/daemon/daemon-spawner.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts index fda1fe25b98..a7d19d59150 100644 --- a/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts +++ b/src/main/daemon/daemon-stream-droppability-lifecycle.test.ts @@ -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)), diff --git a/src/main/daemon/daemon-transport-attachment-release.test.ts b/src/main/daemon/daemon-transport-attachment-release.test.ts index 7b7c5e4fee6..a83766864a7 100644 --- a/src/main/daemon/daemon-transport-attachment-release.test.ts +++ b/src/main/daemon/daemon-transport-attachment-release.test.ts @@ -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: () => { diff --git a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts index 35b84245a83..a84b76b1ab3 100644 --- a/src/main/daemon/issue-6814-daemon-failure-classification.test.ts +++ b/src/main/daemon/issue-6814-daemon-failure-classification.test.ts @@ -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() {}, diff --git a/src/main/daemon/pty-subprocess/foreground-identity-refresh.ts b/src/main/daemon/pty-subprocess/foreground-identity-refresh.ts new file mode 100644 index 00000000000..daa93c372d9 --- /dev/null +++ b/src/main/daemon/pty-subprocess/foreground-identity-refresh.ts @@ -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(({ 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 + }) + } +} diff --git a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts index 115b795202d..c47340e5ae4 100644 --- a/src/main/daemon/pty-subprocess/foreground-process-tracker.ts +++ b/src/main/daemon/pty-subprocess/foreground-process-tracker.ts @@ -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 confirmShellForeground(): Promise } @@ -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(({ 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 diff --git a/src/main/daemon/pty-subprocess/subprocess-handle.ts b/src/main/daemon/pty-subprocess/subprocess-handle.ts index e2abd59c6ea..fdbf438ab79 100644 --- a/src/main/daemon/pty-subprocess/subprocess-handle.ts +++ b/src/main/daemon/pty-subprocess/subprocess-handle.ts @@ -70,6 +70,7 @@ export function createDaemonPtySubprocessHandle(args: { ? { startupCommandDeliveredInShellArgs: true } : {}), getForegroundProcess: foreground.getForegroundProcess, + observeForegroundProcess: foreground.observeForegroundProcess, confirmForegroundProcess: foreground.confirmForegroundProcess, confirmShellForeground: foreground.confirmShellForeground, write: (data) => { diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index 9bbf7a103d6..4ce026cbbb2 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -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)), diff --git a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts index f58536f36b8..8532fdafdf1 100644 --- a/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts +++ b/src/main/daemon/repro-12101-mouse-tracking-survives-agent-death.test.ts @@ -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: () => {}, diff --git a/src/main/daemon/session-ingest-throughput.bench.test.ts b/src/main/daemon/session-ingest-throughput.bench.test.ts index c10d86661ca..468da3ce949 100644 --- a/src/main/daemon/session-ingest-throughput.bench.test.ts +++ b/src/main/daemon/session-ingest-throughput.bench.test.ts @@ -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: () => {}, diff --git a/src/main/daemon/session-pending-output.test.ts b/src/main/daemon/session-pending-output.test.ts index 2c95ce248bb..e24958aef14 100644 --- a/src/main/daemon/session-pending-output.test.ts +++ b/src/main/daemon/session-pending-output.test.ts @@ -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() {}, diff --git a/src/main/daemon/session-subprocess-handle.ts b/src/main/daemon/session-subprocess-handle.ts index f14469afbb4..50866f83fa6 100644 --- a/src/main/daemon/session-subprocess-handle.ts +++ b/src/main/daemon/session-subprocess-handle.ts @@ -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 /** Proves a fresh post-boundary PTY process tree contains only the shell. */ diff --git a/src/main/daemon/session-terminal-control.test.ts b/src/main/daemon/session-terminal-control.test.ts index 0677ab45bba..2b77f47a25d 100644 --- a/src/main/daemon/session-terminal-control.test.ts +++ b/src/main/daemon/session-terminal-control.test.ts @@ -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) }, diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 191b8ee7eaa..04945e1cc74 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -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) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index b0f1dfa538d..77a8757670a 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -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 { diff --git a/src/main/daemon/slow-daemon-session-verification.test.ts b/src/main/daemon/slow-daemon-session-verification.test.ts index 082f1949a5f..8aba0f12579 100644 --- a/src/main/daemon/slow-daemon-session-verification.test.ts +++ b/src/main/daemon/slow-daemon-session-verification.test.ts @@ -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)), diff --git a/src/main/daemon/terminal-host-agent-session.test.ts b/src/main/daemon/terminal-host-agent-session.test.ts index 1cf929fb93f..0937f296279 100644 --- a/src/main/daemon/terminal-host-agent-session.test.ts +++ b/src/main/daemon/terminal-host-agent-session.test.ts @@ -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(), diff --git a/src/main/daemon/terminal-host-attach-only.test.ts b/src/main/daemon/terminal-host-attach-only.test.ts index 97284451233..d688d43d476 100644 --- a/src/main/daemon/terminal-host-attach-only.test.ts +++ b/src/main/daemon/terminal-host-attach-only.test.ts @@ -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)), diff --git a/src/main/daemon/terminal-host-concurrent-create.test.ts b/src/main/daemon/terminal-host-concurrent-create.test.ts index aa7b9fda956..49824961ebd 100644 --- a/src/main/daemon/terminal-host-concurrent-create.test.ts +++ b/src/main/daemon/terminal-host-concurrent-create.test.ts @@ -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 } : {}), diff --git a/src/main/daemon/terminal-host-process-evidence.ts b/src/main/daemon/terminal-host-process-evidence.ts new file mode 100644 index 00000000000..990d82b1015 --- /dev/null +++ b/src/main/daemon/terminal-host-process-evidence.ts @@ -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' } +} diff --git a/src/main/daemon/terminal-host-pty-owner-backend.test.ts b/src/main/daemon/terminal-host-pty-owner-backend.test.ts index 3dc14863884..b3276f1ab24 100644 --- a/src/main/daemon/terminal-host-pty-owner-backend.test.ts +++ b/src/main/daemon/terminal-host-pty-owner-backend.test.ts @@ -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)), diff --git a/src/main/daemon/terminal-host-readiness-reporting.test.ts b/src/main/daemon/terminal-host-readiness-reporting.test.ts index 677bcae258a..d556d4df852 100644 --- a/src/main/daemon/terminal-host-readiness-reporting.test.ts +++ b/src/main/daemon/terminal-host-readiness-reporting.test.ts @@ -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(), diff --git a/src/main/daemon/terminal-host-session-reaping-leak.test.ts b/src/main/daemon/terminal-host-session-reaping-leak.test.ts index c937607a33a..0f671926ddd 100644 --- a/src/main/daemon/terminal-host-session-reaping-leak.test.ts +++ b/src/main/daemon/terminal-host-session-reaping-leak.test.ts @@ -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(() => { diff --git a/src/main/daemon/terminal-host-startup.test.ts b/src/main/daemon/terminal-host-startup.test.ts index fafa34eedc0..3cf7c404ce7 100644 --- a/src/main/daemon/terminal-host-startup.test.ts +++ b/src/main/daemon/terminal-host-startup.test.ts @@ -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(), diff --git a/src/main/daemon/terminal-host-wsl-context.test.ts b/src/main/daemon/terminal-host-wsl-context.test.ts index 3958e370684..2b0d56e1658 100644 --- a/src/main/daemon/terminal-host-wsl-context.test.ts +++ b/src/main/daemon/terminal-host-wsl-context.test.ts @@ -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)), diff --git a/src/main/daemon/terminal-host.test.ts b/src/main/daemon/terminal-host.test.ts index 8755005b42e..fdb76dfeee2 100644 --- a/src/main/daemon/terminal-host.test.ts +++ b/src/main/daemon/terminal-host.test.ts @@ -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(() => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index f85980b5453..8fd1ed2f105 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -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 { diff --git a/src/main/runtime/remote-agent-session-host-authority.integration.test.ts b/src/main/runtime/remote-agent-session-host-authority.integration.test.ts index 56d74744238..74835315d40 100644 --- a/src/main/runtime/remote-agent-session-host-authority.integration.test.ts +++ b/src/main/runtime/remote-agent-session-host-authority.integration.test.ts @@ -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), diff --git a/tests/e2e/daemon-completion-evidence/daemon-inspection-evidence-wire.unit.test.ts b/tests/e2e/daemon-completion-evidence/daemon-inspection-evidence-wire.unit.test.ts new file mode 100644 index 00000000000..378efdcebf3 --- /dev/null +++ b/tests/e2e/daemon-completion-evidence/daemon-inspection-evidence-wire.unit.test.ts @@ -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 + + 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) } + } + }) + }) +})