@fradser/pi-agent-teams 0.5.0 → 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.0",
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,7 +35,10 @@
34
35
  },
35
36
  "pi": {
36
37
  "extensions": [
37
- "./src/index.ts"
38
+ "./index.ts"
38
39
  ]
40
+ },
41
+ "dependencies": {
42
+ "@fradser/pi-kit": "0.1.1"
39
43
  }
40
44
  }
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @fradser/pi-agent-teams — Pi extension for run-centric agent teams.
2
+ * pi-agent-teams-fradser — Pi extension for run-centric agent teams.
3
3
  *
4
4
  * The entry point is composition only: worker capability registration,
5
5
  * session lifecycle, and delegation to tools.ts. Scheduling lives in
@@ -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
@@ -12,6 +12,7 @@ import * as fs from "node:fs";
12
12
  import * as os from "node:os";
13
13
  import * as path from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
+ import { extractTextContent } from "@fradser/pi-kit";
15
16
  import type { WorkerUsage } from "./types";
16
17
 
17
18
  /**
@@ -151,10 +152,68 @@ export class CancellationIntents {
151
152
  /** Live workers by teammate name — lets the panel interrupt/stop a running worker. */
152
153
  const workers = new Map<string, ReturnType<typeof spawn>>();
153
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
+
154
164
  function isChildRunning(child: ReturnType<typeof spawn>): boolean {
155
165
  return child.exitCode === null && child.signalCode === null;
156
166
  }
