/** * runtime.ts — SubagentRuntime: composition root for all mutable extension state. * * Eliminates module-scope state in agent-runner.ts and closure-scoped state * in index.ts by consolidating them into a single, testable object. * Follows the same pattern as pi-permission-system's ExtensionRuntime. */ import { defined } from "#src/optional"; import { buildParentSnapshot, type ParentPromptOptions, type ParentSnapshot, } from "#src/lifecycle/parent-snapshot"; import { buildParentContext } from "#src/session/context"; import type { RoutingLadders } from "#src/models/router"; import type { ModelInfo } from "#src/tools/spawn-config"; import type { SessionContext } from "#src/types"; /** * The settings a run reads live, rather than the ones it captured. * * Only an operational control belongs here: the turn ceiling and wrap-up * allowance are resolved into a `TurnPolicy` when the launch is prepared, so a * change to them reaches only the next launch. */ export type RunConfig = Readonly<{ /** Whether a background child gets the `notify_parent` channel. */ midRunUpdates: boolean; }>; /** * All mutable state owned by the pi-subagents extension. * * Created once inside `piSubagentsExtension()` via `createSubagentRuntime()`. * Tests construct a fresh runtime per test for full isolation. */ export class SubagentRuntime { // ── Session state (was closure-scoped in index.ts) ─────────────────────── /** Active Pi session context — set on session_start, cleared on session_shutdown. */ currentCtx: SessionContext | undefined = undefined; /** * The names of the tools the parent session has active. * * Read from Pi's extension API when the request is made, so a definition * that inherits its tools takes the parent's current selection rather than * a configured list. */ private listActiveTools: () => string[]; /** Every registered parent tool name, captured for MCP wildcard expansion. */ private listRegisteredTools: () => string[]; /** * The routing ladders the automatic model selection walks. */ private readonly ladders: RoutingLadders; /** * Prompt options Pi assembled for the parent's latest turn, captured from * `before_agent_start`. Undefined until the parent has run one. */ private lastPromptOptions: ParentPromptOptions | undefined = undefined; constructor( listActiveTools: () => string[] = () => [], ladders: RoutingLadders = { profiles: new Map(), strategies: new Map() }, listRegisteredTools: () => string[] = () => [], ) { this.listActiveTools = listActiveTools; this.listRegisteredTools = listRegisteredTools; this.ladders = ladders; } // ── Session-context methods ────────────────────────────────────────────── /** Store the active Pi session context (called from session_start). */ setSessionContext(ctx: SessionContext): void { this.currentCtx = ctx; } /** Clear the session context (called from session_shutdown). */ clearSessionContext(): void { this.currentCtx = undefined; } /** * Record the prompt options Pi assembled for the parent's latest turn. * * Captured from `before_agent_start`, which is the only event carrying them: * `getSystemPromptOptions()` is attached to a command context, not to the * session context this runtime holds. */ setSystemPromptOptions(options: ParentPromptOptions): void { this.lastPromptOptions = options; } /** * Build a parent snapshot from the current session context. * Only valid during an active session (currentCtx is defined). */ buildSnapshot(inheritContext: boolean): ParentSnapshot { return buildParentSnapshot( this.currentCtx!, inheritContext, this.lastPromptOptions, this.listActiveTools(), this.listRegisteredTools(), ); } /** * Capture every parent input a launch needs before its first asynchronous * step (ARCHITECTURE-SPEC stories 15 and 16). * * This is the one capture operation: the snapshot, the conversation text, * and the session identity are taken together, so no caller can read * inconsistent slices of parent state. The snapshot excludes the * conversation text, which is the one input whose need depends on the * resolved definition; it is captured here too, so a later parent turn * cannot change what an accepted child inherits. The caller attaches it * when the resolved definition asks for it. */ captureLaunchInputs(): { snapshot: ParentSnapshot; parentContext: string | undefined; parentSession: { parentSessionFile: string; parentSessionId: string }; } { const ctx = this.currentCtx!; return { snapshot: buildParentSnapshot( ctx, false, this.lastPromptOptions, this.listActiveTools(), this.listRegisteredTools(), ), parentContext: buildParentContext(ctx) || undefined, parentSession: this.getSessionInfo(), }; } /** Extract model info from the current session context. */ getModelInfo(): ModelInfo { const registry = this.currentCtx?.modelRegistry; return defined({ // The routing ladders the model selection walks. ladders: this.ladders, parentModel: this.currentCtx?.model, modelRegistry: registry, // The effort the parent is running at: the reasoning fallback needs it, // because a child that keeps the parent's model must not work harder // than the parent did. parentThinkingLevel: this.currentCtx?.thinkingLevel, }); } /** Extract session identity from the current session context. */ getSessionInfo(): { parentSessionFile: string; parentSessionId: string } { return { parentSessionFile: this.currentCtx?.sessionManager.getSessionFile() ?? "", parentSessionId: this.currentCtx?.sessionManager.getSessionId() ?? "", }; } } /** * Create a fully-initialized SubagentRuntime with default values. * * Call once at extension startup; pass the result to factories and handlers. */ export function createSubagentRuntime( listActiveTools: () => string[] = () => [], ladders: RoutingLadders = { profiles: new Map(), strategies: new Map() }, listRegisteredTools: () => string[] = () => [], ): SubagentRuntime { return new SubagentRuntime(listActiveTools, ladders, listRegisteredTools); }