@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 +1 -0
- package/index.ts +1 -0
- package/package.json +6 -2
- package/src/index.ts +1 -1
- package/src/run-machine.ts +7 -14
- package/src/spawner.ts +77 -14
- package/src/ui.ts +8 -16
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.
|
|
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
|
-
"./
|
|
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
|
-
*
|
|
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
|
package/src/run-machine.ts
CHANGED
|
@@ -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,
|
|
9
|
+
deliverToLeader, failRunTimeout, findSharedWorkspaceWriteConflict, getNode,
|
|
11
10
|
getRun, getState, listNodes, listRuns, markNodeRunning, markStateDirty,
|
|
12
|
-
readyPendingNodes, receiveWorkerMessage,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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) %
|
|
73
|
+
spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
|
|
75
74
|
panelRequestRender?.();
|
|
76
|
-
},
|
|
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,
|
|
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
|
|
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
|
|
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);
|