@fradser/pi-agent-teams 0.5.2 → 0.6.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 CHANGED
@@ -1,75 +1,30 @@
1
1
  /**
2
- * Teammate spawner — spawns real child Pi processes as workers.
2
+ * Teammate spawner — spawns named resident child Pi processes in RPC mode.
3
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.
4
+ * A teammate is a long-lived worker: it receives prompts on its control
5
+ * stream (stdin), streams JSON events on stdout, and suspends between wake
6
+ * ups without consuming tokens. The harness delivers new prompts via
7
+ * deliverPrompt (idle wake-up) and steering lines via sendWorkerSteer
8
+ * (mid-turn delivery). Wake-up sequences are uncapped: no turn-count or
9
+ * wall-clock limit terminates a working teammate; anomalies surface as
10
+ * leader notifications instead.
8
11
  */
9
12
 
10
- import { spawn } from "node:child_process";
13
+ import { randomUUID } from "node:crypto";
11
14
  import * as fs from "node:fs";
12
- import * as os from "node:os";
13
15
  import * as path from "node:path";
14
16
  import { fileURLToPath } from "node:url";
15
- import { extractTextContent } from "@fradser/pi-kit";
16
- import type { WorkerUsage } from "./types";
17
+ import {
18
+ DEFAULT_TERMINATION_GRACE_MS,
19
+ extractTextContent,
20
+ resolvePiCli,
21
+ spawnPiChild,
22
+ terminateChildProcess,
23
+ } from "@fradser/pi-kit";
24
+ import type { ChildProcess } from "node:child_process";
25
+ import type { WorkerUsage } from "./types.ts";
17
26
 
