import { runtimeTestFromResult, type RuntimeTestRecord } from "./runtime-test-check.ts"; import { createToolEventRecorder, type ToolExecutionEvent } from "./tool-protocol.ts"; import { readActiveProfile, assertProfileModel, profileFallback, withProfileWork, PROFILE_ENV, type ActiveModelProfile } from './policy/profile-runtime.ts'; import { DISCOVERY_CHILD_ENV_KEYS, openDiscoveryAttempt, type DiscoverySpawnRegistration } from './file-discovery/owners.ts'; import { SYSTEM1_API_KEY_ENV } from "../lib/system1/config.js"; import { confineNativeChild, type WriteIsolationRequest, type WriteIsolationResult } from "./write-isolation.ts"; /** * spawnPiAgent — the ONE place agent-hub code spawns a headless `pi` child and * parses its JSON event stream. Research helpers and read-only delegate children * opt into the per-tool watchdog here, rather than trusting an agent-side timer. */ import { spawn, type ChildProcess } from "child_process"; import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; import { boundOutput } from "./deterministic-fs.ts"; export interface PiUsage { input?: number; output?: number; [k: string]: any; } export interface ToolTimeout { toolCallId: string; toolName: string; args: string; startedAt: number; deadlineAt: number; } export interface Termination { reason: "tool_timeout" | "turn_timeout" | "drift_stop" | "cancelled"; confirmed: boolean; escalated: boolean; tool?: ToolTimeout; } /** External run control handed to the caller via onControl (drift watchdog). */ export interface PiRunControl { /** Terminate the child group with the given classification (default "drift_stop"). */ terminate(reason?: "drift_stop" | "cancelled"): void; } export interface ToolWatchdogOptions { /** Per read-only tool call deadline. null disables the watchdog. */ timeoutMs: number | null; /** Grace after SIGTERM, followed by SIGKILL. Kept injectable for focused tests. */ termGraceMs?: number; /** Bound on final settlement; never wait forever for close or inherited pipes. */ settleGraceMs?: number; /** Only these tool names are watched (research/delegate read-only surface). */ tools?: readonly string[]; } export interface SpawnPiAgentOptions { /** Trusted parent registration; a fresh lease is opened for EVERY physical launch. Never an env override. */ discoveryRegistration?: DiscoverySpawnRegistration; /** Enabled only by native preparation with the runtime observer extension. */ runtimeTestObserver?: boolean; /** Snapshotted at dispatch start; presence prevents a mid-run profile switch changing this launch. */ activeProfileSnapshot?: ActiveModelProfile; /** Native-only whole-process write boundary. Omission preserves the legacy launch. */ writeIsolation?: WriteIsolationRequest; model: string; tools: string; thinking: string; /** Replacement prompt; mutually exclusive with appendSystemPrompt when spawning. */ systemPrompt?: string; /** Prompt appended to Pi's inherited prompt (legacy/default child policy). */ appendSystemPrompt?: string; /** Disable automatic global skill discovery for a minimized child. */ noSkills?: boolean; /** Disable AGENTS.md/CLAUDE.md and other automatic context-file discovery. */ noContextFiles?: boolean; sessionFile: string; resume?: boolean; prompt: string; extensions?: string[]; env?: Record; /** Own a process group. Required for a watchdog/cancellation kill cascade. */ detached?: boolean; cwd?: string; /** Parent tool cancellation; classified separately from a tool timeout. */ signal?: AbortSignal; /** Distinguishes physical launches that share one callback object. Identity is owned by the lifecycle. */ physicalAttempt?: { generation: number }; /** Optional per-physical-spawn fence. Omission preserves every existing launcher. */ attemptLifecycle?: { beforePhysicalSpawn(info: { generation: number }): { signal?: AbortSignal } | void; afterPhysicalSpawn(info: { generation: number }): void; }; toolWatchdog?: ToolWatchdogOptions; /** * Whole-run deadline: one timer from spawn start; on expiry the child group is * terminated with reason "turn_timeout" (same SIGTERM→SIGKILL→settle cascade as * the per-tool watchdog). null/undefined disables it. Unlike the tool watchdog * this bounds the ENTIRE run, thinking and non-watched tools included. */ turnDeadlineMs?: number | null; /** Opt-in T5 transport bound; complete original output is retained under this directory. */ boundedOutputDir?: string; } export interface ModelFallbackNotice { from: string; to: string; reason: string; } export interface SpawnPiAgentCallbacks { onProcess?(proc: ChildProcess): void; onModelFallback?(notice: ModelFallbackNotice): void; /** Receives a terminate handle for parent-side classified stops (drift watchdog). */ onControl?(control: PiRunControl): void; onTextDelta?(delta: string): void; onThinkingDelta?(delta: string): void; onToolStart?(toolName: string, argStr: string, toolCallId?: string): void; /** Result text is the complete textual tool payload; duration is measured in the parent process. */ onToolEnd?(toolName: string, toolCallId?: string, isError?: boolean, resultText?: string, durationMs?: number): void; onUsage?(usage: PiUsage, source: "message_end" | "agent_end"): void; } export interface SpawnPiAgentResult { runtimeTests?: RuntimeTestRecord[]; toolEvents?: ToolExecutionEvent[]; output: string; exitCode: number | null; stderr: string; spawnError?: string; /** Assistant-level provider/model failure reported in Pi's JSON stream. */ assistantError?: string; toolCallsStarted: number; modelUsed: string; modelFallback?: ModelFallbackNotice; /** Present only when parent-side bounded termination was requested. */ termination?: Termination; /** T3 runtime lifecycle facts: launched/close/termination are separate dimensions. * Safety refusal and spawn failure never launched; timeout without close stays uncertain. */ lifecycle: { launched: boolean; closeSeen: boolean }; boundedOutput?: { truncated: boolean; totalBytes: number; handle: string; contentPath: string; sha256: string }; writeIsolation?: Pick; } const DEFAULT_TERM_GRACE_MS = 1_000; const DEFAULT_SETTLE_GRACE_MS = 1_000; const WATCHED_TOOLS = new Set(["read", "grep", "find", "ls"]); /** Sandboxed children must not inherit writable descriptors outside the mount policy. */ export function assertSafeSandboxStdio(stdio: readonly unknown[]): void { if (stdio.length !== 3) throw new Error("write isolation refuses extra inherited file descriptors"); if (stdio.some(value => value !== "pipe")) throw new Error("write isolation requires safe stdio pipes"); } /** Credential names removed from the final native child environment. Parent env is never mutated. */ export const NATIVE_CHILD_STRIPPED_ENV_KEYS = [SYSTEM1_API_KEY_ENV, ...DISCOVERY_CHILD_ENV_KEYS] as const; /** The sandbox helper may echo env. Never publish that echo on the spawn result. */ function isolationWithoutEnv }>(isolation: T): Omit { const published = { ...isolation }; delete published.env; return published; } /** Drop undefined entries so the sandbox helper receives a string env. */ function envRecord(env: NodeJS.ProcessEnv): Record { const record: Record = {}; for (const [key, value] of Object.entries(env)) { if (typeof value === "string") record[key] = value; } return record; } /** Merge parent + overrides, then strip System 1 credentials and inherited D9 assignments. */ export function nativeChildEnv( base: NodeJS.ProcessEnv = process.env, overrides?: Record | NodeJS.ProcessEnv, ): NodeJS.ProcessEnv { const merged: NodeJS.ProcessEnv = { ...base }; if (overrides) { for (const [key, value] of Object.entries(overrides)) { if (value === undefined) delete merged[key]; else merged[key] = value; } } for (const key of Object.keys(merged)) if (key.startsWith('AF_D9_') || (NATIVE_CHILD_STRIPPED_ENV_KEYS as readonly string[]).includes(key)) delete merged[key]; return merged; } const discoveryProcessRevocations = new WeakMap void>(); /** Signal an explicitly owned process group, falling back only for legacy callers. */ export function killPiTree(proc: ChildProcess, signal: NodeJS.Signals = "SIGTERM"): void { discoveryProcessRevocations.get(proc)?.(); const pid = proc.pid; if (pid == null) return; try { process.kill(-pid, signal); } catch { try { proc.kill(signal); } catch {} } } export function spawnPiAgent(opts: SpawnPiAgentOptions, cbs: SpawnPiAgentCallbacks = {}): Promise { return withProfileWork(async () => { try { const active = Object.prototype.hasOwnProperty.call(opts, "activeProfileSnapshot") ? opts.activeProfileSnapshot : (readActiveProfile() ?? readActiveProfile(opts.env)); assertProfileModel(opts.model,active); if(active) opts={...opts,env:{...opts.env,[PROFILE_ENV]:JSON.stringify(active)}}; return await spawnPiAgentUnchecked(opts,cbs); } catch(error) { return {output:'',stderr:String(error),exitCode:1,spawnError:String(error),modelUsed:opts.model,toolCallsStarted:0,lifecycle:{launched:false,closeSeen:false}}; } }); } async function spawnPiAgentUnchecked( opts: SpawnPiAgentOptions, cbs: SpawnPiAgentCallbacks = {}, ): Promise { const promptArg = opts.systemPrompt !== undefined ? ["--system-prompt", opts.systemPrompt] : ["--append-system-prompt", opts.appendSystemPrompt ?? ""]; const args = [ "--mode", "json", "-p", "--no-extensions", ...(opts.noSkills ? ["--no-skills"] : []), ...(opts.noContextFiles ? ["--no-context-files"] : []), ...(opts.extensions || []).flatMap(e => ["-e", e]), "--model", opts.model, "--tools", opts.tools, "--thinking", opts.thinking, ...promptArg, "--session", opts.sessionFile, ]; if (opts.resume) args.push("-c"); const generation = opts.physicalAttempt?.generation ?? 1; let attemptSignal: AbortSignal | undefined; let discoveryAttempt: ReturnType | undefined; let attemptEnded = false; const endAttempt = () => { if (attemptEnded) return; attemptEnded = true; discoveryAttempt?.revoke(); opts.attemptLifecycle?.afterPhysicalSpawn({ generation }); }; try { attemptSignal = opts.attemptLifecycle?.beforePhysicalSpawn({ generation })?.signal; if (!opts.signal?.aborted && !attemptSignal?.aborted && opts.discoveryRegistration) { // Do not let even a parent registration claim tools absent from the physical command. const effectiveTools = opts.tools.split(',').map(t=>t.trim()).filter(t=>opts.discoveryRegistration!.owner.effectiveTools.includes(t)); try { discoveryAttempt = openDiscoveryAttempt({...opts.discoveryRegistration,owner:{...opts.discoveryRegistration.owner,effectiveTools,cwd:opts.cwd??process.cwd()}}); if(opts.discoveryRegistration.extension)args.push('-e',opts.discoveryRegistration.extension); } catch(error) { // D9 availability is advisory; authoritative launch admission still fails closed. if(error instanceof Error&&error.message==='owner_admission_changed')throw error; opts={...opts,prompt:opts.prompt+'\n\nFile discovery physical attempt: unavailable (registration unavailable). Continue ordinary permitted discovery; original task, scope and gates remain authoritative.'}; } } if (opts.signal?.aborted || attemptSignal?.aborted || discoveryAttempt?.signal.aborted) { endAttempt(); return Promise.resolve({ output: "", stderr: "", exitCode: null, modelUsed: opts.model, toolCallsStarted: 0, termination: { reason: "cancelled", confirmed: false, escalated: false }, lifecycle: { launched: false, closeSeen: false }, }); } const childEnv = {...nativeChildEnv(process.env, opts.env), ...discoveryAttempt?.env}; const isolation = opts.writeIsolation ? confineNativeChild({ ...opts.writeIsolation, command: "pi", args, env: envRecord(childEnv) }) : undefined; const publishedIsolation = isolation ? isolationWithoutEnv(isolation) : undefined; if (isolation && !isolation.applied) { endAttempt(); return Promise.resolve({ output: "", stderr: isolation.reason ?? "write isolation unavailable", exitCode: 1, spawnError: isolation.reason ?? "write isolation unavailable; unsandboxed execution refused", modelUsed: opts.model, toolCallsStarted: 0, writeIsolation: publishedIsolation, lifecycle: { launched: false, closeSeen: false }, }); } const launchCommand = isolation?.command ?? "pi"; const launchArgs = isolation?.args ?? args; const watchdog = opts.toolWatchdog; const turnDeadlineMs = opts.turnDeadlineMs ?? null; // A watchdog or deadline must own its group: group signalling remains valid even // after the pi leader exits while an inherited-stdio descendant is still alive. const ownsGroup = opts.detached === true || watchdog !== undefined || opts.signal !== undefined || attemptSignal !== undefined || discoveryAttempt !== undefined || turnDeadlineMs != null || cbs.onControl !== undefined; const watchedTools = new Set(watchdog?.tools ?? WATCHED_TOOLS); const timeoutMs = watchdog?.timeoutMs ?? null; const termGraceMs = watchdog?.termGraceMs ?? DEFAULT_TERM_GRACE_MS; const settleGraceMs = watchdog?.settleGraceMs ?? DEFAULT_SETTLE_GRACE_MS; const textChunks: string[] = []; const stderrChunks: string[] = []; return await new Promise((resolve) => { const stdio: ["pipe", "pipe", "pipe"] = ["pipe", "pipe", "pipe"]; if (isolation) assertSafeSandboxStdio(stdio); const proc = spawn(launchCommand, launchArgs, { stdio, env: childEnv, ...(opts.cwd ? { cwd: opts.cwd } : {}), ...(ownsGroup ? { detached: true } : {}), }); discoveryProcessRevocations.set(proc,()=>discoveryAttempt?.revoke()); const originalKill=proc.kill.bind(proc); proc.kill=(signal)=>{if(signal!==0)discoveryAttempt?.revoke();return originalKill(signal);}; cbs.onProcess?.(proc); proc.stdin?.on("error", () => {}); proc.stdin?.end(opts.prompt); let buffer = ""; let settled = false; let closeSeen = false; let termination: Termination | undefined; let termTimer: ReturnType | undefined; let settleTimer: ReturnType | undefined; let deadlineTimer: ReturnType | undefined; const calls = new Map }>(); const clearCalls = () => { for (const call of calls.values()) clearTimeout(call.timer); calls.clear(); }; const cleanup = () => { clearCalls(); if (termTimer) clearTimeout(termTimer); if (settleTimer) clearTimeout(settleTimer); if (deadlineTimer) clearTimeout(deadlineTimer); opts.signal?.removeEventListener("abort", onAbort); attemptSignal?.removeEventListener("abort", onAbort); discoveryAttempt?.signal.removeEventListener('abort', onAbort); endAttempt(); }; let assistantError: string | undefined; let toolCallsStarted = 0; const runtimeTests: RuntimeTestRecord[] = []; const runtimeTestStarts = new Map(); const toolEventRecorder = createToolEventRecorder(); const toolEvents: ToolExecutionEvent[] = toolEventRecorder.events; const settle = (code: number | null, spawnError?: string) => { if (settled) return; settled = true; cleanup(); if (termination) termination.confirmed = closeSeen; // A pipe held by a descendant must not hold the caller forever. try { proc.stdin?.destroy(); } catch {} try { proc.stdout?.destroy(); } catch {} try { proc.stderr?.destroy(); } catch {} const rawOutput = textChunks.join(""); const bounded = opts.boundedOutputDir ? boundOutput({ content: rawOutput, retentionDir: opts.boundedOutputDir, label: "assistant-reply" }) : null; resolve({ output: bounded?.reply ?? rawOutput, // Pi's headless process can exit 0 while the assistant message itself // reports stopReason:error. Normalize that to a failed run so callers // never mark a provider/model error as successful. exitCode: assistantError && code === 0 ? 1 : code, stderr: stderrChunks.join(""), toolCallsStarted, runtimeTests, toolEvents, modelUsed: opts.model, // T3: explicit lifecycle facts travel with every settlement. // Missing close or an unconfirmed process tree stays uncertain downstream. lifecycle: { launched: true, closeSeen }, ...(spawnError ? { spawnError } : {}), ...(assistantError ? { assistantError } : {}), ...(termination ? { termination } : {}), ...(publishedIsolation ? { writeIsolation: publishedIsolation } : {}), ...(bounded ? { boundedOutput: { truncated: bounded.truncated, totalBytes: bounded.totalBytes, handle: bounded.handle, contentPath: bounded.contentPath, sha256: bounded.hash } } : {}), }); }; const terminate = (reason: Termination["reason"], tool?: ToolTimeout) => { // The first classification wins; later cancellation/exit events keep it. if (termination) return; termination = { reason, confirmed: false, escalated: false, ...(tool ? { tool } : {}) }; discoveryAttempt?.revoke(); // fence replies immediately, not after kill/pipe settlement clearCalls(); killPiTree(proc, "SIGTERM"); termTimer = setTimeout(() => { if (settled) return; termination!.escalated = true; killPiTree(proc, "SIGKILL"); }, termGraceMs); settleTimer = setTimeout(() => settle(null), termGraceMs + settleGraceMs); }; const onAbort = () => terminate("cancelled"); const watchAbort = (signal?: AbortSignal) => { if (!signal) return; if (signal.aborted) onAbort(); else signal.addEventListener("abort", onAbort, { once: true }); }; watchAbort(opts.signal); watchAbort(attemptSignal); watchAbort(discoveryAttempt?.signal); // External classified stop (drift watchdog): same first-classification-wins // cascade as every other termination path; harmless after settle. cbs.onControl?.({ terminate: (reason = "drift_stop") => { if (!settled) terminate(reason); } }); if (turnDeadlineMs != null) { deadlineTimer = setTimeout(() => terminate("turn_timeout"), turnDeadlineMs); } const toolStarts = new Map(); const toolId = (event: any) => String(event.toolCallId ?? event.tool_call_id ?? event.id ?? ""); const toolResultText = (result: any): string => { const content = result?.content ?? result; if (typeof content === "string") return content; if (!Array.isArray(content)) { try { return content == null ? "" : JSON.stringify(content); } catch { return String(content ?? ""); } } return content.map((item: any) => { if (typeof item === "string") return item; if (item?.type === "text" && typeof item.text === "string") return item.text; try { return JSON.stringify(item); } catch { return String(item ?? ""); } }).filter(Boolean).join("\n"); }; const handleEvent = (event: any) => { if (event.type === "message_update") { const delta = event.assistantMessageEvent; if (delta?.type === "text_delta") { textChunks.push(delta.delta || ""); cbs.onTextDelta?.(delta.delta || ""); } else if (delta?.type === "thinking_delta") cbs.onThinkingDelta?.(delta.delta || ""); } else if (event.type === "tool_execution_start") { toolCallsStarted++; let argStr = ""; try { argStr = event.args != null ? JSON.stringify(event.args) : ""; } catch {} const id = toolId(event); const name = event.toolName || "tool"; if (id && !toolStarts.has(id)) toolStarts.set(id, Date.now()); const protocolArgs = event.args && typeof event.args === "object" ? JSON.stringify(Object.fromEntries(["path", "file", "filePath"].filter(key => typeof event.args[key] === "string").map(key => [key, event.args[key]]))) : "{}"; toolEventRecorder.start(name, protocolArgs, id || undefined); if (opts.runtimeTestObserver && event.toolName === "bash" && typeof event.args?.command === "string" && toolId(event)) runtimeTestStarts.set(toolId(event), event.args.command); cbs.onToolStart?.(name, argStr, id || undefined); if (timeoutMs != null && id && watchedTools.has(name) && !calls.has(id)) { const startedAt = Date.now(); const call: ToolTimeout = { toolCallId: id, toolName: name, args: argStr, startedAt, deadlineAt: startedAt + timeoutMs }; calls.set(id, { ...call, timer: setTimeout(() => terminate("tool_timeout", call), timeoutMs) }); } } else if (event.type === "tool_execution_end") { const id = toolId(event); if (id) { const call = calls.get(id); if (call) { clearTimeout(call.timer); calls.delete(id); } } const rawIsError = event.isError ?? event.is_error ?? event.result?.isError ?? event.result?.is_error; toolEventRecorder.end(id || undefined, typeof rawIsError === "boolean" ? rawIsError : undefined); const startedCommand = id ? runtimeTestStarts.get(id) : undefined; if (id) runtimeTestStarts.delete(id); const check = runtimeTestFromResult(opts.runtimeTestObserver, startedCommand, event.toolName, event.result); if (check) runtimeTests.push(check); const startedAt = id ? toolStarts.get(id) : undefined; if (id) toolStarts.delete(id); cbs.onToolEnd?.( event.toolName || "tool", id || undefined, typeof rawIsError === "boolean" ? rawIsError : undefined, toolResultText(event.result), startedAt == null ? undefined : Math.max(0, Date.now() - startedAt), ); } else if (event.type === "message_end") { if (event.message?.role === "assistant" && (event.message.stopReason === "error" || event.message.stopReason === "aborted")) { assistantError = String(event.message.errorMessage || (event.message.stopReason === "aborted" ? "assistant model request aborted" : "assistant model run failed")); } if (event.message?.usage) cbs.onUsage?.(event.message.usage, "message_end"); } else if (event.type === "agent_end") { const last = [...(event.messages || [])].reverse().find((m: any) => m.role === "assistant"); if (last?.usage) cbs.onUsage?.(last.usage, "agent_end"); } }; proc.stdout!.setEncoding("utf-8"); proc.stdout!.on("data", (chunk: string) => { buffer += chunk; const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (line.trim()) try { handleEvent(JSON.parse(line)); } catch {} } }); proc.stderr!.setEncoding("utf-8"); proc.stderr!.on("data", (chunk: string) => stderrChunks.push(chunk)); proc.once('exit', () => { discoveryAttempt?.signal.removeEventListener('abort', onAbort); discoveryAttempt?.revoke(); }); proc.on("close", (code) => { closeSeen = true; if (buffer.trim()) try { handleEvent(JSON.parse(buffer)); } catch {} settle(code); }); proc.on("error", (err) => settle(1, err.message)); }); } finally { endAttempt(); } } const PROVIDER_FAILURE_RE = /\b(provider unavailable|service unavailable|overloaded|rate limit|too many requests|out of memory|oom|memory limit|resource exhausted|econn(?:reset|refused)|connection (?:reset|refused)|fetch failed|network error|gateway timeout|http\s*5\d\d|\b429\b)\b/i; function modelFallbackReason(result: SpawnPiAgentResult, midRun = false): string | null { // A terminated run (watchdog, deadline, cancellation) is a verdict on the // work, not a provider outage — never retry it. A spawn failure never // reached a provider at all. if (result.spawnError || result.termination) return null; const workStarted = result.toolCallsStarted > 0 || Boolean(result.output.trim()); if (!midRun && workStarted) return null; const reason = result.assistantError ? result.assistantError.slice(-500) : result.exitCode !== 0 ? (result.stderr.trim().slice(-500) || `pi exited with code ${result.exitCode ?? "unknown"}`) : null; if (!reason) return null; // Once work began, only a recognizable provider/transport failure is safe to // route to another model. Deterministic session/configuration/harness errors // must remain visible instead of being masked by an expensive second run. if (midRun && workStarted && !PROVIDER_FAILURE_RE.test(reason)) return null; return reason; } export interface ModelFallbackOptions { /** * Retry even when the failed attempt had already started tools or emitted * text. Only pass this for a run that holds NO write-capable tools: the * retry re-executes whatever the first attempt did, which is harmless for a * reader and duplicates edits for a writer. Use `isReadOnlyToolList` on the * effective tool list rather than assuming. */ midRun?: boolean; } /** * Run an overridden model and retry once on the original persona model when the * first attempt failed for a provider-level reason. By default that means * "failed before producing text or starting a tool"; `midRun` extends it to * read-only runs, where a provider dying halfway through would otherwise throw * the whole run away — and those are exactly the children that exist to protect * their parent's context. The session file is restored before the retry so the * failed turn never enters the resumable conversation. */ export async function spawnPiAgentWithModelFallback( opts: SpawnPiAgentOptions, fallbackModel?: string, cbs: SpawnPiAgentCallbacks = {}, fallbackOptions: ModelFallbackOptions = {}, ): Promise { try { fallbackModel=profileFallback(fallbackModel,readActiveProfile()??readActiveProfile(opts.env)); } catch(error) { return {output:'',stderr:String(error),exitCode:1,spawnError:String(error),modelUsed:opts.model,toolCallsStarted:0,lifecycle:{launched:false,closeSeen:false}}; } if (!fallbackModel || fallbackModel === opts.model) return spawnPiAgent(opts, cbs); const sessionExisted = existsSync(opts.sessionFile); let sessionSnapshot: Buffer | undefined; if (sessionExisted) { try { sessionSnapshot = readFileSync(opts.sessionFile); } catch {} } const primary = await spawnPiAgent(opts, cbs); const reason = modelFallbackReason(primary, fallbackOptions.midRun === true); // If an existing resumable session could not be snapshotted, fail closed: // retrying would preserve the failed turn and could duplicate the prompt. if (!reason || (sessionExisted && !sessionSnapshot)) return primary; try { if (sessionExisted && sessionSnapshot) writeFileSync(opts.sessionFile, sessionSnapshot); else unlinkSync(opts.sessionFile); } catch (error: any) { if (error?.code !== "ENOENT") return primary; } const notice: ModelFallbackNotice = { from: opts.model, to: fallbackModel, reason }; cbs.onModelFallback?.(notice); const fallback = await spawnPiAgent({ ...opts, model: fallbackModel }, cbs); return { ...fallback, modelFallback: notice }; }