157
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
+
158
217
  function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promise<boolean> {
159
218
  return new Promise((resolve) => {
160
219
  let settled = false;
@@ -179,6 +238,7 @@ function waitForClose(child: ReturnType<typeof spawn>, timeoutMs: number): Promi
179
238
  * resolve only after a bounded wait observes the child `close` event.
180
239
  */
181
240
  export async function terminateChildProcess(child: ReturnType<typeof spawn>, graceMs = CANCEL_GRACE_MS): Promise<boolean> {
241
+ cancelWorkerTimeout(child);
182
242
  if (!isChildRunning(child)) return false;
183
243
 
184
244
  const closedAfterTerm = waitForClose(child, graceMs);
@@ -307,10 +367,7 @@ function applyWorkerJsonLine(state: WorkerStreamState, line: string): boolean {
307
367
  if (event.type !== "message_end" || event.message?.role !== "assistant") return false;
308
368
  state.turns++;
309
369
  if (event.message.stopReason === "stop") state.finalResponse = true;
310
- const parts = (event.message.content ?? [])
311
- .filter((part) => part.type === "text" && typeof part.text === "string")
312
- .map((part) => part.text as string)
313
- .join("");
370
+ const parts = extractTextContent(event.message.content, "");
314
371
  if (parts.trim()) state.text = parts;
315
372
  const u = event.message.usage;
316
373
  if (u) {
@@ -433,7 +490,17 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
433
490
  // resolvePiCli always resolves (worst case a "pi" binary on PATH); a missing
434
491
  // binary surfaces later as a child "error" event through onError.
435
492
  const cli = resolvePiCli();
436
- 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
+ ];
437
504
  if (options.model) args.push("--model", options.model);
438
505
  // Capability tools cannot be removed; execution tools are selected by the
439
506
  // leader from the teammate's explicit configuration or role default.
@@ -489,7 +556,7 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
489
556
  const stderrChunks: string[] = [];
490
557
  const streamState = createWorkerStreamState();
491
558
  let stdoutBuffer = "";
492
- let timedOut = false;
559
+ let timeoutHandle: WorkerTimeoutHandle | undefined;
493
560
  let settled = false;
494
561
  const emitProgress = () => options.onUpdate?.({
495
562
  text: truncate(streamState.text, OUTPUT_CAP),
@@ -510,17 +577,12 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
510
577
  });
511
578
  child.stderr.on("data", (chunk: Buffer) => stderrChunks.push(chunk.toString()));
512
579
 
513
- let timer: ReturnType<typeof setTimeout> | undefined;
514
580
  if (options.timeoutMs) {
515
- timer = setTimeout(() => {
516
- timedOut = true;
517
- child.kill("SIGKILL");
518
- }, options.timeoutMs);
519
- timer.unref?.();
581
+ timeoutHandle = registerWorkerTimeout(child, options.timeoutMs);
520
582
  }
521
583
 
522
584
  child.on("error", (error) => {
523
- if (timer) clearTimeout(timer);
585
+ timeoutHandle?.dispose();
524
586
  cleanupTempDir();
525
587
  settled = true;
526
588
  if (options.workerName && workers.get(options.workerName) === child) workers.delete(options.workerName);
@@ -528,7 +590,8 @@ export function spawnPiWorker(options: SpawnPiWorkerOptions): SpawnedWorker | {
528
590
  });
529
591
 
530
592
  child.on("close", (code, signal) => {
531
- if (timer) clearTimeout(timer);
593
+ const timedOut = timeoutHandle?.didTimeout() ?? false;
594
+ timeoutHandle?.dispose();
532
595
  cleanupTempDir();
533
596
  // A spawn failure already reported via onError (Node fires error then
534
597
  // close) — do not double-report through onExit, which would let a failed
package/src/ui.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  /** Passive teammate widget and interactive full-screen console. */
2
2
 
3
3
  import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
4
+ import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
4
5
  import type { ExtensionUIContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
5
6
  import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
6
7
  import {
@@ -23,8 +24,6 @@ function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
23
24
 
24
25
  const TEAM_COLORS = ["accent", "success", "warning", "error", "toolTitle", "mdLink"] as const;
25
26
  const PANEL_IDLE_COLLAPSE_MS = 30_000;
26
- const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
27
- const SPINNER_MS = 120;
28
27
 
29
28
  let panelRequestRender: (() => void) | undefined;
30
29
  let spinnerTimer: ReturnType<typeof setInterval> | undefined;
@@ -50,7 +49,7 @@ function buildPanelRows(): PanelRow[] {
50
49
  }
51
50
 
52
51
  function runningTeammateLabel(node: Node): string {
53
- const frame = SPINNER_FRAMES[spinnerFrame];
52
+ const frame = PI_SPINNER_FRAMES[spinnerFrame];
54
53
  const tool = node.spawn?.activeTool;
55
54
  const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
56
55
  const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
@@ -71,9 +70,9 @@ function ensureSpinner(): void {
71
70
  const running = listNodes().some((node) => node.status === "running");
72
71
  if (running && !spinnerTimer) {
73
72
  spinnerTimer = setInterval(() => {
74
- spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
73
+ spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
75
74
  panelRequestRender?.();
76
- }, SPINNER_MS);
75
+ }, PI_SPINNER_INTERVAL_MS);
77
76
  spinnerTimer.unref?.();
78
77
  } else if (!running && spinnerTimer) {
79
78
  clearInterval(spinnerTimer);
@@ -245,7 +244,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
245
244
  };
246
245
  const startLiveRefresh = () => {
247
246
  if (renderTimer) return;
248
- renderTimer = setInterval(requestRender, SPINNER_MS);
247
+ renderTimer = setInterval(requestRender, PI_SPINNER_INTERVAL_MS);
249
248
  renderTimer.unref?.();
250
249
  };
251
250
  const stopLiveRefresh = () => {
@@ -255,15 +254,8 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
255
254
  };
256
255
  startLiveRefresh();
257
256
 
258
- // btw-style callbacks (same accent/muted/dim/border/success/error language as @fradser/pi-btw).
259
- const style = {
260
- accent: (s: string) => theme.fg("accent", s),
261
- muted: (s: string) => theme.fg("muted", s),
262
- dim: (s: string) => theme.fg("dim", s),
263
- border: (s: string) => theme.fg("border", s),
264
- success: (s: string) => theme.fg("success", s),
265
- error: (s: string) => theme.fg("error", s),
266
- };
257
+ // btw-style callbacks (same accent/muted/dim/border/success/error language as pi-btw-fradser).
258
+ const style = createPiThemeStyle(theme);
267
259
 
268
260
  const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
269
261
  const wrapped = wrapConsoleDetail(full, width);
@@ -341,7 +333,7 @@ export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void>
341
333
  }
342
334
  const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
343
335
  const viewport = maxConsoleBody(tui.terminal.rows);
344
- // Mouse-wheel scrolling (SGR wheel events, same as @fradser/pi-btw).
336
+ // Mouse-wheel scrolling (SGR wheel events, same as pi-btw-fradser).
345
337
  const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
346
338
  if (sgrWheel) {
347
339
  const button = Number.parseInt(sgrWheel[1], 10);