18
- /**
19
- * Build the one-task execution prompt for a spawned worker.
20
- *
21
- * A worker executes one bounded task run. It reports to the leader throughout
22
- * the run and then exits. The parent independently drains the worker outbox.
23
- */
24
- export function buildAutonomousPrompt(opts: {
25
- name: string;
26
- role: string;
27
- prompt: string;
28
- taskId?: string;
29
- timeoutSec: number;
30
- }): string {
31
- const taskLine = `\nAssigned task: [${opts.taskId}].\nWhen finished, you MUST call teammate_message with status="completed" to submit your FULL final deliverable.`;
32
-
33
- return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
34
-
35
- === ROLE PROMPT ===
36
- ${opts.prompt}
37
- ${taskLine}
38
-
39
- YOUR ROLE IN THIS RUN:
40
- 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.
41
- 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.
42
- 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.
43
- 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.
44
-
45
- BOUND CAPABILITIES:
46
- - teammate_message reports progress or a final deliverable to the team leader.`;
47
- }
48
-
49
- export interface SpawnPiWorkerOptions {
50
- /** Worker identity — used to register the child so the panel can interrupt/stop it. */
51
- workerName?: string;
52
- /** Task description — the actual work the worker executes. */
53
- description: string;
54
- /** Optional model pattern for the child (e.g. "anthropic/claude-sonnet-4"). */
55
- model?: string;
56
- /** Optional execution-tool allowlist for the child. Capability tools are always appended. */
57
- tools?: string[];
58
- /** Environment additions bound to this worker process. */
59
- env?: Record<string, string | undefined>;
60
- /** Working directory for the child (defaults to the parent's cwd). */
61
- cwd?: string;
62
- /** Kill the worker after this many milliseconds (undefined = no timeout). */
63
- timeoutMs?: number;
64
- /** Output parsing mode. "json" (default) parses usage from JSONL output. */
65
- mode?: "json" | "text";
66
- /** Called whenever JSON-mode worker output reveals live model or tool activity. */
67
- onUpdate?: (update: WorkerProgressUpdate) => void;
68
- /** Called once with the captured output when the child exits. */
69
- onExit: (result: WorkerProcessResult) => void;
70
- /** Called when the child could not be spawned at all. */
71
- onError?: (error: Error) => void;
72
- }
27
+ const OUTPUT_CAP = 16_000;
73
28
 
74
29
  export interface WorkerProcessResult {
75
30
  pid: number;
@@ -78,220 +33,109 @@ export interface WorkerProcessResult {
78
33
  stdout: string;
79
34
  stderr: string;
80
35
  usage?: WorkerUsage;
81
- timedOut: boolean;
82
36
  }
83
37
 
84
- /** Live state extracted from a spawned worker's JSON-mode output. */
38
+ /** Live state extracted from a teammate's RPC output stream. */
85
39
  export interface WorkerProgressUpdate {
86
40
  text: string;
87
41
  activeTool?: string;
88
42
  liveThinking?: string;
43
+ /** Assistant turns observed in the current wake-up sequence. */
89
44
  turns: number;
45
+ /** True after the current sequence's final response. */
90
46
  finalResponse?: boolean;
91
47
  }
92
48
 
93
- /** A worker only succeeds after a normal zero exit; signals are failures. */
94
- export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">): boolean {
95
- return result.exitCode === 0 && result.signal === null && !result.timedOut;
96
- }
97
-
98
- /** A reported completion remains successful when the harness closes it or teardown observes a signal/timeout. */
99
- export function isCompletedWorkerExit(
100
- result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">,
101
- reportedCompleted = false,
102
- ): boolean {
103
- return isSuccessfulWorkerExit(result) || reportedCompleted;
49
+ /** A crashed teammate is one that closed without a normal zero exit. */
50
+ export function isCleanExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
51
+ return result.exitCode === 0 && result.signal === null;
104
52
  }
105
53
 
106
- export interface SpawnedWorker {
107
- pid: number;
108
- }
109
-
110
- /** Bounded grace period before a cancellation escalates from SIGTERM to SIGKILL. */
111
- const CANCEL_GRACE_MS = 5_000;
112
- /** Give a terminally reported worker a short chance to exit cleanly. */
113
- export const POST_REPORT_GRACE_MS = 10_000;
114
-
115
- /**
116
- * Defers a close finalizer while the leader confirms a cancellation request.
117
- * A run ID is unique to one child process, so an old close event cannot affect
118
- * a later task run.
119
- */
120
- export class CancellationIntents {
121
- private readonly finalizers = new Map<string, Array<(cancelled: boolean) => void>>();
122
-
123
- begin(spawnId: string): boolean {
124
- if (this.finalizers.has(spawnId)) return false;
125
- this.finalizers.set(spawnId, []);
126
- return true;
127
- }
128
-
129
- has(spawnId: string): boolean {
130
- return this.finalizers.has(spawnId);
131
- }
132
-
133
- defer(spawnId: string, finalize: (cancelled: boolean) => void): boolean {
134
- const pending = this.finalizers.get(spawnId);
135
- if (!pending) return false;
136
- pending.push(finalize);
137
- return true;
138
- }
139
-
140
- resolve(spawnId: string, cancelled: boolean): boolean {
141
- const pending = this.finalizers.get(spawnId);
142
- if (!pending) return false;
143
- try {
144
- for (const finalize of pending) finalize(cancelled);
145
- } finally {
146
- this.finalizers.delete(spawnId);
147
- }
148
- return true;
149
- }
150
- }
54
+ // ── Process registry ──────────────────────────────────────────────
151
55
 
152
- /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
153
- const workers = new Map<string, ReturnType<typeof spawn>>();
56
+ /** Live children by teammate name — powers steering, prompting, and shutdown. */
57
+ const workers = new Map<string, ChildProcess>();
58
+ const closedWorkers = new WeakSet<ChildProcess>();
154
59
 
155
- export interface WorkerTimeoutHandle {
156
- cancel(): void;
157
- dispose(): void;
158
- wasCancelled(): boolean;
159
- didTimeout(): boolean;
60
+ function observeWorkerClose(name: string, child: ChildProcess): void {
61
+ child.once("close", () => {
62
+ closedWorkers.add(child);
63
+ if (workers.get(name) === child) workers.delete(name);
64
+ });
160
65
  }
161
66
 
162
- const workerTimeouts = new WeakMap<ReturnType<typeof spawn>, WorkerTimeoutHandle>();
163
-
164
- function isChildRunning(child: ReturnType<typeof spawn>): boolean {
165
- return child.exitCode === null && child.signalCode === null;
67
+ /** True only after Node has emitted the child process close event. */
68
+ export function isWorkerCloseObserved(name: string): boolean {
69
+ const child = workers.get(name);
70
+ if (child) return closedWorkers.has(child);
71
+ // Not registered anymore: either never spawned or already unregistered by close.
72
+ return true;
166
73
  }
167
74
 
168
- export function registerWorkerTimeout(
169
- child: ReturnType<typeof spawn>,
170
- timeoutMs: number,
171
- ): WorkerTimeoutHandle {
172
- let cancelled = false;
173
- let disposed = false;
174
- let timedOut = false;
175
- let timer: ReturnType<typeof setTimeout> | undefined;
176
-
177
- const handle: WorkerTimeoutHandle = {
178
- cancel() {
179
- if (disposed) return;
180
- cancelled = true;
181
- if (timer) clearTimeout(timer);
182
- disposed = true;
183
- workerTimeouts.delete(child);
184
- },
185
- dispose() {
186
- if (disposed) return;
187
- if (timer) clearTimeout(timer);
188
- disposed = true;
189
- workerTimeouts.delete(child);
190
- },
191
- wasCancelled: () => cancelled,
192
- didTimeout: () => timedOut,
193
- };
75
+ export { terminateChildProcess };
194
76
 
195
- timer = setTimeout(() => {
196
- if (disposed || cancelled) return;
197
- if (!isChildRunning(child)) {
198
- handle.dispose();
199
- return;
200
- }
201
- try {
202
- if (child.kill("SIGKILL")) timedOut = true;
203
- } catch {
204
- // A failed kill is not a timeout outcome; close/error will report the
205
- // actual child result.
206
- }
207
- }, timeoutMs);
208
- timer.unref?.();
209
- workerTimeouts.set(child, handle);
210
- return handle;
77
+ /** Terminate a living teammate and wait until its child process has closed. */
78
+ export async function terminateTeammate(name: string, graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<boolean> {
79
+ const child = workers.get(name);
80
+ if (!child) return false;
81
+ return terminateChildProcess(child, graceMs);
211
82
  }
212
83
 
213
- export function cancelWorkerTimeout(child: ReturnType<typeof spawn>): void {
214
- workerTimeouts.get(child)?.cancel();
84
+ export async function terminateAllTeammates(graceMs = DEFAULT_TERMINATION_GRACE_MS): Promise<Array<{ name: string; confirmedClosed: boolean }>> {
85
+ const entries = [...workers.entries()];
86
+ return Promise.all(entries.map(async ([name, child]) => ({
87
+ name,
88
+ confirmedClosed: closedWorkers.has(child) || await terminateChildProcess(child, graceMs),
89
+ })));
215
90
  }
216
91
 
217
- function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
218
- return new Promise((resolve) => {
219
- let settled = false;
220
- const onClose = () => finish(true);
221
- const timer = setTimeout(() => finish(false), timeoutMs);
222
- timer.unref?.();
223
-
224
- function finish(closed: boolean): void {
225
- if (settled) return;
226
- settled = true;
227
- clearTimeout(timer);
228
- child.off("close", onClose);
229
- resolve(closed);
230
- }
92
+ // ── Control stream ────────────────────────────────────────────────
231
93
 
232
- child.once("close", onClose);
233
- });
94
+ function writeToControlStream(child: ChildProcess, line: unknown): boolean {
95
+ if (!child.stdin || child.stdin.destroyed || !child.stdin.writable) return false;
96
+ child.stdin.write(`${JSON.stringify(line)}\n`);
97
+ return true;
234
98
  }
235
99
 
236
100
  /**
237
- * Request a graceful child shutdown, escalate to SIGKILL when it resists, and
238
- * resolve only after a bounded wait observes the child `close` event.
101
+ * Deliver a new wake-up prompt to an idle teammate's control stream.
102
+ * This starts a fresh assistant sequence in the child process.
239
103
  */
240
- export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
241
- cancelWorkerTimeout(child);
242
- if (!isChildRunning(child)) return false;
243
-
244
- const closedAfterTerm = waitForClose(child, graceMs);
245
- try {
246
- if (!child.kill("SIGTERM")) {
247
- void closedAfterTerm;
248
- return false;
249
- }
250
- } catch {
251
- void closedAfterTerm;
252
- return false;
253
- }
254
- if (await closedAfterTerm) return true;
255
- if (!isChildRunning(child)) return false;
256
-
257
- const closedAfterKill = waitForClose(child, graceMs);
258
- try {
259
- if (!child.kill("SIGKILL")) {
260
- void closedAfterKill;
261
- return false;
262
- }
263
- } catch {
264
- void closedAfterKill;
265
- return false;
266
- }
267
- return closedAfterKill;
268
- }
269
-
270
- /** Terminate a live worker and wait until its child process has closed. */
271
- export async function terminateWorker(name: string, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
104
+ export function deliverPrompt(name: string, message: string): boolean {
272
105
  const child = workers.get(name);
273
106
  if (!child) return false;
274
- return terminateChildProcess(child, graceMs);
107
+ const sent = writeToControlStream(child, { type: "prompt", id: randomUUID(), message });
108
+ if (sent) beginSequence(name);
109
+ return sent;
275
110
  }
276
111
 
277
- /** Gracefully end a worker that has already reported a terminal task result. */
278
- export async function finishReportedWorker(name: string, graceMs = POST_REPORT_GRACE_MS): Promise<boolean> {
279
- return terminateWorker(name, graceMs);
112
+ /** Per-name stream states so prompt delivery can reset sequence boundaries. */
113
+ const streamStates = new Map<string, StreamState>();
114
+
115
+ function beginSequence(name: string): void {
116
+ const state = streamStates.get(name);
117
+ if (state) {
118
+ state.finalResponse = false;
119
+ state.text = "";
120
+ state.thinking = "";
121
+ clearActiveTools(state);
122
+ }
123
+ baselines.set(name, streamTurns.get(name) ?? 0);
280
124
  }
281
125
 
282
- /** Stop every live child before the leader discards the current session board. */
283
- export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<void> {
284
- const children = [...workers.values()];
285
- await Promise.all(children.map((child) => (isChildRunning(child) ? terminateChildProcess(child, graceMs) : undefined)));
286
- workers.clear();
126
+ /** Send a mid-turn steer to a working teammate; no peer mailbox is involved. */
127
+ export function sendWorkerSteer(name: string, message: string): boolean {
128
+ const child = workers.get(name);
129
+ return child ? writeToControlStream(child, { type: "steer", message }) : false;
287
130
  }
288
131
 
289
- const TASK_ARG_LIMIT = 8000;
290
- const OUTPUT_CAP = 16_000;
132
+ // ── Stream parsing ────────────────────────────────────────────────
291
133
 
292
134
  type JsonEvent = {
293
135
  type?: string;
136
+ toolCallId?: string;
294
137
  toolName?: string;
138
+ args?: unknown;
295
139
  assistantMessageEvent?: {
296
140
  type?: string;
297
141
  delta?: string;
@@ -311,28 +155,31 @@ type JsonEvent = {
311
155
  };
312
156
  };
313
157
 
314
- /**
315
- * Parse the JSONL output of a `pi --print --mode json` worker run.
316
- * Returns the final assistant text and the last reported usage.
317
- */
318
- export function parseWorkerOutput(stdout: string): { text: string; usage?: WorkerUsage } {
319
- const state = createWorkerStreamState();
320
- for (const line of stdout.split("\n")) applyWorkerJsonLine(state, line);
321
- return { text: state.text.trim(), usage: state.usage };
322
- }
323
-
324
- interface WorkerStreamState {
158
+ interface StreamState {
325
159
  text: string;
326
160
  thinking: string;
327
161
  toolcallArgs: string;
328
162
  activeTool?: string;
163
+ activeTools: Map<string, string>;
164
+ /** Lifetime count of completed assistant messages. */
329
165
  turns: number;
330
166
  finalResponse?: boolean;
331
167
  usage?: WorkerUsage;
332
168
  }
333
169
 
334
- function createWorkerStreamState(): WorkerStreamState {
335
- return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
170
+ function createStreamState(): StreamState {
171
+ return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
172
+ }
173
+
174
+ function clearActiveTools(state: StreamState): void {
175
+ state.toolcallArgs = "";
176
+ state.activeTool = undefined;
177
+ state.activeTools.clear();
178
+ }
179
+
180
+ function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
181
+ const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
182
+ return toolcallLabel(serialized) ?? toolName ?? "tool";
336
183
  }
337
184
 
338
185
  /** Human-readable label from a partially streamed tool-call argument JSON. */
@@ -342,20 +189,26 @@ function toolcallLabel(rawArgs: string): string | undefined {
342
189
  try {
343
190
  const args = JSON.parse(trimmed) as Record<string, unknown>;
344
191
  const command = args.command;
345
- if (typeof command === "string" && command.trim()) return `bash: ${truncateInline(command, 40)}`;
192
+ if (typeof command === "string" && command.trim()) return `bash: ${normalizeInline(command)}`;
346
193
  const filePath = args.path;
347
- if (typeof filePath === "string" && filePath.trim()) return `file: ${path.basename(filePath.trim())}`;
194
+ if (typeof filePath === "string" && filePath.trim()) return `file: ${normalizeInline(path.basename(filePath.trim()))}`;
348
195
  const subject = args.subject;
349
- if (typeof subject === "string" && subject.trim()) return `message: ${truncateInline(subject, 40)}`;
196
+ if (typeof subject === "string" && subject.trim()) return `message: ${normalizeInline(subject)}`;
350
197
  const query = args.query;
351
- if (typeof query === "string" && query.trim()) return `search: ${truncateInline(query, 40)}`;
198
+ if (typeof query === "string" && query.trim()) return `search: ${normalizeInline(query)}`;
199
+ const to = args.to;
200
+ if (typeof to === "string" && to.trim() && typeof args.subject === "string") return `send: ${normalizeInline(args.subject)}`;
352
201
  } catch {
353
202
  // Incomplete JSON mid-stream — retry on the next delta.
354
203
  }
355
204
  return undefined;
356
205
  }
357
206
 
358
- function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
207
+ function normalizeInline(text: string): string {
208
+ return text.replace(/\s+/g, " ").trim();
209
+ }
210
+
211
+ function applyStreamLine(state: StreamState, line: string): boolean {
359
212
  if (!line.trim()) return false;
360
213
  let event: JsonEvent;
361
214
  try {
@@ -363,21 +216,34 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
363
216
  } catch {
364
217
  return false;
365
218
  }
219
+ if (event.type === "tool_execution_start") {
220
+ state.activeTools.set(event.toolCallId ?? `tool-${state.activeTools.size}`, toolExecutionLabel(event.toolName, event.args));
221
+ state.activeTool = [...state.activeTools.values()].at(-1);
222
+ return true;
223
+ }
224
+ if (event.type === "tool_execution_end") {
225
+ if (event.toolCallId) state.activeTools.delete(event.toolCallId);
226
+ else state.activeTools.clear();
227
+ state.activeTool = [...state.activeTools.values()].at(-1);
228
+ return true;
229
+ }
366
230
  if (event.type !== "message_update") {
367
231
  if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
368
232
  state.turns++;
233
+ clearActiveTools(state);
234
+ state.thinking = "";
369
235
  if (event.message.stopReason === "stop") state.finalResponse = true;
370
236
  const parts = extractTextContent(event.message.content, "");
371
237
  if (parts.trim()) state.text = parts;
372
238
  const u = event.message.usage;
373
239
  if (u) {
374
240
  state.usage = {
375
- input: u.input ?? 0,
376
- output: u.output ?? 0,
377
- cacheRead: u.cacheRead ?? 0,
378
- cacheWrite: u.cacheWrite ?? 0,
379
- totalTokens: u.totalTokens ?? 0,
380
- cost: u.cost?.total ?? 0,
241
+ input: (state.usage?.input ?? 0) + (u.input ?? 0),
242
+ output: (state.usage?.output ?? 0) + (u.output ?? 0),
243
+ cacheRead: (state.usage?.cacheRead ?? 0) + (u.cacheRead ?? 0),
244
+ cacheWrite: (state.usage?.cacheWrite ?? 0) + (u.cacheWrite ?? 0),
245
+ totalTokens: (state.usage?.totalTokens ?? 0) + (u.totalTokens ?? 0),
246
+ cost: (state.usage?.cost ?? 0) + (u.cost?.total ?? 0),
381
247
  };
382
248
  }
383
249
  return true;
@@ -386,14 +252,15 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
386
252
  if (!sub) return false;
387
253
  switch (sub.type) {
388
254
  case "text_delta":
255
+ state.activeTool = undefined;
389
256
  state.text += sub.delta ?? "";
390
257
  return true;
391
258
  case "thinking_delta":
259
+ state.activeTool = undefined;
392
260
  state.thinking += sub.delta ?? "";
393
261
  return true;
394
262
  case "toolcall_start":
395
- state.toolcallArgs = "";
396
- state.activeTool = undefined;
263
+ clearActiveTools(state);
397
264
  return true;
398
265
  case "toolcall_delta": {
399
266
  state.toolcallArgs += sub.delta ?? "";
@@ -401,23 +268,20 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
401
268
  if (label) state.activeTool = label;
402
269
  return true;
403
270
  }
404
- case "toolcall_end": {
405
- const tc = (sub as { toolCall?: { name?: string } }).toolCall;
406
- // Prefer the richer delta-derived label (e.g. "bash: echo hello");
407
- // fall back to the bare tool name only when no label could be parsed.
408
- if (tc?.name && !state.activeTool) state.activeTool = tc.name;
409
- state.toolcallArgs = "";
271
+ case "toolcall_end":
272
+ // Execution events own the activity label until the result arrives.
273
+ clearActiveTools(state);
410
274
  return true;
411
- }
412
275
  default:
413
276
  return false;
414
277
  }
415
278
  }
416
279
 
417
- function truncateInline(text: string, cap: number): string {
418
- const oneLine = text.replace(/\s+/g, " ").trim();
419
- if (oneLine.length <= cap) return oneLine;
420
- return `${oneLine.slice(0, cap).trimEnd()} ...`;
280
+ /** Parse the final assistant text and accumulated usage from captured stdout. */
281
+ export function parseTeammateOutput(stdout: string): { text: string; usage?: WorkerUsage } {
282
+ const state = createStreamState();
283
+ for (const line of stdout.split("\n")) applyStreamLine(state, line);
284
+ return { text: state.text.trim(), usage: state.usage };
421
285
  }
422
286
 
423
287
  function truncate(text: string, cap: number): string {
@@ -425,89 +289,51 @@ function truncate(text: string, cap: number): string {
425
289
  return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
426
290
  }
427
291
 
428
- export interface PiCliResolution {
429
- /** Command to execute — either a runtime (node/bun) or a `pi` binary. */
430
- command: string;
431
- /** Leading args: the CLI script path when running via a runtime, else empty. */
432
- args: string[];
433
- }
434
-
435
- /** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
436
- function isPiPackageScript(filePath: string): boolean {
437
- try {
438
- const resolved = fs.realpathSync(filePath);
439
- if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
440
- let dir = path.dirname(resolved);
441
- while (dir !== path.dirname(dir)) {
442
- const pkgPath = path.join(dir, "package.json");
443
- if (fs.existsSync(pkgPath)) {
444
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
445
- return pkg.name === "@earendil-works/pi-coding-agent";
446
- }
447
- dir = path.dirname(dir);
448
- }
449
- } catch {
450
- // Unreadable paths are simply not candidates.
451
- }
452
- return false;
453
- }
292
+ // ── Resident spawn ────────────────────────────────────────────────
454
293
 
455
- /**
456
- * Resolve how to launch a worker Pi process.
457
- *
458
- * Resolution order:
459
- * 1. `process.argv[1]` — the current Pi process entry, verified against the
460
- * package manifest (avoids mistaking unrelated scripts for the CLI).
461
- * 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
462
- * 3. A `pi` binary on PATH (best effort).
463
- */
464
- export function resolvePiCli(): PiCliResolution {
465
- const argv1 = process.argv[1];
466
- if (argv1 && isPiPackageScript(argv1)) {
467
- return { command: process.execPath, args: [path.resolve(argv1)] };
468
- }
294
+ /** Turn-count baseline per teammate at its most recent delivered prompt. */
295
+ const baselines = new Map<string, number>();
296
+ const streamTurns = new Map<string, number>();
469
297
 
470
- try {
471
- const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
472
- const packageRoot = path.dirname(path.dirname(entry));
473
- const cliPath = path.join(packageRoot, "dist", "cli.js");
474
- if (fs.existsSync(cliPath)) {
475
- return { command: process.execPath, args: [cliPath] };
476
- }
477
- } catch {
478
- // Package resolution is best-effort; fall through to PATH.
479
- }
298
+ export interface ResidentSpawnOptions {
299
+ /** Teammate name — the registry key for steering, prompting, and shutdown. */
300
+ workerName: string;
301
+ /** Optional kickoff prompt; omit to let the teammate idle immediately. */
302
+ description?: string;
303
+ model?: string;
304
+ /** Execution-tool allowlist; capability tools are always appended. */
305
+ tools?: string[];
306
+ env?: Record<string, string | undefined>;
307
+ cwd?: string;
308
+ onUpdate?: (update: WorkerProgressUpdate) => void;
309
+ onExit: (result: WorkerProcessResult) => void;
310
+ onError?: (error: Error) => void;
311
+ }
480
312
 
481
- return { command: "pi", args: [] };
313
+ export interface SpawnedResident {
314
+ pid: number;
482
315
  }
483
316
 
317
+ const WORKER_EXTENSION = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
318
+
484
319
  /**
485
- * Spawn a child Pi process that executes the task description in
486
- * non-interactive mode. Returns immediately with the child pid; the outcome
487
- * is delivered via `onExit` / `onError`.
320
+ * Spawn one resident child Pi process in RPC mode. Returns immediately with
321
+ * the child pid; outcomes arrive via onExit / onError. An empty description
322
+ * spawns an idle teammate that waits for its first delivered prompt.
488
323
  */
489
- export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | { error: string } {
490
- // resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
491
- // binary surfaces later as a child "error" event through onError.
324
+ export function spawnResident(options: ResidentSpawnOptions): SpawnedResident | { error: string } {
492
325
  const cli = resolvePiCli();
493
- const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
494
326
  const args: string[] = [
495
327
  ...cli.args,
496
- "--print",
497
- "--mode",
498
- "json",
328
+ "--mode", "rpc",
499
329
  "--no-session",
500
330
  "--no-extensions",
501
- "--extension",
502
- workerExtension,
331
+ "--extension", WORKER_EXTENSION,
503
332
  ];
504
333
  if (options.model) args.push("--model", options.model);
505
- // Capability tools cannot be removed; execution tools are selected by the
506
- // leader from the teammate's explicit configuration or role default.
507
- const capabilityTools = ["teammate_message"];
508
- const requestedTools = (options.tools ?? []).filter((tool) => !tool.startsWith("teammate_"));
509
- const tools = [...new Set([...requestedTools, ...capabilityTools])];
510
- args.push("--tools", tools.join(","));
334
+ const capabilityTools = ["send_message", "task_list", "task_claim", "task_submit"];
335
+ const requestedTools = (options.tools ?? []).filter((tool) => !capabilityTools.includes(tool));
336
+ args.push("--tools", [...new Set([...requestedTools, ...capabilityTools])].join(","));
511
337
 
512
338
  let tempDir: string | undefined;
513
339
  const cleanupTempDir = () => {
@@ -520,93 +346,91 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
520
346
  tempDir = undefined;
521
347
  }
522
348
  };
523
- const setupError = (error: unknown): { error: string } => ({
524
- error: error instanceof Error ? error.message : String(error),
525
- });
526
-
527
- try {
528
- const taskText = `Task: ${options.description}`;
529
- if (taskText.length > TASK_ARG_LIMIT) {
530
- tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
531
- const taskFile = path.join(tempDir, "task.md");
532
- fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
533
- args.push(`@${taskFile}`);
534
- } else {
535
- args.push(taskText);
536
- }
537
- } catch (error) {
538
- cleanupTempDir();
539
- return setupError(error);
540
- }
541
349
 
542
- let child;
350
+ let child: ChildProcess;
543
351
  try {
544
- child = spawn(cli.command, args, {
352
+ child = spawnPiChild(cli.command, args, {
545
353
  cwd: options.cwd,
546
354
  env: { ...process.env, ...options.env },
547
- stdio: ["ignore", "pipe", "pipe"],
355
+ stdio: ["pipe", "pipe", "pipe"],
548
356
  });
549
357
  } catch (error) {
550
358
  cleanupTempDir();
551
- return setupError(error);
359
+ return { error: error instanceof Error ? error.message : String(error) };
360
+ }
361
+ if (options.description && options.description.trim()) {
362
+ writeToControlStream(child, { type: "prompt", id: randomUUID(), message: options.description });
363
+ baselines.set(options.workerName, 0);
552
364
  }
553
- if (options.workerName) workers.set(options.workerName, child);
365
+ observeWorkerClose(options.workerName, child);
366
+ workers.set(options.workerName, child);
554
367
 
555
368
  const stdoutChunks: string[] = [];
556
369
  const stderrChunks: string[] = [];
557
- const streamState = createWorkerStreamState();
370
+ const streamState = createStreamState();
371
+ streamStates.set(options.workerName, streamState);
558
372
  let stdoutBuffer = "";
559
- let timeoutHandle: WorkerTimeoutHandle | undefined;
560
373
  let settled = false;
374
+
561
375
  const emitProgress = () => options.onUpdate?.({
562
376
  text: truncate(streamState.text, OUTPUT_CAP),
563
377
  activeTool: streamState.activeTool,
564
378
  liveThinking: truncate(streamState.thinking, OUTPUT_CAP),
565
- turns: streamState.turns,
379
+ turns: Math.max(0, streamState.turns - (baselines.get(options.workerName) ?? 0)),
566
380
  finalResponse: streamState.finalResponse,
567
381
  });
568
- child.stdout.on("data", (chunk: Buffer) => {
382
+
383
+ child.stdout?.on("data", (chunk: Buffer) => {
569
384
  const text = chunk.toString();
570
- stdoutChunks.push(text);
385
+ appendCapped(stdoutChunks, text, OUTPUT_CAP * 4);
571
386
  stdoutBuffer += text;
572
387
  const lines = stdoutBuffer.split("\n");
573
388
  stdoutBuffer = lines.pop() ?? "";
574
389
  let changed = false;
575
- for (const line of lines) changed = applyWorkerJsonLine(streamState, line) || changed;
390
+ for (const line of lines) {
391
+ changed = applyStreamLine(streamState, line) || changed;
392
+ }
393
+ streamTurns.set(options.workerName, streamState.turns);
576
394
  if (changed) emitProgress();
577
395
  });
578
- child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
579
-
580
- if (options.timeoutMs) {
581
- timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
582
- }
396
+ child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
583
397
 
584
398
  child.on("error", (error) => {
585
- timeoutHandle?.dispose();
586
399
  cleanupTempDir();
587
400
  settled = true;
588
- if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
401
+ // Keep the registry entry until close is observed so shutdown diagnostics
402
+ // can distinguish an error/exit code from a confirmed close event.
589
403
  options.onError?.(error);
590
404
  });
591
405
 
592
406
  child.on("close", (code, signal) => {
593
- const timedOut = timeoutHandle?.didTimeout() ?? false;
594
- timeoutHandle?.dispose();
595
407
  cleanupTempDir();
596
- // A spawn failure already reported via onError (Node fires error then
597
- // close) — do not double-report through onExit, which would let a failed
598
- // spawn look like a successful 0-exit run.
408
+ if (workers.get(options.workerName) === child) workers.delete(options.workerName);
409
+ streamStates.delete(options.workerName);
410
+ baselines.delete(options.workerName);
411
+ streamTurns.delete(options.workerName);
412
+ // A spawn failure was already reported via onError (Node fires error then
413
+ // close) — do not double-report through onExit.
599
414
  if (settled) return;
600
- if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
601
- const rawStdout = stdoutChunks.join("");
602
- const parsed = (options.mode ?? "json") === "json"
603
- ? parseWorkerOutput(rawStdout)
604
- : { text: rawStdout.trim(), usage: undefined };
605
- const stdout = truncate(parsed.text, OUTPUT_CAP);
606
- const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
607
- options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, timedOut });
415
+ const parsed = parseTeammateOutput(stdoutChunks.join(""));
416
+ options.onExit({
417
+ pid: child.pid ?? 0,
418
+ exitCode: code,
419
+ signal,
420
+ stdout: truncate(parsed.text, OUTPUT_CAP),
421
+ stderr: truncate(stderrChunks.join("").trim(), OUTPUT_CAP),
422
+ usage: parsed.usage,
423
+ });
608
424
  });
609
425
 
610
426
  return { pid: child.pid ?? 0 };
611
427
  }
612
428
 
429
+ function appendCapped(chunks: string[], chunk: string, cap: number): void {
430
+ chunks.push(chunk);
431
+ let total = chunks.reduce((sum, value) => sum + value.length, 0);
432
+ while (total > cap && chunks.length > 1) {
433
+ total -= chunks.shift()?.length ?? 0;
434
+ }
435
+ if (total > cap && chunks.length === 1) chunks[0] = chunks[0].slice(-cap);
436
+ }