// Parent runtime: the parent-side orchestration state constructed once per // parent runtime and preserved across /reload (Supported reload boundary). // Owns the Running subagent registry, the Completion delivery boundary, the // status supervisor, the Subagent launcher wiring, Completion watch arc // collaborators, live presentation, and the reload reset policy. Tool // registration and message rendering stay declarative shell concerns. // See CONTEXT.md "Subagent orchestration" and "Supported reload boundary". import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { sendHerdrAgentEscape } from "./herdr.ts"; import { createOperationArtifactsAllocator, type OperationArtifactsAllocator, } from "./operation-artifacts.ts"; import { createOperationReference, createParentRuntimeId, getProcessParentRuntimeId, type ParentRuntimeId, } from "./operation-identity.ts"; import { createNativePiStartupAdapter } from "./native-startup.ts"; import { publishCancellationRequest } from "./cancellation-sidecar.ts"; import { CompletionDeliveryManager, CompletionDeliverySuppressedError, } from "./completion-delivery.ts"; import { CompletionOperationError, createCompletionOperationPreparer, type CompletionOperationPreparer, type OperationLaunchScope, } from "./completion-operation.ts"; import { buildAuthenticatedModelCatalog, wrapPiModelRegistry, } from "./runtime-routing.ts"; import { createLaunchPolicy, type LaunchPolicyResolver, } from "./launch-policy.ts"; import { createAgentProfileAdmission, type AgentProfileAdmission } from "./admission.ts"; import { createSubagentLauncher, type SubagentLauncher } from "./launcher.ts"; import { createRunningRegistry, ensureLifecycle, interruptAndCloseSubagent, noteExternalCancellation, observeRunningSubagent, operationForRunningSubagent, requestSubagentCancellation, requestUserCancellation, type RunningRegistry, type RunningRegistryTargetResolution, type RunningSubagent, } from "./running-registry.ts"; import { createSubagentStatusSupervisor } from "./status-supervisor.ts"; import { hasPendingDescendantContinuations, noteParentApi, queueDescendantContinuation, rejectPendingDescendantContinuations, rescheduleFlushAfterReload, sendSteerDirect, } from "./descendant-continuation.ts"; import { getNestedLifecycleCoordinator, hasNestedLifecycleContext, resetNestedLifecycleCoordinator, type NestedLifecycleCoordinator, } from "./nested-lifecycle.ts"; import { cleanupSubagentsForShutdown } from "./parent-shutdown.ts"; import { loadStatusConfig, type StatusConfig } from "./status.ts"; import { startBackgroundCompletionWatch, waitForForegroundCompletion, type CompletionWatchRuntime, } from "./completion-watch.ts"; import { buildForegroundSubagentResult, failedCompletionHandoff, type CompletionHandoffResult, type SubagentToolResult, } from "./completion-handoff.ts"; import { renderSubagentWidgetLines } from "./presentation.ts"; import { retainAcrossReload } from "./reload-boundary.ts"; import { projectLifecycle } from "./lifecycle.ts"; /** Collaborators a fresh parent runtime is built from; tests inject fakes. */ export interface ParentRuntimeOptions { /** Bound at construction; /reload rebinds through getSharedParentRuntime. */ pi?: ExtensionAPI; /** Test seam; production allocates a process-local identity once. */ parentRuntimeId?: ParentRuntimeId; /** Test seam: pre-built resolver instead of loading real model config. */ launchPolicy?: LaunchPolicyResolver; /** Test seam for deterministic operation-namespace allocation. */ operationArtifacts?: OperationArtifactsAllocator; /** Test seam: status config instead of reading the JSON file. */ statusConfig?: StatusConfig; } export interface ParentRuntime { /** Process-local identity; it is stable across /reload and never persisted. */ readonly parentRuntimeId: ParentRuntimeId; /** Agent profile discovery for tools and commands. */ readonly launchPolicy: LaunchPolicyResolver; /** Direct self-spawn classification for the subagent tool. */ readonly agentProfileAdmission: AgentProfileAdmission; readonly runningRegistry: RunningRegistry; readonly completionDeliveryManager: CompletionDeliveryManager; readonly subagentLauncher: SubagentLauncher; /** Rebind the current parent API; called again after every /reload. */ bindPi(pi: ExtensionAPI): void; /** Completion operation preparation, independent of any Pi session file. */ getCompletionOperationPreparer(ctx: { cwd: string }): CompletionOperationPreparer; /** Run one Foreground subagent call to its tool result. */ watchForeground(running: RunningSubagent, signal: AbortSignal): Promise; /** Start one Background subagent call; the result arrives as a steer. */ watchBackground(running: RunningSubagent): void; /** Cancel one adopted descendant without waiting on another delivery. */ cancelManagedSubagent(running: RunningSubagent): void; /** Translate a synchronously rejected Native startup into a failed tool result. */ failedLaunchToolResult( params: { name: string; task: string; agent: string }, error: unknown, startedAt: number, ): SubagentToolResult | undefined; /** Resolve a cancel target and request user-requested lifecycle cancellation. */ handleSubagentCancel( params: { id?: string; name?: string }, interruptPaneKey?: (surface: string) => void, ): unknown; /** Report a lost delivery as parent-side status without retrying. */ reportCompletionDeliveryFailure( running: RunningSubagent, result: CompletionHandoffResult, error: string, ): void; /** Begin live supervision and widget refresh when work is in flight. */ startLiveSupervision(): void; /** * Session-start boundary: apply the Supported-reload-boundary reset policy, * reopen the delivery boundary, refresh the model catalog, and resume live * presentation. Returns the authenticated model catalog for prompt text. */ sessionStarting(ctx: ExtensionContext): string; /** Session-shutdown boundary: permanent shutdown cascades cancellation. */ sessionShuttingDown(reason: unknown): Promise; } /** Shared instance across extension module executions within one process. */ const SHARED_RUNTIME_KEY = Symbol.for("pi-subagents/runtime"); /** * Return the process-shared parent runtime, creating it on first call and * rebinding the current parent API afterwards (/reload instantiates a fresh * extension module against the same runtime). */ export function getSharedParentRuntime(pi: ExtensionAPI): ParentRuntime { const runtime = retainAcrossReload( SHARED_RUNTIME_KEY, () => createParentRuntime({ pi, parentRuntimeId: getProcessParentRuntimeId() }), ); runtime.bindPi(pi); return runtime; } function nestedDescendantForScope(scope: OperationLaunchScope) { return createOperationReference(scope.operationId, scope.artifacts); } function nestedDescendantForRunning(running: Pick) { return operationForRunningSubagent(running); } /** Turn a registry resolution into the tool-facing cancellation error message. */ function cancelResolutionError( params: { id?: string; name?: string }, resolution: Exclude, ): string { switch (resolution.kind) { case "missing-target": return "Provide a running subagent id or exact display name."; case "not-found": { const requestedId = params.id?.trim(); return requestedId ? `No running subagent with id "${requestedId}".` : `No running subagent named "${params.name?.trim()}".`; } case "ambiguous": { const requestedId = params.id?.trim(); if (requestedId) return `Ambiguous subagent id "${requestedId}".`; const candidates = resolution.matches .map((running) => `${running.name} [${running.id}]`) .join(", "); return `Ambiguous subagent name "${params.name?.trim()}". Matches: ${candidates}`; } } } class ParentRuntimeImpl implements ParentRuntime { pi: ExtensionAPI | undefined; readonly parentRuntimeId: ParentRuntimeId; readonly launchPolicy: LaunchPolicyResolver; readonly statusConfig: StatusConfig; readonly agentProfileAdmission: AgentProfileAdmission; readonly runningRegistry: RunningRegistry; readonly completionDeliveryManager: CompletionDeliveryManager; readonly subagentLauncher: SubagentLauncher; readonly nativeStartupAdapter = createNativePiStartupAdapter(); statusSupervisor: ReturnType; private completionOperationPreparer?: CompletionOperationPreparer; private latestCtx?: ExtensionContext; private modelCatalog?: string; private lastShutdownReason?: unknown; private widgetInterval: ReturnType | null = null; private watchRuntime?: CompletionWatchRuntime; private readonly operationArtifacts: OperationArtifactsAllocator; constructor(options: ParentRuntimeOptions = {}) { this.pi = options.pi; this.parentRuntimeId = options.parentRuntimeId ?? createParentRuntimeId(); this.operationArtifacts = options.operationArtifacts ?? createOperationArtifactsAllocator(); this.launchPolicy = options.launchPolicy ?? createLaunchPolicy(); this.statusConfig = options.statusConfig ?? loadStatusConfig(); // Running subagent registry: created once per parent runtime and the sole // writer of record runtime state. this.runningRegistry = createRunningRegistry(); this.completionDeliveryManager = new CompletionDeliveryManager(); // Subagent launcher owns Fresh coordination up to RunningSubagent adoption; // the registry crosses the seam as a `register` callback. const runningRegistry = this.runningRegistry; this.subagentLauncher = createSubagentLauncher({ launchPolicy: this.launchPolicy, reserveDescendant: (scope) => { const descendant = nestedDescendantForScope(scope); this.nestedLifecycle().reserveDescendant(descendant, () => { try { publishCancellationRequest(descendant); } catch { // Startup/adoption cleanup remains authoritative if the request // file cannot be written before Native startup accepts the Child. } }); }, releaseDescendant: (scope) => { this.nestedLifecycle().releaseDescendant(nestedDescendantForScope(scope)); }, register: (running) => { const coordinator = this.nestedLifecycle(); const adopted = coordinator.adoptDescendant( nestedDescendantForRunning(running), () => this.cancelManagedSubagent(running), ); if (!adopted) { // A cancellation cutover may win while Native startup is being // adopted. Do not expose the late surface as a live registry entry. // Completion-operation owns cleanup for this pre-watch startup path; // marking the record here prevents a second native close. noteExternalCancellation(running); throw new Error("Nested Subagent startup was cancelled before adoption."); } runningRegistry.register(running); }, }); this.agentProfileAdmission = createAgentProfileAdmission(); // Subagent status supervision owns the lifecycle transition loop; widget // rendering is requested via the onRefresh hook. this.statusSupervisor = createSubagentStatusSupervisor({ snapshot: () => this.runningRegistry.values(), parentSnapshot: () => this.nestedLifecycle().snapshot(), observe: observeRunningSubagent, onTransition: (payload) => this.pi?.sendMessage(payload, { triggerTurn: payload.details.source !== "parent", deliverAs: "steer", }), onRefresh: () => this.updateWidget(), }); } private nestedLifecycle(): NestedLifecycleCoordinator { return getNestedLifecycleCoordinator(this.parentRuntimeId); } bindPi(pi: ExtensionAPI): void { this.pi = pi; noteParentApi(pi, this.parentRuntimeId); } getCompletionOperationPreparer(_ctx: { cwd: string }): CompletionOperationPreparer { // Operation artifacts are deliberately independent of the parent Pi // session. This keeps the same preparation seam valid for --no-session // parents and prevents child state from entering Pi's session catalogue. if (!this.completionOperationPreparer) { this.completionOperationPreparer = createCompletionOperationPreparer({ native: this.nativeStartupAdapter, operationArtifacts: this.operationArtifacts, }); } return this.completionOperationPreparer; } /** Parent-runtime collaborators wired into the Completion watch arc, once. */ private getWatchRuntime(): CompletionWatchRuntime { if (!this.watchRuntime) { this.watchRuntime = { manager: this.completionDeliveryManager, onLiveStateChange: () => this.updateWidget(), admitHandoff: (running, result) => this.nestedLifecycle().admitDescendantResult( nestedDescendantForRunning(running), { descendant: nestedDescendantForRunning(running), outcome: result }, ), settleDelivery: (running, request, consumed) => { this.nestedLifecycle().settleDescendantDelivery( nestedDescendantForRunning(running), request, consumed, ); }, onDeliveryFailure: (running, result, error) => { this.reportCompletionDeliveryFailure(running, result, error); }, release: (running) => { try { this.nestedLifecycle().completeDescendant(nestedDescendantForRunning(running)); } catch { // A failed deferred sidecar write remains tracked by the child-side // coordinator; parent cleanup must still release this watch record. } this.runningRegistry.remove(running); try { running.artifacts?.release(); } catch { // Artifact cleanup is best effort after the Completion handoff. } this.updateWidget(); }, }; } return this.watchRuntime; } async watchForeground(running: RunningSubagent, signal: AbortSignal): Promise { return waitForForegroundCompletion( running, { kind: "foreground", signal }, this.getWatchRuntime(), ); } watchBackground(running: RunningSubagent): void { const pi = this.pi; if (!pi) throw new Error("Background watching requires a bound parent API."); startBackgroundCompletionWatch( running, { kind: "background", steer: hasNestedLifecycleContext() ? (payload) => queueDescendantContinuation(pi, payload, this.nestedLifecycle()) : (payload) => sendSteerDirect(pi, payload, this.parentRuntimeId), }, this.getWatchRuntime(), ); } /** * Cancel one adopted descendant without leaving its parent waiting on another * delivery attempt. Child-side cancellation still owns supervision and drain; * suppressing the local delivery state only settles a result that has already * reached this parent. */ cancelManagedSubagent(running: RunningSubagent): void { requestSubagentCancellation(running); this.completionDeliveryManager.suppress(operationForRunningSubagent(running)); } /** Report a lost delivery without changing the Child Completion outcome. */ reportCompletionDeliveryFailure( running: RunningSubagent, result: CompletionHandoffResult, error: string, ): void { const childStatus = result.exitCode === 0 ? "completed" : result.failureCategory === "child-reported-error" ? "failed" : "unknown"; const childOutcome = childStatus === "unknown" ? "The Child outcome was not established" : `The Child ${childStatus}`; const line = `Completion delivery failed for subagent "${running.name}": ${error}. ` + `${childOutcome}, but the parent did not accept its Completion result; ` + "the delivery will not be retried automatically."; const payload = { customType: "subagent_status" as const, content: `Subagent status:\n• ${line}`, display: true as const, details: { lines: [line], overflow: 0, source: "subagent" as const, deliveryError: error, childStatus, ...(result.failureCategory ? { completionFailureCategory: result.failureCategory } : {}), operation: { operationId: running.id, }, }, }; try { this.pi?.sendMessage(payload, { triggerTurn: true, deliverAs: "steer", }); } catch { // There is no second Completion delivery attempt. Reporting is best effort // after the destination has already rejected the one-shot handoff. } } /** * Turn a synchronously rejected Native startup into the same independent * failed result a watched child would produce. There is no RunningSubagent * to watch or release here: the Completion operation preparer has already * released its launch reservation, and the tool call itself is the direct * parent's handoff point. */ failedLaunchToolResult( params: { name: string; task: string; agent: string; }, error: unknown, startedAt: number, ): SubagentToolResult | undefined { if (!(error instanceof CompletionOperationError)) return undefined; // Fresh-only startup and adoption failures are terminal for this attempt. // The preparer has already cleaned temporary metadata and closed any // uncertain Native surface before returning the structured error. if (error.phase !== "native") return undefined; if (!error.operationId) return undefined; const context = { operationId: error.operationId, name: params.name, task: params.task, agent: params.agent.trim(), startedAt, }; const result = failedCompletionHandoff( context, { errorMessage: error.message, failureCategory: "supervision-error", }, Math.max(0, Math.floor((Date.now() - startedAt) / 1000)), ); return buildForegroundSubagentResult(context, result); } handleSubagentCancel( params: { id?: string; name?: string }, interruptPaneKey: (surface: string) => void = sendHerdrAgentEscape, ) { const resolution = this.runningRegistry.resolveTarget(params); if (resolution.kind !== "resolved") { const error = cancelResolutionError(params, resolution); return { content: [{ type: "text" as const, text: error }], details: { error }, }; } const running = resolution.running; const result = requestUserCancellation(running, interruptPaneKey); this.updateWidget(); if (result.kind === "finalizing") { const error = `Subagent "${running.name}" is already finalizing its result; cancellation was not requested.`; return { content: [{ type: "text" as const, text: error }], details: { error, id: running.id, name: running.name }, }; } if (result.kind === "already-cancelling") { return { content: [{ type: "text" as const, text: `Cancellation is already in progress for subagent "${running.name}".` }], details: { id: running.id, name: running.name, status: "cancel_requested" }, }; } return { content: [{ type: "text" as const, text: `Cancel requested for subagent "${running.name}". Its herdr pane and any descendants will be reclaimed.` }], details: { id: running.id, name: running.name, status: "cancel_requested" }, }; } startLiveSupervision(): void { this.startWidgetRefresh(); if (this.statusConfig.enabled) this.statusSupervisor.start(); } sessionStarting(ctx: ExtensionContext): string { // A permanent shutdown leaves the old root coordinator cancelled. Once // its registry has drained, the next parent session starts a fresh local // lifecycle; `/reload` keeps the active coordinator intact. const previousCoordinator = this.nestedLifecycle(); if ( this.lastShutdownReason !== "reload" && this.runningRegistry.size === 0 && previousCoordinator.isCancellationRequested() && !previousCoordinator.isDraining() ) { resetNestedLifecycleCoordinator(this.parentRuntimeId); } // Reload preserves the manager and reopens the same parent-runtime // delivery boundary; a new process does not recover old outcomes. this.completionDeliveryManager.reopenForReload(); this.latestCtx = ctx; this.modelCatalog = buildAuthenticatedModelCatalog(wrapPiModelRegistry(ctx.modelRegistry)); const parentLifecycle = this.nestedLifecycle().snapshot(); if (this.runningRegistry.size > 0 || parentLifecycle.state === "draining") { this.startLiveSupervision(); this.updateWidget(); } // A result accepted immediately before /reload must still get its one // continuation turn. The queue survives in the continuation module; the // reloaded module reschedules a flush through its own API (a flush timer // left by the old module drains nothing once the queue is taken). if (hasPendingDescendantContinuations(this.parentRuntimeId)) { rescheduleFlushAfterReload(this.pi!, this.nestedLifecycle()); } return this.modelCatalog; } async sessionShuttingDown(reason: unknown): Promise { this.lastShutdownReason = reason; if (reason !== "reload") { rejectPendingDescendantContinuations( new CompletionDeliverySuppressedError("Descendant continuation suppressed by parent shutdown."), this.parentRuntimeId, ); } this.stopWidgetRefresh(); this.statusSupervisor.stop(); await cleanupSubagentsForShutdown(reason, this.runningRegistry, { deliveryManager: this.completionDeliveryManager, cleanupSurface: interruptAndCloseSubagent, coordinator: this.nestedLifecycle(), }); } private updateWidget() { const latestCtx = this.latestCtx; if (!latestCtx?.hasUI) return; const parentLifecycle = this.nestedLifecycle().snapshot(); const parentIsDraining = parentLifecycle.state === "draining"; if (this.runningRegistry.size === 0 && !parentIsDraining) { latestCtx.ui.setWidget("subagent-status", undefined); this.stopWidgetRefresh(); return; } // Capture the stable collaborators once; the renderer object outlives the // call and must not depend on its own `this`. const registry = this.runningRegistry; const nestedLifecycle = this.nestedLifecycle(); const statusEnabled = this.statusConfig.enabled; latestCtx.ui.setWidget( "subagent-status", (_tui: any, _theme: any) => { return { invalidate() {}, render(width: number) { const now = Date.now(); const rows = Array.from(registry.values()).map((agent) => ({ name: agent.name, ...(agent.agent ? { agent: agent.agent } : {}), startTime: agent.startTime, ...(agent.runtimePlan ? { runtimePlan: agent.runtimePlan } : {}), projection: projectLifecycle(ensureLifecycle(agent), now), })); return renderSubagentWidgetLines(rows, width, { statusEnabled, parentLifecycle: nestedLifecycle.snapshot(), now, }); }, }; }, { placement: "aboveEditor" }, ); } private startWidgetRefresh() { if (this.widgetInterval) return; this.updateWidget(); // immediate first render this.widgetInterval = setInterval(() => { this.updateWidget(); }, 1000); } private stopWidgetRefresh() { if (this.widgetInterval) { clearInterval(this.widgetInterval); this.widgetInterval = null; } } } /** Build a fresh parent runtime; production goes through getSharedParentRuntime. */ export function createParentRuntime(options: ParentRuntimeOptions = {}): ParentRuntime { return new ParentRuntimeImpl(options); }