import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { spawn } from "node:child_process"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import { DefaultResourceLoader, ModelRuntime, SessionManager, SettingsManager, createAgentSession, defineTool, getAgentDir, resolveCliModel, type ExtensionFactory, type Skill, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import { Type } from "typebox"; import type { AgentDefinition } from "./agents.ts"; import { agentAllowsNestedAgents, resolveAgentTools, type ToolDescriptor } from "./capabilities.ts"; import type { PiSubagentsConfig } from "./config.ts"; import { createChildLifecycleController, resolveWarningSchedule, type ChildLifecycleController, type LifecycleUsage, type ProgressWarning, } from "./lifecycle.ts"; import { buildChildBoundary, resolveTaskIsolation } from "./prompts.ts"; import { appendTaskOutput, createUnpersistedTaskRecord, extractFinalText, extractInvocationText, formatInvocationOutput, formatTaskOutputForModel, persistTask, saveTaskOutput, type LiveTask, type TaskRecord, } from "./tasks.ts"; function escapeXml(value: string): string { return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); } /** * Build the ordered model-attempt chain for a task launch. * * Index 0 is always `undefined`, meaning "resolve the configured primary model * (and its parse-time fallback) inside makeChildSession". When the agent declares * a `fallbackModel` distinct from the actual primary reference, a second entry * forces a runtime retry with that exact model after an early (pre-work) * provider/startup failure, such as a 503 auth error on the first model call. */ export function buildModelChain(spec: LaunchSpec, parentModel?: string): Array { const fallbackRef = spec.agent.fallbackModel?.trim(); if (!fallbackRef) return [undefined]; const frontmatterModel = spec.agent.model === "inherit" ? undefined : spec.agent.model; // Mirrors makeChildSession's primary resolution: forked inherits the parent // model; otherwise explicit call model > agent model > parent model. const primaryRef = spec.forked ? parentModel : spec.model ?? frontmatterModel ?? parentModel; if (fallbackRef === primaryRef) return [undefined]; return [undefined, fallbackRef]; } /** Create a Fresh child session in Pi's standard catalogue, linked to the parent when available. */ export function createFreshChildSessionManager(options: { cwd: string; parentSessionFile?: string; }): SessionManager { if (options.parentSessionFile) { return SessionManager.create(options.cwd, path.dirname(options.parentSessionFile), { parentSession: options.parentSessionFile, }); } // Fallback: still use Pi's default standard catalogue; parent linkage is unavailable. return SessionManager.create(options.cwd); } export async function prepareForkSession(options: { parentSessionFile: string; parentLeafId: string; cwd: string; }): Promise { if (!fs.existsSync(options.parentSessionFile)) { throw new Error("unable to create persisted fork session; the selected parent branch has not been durably written yet"); } const durableDeadline = Date.now() + 1500; while (Date.now() < durableDeadline) { const contents = fs.readFileSync(options.parentSessionFile, "utf8"); if (contents.includes(options.parentLeafId)) break; await new Promise(resolve => setTimeout(resolve, 25)); } const durableContents = fs.readFileSync(options.parentSessionFile, "utf8"); if (!durableContents.includes(options.parentLeafId)) { throw new Error("unable to create persisted fork session; the selected parent branch has not been durably written yet"); } const parentManager = SessionManager.open(options.parentSessionFile); const forked = parentManager.createBranchedSession(options.parentLeafId); if (!forked || !fs.existsSync(forked)) { throw new Error("unable to create persisted fork session; the selected parent branch has not been durably written yet"); } // Keep the branched file at its standard catalogue path (do not move into the task artifact dir). const sessionManager = SessionManager.open(forked, path.dirname(forked), options.cwd); const branch = sessionManager.getBranch(); const resolvedToolCalls = new Set( branch .filter(entry => entry.type === "message" && entry.message.role === "toolResult") .map(entry => entry.type === "message" && entry.message.role === "toolResult" ? entry.message.toolCallId : ""), ); const pendingCalls = branch .filter(entry => entry.type === "message" && entry.message.role === "assistant") .flatMap(entry => entry.type === "message" && entry.message.role === "assistant" ? entry.message.content.filter(part => part.type === "toolCall") : []) .filter(call => !resolvedToolCalls.has(call.id)); for (const call of pendingCalls) { sessionManager.appendMessage({ role: "toolResult", toolCallId: call.id, toolName: call.name, content: [{ type: "text", text: "Fork started; this sibling tool call continues outside the child context." }], isError: false, timestamp: Date.now(), }); } return sessionManager; } export interface TaskQuota { readonly inUse: number; readonly limit: number; setLimit(limit: number): void; tryAcquire(count?: number): boolean; acquire(count?: number, signal?: AbortSignal): Promise; acquireDependency(signal?: AbortSignal): Promise; release(count?: number): void; } export function createTaskQuota(initialLimit: number): TaskQuota { let limit = Math.max(1, initialLimit); let inUse = 0; const waiters: Array<{ count: number; resolve: () => void; reject: (error: Error) => void; signal?: AbortSignal; onAbort?: () => void }> = []; const tryStartWaiters = () => { while (waiters.length > 0) { const next = waiters[0]!; if (next.count > limit) { waiters.shift(); if (next.signal && next.onAbort) next.signal.removeEventListener("abort", next.onAbort); next.reject(new Error(`Task quota acquire count ${next.count} exceeds limit ${limit}`)); continue; } if (inUse + next.count > limit) break; waiters.shift(); if (next.signal && next.onAbort) next.signal.removeEventListener("abort", next.onAbort); inUse += next.count; next.resolve(); } }; return { get inUse() { return inUse; }, get limit() { return limit; }, setLimit(value) { limit = Math.max(1, value); tryStartWaiters(); }, tryAcquire(count = 1) { if (count < 1 || count > limit || inUse + count > limit) return false; inUse += count; return true; }, acquire(count = 1, signal) { if (count < 1) return Promise.reject(new Error("Task quota acquire count must be >= 1")); if (count > limit) { return Promise.reject(new Error(`Task quota acquire count ${count} exceeds limit ${limit}`)); } if (signal?.aborted) return Promise.reject(new Error("Task quota acquisition aborted")); if (inUse + count <= limit && waiters.length === 0) { inUse += count; return Promise.resolve(); } return new Promise((resolve, reject) => { const entry: (typeof waiters)[number] = { count, resolve, reject, signal }; if (signal) { entry.onAbort = () => { const index = waiters.indexOf(entry); if (index >= 0) waiters.splice(index, 1); reject(new Error("Task quota acquisition aborted")); tryStartWaiters(); }; signal.addEventListener("abort", entry.onAbort, { once: true }); } waiters.push(entry); }); }, acquireDependency(signal) { if (signal?.aborted) return Promise.reject(new Error("Task quota acquisition aborted")); if (inUse >= limit) { return Promise.reject(new Error(`Synchronous dependency requires spare task capacity; ${inUse}/${limit} slots are already in use.`)); } return this.acquire(1, signal); }, release(count = 1) { inUse = Math.max(0, inUse - Math.max(0, count)); tryStartWaiters(); }, }; } export interface LaunchSpec { agent: AgentDefinition; prompt: string; description: string; cwd: string; background: boolean; forked: boolean; model?: string; thinking?: string; timeoutMs?: number; maxTurns?: number; graceTurns?: number; maxToolCalls?: number; softToolCalls?: number; toolBudgetBlock?: string[] | "*"; warningTurns: number; warningIntervalTurns: number; toolInventory?: ToolDescriptor[]; allowNestedAgent?: boolean; containmentRoot?: string; isolation?: "worktree" | "none"; name?: string; } export interface ProgressWarningDetails { turn: number; nextWarningTurn: number; warningCount: number; warningTurns: number; warningIntervalTurns: number; } export interface ParentLaunchContext { parentSessionId: string; rootParentSessionId?: string; parentTaskId?: string; depth?: number; parentSessionFile?: string; parentLeafId?: string | null; parentModel?: string; parentThinking?: string; parentSystemPrompt?: string; appendSubagentSystemPrompt?: string; toolInventory?: ToolDescriptor[]; taskQuota?: TaskQuota; availableModels?: Array<{ provider: string; id: string }>; projectTrusted: boolean; } interface WorktreeInfo { root: string; path: string; cwd: string; branch: string; baseCommit: string; } function asThinkingLevel(value: string | undefined): ThinkingLevel | undefined { const levels: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"]; return levels.includes(value as ThinkingLevel) ? (value as ThinkingLevel) : undefined; } export function finalNewTurnText(messages: AgentMessage[]): string { return extractFinalText(messages) || "(Subagent completed without new text output.)"; } async function createWorktree(cwd: string, taskId: string): Promise { const probe = await runGit(cwd, ["rev-parse", "--show-toplevel"]); if (probe.code !== 0) throw new Error("worktree isolation requires a git repository"); const root = probe.stdout.trim(); const status = await runGit(root, ["status", "--porcelain"]); if (status.code !== 0) throw new Error(status.stderr || "unable to inspect git status"); if (status.stdout.trim()) throw new Error("worktree isolation requires a clean git working tree"); const head = await runGit(root, ["rev-parse", "HEAD"]); if (head.code !== 0) throw new Error(head.stderr || "unable to resolve HEAD"); const worktreeDir = path.join(os.tmpdir(), "pi-subagents", taskId); const relativeCwd = path.relative(root, path.resolve(cwd)); if (relativeCwd.startsWith("..") || path.isAbsolute(relativeCwd)) throw new Error("worktree cwd must be inside the git repository"); const branch = `pi-agent-${taskId.slice(0, 8)}`; await fs.promises.mkdir(path.dirname(worktreeDir), { recursive: true }); const add = await runGit(root, ["worktree", "add", "-b", branch, worktreeDir, head.stdout.trim()]); if (add.code !== 0) throw new Error(add.stderr || "git worktree add failed"); try { const worktreeCwd = path.join(worktreeDir, relativeCwd); const canonicalRoot = fs.realpathSync(worktreeDir); const canonicalCwd = fs.realpathSync(worktreeCwd); const canonicalRelative = path.relative(canonicalRoot, canonicalCwd); if (canonicalRelative.startsWith("..") || path.isAbsolute(canonicalRelative)) throw new Error("worktree cwd resolves outside the isolated checkout"); return { root, path: worktreeDir, cwd: canonicalCwd, branch, baseCommit: head.stdout.trim() }; } catch (error) { await runGit(root, ["worktree", "remove", "--force", worktreeDir]); await runGit(root, ["branch", "-D", branch]); throw error; } } async function finalizeWorktree(info: WorktreeInfo | undefined): Promise<{ kept?: WorktreeInfo }> { if (!info) return {}; const status = await runGit(info.path, ["status", "--porcelain"]); const head = await runGit(info.path, ["rev-parse", "HEAD"]); if (status.code !== 0 || head.code !== 0) return { kept: info }; const changed = status.stdout.trim().length > 0 || head.stdout.trim() !== info.baseCommit; if (changed) return { kept: info }; const removed = await runGit(info.root, ["worktree", "remove", "--force", info.path]); if (removed.code !== 0) return { kept: info }; const deleted = await runGit(info.root, ["branch", "-D", info.branch]); if (deleted.code !== 0) return {}; return {}; } function runGit(cwd: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise(resolve => { const proc = spawn("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; proc.stdout.on("data", data => (stdout += data.toString())); proc.stderr.on("data", data => (stderr += data.toString())); proc.on("error", error => resolve({ stdout, stderr: `${stderr}${error.message}`, code: 1 })); proc.on("close", code => resolve({ stdout, stderr, code: code ?? 1 })); }); } export function validateAgentDefinition(agent: AgentDefinition): void { resolveTools(agent); } function resolveTools(agent: AgentDefinition, options?: { inventory?: ToolDescriptor[]; allowNestedAgent?: boolean }): string[] { return resolveAgentTools({ agent, inventory: options?.inventory, allowNestedAgent: options?.allowNestedAgent ?? false, }); } function selectedSkills(agent: AgentDefinition, skills: Skill[]): Skill[] { if (!agent.skills?.length) return skills; const wanted = new Set(agent.skills.map(name => name.toLowerCase())); return skills.filter(skill => wanted.has(skill.name.toLowerCase())); } function preloadedSkillPrompt(agent: AgentDefinition, skills: Skill[]): string | undefined { if (!agent.skills?.length) return undefined; const selected = selectedSkills(agent, skills); const missing = agent.skills.filter(name => !selected.some(skill => skill.name.toLowerCase() === name.toLowerCase())); const sections: string[] = []; for (const skill of selected) { try { sections.push(`## Preloaded skill: ${skill.name}\n\n${fs.readFileSync(skill.filePath, "utf8")}`); } catch { // Pi discovery reports unreadable skills separately. } } if (missing.length) sections.push(`Missing requested skills: ${missing.join(", ")}`); return sections.length ? `# Preloaded skills\n\n${sections.join("\n\n")}` : undefined; } export const FINAL_HANDOFF_DIRECTIVE = "You have reached a wrap-up checkpoint. Finish any concise final step needed, keep additional browsing minimal, and return a clear factual handoff using evidence already collected. Include incomplete work or blockers explicitly."; export const TOOL_BUDGET_WRAP_UP_DIRECTIVE = "You are approaching the configured tool budget. Prefer finishing from evidence already collected and prepare a concise final report."; export interface LifecycleUsageBaseline { turns: number; toolCallsRequested: number; toolCallsExecuted: number; toolCallsBlocked: number; } export function applyAssistantTokenUsage( record: TaskRecord, message: Extract, ): void { record.usage.input += message.usage.input || 0; record.usage.output += message.usage.output || 0; record.usage.cacheRead += message.usage.cacheRead || 0; record.usage.cacheWrite += message.usage.cacheWrite || 0; record.usage.cost += message.usage.cost?.total || 0; } export function applyLifecycleUsage(record: TaskRecord, baseline: LifecycleUsageBaseline, usage: LifecycleUsage): void { record.usage.turns = baseline.turns + usage.turns; record.usage.toolCallsRequested = baseline.toolCallsRequested + usage.toolCallsRequested; record.usage.toolCallsExecuted = baseline.toolCallsExecuted + usage.toolCallsExecuted; record.usage.toolCallsBlocked = baseline.toolCallsBlocked + usage.toolCallsBlocked; record.usage.toolCalls = record.usage.toolCallsExecuted; } export function finalizeInvocationRecord(options: { record: TaskRecord; lifecycle: ChildLifecycleController; baseline: LifecycleUsageBaseline; messages: AgentMessage[]; error?: string; startupFailure?: boolean; }): string { const lastAssistant = [...options.messages].reverse().find( (message): message is Extract => message.role === "assistant", ); const terminal = options.startupFailure ? options.lifecycle.failStartup(options.error ?? "Subagent failed to start.") : options.lifecycle.finishProvider({ stopReason: lastAssistant?.stopReason, errorMessage: lastAssistant?.errorMessage ?? options.error, hasInvocationText: Boolean(extractInvocationText(options.messages)), }); applyLifecycleUsage(options.record, options.baseline, terminal.usage); if (!terminal.status || !terminal.terminationKind) { throw new Error("Lifecycle did not produce a terminal task result."); } options.record.status = terminal.status; options.record.terminationKind = terminal.terminationKind; const error = options.error ?? lastAssistant?.errorMessage; options.record.error = error; return formatInvocationOutput({ text: extractInvocationText(options.messages), status: terminal.status, terminationKind: terminal.terminationKind, error, }); } /** * @deprecated Soft grace keeps normal Pi queue/continuation behavior. * Queue clearing is no longer tied to wrap-up phase; retained as a no-op for compatibility. */ export function clearQueuedMessagesAfterFinalHandoff( _session: { clearQueue(): { steering: string[]; followUp: string[] } }, _lifecycle: ChildLifecycleController, ): void { // Soft grace retains normal Pi queue/continuation until the invocation terminates. } export function deriveThinkingClampReason(options: { requested?: string; effective?: string; modelReasoning?: boolean; availableLevels: string[]; }): string | undefined { if (!options.requested || !options.effective || options.requested === options.effective) return undefined; if (options.modelReasoning === false) { return `Model metadata reports reasoning unsupported; requested ${options.requested}, effective ${options.effective}.`; } return `Requested thinking ${options.requested} is unavailable; effective ${options.effective}. Available levels: ${options.availableLevels.join(", ") || "off"}.`; } function assistantText(message: AgentMessage): string { if (message.role !== "assistant") return ""; return message.content .filter((part): part is Extract<(typeof message.content)[number], { type: "text" }> => part.type === "text") .map(part => part.text) .join("\n") .trim(); } /** * True when the child terminated on its very first model call with a provider * error (for example a 503 auth failure) before performing any real work. * Pi surfaces provider failures as an assistant error message with * `stopReason: "error"` and an `errorMessage`; the prompt promise itself * resolves normally, so retry detection must inspect the produced messages. */ export function isEarlyProviderError( messages: AgentMessage[], lifecycle: ChildLifecycleController, ): boolean { if (lifecycle.snapshot.usage.toolCallsExecuted !== 0) return false; if (extractFinalText(messages)) return false; const lastAssistant = [...messages].reverse().find( (message): message is Extract => message.role === "assistant", ); if (!lastAssistant) return false; return lastAssistant.stopReason === "error" && Boolean(lastAssistant.errorMessage?.trim()); } /** Internal control signal: the child failed on its first model call before doing work, so the launch may retry with the fallback model. */ class FallbackRetrySignal extends Error { constructor() { super("early provider failure; retrying with fallback model"); this.name = "FallbackRetrySignal"; } } function assistantToolCalls(message: AgentMessage): Array["content"][number], { type: "toolCall" }>> { if (message.role !== "assistant") return []; return message.content.filter((part): part is Extract<(typeof message.content)[number], { type: "toolCall" }> => part.type === "toolCall"); } export function createChildLifecycleExtension( _agent: AgentDefinition, lifecycle: ChildLifecycleController, options: { maxTurns?: number; onProgressWarning?: (warning: ProgressWarning) => void; } = {}, ): ExtensionFactory { return pi => { let finalHandoffSent = false; const enterFinalHandoff = () => { if (finalHandoffSent) return; finalHandoffSent = true; pi.sendUserMessage(FINAL_HANDOFF_DIRECTIVE, { deliverAs: "steer" }); }; const enterToolWrapUp = () => { pi.sendUserMessage(TOOL_BUDGET_WRAP_UP_DIRECTIVE, { deliverAs: "steer" }); }; pi.on("turn_start", () => { lifecycle.onTurnStart(); }); pi.on("tool_call", event => { const admission = lifecycle.admitTool(event.toolName); if (admission.queueWrapUp) enterToolWrapUp(); if (!admission.allowed) return { block: true, reason: admission.reason }; }); pi.on("turn_end", (event, ctx) => { const completion = lifecycle.onTurnEnd({ messageHasText: Boolean(assistantText(event.message)), wouldContinue: ctx.hasPendingMessages() || (assistantToolCalls(event.message).length > 0 && event.message.role === "assistant" && event.message.stopReason !== "error" && event.message.stopReason !== "aborted"), }); // Progress warnings never abort, restrict tools, inject wrap-up, or change running status. if (completion.progressWarning) options.onProgressWarning?.(completion.progressWarning); if (completion.queueFinalHandoff) enterFinalHandoff(); if (completion.stopAfterTurn) ctx.abort(); }); }; } export interface NestedAgentAdapterOptions { agent: AgentDefinition; agents: AgentDefinition[]; config: PiSubagentsConfig; parent: ParentLaunchContext; parentTask: TaskRecord; taskQuota: TaskQuota; onComplete: (record: TaskRecord) => void; onProgressWarning?: (record: TaskRecord, details: ProgressWarningDetails) => void; onTaskStarted?: (task: LiveTask) => void; deliverNestedResult?: (record: TaskRecord) => Promise; } function resolveNestedCwd(base: string, requested: string | undefined, worktreeRoot: string | undefined): string { const resolved = path.resolve(base, requested ?? "."); if (worktreeRoot) { const relative = path.relative(worktreeRoot, resolved); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Nested agent cwd must remain inside the isolated worktree."); const canonicalRoot = fs.realpathSync(worktreeRoot); const canonicalResolved = fs.realpathSync(resolved); const canonicalRelative = path.relative(canonicalRoot, canonicalResolved); if (canonicalRelative.startsWith("..") || path.isAbsolute(canonicalRelative)) throw new Error("Nested agent cwd resolves outside the isolated worktree."); return canonicalResolved; } return resolved; } function canonicalModelReference(modelRef: string, availableModels: Array<{ provider: string; id: string }> | undefined): string { const trimmed = modelRef.trim(); if (!trimmed || trimmed === "inherit" || trimmed === "default") return trimmed; const exact = availableModels?.find(model => `${model.provider}/${model.id}` === trimmed); if (exact) return `${exact.provider}/${exact.id}`; if (!trimmed.includes("/")) { const matches = availableModels?.filter(model => model.id === trimmed) ?? []; if (matches.length === 1) return `${matches[0]!.provider}/${matches[0]!.id}`; } throw new Error(`Model '${trimmed}' is not available in the parent Pi model registry. Omit model to use the selected agent's configured model.`); } export function createNestedAgentAdapter(options: NestedAgentAdapterOptions): ToolDefinition { const paramsSchema = Type.Object({ description: Type.String(), prompt: Type.String(), subagent_type: Type.Optional(Type.String()), model: Type.Optional(Type.String()), isolation: Type.Optional(Type.Union([Type.Literal("none"), Type.Literal("worktree")])), cwd: Type.Optional(Type.String()), name: Type.Optional(Type.String()), warning_turns: Type.Integer({ minimum: 1, default: 40, description: "Required first checkpoint for this nested task. Default/general recommendation: 40 turns. Typical ranges: 15-20 for narrow/high-risk work, 30-40 for routine investigation, 45-60 for broad research, 50-70 for multi-file implementation, and 15-25 for external/deployment work." }), warning_interval_turns: Type.Integer({ minimum: 1, default: 25, description: "Required reassessment interval. Default/general recommendation: 25 turns. Typical ranges: 10-15 for narrow/high-risk work, 20-25 for routine investigation, 30-40 for broad research, 35-45 for multi-file implementation, and 10-15 for external/deployment work." }), }); return defineTool({ name: "Agent", label: "Agent", description: `Launch a nested named agent for a genuinely independent subtask or better-matched specialist, then synthesize its result into this worker's handoff. Named children start Fresh and require a complete brief. Default/general warning schedule: first checkpoint at 40 turns, then every 25 turns. Choose a different pair only when scope or risk materially warrants it. Typical first/interval ranges: narrow or high-risk 15-20/10-15; routine investigation 30-40/20-25; broad research 45-60/30-40; multi-file implementation 50-70/35-45; external or deployment work 15-25/10-15. Children emit brief stage notes during long work. Checkpoints are not failures or timeouts; repeated/empty preview alone is not a stall signal. Do not delegate understanding, duplicate work, poll background tasks, or request a nested fork. Nesting is bounded at depth ${options.config.maxAgentDepth}; all children share the root concurrency quota.`, parameters: paramsSchema, async execute(_id, params, signal, onUpdate) { const nextDepth = options.parentTask.depth ?? 1; if (!options.config.enableNestedAgents || nextDepth >= options.config.maxAgentDepth) { throw new Error(`Nested subagent depth limit reached (${options.config.maxAgentDepth}).`); } if ((params.subagent_type ?? "general-purpose").toLowerCase() === "fork") { throw new Error("Nested Agent accepts named agent types; inherited-context workers are launched from the root session."); } const selected = options.agents.find(agent => agent.name === (params.subagent_type ?? "general-purpose")) ?? options.agents.find(agent => agent.name.toLowerCase() === (params.subagent_type ?? "general-purpose").toLowerCase()); if (!selected) throw new Error(`Unknown nested agent '${params.subagent_type ?? "general-purpose"}'.`); await options.taskQuota.acquireDependency(signal ?? undefined); try { const nestedSchedule = resolveWarningSchedule({ warningTurns: params.warning_turns, warningIntervalTurns: params.warning_interval_turns, fallbackTurns: options.config.warningTurns, fallbackInterval: options.config.warningIntervalTurns, }); const task = await launchTask({ spec: { agent: selected, prompt: params.prompt, description: params.description, cwd: resolveNestedCwd(options.parentTask.worktreeCwd ?? options.parentTask.worktreePath ?? options.parentTask.cwd, params.cwd, options.parentTask.containmentRoot ?? options.parentTask.worktreePath), background: false, forked: false, model: params.model ? canonicalModelReference(params.model, options.parent.availableModels) : undefined, timeoutMs: selected.timeoutMs ?? options.config.defaultTimeoutMs, maxTurns: selected.maxTurns ?? options.config.defaultMaxTurns, graceTurns: selected.graceTurns ?? options.config.defaultGraceTurns, maxToolCalls: selected.maxToolCalls ?? options.config.defaultMaxToolCalls, softToolCalls: selected.softToolCalls ?? options.config.defaultSoftToolCalls, toolBudgetBlock: selected.toolBudgetBlock ?? options.config.defaultToolBudgetBlock, warningTurns: nestedSchedule.warningTurns, warningIntervalTurns: nestedSchedule.warningIntervalTurns, isolation: resolveTaskIsolation(params.isolation as "none" | "worktree" | undefined, selected.isolation), name: params.name, toolInventory: options.parent.toolInventory, allowNestedAgent: options.config.enableNestedAgents && agentAllowsNestedAgents(selected) && nextDepth + 1 < options.config.maxAgentDepth, containmentRoot: options.parentTask.containmentRoot ?? options.parentTask.worktreePath, }, parent: { ...options.parent, parentSessionId: options.parentTask.rootParentSessionId ?? options.parent.parentSessionId, rootParentSessionId: options.parentTask.rootParentSessionId ?? options.parent.parentSessionId, parentTaskId: options.parentTask.id, depth: nextDepth, parentSessionFile: options.parentTask.sessionFile, parentLeafId: undefined, parentModel: options.parentTask.model ?? options.parent.parentModel, parentThinking: options.parentTask.thinking ?? options.parent.parentThinking, parentSystemPrompt: options.agent.prompt, }, config: options.config, onComplete: record => { options.onComplete(record); if (record.background) void options.deliverNestedResult?.(record); }, onProgressWarning: options.onProgressWarning, onUpdate: record => onUpdate?.({ content: [{ type: "text", text: `${record.description}: ${record.preview ?? "running"}` }], details: record }), }); options.onTaskStarted?.(task); // Nested Agent is always foreground. Parent Stop must abort this wait, not hang on task.promise alone. // Attach the listener before the aborted re-check so a Stop between launch and wait cannot be missed. const stopNested = () => { if (!task.record.background) void task.stop("manual_stop"); }; signal?.addEventListener("abort", stopNested, { once: true }); try { if (signal?.aborted) stopNested(); await Promise.race([ task.promise, task.foregroundReleased ?? new Promise(() => {}), ]); } finally { signal?.removeEventListener("abort", stopNested); } return { content: [{ type: "text", text: formatTaskOutputForModel(task.record, { bytes: options.config.maxOutputBytes, lines: options.config.maxOutputLines, }) }], details: task.record, }; } catch (error) { options.taskQuota.release(); throw error; } }, }); } async function makeChildSession(options: { spec: LaunchSpec; parent: ParentLaunchContext; record: TaskRecord; config: PiSubagentsConfig; agents?: AgentDefinition[]; onComplete: (record: TaskRecord) => void; onProgressWarning?: (record: TaskRecord, details: ProgressWarningDetails) => void; onTaskStarted?: (task: LiveTask) => void; deliverNestedResult?: (record: TaskRecord) => Promise; worktree?: WorktreeInfo; lifecycle: ChildLifecycleController; onLifecycleProgressWarning?: (warning: ProgressWarning) => void; /** Force a specific model reference instead of the configured resolution chain (used for fallback retries). */ modelOverride?: string; }) { const cwd = options.worktree?.cwd ?? options.spec.cwd; const containmentRoot = options.worktree?.path ?? options.spec.containmentRoot; if (containmentRoot) options.record.containmentRoot = containmentRoot; const agentDir = getAgentDir(); const modelRuntime = await ModelRuntime.create({ authPath: path.join(agentDir, "auth.json"), modelsPath: path.join(agentDir, "models.json"), }); const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: options.parent.projectTrusted }); const guardFactory = createChildLifecycleExtension(options.spec.agent, options.lifecycle, { maxTurns: options.spec.maxTurns, onProgressWarning: options.onLifecycleProgressWarning, }); // Optional budgets are enforced by the lifecycle controller; wrap-up is advisory. const requestedTools = resolveTools(options.spec.agent, { inventory: options.spec.toolInventory ?? options.parent.toolInventory, allowNestedAgent: options.spec.allowNestedAgent }); options.record.effectiveTools = requestedTools; options.record.effectiveReadonly = options.spec.agent.readonly; options.record.effectiveShellPolicy = options.spec.agent.shellPolicy; const skillLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noPromptTemplates: true, noThemes: true, noContextFiles: true, }); await skillLoader.reload(); const skillPrompt = preloadedSkillPrompt(options.spec.agent, skillLoader.getSkills().skills); const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noSkills: false, noPromptTemplates: true, noThemes: true, noContextFiles: true, extensionFactories: [ { name: "pi-subagent-guard", factory: guardFactory }, ], systemPromptOverride: () => options.spec.forked && options.parent.parentSystemPrompt ? options.parent.parentSystemPrompt : [options.spec.agent.prompt, skillPrompt].filter(Boolean).join("\n\n"), appendSystemPromptOverride: base => [ ...base, buildChildBoundary({ agent: options.spec.agent, forked: options.spec.forked, cwd, parentCwd: options.spec.cwd, worktree: Boolean(options.worktree), depth: (options.parent.depth ?? 0) + 1, maxDepth: options.config.maxAgentDepth, }), ...(options.parent.appendSubagentSystemPrompt ? [options.parent.appendSubagentSystemPrompt] : []), ], }); await loader.reload(); let sessionManager: SessionManager; if (options.spec.forked && options.parent.parentSessionFile && options.parent.parentLeafId) { sessionManager = await prepareForkSession({ parentSessionFile: options.parent.parentSessionFile, parentLeafId: options.parent.parentLeafId, cwd, }); } else { sessionManager = createFreshChildSessionManager({ cwd, parentSessionFile: options.parent.parentSessionFile, }); } const frontmatterModel = options.spec.agent.model === "inherit" ? undefined : options.spec.agent.model; const modelRef = options.modelOverride ?? (options.spec.forked ? options.parent.parentModel : options.spec.model ?? frontmatterModel ?? options.parent.parentModel); if (!modelRef) throw new Error(`Unable to resolve a model for agent '${options.spec.agent.name}'.`); let resolved = resolveCliModel({ cliModel: modelRef, modelRuntime }); let effectiveModelRef = modelRef; if (resolved.error || !resolved.model) { const primaryError = resolved.error ?? "unresolved"; const fallbackRef = options.spec.agent.fallbackModel?.trim(); if (fallbackRef && fallbackRef !== modelRef) { const fallbackResolved = resolveCliModel({ cliModel: fallbackRef, modelRuntime }); if (!fallbackResolved.error && fallbackResolved.model) { resolved = fallbackResolved; effectiveModelRef = fallbackRef; options.record.modelFallbackNote = `Primary model '${modelRef}' unavailable (${primaryError}); using fallback '${fallbackRef}'.`; } } } if (resolved.error || !resolved.model) throw new Error(resolved.error ?? `Unable to resolve model '${modelRef}'.`); const requestedThinking = asThinkingLevel(options.spec.forked ? options.parent.parentThinking ?? resolved.thinkingLevel : options.spec.thinking ?? options.spec.agent.thinking ?? options.parent.parentThinking ?? resolved.thinkingLevel); const nestedTool = options.spec.allowNestedAgent && options.parent.taskQuota && options.agents ? createNestedAgentAdapter({ agent: options.spec.agent, agents: options.agents, config: options.config, parent: options.parent, parentTask: options.record, taskQuota: options.parent.taskQuota, onComplete: options.onComplete, onProgressWarning: options.onProgressWarning, onTaskStarted: options.onTaskStarted, deliverNestedResult: options.deliverNestedResult, }) : undefined; const result = await createAgentSession({ cwd, agentDir, modelRuntime, settingsManager, resourceLoader: loader, sessionManager, model: resolved.model, thinkingLevel: requestedThinking, tools: requestedTools, customTools: nestedTool ? [nestedTool] : undefined, }); options.record.sessionFile = result.session.sessionFile; options.record.model = result.session.model ? `${result.session.model.provider}/${result.session.model.id}` : effectiveModelRef; options.record.requestedThinking = requestedThinking; options.record.effectiveThinking = result.session.thinkingLevel; options.record.thinking = result.session.thinkingLevel; options.record.thinkingClampReason = deriveThinkingClampReason({ requested: requestedThinking, effective: result.session.thinkingLevel, modelReasoning: result.session.model?.reasoning, availableLevels: result.session.getAvailableThinkingLevels(), }); await persistTask(options.record); return result.session; } export async function launchTask(options: { spec: LaunchSpec; parent: ParentLaunchContext; config: PiSubagentsConfig; agents?: AgentDefinition[]; onComplete: (record: TaskRecord) => void; onProgressWarning?: (record: TaskRecord, details: ProgressWarningDetails) => void; onTaskStarted?: (task: LiveTask) => void; onUpdate?: (record: TaskRecord) => void; }): Promise { validateAgentDefinition(options.spec.agent); const schedule = resolveWarningSchedule({ warningTurns: options.spec.warningTurns, warningIntervalTurns: options.spec.warningIntervalTurns, fallbackTurns: options.config.warningTurns, fallbackInterval: options.config.warningIntervalTurns, }); const record = await createUnpersistedTaskRecord({ parentSessionId: options.parent.parentSessionId, rootParentSessionId: options.parent.rootParentSessionId ?? options.parent.parentSessionId, parentTaskId: options.parent.parentTaskId, depth: (options.parent.depth ?? 0) + 1, parentSessionFile: options.parent.parentSessionFile, projectTrusted: options.parent.projectTrusted, oneShot: options.spec.agent.oneShot ?? false, agent: options.spec.agent.name, description: options.spec.description, prompt: options.spec.prompt, cwd: options.spec.cwd, background: options.spec.background, forked: options.spec.forked, model: options.spec.model, thinking: options.spec.thinking, maxTurns: options.spec.maxTurns, graceTurns: options.spec.graceTurns, maxToolCalls: options.spec.maxToolCalls, softToolCalls: options.spec.softToolCalls, toolBudgetBlock: options.spec.toolBudgetBlock, timeoutMs: options.spec.timeoutMs, warningTurns: schedule.warningTurns, warningIntervalTurns: schedule.warningIntervalTurns, nextWarningTurn: schedule.warningTurns, warningCount: 0, forkSystemPrompt: options.spec.forked ? options.parent.parentSystemPrompt : undefined, name: options.spec.name, }); const abortController = new AbortController(); let resolveForegroundReleased!: () => void; const foregroundReleased = new Promise(resolve => { resolveForegroundReleased = resolve; }); // Background launches never block the parent Agent tool on foreground wait. if (options.spec.background) resolveForegroundReleased(); const modelChain = buildModelChain(options.spec, options.parent.parentModel); let lifecycle = createChildLifecycleController({ maxToolCalls: options.spec.maxToolCalls, softToolCalls: options.spec.softToolCalls, toolBudgetBlock: options.spec.toolBudgetBlock, maxTurns: options.spec.maxTurns, graceTurns: options.spec.graceTurns, warningTurns: schedule.warningTurns, warningIntervalTurns: schedule.warningIntervalTurns, }); let usageBaseline: LifecycleUsageBaseline = { turns: record.usage.turns, toolCallsRequested: record.usage.toolCallsRequested, toolCallsExecuted: record.usage.toolCallsExecuted, toolCallsBlocked: record.usage.toolCallsBlocked, }; const handleProgressWarning = (warning: ProgressWarning) => { record.nextWarningTurn = warning.nextWarningTurn; record.warningCount = warning.warningCount; record.lastWarningAt = new Date().toISOString(); record.lastWarningTurn = warning.turn; // A foreground invocation cannot be supervised while its parent is blocked inside Agent. // Promote it before notifying/releasing so actual completion follows the background path. if (!record.background) record.background = true; applyLifecycleUsage(record, usageBaseline, lifecycle.snapshot.usage); void persistTask(record).catch(() => {}); options.onUpdate?.(record); options.onProgressWarning?.(record, warning); // First warning releases a blocked foreground Agent wait without completing the task. resolveForegroundReleased(); }; let stopError: string | undefined; let startupComplete = false; let childSession: Awaited> | undefined; let sendQueue = Promise.resolve(); let acceptingMessages = true; let resolveChildReady!: () => void; let rejectChildReady!: (error: unknown) => void; let childReady = new Promise((resolve, reject) => { resolveChildReady = resolve; rejectChildReady = reject; }); void childReady.catch(() => {}); let worktree: WorktreeInfo | undefined; const promise = (async () => { let messages: AgentMessage[] = []; let timeout: ReturnType | undefined; let attemptIndex = 0; try { if (options.spec.isolation === "worktree") { if (!options.config.enableWorktrees) throw new Error("worktree isolation is disabled by configuration"); worktree = await createWorktree(options.spec.cwd, record.id); record.worktreePath = worktree.path; record.worktreeCwd = worktree.cwd; record.worktreeBranch = worktree.branch; } if (abortController.signal.aborted) throw new Error("Task was stopped before child startup completed."); while (attemptIndex < modelChain.length) { const modelOverride = modelChain[attemptIndex]; try { childSession = await makeChildSession({ spec: options.spec, parent: options.parent, record, config: options.config, agents: options.agents, onComplete: options.onComplete, onProgressWarning: options.onProgressWarning, onTaskStarted: options.onTaskStarted, onLifecycleProgressWarning: handleProgressWarning, deliverNestedResult: async nestedRecord => { if (!childSession) return; const nestedResult = formatTaskOutputForModel(nestedRecord, { bytes: options.config.maxOutputBytes, lines: options.config.maxOutputLines, }); const notification = [ "", `${escapeXml(nestedRecord.id)}`, `${nestedRecord.status}`, `${escapeXml(nestedRecord.description)}`, `${escapeXml(nestedRecord.outputFile)}`, `${escapeXml(nestedResult)}`, "", ].join("\n"); if (childSession.isStreaming) await childSession.followUp(notification); else await childSession.prompt(notification, { expandPromptTemplates: false, source: "extension" }); }, worktree, lifecycle, modelOverride, }); startupComplete = true; if (abortController.signal.aborted) { await childSession.abort(); throw new Error("Task was stopped during child startup."); } await persistTask(record); const unsubscribe = childSession.subscribe(event => { if (event.type === "message_end") { messages.push(event.message); if (event.message.role === "assistant") { applyAssistantTokenUsage(record, event.message); } const preview = extractFinalText(messages); if (preview) record.preview = preview.split("\n")[0]?.slice(0, 300); applyLifecycleUsage(record, usageBaseline, lifecycle.snapshot.usage); options.onUpdate?.(record); } }); const stopChild = () => void childSession?.abort(); abortController.signal.addEventListener("abort", stopChild, { once: true }); if (abortController.signal.aborted) { await childSession.abort(); throw new Error("Task was stopped before prompting the child."); } if (options.spec.timeoutMs !== undefined) { timeout = setTimeout(() => { stopError = `Task timed out after ${options.spec.timeoutMs}ms`; lifecycle.requestStop("timeout"); abortController.abort(new Error(stopError)); }, options.spec.timeoutMs); } const kickoff = options.spec.prompt; const initialPrompt = childSession.prompt(kickoff, { expandPromptTemplates: false, source: "extension" }); resolveChildReady(); try { await initialPrompt; } finally { acceptingMessages = false; await sendQueue; abortController.signal.removeEventListener("abort", stopChild); unsubscribe(); } // Pi surfaces provider failures (e.g. 503 auth unavailable) as an assistant // error message; the prompt promise itself resolves. Detect the early // no-work provider failure here so the launch can retry with the fallback. // When the model resolution layer already switched to the fallback // (modelFallbackNote set), the current attempt is already using the // fallback; retrying it again would duplicate the fallback attempt. if (attemptIndex < modelChain.length - 1 && !stopError && !abortController.signal.aborted && !record.modelFallbackNote && isEarlyProviderError(messages, lifecycle)) { throw new FallbackRetrySignal(); } const output = finalizeInvocationRecord({ record, lifecycle, baseline: usageBaseline, messages, error: stopError, }); await saveTaskOutput(record, output); break; } catch (error) { if (childSession) { childSession.dispose(); childSession = undefined; } if (timeout) { clearTimeout(timeout); timeout = undefined; } const isFallbackSignal = error instanceof FallbackRetrySignal; const canTryFallback = attemptIndex < modelChain.length - 1 && !stopError && !abortController.signal.aborted; const neverStarted = isFallbackSignal || !startupComplete || (lifecycle.snapshot.usage.toolCallsExecuted === 0 && messages.length === 0 && !record.preview); const retryable = canTryFallback && neverStarted; if (retryable) { attemptIndex++; messages = []; startupComplete = false; acceptingMessages = true; sendQueue = Promise.resolve(); // Clear stale session/model state from the failed attempt so a later // startup failure is not attributed to the disposed primary session. delete record.sessionFile; delete record.preview; record.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, toolCalls: 0, toolCallsRequested: 0, toolCallsExecuted: 0, toolCallsBlocked: 0, }; record.modelFallbackNote = `Primary model attempt failed before doing any work${error instanceof Error && error.message ? ` (${error.message})` : ""}; retried with fallback model.`; lifecycle = createChildLifecycleController({ maxToolCalls: options.spec.maxToolCalls, softToolCalls: options.spec.softToolCalls, toolBudgetBlock: options.spec.toolBudgetBlock, maxTurns: options.spec.maxTurns, graceTurns: options.spec.graceTurns, warningTurns: schedule.warningTurns, warningIntervalTurns: schedule.warningIntervalTurns, }); usageBaseline = { turns: 0, toolCallsRequested: 0, toolCallsExecuted: 0, toolCallsBlocked: 0, }; continue; } rejectChildReady(error); const message = stopError ?? (error instanceof Error ? error.message : String(error)); const output = finalizeInvocationRecord({ record, lifecycle, baseline: usageBaseline, messages, error: message, startupFailure: !startupComplete, }); await saveTaskOutput(record, output); break; } } } catch (error) { rejectChildReady(error); const message = stopError ?? (error instanceof Error ? error.message : String(error)); const output = finalizeInvocationRecord({ record, lifecycle, baseline: usageBaseline, messages, error: message, startupFailure: !startupComplete, }); await saveTaskOutput(record, output); } finally { if (timeout) clearTimeout(timeout); if (childSession) childSession.dispose(); const finalized = await finalizeWorktree(worktree); const cleanedResumableWorktree = Boolean(worktree && !finalized.kept && !record.oneShot); if (!finalized.kept) { delete record.worktreePath; delete record.worktreeCwd; delete record.worktreeBranch; if (worktree && !record.oneShot) record.worktreeCleaned = true; } if (cleanedResumableWorktree && record.status === "completed") { record.worktreeCleaned = true; } // Always release any remaining foreground wait so callers cannot hang after terminal. resolveForegroundReleased(); if (record.status === "failed" && !record.sessionFile) { record.completedAt = new Date().toISOString(); await fs.promises.rm(path.dirname(record.taskFile), { recursive: true, force: true }); options.onComplete(record); } else { record.completedAt = new Date().toISOString(); await persistTask(record); options.onComplete(record); } } return record; })(); return { record, abortController, promise, foregroundReleased, send: async message => { await childReady; if (!childSession) throw new Error("child session failed to start"); if (!acceptingMessages) throw new Error("child task is no longer accepting live messages; resume the persisted task instead"); const delivery = sendQueue.then(async () => { if (abortController.signal.aborted) throw new Error("child task is no longer accepting live messages"); if (!childSession) throw new Error("child session is unavailable"); if (childSession.isStreaming) await childSession.steer(message); else await childSession.prompt(message, { expandPromptTemplates: false, source: "extension" }); }); sendQueue = delivery.catch(error => { if (!abortController.signal.aborted) abortController.abort(error instanceof Error ? error : new Error(String(error))); }); await delivery; }, stop: async kind => { if (lifecycle.snapshot.phase === "terminal") return; lifecycle.requestStop(kind); stopError = kind === "manual_stop" ? "Stopped by parent." : "Parent session shut down."; abortController.abort(new Error(stopError)); if (childSession) await childSession.abort(); }, }; } export interface ResolvedResumeCapabilities { tools: string[]; readonly: boolean; shellPolicy: AgentDefinition["shellPolicy"]; allowNestedAgent: boolean; } export interface ResolveResumeCapabilitiesOptions { agent: AgentDefinition; config: PiSubagentsConfig; recordDepth?: number; forked?: boolean; inventory?: ToolDescriptor[]; hasAgentRegistry: boolean; hasTaskQuota: boolean; } export function resolveResumeCapabilities(options: ResolveResumeCapabilitiesOptions): ResolvedResumeCapabilities { const depth = options.recordDepth ?? 1; const allowNestedAgent = !options.forked && options.config.enableNestedAgents && agentAllowsNestedAgents(options.agent) && depth < options.config.maxAgentDepth && options.hasAgentRegistry && options.hasTaskQuota; return { tools: resolveTools(options.agent, { inventory: options.inventory, allowNestedAgent, }), readonly: options.agent.readonly, shellPolicy: options.agent.shellPolicy, allowNestedAgent, }; } export function applyResumeCapabilities( record: Pick, options: ResolveResumeCapabilitiesOptions, ): ResolvedResumeCapabilities { const resolved = resolveResumeCapabilities(options); record.effectiveTools = [...resolved.tools]; record.effectiveReadonly = resolved.readonly; record.effectiveShellPolicy = resolved.shellPolicy; return resolved; } export async function resumeCompletedTask(options: { record: TaskRecord; message: string; agent: AgentDefinition; config: PiSubagentsConfig; agents?: AgentDefinition[]; parent?: ParentLaunchContext; onTaskStarted?: (task: LiveTask) => void; onComplete: (record: TaskRecord) => void; onProgressWarning?: (record: TaskRecord, details: ProgressWarningDetails) => void; }): Promise { validateAgentDefinition(options.agent); const sessionFile = options.record.sessionFile; if (!sessionFile || !fs.existsSync(sessionFile)) { throw new Error(`No persisted child session for task ${options.record.id}`); } if (options.record.status === "running") { throw new Error(`Task ${options.record.id} is already running and cannot be resumed concurrently.`); } if (options.record.oneShot) { throw new Error(`Task ${options.record.id} uses one-shot execution and cannot be resumed.`); } if (options.record.worktreeCleaned) { throw new Error(`Task ${options.record.id} used a cleaned isolated worktree; start a new isolated task instead of resuming it.`); } const resumeCapabilities = applyResumeCapabilities(options.record, { agent: options.agent, config: options.config, recordDepth: options.record.depth, forked: options.record.forked, inventory: options.parent?.toolInventory, hasAgentRegistry: Boolean(options.agents), hasTaskQuota: Boolean(options.parent?.taskQuota), }); const resumeTools = resumeCapabilities.tools; const resumeAgent = options.agent; const persistedMaxToolCalls = options.record.maxToolCalls ?? options.config.defaultMaxToolCalls; const persistedSoftToolCalls = options.record.softToolCalls ?? options.config.defaultSoftToolCalls; const persistedToolBudgetBlock = options.record.toolBudgetBlock ?? options.config.defaultToolBudgetBlock; const persistedMaxTurns = options.record.maxTurns ?? options.config.defaultMaxTurns; const persistedGraceTurns = options.record.graceTurns ?? options.config.defaultGraceTurns; const persistedTimeoutMs = options.record.timeoutMs ?? options.config.defaultTimeoutMs; const resumeSchedule = resolveWarningSchedule({ warningTurns: options.record.warningTurns ?? options.config.warningTurns, warningIntervalTurns: options.record.warningIntervalTurns ?? options.config.warningIntervalTurns, fallbackTurns: options.config.warningTurns, fallbackInterval: options.config.warningIntervalTurns, }); options.record.maxToolCalls = persistedMaxToolCalls; options.record.softToolCalls = persistedSoftToolCalls; options.record.toolBudgetBlock = persistedToolBudgetBlock; options.record.maxTurns = persistedMaxTurns; options.record.graceTurns = persistedGraceTurns; options.record.timeoutMs = persistedTimeoutMs; options.record.warningTurns = resumeSchedule.warningTurns; options.record.warningIntervalTurns = resumeSchedule.warningIntervalTurns; options.record.nextWarningTurn = options.record.nextWarningTurn ?? resumeSchedule.warningTurns; options.record.warningCount = options.record.warningCount ?? 0; const lifecycle = createChildLifecycleController({ maxToolCalls: persistedMaxToolCalls, softToolCalls: persistedSoftToolCalls, toolBudgetBlock: persistedToolBudgetBlock, maxTurns: persistedMaxTurns, graceTurns: persistedGraceTurns, warningTurns: resumeSchedule.warningTurns, warningIntervalTurns: resumeSchedule.warningIntervalTurns, initialTurns: options.record.usage.turns, nextWarningTurn: options.record.nextWarningTurn, warningCount: options.record.warningCount, }); const usageBaseline: LifecycleUsageBaseline = { turns: options.record.usage.turns, toolCallsRequested: options.record.usage.toolCallsRequested, toolCallsExecuted: options.record.usage.toolCallsExecuted, toolCallsBlocked: options.record.usage.toolCallsBlocked, }; const abortController = new AbortController(); let stopError: string | undefined; let startupComplete = false; let childSession: Awaited> | undefined; let sendQueue = Promise.resolve(); let acceptingMessages = true; let resolveChildReady!: () => void; let rejectChildReady!: (error: unknown) => void; const childReady = new Promise((resolve, reject) => { resolveChildReady = resolve; rejectChildReady = reject; }); void childReady.catch(() => {}); let unsubscribe: (() => void) | undefined; let stopChild: (() => void) | undefined; let timeout: ReturnType | undefined; const handleResumeProgressWarning = (warning: ProgressWarning) => { options.record.nextWarningTurn = warning.nextWarningTurn; options.record.warningCount = warning.warningCount; options.record.lastWarningAt = new Date().toISOString(); options.record.lastWarningTurn = warning.turn; applyLifecycleUsage(options.record, usageBaseline, lifecycle.snapshot.usage); void persistTask(options.record).catch(() => {}); options.onProgressWarning?.(options.record, warning); }; options.record.status = "running"; options.record.terminationKind = undefined; options.record.background = true; options.record.error = undefined; options.record.completedAt = undefined; await persistTask(options.record); const promise = (async () => { const messages: AgentMessage[] = []; try { const cwd = options.record.worktreeCwd ?? options.record.worktreePath ?? options.record.cwd; if (options.record.worktreePath) { const canonicalRoot = fs.realpathSync(options.record.worktreePath); const canonicalCwd = fs.realpathSync(cwd); const relative = path.relative(canonicalRoot, canonicalCwd); if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error("Persisted worktree cwd resolves outside the isolated checkout."); } const agentDir = getAgentDir(); const modelRuntime = await ModelRuntime.create({ authPath: path.join(agentDir, "auth.json"), modelsPath: path.join(agentDir, "models.json"), }); const runProjectTrusted = options.record.projectTrusted ?? false; options.record.projectTrusted = runProjectTrusted; const settingsManager = SettingsManager.create(cwd, agentDir, { projectTrusted: runProjectTrusted }); const guard = createChildLifecycleExtension(resumeAgent, lifecycle, { maxTurns: persistedMaxTurns, onProgressWarning: handleResumeProgressWarning, }); const skillLoader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noPromptTemplates: true, noThemes: true, noContextFiles: true }); await skillLoader.reload(); const skillPrompt = preloadedSkillPrompt(resumeAgent, skillLoader.getSkills().skills); const loader = new DefaultResourceLoader({ cwd, agentDir, settingsManager, noExtensions: true, noSkills: false, noPromptTemplates: true, noThemes: true, noContextFiles: true, extensionFactories: [ { name: "pi-subagent-guard", factory: guard }, ], systemPromptOverride: () => options.record.forked && options.record.forkSystemPrompt ? options.record.forkSystemPrompt : [resumeAgent.prompt, skillPrompt].filter(Boolean).join("\n\n"), appendSystemPromptOverride: base => [...base, buildChildBoundary({ agent: resumeAgent, forked: options.record.forked, cwd, parentCwd: options.record.cwd, worktree: Boolean(options.record.worktreePath), depth: options.record.depth ?? 1, maxDepth: options.config.maxAgentDepth, })], }); await loader.reload(); const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), cwd); let resolvedModel = options.record.model ? resolveCliModel({ cliModel: options.record.model, modelRuntime }) : undefined; if (options.record.model && (resolvedModel?.error || !resolvedModel?.model)) { const primaryError = resolvedModel?.error ?? "unresolved"; const fallbackRef = resumeAgent.fallbackModel?.trim(); if (fallbackRef && fallbackRef !== options.record.model) { const fallbackResolved = resolveCliModel({ cliModel: fallbackRef, modelRuntime }); if (!fallbackResolved.error && fallbackResolved.model) { resolvedModel = fallbackResolved; options.record.modelFallbackNote = `Primary model '${options.record.model}' unavailable (${primaryError}); using fallback '${fallbackRef}'.`; options.record.model = fallbackRef; } } } const resolved = resolvedModel; const restoredTools = [...resumeTools]; const resumeNestedTool = resumeCapabilities.allowNestedAgent && options.agents && options.parent?.taskQuota ? createNestedAgentAdapter({ agent: resumeAgent, agents: options.agents, config: options.config, parent: options.parent, parentTask: options.record, taskQuota: options.parent.taskQuota, onComplete: options.onComplete, onProgressWarning: options.onProgressWarning, onTaskStarted: options.onTaskStarted, }) : undefined; const requestedThinking = asThinkingLevel( options.record.requestedThinking ?? options.record.effectiveThinking ?? options.record.thinking, ); childSession = (await createAgentSession({ cwd, agentDir, modelRuntime, settingsManager, resourceLoader: loader, sessionManager, model: resolved?.model, thinkingLevel: requestedThinking, tools: restoredTools, customTools: resumeNestedTool ? [resumeNestedTool] : undefined, })).session; startupComplete = true; options.record.requestedThinking = requestedThinking; options.record.effectiveThinking = childSession.thinkingLevel; options.record.thinking = childSession.thinkingLevel; options.record.thinkingClampReason = deriveThinkingClampReason({ requested: requestedThinking, effective: childSession.thinkingLevel, modelReasoning: childSession.model?.reasoning, availableLevels: childSession.getAvailableThinkingLevels(), }); unsubscribe = childSession.subscribe(event => { if (event.type !== "message_end") return; messages.push(event.message); if (event.message.role === "assistant") applyAssistantTokenUsage(options.record, event.message); applyLifecycleUsage(options.record, usageBaseline, lifecycle.snapshot.usage); }); stopChild = () => void childSession?.abort(); abortController.signal.addEventListener("abort", stopChild, { once: true }); if (abortController.signal.aborted) { await childSession.abort(); throw new Error("Task was stopped before the resumed prompt."); } await persistTask(options.record); if (persistedTimeoutMs !== undefined) { timeout = setTimeout(() => { stopError = `Task timed out after ${persistedTimeoutMs}ms`; lifecycle.requestStop("timeout"); abortController.abort(new Error(stopError)); }, persistedTimeoutMs); } try { const resumedPrompt = childSession.prompt(options.message, { expandPromptTemplates: false, source: "extension" }); resolveChildReady(); await resumedPrompt; } finally { acceptingMessages = false; await sendQueue; clearTimeout(timeout); if (stopChild) abortController.signal.removeEventListener("abort", stopChild); unsubscribe?.(); unsubscribe = undefined; } const output = finalizeInvocationRecord({ record: options.record, lifecycle, baseline: usageBaseline, messages, error: stopError, }); await appendTaskOutput(options.record, output); } catch (error) { rejectChildReady(error); const message = stopError ?? (error instanceof Error ? error.message : String(error)); const output = finalizeInvocationRecord({ record: options.record, lifecycle, baseline: usageBaseline, messages, error: message, startupFailure: !startupComplete, }); await appendTaskOutput(options.record, output); } finally { if (timeout) clearTimeout(timeout); if (stopChild) abortController.signal.removeEventListener("abort", stopChild); unsubscribe?.(); childSession?.dispose(); options.record.completedAt = new Date().toISOString(); await persistTask(options.record); options.onComplete(options.record); } return options.record; })(); return { record: options.record, abortController, promise, foregroundReleased: Promise.resolve(), send: async message => { await childReady; if (!childSession) throw new Error("child session failed to resume"); if (!acceptingMessages) throw new Error("resumed child task is no longer accepting live messages"); const delivery = sendQueue.then(async () => { if (abortController.signal.aborted) throw new Error("resumed child task is no longer accepting live messages"); if (!childSession) throw new Error("child session is unavailable"); if (childSession.isStreaming) await childSession.steer(message); else await childSession.prompt(message, { expandPromptTemplates: false, source: "extension" }); }); sendQueue = delivery.catch(error => { if (!abortController.signal.aborted) abortController.abort(error instanceof Error ? error : new Error(String(error))); }); await delivery; }, stop: async kind => { if (lifecycle.snapshot.phase === "terminal") return; lifecycle.requestStop(kind); stopError = kind === "manual_stop" ? "Stopped by parent." : "Parent session shut down."; abortController.abort(new Error(stopError)); if (childSession) await childSession.abort(); }, }; }