/** * AgentManager — spawns and manages child pi agents via RPC protocol * * Each child runs as `pi --mode rpc --session ` with JSON lines. * Supports: steer, interrupt, wait, status, and shared workspaces. */ import { type ChildProcess } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { StringDecoder } from "node:string_decoder"; import type { Message } from "@earendil-works/pi-ai"; import { Workspace } from "./workspace.ts"; // ─── Types ─────────────────────────────────────────────────────────────────── export interface AgentSpawnConfig { agentName: string; model?: string; thinkingLevel?: string; systemPrompt?: string; task: string; cwd: string; workspaceId?: string; workspace?: Workspace; signal?: AbortSignal; tools?: string[]; onUpdate?: (event: AgentEvent) => void; sessionFile?: string; wakeOnYield?: boolean; } export type AgentState = "queued" | "spawning" | "running" | "idle" | "completed" | "yielded" | "failed" | "aborted"; export interface UsageStats { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number; turns: number; contextTokens: number; } export interface ManagedAgent { handle: string; agentName: string; model?: string; responseModel?: string; thinkingLevel?: string; task: string; parentGoalId?: string; wakeOnYield: boolean; status: AgentState; process?: ChildProcess; stdin?: NodeJS.WritableStream; queuedPrompt?: string; cancelAdmission?: () => void; messages: Message[]; usage: UsageStats; startTime: number; endTime?: number; finalOutput?: string; yieldReason?: string; error?: string; workspaceId?: string; workspace?: Workspace; completionPromise: Promise; _resolveCompletion: () => void; _rejectCompletion: (err: Error) => void; sessionFile: string; idleTimer?: ReturnType; preflightTimer?: ReturnType; guardState?: string; guardVerified: boolean; startPrompt: () => void; _stdoutBuffer: string; _stderrBuffer: string; } export interface AgentEvent { type: string; handle: string; data?: any; } export interface AgentStatusInfo { sessionFile: string; guardState?: string; activeChildren: number; queuedChildren?: number; queueReason?: string; handle: string; agentName: string; model?: string; responseModel?: string; thinkingLevel?: string; status: AgentState; task: string; elapsedMs: number; usage: UsageStats; workspaceId?: string; finalOutput?: string; yieldReason?: string; error?: string; } // ─── Helpers ───────────────────────────────────────────────────────────────── let handleCounter = 0; export function generateHandle(agentName: string): string { return `${agentName}-${++handleCounter}`; } export function getPiInvocation(): { command: string; args: string[] } { const currentScript = process.argv[1]; const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/"); if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) { return { command: process.execPath, args: [currentScript] }; } const execName = path.basename(process.execPath).toLowerCase(); const isGenericRuntime = /^(node|bun)(\.exe)?$/.test(execName); if (!isGenericRuntime) { return { command: process.execPath, args: [] }; } return { command: "pi", args: [] }; } export function getFinalOutput(messages: Message[]): string { for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; if (msg.role === "assistant") { for (const part of msg.content) { if (part.type === "text") return part.text; } } } return ""; } export function emptyUsage(): UsageStats { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0, contextTokens: 0 }; } export function rpcSend(stdin: NodeJS.WritableStream | undefined, cmd: Record): void { if (!stdin || !stdin.writable) throw new Error("Child RPC input is unavailable"); stdin.write(JSON.stringify(cmd) + "\n"); } /** Attach a JSONL reader to a stream. Splits on \n only (protocol-compliant). */ export function attachJsonlReader( stream: NodeJS.ReadableStream, onLine: (line: string) => void, onEnd?: () => void, ): void { const decoder = new StringDecoder("utf8"); let buffer = ""; stream.on("data", (chunk: Buffer | string) => { buffer += typeof chunk === "string" ? chunk : decoder.write(chunk); while (true) { const idx = buffer.indexOf("\n"); if (idx === -1) break; let line = buffer.slice(0, idx); buffer = buffer.slice(idx + 1); if (line.endsWith("\r")) line = line.slice(0, -1); if (line) onLine(line); } }); stream.on("end", () => { buffer += decoder.end(); if (buffer) { const line = buffer.endsWith("\r") ? buffer.slice(0, -1) : buffer; if (line) onLine(line); } onEnd?.(); }); }