@fradser/pi-agent-teams 0.5.1 → 0.5.3

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
@@ -8,13 +8,17 @@
8
8
  */
9
9
 
10
10
  import { spawn } from "node:child_process";
11
+ import { randomUUID } from "node:crypto";
11
12
  import * as fs from "node:fs";
12
13
  import * as os from "node:os";
13
14
  import * as path from "node:path";
14
15
  import { fileURLToPath } from "node:url";
15
- import { extractTextContent } from "@fradser/pi-kit";
16
+ import { extractTextContent, resolvePiCli } from "@fradser/pi-kit";
16
17
  import type { WorkerUsage } from "./types";
17
18
 
19
+ /** High default safety cap; explicit task budgets can still be lower. */
20
+ export const DEFAULT_TURN_BUDGET = 100;
21
+
18
22
  /**
19
23
  * Build the one-task execution prompt for a spawned worker.
20
24
  *
@@ -26,9 +30,12 @@ export function buildAutonomousPrompt(opts: {
26
30
  role: string;
27
31
  prompt: string;
28
32
  taskId?: string;
29
- timeoutSec: number;
33
+ turnBudget?: number;
30
34
  }): string {
31
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.`;
32
39
 
33
40
  return `You are a FULLY AUTONOMOUS teammate named "${opts.name}" (agent: ${opts.role}) in a pi multi-agent team run.
34
41
 
@@ -40,7 +47,7 @@ YOUR ROLE IN THIS RUN:
40
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.
41
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.
42
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.
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.
50
+ ${budgetLine}
44
51
 
45
52
  BOUND CAPABILITIES:
46
53
  - teammate_message reports progress or a final deliverable to the team leader.`;
@@ -59,10 +66,10 @@ export interface SpawnPiWorkerOptions {
59
66
  env?: Record<string, string | undefined>;
60
67
  /** Working directory for the child (defaults to the parent's cwd). */
61
68
  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";
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";
66
73
  /** Called whenever JSON-mode worker output reveals live model or tool activity. */
67
74
  onUpdate?: (update: WorkerProgressUpdate) => void;
68
75
  /** Called once with the captured output when the child exits. */
@@ -78,7 +85,7 @@ export interface WorkerProcessResult {
78
85
  stdout: string;
79
86
  stderr: string;
80
87
  usage?: WorkerUsage;
81
- timedOut: boolean;
88
+ turnBudgetExceeded?: boolean;
82
89
  }
83
90
 
84
91
  /** Live state extracted from a spawned worker's JSON-mode output. */
@@ -91,13 +98,13 @@ export interface WorkerProgressUpdate {
91
98
  }
92
99
 
93
100
  /** 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;
101
+ export function isSuccessfulWorkerExit(result: Pick<WorkerProcessResult, "exitCode" | "signal">): boolean {
102
+ return result.exitCode === 0 && result.signal === null;
96
103
  }
97
104
 
98
- /** A reported completion remains successful when the harness closes it or teardown observes a signal/timeout. */
105
+ /** A reported completion remains successful when the harness closes it or teardown observes a signal. */
99
106
  export function isCompletedWorkerExit(
100
- result: Pick<WorkerProcessResult, "exitCode" | "signal" | "timedOut">,
107
+ result: Pick<WorkerProcessResult, "exitCode" | "signal">,
101
108
  reportedCompleted = false,
102
109
  ): boolean {
103
110
  return isSuccessfulWorkerExit(result) || reportedCompleted;
@@ -152,6 +159,14 @@ export class CancellationIntents {
152
159
  /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
153
160
  const workers = new Map<string, ReturnType<typeof spawn>>();
154
161
 
162
+ /** Send a steering message to an RPC worker without adding a peer mailbox. */
163
+ export function sendWorkerSteer(name: string, message: string): boolean {
164
+ 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;
168
+ }
169
+
155
170
  function isChildRunning(child: ReturnType<typeof spawn>): boolean {
156
171
  return child.exitCode === null && child.signalCode === null;
157
172
  }
@@ -230,9 +245,20 @@ export async function terminateAllWorkers(graceMs = CANCEL_GRACE_MS): Promise<vo
230
245
  const TASK_ARG_LIMIT = 8000;
231
246
  const OUTPUT_CAP = 16_000;
232
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);
255
+ }
256
+
233
257
  type JsonEvent = {
234
258
  type?: string;
259
+ toolCallId?: string;
235
260
  toolName?: string;
261
+ args?: unknown;
236
262
  assistantMessageEvent?: {
237
263
  type?: string;
238
264
  delta?: string;
@@ -262,18 +288,53 @@ export function parseWorkerOutput(stdout: string): { text: string; usage?: Worke
262
288
  return { text: state.text.trim(), usage: state.usage };
263
289
  }
264
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
+
265
304
  interface WorkerStreamState {
266
305
  text: string;
267
306
  thinking: string;
268
307
  toolcallArgs: string;
269
308
  activeTool?: string;
309
+ activeToolCallId?: string;
310
+ activeTools: Map<string, string>;
270
311
  turns: number;
271
312
  finalResponse?: boolean;
272
313
  usage?: WorkerUsage;
273
314
  }
274
315
 
275
316
  function createWorkerStreamState(): WorkerStreamState {
276
- return { text: "", thinking: "", toolcallArgs: "", turns: 0 };
317
+ return { text: "", thinking: "", toolcallArgs: "", activeTools: new Map(), turns: 0 };
318
+ }
319
+
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];
333
+ }
334
+
335
+ function toolExecutionLabel(toolName: string | undefined, args: unknown): string {
336
+ const serialized = typeof args === "string" ? args : JSON.stringify(args ?? {});
337
+ return toolcallLabel(serialized) ?? toolName ?? "tool";
277
338
  }
