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