@fradser/pi-agent-teams 0.5.1 → 0.5.2

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/README.md CHANGED
@@ -86,6 +86,7 @@ State is session-scoped and dies with the session (`~/.pi/agent/teammate/<sessio
86
86
 
87
87
  ```
88
88
  agent-teams/
89
+ ├── index.ts — package-root extension entry point
89
90
  ├── package.json — Pi package manifest
90
91
  ├── agents/ — bundled agent definitions (worker/reviewer/specialist/observer)
91
92
  ├── src/
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fradser/pi-agent-teams",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Multi-agent team system for Pi — DAG orchestration, leader inbox, and one-way worker reports",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -22,6 +22,7 @@
22
22
  "access": "public"
23
23
  },
24
24
  "files": [
25
+ "index.ts",
25
26
  "src",
26
27
  "agents",
27
28
  ".memory",
@@ -34,10 +35,10 @@
34
35
  },
35
36
  "pi": {
36
37
  "extensions": [
37
- "./src/index.ts"
38
+ "./index.ts"
38
39
  ]
39
40
  },
40
41
  "dependencies": {
41
- "@fradser/pi-kit": "0.1.0"
42
+ "@fradser/pi-kit": "0.1.1"
42
43
  }
43
44
  }
@@ -2,25 +2,23 @@
2
2
 
3
3
  import { randomUUID } from "node:crypto";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
- import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
6
5
  import { resolveAgent } from "./agents";
7
6
  import { isWorkerEvent } from "./worker";
8
7
  import {
9
8
  cancelBlockedDependents, cancelNode, cancelRun, clearWorkerRunEvents, clearStateDirty, isStateDirty,
10
- deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode, getNodeByWorkerKey,
9
+ deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode,
11
10
  getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
12
- readyPendingNodes, receiveWorkerMessage, retryRun, runningNodeCount, settleRun, setNodeSpawnInfo,
11
+ readyPendingNodes, receiveWorkerMessage, runningNodeCount, settleRun, setNodeSpawnInfo,
13
12
  SUMMARY_NODE_ID, updateNodeSpawnProgress, updateNodeStatus,
14
13
  } from "./state";
15
14
  import {
16
15
  CancellationIntents, buildAutonomousPrompt, finishReportedWorker, isCompletedWorkerExit,
17
- POST_REPORT_GRACE_MS, spawnPiWorker, terminateAllWorkers, terminateWorker,
16
+ POST_REPORT_GRACE_MS, spawnPiWorker, terminateWorker,
18
17
  type WorkerProcessResult,
19
18
  } from "./spawner";
20
19
  import { buildNodeTerminalResult } from "./terminal";
21
20
  import { captureWorktreeDiff, cleanupWorktree, createWorktree, discardWorktree } from "./worktree";
22
21
  import { readWorkerEvents, removeWorkerOutbox, stateFilePath, workerOutboxPath, writeStateFile } from "./statefile";
23
- import type { Run } from "./types";
24
22
 