278
339
 
279
340
  /** Human-readable label from a partially streamed tool-call argument JSON. */
@@ -304,9 +365,31 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
304
365
  } catch {
305
366
  return false;
306
367
  }
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
+ if (event.type === "tool_execution_start") {
376
+ setActiveTool(state, event.toolCallId, toolExecutionLabel(event.toolName, event.args));
377
+ return true;
378
+ }
379
+ 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;
385
+ }
307
386
  if (event.type !== "message_update") {
308
387
  if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
309
388
  state.turns++;
389
+ state.activeTool = undefined;
390
+ state.activeToolCallId = undefined;
391
+ state.activeTools.clear();
392
+ state.thinking = "";
310
393
  if (event.message.stopReason === "stop") state.finalResponse = true;
311
394
  const parts = extractTextContent(event.message.content, "");
312
395
  if (parts.trim()) state.text = parts;
@@ -327,14 +410,18 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
327
410
  if (!sub) return false;
328
411
  switch (sub.type) {
329
412
  case "text_delta":
413
+ state.activeTool = undefined;
330
414
  state.text += sub.delta ?? "";
331
415
  return true;
332
416
  case "thinking_delta":
417
+ state.activeTool = undefined;
333
418
  state.thinking += sub.delta ?? "";
334
419
  return true;
335
420
  case "toolcall_start":
336
421
  state.toolcallArgs = "";
337
422
  state.activeTool = undefined;
423
+ state.activeToolCallId = undefined;
424
+ state.activeTools.clear();
338
425
  return true;
339
426
  case "toolcall_delta": {
340
427
  state.toolcallArgs += sub.delta ?? "";
@@ -342,14 +429,14 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
342
429
  if (label) state.activeTool = label;
343
430
  return true;
344
431
  }
345
- case "toolcall_end": {
346
- const tc = (sub as { toolCall?: { name?: string } }).toolCall;
347
- // Prefer the richer delta-derived label (e.g. "bash: echo hello");
348
- // fall back to the bare tool name only when no label could be parsed.
349
- if (tc?.name && !state.activeTool) state.activeTool = tc.name;
432
+ case "toolcall_end":
433
+ // The tool call has been emitted; execution events now own the current
434
+ // activity label until the tool result arrives.
350
435
  state.toolcallArgs = "";
436
+ state.activeTool = undefined;
437
+ state.activeToolCallId = undefined;
438
+ state.activeTools.clear();
351
439
  return true;
352
- }
353
440
  default:
354
441
  return false;
355
442
  }
@@ -366,61 +453,11 @@ function truncate(text: string, cap: number): string {
366
453
  return `${text.slice(0, cap)}\n... [truncated ${text.length - cap} chars]`;
367
454
  }
368
455
 
369
- export interface PiCliResolution {
370
- /** Command to execute — either a runtime (node/bun) or a `pi` binary. */
371
- command: string;
372
- /** Leading args: the CLI script path when running via a runtime, else empty. */
373
- args: string[];
374
- }
375
-
376
- /** Whether a file is the pi coding-agent CLI script (verified against its package manifest). */
377
- function isPiPackageScript(filePath: string): boolean {
378
- try {
379
- const resolved = fs.realpathSync(filePath);
380
- if (!/\.(mjs|cjs|js)$/.test(resolved)) return false;
381
- let dir = path.dirname(resolved);
382
- while (dir !== path.dirname(dir)) {
383
- const pkgPath = path.join(dir, "package.json");
384
- if (fs.existsSync(pkgPath)) {
385
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8")) as { name?: unknown };
386
- return pkg.name === "@earendil-works/pi-coding-agent";
387
- }
388
- dir = path.dirname(dir);
389
- }
390
- } catch {
391
- // Unreadable paths are simply not candidates.
392
- }
393
- return false;
394
- }
395
-
396
456
  /**
397
- * Resolve how to launch a worker Pi process.
398
- *
399
- * Resolution order:
400
- * 1. `process.argv[1]` — the current Pi process entry, verified against the
401
- * package manifest (avoids mistaking unrelated scripts for the CLI).
402
- * 2. The installed `@earendil-works/pi-coding-agent` package's `dist/cli.js`.
403
- * 3. A `pi` binary on PATH (best effort).
457
+ * Resolve how to launch a Pi CLI process.
458
+ * Re-exported from pi-kit for backward compatibility.
404
459
  */
405
- export function resolvePiCli(): PiCliResolution {
406
- const argv1 = process.argv[1];
407
- if (argv1 && isPiPackageScript(argv1)) {
408
- return { command: process.execPath, args: [path.resolve(argv1)] };
409
- }
410
-
411
- try {
412
- const entry = fileURLToPath(import.meta.resolve("@earendil-works/pi-coding-agent"));
413
- const packageRoot = path.dirname(path.dirname(entry));
414
- const cliPath = path.join(packageRoot, "dist", "cli.js");
415
- if (fs.existsSync(cliPath)) {
416
- return { command: process.execPath, args: [cliPath] };
417
- }
418
- } catch {
419
- // Package resolution is best-effort; fall through to PATH.
420
- }
421
-
422
- return { command: "pi", args: [] };
423
- }
460
+ export { resolvePiCli } from "@fradser/pi-kit";
424
461
 
425
462
  /**
426
463
  * Spawn a child Pi process that executes the task description in
@@ -431,7 +468,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
431
468
  // resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
432
469
  // binary surfaces later as a child "error" event through onError.
433
470
  const cli = resolvePiCli();
434
- const args: string[] = [...cli.args, "--print", "--mode", "json", "--no-session"];
471
+ const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
472
+ const mode = options.mode ?? "json";
473
+ const args: string[] = [
474
+ ...cli.args,
475
+ ...(mode === "rpc" ? ["--mode", "rpc"] : ["--print", "--mode", mode]),
476
+ "--no-session",
477
+ "--no-extensions",
478
+ "--extension",
479
+ workerExtension,
480
+ ];
435
481
  if (options.model) args.push("--model", options.model);
436
482
  // Capability tools cannot be removed; execution tools are selected by the
437
483
  // leader from the teammate's explicit configuration or role default.
@@ -455,14 +501,14 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
455
501
  error: error instanceof Error ? error.message : String(error),
456
502
  });
457
503
 
504
+ let taskText = `Task: ${options.description}`;
458
505
  try {
459
- const taskText = `Task: ${options.description}`;
460
- if (taskText.length > TASK_ARG_LIMIT) {
506
+ if (mode !== "rpc" && taskText.length > TASK_ARG_LIMIT) {
461
507
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "teammate-"));
462
508
  const taskFile = path.join(tempDir, "task.md");
463
509
  fs.writeFileSync(taskFile, taskText, { mode: 0o600 });
464
510
  args.push(`@${taskFile}`);
465
- } else {
511
+ } else if (mode !== "rpc") {
466
512
  args.push(taskText);
467
513
  }
468
514
  } catch (error) {
@@ -475,7 +521,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
475
521
  child = spawn(cli.command, args, {
476
522
  cwd: options.cwd,
477
523
  env: { ...process.env, ...options.env },
478
- stdio: ["ignore", "pipe", "pipe"],
524
+ stdio: [mode === "rpc" ? "pipe" : "ignore", "pipe", "pipe"],
479
525
  });
480
526
  } catch (error) {
481
527
  cleanupTempDir();
@@ -483,12 +529,16 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
483
529
  }
484
530
  if (options.workerName) workers.set(options.workerName, child);
485
531
 
532
+ if (mode === "rpc") {
533
+ child.stdin?.write(`${JSON.stringify({ type: "prompt", id: randomUUID(), message: taskText })}\n`);
534
+ }
535
+
486
536
  const stdoutChunks: string[] = [];
487
537
  const stderrChunks: string[] = [];
488
538
  const streamState = createWorkerStreamState();
489
539
  let stdoutBuffer = "";
490
- let timedOut = false;
491
540
  let settled = false;
541
+ let turnBudgetExceeded = false;
492
542
  const emitProgress = () => options.onUpdate?.({
493
543
  text: truncate(streamState.text, OUTPUT_CAP),
494
544
  activeTool: streamState.activeTool,
@@ -496,29 +546,25 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
496
546
  turns: streamState.turns,
497
547
  finalResponse: streamState.finalResponse,
498
548
  });
499
- child.stdout.on("data", (chunk: Buffer) => {
549
+ child.stdout?.on("data", (chunk: Buffer) => {
500
550
  const text = chunk.toString();
501
- stdoutChunks.push(text);
551
+ appendCapped(stdoutChunks, text, OUTPUT_CAP * 2);
502
552
  stdoutBuffer += text;
503
553
  const lines = stdoutBuffer.split("\n");
504
554
  stdoutBuffer = lines.pop() ?? "";
505
555
  let changed = false;
506
- for (const line of lines) changed = applyWorkerJsonLine(streamState, line) || changed;
556
+ 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
+ }
562
+ }
507
563
  if (changed) emitProgress();
508
564
  });
509
- child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
510
-
511
- let timer: ReturnType<typeof setTimeout> | undefined;
512
- if (options.timeoutMs) {
513
- timer = setTimeout(() => {
514
- timedOut = true;
515
- child.kill("SIGKILL");
516
- }, options.timeoutMs);
517
- timer.unref?.();
518
- }
565
+ child.stderr?.on("data", (chunk: Buffer) => appendCapped(stderrChunks, chunk.toString(), OUTPUT_CAP * 2));
519
566
 
520
567
  child.on("error", (error) => {
521
- if (timer) clearTimeout(timer);
522
568
  cleanupTempDir();
523
569
  settled = true;
524
570
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
@@ -526,7 +572,6 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
526
572
  });
527
573
 
528
574
  child.on("close", (code, signal) => {
529
- if (timer) clearTimeout(timer);
530
575
  cleanupTempDir();
531
576
  // A spawn failure already reported via onError (Node fires error then
532
577
  // close) — do not double-report through onExit, which would let a failed
@@ -534,12 +579,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
534
579
  if (settled) return;
535
580
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
536
581
  const rawStdout = stdoutChunks.join("");
537
- const parsed = (options.mode ?? "json") === "json"
538
- ? parseWorkerOutput(rawStdout)
539
- : { text: rawStdout.trim(), usage: undefined };
582
+ const parsed = mode === "text"
583
+ ? { text: rawStdout.trim(), usage: undefined }
584
+ : parseWorkerOutput(rawStdout);
540
585
  const stdout = truncate(parsed.text, OUTPUT_CAP);
541
586
  const stderr = truncate(stderrChunks.join("").trim(), OUTPUT_CAP);
542
- options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, timedOut });
587
+ options.onExit({ pid: child.pid ?? 0, exitCode: code, signal, stdout, stderr, usage: parsed.usage, turnBudgetExceeded });
543
588
  });
544
589
 
545
590
  return { pid: child.pid ?? 0 };
package/src/state.ts CHANGED
@@ -84,6 +84,28 @@ export function normalizeNodePaths(paths: string[]): { ok: true; paths: string[]
84
84
 
85
85
  // ── Run creation ──────────────────────────────────────────────────
86
86
 
87
+ export const MAX_STRUCTURED_OUTPUT_BYTES = 64 * 1024;
88
+ export const MAX_STRUCTURED_OUTPUT_DEPTH = 8;
89
+ export const MAX_FANOUT_ITEMS = 32;
90
+
91
+ export function structuredOutputSize(value: unknown): number {
92
+ return Buffer.byteLength(JSON.stringify(value), "utf8");
93
+ }
94
+
95
+ export function structuredOutputDepth(value: unknown): number {
96
+ if (!value || typeof value !== "object") return 0;
97
+ if (Array.isArray(value)) return 1 + Math.max(0, ...value.map(structuredOutputDepth));
98
+ return 1 + Math.max(0, ...Object.values(value as Record<string, unknown>).map(structuredOutputDepth));
99
+ }
100
+
101
+ export function validateStructuredOutput(value: unknown): string | undefined {
102
+ let serialized: string;
103
+ try { serialized = JSON.stringify(value); } catch { return "Structured output must be JSON-serializable."; }
104
+ if (Buffer.byteLength(serialized, "utf8") > MAX_STRUCTURED_OUTPUT_BYTES) return `Structured output exceeds ${MAX_STRUCTURED_OUTPUT_BYTES} bytes.`;
105
+ if (structuredOutputDepth(value) > MAX_STRUCTURED_OUTPUT_DEPTH) return `Structured output exceeds depth ${MAX_STRUCTURED_OUTPUT_DEPTH}.`;
106
+ return undefined;
107
+ }
108
+
87
109
  export interface RunNodeInput {
88
110
  id: string;
89
111
  agent: string;
@@ -91,7 +113,10 @@ export interface RunNodeInput {
91
113
  paths: string[];
92
114
  access: Node["access"];
93
115
  model?: string;
94
- timeoutMs?: number;
116
+ mode?: Node["mode"];
117
+ turnBudget?: number;
118
+ forkContext?: string[];
119
+ inputBindings?: Record<string, string>;
95
120
  dependsOn: string[];
96
121
  }
97
122
 
@@ -122,7 +147,6 @@ export function createRun(
122
147
  concurrency: number;
123
148
  worktree: boolean;
124
149
  background?: boolean;
125
- timeoutMs?: number;
126
150
  summarize?: boolean;
127
151
  summaryAgent?: string;
128
152
  nodes: RunNodeInput[];
@@ -146,6 +170,15 @@ export function createRun(
146
170
  return { ok: false, error: `Node "${node.id}" depends on unknown node "${dep}".` };
147
171
  }
148
172
  }
173
+ for (const source of Object.values(node.inputBindings ?? {})) {
174
+ const dependencyId = source.split("#", 1)[0];
175
+ if (!node.dependsOn.includes(dependencyId)) {
176
+ return { ok: false, error: `Node "${node.id}" input binding must reference a dependency: "${source}".` };
177
+ }
178
+ }
179
+ if (node.forkContext?.some((dependencyId) => !node.dependsOn.includes(dependencyId))) {
180
+ return { ok: false, error: `Node "${node.id}" forkContext must reference dependencies only.` };
181
+ }
149
182
  normalized.push({
150
183
  id: node.id,
151
184
  workerKey: "", // filled after the run id is assigned
@@ -154,7 +187,10 @@ export function createRun(
154
187
  paths: normalizedPaths.paths,
155
188
  access: node.access,
156
189
  model: node.model,
157
- timeoutMs: node.timeoutMs,
190
+ mode: node.mode,
191
+ turnBudget: node.turnBudget,
192
+ forkContext: node.forkContext ? [...new Set(node.forkContext)] : undefined,
193
+ inputBindings: node.inputBindings ? { ...node.inputBindings } : undefined,
158
194
  dependsOn: [...new Set(node.dependsOn)],
159
195
  status: "pending",
160
196
  createdAt: Date.now(),
@@ -208,8 +244,6 @@ export function createRun(
208
244
  concurrency: input.concurrency,
209
245
  worktree: input.worktree,
210
246
  background: input.background ?? true,
211
- timeoutMs: input.timeoutMs,
212
- deadlineAt: input.timeoutMs ? Date.now() + input.timeoutMs : undefined,
213
247
  nodes: {},
214
248
  createdAt: Date.now(),
215
249
  updatedAt: Date.now(),
@@ -526,40 +560,6 @@ export function cancelNode(runId: string, nodeId: string): { ok: boolean; runnin
526
560
  return { ok: true, runningNodeIds };
527
561
  }
528
562
 
529
- /**
530
- * Fail a run for exceeding its hard wall-clock cap: pending nodes are
531
- * cancelled and the run is marked failed; live workers must be terminated by
532
- * the caller.
533
- */
534
- export function failRunTimeout(runId: string, errorMessage: string): { ok: boolean; runningNodeIds: string[]; error?: string } {
535
- const run = state.runs[runId];
536
- if (!run) return { ok: false, runningNodeIds: [], error: `Run "${runId}" not found.` };
537
- if (run.status !== "running") return { ok: false, runningNodeIds: [], error: `Run "${runId}" is already ${run.status}.` };
538
- const runningNodeIds: string[] = [];
539
- for (const node of Object.values(run.nodes)) {
540
- if (node.status === "running") {
541
- // The node's worker is terminated by the caller; clearing the spawn means
542
- // its late close event cannot pollute a retried (reset) node.
543
- node.status = "failed";
544
- node.errorMessage = errorMessage;
545
- node.completedAt = Date.now();
546
- node.spawn = undefined;
547
- node.updatedAt = Date.now();
548
- runningNodeIds.push(node.id);
549
- } else if (node.status === "pending") {
550
- node.status = "cancelled";
551
- node.completedAt = Date.now();
552
- node.updatedAt = Date.now();
553
- }
554
- }
555
- run.status = "failed";
556
- run.errorMessage = errorMessage;
557
- run.finishedAt = Date.now();
558
- run.updatedAt = Date.now();
559
- markStateDirty();
560
- return { ok: true, runningNodeIds };
561
- }
562
-
563
563
  /**
564
564
  * Reset failed and cancelled nodes of a settled run back to pending so the
565
565
  * run can be re-dispatched. Completed nodes are retained. The run returns to
@@ -612,6 +612,7 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
612
612
  node.errorMessage = undefined;
613
613
  node.completedAt = undefined;
614
614
  node.spawn = undefined;
615
+ node.nodeFollowUpSent = undefined;
615
616
  node.updatedAt = Date.now();
616
617
  }
617
618
  const reset = [...resetSet];
@@ -620,9 +621,6 @@ export function retryRun(runId: string, nodeIds?: string[]): { ok: boolean; rese
620
621
  run.finishedAt = undefined;
621
622
  run.completionNotified = false;
622
623
  run.settledMessageSent = false;
623
- // Re-arm the run-level cap: a stale deadline would re-fail the retried run
624
- // on the very next poll.
625
- run.deadlineAt = run.timeoutMs ? Date.now() + run.timeoutMs : undefined;
626
624
  run.updatedAt = Date.now();
627
625
  markStateDirty();
628
626
  return { ok: true, reset };
package/src/tools.ts CHANGED
@@ -10,8 +10,9 @@ import {
10
10
  scheduleRun,
11
11
  type DispatchCtx,
12
12
  } from "./run-machine";
13
- import { createRun, getRun, getSummary, markRunCompletionDelivered, retryRun } from "./state";
14
- import { TeammateCancelParams, TeammateRetryParams, TeammateRunParams } from "./types";
13
+ import { createRun, getRun, getSummary, listNodes, markRunCompletionDelivered, retryRun, MAX_FANOUT_ITEMS } from "./state";
14
+ import { TeammateCancelParams, TeammateFanoutParams, TeammateLeaderMessageParams, TeammateRetryParams, TeammateRunParams } from "./types";
15
+ import { sendWorkerSteer } from "./spawner";
15
16
  import { ensureTeamWidget, openTeamConsole, refreshTeamUI } from "./ui";
16
17
 
17
18
  function dispatchContext(ctx: ExtensionContext): DispatchCtx {
@@ -48,6 +49,10 @@ async function gatherForeground(runId: string, signal: AbortSignal | undefined):
48
49
  return buildRunSummary(runId);
49
50
  }
50
51
 
52
+ function resolveTargetNode(target: string): { node: import("./types").Node } | undefined {
53
+ return listNodes().map((node) => ({ node })).find(({ node }) => node.id === target || node.workerKey === target);
54
+ }
55
+
51
56
  export function registerLeaderTools(pi: ExtensionAPI): void {
52
57
  pi.registerTool({
53
58
  name: "teammate_run",
@@ -71,7 +76,6 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
71
76
  concurrency: params.concurrency ?? 4,
72
77
  worktree: params.worktree ?? false,
73
78
  background: params.background ?? true,
74
- timeoutMs: params.timeoutMs,
75
79
  summarize: params.summarize,
76
80
  summaryAgent: params.summaryAgent,
77
81
  nodes: params.tasks.map((task) => ({
@@ -81,7 +85,10 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
81
85
  paths: task.paths,
82
86
  access: task.access ?? "read",
83
87
  model: task.model,
84
- timeoutMs: task.timeoutMs,
88
+ mode: task.mode,
89
+ turnBudget: task.turnBudget,
90
+ forkContext: task.forkContext,
91
+ inputBindings: task.inputBindings,
85
92
  dependsOn: task.dependsOn ?? [],
86
93
  })),
87
94
  });
@@ -101,6 +108,58 @@ export function registerLeaderTools(pi: ExtensionAPI): void {
101
108
  },
102
109
  });
103
110
 
111
+ pi.registerTool({
112
+ name: "teammate_fanout",
113
+ promptSnippet: "Fan out a completed node's structured array output",
114
+ label: "Fan Out Tasks",
115
+ description: "Leader-only bounded fanout. Validates a completed structured array before creating a separate child run.",
116
+ parameters: TeammateFanoutParams,
117
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
118
+ const source = getRun(params.runId)?.nodes[params.nodeId];
119
+ if (!source || source.status !== "completed") throw new Error("Fanout source must be a completed node.");
120
+ if (!Array.isArray(source.structuredOutput)) throw new Error("Fanout source must be a structured array.");
121
+ if (source.structuredOutput.length > MAX_FANOUT_ITEMS) throw new Error(`Fanout is limited to ${MAX_FANOUT_ITEMS} items.`);
122
+ const cwd = ctx.cwd ?? process.cwd();
123
+ const dctx = dispatchContext(ctx);
124
+ ensureRunContext(dctx);
125
+ const tasks = source.structuredOutput.map((item, index) => ({
126
+ id: `${params.nodeId}-${index + 1}`,
127
+ agent: params.agent,
128
+ prompt: `${params.prompt}\n\nFanout item:\n${JSON.stringify(item)}`,
129
+ paths: params.paths,
130
+ access: params.access ?? "read" as const,
131
+ model: params.model,
132
+ mode: "json" as const,
133
+ turnBudget: params.turnBudget,
134
+ dependsOn: [],
135
+ }));
136
+ if (!resolveAgent(params.agent, cwd)) throw new Error(`Agent "${params.agent}" not found.`);
137
+ const created = createRun({ cwd, concurrency: params.concurrency ?? 4, worktree: false, background: params.background ?? true, summarize: true, nodes: tasks });
138
+ if (!created.ok) throw new Error(created.error ?? "Failed to create fanout run.");
139
+ publishStateSnapshot();
140
+ scheduleRun(created.run.id, dctx);
141
+ return { content: [{ type: "text", text: `Started fanout run [${created.run.id}] with ${tasks.length} item(s).` }], details: {} };
142
+ },
143
+ });
144
+
145
+ pi.registerTool({
146
+ name: "teammate_message",
147
+ promptSnippet: "Send a runtime steer to a running RPC teammate",
148
+ label: "Message Teammate",
149
+ description: "Leader-only runtime steer. Sends through the existing teammate_message protocol to a running RPC worker; no message is available for JSON-mode workers.",
150
+ parameters: TeammateLeaderMessageParams,
151
+ async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
152
+ if (!params.target.includes(":")) throw new Error("Target must be a run-qualified worker key.");
153
+ const target = resolveTargetNode(params.target);
154
+ if (!target?.node.spawn || target.node.status !== "running" || target.node.spawn.mode !== "rpc") {
155
+ throw new Error("Target worker is not a running RPC teammate.");
156
+ }
157
+ const sent = sendWorkerSteer(target.node.workerKey, params.body);
158
+ if (!sent) throw new Error("Target worker steering stream is unavailable.");
159
+ return { content: [{ type: "text", text: `Steer sent to ${target.node.workerKey}.` }], details: {} };
160
+ },
161
+ });
162
+
104
163
  pi.registerTool({
105
164
  name: "teammate_cancel",
106
165
  promptSnippet: "Cancel a run and stop its workers",