import { execFile } from "node:child_process"; import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { unlink } from "node:fs/promises"; import { resolve } from "node:path"; import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { Model, Usage } from "@earendil-works/pi-ai"; import { clampThinkingLevel } from "@earendil-works/pi-ai/compat"; import { AgentSession, createAgentSession, estimateTokens, findCutPoint, generateSummaryWithUsage, ModelRuntime, parseSessionEntries, SessionManager, SettingsManager, sessionEntryToContextMessages, type AgentSessionEvent, type Extension, type SessionEntry, type ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { Static, TSchema } from "typebox"; import { Value } from "typebox/value"; import { buildSubagentSystemPrompt, selectChildAgentTranscript, snapshotCommittedContext, } from "./minimal-subagents-context.js"; import { canAgentContractSpawn, COORDINATOR_TOOL_NAMES, DEFAULT_MAX_SUBAGENT_DEPTH, getSubagentDepth, } from "./minimal-subagents-capabilities.js"; import { createChildResourceLoader } from "./minimal-subagents-child-resources.js"; import { CHILD_IDENTITY_ENTRY_TYPE, FORK_CLONE_ENTRY_TYPE, FORK_OWNERSHIP_ENTRY_TYPE, } from "./minimal-subagents-registry.js"; import { canonicalPath } from "./minimal-subagents-paths.js"; import { ChildSessionIdentityRecordSchema, DeliveryEvidenceDetailsSchema, ForkCloneProvenanceRecordSchema, ForkOwnershipRecordSchema, type ChildSessionIdentityRecord, type ForkCloneProvenanceRecord, type ForkOwnershipRecord, } from "./minimal-subagents-session-wire.js"; import { addMinimalSubagentsUsage } from "./minimal-subagents-usage.js"; import type { AgentSessionFactory, ChildAgentRuntime, ChildSessionObserver, ChildAgentTranscriptSnapshot, CoordinatorMessage, PersistedAgent, PersistedSessionIdentity, RuntimeProfile, RuntimeTurnOutcome, } from "./minimal-subagents-types.js"; const PI_BUILTIN_ORDINARY_TOOL_NAMES = new Set([ "read", "grep", "find", "ls", "bash", "powershell", "edit", "write", ]); interface PersistentIdentityOptions { agent: PersistedAgent; importedMessages: AgentMessage[]; cwd: string; sessionDir: string; rootSessionId: string; } /** Replaces the complete source group only while every runtime tool is active. */ export interface RuntimeToolReplacement { /** Launch Contract tools required to authorize this replacement. */ readonly sourceToolNames: readonly string[]; /** Adapter tools that jointly replace the source group. */ readonly runtimeToolNames: readonly string[]; } /** Identifies one configured extension allowed to replace Child Agent runtime tools. */ export interface RuntimeToolAdapter { /** Every tool registered by the adapter, used to defer initial tool selection until it loads. */ readonly toolNames: readonly string[]; /** Runtime replacements recognized for this adapter. */ readonly replacements: readonly RuntimeToolReplacement[]; } /** Resolve the exact active tools permitted by one Child Agent Launch Contract. */ export function resolveChildActiveToolNames( allowedToolNames: readonly string[], requestedToolNames: readonly string[], runtimeToolAdapters: readonly RuntimeToolAdapter[], ): string[] { const allowedNames = new Set(allowedToolNames); const requestedNames = new Set(requestedToolNames); const activeReplacements = runtimeToolAdapters .flatMap((adapter) => adapter.replacements) .filter((replacement) => replacement.sourceToolNames.every((toolName) => allowedNames.has(toolName)), ) .filter((replacement) => replacement.runtimeToolNames.every((toolName) => requestedNames.has(toolName)), ); const replacedSourceNames = new Set( activeReplacements.flatMap((replacement) => replacement.sourceToolNames), ); const activeRuntimeNames = new Set( activeReplacements.flatMap((replacement) => replacement.runtimeToolNames), ); return [ ...new Set([ ...allowedToolNames.filter((toolName) => !replacedSourceNames.has(toolName)), ...requestedToolNames.filter((toolName) => activeRuntimeNames.has(toolName)), ]), ]; } function installChildToolCapabilityPolicy( session: AgentSession, allowedToolNames: readonly string[], runtimeToolAdapters: readonly RuntimeToolAdapter[], preserveGrantedTools = true, ): void { const applyActiveTools = session.setActiveToolsByName.bind(session); session.setActiveToolsByName = (requestedToolNames) => { const permitted = resolveChildActiveToolNames( allowedToolNames, requestedToolNames, runtimeToolAdapters, ); // Inside an exposure wrapper, ordinary tools may intentionally be hidden. The // outer policy restores grants before that wrapper applies its own selection. applyActiveTools( preserveGrantedTools ? permitted : permitted.filter((name) => requestedToolNames.includes(name)), ); }; session.setActiveToolsByName(session.getActiveToolNames()); } /** Moves one verified child session file to trash and reports command unavailability. */ export interface SessionFileTrashCapability { moveSessionFile(sessionFile: string): Promise; } const execFileSessionTrashCapability: SessionFileTrashCapability = { moveSessionFile: (sessionFile) => new Promise((resolvePromise) => { const trashArguments = sessionFile.startsWith("-") ? ["--", sessionFile] : [sessionFile]; execFile("trash", trashArguments, (cause) => resolvePromise(cause ?? undefined)); }), }; /** Configures root ownership, model scope, child resources, and coordinator tool injection for Pi sessions. */ export interface PiAgentSessionFactoryOptions { cwd: string; agentDir: string; sessionDir: string; rootSessionId: string; extensionEntrypoint: string; models: readonly Model[]; eligibleModelIds: readonly string[]; modelScopeRestricted: boolean; availableToolNames: readonly string[]; getRuntimeToolAdapters?: () => readonly RuntimeToolAdapter[]; projectTrusted: boolean; maxSubagentDepth?: number; sessionFileTrash?: SessionFileTrashCapability; getCoordinatorTools: (callerId: string) => ToolDefinition[]; onChildSessionActivity?: () => void; observeSession?: ( session: AgentSession, agentId: string, resourceInputs: { agentDir: string; extensions: readonly Pick[]; flagValues: ReadonlyMap; }, ) => ChildSessionObserver | undefined; } /** Build one child prompt using the active delegation depth rather than persisted launch state. */ export function buildDepthBoundSubagentPrompt( agent: PersistedAgent, maxSubagentDepth = DEFAULT_MAX_SUBAGENT_DEPTH, ): string { return buildSubagentSystemPrompt(agent.agent_id, agent.parent_id, { canSpawn: canAgentContractSpawn( agent.agent_id, agent.launch_contract.delegation, maxSubagentDepth, ), remainingDepth: Math.max(0, maxSubagentDepth - getSubagentDepth(agent.agent_id)), }); } /** Build the shared unavailable-agent projection used by fork recovery and ownership binding. */ export function unavailableAgent( agent: PersistedAgent, error: string, latestActivityAt?: string, ): PersistedAgent { // SAFETY: the spread preserves every required PersistedAgent field; only optional session fields are cleared. const unavailable = { ...structuredClone(agent), session_file: undefined, session_id: undefined, session_leaf_id: undefined, clone_error: error, active_turn_id: undefined, active_turn_started_at: undefined, availability: "unavailable", missing_dependencies: [error], unavailable_reason: error, } as PersistedAgent; if (latestActivityAt !== undefined) unavailable.latest_activity_at = latestActivityAt; return unavailable; } function appendImportedMessage(sessionManager: SessionManager, message: AgentMessage): void { if (message.role === "compactionSummary") { sessionManager.appendCustomMessageEntry( "minimal-subagents.imported-compaction", message.summary, false, ); return; } if (message.role === "branchSummary") { sessionManager.appendCustomMessageEntry( "minimal-subagents.imported-branch-summary", message.summary, false, ); return; } // SAFETY: AgentMessage is Pi's broader message union; non-session variants were handled above. sessionManager.appendMessage(message as Parameters[0]); } /** Create and force-flush a writable child JSONL identity before its first model response. */ export function createPersistentChildIdentity( options: PersistentIdentityOptions, ): PersistedSessionIdentity { let sessionManager = SessionManager.create(options.cwd, options.sessionDir); sessionManager.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, { version: 1, original_root_session_id: options.rootSessionId, canonical_agent_id: options.agent.agent_id, direct_parent_id: options.agent.parent_id, created_at: options.agent.created_at, }); sessionManager.appendSessionInfo(`[subagent] ${options.agent.agent_id}`); sessionManager.appendModelChange( options.agent.launch_contract.model.slice(0, options.agent.launch_contract.model.indexOf("/")), options.agent.launch_contract.model.slice(options.agent.launch_contract.model.indexOf("/") + 1), ); sessionManager.appendThinkingLevelChange(options.agent.launch_contract.thinking_level); for (const message of options.importedMessages) appendImportedMessage(sessionManager, message); const sessionFile = sessionManager.getSessionFile(); if (!sessionFile) throw new Error( `Minimal subagents session creation: no session file for ${options.agent.agent_id}`, ); if (!existsSync(sessionFile)) { const lines = [sessionManager.getHeader(), ...sessionManager.getEntries()] .filter((entry) => entry !== null) .map((entry) => JSON.stringify(entry)) .join("\n"); writeFileSync(sessionFile, `${lines}\n`, "utf8"); sessionManager = SessionManager.open(sessionFile, options.sessionDir, options.cwd); } return { sessionFile, sessionId: sessionManager.getSessionId(), sessionLeafId: sessionManager.getLeafId() ?? undefined, }; } function findLatestChildSessionRecord( entries: ReturnType, customType: string, schema: TRecordSchema, ): Static | undefined { return entries.findLast( ( entry, ): entry is Extract & { data: Static; } => entry.type === "custom" && entry.customType === customType && Value.Check(schema, entry.data), )?.data; } function findLatestForkGeneration( entries: ReturnType, ): { identity: ChildSessionIdentityRecord; provenance: ForkCloneProvenanceRecord } | undefined { for (let provenanceIndex = entries.length - 1; provenanceIndex >= 0; provenanceIndex--) { const provenanceEntry = entries[provenanceIndex]; if ( !provenanceEntry || provenanceEntry.type !== "custom" || provenanceEntry.customType !== FORK_CLONE_ENTRY_TYPE ) { continue; } if (!Value.Check(ForkCloneProvenanceRecordSchema, provenanceEntry.data)) continue; const provenance = provenanceEntry.data; for (let identityIndex = provenanceIndex - 1; identityIndex >= 0; identityIndex--) { const identityEntry = entries[identityIndex]; if ( !identityEntry || identityEntry.type !== "custom" || identityEntry.customType !== CHILD_IDENTITY_ENTRY_TYPE ) { continue; } if (Value.Check(ChildSessionIdentityRecordSchema, identityEntry.data)) { return { identity: identityEntry.data, provenance }; } } return undefined; } return undefined; } function findCurrentForkOwnership( entries: ReturnType, cloneSessionId: string, ): ForkOwnershipRecord | undefined { return entries.findLast( ( entry, ): entry is Extract & { data: ForkOwnershipRecord; } => entry.type === "custom" && entry.customType === FORK_OWNERSHIP_ENTRY_TYPE && Value.Check(ForkOwnershipRecordSchema, entry.data) && entry.data.clone_session_id === cloneSessionId, )?.data; } function verifyForkCloneProvenance( branch: ReturnType, agent: PersistedAgent, sourceRootSessionId: string, ): ForkCloneProvenanceRecord { const provenance = findLatestForkGeneration(branch)?.provenance; if ( !provenance || provenance.source_root_session_id !== sourceRootSessionId || provenance.source_agent_id !== agent.agent_id ) { throw new Error( `Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`, ); } return provenance; } /** Verify that a persisted child session path belongs to the expected canonical agent and root. */ export function verifyChildSessionIdentity( sessionManager: SessionManager, agent: PersistedAgent, rootSessionId: string, ): void { if (sessionManager.getSessionId() !== agent.session_id) { throw new Error( `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`, ); } if (agent.session_leaf_id && !sessionManager.getEntry(agent.session_leaf_id)) { throw new Error(`Minimal subagents session identity mismatch: leaf for ${agent.agent_id}`); } const identityBranch = sessionManager.getBranch(agent.session_leaf_id); const generation = findLatestForkGeneration(identityBranch); const identity = generation?.identity ?? findLatestChildSessionRecord( identityBranch, CHILD_IDENTITY_ENTRY_TYPE, ChildSessionIdentityRecordSchema, ); if (!identity) { throw new Error(`Minimal subagents session identity missing for ${agent.agent_id}`); } if ( identity.canonical_agent_id !== agent.agent_id || identity.direct_parent_id !== agent.parent_id || identity.created_at !== agent.created_at ) { throw new Error(`Minimal subagents session identity mismatch: ownership for ${agent.agent_id}`); } const ownership = findCurrentForkOwnership(identityBranch, sessionManager.getSessionId()); if (ownership || identity.original_root_session_id !== rootSessionId) { const provenance = generation?.provenance; if ( !ownership || !provenance || ownership.destination_root_session_id !== rootSessionId || ownership.source_root_session_id !== identity.original_root_session_id || ownership.source_root_session_id !== provenance.source_root_session_id || ownership.source_agent_id !== agent.agent_id || ownership.source_agent_id !== provenance.source_agent_id || ownership.source_session_id !== provenance.source_session_id || ownership.clone_session_id !== sessionManager.getSessionId() || ownership.direct_parent_id !== agent.parent_id ) { throw new Error( `Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`, ); } } } /** Find durable keyed evidence for exactly-once wait or custom-result delivery. */ export function findDeliveryEvidence( entries: readonly SessionEntry[], sourceAgentId: string, sourceTurnId: string, deliveryId?: string, ): boolean { return entries.some((entry) => { let details: SessionEntry extends infer TEntry ? TEntry extends { details?: infer TDetails } ? TDetails : undefined : undefined; let waitToolResult = false; if ( entry.type === "custom_message" && (entry.customType === "minimal-subagents.result" || (deliveryId !== undefined && entry.customType === "minimal-subagents.message")) ) { details = entry.details; } else if ( entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "subagent_wait" ) { details = entry.message.details; waitToolResult = true; } else { return false; } if (!Value.Check(DeliveryEvidenceDetailsSchema, details)) return false; if (deliveryId !== undefined) { return ( details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId && (details.delivery_id === deliveryId || details.message_id === deliveryId || details.messages?.some( (message) => message.delivery_id === deliveryId || message.message_id === deliveryId, )) ); } if (waitToolResult && details.event === "message") return false; return details.source_agent_id === sourceAgentId && details.source_turn_id === sourceTurnId; }); } function sumUsage(messages: readonly AgentMessage[]): Usage | undefined { return messages.reduce( (total, message) => addMinimalSubagentsUsage(total, "usage" in message ? message.usage : undefined), undefined, ); } function assistantText(message: AgentMessage | undefined): string { if (!message || message.role !== "assistant") return ""; return message.content .filter((content) => content.type === "text") .map((content) => content.text) .join("\n"); } /** Collect finalized turn messages and reduce them to one runtime outcome. */ function collectChildTurnOutcome( messages: readonly AgentMessage[], aborted: boolean, ): RuntimeTurnOutcome { const finalAssistant = [...messages].reverse().find((message) => message.role === "assistant"); if (!finalAssistant || finalAssistant.role !== "assistant") { return { status: aborted ? "cancelled" : "failed", output: "", error: "No terminal assistant response", }; } if (aborted || finalAssistant.stopReason === "aborted") { return { status: "cancelled", output: assistantText(finalAssistant), error: finalAssistant.errorMessage, usage: sumUsage(messages), }; } if (finalAssistant.stopReason === "error") { return { status: "failed", output: assistantText(finalAssistant), error: finalAssistant.errorMessage ?? "Provider request failed", usage: sumUsage(messages), }; } return { status: "completed", output: assistantText(finalAssistant), usage: sumUsage(messages), }; } /** Run one child operation while retaining its finalized outcome across compaction. */ export async function captureChildTurnOutcome( session: Pick, operation: () => Promise, isAborted: () => boolean, ): Promise { const messages: AgentMessage[] = []; const unsubscribe = session.subscribe((event: AgentSessionEvent) => { if (event.type === "message_end") messages.push(event.message); }); try { await operation(); return collectChildTurnOutcome(messages, isAborted()); } catch (error) { return { status: isAborted() ? "cancelled" : "failed", output: "", error: error instanceof Error ? error.message : String(error), }; } finally { unsubscribe(); } } class PiChildAgentRuntime implements ChildAgentRuntime { private aborted = false; private readonly unsubscribe: () => void; private transcriptLeafId: string | null | undefined; private readonly transcriptEntries = new WeakMap(); private transcriptMessages: AgentMessage[] = []; private transcriptSources = new Set(); constructor( private readonly session: AgentSession, private readonly modelRuntime: ModelRuntime, private readonly modelById: ReadonlyMap>, onSessionActivity?: () => void, private readonly observer?: ChildSessionObserver, ) { // Keep this child-only; AgentSession.setSteeringMode would overwrite the user's global setting. session.agent.steeringMode = "all"; this.unsubscribe = session.subscribe((event) => { if (event.type !== "entry_appended") return; if ( event.entry.type === "custom_message" || (event.entry.type === "message" && event.entry.message.role === "toolResult") ) { onSessionActivity?.(); } }); } get sessionLeafId(): string | undefined { return this.session.sessionManager.getLeafId() ?? undefined; } get isRunning(): boolean { return this.session.isStreaming; } async runPrompt( task: string, compact: boolean, callerModel: string, callerThinkingLevel: ThinkingLevel, ): Promise { if (compact) await this.compactImportedContext(callerModel, callerThinkingLevel); return this.captureTurn(() => this.session.prompt(task, { expandPromptTemplates: false })); } runMessage(message: CoordinatorMessage): Promise { return this.captureTurn(() => this.session.sendCustomMessage( { customType: message.customType, content: message.content, display: true, details: message.details, }, { triggerTurn: true, deliverAs: "steer" }, ), ); } async queueCoordinatorMessage(message: CoordinatorMessage): Promise { this.session.agent.steer({ role: "custom", customType: message.customType, content: message.content, display: true, details: message.details, timestamp: Date.now(), }); } async abort(): Promise { this.aborted = true; await Promise.all([this.observer?.abort(), this.session.abort()]); } async dispose(): Promise { this.unsubscribe(); try { await this.observer?.dispose(); } finally { this.session.dispose(); } } getRuntimeProfile(): RuntimeProfile | undefined { const model = this.session.model; if (!model) return undefined; return { model: `${model.provider}/${model.id}`, thinking_level: this.session.thinkingLevel, }; } getActiveToolNames(): string[] { const coordinatorTools = new Set(COORDINATOR_TOOL_NAMES); return this.session.getActiveToolNames().filter((toolName) => !coordinatorTools.has(toolName)); } snapshotCommittedMessages(): AgentMessage[] { return snapshotCommittedContext(this.session.messages, this.session.isStreaming); } snapshotActivityMessages(): AgentMessage[] { const streamingMessage = this.session.state.streamingMessage; return [...this.session.messages, ...(streamingMessage ? [streamingMessage] : [])]; } snapshotActivityTranscript(): ChildAgentTranscriptSnapshot { const manager = this.session.sessionManager; const leafId = manager.getLeafId(); if (this.transcriptLeafId !== leafId) { this.transcriptSources = new Set(); this.transcriptMessages = manager.getBranch().flatMap((entry) => { const source = sessionEntryToContextMessages(entry); for (const message of source) this.transcriptSources.add(message); let messages = this.transcriptEntries.get(entry); if (!messages) { messages = selectChildAgentTranscript(source).messages; this.transcriptEntries.set(entry, messages); } return messages; }); this.transcriptLeafId = leafId; } const state = this.session.state; // Native message_end finalizes agent state before async extension handlers persist it. const pending = state.messages.filter( (message) => (message.role === "user" || message.role === "assistant" || message.role === "toolResult") && !this.transcriptSources.has(message), ); const streaming = state.streamingMessage; const tail = selectChildAgentTranscript( pending, streaming && !this.transcriptSources.has(streaming) ? streaming : undefined, ); const snapshot: ChildAgentTranscriptSnapshot = { messages: tail.messages.length ? [...this.transcriptMessages, ...tail.messages] : this.transcriptMessages, streamingAssistantIndex: tail.streamingAssistantIndex === undefined ? undefined : this.transcriptMessages.length + tail.streamingAssistantIndex, toolDefinitions: [], }; const toolNames = new Set(); for (const message of snapshot.messages) { if (message.role !== "assistant") continue; for (const content of message.content) { if (content.type === "toolCall") toolNames.add(content.name); } } return { ...snapshot, toolDefinitions: [...toolNames] .map((toolName) => this.session.getToolDefinition(toolName)) .filter((definition) => definition !== undefined), }; } hasDeliveryEvidence(sourceAgentId: string, sourceTurnId: string, deliveryId?: string): boolean { return findDeliveryEvidence( this.session.sessionManager.getBranch(), sourceAgentId, sourceTurnId, deliveryId, ); } getUsage(): Usage | undefined { return sumUsage(this.session.messages); } private async captureTurn(operation: () => Promise): Promise { this.aborted = false; this.observer?.beginTurn(); return captureChildTurnOutcome( this.session, async () => { try { await operation(); if (!this.aborted) await this.observer?.finishTurn(); } catch (error) { await this.observer?.abort(); throw error; } }, () => this.aborted, ); } private async compactImportedContext( callerModelId: string, thinkingLevel: ThinkingLevel, ): Promise { const contextEntries = this.session.sessionManager.buildContextEntries(); const cutPoint = findCutPoint( contextEntries, 0, contextEntries.length, this.session.settingsManager.getCompactionKeepRecentTokens(), ); const firstKeptEntryIndex = cutPoint.isSplitTurn && cutPoint.turnStartIndex >= 0 ? cutPoint.turnStartIndex : cutPoint.firstKeptEntryIndex; const firstKeptEntry = contextEntries[firstKeptEntryIndex]; if (!firstKeptEntry || firstKeptEntryIndex <= 0) return; const messagesToSummarize = contextEntries .slice(0, firstKeptEntryIndex) .flatMap((entry) => sessionEntryToContextMessages(entry)); if (messagesToSummarize.length === 0) return; const model = this.modelById.get(callerModelId); if (!model) throw new Error( `Minimal subagents compact context: unavailable caller model ${callerModelId}`, ); const auth = await this.modelRuntime.getAuth(model); if (!auth) throw new Error( `Minimal subagents compact context: authentication unavailable for ${callerModelId}`, ); const summary = await generateSummaryWithUsage( messagesToSummarize, model, this.session.settingsManager.getCompactionReserveTokens(), auth.auth.apiKey, auth.auth.headers ? Object.fromEntries( Object.entries(auth.auth.headers).filter( (entry): entry is [string, string] => entry[1] !== null, ), ) : undefined, undefined, undefined, undefined, thinkingLevel, (streamModel, context, options) => this.modelRuntime.streamSimple(streamModel, context, options), auth.env, { enabled: false, maxRetries: 0, baseDelayMs: 0 }, ); const tokensBefore = contextEntries .flatMap((entry) => sessionEntryToContextMessages(entry)) .reduce((total, message) => total + estimateTokens(message), 0); this.session.sessionManager.appendCompaction( summary.text, firstKeptEntry.id, tokensBefore, { source: "minimal-subagents", caller_model: callerModelId }, false, summary.usage, ); this.session.agent.state.messages = this.session.sessionManager.buildSessionContext().messages; } } /** Production Pi SDK session factory used by the process-local coordinator. */ export class PiAgentSessionFactory implements AgentSessionFactory { private readonly modelById: Map>; private readonly eligibleModelIds: Set; private readonly availableToolNames: Set; private readonly discoveredToolNames = new Map>>(); private readonly sessionFileTrash: SessionFileTrashCapability; private savedTranscript?: { key: string; snapshot: ChildAgentTranscriptSnapshot }; constructor(private readonly options: PiAgentSessionFactoryOptions) { this.modelById = new Map( options.models.map((model) => [`${model.provider}/${model.id}`, model]), ); this.eligibleModelIds = new Set(options.eligibleModelIds); this.availableToolNames = new Set(options.availableToolNames); this.sessionFileTrash = options.sessionFileTrash ?? execFileSessionTrashCapability; } createIdentity( agent: PersistedAgent, importedMessages: AgentMessage[], ): PersistedSessionIdentity { return createPersistentChildIdentity({ agent, importedMessages, cwd: this.options.cwd, sessionDir: this.options.sessionDir, rootSessionId: this.options.rootSessionId, }); } /** Read one verified saved Child Session Position without opening a writable runtime. */ readTranscript(agent: PersistedAgent): ChildAgentTranscriptSnapshot { if (!agent.session_file || !agent.session_id || !agent.session_leaf_id) { throw new Error(`Child Session Position is unavailable for ${agent.agent_id}.`); } const sessionFile = canonicalPath(agent.session_file); const stat = statSync(sessionFile); const key = JSON.stringify([ sessionFile, agent.agent_id, agent.parent_id, agent.created_at, agent.session_id, agent.session_leaf_id, stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, ]); if (this.savedTranscript?.key === key) return this.savedTranscript.snapshot; const manager = this.readSession(agent); const snapshot = selectChildAgentTranscript( manager.getBranch(agent.session_leaf_id).flatMap(sessionEntryToContextMessages), ); this.savedTranscript = { key, snapshot }; return snapshot; } /** Inspect durable evidence without starting child extensions or changing the saved branch. */ hasDeliveryEvidence( agent: PersistedAgent, sourceAgentId: string, sourceTurnId: string, deliveryId?: string, ): boolean { return findDeliveryEvidence( this.readSession(agent).getBranch(agent.session_leaf_id), sourceAgentId, sourceTurnId, deliveryId, ); } private readSession(agent: PersistedAgent): SessionManager { if (!agent.session_file || !agent.session_id || !agent.session_leaf_id) { throw new Error(`Child Session Position is unavailable for ${agent.agent_id}.`); } const sessionFile = canonicalPath(agent.session_file); const entries = parseSessionEntries(readFileSync(sessionFile, "utf8")); if (entries[0]?.type !== "session") throw new Error(`Invalid child session file: ${sessionFile}`); // SessionManager.open can migrate/rewrite files; an in-memory reader cannot write them. const manager = SessionManager.inMemory(this.options.cwd, undefined, entries); verifyChildSessionIdentity(manager, agent, this.options.rootSessionId); return manager; } resolveLaunchMissingDependencies(agent: PersistedAgent): Promise { return this.findMissingDependencies(agent, false); } async resolveRestorationMissingDependencies(agent: PersistedAgent): Promise { this.readSession(agent); return this.findMissingDependencies(agent, this.options.modelScopeRestricted); } resolveThinkingLevel(modelId: string, requested: ThinkingLevel): ThinkingLevel { const model = this.modelById.get(modelId); if (!model) return requested; return clampThinkingLevel(model, requested); } modelSupportsImages(modelId: string): boolean { return this.modelById.get(modelId)?.input.includes("image") ?? false; } /** Clone one source-owned child leaf with explicit source-root provenance. */ async cloneSession(agent: PersistedAgent): Promise { return this.cloneSessionOwnedByRoot(agent, this.options.rootSessionId); } /** Recover one proven selected child leaf after the source process handoff was lost. */ async cloneForkSourceSession( agent: PersistedAgent, sourceRootSessionId: string, ): Promise { return this.cloneSessionOwnedByRoot(agent, sourceRootSessionId); } /** Bind one verified fork clone exclusively to this factory's destination root. */ async adoptForkSessionOwnership( agent: PersistedAgent, sourceRootSessionId: string, ): Promise { if (!agent.session_file || !agent.session_id) { throw new Error(`Minimal subagents fork ownership: ${agent.agent_id} has no clone session`); } const sessionFile = canonicalPath(agent.session_file); const sessionManager = SessionManager.open( sessionFile, this.options.sessionDir, this.options.cwd, ); if (sessionManager.getSessionId() !== agent.session_id) { throw new Error( `Minimal subagents session identity mismatch: session ID for ${agent.agent_id}`, ); } const branch = sessionManager.getBranch(agent.session_leaf_id); const generation = findLatestForkGeneration(branch); const identity = generation?.identity ?? findLatestChildSessionRecord( branch, CHILD_IDENTITY_ENTRY_TYPE, ChildSessionIdentityRecordSchema, ); if ( !identity || identity.original_root_session_id !== sourceRootSessionId || identity.canonical_agent_id !== agent.agent_id || identity.direct_parent_id !== agent.parent_id || identity.created_at !== agent.created_at ) { throw new Error( `Minimal subagents session identity mismatch: fork provenance for ${agent.agent_id}`, ); } const provenance = verifyForkCloneProvenance(branch, agent, sourceRootSessionId); const existingOwnership = findCurrentForkOwnership(branch, sessionManager.getSessionId()); if (existingOwnership) { if ( existingOwnership.source_root_session_id !== sourceRootSessionId || existingOwnership.destination_root_session_id !== this.options.rootSessionId || existingOwnership.source_agent_id !== agent.agent_id || existingOwnership.source_session_id !== provenance.source_session_id || existingOwnership.direct_parent_id !== agent.parent_id ) { throw new Error( `Minimal subagents session identity mismatch: root owner for ${agent.agent_id}`, ); } } else { sessionManager.appendCustomEntry(FORK_OWNERSHIP_ENTRY_TYPE, { version: 1, source_root_session_id: sourceRootSessionId, destination_root_session_id: this.options.rootSessionId, source_agent_id: agent.agent_id, source_session_id: provenance.source_session_id, clone_session_id: sessionManager.getSessionId(), direct_parent_id: agent.parent_id, }); } return { sessionFile, sessionId: sessionManager.getSessionId(), sessionLeafId: sessionManager.getLeafId() ?? undefined, }; } async trashSession(agent: PersistedAgent): Promise { if (!agent.session_file) return; const sessionFile = canonicalPath(agent.session_file); const sessionManager = SessionManager.open( sessionFile, this.options.sessionDir, this.options.cwd, ); verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId); const trashError = await this.sessionFileTrash.moveSessionFile(sessionFile); if (!trashError || !existsSync(sessionFile)) return; try { await unlink(sessionFile); } catch (error) { const unlinkError = error instanceof Error ? error.message : String(error); throw new Error( `Minimal subagents session deletion failed for ${sessionFile}: ${unlinkError} (trash: ${trashError.message})`, ); } } private async cloneSessionOwnedByRoot( agent: PersistedAgent, sourceRootSessionId: string, ): Promise { if (!agent.session_file || !agent.session_id) { throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no source session`); } const source = SessionManager.open( canonicalPath(agent.session_file), this.options.sessionDir, this.options.cwd, ); verifyChildSessionIdentity(source, agent, sourceRootSessionId); const leafId = agent.session_leaf_id ?? source.getLeafId(); if (!leafId || !source.getEntry(leafId)) throw new Error(`Minimal subagents fork clone: ${agent.agent_id} has no child leaf`); const sessionFile = source.createBranchedSession(leafId); if (!sessionFile) throw new Error(`Minimal subagents fork clone: ${agent.agent_id} is not persistent`); // createBranchedSession mutates this manager to the new session even when Pi defers // writing an identity-only branch until its first assistant response. const clone = source; clone.appendCustomEntry(CHILD_IDENTITY_ENTRY_TYPE, { version: 1, original_root_session_id: sourceRootSessionId, canonical_agent_id: agent.agent_id, direct_parent_id: agent.parent_id, created_at: agent.created_at, }); clone.appendCustomEntry(FORK_CLONE_ENTRY_TYPE, { version: 1, source_root_session_id: sourceRootSessionId, source_agent_id: agent.agent_id, source_session_id: agent.session_id, }); if (!existsSync(sessionFile)) { const lines = [clone.getHeader(), ...clone.getEntries()] .filter((entry) => entry !== null) .map((entry) => JSON.stringify(entry)) .join("\n"); writeFileSync(sessionFile, `${lines}\n`, "utf8"); } if (!existsSync(sessionFile)) { throw new Error(`Minimal subagents fork clone: clone was not flushed for ${agent.agent_id}`); } return { sessionFile, sessionId: clone.getSessionId(), sessionLeafId: clone.getLeafId() ?? undefined, }; } private buildChildSystemPrompt(agent: PersistedAgent): string { return buildDepthBoundSubagentPrompt( agent, this.options.maxSubagentDepth ?? DEFAULT_MAX_SUBAGENT_DEPTH, ); } private async findMissingDependencies( agent: PersistedAgent, requireEligibleModel: boolean, ): Promise { const missing: string[] = []; if (!this.modelById.has(agent.launch_contract.model)) missing.push(agent.launch_contract.model); else if (requireEligibleModel && !this.eligibleModelIds.has(agent.launch_contract.model)) { missing.push(agent.launch_contract.model); } const discoveredTools = await this.discoverChildToolNames(agent); for (const toolName of agent.launch_contract.ordinary_tools) { if (!discoveredTools.has(toolName)) missing.push(toolName); } return [...new Set(missing)]; } private discoverChildToolNames(agent: PersistedAgent): Promise> { const cacheKey = [...agent.launch_contract.ordinary_tools].sort().join(","); const cached = this.discoveredToolNames.get(cacheKey); if (cached) return cached; const discovery = (async () => { const names = new Set( [...PI_BUILTIN_ORDINARY_TOOL_NAMES].filter((name) => this.availableToolNames.has(name)), ); const requiresCustomToolDiscovery = agent.launch_contract.ordinary_tools.some( (name) => !PI_BUILTIN_ORDINARY_TOOL_NAMES.has(name), ); if (!requiresCustomToolDiscovery) return names; const settingsManager = this.createChildSettingsManager(); const resourceLoader = createChildResourceLoader({ cwd: this.options.cwd, agentDir: this.options.agentDir, projectContext: agent.launch_contract.project_context, extensionEntrypoint: this.options.extensionEntrypoint, systemPromptBlock: this.buildChildSystemPrompt(agent), settingsManager, }); await resourceLoader.reload(); for (const extension of resourceLoader.getExtensions().extensions) { for (const toolName of extension.tools.keys()) names.add(toolName); } return names; })(); this.discoveredToolNames.set(cacheKey, discovery); return discovery; } /** Open one verified persisted Child Agent runtime for launch or restoration. */ async openRuntime(agent: PersistedAgent): Promise { if (!agent.session_file) throw new Error(`Minimal subagents restore: ${agent.agent_id} has no session file`); const model = this.modelById.get(agent.launch_contract.model); if (!model) throw new Error( `Minimal subagents restore: model unavailable: ${agent.launch_contract.model}`, ); const settingsManager = this.createChildSettingsManager(); settingsManager.applyOverrides({ retry: { enabled: false, maxRetries: 0, provider: { maxRetries: 0 } }, }); const resourceLoader = createChildResourceLoader({ cwd: this.options.cwd, agentDir: this.options.agentDir, projectContext: agent.launch_contract.project_context, extensionEntrypoint: this.options.extensionEntrypoint, systemPromptBlock: this.buildChildSystemPrompt(agent), settingsManager, }); await resourceLoader.reload(); const modelRuntime = await ModelRuntime.create({ authPath: resolve(this.options.agentDir, "auth.json"), modelsPath: resolve(this.options.agentDir, "models.json"), }); const sessionManager = SessionManager.open( canonicalPath(agent.session_file), this.options.sessionDir, this.options.cwd, ); verifyChildSessionIdentity(sessionManager, agent, this.options.rootSessionId); if (agent.session_leaf_id) sessionManager.branch(agent.session_leaf_id); const coordinatorTools = this.options.getCoordinatorTools(agent.agent_id); const allowedToolNames = [ ...agent.launch_contract.ordinary_tools, ...coordinatorTools.map((tool) => tool.name), ]; const runtimeToolAdapters = this.options.getRuntimeToolAdapters?.() ?? []; const adapterToolNames = new Set(runtimeToolAdapters.flatMap((adapter) => adapter.toolNames)); const adaptRuntimeTools = adapterToolNames.size > 0; const { session } = await createAgentSession({ cwd: this.options.cwd, agentDir: this.options.agentDir, model, thinkingLevel: agent.launch_contract.thinking_level, tools: [...allowedToolNames, ...adapterToolNames], customTools: coordinatorTools, resourceLoader, sessionManager, settingsManager, modelRuntime, }); if (adaptRuntimeTools) session.setActiveToolsByName(allowedToolNames); const initialActiveNames = new Set(session.getActiveToolNames()); const missingTools = allowedToolNames.filter((toolName) => !initialActiveNames.has(toolName)); if (missingTools.length > 0) { session.dispose(); throw new Error(`Minimal subagents child tool loading failed: ${missingTools.join(", ")}`); } // The inner policy bounds extension-selected exposure without restoring hidden ordinary tools. installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters, false); await session.bindExtensions({ mode: "print" }); // The outer policy filters names before extension wrappers build their own tool catalogues. installChildToolCapabilityPolicy(session, allowedToolNames, runtimeToolAdapters); // Exposure policy may route granted Coordinator Tools through another tool; // require their definitions to remain registered, not necessarily direct. const registeredNames = new Set(session.getAllTools().map((tool) => tool.name)); const missingCoordinatorTools = coordinatorTools .map((tool) => tool.name) .filter((toolName) => !registeredNames.has(toolName)); if (missingCoordinatorTools.length > 0) { session.dispose(); throw new Error( `Minimal subagents child coordinator tool loading failed: ${missingCoordinatorTools.join(", ")}`, ); } return new PiChildAgentRuntime( session, modelRuntime, this.modelById, this.options.onChildSessionActivity, this.options.observeSession?.(session, agent.agent_id, { agentDir: this.options.agentDir, extensions: resourceLoader .getExtensions() .extensions.map(({ path, resolvedPath, sourceInfo, hidden }) => ({ path, resolvedPath, sourceInfo, hidden, })), flagValues: new Map(resourceLoader.getExtensions().runtime.flagValues), }), ); } private createChildSettingsManager(): SettingsManager { return SettingsManager.create(this.options.cwd, this.options.agentDir, { projectTrusted: this.options.projectTrusted, }); } }