/** * child-process-agent.ts — Spawn child Pi agents as separate OS processes. * * Based on nicobailon/pi-subagents's child-process approach. * Unlike in-process createAgentSession(), this spawns a fresh `pi` CLI * subprocess for each child. Benefits: * - Process isolation: no shared state with the parent * - Full tool access: child starts with fresh tool registration * - Model independence: child can use a different model/thinking level * - No context contamination: parent conversation doesn't leak into child * - No EXCLUDED_TOOL_NAMES leaks: orchestration tools not registered at all */ import { type ChildProcess, spawn } from "node:child_process"; import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { delimiter, join } from "node:path"; import type { SubagentType, ThinkingLevel } from "./types.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export interface ChildAgentOptions { /** Agent type name (used for tool selection / system prompt). */ agentType: SubagentType; /** User task prompt. */ task: string; /** Working directory for the child. Default: parent cwd. */ cwd?: string; /** Model override (provider/modelId). Default: parent's active model. */ model?: string; /** Thinking level. */ thinkingLevel?: ThinkingLevel; /** Max turns before steering to wrap up. */ maxTurns?: number; /** Tool names to pass via --tools. When omitted, defaults are used. */ tools?: string[]; /** System prompt for --system-prompt (replace mode) or --append-system-prompt. */ systemPrompt?: string; /** System prompt mode. Default: "replace". */ systemPromptMode?: "replace" | "append"; /** AbortSignal to cancel the child process. */ signal?: AbortSignal; /** Callback for streaming stdout lines as they arrive. */ onStdout?: (line: string) => void; /** Callback for streaming stderr lines as they arrive. */ onStderr?: (line: string) => void; } export interface ChildAgentResult { /** Full stdout content. */ stdout: string; /** Full stderr content. */ stderr: string; /** Exit code of the child process. */ exitCode: number | null; /** Whether the process was aborted via signal. */ aborted: boolean; /** Duration in milliseconds. */ durationMs: number; } // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- /** Default built-in tool names (used when tools option is omitted). */ export const DEFAULT_CHILD_TOOLS = ["read", "bash", "edit", "write", "grep", "find", "ls"]; /** Agent-specific tool allowlists (maps agent type → tools). */ const AGENT_TOOL_MAP: Record = { "general-purpose": ["read", "bash", "edit", "write", "grep", "find", "ls"], "Explore": ["read", "bash", "grep", "find", "ls"], "Plan": ["read", "bash", "grep", "find", "ls"], }; // --------------------------------------------------------------------------- // Find the pi CLI binary // --------------------------------------------------------------------------- /** * Resolve the `pi` CLI binary path. Checks several locations: * 1. PATH (which pi) * 2. node_modules/.bin/pi relative to cwd * 3. Homebrew cellar (/opt/homebrew/bin/pi) */ function resolvePiBinary(): string { // Try PATH first const pathEnv = process.env.PATH ?? ""; for (const dir of pathEnv.split(delimiter)) { const candidate = join(dir, "pi"); if (existsSync(candidate)) return candidate; } // Fallback to Homebrew path (macOS only) if (process.platform === "darwin") { const homebrew = "/opt/homebrew/bin/pi"; if (existsSync(homebrew)) return homebrew; } // Final fallback return "pi"; } // --------------------------------------------------------------------------- // Temp file helpers // --------------------------------------------------------------------------- /** * Create a temporary file with the given content. * Returns the file path. The caller must clean up. */ function writeTempFile(content: string, suffix = ".md"): string { const dir = mkdtempSync(join(tmpdir(), "pi-subagent-")); const filePath = join(dir, `prompt${suffix}`); writeFileSync(filePath, content, "utf-8"); return filePath; } /** Recursively remove a temp directory. */ function cleanupTempDir(dirPath: string): void { try { rmSync(dirPath, { recursive: true, force: true }); } catch { // Best effort cleanup } } // --------------------------------------------------------------------------- // Args builder // --------------------------------------------------------------------------- /** * Build the CLI arguments and environment for spawning a child Pi agent. * * Based on nicobailon/pi-subagents/src/runs/shared/pi-args.ts but simplified. */ function buildChildArgs(options: ChildAgentOptions): { args: string[]; env: Record; tempDirs: string[]; } { const tempDirs: string[] = []; const args: string[] = []; // Use print mode (non-interactive, no TUI) args.push("--print"); // No session for child agents (stateless execution) args.push("--no-session"); // Working directory if (options.cwd) { args.push("--cwd", options.cwd); } // Tools: use the agent-specific allowlist or the provided tools const toolNames = options.tools ?? AGENT_TOOL_MAP[options.agentType] ?? DEFAULT_CHILD_TOOLS; // Exclude orchestration tools from the child const filteredTools = toolNames.filter( (t) => !["Agent", "get_subagent_result", "steer_subagent"].includes(t), ); if (filteredTools.length > 0) { args.push("--tools", filteredTools.join(",")); } // Model override if (options.model) { const modelArg = options.thinkingLevel && options.thinkingLevel !== "off" ? `${options.model}:${options.thinkingLevel}` : options.model; args.push("--model", modelArg); } // System prompt if (options.systemPrompt) { const promptPath = writeTempFile(options.systemPrompt); tempDirs.push(join(promptPath, "..")); if (options.systemPromptMode === "append") { args.push("--append-system-prompt", promptPath); } else { args.push("--system-prompt", promptPath); } } // Max turns if (options.maxTurns && options.maxTurns > 0) { args.push("--max-turns", String(options.maxTurns)); } // No skills (child doesn't inherit parent's skills) args.push("--no-skills"); // The task const task = `Task: ${options.task}`; if (task.length > 8000) { const taskPath = writeTempFile(task); tempDirs.push(join(taskPath, "..")); args.push(`@${taskPath}`); } else { args.push(task); } // Environment: mark as child to prevent recursive extension loading const env: Record = { PI_SUBAGENT_CHILD: "1", ...process.env as Record, }; return { args, env, tempDirs }; } // --------------------------------------------------------------------------- // Main spawn function // --------------------------------------------------------------------------- /** * Spawn a child Pi agent as a separate OS process. * * Uses `pi --print` mode which streams completion as stdout text. * The child runs independently with its own tool registration, * model configuration, and system prompt. * * @returns Result object with stdout, stderr, exit code, etc. */ export async function spawnChildAgent( options: ChildAgentOptions, ): Promise { const startTime = Date.now(); const piBinary = resolvePiBinary(); const { args, env, tempDirs } = buildChildArgs(options); const abortController = new AbortController(); // Wire external abort signal const detachSignal = options.signal ? () => options.signal!.removeEventListener("abort", onAbort) : () => {}; const onAbort = () => { abortController.abort(); detachSignal(); }; if (options.signal) { options.signal.addEventListener("abort", onAbort, { once: true }); } return new Promise((resolvePromise, reject) => { let stdout = ""; let stderr = ""; let child: ChildProcess; try { child = spawn(piBinary, args, { env: env as NodeJS.ProcessEnv, stdio: ["ignore", "pipe", "pipe"], signal: abortController.signal, }); } catch (err) { // Clean up temp files for (const dir of tempDirs) cleanupTempDir(dir); detachSignal(); reject(err); return; } child.stdout?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); stdout += chunk; options.onStdout?.(chunk); }); child.stderr?.on("data", (data: Buffer) => { const chunk = data.toString("utf-8"); stderr += chunk; options.onStderr?.(chunk); }); child.on("close", (exitCode) => { const durationMs = Date.now() - startTime; // Clean up temp files for (const dir of tempDirs) cleanupTempDir(dir); detachSignal(); resolvePromise({ stdout, stderr, exitCode, aborted: abortController.signal.aborted, durationMs, }); }); child.on("error", (err) => { const _durationMs = Date.now() - startTime; for (const dir of tempDirs) cleanupTempDir(dir); detachSignal(); reject(err); }); }); }