/** * In-process subagent runtime for pi extensions. * * Spawns focused child agent sessions through pi's SDK (`createAgentSession`) * — no subprocesses, no dependency on pi-subagents. Because pi's extension * loader aliases `@earendil-works/*` imports to the running host, children are * always version-matched to the pi that loaded the extension. * * Design rules baked in: * - Hermetic by default: no user extensions, skills, prompt templates, themes, * or context files load into a child unless explicitly requested via * `extensionPaths` / `skillPaths` / `includeContextFiles`. * - Tool scoping is by construction: a child gets exactly the allowlisted * built-in tools plus the closure supervisor tool (when requested). A child * cannot spawn children of its own — no spawn capability exists as a tool. * - `spawn()` never rejects. Failures are typed: * 'crashed' | 'empty' | 'schema_invalid' | 'aborted'. * - The ecosystem recursion guard is honored, not namespaced: when * PI_SUBAGENT_DEPTH / PI_SUBAGENT_CHILD are present (we are inside a * pi-subagents child), spawning is refused. * * Proven by spike before implementation: hermetic loader, tool allowlists, * closure supervisor round-trip, streaming, ~2ms abort, and model/auth * inheritance all verified against pi 0.84.1. */ import { type TSchema } from 'typebox'; export interface SupervisorRequest { message: string; reason?: string | undefined; } export type SupervisorHandler = (request: SupervisorRequest) => string | Promise; /** * Parse the editor `&` dispatch prefix: `& `. The first token is * the agent type only when a prompt follows it (`&scout` alone is the prompt * 'scout', not an agent). Returns null when there is no dispatchable prompt. */ export declare function parseAmpDispatch(text: string): { agentType?: string; prompt: string; } | null; export interface SpawnOptions { /** The task prompt sent to the child. */ prompt: string; /** Registry label (e.g. 'reviewer', 'member-A'). Purely informational. */ agent?: string; /** * Model spec ('provider/id', optionally with a ':thinking' suffix) resolved * against the user's configured models/auth. Omit to use pi's default * resolution (session settings, else first available). */ model?: string; /** Appended to pi's default system prompt (like --append-system-prompt). */ systemPrompt?: string; /** Replace pi's default system prompt entirely instead of appending. */ replaceSystemPrompt?: boolean; /** * Built-in tool allowlist. undefined = pi defaults (read, bash, edit, * write); [] = no tools; [...] = exactly those. The supervisor tool (when * `onSupervisorRequest` is set) is always added automatically. */ tools?: string[]; /** Thinking level: off | minimal | low | medium | high | xhigh | max. */ thinkingLevel?: string; /** Explicit extension file paths to load into the child (hermetic otherwise). */ extensionPaths?: string[]; /** Explicit SKILL.md paths to load into the child (hermetic otherwise). */ skillPaths?: string[]; /** Load AGENTS.md / project context files. Default false. */ includeContextFiles?: boolean; /** * TypeBox schema for the child's final message. The final text is parsed * as JSON (fences stripped) and validated; failures produce * kind: 'schema_invalid' — distinct from a schema-valid answer that * merely reports failure inside its fields. */ outputSchema?: TSchema; /** * Parent-side supervisor channel. When set, the child gets a * `_contact_supervisor` tool whose calls invoke this handler * and return its string as the tool result. In-process closure — no * filesystem, no polling. */ onSupervisorRequest?: SupervisorHandler; /** Working directory for the child's tools. Default: process.cwd(). */ cwd?: string; /** Wall-clock limit. Default 15 minutes; pass 0 to disable. */ timeoutMs?: number; /** Aborts the child session (session.abort()) when fired. */ signal?: AbortSignal; /** Receives cumulative usage after each completed assistant response. */ onUsage?: (usage: SpawnUsage) => void; /** Abort after this many agent turns (budget exceeded → kind 'aborted'). */ maxTurns?: number; /** Abort after this many tool executions (budget exceeded → kind 'aborted'). */ maxToolCalls?: number; /** * Run the child in an isolated git worktree (~/.pi/agent/subagent-worktrees/, * detached from HEAD at spawn time). The child's writes never touch the caller's * working tree; on settle, the full change set (INCLUDING untracked files, via * `git add -A` + `git diff --cached`) is captured to `.patch` next to the * run artifact and recorded on RunRecord.worktree. Merge-back is the caller's * decision — central integration, not auto-merge. Fails fast (kind 'crashed') * when cwd is not inside a git repository. */ worktree?: boolean; /** Owning pi session file used to group the run in operational fleet views. */ ownerSession?: string; /** * Owning pi session file path. When set, the run is ALSO persisted as a * standard pi session JSONL via the real `SessionManager` into the default * sessions dir (~/.pi/agent/sessions//), with `parentSession` * set to this path — so the run is inspectable with pi's native /resume, * /tree, and --fork machinery. The bespoke run store (fleet/registry) * keeps working unchanged; this is an additive dual-write. Omit to disable * the mirror entirely (other shared-runtime consumers that don't pass an * owning session are unaffected). */ parentSession?: string; } export interface SpawnUsage { inputTokens: number; outputTokens: number; totalTokens: number; cost?: number | undefined; } export type SpawnFailureKind = 'crashed' | 'empty' | 'schema_invalid' | 'aborted'; export type SpawnFailure = { ok: false; runId: string; kind: SpawnFailureKind; error: string; text: string; usage: SpawnUsage; durationMs: number; }; export type SpawnResult = { ok: true; runId: string; text: string; data?: unknown; usage: SpawnUsage; durationMs: number; } | SpawnFailure; /** One line of a bounded child transcript, for post-hoc debugging. */ export interface TranscriptEntry { kind: 'turn' | 'tool'; label: string; } /** Where a worktree-isolated run lived and what it changed. */ export interface WorktreeInfo { /** Worktree path (kept on disk until GC, for inspection/manual merge). */ path: string; /** Main repo root the worktree belongs to, when known. */ repoRoot?: string | undefined; /** Full untruncated `git diff --cached HEAD` patch file, when anything changed. */ patchPath?: string | undefined; changedFiles?: number | undefined; } export interface RunRecord { runId: string; namespace: string; agent?: string | undefined; model?: string | undefined; status: 'queued' | 'running' | 'completed' | 'failed' | 'aborted'; promptPreview: string; startedAt: number; endedAt?: number | undefined; usage?: SpawnUsage | undefined; error?: string | undefined; /** Host pi process id — reaping distinguishes ghosts from live runs. */ hostPid?: number | undefined; /** Owning pi session file, used to scope operational fleet views. */ ownerSession?: string | undefined; /** Last N child events (turns/tool calls), bounded. */ transcript?: TranscriptEntry[] | undefined; worktree?: WorktreeInfo | undefined; /** * Path to a standard pi session JSONL mirror of this run, when dual-written * via `SessionManager` (see SpawnOptions.parentSession). Additive to the * bespoke run store — the fleet/registry still read the .json artifact. */ sessionFile?: string | undefined; } export interface SubagentRuntime { readonly namespace: string; spawn(options: SpawnOptions): Promise; /** Launch without awaiting; track via listRuns()/artifacts or the returned promise. */ spawnDetached(options: SpawnOptions): { runId: string; done: Promise; }; /** Snapshot of recent runs, newest first. */ listRuns(): RunRecord[]; /** Currently executing (not queued) spawns. */ activeCount(): number; } /** Persisted artifacts (and their worktrees) are GC'd after this age. */ export declare const ARTIFACT_RETENTION_MS: number; /** * Additive dual-write: mirror a finished child run's messages into a standard * pi session JSONL via the real SessionManager, linked back to the owning * session through `parentSession`. Returns the new session file path, or * undefined when no mirror was written (no messages, or a best-effort * failure). Never throws — the bespoke .json artifact remains the source of * truth for the fleet/registry. * * `parentSession` is the owning pi session's file path * (ctx.sessionManager.getSessionFile() from the dispatch tool). When * undefined (in-memory/print host) the mirror is still written, just without * parent linkage. SessionManager.appendMessage refuses compactionSummary and * branchSummary messages, so those are filtered — a single-prompt child run * should never produce them, but the guard keeps a compacted child from * breaking the mirror. */ declare function writeSessionMirror(messages: readonly any[], cwd: string, parentSession: string | undefined): string | undefined; export { writeSessionMirror }; /** * Resolve a bare resource name under the agent dir to a contained absolute * path, or null if the name could escape. Untrusted config (e.g. project-local * config files) must never smuggle path separators or '..' into a path handed * to a child's loader. */ export declare function resolveContainedAgentResource(kind: 'extensions' | 'skills', name: string, leaf: string): string | null; /** A RunRecord persisted to disk, optionally carrying the run's output text. */ export interface RunArtifact extends RunRecord { output?: string | undefined; } /** * Read persisted run artifacts from a shared root ("//.json"). * Cross-extension by design: every runtime persisting to the same root is * visible here, which is what a fleet view needs given pi's per-extension * module isolation. Defensive against partial/garbage files. */ export declare function readRunArtifacts(rootDir: string): RunArtifact[]; /** * One GC + reaping pass over persisted run artifacts: * - records older than ARTIFACT_RETENTION_MS are deleted, together with their * sibling `.patch` and any recorded worktree; * - records still 'queued'/'running' whose hostPid is not this process are * ghosts of a dead host (in-process children cannot outlive it) — marked * 'aborted' with an explanatory error so /fleet shows the truth. * * Returns counts for observability. Never throws. Injectable `now` for tests. */ export declare function sweepRunArtifacts(rootDir: string, opts?: { now?: number; retentionMs?: number; }): { deleted: number; reaped: number; }; /** Run sweepRunArtifacts once per host process (each extension loads its own copy; cheap and idempotent). */ export declare function sweepRunArtifactsOnce(rootDir: string): void; export declare function createSubagentRuntime(options: { namespace: string; maxConcurrent?: number; /** When set, run records (plus bounded output) persist to "//.json". */ artifactsDir?: string; }): SubagentRuntime;