/** * pi-subagent-core — shared agent dispatch for pi subprocesses. * * The common core extracted from pi-review/src/agent/dispatch.ts and * pi-dynamic-workflows/src/agent/dispatch.ts: one `pi --mode json -p * --no-session` subprocess per agent call, stdout parsed for * {message_end, tool_result_end} events, AbortSignal → SIGTERM with a * 5s SIGKILL escalation. * * Each call owns a per-call AbortController registered in an AgentAbortMap, * paired with Map. This is the shared底层 for the * workflow `agent()` primitive and for per-agent abort: a single callId can * be aborted (retry/skip) without disturbing its batch siblings, because * abort is translated to a SIGTERM on exactly one process. * * Workflows-specific machinery (skipAgent/retryAgent/AbortReason/lifecycle * notifications) stays in pi-dynamic-workflows on top of this core. * * When pi promotes spawnAgent to a public @earendil-works/pi-coding-agent * export, this package should be deleted in favor of that import. */ import { spawn, type ChildProcess } from "node:child_process"; import { StringDecoder } from "node:string_decoder"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import type { Message } from "@earendil-works/pi-ai"; /** Stable id for one agent call; the registry key for per-call abort. */ export type AgentCallId = string; /** callId → per-call AbortController (Claude Code per-agent abort map). */ export type AgentAbortMap = Map; // --------------------------------------------------------------------------- // Recursion guard (harden-code-simplify, Decision A3) // --------------------------------------------------------------------------- // // pi's subagent spawns fresh subprocesses whose depth is always 0 at spawn // time, so Claude Code's `depth >= MAX_SUBAGENT_SPAWN_DEPTH` guard (tracked // in-process) is semantically inert here. Instead we propagate three env vars // to each child and let the fan-out tool decide at load time whether to even // register itself: // // PI_SUBAGENT_DEPTH — this process's depth in the tree (0=top) // PI_SUBAGENT_RECURSION_ALLOWED — "1" iff the spawner explicitly opted this // child into recursion (passed the fan-out // tool in its tool whitelist) // PI_SUBAGENT_MAX_SPAWN_DEPTH — optional hard cap; a child at/above it runs // without the fan-out tool even if opted in // // Default (no opt-in): children cannot recurse — physically, the tool is not // registered. Opt-in (caller lists the fan-out tool) re-enables it, bounded by // the max cap. This is the faithful pi-analog of CC's spawn-depth guard, // simplified because no shipped command needs nested fan-out. /** * Parse a strictly-positive integer from an env string. Returns null for * missing, non-numeric, non-integer, or non-positive values so callers can * fall back to a default. Used for depth, max-depth, and concurrency ceilings. */ export function parsePositiveInt(value: string | undefined): number | null { if (value == null || value === "") return null; const n = Number(value); if (!Number.isInteger(n) || n <= 0) return null; return n; } /** * Depth of THIS process in the sub-agent tree. 0 (top-level) when unset; a * child inherits `parent + 1` via the env var spawnAgent sets. */ export function currentSpawnDepth(env: NodeJS.ProcessEnv = process.env): number { return parsePositiveInt(env.PI_SUBAGENT_DEPTH) ?? 0; } /** * Whether the fan-out tool SHOULD be registered in THIS process. Pure — the * policy core, unit-testable without spawning. Top-level always exposes it; a * child exposes it only when its spawner opted in AND it is below the cap. */ export function isFanoutToolAllowed(env: NodeJS.ProcessEnv = process.env): boolean { const depth = currentSpawnDepth(env); if (depth === 0) return true; if (env.PI_SUBAGENT_RECURSION_ALLOWED !== "1") return false; const max = parsePositiveInt(env.PI_SUBAGENT_MAX_SPAWN_DEPTH); return max == null ? true : depth < max; } /** * Build the env block a spawned child receives. Increments depth, records * whether this child may recurse, and propagates the inherited cap (if any). */ function childSpawnEnv(options: AgentSpawnOptions): NodeJS.ProcessEnv { const childDepth = currentSpawnDepth() + 1; // Parse the option through parsePositiveInt so 0/negative/non-numeric values // fall back to the inherited env (or unset) instead of propagating a "0" that // the child would read as "no cap" — the directionally-dangerous reading. const max = parsePositiveInt(options.maxSpawnDepth != null ? String(options.maxSpawnDepth) : process.env.PI_SUBAGENT_MAX_SPAWN_DEPTH); return { ...process.env, PI_SUBAGENT_DEPTH: String(childDepth), PI_SUBAGENT_RECURSION_ALLOWED: options.allowChildRecursion ? "1" : "0", ...(max != null ? { PI_SUBAGENT_MAX_SPAWN_DEPTH: String(max) } : {}), }; } // --------------------------------------------------------------------------- // Concurrency limiter (ported from examples/extensions/subagent) // --------------------------------------------------------------------------- /** * Run `fn` over `items` with at most `concurrency` in flight, preserving * input order in the output array. parallel mode builds on this. */ export async function mapWithConcurrencyLimit( items: TIn[], concurrency: number, fn: (item: TIn, index: number) => Promise, ): Promise { if (items.length === 0) return []; const limit = Math.max(1, Math.min(concurrency, items.length)); const results: TOut[] = new Array(items.length); let nextIndex = 0; // Stop dispatching NEW items once any worker has errored, so a rejection // doesn't leave sibling workers pulling more items and spawning unawaited // subprocesses. In-flight calls are AWAITED before rethrowing — a failure // never leaves spawned subprocesses running in the background after the // caller observes the rejection (the `failed` flag only blocks new dispatch). let failed = false; const workers = new Array(limit).fill(null).map(async () => { while (!failed) { const current = nextIndex++; if (current >= items.length) return; try { results[current] = await fn(items[current], current); } catch (err) { failed = true; throw err; } } }); // Await every worker (including in-flight ones) before rethrowing, so an // error from one item cannot orphan already-spawned subprocesses that keep // running after the caller sees the rejection. The lowest-index worker's // rejection wins (Promise.allSettled preserves input order) — deterministic, // though not necessarily the earliest failure in time. const settled = await Promise.allSettled(workers); const firstRejection = settled.find((s): s is PromiseRejectedResult => s.status === "rejected"); if (firstRejection) throw firstRejection.reason; return results; } // --------------------------------------------------------------------------- // pi binary resolution (ported from examples/extensions/subagent) // --------------------------------------------------------------------------- /** * Resolve the `pi` invocation for the subprocess. Prefers re-entering the * current script (node