@fradser/pi-agent-teams 0.5.0
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/MEMORY.md +3 -0
- package/.memory/openai_teammate_api_reference.md +118 -0
- package/.memory/run-centric-design.md +22 -0
- package/README.md +111 -0
- package/agents/observer.md +10 -0
- package/agents/reviewer.md +11 -0
- package/agents/specialist.md +10 -0
- package/agents/worker.md +11 -0
- package/package.json +40 -0
- package/src/agents.ts +138 -0
- package/src/console-viewport.ts +30 -0
- package/src/guidance.ts +77 -0
- package/src/index.ts +79 -0
- package/src/run-machine.ts +580 -0
- package/src/spawner.ts +549 -0
- package/src/state.ts +684 -0
- package/src/statefile.ts +140 -0
- package/src/terminal.ts +34 -0
- package/src/tools.ts +154 -0
- package/src/types.ts +231 -0
- package/src/ui.ts +411 -0
- package/src/worker.ts +56 -0
- package/src/worktree.ts +115 -0
package/src/ui.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
/** Passive teammate widget and interactive full-screen console. */
|
|
2
|
+
|
|
3
|
+
import { truncateToWidth, Key, matchesKey } from "@earendil-works/pi-tui";
|
|
4
|
+
import type { ExtensionUIContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { truncateTail, DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
clampConsoleScroll, consoleScrollRange, maxConsoleBody, scrollConsoleDetail, wrapConsoleDetail,
|
|
8
|
+
} from "./console-viewport";
|
|
9
|
+
import { getNodeByWorkerKey, getState, listNodes } from "./state";
|
|
10
|
+
import { cancelNodeAndTerminate, ensureLivePoll } from "./run-machine";
|
|
11
|
+
import type { Node } from "./types";
|
|
12
|
+
|
|
13
|
+
function cap(text: string | undefined, maxBytes = DEFAULT_MAX_BYTES): string {
|
|
14
|
+
if (!text) return "";
|
|
15
|
+
if (text.length <= maxBytes) return text;
|
|
16
|
+
const t = truncateTail(text, { maxLines: DEFAULT_MAX_LINES, maxBytes });
|
|
17
|
+
return `${t.content}\n…[truncated ${text.length - t.content.length} chars]`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ── Team UI: passive widget + full-screen console ──────────────────
|
|
21
|
+
// The widget above the editor is DISPLAY-ONLY. ALL interaction happens in the
|
|
22
|
+
// full-screen Team Console (/teammate), which owns input via ctx.ui.custom.
|
|
23
|
+
|
|
24
|
+
const TEAM_COLORS = ["accent", "success", "warning", "error", "toolTitle", "mdLink"] as const;
|
|
25
|
+
const PANEL_IDLE_COLLAPSE_MS = 30_000;
|
|
26
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
27
|
+
const SPINNER_MS = 120;
|
|
28
|
+
|
|
29
|
+
let panelRequestRender: (() => void) | undefined;
|
|
30
|
+
let spinnerTimer: ReturnType<typeof setInterval> | undefined;
|
|
31
|
+
let spinnerFrame = 0;
|
|
32
|
+
let panelLastActivity = 0;
|
|
33
|
+
let panelCollapseTimer: ReturnType<typeof setTimeout> | undefined;
|
|
34
|
+
let widgetRegistered = false;
|
|
35
|
+
|
|
36
|
+
/** Stable per-node color (independent of row order). */
|
|
37
|
+
function hashName(name: string): number {
|
|
38
|
+
let h = 0;
|
|
39
|
+
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) | 0;
|
|
40
|
+
return Math.abs(h);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface PanelRow {
|
|
44
|
+
key: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Console/widget rows contain node status and leader-report access. */
|
|
48
|
+
function buildPanelRows(): PanelRow[] {
|
|
49
|
+
return listNodes().map((node) => ({ key: node.workerKey }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function runningTeammateLabel(node: Node): string {
|
|
53
|
+
const frame = SPINNER_FRAMES[spinnerFrame];
|
|
54
|
+
const tool = node.spawn?.activeTool;
|
|
55
|
+
const thinking = (node.spawn?.liveThinking ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
56
|
+
const live = (node.spawn?.liveText ?? "").split("\n").map((l) => l.trim()).find((l) => l.length > 0) ?? "";
|
|
57
|
+
// Like the memory package's activity line: show what the worker is doing
|
|
58
|
+
// right now — current tool when one is executing, else its live reasoning,
|
|
59
|
+
// else the latest assistant text.
|
|
60
|
+
const detail = tool
|
|
61
|
+
? ` ${tool}`
|
|
62
|
+
: thinking
|
|
63
|
+
? ` ${truncateToWidth(thinking, 48)}`
|
|
64
|
+
: live
|
|
65
|
+
? ` ${truncateToWidth(live, 48)}`
|
|
66
|
+
: " working...";
|
|
67
|
+
return `${frame}${detail}`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureSpinner(): void {
|
|
71
|
+
const running = listNodes().some((node) => node.status === "running");
|
|
72
|
+
if (running && !spinnerTimer) {
|
|
73
|
+
spinnerTimer = setInterval(() => {
|
|
74
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
75
|
+
panelRequestRender?.();
|
|
76
|
+
}, SPINNER_MS);
|
|
77
|
+
spinnerTimer.unref?.();
|
|
78
|
+
} else if (!running && spinnerTimer) {
|
|
79
|
+
clearInterval(spinnerTimer);
|
|
80
|
+
spinnerTimer = undefined;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isPanelCollapsed(): boolean {
|
|
85
|
+
return Date.now() - panelLastActivity > PANEL_IDLE_COLLAPSE_MS;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function scheduleIdleCollapse(): void {
|
|
89
|
+
if (panelCollapseTimer) clearTimeout(panelCollapseTimer);
|
|
90
|
+
panelCollapseTimer = setTimeout(() => panelRequestRender?.(), PANEL_IDLE_COLLAPSE_MS);
|
|
91
|
+
panelCollapseTimer.unref?.();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function stopUiTimers(): void {
|
|
95
|
+
if (spinnerTimer) {
|
|
96
|
+
clearInterval(spinnerTimer);
|
|
97
|
+
spinnerTimer = undefined;
|
|
98
|
+
}
|
|
99
|
+
if (panelCollapseTimer) {
|
|
100
|
+
clearTimeout(panelCollapseTimer);
|
|
101
|
+
panelCollapseTimer = undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
106
|
+
if (widgetRegistered || !ctx?.ui?.setWidget) return;
|
|
107
|
+
if (ctx.mode && ctx.mode !== "tui") return;
|
|
108
|
+
try {
|
|
109
|
+
ctx.ui.setWidget("teammate", (tui, theme) => {
|
|
110
|
+
panelRequestRender = () => tui.requestRender();
|
|
111
|
+
ensureSpinner();
|
|
112
|
+
return {
|
|
113
|
+
render: (width) => panelRows(theme, width),
|
|
114
|
+
invalidate: () => {},
|
|
115
|
+
dispose: () => {
|
|
116
|
+
stopUiTimers();
|
|
117
|
+
panelRequestRender = undefined;
|
|
118
|
+
widgetRegistered = false;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
widgetRegistered = true;
|
|
123
|
+
} catch {
|
|
124
|
+
// Best effort if widget registration is unavailable in the current mode.
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function refreshTeamUI(ctx?: { ui?: ExtensionUIContext; mode?: string }): void {
|
|
129
|
+
ensureTeamWidget(ctx);
|
|
130
|
+
panelLastActivity = Date.now();
|
|
131
|
+
scheduleIdleCollapse();
|
|
132
|
+
ensureLivePoll();
|
|
133
|
+
ensureSpinner();
|
|
134
|
+
panelRequestRender?.();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Passive widget rows (display only — no selection, no key handling). */
|
|
138
|
+
function panelRows(theme: Theme, width?: number): string[] {
|
|
139
|
+
const fg = (color: ThemeColor, s: string): string => theme.fg(color, s);
|
|
140
|
+
const bold = (s: string): string => theme.bold(s);
|
|
141
|
+
const fit = (line: string): string =>
|
|
142
|
+
typeof width === "number" && width > 0 ? truncateToWidth(line, Math.max(10, width - 1)) : line;
|
|
143
|
+
const running = listNodes().filter((node) => node.status === "running");
|
|
144
|
+
// Stay out of the way until a teammate is actually working.
|
|
145
|
+
if (running.length === 0) return [];
|
|
146
|
+
if (isPanelCollapsed()) {
|
|
147
|
+
return [` ${fit(fg("dim", `${running.length} teammate${running.length === 1 ? "" : "s"} working — /teammate`))}`];
|
|
148
|
+
}
|
|
149
|
+
const lines: string[] = [];
|
|
150
|
+
for (const node of running) {
|
|
151
|
+
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
152
|
+
lines.push(` ${fit(`${bold(fg(color, node.id))} ${fg("muted", `(${node.agent})`)} ${fg("warning", runningTeammateLabel(node))}`)}`);
|
|
153
|
+
}
|
|
154
|
+
lines.push(` ${fit(fg("dim", "/teammate — open console"))}`);
|
|
155
|
+
return lines;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Full content of a node as shown on its detail page: spawn lifecycle, live
|
|
159
|
+
* worker text, captured output, result and error. */
|
|
160
|
+
function buildNodeSection(node: Node): string[] {
|
|
161
|
+
const lines: string[] = [`- [${node.id}] ${node.status}: ${node.agent}`];
|
|
162
|
+
if (node.prompt) lines.push(` Task: ${cap(node.prompt, 2000)}`);
|
|
163
|
+
lines.push(` Access: ${node.access} | Paths: ${node.paths.join(", ")}`);
|
|
164
|
+
if (node.dependsOn.length > 0) lines.push(` Depends on: ${node.dependsOn.join(", ")}`);
|
|
165
|
+
|
|
166
|
+
const spawn = node.spawn;
|
|
167
|
+
if (spawn) {
|
|
168
|
+
const stateLabel = spawn.status === "running" ? `running (pid ${spawn.pid})` : spawn.status;
|
|
169
|
+
lines.push(` Spawn: ${stateLabel} | Isolation: ${spawn.isolation ?? "none"}`);
|
|
170
|
+
if (spawn.startedAt) lines.push(` Started: ${new Date(spawn.startedAt).toLocaleString()}`);
|
|
171
|
+
if (spawn.finishedAt) lines.push(` Finished: ${new Date(spawn.finishedAt).toLocaleString()}`);
|
|
172
|
+
if (spawn.exitCode !== undefined) lines.push(` Exit code: ${spawn.exitCode}`);
|
|
173
|
+
if (spawn.timedOut) lines.push(" Timed out: yes");
|
|
174
|
+
if (spawn.usage) {
|
|
175
|
+
const u = spawn.usage;
|
|
176
|
+
lines.push(` Usage: ${u.totalTokens} tokens (in ${u.input} / out ${u.output}) | cost $${u.cost}`);
|
|
177
|
+
}
|
|
178
|
+
if (spawn.turns) lines.push(` Worker turns: ${spawn.turns}`);
|
|
179
|
+
if (spawn.activeTool) lines.push(` Current tool: ${spawn.activeTool}`);
|
|
180
|
+
if (spawn.error) lines.push(` Spawn error: ${spawn.error}`);
|
|
181
|
+
if (spawn.status === "running") {
|
|
182
|
+
lines.push(" --- Live worker activity ---");
|
|
183
|
+
const activity = [
|
|
184
|
+
...(spawn.liveThinking?.trim() ? spawn.liveThinking.split("\n").map((l) => ` ${l}`) : []),
|
|
185
|
+
...(spawn.liveText?.trim() ? spawn.liveText.split("\n").map((l) => ` ${l}`) : []),
|
|
186
|
+
];
|
|
187
|
+
lines.push(...(activity.length > 0 ? activity : [" Waiting for the worker's first response…"]));
|
|
188
|
+
}
|
|
189
|
+
if (spawn.stdout) {
|
|
190
|
+
lines.push(" --- Worker output ---");
|
|
191
|
+
lines.push(...spawn.stdout.split("\n"));
|
|
192
|
+
}
|
|
193
|
+
if (spawn.stderr) {
|
|
194
|
+
lines.push(" --- Worker stderr ---");
|
|
195
|
+
lines.push(...spawn.stderr.split("\n"));
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (node.result && node.result !== spawn?.stdout) lines.push(` Result: ${node.result}`);
|
|
200
|
+
if (node.errorMessage) lines.push(` Error: ${node.errorMessage}`);
|
|
201
|
+
return lines;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Node detail: node section + reports this node sent to team-leader. */
|
|
205
|
+
function buildNodeDetail(workerKey: string): string[] {
|
|
206
|
+
const entry = getNodeByWorkerKey(workerKey);
|
|
207
|
+
if (!entry) return ["(removed)"];
|
|
208
|
+
const { run, node } = entry;
|
|
209
|
+
const outgoing = getState().leaderMailbox.filter((message) => message.from === workerKey);
|
|
210
|
+
|
|
211
|
+
const lines: string[] = [
|
|
212
|
+
`${node.workerKey} (${node.agent}) [${node.status}] — run ${run.status}`,
|
|
213
|
+
"",
|
|
214
|
+
"== node ==",
|
|
215
|
+
...buildNodeSection(node),
|
|
216
|
+
"",
|
|
217
|
+
`== reports to team-leader (${outgoing.length}) ==`,
|
|
218
|
+
...(outgoing.length === 0
|
|
219
|
+
? ["(none)"]
|
|
220
|
+
: outgoing.flatMap((m) => {
|
|
221
|
+
const time = new Date(m.timestamp).toLocaleString();
|
|
222
|
+
return [
|
|
223
|
+
`→ [${m.id}] ${m.subject} | ${time}`,
|
|
224
|
+
m.runId ? ` run ${m.runId}` : "",
|
|
225
|
+
m.body,
|
|
226
|
+
"",
|
|
227
|
+
];
|
|
228
|
+
})),
|
|
229
|
+
];
|
|
230
|
+
return lines;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Full-screen Team Console — owns input via ctx.ui.custom, so ↑/↓ and Enter
|
|
234
|
+
* are safe in here and nothing is intercepted globally. Modes: list / detail. */
|
|
235
|
+
export function openTeamConsole(ctx: { ui: ExtensionUIContext }): Promise<void> {
|
|
236
|
+
return ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
|
|
237
|
+
let mode: "list" | "detail" = "list";
|
|
238
|
+
let selected = 0;
|
|
239
|
+
let detailKey = "";
|
|
240
|
+
let offset = 0;
|
|
241
|
+
let closed = false;
|
|
242
|
+
let renderTimer: ReturnType<typeof setInterval> | undefined;
|
|
243
|
+
const requestRender = () => {
|
|
244
|
+
if (!closed) tui.requestRender();
|
|
245
|
+
};
|
|
246
|
+
const startLiveRefresh = () => {
|
|
247
|
+
if (renderTimer) return;
|
|
248
|
+
renderTimer = setInterval(requestRender, SPINNER_MS);
|
|
249
|
+
renderTimer.unref?.();
|
|
250
|
+
};
|
|
251
|
+
const stopLiveRefresh = () => {
|
|
252
|
+
if (!renderTimer) return;
|
|
253
|
+
clearInterval(renderTimer);
|
|
254
|
+
renderTimer = undefined;
|
|
255
|
+
};
|
|
256
|
+
startLiveRefresh();
|
|
257
|
+
|
|
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
|
+
};
|
|
267
|
+
|
|
268
|
+
const windowLines = (full: string[], width: number): { lines: string[]; range: string } => {
|
|
269
|
+
const wrapped = wrapConsoleDetail(full, width);
|
|
270
|
+
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
271
|
+
offset = clampConsoleScroll(offset, wrapped.length, viewport);
|
|
272
|
+
return {
|
|
273
|
+
lines: wrapped.slice(offset, offset + viewport),
|
|
274
|
+
range: consoleScrollRange(offset, wrapped.length, viewport),
|
|
275
|
+
};
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const renderList = (width: number): string[] => {
|
|
279
|
+
const rows = buildPanelRows();
|
|
280
|
+
if (selected >= rows.length) selected = Math.max(0, rows.length - 1);
|
|
281
|
+
const border = style.border("─".repeat(Math.max(1, width)));
|
|
282
|
+
const lines: string[] = [
|
|
283
|
+
border,
|
|
284
|
+
style.accent(truncateToWidth(`teammate ${listNodes().filter((n) => n.status === "running").length} working / ${listNodes().length} teammate(s)`, width)),
|
|
285
|
+
"",
|
|
286
|
+
];
|
|
287
|
+
for (let i = 0; i < rows.length; i++) {
|
|
288
|
+
const row = rows[i];
|
|
289
|
+
const marker = i === selected ? style.accent("❯ ") : " ";
|
|
290
|
+
const entry = getNodeByWorkerKey(row.key);
|
|
291
|
+
if (!entry) continue;
|
|
292
|
+
const { node } = entry;
|
|
293
|
+
const color = TEAM_COLORS[hashName(node.workerKey) % TEAM_COLORS.length];
|
|
294
|
+
const name = theme.bold(theme.fg(color, node.id));
|
|
295
|
+
const role = style.muted(`(${node.agent})`);
|
|
296
|
+
const status = node.status === "running"
|
|
297
|
+
? theme.fg("warning", runningTeammateLabel(node))
|
|
298
|
+
: node.status === "completed"
|
|
299
|
+
? style.success("✓ completed")
|
|
300
|
+
: node.status === "failed"
|
|
301
|
+
? style.error("✗ failed")
|
|
302
|
+
: style.dim(`○ ${node.status}`);
|
|
303
|
+
lines.push(`${marker}${name} ${role} ${status}`);
|
|
304
|
+
}
|
|
305
|
+
lines.push("", style.dim("↑↓ select · enter open · esc/q close · x cancel"), border);
|
|
306
|
+
return lines.map((l) => truncateToWidth(l, Math.max(10, width - 1)));
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const renderDetail = (width: number): string[] => {
|
|
310
|
+
const border = style.border("─".repeat(Math.max(1, width)));
|
|
311
|
+
const detail = windowLines(buildNodeDetail(detailKey), width);
|
|
312
|
+
const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
|
|
313
|
+
const lines = [
|
|
314
|
+
border,
|
|
315
|
+
style.accent(truncateToWidth(`agent-teams ${detailKey}`, width)),
|
|
316
|
+
"",
|
|
317
|
+
...detail.lines.map((line) => ` ${line}`),
|
|
318
|
+
"",
|
|
319
|
+
footer,
|
|
320
|
+
border,
|
|
321
|
+
];
|
|
322
|
+
return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
return {
|
|
326
|
+
render: (width) =>
|
|
327
|
+
mode === "list" ? renderList(width) : renderDetail(width),
|
|
328
|
+
handleInput: (data: string) => {
|
|
329
|
+
// Detail mode: Esc returns to the list, q closes.
|
|
330
|
+
if (mode !== "list") {
|
|
331
|
+
if (matchesKey(data, Key.escape)) {
|
|
332
|
+
mode = "list";
|
|
333
|
+
offset = 0;
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (data === "q" || data === "Q") {
|
|
337
|
+
closed = true;
|
|
338
|
+
stopLiveRefresh();
|
|
339
|
+
done();
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const detail = wrapConsoleDetail(buildNodeDetail(detailKey), tui.terminal.columns);
|
|
343
|
+
const viewport = maxConsoleBody(tui.terminal.rows);
|
|
344
|
+
// Mouse-wheel scrolling (SGR wheel events, same as @fradser/pi-btw).
|
|
345
|
+
const sgrWheel = /^\x1b\[<(\d+);(\d+);(\d+)[Mm]$/.exec(data);
|
|
346
|
+
if (sgrWheel) {
|
|
347
|
+
const button = Number.parseInt(sgrWheel[1], 10);
|
|
348
|
+
if ((button & 64) !== 0) {
|
|
349
|
+
const direction = button & 3;
|
|
350
|
+
if (direction === 0) offset = scrollConsoleDetail(offset, -3, detail.length, viewport);
|
|
351
|
+
else if (direction === 1) offset = scrollConsoleDetail(offset, 3, detail.length, viewport);
|
|
352
|
+
}
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (matchesKey(data, Key.up)) offset = scrollConsoleDetail(offset, -1, detail.length, viewport);
|
|
356
|
+
else if (matchesKey(data, Key.down)) offset = scrollConsoleDetail(offset, 1, detail.length, viewport);
|
|
357
|
+
else if (matchesKey(data, Key.pageUp)) offset = scrollConsoleDetail(offset, -Math.max(1, viewport - 1), detail.length, viewport);
|
|
358
|
+
else if (matchesKey(data, Key.pageDown)) offset = scrollConsoleDetail(offset, Math.max(1, viewport - 1), detail.length, viewport);
|
|
359
|
+
else if (matchesKey(data, Key.home)) offset = 0;
|
|
360
|
+
else if (matchesKey(data, Key.end)) offset = clampConsoleScroll(Number.MAX_SAFE_INTEGER, detail.length, viewport);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// List mode — the console owns input, so ↑/↓/Enter are safe here.
|
|
365
|
+
const rows = buildPanelRows();
|
|
366
|
+
if (matchesKey(data, Key.down)) selected = Math.min(selected + 1, rows.length - 1);
|
|
367
|
+
if (matchesKey(data, Key.up)) selected = Math.max(selected - 1, 0);
|
|
368
|
+
if (matchesKey(data, Key.enter)) {
|
|
369
|
+
const row = rows[Math.min(selected, rows.length - 1)];
|
|
370
|
+
if (row?.key) {
|
|
371
|
+
mode = "detail";
|
|
372
|
+
detailKey = row.key;
|
|
373
|
+
offset = 0;
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
if (matchesKey(data, Key.escape) || data === "q" || data === "Q") {
|
|
378
|
+
closed = true;
|
|
379
|
+
stopLiveRefresh();
|
|
380
|
+
done();
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (data === "x" || data === "X") {
|
|
384
|
+
// Same node-cancel path as teammate_cancel nodeId: SIGTERM→SIGKILL,
|
|
385
|
+
// dependents cancelled, the rest of the run continues.
|
|
386
|
+
const row = rows[Math.min(selected, rows.length - 1)];
|
|
387
|
+
const entry = row?.key ? getNodeByWorkerKey(row.key) : undefined;
|
|
388
|
+
if (entry && (entry.node.status === "running" || entry.node.status === "pending")) {
|
|
389
|
+
const runId = entry.run.id;
|
|
390
|
+
const nodeId = entry.node.id;
|
|
391
|
+
void cancelNodeAndTerminate(runId, nodeId, { ui: ctx.ui })
|
|
392
|
+
.then((result) => {
|
|
393
|
+
if (result.ok) ctx.ui.notify(`Cancelled node [${runId}/${nodeId}] — the rest of the run continues.`, "info");
|
|
394
|
+
else ctx.ui.notify(result.error, "error");
|
|
395
|
+
})
|
|
396
|
+
.catch(() => false);
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
},
|
|
401
|
+
invalidate: () => {
|
|
402
|
+
requestRender();
|
|
403
|
+
},
|
|
404
|
+
dispose: () => {
|
|
405
|
+
closed = true;
|
|
406
|
+
stopLiveRefresh();
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
package/src/worker.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { appendWorkerEvent } from "./statefile";
|
|
4
|
+
import { TeammateMessageParams, type WorkerEvent } from "./types";
|
|
5
|
+
|
|
6
|
+
export interface WorkerBinding {
|
|
7
|
+
worker: string;
|
|
8
|
+
taskId: string;
|
|
9
|
+
spawnId: string;
|
|
10
|
+
outbox: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function workerOutboxBinding(): WorkerBinding | undefined {
|
|
14
|
+
const worker = process.env.PI_TEAMMATE_WORKER_NAME;
|
|
15
|
+
const taskId = process.env.PI_TEAMMATE_TASK_ID;
|
|
16
|
+
const spawnId = process.env.PI_TEAMMATE_SPAWN_ID;
|
|
17
|
+
const outbox = process.env.PI_TEAMMATE_OUTBOX_FILE;
|
|
18
|
+
return worker && taskId && spawnId && outbox ? { worker, taskId, spawnId, outbox } : undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isWorkerEvent(value: unknown): value is WorkerEvent {
|
|
22
|
+
if (!value || typeof value !== "object") return false;
|
|
23
|
+
const event = value as Partial<WorkerEvent>;
|
|
24
|
+
return typeof event.id === "string"
|
|
25
|
+
&& event.type === "message"
|
|
26
|
+
&& typeof event.worker === "string"
|
|
27
|
+
&& typeof event.spawnId === "string"
|
|
28
|
+
&& typeof event.subject === "string"
|
|
29
|
+
&& typeof event.body === "string"
|
|
30
|
+
&& (event.status === undefined || ["in_progress", "completed", "failed"].includes(event.status));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
34
|
+
pi.registerTool({
|
|
35
|
+
name: "teammate_message",
|
|
36
|
+
promptSnippet: "Report progress or a final deliverable to the team leader",
|
|
37
|
+
label: "Teammate Report",
|
|
38
|
+
description: "Worker-only report channel. Send progress, blockers, or the final deliverable to the team leader. Set status=\"completed\" or \"failed\" for the terminal report.",
|
|
39
|
+
parameters: TeammateMessageParams,
|
|
40
|
+
async execute(_toolCallId, params) {
|
|
41
|
+
const binding = workerOutboxBinding();
|
|
42
|
+
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
43
|
+
appendWorkerEvent(binding.outbox, {
|
|
44
|
+
id: randomUUID(),
|
|
45
|
+
type: "message",
|
|
46
|
+
worker: binding.worker,
|
|
47
|
+
spawnId: binding.spawnId,
|
|
48
|
+
subject: params.subject,
|
|
49
|
+
body: params.body,
|
|
50
|
+
status: params.status,
|
|
51
|
+
});
|
|
52
|
+
const statusNote = params.status ? ` with status "${params.status}"` : "";
|
|
53
|
+
return { content: [{ type: "text", text: `Queued report to team-leader${statusNote}.` }], details: {} };
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Teammate worktree isolation — run each spawned worker in a fresh git
|
|
3
|
+
* worktree so parallel workers never collide on the same working tree.
|
|
4
|
+
*
|
|
5
|
+
* Layout: `<repoRoot>/.pi/worktrees/teammate-<taskId>/`, branch
|
|
6
|
+
* `teammate/<taskId>`. On completion the worker's diff is captured against
|
|
7
|
+
* the base commit, then the worktree and branch are removed.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface WorktreeSetup {
|
|
14
|
+
repoRoot: string;
|
|
15
|
+
/** Absolute path of the worktree. */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Working directory for the worker (worktree root). */
|
|
18
|
+
cwd: string;
|
|
19
|
+
branch: string;
|
|
20
|
+
baseCommit: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WorktreeDiff {
|
|
24
|
+
/** Full patch of the worker's changes vs the base commit ("" when clean). */
|
|
25
|
+
patch: string;
|
|
26
|
+
/** `git diff --stat` summary ("" when clean). */
|
|
27
|
+
diffStat: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runGit(cwd: string, args: string[]): { status: number | null; stdout: string; stderr: string } {
|
|
31
|
+
const result = spawnSync("git", ["-C", cwd, ...args], { encoding: "utf-8" });
|
|
32
|
+
return {
|
|
33
|
+
status: result.status,
|
|
34
|
+
stdout: result.stdout ?? "",
|
|
35
|
+
stderr: result.stderr ?? "",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function safeTaskSegment(taskId: string): string {
|
|
40
|
+
return taskId.replace(/[^\w.-]/g, "_");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Create a worktree for a task, branched from the repo's current HEAD.
|
|
45
|
+
* Returns an error string when the cwd is not a git repository or the
|
|
46
|
+
* worktree could not be created.
|
|
47
|
+
*/
|
|
48
|
+
export function createWorktree(
|
|
49
|
+
cwd: string,
|
|
50
|
+
taskId: string,
|
|
51
|
+
): WorktreeSetup | { error: string } {
|
|
52
|
+
const toplevel = runGit(cwd, ["rev-parse", "--show-toplevel"]);
|
|
53
|
+
if (toplevel.status !== 0) {
|
|
54
|
+
return { error: "worktree isolation requires a git repository" };
|
|
55
|
+
}
|
|
56
|
+
const repoRoot = toplevel.stdout.trim();
|
|
57
|
+
const baseCommit = runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
58
|
+
if (baseCommit.status !== 0 || !baseCommit.stdout.trim()) {
|
|
59
|
+
return { error: "worktree isolation requires at least one commit" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const safeId = safeTaskSegment(taskId);
|
|
63
|
+
const worktreePath = path.join(repoRoot, ".pi", "worktrees", `teammate-${safeId}`);
|
|
64
|
+
const branch = `teammate/${safeId}`;
|
|
65
|
+
|
|
66
|
+
const add = runGit(repoRoot, ["worktree", "add", worktreePath, "-b", branch, "HEAD"]);
|
|
67
|
+
if (add.status !== 0) {
|
|
68
|
+
return { error: `failed to create worktree: ${add.stderr.trim() || add.stdout.trim()}` };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
repoRoot,
|
|
73
|
+
path: worktreePath,
|
|
74
|
+
cwd: worktreePath,
|
|
75
|
+
branch,
|
|
76
|
+
baseCommit: baseCommit.stdout.trim(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Capture the worker's changes in the worktree as a patch against the base
|
|
82
|
+
* commit. The worktree is left untouched; call cleanupWorktree afterwards.
|
|
83
|
+
*/
|
|
84
|
+
export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiff {
|
|
85
|
+
execFileSync("git", ["-C", setup.path, "add", "-A"], { stdio: "ignore" });
|
|
86
|
+
const stat = runGit(setup.path, ["diff", "--cached", "--stat", setup.baseCommit]);
|
|
87
|
+
const patch = runGit(setup.path, ["diff", "--cached", setup.baseCommit]);
|
|
88
|
+
return {
|
|
89
|
+
patch: patch.stdout ?? "",
|
|
90
|
+
diffStat: stat.stdout?.trim() ?? "",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Remove the worktree and its branch. Errors are returned rather than thrown
|
|
96
|
+
* so task completion is never blocked by cleanup failure.
|
|
97
|
+
*/
|
|
98
|
+
export function cleanupWorktree(setup: WorktreeSetup): { ok: boolean; error?: string } {
|
|
99
|
+
const remove = runGit(setup.repoRoot, ["worktree", "remove", "--force", setup.path]);
|
|
100
|
+
const branch = runGit(setup.repoRoot, ["branch", "-D", setup.branch]);
|
|
101
|
+
const errors: string[] = [];
|
|
102
|
+
if (remove.status !== 0) errors.push(`worktree remove: ${remove.stderr.trim()}`);
|
|
103
|
+
if (branch.status !== 0) errors.push(`branch delete: ${branch.stderr.trim()}`);
|
|
104
|
+
try {
|
|
105
|
+
runGit(setup.repoRoot, ["worktree", "prune"]);
|
|
106
|
+
} catch {
|
|
107
|
+
// Prune is best effort.
|
|
108
|
+
}
|
|
109
|
+
return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Best-effort cleanup used when a spawn fails before the worker starts. */
|
|
113
|
+
export function discardWorktree(setup: WorktreeSetup): void {
|
|
114
|
+
cleanupWorktree(setup);
|
|
115
|
+
}
|