25
23
  export interface DispatchCtx {
26
24
  ui: ExtensionContext["ui"];
@@ -184,7 +182,7 @@ function enforceRunTimeouts(): void {
184
182
  const node = getNode(run.id, nodeId);
185
183
  if (node) void terminateWorker(node.workerKey).catch(() => false);
186
184
  }
187
- onRunSettled(run.id, machineCtx);
185
+ onRunSettled(run.id);
188
186
  // failRunTimeout clears spawns, so those nodes' close events skip their
189
187
  // finalize path — release write-deferred nodes in OTHER runs here.
190
188
  if (machineCtx) scheduleAllRuns(machineCtx);
@@ -194,10 +192,6 @@ function enforceRunTimeouts(): void {
194
192
 
195
193
  // ── Run dispatch machinery ────────────────────────────────────────
196
194
 
197
- function sleep(ms: number): Promise<void> {
198
- return new Promise((resolve) => setTimeout(resolve, ms));
199
- }
200
-
201
195
  /** Compact run summary for tool returns and follow-ups. When the run has a
202
196
  * synthesized __summary node result, that is shown instead of per-node
203
197
  * headlines (no truncation heuristics). */
@@ -231,14 +225,13 @@ export function buildRunSummary(runId: string): string {
231
225
  for (const node of nodes) {
232
226
  lines.push(`- [${node.id}] ${node.status} (${node.agent})`);
233
227
  }
234
- lines.push("", "Console: /teammate");
235
228
  return lines.join("\n");
236
229
  }
237
230
 
238
231
  /** Called once a run reaches a terminal status. Idempotent: the leader summary
239
232
  * and follow-up fire only on the first settled observation (a run can transition
240
233
  * through settleRun once per node close). */
241
- export function onRunSettled(runId: string, ctx?: DispatchCtx): void {
234
+ export function onRunSettled(runId: string): void {
242
235
  const run = getRun(runId);
243
236
  if (!run) return;
244
237
  if (run.settledMessageSent) return;
@@ -266,7 +259,7 @@ export function scheduleRun(runId: string, ctx: DispatchCtx): void {
266
259
  if (!run || run.status !== "running") return;
267
260
  const settled = settleRun(runId);
268
261
  if (settled !== "running") {
269
- onRunSettled(runId, ctx);
262
+ onRunSettled(runId);
270
263
  return;
271
264
  }
272
265
  const sessionBudget = MAX_SESSION_WORKERS - listNodes().filter((node) => node.status === "running").length;
@@ -346,7 +339,7 @@ export async function cancelRunAndTerminate(
346
339
  cancellationIntents.resolve(spawnId, terminated);
347
340
  }
348
341
  publishStateSnapshot();
349
- onRunSettled(runId, ctx);
342
+ onRunSettled(runId);
350
343
  scheduleAllRuns(ctx);
351
344
  notifyChange();
352
345
  return { ok: true };
package/src/spawner.ts CHANGED
@@ -152,10 +152,68 @@ export class CancellationIntents {
152
152
  /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
153
153
  const workers = new Map<string, ReturnType<typeof spawn>>();
154
154
 
155
+ export interface WorkerTimeoutHandle {
156
+ cancel(): void;
157
+ dispose(): void;
158
+ wasCancelled(): boolean;
159
+ didTimeout(): boolean;
160
+ }
161
+
162
+ const workerTimeouts = new WeakMap<ReturnType<typeof spawn>, WorkerTimeoutHandle>();
163
+
155
164
  function isChildRunning(child: ReturnType<typeof spawn>): boolean {
156
165
  return child.exitCode === null && child.signalCode === null;
157
166
  }
158
167
 
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
+ };
194
+
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;
211
+ }
212
+
213
+ export function cancelWorkerTimeout(child: ReturnType<typeof spawn>): void {
214
+ workerTimeouts.get(child)?.cancel();
215
+ }
216
+
159
217
  function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
160
218
  return new Promise((resolve) => {
161
219
  let settled = false;
@@ -180,6 +238,7 @@ function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promi
180
238
  * resolve only after a bounded wait observes the child `close` event.
181
239
  */
182
240
  export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
241
+ cancelWorkerTimeout(child);
183
242
  if (!isChildRunning(child)) return false;
184
243
 
185
244
  const closedAfterTerm = waitForClose(child, graceMs);
@@ -431,7 +490,17 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
431
490
  // resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
432
491
  // binary surfaces later as a child "error" event through onError.
433
492
  const cli = resolvePiCli();
434
- const args: string[] = [...cli.args, "--print", "--mode", "json", "--no-session"];
493
+ const workerExtension = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "index.ts");
494
+ const args: string[] = [
495
+ ...cli.args,
496
+ "--print",
497
+ "--mode",
498
+ "json",
499
+ "--no-session",
500
+ "--no-extensions",
501
+ "--extension",
502
+ workerExtension,
503
+ ];
435
504
  if (options.model) args.push("--model", options.model);
436
505
  // Capability tools cannot be removed; execution tools are selected by the
437
506
  // leader from the teammate's explicit configuration or role default.
@@ -487,7 +556,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
487
556
  const stderrChunks: string[] = [];
488
557
  const streamState = createWorkerStreamState();
489
558
  let stdoutBuffer = "";
490
- let timedOut = false;
559
+ let timeoutHandle: WorkerTimeoutHandle | undefined;
491
560
  let settled = false;
492
561
  const emitProgress = () => options.onUpdate?.({
493
562
  text: truncate(streamState.text, OUTPUT_CAP),
@@ -508,17 +577,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
508
577
  });
509
578
  child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
510
579
 
511
- let timer: ReturnType<typeof setTimeout> | undefined;
512
580
  if (options.timeoutMs) {
513
- timer = setTimeout(() => {
514
- timedOut = true;
515
- child.kill("SIGKILL");
516
- }, options.timeoutMs);
517
- timer.unref?.();
581
+ timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
518
582
  }
519
583
 
520
584
  child.on("error", (error) => {
521
- if (timer) clearTimeout(timer);
585
+ timeoutHandle?.dispose();
522
586
  cleanupTempDir();
523
587
  settled = true;
524
588
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
@@ -526,7 +590,8 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
526
590
  });
527
591
 
528
592
  child.on("close", (code, signal) => {
529
- if (timer) clearTimeout(timer);
593
+ const timedOut = timeoutHandle?.didTimeout() ?? false;
594
+ timeoutHandle?.dispose();
530
595
  cleanupTempDir();
531
596
  // A spawn failure already reported via onError (Node fires error then
532
597
  // close) — do not double-report through onExit, which would let a failed