@fradser/pi-agent-teams 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/spawner.ts ADDED
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Teammate spawner — spawns real child Pi processes as workers.
3
+ *
4
+ * A teammate becomes "real" by running its assigned task in a fresh,
5
+ * non-interactive Pi CLI process (`--print`), with its own model and tool
6
+ * scope. stdout/stderr are captured and reported back through the callback
7
+ * so the board can record the outcome on the task.
8
+ */
9
+
10
+ import { spawn } from "node:child_process";
11
+ import * as fs from "node:fs";
12
+ import * as os from "node:os";
13
+ import * as path from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import type { WorkerUsage } from "./types";
16
+
17
+ /**
18
+ * Build the one-task execution prompt for a spawned worker.
19
+ *
20
+ * A worker executes one bounded task run. It reports to the leader throughout
21
+ * the run and then exits. The parent independently drains the worker outbox.
22
+ */
23
+ export function buildAutonomousPrompt(opts: {
24
+ name: string;
25
+ role: string;
26
+ prompt: string;
27
+ taskId?: string;
28
+ timeoutSec: number;
29
+ }): string {
30
+ const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
31
+
32
+ return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
33
+
34
+ === ROLE PROMPT ===
35
+ ${opts.prompt}
36
+ ${taskLine}
37
+
38
+ YOUR ROLE IN THIS RUN:
39
+ 1. Work directly on your assigned scope and declared paths. DAG upstream results are ALREADY injected into this prompt below; do not poll for them.
40
+ 2. Report plans, progress, and blockers with teammate_message. When finished, call teammate_message with status="completed" and put your FULL deliverable in the body. If blocked or failed, use status="failed" and explain the error.
41
+ 3. There is no peer or leader-to-worker message channel. Make decisions within the assigned task and report blockers instead of waiting for a reply.
42
+ 4. The hard wall-clock cap is ${opts.timeoutSec}s — manage your time budget, avoid unnecessary exploration, and deliver your final message before the deadline.
43
+
44
+ BOUND CAPABILITIES:
45
+ - teammate_message reports progress or a final deliverable to the team leader.`;
46
+ }
47
+
48
+ export interface SpawnPiWorkerOptions {
49
+ /** Worker identity — used to register the child so the panel can interrupt/stop it. */
50
+ workerName?: string;
51
+ /** Task description — the actual work the worker executes. */
52
+ description: string;
53
+ /** Optional model pattern for the child (e.g. "anthropic/claude-sonnet-4"). */
54
+ model?: string;
55
+ /** Optional execution-tool allowlist for the child. Capability tools are always appended. */
56
+ tools?: string[];
57
+ /** Environment additions bound to this worker process. */
58
+ env?: Record<string, string | undefined>;
59
+ /** Working directory for the child (defaults to the parent's cwd). */
60
+ cwd?: string;
61
+ /** Kill the worker after this many milliseconds (undefined = no timeout). */
62
+ timeoutMs?: number;
63
+ /** Output parsing mode. "json" (default) parses usage from JSONL output. */
64
+ mode?: "json" | "text";
65
+ /** Called whenever JSON-mode worker output reveals live model or tool activity. */
66
+ onUpdate?: (update: WorkerProgressUpdate) => void;
67
+ /** Called once with the captured output when the child exits. */
68
+ onExit: (result: WorkerProcessResult) => void;
69
+ /** Called when the child could not be spawned at all. */
70
+ onError?: (error: Error) => void;
71
+ }
72
+
73
+ export interface WorkerProcessResult {
74
+ pid: number;
75
+ exitCode: number | null;
76
+ signal: NodeJS.Signals | null;
77
+ stdout: string;
78
+ stderr: string;
79
+ usage?: WorkerUsage;
80
+ timedOut: boolean;
81
+ }
82
+
83
+ /** Live state extracted from a spawned worker's JSON-mode output. */
84
+ export interface WorkerProgressUpdate {
85
+ text: string;
86
+ activeTool?: string;
87
+ liveThinking?: string;
88
+ turns: number;
89
+ finalResponse?: boolean;
90
+ }
91
+
92
+ /** A worker only succeeds after a normal zero exit; signals are failures. */
93
+ export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">): boolean {
94
+ return result.exitCode === 0 && result.signal === null && !result.timedOut;
95
+ }
96
+
97
+ /** A reported completion remains successful when the harness closes it or teardown observes a signal/timeout. */
98
+ export function isCompletedWorkerExit(
99
+ result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">,
100
+ reportedCompleted = false,
101
+ ): boolean {
102
+ return isSuccessfulWorkerExit(result) || reportedCompleted;
103
+ }
104
+
105
+ export interface SpawnedWorker {
106
+ pid: number;
107
+ }
108
+
109
+ /** Bounded grace period before a cancellation escalates from SIGTERM to SIGKILL. */
110
+ const CANCEL_GRACE_MS = 5_000;
111
+ /** Give a terminally reported worker a short chance to exit cleanly. */
112
+ export const POST_REPORT_GRACE_MS = 10_000;
113
+
114
+ /**
115
+ * Defers a close finalizer while the leader confirms a cancellation request.
116
+ * A run ID is unique to one child process, so an old close event cannot affect
117
+ * a later task run.
118
+ */
119
+ export class CancellationIntents {
120
+ private readonly finalizers = new Map<string, Array<(cancelled: boolean) => void>>();
121
+
122
+ begin(spawnId: string): boolean {
123
+ if (this.finalizers.has(spawnId)) return false;
124
+ this.finalizers.set(spawnId, []);
125
+ return true;
126
+ }
127
+
128
+ has(spawnId: string): boolean {
129
+ return this.finalizers.has(spawnId);
130
+ }
131
+
132
+ defer(spawnId: string, finalize: (cancelled: boolean) => void): boolean {
133
+ const pending = this.finalizers.get(spawnId);
134
+ if (!pending) return false;
135
+ pending.push(finalize);
136
+ return true;
137
+ }
138
+
139
+ resolve(spawnId: string, cancelled: boolean): boolean {
140
+ const pending = this.finalizers.get(spawnId);
141
+ if (!pending) return false;
142
+ try {
143
+ for (const finalize of pending) finalize(cancelled);
144
+ } finally {
145
+ this.finalizers.delete(spawnId);
146
+ }
147
+ return true;
148
+ }
149
+ }
150
+
151
+ /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
152
+ const workers = new Map<string, ReturnType<typeof spawn>>();
153
+
154
+ function isChildRunning(child: ReturnType<typeof spawn>): boolean {
155
+ return child.exitCode === null && child.signalCode === null;
156
+ }
157
+
158
+ function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
159
+ return new Promise((resolve) => {
160
+ let settled = false;
161
+ const onClose = () => finish(true);
162
+ const timer = setTimeout(() => finish(false), timeoutMs);
163
+ timer.unref?.();
164
+
165
+ function finish(closed: boolean): void {
166
+ if (settled) return;
167
+ settled = true;
168
+ clearTimeout(timer);
169
+ child.off("close", onClose);
170
+ resolve(closed);
171
+ }
172
+
173
+ child.once("close", onClose);
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Request a graceful child shutdown, escalate to SIGKILL when it resists, and
179
+ * resolve only after a bounded wait observes the child `close` event.
180
+ */
181
+ export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
182
+ if (!isChildRunning(child)) return false;
183
+
184
+ const closedAfterTerm = waitForClose(child, graceMs);
185
+ try {
186
+ if (!child.kill("SIGTERM")) {
187
+ void closedAfterTerm;
188
+ return false;
189
+ }
190
+ } catch {
191
+ void closedAfterTerm;
192
+ return false;
193
+ }
194
+ if (await closedAfterTerm) return true;
195
+ if (!isChildRunning(child)) return false;
196
+
197
+ const closedAfterKill = waitForClose(child, graceMs);
198
+ try {
199
+ if (!child.kill("SIGKILL")) {
200
+ void closedAfterKill;
201
+ return false;
202
+ }
203
+ } catch {
204
+ void closedAfterKill;
205
+ return false;
206
+ }
207
+ return closedAfterKill;
208
+ }
209
+
210
+ /** Terminate a live worker and wait until its child process has closed. */
211
+ export async function terminateWorker(name: string, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
212
+ const child = workers.get(name);
213
+ if (!child) return false;
214
+ return terminateChildProcess(child, graceMs);
215
+ }
216
+
217
+ /** Gracefully end a worker that has already reported a terminal task result. */
218
+ export async function finishReportedWorker(name: string, graceMs = POST_REPORT_GRACE_MS): Promise<boolean> {
219
+ return terminateWorker(name, graceMs);
220
+ }
221
+
222
+ /** Stop every live child before the leader discards the current session board. */
223
+ export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<void> {
224
+ const children = [...workers.values()];
225
+ await Promise.all(children.map((child) => (isChildRunning(child) ? terminateChildProcess(child, graceMs) : undefined)));
226
+ workers.clear();
227
+ }
228
+
229
+ const TASK_ARG_LIMIT = 8000;
230
+ const OUTPUT_CAP = 16_000;
231
+
232
+ type JsonEvent = {
233
+ type?: string;
234
+ toolName?: string;
235
+ assistantMessageEvent?: {
236
+ type?: string;
237
+ delta?: string;
238
+ };
239
+ message?: {
240
+ role?: string;
241
+ stopReason?: string;
242
+ content?: Array<{ type?: string; text?: string }>;
243
+ usage?: {
244
+ input?: number;
245
+ output?: number;
246
+ cacheRead?: number;
247
+ cacheWrite?: number;
248
+ totalTokens?: number;
249
+ cost?: { total?: number };
250
+ };
251
+ };
252
+ };
253
+
254
+ /**
255
+ * Parse the JSONL output of a `pi --print --mode json` worker run.
256
+ * Returns the final assistant text and the last reported usage.
257
+ */
258
+ export function parseWorkerOutput(stdout: string): { text: string; usage?: WorkerUsage } {
259
+ const state = createWorkerStreamState();
260
+ for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
261
+ return { text: state.text.trim(), usage: state.usage };
262
+ }
263
+
264
+ interface WorkerStreamState {
265
+ text: string;
266
+ thinking: string;
267
+ toolcallArgs: string;
268
+ activeTool?: string;
269
+ turns: number;
270
+ finalResponse?: boolean;
271
+ usage?: WorkerUsage;
272
+ }
273
+
274
+ function createWorkerStreamState(): WorkerStreamState {
275
+ return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
276
+ }
277
+
278
+ /** Human-readable label from a partially streamed tool-call argument JSON. */
279
+ function toolcallLabel(rawArgs: string): string | undefined {
280
+ const trimmed = rawArgs.trim();
281
+ if (!trimmed.startsWith("{")) return undefined;
282
+ try {
283
+ const args = JSON.parse(trimmed) as Record<string, unknown>;
284
+ const command = args.command;
285
+ if (typeof command === "string" && command.trim()) return `bash: ${truncateInline(command, 40)}`;
286
+ const filePath = args.path;
287
+ if (typeof filePath === "string" && filePath.trim()) return `file: ${path.basename(filePath.trim())}`;
288
+ const subject = args.subject;
289
+ if (typeof subject === "string" && subject.trim()) return `message: ${truncateInline(subject, 40)}`;
290
+ const query = args.query;
291
+ if (typeof query === "string" && query.trim()) return `search: ${truncateInline(query, 40)}`;
292
+ } catch {
293
+ // Incomplete JSON mid-stream — retry on the next delta.
294
+ }
295
+ return undefined;
296
+ }
297
+
298
+ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
299
+ if (!line.trim()) return false;
300
+ let event: JsonEvent;
301
+ try {
302
+ event = JSON.parse(line) as JsonEvent;
303
+ } catch {
304
+ return false;
305
+ }
306
+ if (event.type !== "message_update") {
307
+ if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
308
+ state.turns++;
309
+ if (event.message.stopReason === "stop") state.finalResponse = true;
310
+ const parts = (event.message.content ?? [])
311
+ .filter((part) => part.type === "text" && typeof part.text === "string")
312
+ .map((part) => part.text as string)
313
+ .join("");
314
+ if (parts.trim()) state.text = parts;
315
+ const u = event.message.usage;
316
+ if (u) {
317
+ state.usage = {
318
+ input: u.input ?? 0,
319
+ output: u.output ?? 0,
320
+ cacheRead: u.cacheRead ?? 0,
321
+ cacheWrite: u.cacheWrite ?? 0,
322
+ totalTokens: u.totalTokens ?? 0,
323
+ cost: u.cost?.total ?? 0,
324
+ };
325
+ }
326
+ return true;
327
+ }
328
+ const sub = event.assistantMessageEvent;
329
+ if (!sub) return false;
330
+ switch (sub.type) {
331
+ case "text_delta":
332
+ state.text += sub.delta ?? "";
333
+ return true;
334
+ case "thinking_delta":
335
+ state.thinking += sub.delta ?? "";
336
+ return true;
337
+ case "toolcall_start":
338
+ state.toolcallArgs = "";
339
+ state.activeTool = undefined;
340
+ return true;
341
+ case "toolcall_delta": {
342
+ state.toolcallArgs += sub.delta ?? "";
343
+ const label = toolcallLabel(state.toolcallArgs);
344
+ if (label) state.activeTool = label;
345
+ return true;
346
+ }
347
+ case "toolcall_end": {
348
+ const tc = (sub as { toolCall?: { name?: string } }).toolCall;
349
+ // Prefer the richer delta-derived label (e.g. "bash: echo hello");
350
+ // fall back to the bare tool name only when no label could be parsed.
351
+ if (tc?.name && !state.activeTool) state.activeTool = tc.name;
352
+ state.toolcallArgs = "";
353
+ return true;
354
+ }
355
+ default:
356
+ return false;
357
+ }
358
+ }
359
+
360
+ function truncateInline(text: string, cap: number): string {
361
+ const oneLine = text.replace(/\s+/g, " ").trim();
362
+ if (oneLine.length <= cap) return oneLine;
363
+ return `${oneLine.slice(0, cap).trimEnd()} ...`;
364
+ }
365
+
366
+ function truncate(text: string, cap: number): string {
367
+ if (text.length <= cap) return text;
368
+ return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
369
+ }
370
+
371
+ export interface PiCliResolution {
372
+ /** Command to execute — either a runtime (node/bun) or a `pi` binary. */
373
+ command: string;
374
+ /** Leading args: the CLI script path when running via a runtime, else empty. */
375
+ args: string[];
376
+ }
377
+
378
+ /** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
379
+ function isPiPackageScript(filePath: string): boolean {
380
+ try {
381
+ const resolved = fs.realpathSync(filePath);
382
+ if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
383
+ let dir = path.dirname(resolved);
384
+ while (dir !== path.dirname(dir)) {
385
+ const pkgPath = path.join(dir, "package.json");
386
+ if (fs.existsSync(pkgPath)) {
387
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
388
+ return pkg.name === "@earendil-works/pi-coding-agent";
389
+ }
390
+ dir = path.dirname(dir);
391
+ }
392
+ } catch {
393
+ // Unreadable paths are simply not candidates.
394
+ }
395
+ return false;
396
+ }
397
+
398
+ /**
399
+ * Resolve how to launch a worker Pi process.
400
+ *
401
+ * Resolution order:
402
+ * 1. `process.argv[1]` — the current Pi process entry, verified against the
403
+ * package manifest (avoids mistaking unrelated scripts for the CLI).
404
+ * 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
405
+ * 3. A `pi` binary on PATH (best effort).
406
+ */
407
+ export function resolvePiCli(): PiCliResolution {
408
+ const argv1 = process.argv[1];
409
+ if (argv1 && isPiPackageScript(argv1)) {
410
+ return { command: process.execPath, args: [path.resolve(argv1)] };
411
+ }
412
+
413
+ try {
414
+ const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
415
+ const packageRoot = path.dirname(path.dirname(entry));
416
+ const cliPath = path.join(packageRoot, "dist", "cli.js");
417
+ if (fs.existsSync(cliPath)) {
418
+ return { command: process.execPath, args: [cliPath] };
419
+ }
420
+ } catch {
421
+ // Package resolution is best-effort; fall through to PATH.
422
+ }
423
+
424
+ return { command: "pi", args: [] };
425
+ }
426
+
427
+ /**
428
+ * Spawn a child Pi process that executes the task description in
429
+ * non-interactive mode. Returns immediately with the child pid; the outcome
430
+ * is delivered via `onExit` / `onError`.
431
+ */
432
+ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | { error: string } {
433
+ // resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
434
+ // binary surfaces later as a child "error" event through onError.
435
+ const cli = resolvePiCli();
436
+ const args: string[] = [...cli.args, "--print", "--mode", "json", "--no-session"];
437
+ if (options.model) args.push("--model", options.model);
438
+ // Capability tools cannot be removed; execution tools are selected by the
439
+ // leader from the teammate's explicit configuration or role default.
440
+ const capabilityTools = ["teammate_message"];
441
+ const requestedTools = (options.tools ?? []).filter((tool) => !tool.startsWith("teammate_"));
442
+ const tools = [...new Set([...requestedTools, ...capabilityTools])];
443
+ args.push("--tools", tools.join(","));
444
+
445
+ let tempDir: string | undefined;
446
+ const cleanupTempDir = () => {
447
+ if (!tempDir) return;
448
+ try {
449
+ fs.rmSync(tempDir, { recursive: true, force: true });
450
+ } catch {
451
+ // Temporary cleanup must not mask the worker outcome.
452
+ } finally {
453
+ tempDir = undefined;
454
+ }
455
+ };
456
+ const setupError = (error: unknown): { error: string } => ({
457
+ error: error instanceof Error ? error.message : String(error),
458
+ });
459
+
460
+ try {
461
+ const taskText = `Task: ${options.description}`;
462
+ if (taskText.length > TASK_ARG_LIMIT) {
463
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
464
+ const taskFile = path.join(tempDir, "task.md");
465
+ fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
466
+ args.push(`@${taskFile}`);
467
+ } else {
468
+ args.push(taskText);
469
+ }
470
+ } catch (error) {
471
+ cleanupTempDir();
472
+ return setupError(error);
473
+ }
474
+
475
+ let child;
476
+ try {
477
+ child = spawn(cli.command, args, {
478
+ cwd: options.cwd,
479
+ env: { ...process.env, ...options.env },
480
+ stdio: ["ignore", "pipe", "pipe"],
481
+ });
482
+ } catch (error) {
483
+ cleanupTempDir();
484
+ return setupError(error);
485
+ }
486
+ if (options.workerName) workers.set(options.workerName, child);
487
+
488
+ const stdoutChunks: string[] = [];
489
+ const stderrChunks: string[] = [];
490
+ const streamState = createWorkerStreamState();
491
+ let stdoutBuffer = "";
492
+ let timedOut = false;
493
+ let settled = false;
494
+ const emitProgress = () => options.onUpdate?.({
495
+ text: truncate(streamState.text, OUTPUT_CAP),
496
+ activeTool: streamState.activeTool,
497
+ liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
498
+ turns: streamState.turns,
499
+ finalResponse: streamState.finalResponse,
500
+ });
501
+ child.stdout.on("data", (chunk: Buffer) => {
502
+ const text = chunk.toString();
503
+ stdoutChunks.push(text);
504
+ stdoutBuffer += text;
505
+ const lines = stdoutBuffer.split("\n");
506
+ stdoutBuffer = lines.pop() ?? "";
507
+ let changed = false;
508
+ for (const line of lines) changed = applyWorkerJsonLine(streamState, line) || changed;
509
+ if (changed) emitProgress();
510
+ });
511
+ child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
512
+
513
+ let timer: ReturnType<typeof setTimeout> | undefined;
514
+ if (options.timeoutMs) {
515
+ timer = setTimeout(() => {
516
+ timedOut = true;
517
+ child.kill("SIGKILL");
518
+ }, options.timeoutMs);
519
+ timer.unref?.();
520
+ }
521
+
522
+ child.on("error", (error) => {
523
+ if (timer) clearTimeout(timer);
524
+ cleanupTempDir();
525
+ settled = true;
526
+ if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
527
+ options.onError?.(error);
528
+ });
529
+
530
+ child.on("close", (code, signal) => {
531
+ if (timer) clearTimeout(timer);
532
+ cleanupTempDir();
533
+ // A spawn failure already reported via onError (Node fires error then
534
+ // close) — do not double-report through onExit, which would let a failed
535
+ // spawn look like a successful 0-exit run.
536
+ if (settled) return;
537
+ if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
538
+ const rawStdout = stdoutChunks.join("");
539
+ const parsed = (options.mode ?? "json") === "json"
540
+ ? parseWorkerOutput(rawStdout)
541
+ : { text: rawStdout.trim(), usage: undefined };
542
+ const stdout = truncate(parsed.text, OUTPUT_CAP);
543
+ const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
544
+ options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, timedOut });
545
+ });
546
+
547
+ return { pid: child.pid ?? 0 };
548
+ }
549
+