@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/.memory/follow-up-queue.md +17 -0
- package/.memory/node-terminal-followups.md +17 -0
- package/README.md +3 -1
- package/index.ts +1 -0
- package/package.json +4 -3
- package/src/console-viewport.ts +2 -1
- package/src/follow-up-queue.ts +170 -0
- package/src/guidance.ts +12 -9
- package/src/index.ts +33 -16
- package/src/run-machine.ts +99 -73
- package/src/spawner.ts +142 -97
- package/src/state.ts +40 -42
- package/src/tools.ts +63 -4
- package/src/types.ts +48 -10
- package/src/ui.ts +56 -73
- package/src/worker.ts +4 -1
package/src/types.ts
CHANGED
|
@@ -42,13 +42,25 @@ export interface Node {
|
|
|
42
42
|
access: "read" | "write";
|
|
43
43
|
/** Optional per-node model pin (provider/model). */
|
|
44
44
|
model?: string;
|
|
45
|
-
/**
|
|
46
|
-
|
|
45
|
+
/** RPC mode keeps stdin available for runtime steering. */
|
|
46
|
+
mode?: "json" | "rpc";
|
|
47
|
+
/** Optional maximum number of assistant turns before the worker is stopped. */
|
|
48
|
+
turnBudget?: number;
|
|
47
49
|
/** Node ids that must complete before this node may start. */
|
|
48
50
|
dependsOn: string[];
|
|
51
|
+
/** Named upstream node ids whose results are included as fork context. */
|
|
52
|
+
forkContext?: string[];
|
|
53
|
+
/** Named input bindings using `nodeId#/json/pointer` sources. */
|
|
54
|
+
inputBindings?: Record<string, string>;
|
|
49
55
|
status: NodeStatus;
|
|
50
56
|
result?: string;
|
|
57
|
+
/** Named structured outputs emitted through teammate_message. */
|
|
58
|
+
namedOutputs?: Record<string, string>;
|
|
59
|
+
/** Bounded JSON output emitted through teammate_message for downstream data flow. */
|
|
60
|
+
structuredOutput?: unknown;
|
|
51
61
|
errorMessage?: string;
|
|
62
|
+
/** True once the terminal node follow-up has been delivered. */
|
|
63
|
+
nodeFollowUpSent?: boolean;
|
|
52
64
|
/** Real child-process execution info when this node was spawned. */
|
|
53
65
|
spawn?: SpawnInfo;
|
|
54
66
|
createdAt: number;
|
|
@@ -66,11 +78,7 @@ export interface Run {
|
|
|
66
78
|
worktree: boolean;
|
|
67
79
|
/** background=true returns immediately; false gathers in the tool call. */
|
|
68
80
|
background: boolean;
|
|
69
|
-
/**
|
|
70
|
-
timeoutMs?: number;
|
|
71
|
-
/** Absolute deadline for timeoutMs, when set. */
|
|
72
|
-
deadlineAt?: number;
|
|
73
|
-
/** Run-level failure detail (e.g. run timeout). */
|
|
81
|
+
/** Run-level failure detail. */
|
|
74
82
|
errorMessage?: string;
|
|
75
83
|
nodes: Record<string, Node>;
|
|
76
84
|
createdAt: number;
|
|
@@ -102,6 +110,7 @@ export interface WorkerUsage {
|
|
|
102
110
|
export interface SpawnInfo {
|
|
103
111
|
/** Per-spawn capability identity, regenerated for every worker process. */
|
|
104
112
|
spawnId: string;
|
|
113
|
+
mode?: "json" | "rpc";
|
|
105
114
|
pid: number;
|
|
106
115
|
status: SpawnStatus;
|
|
107
116
|
startedAt: number;
|
|
@@ -125,7 +134,6 @@ export interface SpawnInfo {
|
|
|
125
134
|
error?: string;
|
|
126
135
|
/** Token/cost usage reported by the worker, when available. */
|
|
127
136
|
usage?: WorkerUsage;
|
|
128
|
-
/** True when the worker was killed by the spawn timeout. */
|
|
129
137
|
timedOut?: boolean;
|
|
130
138
|
/** Whether this spawn owns a dedicated Git worktree. */
|
|
131
139
|
isolation?: "worktree" | "none";
|
|
@@ -151,6 +159,13 @@ export interface WorkerMessageEvent {
|
|
|
151
159
|
subject: string;
|
|
152
160
|
body: string;
|
|
153
161
|
status?: "in_progress" | "completed" | "failed";
|
|
162
|
+
data?: {
|
|
163
|
+
kind?: "named_output" | "output";
|
|
164
|
+
name?: string;
|
|
165
|
+
value?: string;
|
|
166
|
+
output?: unknown;
|
|
167
|
+
message?: string;
|
|
168
|
+
};
|
|
154
169
|
}
|
|
155
170
|
|
|
156
171
|
export type WorkerEvent = WorkerMessageEvent;
|
|
@@ -174,7 +189,10 @@ export const RunTaskSpec = Type.Object({
|
|
|
174
189
|
}),
|
|
175
190
|
access: Type.Optional(NodeAccess),
|
|
176
191
|
model: Type.Optional(Type.String({ description: "Optional per-node provider/model pin" })),
|
|
177
|
-
|
|
192
|
+
mode: Type.Optional(Type.Union([Type.Literal("json"), Type.Literal("rpc")], { description: "Worker process mode; rpc enables runtime steering" })),
|
|
193
|
+
turnBudget: Type.Optional(Type.Integer({ minimum: 1, description: "Optional maximum assistant turns before the worker is stopped (default: 100; high safety cap for edge cases)" })),
|
|
194
|
+
forkContext: Type.Optional(Type.Array(Type.String(), { description: "Named upstream node ids whose results are included as fork context" })),
|
|
195
|
+
inputBindings: Type.Optional(Type.Record(Type.String(), Type.String(), { description: "Named inputs bound from dependency outputs using nodeId#/json/pointer" })),
|
|
178
196
|
});
|
|
179
197
|
|
|
180
198
|
/** Dispatch a dependency-aware task graph in one call. */
|
|
@@ -187,7 +205,6 @@ export const TeammateRunParams = Type.Object({
|
|
|
187
205
|
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 32, description: "Max nodes running at once (default: 4)" })),
|
|
188
206
|
worktree: Type.Optional(Type.Boolean({ description: "Run every node in its own git worktree (default: false)" })),
|
|
189
207
|
background: Type.Optional(Type.Boolean({ default: true, description: "Return immediately and deliver one completion follow-up. Default: true — teammates always run in the background; workers message team-leader with deliverables upon completion." })),
|
|
190
|
-
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, description: "Run-level hard wall-clock cap; the run fails when exceeded (default: none, nodes have their own caps)" })),
|
|
191
208
|
summarize: Type.Optional(Type.Boolean({ description: "Append a __summary node after all leaf nodes. Default: true when the run has more than one user task, false for a single task." })),
|
|
192
209
|
summaryAgent: Type.Optional(Type.String({ description: "Agent used for the summary node when summarize is on (default: observer)" })),
|
|
193
210
|
cwd: Type.Optional(Type.String({ description: "Working directory for this run (default: the session cwd)" })),
|
|
@@ -214,6 +231,27 @@ export const TeammateMessageParams = Type.Object({
|
|
|
214
231
|
Type.Literal("completed"),
|
|
215
232
|
Type.Literal("failed"),
|
|
216
233
|
], { description: "Optional worker status; use completed or failed for the final deliverable" })),
|
|
234
|
+
data: Type.Optional(Type.Record(Type.String(), Type.Unknown(), { description: "Structured teammate data: named_output or output" })),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
/** Leader-only operation that fans out a completed node's structured array output. */
|
|
238
|
+
export const TeammateFanoutParams = Type.Object({
|
|
239
|
+
runId: Type.String({ description: "Source run id" }),
|
|
240
|
+
nodeId: Type.String({ description: "Completed source node whose structured output is an array" }),
|
|
241
|
+
agent: Type.String({ description: "Agent to run for each item" }),
|
|
242
|
+
prompt: Type.String({ minLength: 1, description: "Task prompt; each item is appended as JSON" }),
|
|
243
|
+
paths: Type.Array(Type.String(), { minItems: 1, description: "Repository-relative paths for each child run" }),
|
|
244
|
+
access: Type.Optional(NodeAccess),
|
|
245
|
+
model: Type.Optional(Type.String()),
|
|
246
|
+
turnBudget: Type.Optional(Type.Integer({ minimum: 1 })),
|
|
247
|
+
concurrency: Type.Optional(Type.Integer({ minimum: 1, maximum: 32 })),
|
|
248
|
+
background: Type.Optional(Type.Boolean({ default: true })),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
/** Leader-only runtime steer sent through the existing teammate_message name. */
|
|
252
|
+
export const TeammateLeaderMessageParams = Type.Object({
|
|
253
|
+
target: Type.String({ description: "Run-qualified worker key, for example run_1:inspect" }),
|
|
254
|
+
body: Type.String({ minLength: 1, description: "Steering message for the running RPC worker" }),
|
|
217
255
|
});
|
|
218
256
|
|
|
219
257
|
// ── State snapshot for persistence ────────────────────────────────
|
package/src/ui.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
4
|
import { createPiThemeStyle, PI_SPINNER_FRAMES, PI_SPINNER_INTERVAL_MS } from "@fradser/pi-kit";
|
|
5
|
-
import type { ExtensionUIContext
|
|
5
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import {
|
|
8
8
|
clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
|
|
@@ -19,18 +19,12 @@ function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
// ── Team UI: passive widget + full-screen console ──────────────────
|
|
22
|
-
// The widget
|
|
22
|
+
// The widget below the editor is DISPLAY-ONLY. ALL interaction happens in the
|
|
23
23
|
// full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
|
|
24
24
|
|
|
25
|
-
const TEAM_COLORS = ["
|
|
26
|
-
const PANEL_IDLE_COLLAPSE_MS = 30_000;
|
|
27
|
-
|
|
28
|
-
let panelRequestRender: (() => void) | undefined;
|
|
25
|
+
const TEAM_COLORS = ["success", "warning", "error", "mdLink"] as const;
|
|
29
26
|
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
30
27
|
let spinnerFrame = 0;
|
|
31
|
-
let panelLastActivity = 0;
|
|
32
|
-
let panelCollapseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
33
|
-
let widgetRegistered = false;
|
|
34
28
|
|
|
35
29
|
/** Stable per-node color (independent of row order). */
|
|
36
30
|
function hashName(name: string): number {
|
|
@@ -56,14 +50,10 @@ function runningTeammateLabel(node: Node): string {
|
|
|
56
50
|
// Like the memory package's activity line: show what the worker is doing
|
|
57
51
|
// right now — current tool when one is executing, else its live reasoning,
|
|
58
52
|
// else the latest assistant text.
|
|
59
|
-
const
|
|
60
|
-
?
|
|
61
|
-
:
|
|
62
|
-
|
|
63
|
-
: live
|
|
64
|
-
? ` ${truncateToWidth(live, 48)}`
|
|
65
|
-
: " working...";
|
|
66
|
-
return `${frame}${detail}`;
|
|
53
|
+
const activity = tool
|
|
54
|
+
?? (thinking ? truncateToWidth(thinking, 48) : undefined)
|
|
55
|
+
?? (live ? truncateToWidth(live, 48) : "Working...");
|
|
56
|
+
return `${frame} ${activity}`;
|
|
67
57
|
}
|
|
68
58
|
|
|
69
59
|
function ensureSpinner(): void {
|
|
@@ -71,7 +61,6 @@ function ensureSpinner(): void {
|
|
|
71
61
|
if (running && !spinnerTimer) {
|
|
72
62
|
spinnerTimer = setInterval(() => {
|
|
73
63
|
spinnerFrame = (spinnerFrame + 1) % PI_SPINNER_FRAMES.length;
|
|
74
|
-
panelRequestRender?.();
|
|
75
64
|
}, PI_SPINNER_INTERVAL_MS);
|
|
76
65
|
spinnerTimer.unref?.();
|
|
77
66
|
} else if (!running && spinnerTimer) {
|
|
@@ -80,79 +69,73 @@ function ensureSpinner(): void {
|
|
|
80
69
|
}
|
|
81
70
|
}
|
|
82
71
|
|
|
83
|
-
function isPanelCollapsed(): boolean {
|
|
84
|
-
return Date.now() - panelLastActivity > PANEL_IDLE_COLLAPSE_MS;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function scheduleIdleCollapse(): void {
|
|
88
|
-
if (panelCollapseTimer) clearTimeout(panelCollapseTimer);
|
|
89
|
-
panelCollapseTimer = setTimeout(() => panelRequestRender?.(), PANEL_IDLE_COLLAPSE_MS);
|
|
90
|
-
panelCollapseTimer.unref?.();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
72
|
export function stopUiTimers(): void {
|
|
94
73
|
if (spinnerTimer) {
|
|
95
74
|
clearInterval(spinnerTimer);
|
|
96
75
|
spinnerTimer = undefined;
|
|
97
76
|
}
|
|
98
|
-
if (panelCollapseTimer) {
|
|
99
|
-
clearTimeout(panelCollapseTimer);
|
|
100
|
-
panelCollapseTimer = undefined;
|
|
101
|
-
}
|
|
102
77
|
}
|
|
103
78
|
|
|
104
79
|
export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
105
|
-
if (
|
|
80
|
+
if (!ctx?.ui?.setWidget) return;
|
|
106
81
|
if (ctx.mode && ctx.mode !== "tui") return;
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
render: (width) => panelRows(theme, width),
|
|
113
|
-
invalidate: () => {},
|
|
114
|
-
dispose: () => {
|
|
115
|
-
stopUiTimers();
|
|
116
|
-
panelRequestRender = undefined;
|
|
117
|
-
widgetRegistered = false;
|
|
118
|
-
},
|
|
119
|
-
};
|
|
120
|
-
});
|
|
121
|
-
widgetRegistered = true;
|
|
122
|
-
} catch {
|
|
123
|
-
// Best effort if widget registration is unavailable in the current mode.
|
|
82
|
+
|
|
83
|
+
const running = listNodes().filter((node) => node.status === "running");
|
|
84
|
+
if (running.length === 0) {
|
|
85
|
+
ctx.ui.setWidget("teammate", undefined);
|
|
86
|
+
return;
|
|
124
87
|
}
|
|
88
|
+
|
|
89
|
+
ctx.ui.setWidget("teammate", (tui, theme) => {
|
|
90
|
+
const timer = setInterval(() => tui.requestRender(), PI_SPINNER_INTERVAL_MS);
|
|
91
|
+
timer.unref?.();
|
|
92
|
+
const style = createPiThemeStyle(theme);
|
|
93
|
+
const assignedColors = new Map<string, (typeof TEAM_COLORS)[number]>();
|
|
94
|
+
return {
|
|
95
|
+
placement: "belowEditor",
|
|
96
|
+
render: (width: number) => {
|
|
97
|
+
const running = listNodes().filter((n) => n.status === "running");
|
|
98
|
+
if (running.length === 0) return [];
|
|
99
|
+
const runningKeys = new Set(running.map((node) => node.workerKey));
|
|
100
|
+
for (const key of assignedColors.keys()) {
|
|
101
|
+
if (!runningKeys.has(key)) assignedColors.delete(key);
|
|
102
|
+
}
|
|
103
|
+
const usedColors = new Set(assignedColors.values());
|
|
104
|
+
for (const node of running) {
|
|
105
|
+
if (!assignedColors.has(node.workerKey)) {
|
|
106
|
+
const start = hashName(node.workerKey) % TEAM_COLORS.length;
|
|
107
|
+
const color = TEAM_COLORS.find((_, offset) => !usedColors.has(TEAM_COLORS[(start + offset) % TEAM_COLORS.length]));
|
|
108
|
+
assignedColors.set(node.workerKey, color ?? TEAM_COLORS[start]);
|
|
109
|
+
usedColors.add(color ?? TEAM_COLORS[start]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const lines: string[] = [];
|
|
113
|
+
for (const node of running) {
|
|
114
|
+
const label = runningTeammateLabel(node);
|
|
115
|
+
const color = assignedColors.get(node.workerKey) ?? TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
116
|
+
const name = style.fg(color, node.id);
|
|
117
|
+
const role = style.dim(`(${node.agent})`);
|
|
118
|
+
const separator = label.indexOf(" ");
|
|
119
|
+
const spinner = separator === -1 ? label : label.slice(0, separator);
|
|
120
|
+
const activityText = separator === -1 ? "Working..." : label.slice(separator + 1).trim();
|
|
121
|
+
const activity = theme.bold(style.fg("accent", activityText));
|
|
122
|
+
const line = ` ${spinner} ${name} ${role} · ${activity}`;
|
|
123
|
+
lines.push(truncateToWidth(line, Math.max(10, width - 1)));
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
},
|
|
127
|
+
invalidate: () => {},
|
|
128
|
+
dispose: () => clearInterval(timer),
|
|
129
|
+
};
|
|
130
|
+
});
|
|
125
131
|
}
|
|
126
132
|
|
|
127
133
|
export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
128
134
|
ensureTeamWidget(ctx);
|
|
129
|
-
panelLastActivity = Date.now();
|
|
130
|
-
scheduleIdleCollapse();
|
|
131
135
|
ensureLivePoll();
|
|
132
136
|
ensureSpinner();
|
|
133
|
-
panelRequestRender?.();
|
|
134
137
|
}
|
|
135
138
|
|
|
136
|
-
/** Passive widget rows (display only — no selection, no key handling). */
|
|
137
|
-
function panelRows(theme: Theme, width?: number): string[] {
|
|
138
|
-
const fg = (color: ThemeColor, s: string): string => theme.fg(color, s);
|
|
139
|
-
const bold = (s: string): string => theme.bold(s);
|
|
140
|
-
const fit = (line: string): string =>
|
|
141
|
-
typeof width === "number" && width > 0 ? truncateToWidth(line, Math.max(10, width - 1)) : line;
|
|
142
|
-
const running = listNodes().filter((node) => node.status === "running");
|
|
143
|
-
// Stay out of the way until a teammate is actually working.
|
|
144
|
-
if (running.length === 0) return [];
|
|
145
|
-
if (isPanelCollapsed()) {
|
|
146
|
-
return [` ${fit(fg("dim", `${running.length} teammate${running.length === 1 ? "" : "s"} working — /teammate`))}`];
|
|
147
|
-
}
|
|
148
|
-
const lines: string[] = [];
|
|
149
|
-
for (const node of running) {
|
|
150
|
-
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
151
|
-
lines.push(` ${fit(`${bold(fg(color, node.id))} ${fg("muted", `(${node.agent})`)} ${fg("warning", runningTeammateLabel(node))}`)}`);
|
|
152
|
-
}
|
|
153
|
-
lines.push(` ${fit(fg("dim", "/teammate — open console"))}`);
|
|
154
|
-
return lines;
|
|
155
|
-
}
|
|
156
139
|
|
|
157
140
|
/** Full content of a node as shown on its detail page: spawn lifecycle, live
|
|
158
141
|
* worker text, captured output, result and error. */
|
package/src/worker.ts
CHANGED
|
@@ -27,7 +27,9 @@ export function isWorkerEvent(value: unknown): value is WorkerEvent {
|
|
|
27
27
|
&& typeof event.spawnId === "string"
|
|
28
28
|
&& typeof event.subject === "string"
|
|
29
29
|
&& typeof event.body === "string"
|
|
30
|
-
&& (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
|
|
30
|
+
&& (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status))
|
|
31
|
+
&& (event.data === undefined || typeof event.data === "object")
|
|
32
|
+
;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
@@ -48,6 +50,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
48
50
|
subject: params.subject,
|
|
49
51
|
body: params.body,
|
|
50
52
|
status: params.status,
|
|
53
|
+
data: params.data,
|
|
51
54
|
});
|
|
52
55
|
const statusNote = params.status ? ` with status "${params.status}"` : "";
|
|
53
56
|
return { content: [{ type: "text", text: `Queued report to team-leader${statusNote}.` }], details: {} };
|