@fradser/pi-agent-teams 0.8.0 → 0.8.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/.memory/MEMORY.md +1 -0
- package/.memory/follow-up-queue.md +10 -6
- package/.memory/team-centric-design.md +3 -3
- package/README.md +26 -16
- package/package.json +2 -2
- package/references/agent-roles.md +8 -2
- package/src/follow-up-queue.ts +84 -21
- package/src/guidance.ts +127 -42
- package/src/index.ts +88 -33
- package/src/spawner.ts +63 -3
- package/src/state.ts +224 -21
- package/src/statefile.ts +14 -7
- package/src/team-machine.ts +657 -81
- package/src/tool-render.ts +48 -0
- package/src/tools.ts +115 -78
- package/src/types.ts +51 -7
- package/src/ui.ts +62 -53
- package/src/worker.ts +182 -19
- package/src/worktree.ts +46 -9
package/src/ui.ts
CHANGED
|
@@ -9,6 +9,9 @@ import {
|
|
|
9
9
|
modelSearchText,
|
|
10
10
|
PI_SPINNER_FRAMES,
|
|
11
11
|
PI_SPINNER_INTERVAL_MS,
|
|
12
|
+
notifyPi,
|
|
13
|
+
renderPiPanel,
|
|
14
|
+
renderPiWidgetRow,
|
|
12
15
|
sortModels,
|
|
13
16
|
type SearchPicker,
|
|
14
17
|
} from "@fradser/pi-kit";
|
|
@@ -18,7 +21,7 @@ import {
|
|
|
18
21
|
} from "./console-viewport.ts";
|
|
19
22
|
import { fitTeammateRow, formatTeammateLabel, runningTeammateActivity } from "./activity.ts";
|
|
20
23
|
import { MODEL_INHERIT_ALIAS, discoverAgents, resolveAgent, type AgentDefinition } from "./agents.ts";
|
|
21
|
-
import { getState, getTeammate, getTeamDefaultModel, listTasks, listTeammates, livingTeammates, setTeamDefaultModel } from "./state.ts";
|
|
24
|
+
import { getPeerDeliveryState, getState, getTeammate, getTeamDefaultModel, listTasks, listTeammates, livingTeammates, setTeamDefaultModel } from "./state.ts";
|
|
22
25
|
import {
|
|
23
26
|
currentLeaderModelRef,
|
|
24
27
|
ensureLivePoll,
|
|
@@ -26,8 +29,8 @@ import {
|
|
|
26
29
|
resolveSpawnModel,
|
|
27
30
|
runtimeDirPath,
|
|
28
31
|
shutdownTeammate,
|
|
29
|
-
STALL_NOTICE_MS,
|
|
30
32
|
stallSilenceMs,
|
|
33
|
+
stallThresholdMs,
|
|
31
34
|
} from "./team-machine.ts";
|
|
32
35
|
import type { Teammate } from "./types.ts";
|
|
33
36
|
import { mapPickerKey } from "./picker-keys.ts";
|
|
@@ -91,13 +94,13 @@ export function ensureTeamWidget(ctx?: { ui?: ExtensionUIContext; mode?: string
|
|
|
91
94
|
if (working.length === 0) return [];
|
|
92
95
|
const lines: string[] = [];
|
|
93
96
|
for (const teammate of working) {
|
|
94
|
-
lines.push(fitTeammateRow(
|
|
97
|
+
lines.push(renderPiWidgetRow(fitTeammateRow(
|
|
95
98
|
PI_SPINNER_FRAMES[spinnerFrame],
|
|
96
99
|
style.fg(colorFor(teammate.name), teammate.name),
|
|
97
100
|
runningTeammateActivity(teammate) + stallSuffix(teammate),
|
|
98
|
-
width,
|
|
101
|
+
Math.max(1, width - 1),
|
|
99
102
|
(activity) => theme.bold(style.fg("accent", activity)),
|
|
100
|
-
));
|
|
103
|
+
), width, truncateToWidth));
|
|
101
104
|
}
|
|
102
105
|
return lines;
|
|
103
106
|
},
|
|
@@ -111,11 +114,12 @@ function isWorking(teammate: { status: string }): boolean {
|
|
|
111
114
|
return teammate.status === "working" || teammate.status === "starting";
|
|
112
115
|
}
|
|
113
116
|
|
|
114
|
-
/** Silence marker appended once a working teammate passes
|
|
117
|
+
/** Silence marker appended once a working teammate passes its stall threshold. */
|
|
115
118
|
function stallSuffix(teammate: Teammate): string {
|
|
116
119
|
if (!isWorking(teammate)) return "";
|
|
117
120
|
const silence = stallSilenceMs(teammate);
|
|
118
|
-
|
|
121
|
+
const threshold = stallThresholdMs(teammate);
|
|
122
|
+
return silence !== undefined && threshold > 0 && silence >= threshold
|
|
119
123
|
? ` · stalled for ${formatSilenceDuration(silence)}`
|
|
120
124
|
: "";
|
|
121
125
|
}
|
|
@@ -134,6 +138,7 @@ function cap(text: string | undefined, maxBytes = 2000): string {
|
|
|
134
138
|
// ── Detail builders ───────────────────────────────────────────────
|
|
135
139
|
|
|
136
140
|
interface PeerMailLine {
|
|
141
|
+
id: string;
|
|
137
142
|
direction: "sent" | "received";
|
|
138
143
|
counterpart: string;
|
|
139
144
|
subject: string;
|
|
@@ -167,6 +172,7 @@ function readPeerMail(teammateName: string): PeerMailLine[] {
|
|
|
167
172
|
if (!message.id || !message.from || !message.subject) continue;
|
|
168
173
|
if (message.from !== teammateName && recipient !== teammateName) continue;
|
|
169
174
|
lines.push({
|
|
175
|
+
id: message.id,
|
|
170
176
|
direction: message.from === teammateName ? "sent" : "received",
|
|
171
177
|
counterpart: message.from === teammateName ? recipient : message.from,
|
|
172
178
|
subject: message.subject,
|
|
@@ -202,6 +208,7 @@ function buildTeammateDetail(name: string): string[] {
|
|
|
202
208
|
` Status: ${teammate.status}${teammate.currentTaskId ? ` | Task: ${teammate.currentTaskId}` : ""}`,
|
|
203
209
|
` Spawn: ${teammate.pid > 0 ? `pid ${teammate.pid}` : "pid unknown"} | Isolation: ${teammate.isolation}`,
|
|
204
210
|
...(teammate.model ? [` Launch model: ${teammate.model}`] : []),
|
|
211
|
+
...(teammate.tools?.length ? [` Tools: ${teammate.tools.join(", ")}`] : []),
|
|
205
212
|
` Created: ${new Date(teammate.createdAt).toLocaleString()}`,
|
|
206
213
|
];
|
|
207
214
|
if (teammate.stoppedAt) lines.push(` Stopped: ${new Date(teammate.stoppedAt).toLocaleString()}`);
|
|
@@ -217,7 +224,8 @@ function buildTeammateDetail(name: string): string[] {
|
|
|
217
224
|
lines.push("", `== reports to leader (${reports.length}) ==`);
|
|
218
225
|
if (reports.length === 0) lines.push(" (none)");
|
|
219
226
|
for (const report of reports) {
|
|
220
|
-
|
|
227
|
+
const archive = report.archived ? " · archived after shutdown" : "";
|
|
228
|
+
lines.push(` ->${archive} [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
const peer = readPeerMail(name);
|
|
@@ -225,7 +233,10 @@ function buildTeammateDetail(name: string): string[] {
|
|
|
225
233
|
if (peer.length === 0) lines.push(" (none)");
|
|
226
234
|
for (const mail of peer) {
|
|
227
235
|
const arrow = mail.direction === "sent" ? `to @${mail.counterpart}` : `from @${mail.counterpart}`;
|
|
228
|
-
|
|
236
|
+
const routing = mail.direction === "sent"
|
|
237
|
+
? getPeerDeliveryState(mail.id) ?? "written"
|
|
238
|
+
: "received";
|
|
239
|
+
lines.push(` ${arrow} · ${routing} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
|
|
229
240
|
}
|
|
230
241
|
return lines;
|
|
231
242
|
}
|
|
@@ -239,7 +250,8 @@ function buildTaskDetail(taskId: string): string[] {
|
|
|
239
250
|
`[${task.id}] ${task.subject}`,
|
|
240
251
|
"",
|
|
241
252
|
"== task ==",
|
|
242
|
-
` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}`,
|
|
253
|
+
` Status: ${task.status}${task.claimedBy ? ` (@${task.claimedBy})` : ""}${task.supersededBy ? ` → superseded by ${task.supersededBy}` : ""}`,
|
|
254
|
+
...((task.resources?.length ?? 0) > 0 ? [` Resources: ${task.resources.join(", ")}`] : []),
|
|
243
255
|
...(task.description ? [` Description:`, ...indent(cap(task.description))] : []),
|
|
244
256
|
...(task.dependsOn.length > 0
|
|
245
257
|
? [` Depends on: ${task.dependsOn.join(", ")} (${task.dependsOn.every((dep) => byId.get(dep)?.status === "completed") ? "met" : "unmet"})`]
|
|
@@ -406,7 +418,6 @@ export function openTeamConsole(ctx: {
|
|
|
406
418
|
|
|
407
419
|
const renderPicker = (width: number): string[] => {
|
|
408
420
|
if (!picker) return [];
|
|
409
|
-
const border = style.border("─".repeat(Math.max(1, width)));
|
|
410
421
|
const results = picker.results();
|
|
411
422
|
const selected = picker.selectedIndex();
|
|
412
423
|
if (selected < pickerOffset) pickerOffset = selected;
|
|
@@ -427,16 +438,19 @@ export function openTeamConsole(ctx: {
|
|
|
427
438
|
const display = model.name && model.name !== model.id ? style.dim(model.name) : "";
|
|
428
439
|
return truncateToWidth(`${marker}${theme.fg("customMessageText", modelLabel(model))} ${display} ${tags}`.replace(/\s+$/g, ""), Math.max(10, width - 1));
|
|
429
440
|
});
|
|
430
|
-
return
|
|
431
|
-
|
|
432
|
-
style
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
441
|
+
return renderPiPanel({
|
|
442
|
+
width,
|
|
443
|
+
style,
|
|
444
|
+
fit: truncateToWidth,
|
|
445
|
+
title: `teammate model ${teamDefault ?? "auto (Pi default)"}`,
|
|
446
|
+
body: [
|
|
447
|
+
"",
|
|
448
|
+
`${style.accent("❯ ")}${picker.query()}▏ ${style.dim(`${results.length} models`)}`,
|
|
449
|
+
...rows,
|
|
450
|
+
"",
|
|
451
|
+
],
|
|
452
|
+
footer: "type to filter · ↑↓ select · enter set/clear · esc cancel",
|
|
453
|
+
});
|
|
440
454
|
};
|
|
441
455
|
|
|
442
456
|
function handlePickerInput(data: string): void {
|
|
@@ -456,10 +470,10 @@ export function openTeamConsole(ctx: {
|
|
|
456
470
|
if (!picked) return;
|
|
457
471
|
if (isClearEntry(picked)) {
|
|
458
472
|
setTeamDefaultModel(undefined);
|
|
459
|
-
ctx.ui
|
|
473
|
+
notifyPi(ctx.ui, "Teammate model cleared — Pi picks its default", "info");
|
|
460
474
|
} else {
|
|
461
475
|
setTeamDefaultModel(modelLabel(picked));
|
|
462
|
-
ctx.ui
|
|
476
|
+
notifyPi(ctx.ui, `Teammate model set to ${modelLabel(picked)} for this session`, "info");
|
|
463
477
|
}
|
|
464
478
|
closeModelPicker();
|
|
465
479
|
return;
|
|
@@ -494,7 +508,7 @@ export function openTeamConsole(ctx: {
|
|
|
494
508
|
const headerLine = (): string => {
|
|
495
509
|
const alive = livingTeammates();
|
|
496
510
|
const tasks = listTasks();
|
|
497
|
-
return `team ${alive.length} alive · ${alive.filter(isWorking).length} working · ${getRoles().length} roles · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d · model ${getTeamDefaultModel() ?? "auto"} · ${page}`;
|
|
511
|
+
return `team ${alive.length} alive · ${alive.filter(isWorking).length} working · ${getRoles().length} roles · board ${tasks.filter((task) => task.status === "pending").length}p/${tasks.filter((task) => task.status === "claimed").length}c/${tasks.filter((task) => task.status === "completed").length}d/${tasks.filter((task) => task.status === "superseded").length}s · model ${getTeamDefaultModel() ?? "auto"} · ${page}`;
|
|
498
512
|
};
|
|
499
513
|
|
|
500
514
|
interface ContentLine {
|
|
@@ -537,9 +551,11 @@ export function openTeamConsole(ctx: {
|
|
|
537
551
|
const holder = task.claimedBy ? style.dim(` @${task.claimedBy}`) : "";
|
|
538
552
|
const statusText = task.status === "completed"
|
|
539
553
|
? style.success("✓")
|
|
540
|
-
: task.status === "
|
|
541
|
-
?
|
|
542
|
-
:
|
|
554
|
+
: task.status === "superseded"
|
|
555
|
+
? style.dim("⊘ superseded")
|
|
556
|
+
: task.status === "claimed"
|
|
557
|
+
? theme.fg("warning", "◐ claimed")
|
|
558
|
+
: style.dim("○ pending");
|
|
543
559
|
return `${marker}${label} ${statusText} ${theme.fg("customMessageText", task.subject)}${holder}`;
|
|
544
560
|
};
|
|
545
561
|
|
|
@@ -573,7 +589,6 @@ export function openTeamConsole(ctx: {
|
|
|
573
589
|
const listViewport = (): number => Math.max(1, tui.terminal.rows - LIST_CHROME_LINES);
|
|
574
590
|
|
|
575
591
|
const renderList = (width: number): string[] => {
|
|
576
|
-
const border = style.border("─".repeat(Math.max(1, width)));
|
|
577
592
|
const content = buildContent();
|
|
578
593
|
const viewport = listViewport();
|
|
579
594
|
const selection = currentPageSelection(currentRows().length);
|
|
@@ -588,16 +603,14 @@ export function openTeamConsole(ctx: {
|
|
|
588
603
|
: style.dim(page === "roster"
|
|
589
604
|
? "↑↓ select · enter open · tab board · x shutdown · m model · esc/q close"
|
|
590
605
|
: "↑↓ select · enter open · tab roster · esc/q close");
|
|
591
|
-
return
|
|
592
|
-
|
|
593
|
-
style
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
border,
|
|
600
|
-
];
|
|
606
|
+
return renderPiPanel({
|
|
607
|
+
width,
|
|
608
|
+
style,
|
|
609
|
+
fit: truncateToWidth,
|
|
610
|
+
title: headerLine(),
|
|
611
|
+
body: content.slice(listOffset, listOffset + viewport).map((line) => line.text),
|
|
612
|
+
footer,
|
|
613
|
+
});
|
|
601
614
|
};
|
|
602
615
|
|
|
603
616
|
const currentPageSelection = (rowCount: number): number => {
|
|
@@ -609,20 +622,16 @@ export function openTeamConsole(ctx: {
|
|
|
609
622
|
detailKind === "task" ? `[${detailKey}]` : detailKind === "role" ? `@${detailKey} · role` : `@${detailKey}`;
|
|
610
623
|
|
|
611
624
|
const renderDetail = (width: number): string[] => {
|
|
612
|
-
const border = style.border("─".repeat(Math.max(1, width)));
|
|
613
625
|
const source = detailSource();
|
|
614
626
|
const detail = windowLines(source, width);
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
border,
|
|
624
|
-
];
|
|
625
|
-
return lines.map((line) => truncateToWidth(line, Math.max(10, width - 1)));
|
|
627
|
+
return renderPiPanel({
|
|
628
|
+
width,
|
|
629
|
+
style,
|
|
630
|
+
fit: truncateToWidth,
|
|
631
|
+
title: `agent-teams ${detailTitle()}`,
|
|
632
|
+
body: detail.lines,
|
|
633
|
+
footer: `${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`,
|
|
634
|
+
});
|
|
626
635
|
};
|
|
627
636
|
|
|
628
637
|
const detailSource = (): string[] => {
|
|
@@ -685,7 +694,7 @@ export function openTeamConsole(ctx: {
|
|
|
685
694
|
if (ctx.modelRegistry && ctx.modelRegistry.getAvailable().length > 0) {
|
|
686
695
|
openModelPicker();
|
|
687
696
|
} else {
|
|
688
|
-
ctx.ui
|
|
697
|
+
notifyPi(ctx.ui, "No models are available in the model registry.", "warning");
|
|
689
698
|
}
|
|
690
699
|
return;
|
|
691
700
|
}
|
|
@@ -753,8 +762,8 @@ export function openTeamConsole(ctx: {
|
|
|
753
762
|
|
|
754
763
|
async function shutdownFromConsole(ctx: { ui: ExtensionUIContext }, name: string): Promise<void> {
|
|
755
764
|
const result = await shutdownTeammate(name);
|
|
756
|
-
if (result.ok) ctx.ui
|
|
757
|
-
else ctx.ui
|
|
765
|
+
if (result.ok) notifyPi(ctx.ui, result.body, "info");
|
|
766
|
+
else notifyPi(ctx.ui, result.error, "error");
|
|
758
767
|
}
|
|
759
768
|
|
|
760
769
|
function clampIndex(index: number, rowCount: number): number {
|
package/src/worker.ts
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
|
|
7
7
|
import { randomUUID } from "node:crypto";
|
|
8
8
|
import * as path from "node:path";
|
|
9
|
-
import type
|
|
9
|
+
import { type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { detailField, eventToolLifecycle } from "@fradser/pi-kit";
|
|
11
|
+
import { emptyToolCall, renderLifecycleResult } from "./tool-render.ts";
|
|
10
12
|
import { livingTeammates, listTasks } from "./state.ts";
|
|
11
13
|
import { appendInboxMessage, appendWorkerEvent, createTaskIntent, readBoardFile, readRoster } from "./statefile.ts";
|
|
12
14
|
import {
|
|
@@ -19,6 +21,46 @@ import {
|
|
|
19
21
|
type BoardTask,
|
|
20
22
|
} from "./types.ts";
|
|
21
23
|
|
|
24
|
+
const BOARD_DISCLOSURE_TOOLS = ["task_list", "task_claim", "task_submit"] as const;
|
|
25
|
+
type BoardDisclosure = "none" | "notice" | "claimed";
|
|
26
|
+
|
|
27
|
+
export interface WorkerToolDisclosure {
|
|
28
|
+
update(prompt: string): void;
|
|
29
|
+
reset(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
33
|
+
let state: BoardDisclosure = "none";
|
|
34
|
+
const apply = () => {
|
|
35
|
+
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
36
|
+
const active = pi.getActiveTools();
|
|
37
|
+
const withoutBoardControls = active.filter((tool) => !BOARD_DISCLOSURE_TOOLS.includes(tool as typeof BOARD_DISCLOSURE_TOOLS[number]));
|
|
38
|
+
const revealed = state === "notice"
|
|
39
|
+
? ["task_list", "task_claim"]
|
|
40
|
+
: state === "claimed"
|
|
41
|
+
? ["task_submit"]
|
|
42
|
+
: [];
|
|
43
|
+
pi.setActiveTools([...withoutBoardControls, ...revealed]);
|
|
44
|
+
};
|
|
45
|
+
return {
|
|
46
|
+
update(prompt) {
|
|
47
|
+
const binding = workerBinding();
|
|
48
|
+
const rosterEntry = binding
|
|
49
|
+
? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
|
|
50
|
+
: undefined;
|
|
51
|
+
const assignment = rosterEntry?.assignment;
|
|
52
|
+
if (assignment?.kind === "board" && !assignment.closed) state = "claimed";
|
|
53
|
+
else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
|
|
54
|
+
else state = "none";
|
|
55
|
+
apply();
|
|
56
|
+
},
|
|
57
|
+
reset() {
|
|
58
|
+
state = "none";
|
|
59
|
+
apply();
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
22
64
|
export interface WorkerBinding {
|
|
23
65
|
worker: string;
|
|
24
66
|
spawnId: string;
|
|
@@ -63,18 +105,54 @@ function dependenciesMet(task: BoardTask, tasks: Map<string, BoardTask>): boolea
|
|
|
63
105
|
return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
|
|
64
106
|
}
|
|
65
107
|
|
|
108
|
+
function taskCounts(tasks: BoardTask[], byId: Map<string, BoardTask>): string {
|
|
109
|
+
const counts = { pending: 0, claimed: 0, completed: 0, superseded: 0 };
|
|
110
|
+
for (const task of tasks) counts[task.status] += 1;
|
|
111
|
+
const claimable = tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId)).length;
|
|
112
|
+
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · claimed=${counts.claimed} · completed=${counts.completed} · superseded=${counts.superseded}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function resourcesConflict(left: readonly string[] | undefined, right: readonly string[] | undefined): boolean {
|
|
116
|
+
return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
|
|
120
|
+
const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
|
|
121
|
+
if (self?.assignment) {
|
|
122
|
+
const state = self.assignment.closed ? "closed pending leader reopen" : "active";
|
|
123
|
+
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming board work.`;
|
|
124
|
+
}
|
|
125
|
+
const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
|
|
126
|
+
&& entry.assignment && !entry.assignment.closed
|
|
127
|
+
&& resourcesConflict(task.resources, entry.assignment.resources));
|
|
128
|
+
if (conflict) {
|
|
129
|
+
return `Task "${task.id}" conflicts with @${conflict.name}'s ${conflict.assignment?.kind} assignment "${conflict.assignment?.id}".`;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function taskStatusLabel(task: BoardTask, tasks: Map<string, BoardTask>): string {
|
|
135
|
+
if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
|
|
136
|
+
return task.status;
|
|
137
|
+
}
|
|
138
|
+
|
|
66
139
|
export function renderTaskBoard(tasks: BoardTask[]): string {
|
|
67
|
-
if (tasks.length === 0) return "(the task board is empty)";
|
|
68
140
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
69
|
-
|
|
141
|
+
const lines = [`BOARD · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
|
|
142
|
+
if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
|
|
143
|
+
lines.push("TASKS");
|
|
144
|
+
for (const task of tasks) {
|
|
70
145
|
const deps = task.dependsOn.length > 0
|
|
71
|
-
? `
|
|
146
|
+
? ` · depends=${task.dependsOn.join(",")} (${dependenciesMet(task, byId) ? "met" : "blocked"})`
|
|
72
147
|
: "";
|
|
73
|
-
const holder = task.claimedBy ? `
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
148
|
+
const holder = task.claimedBy ? ` · claimant=@${task.claimedBy}` : "";
|
|
149
|
+
lines.push(`- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}${holder}${deps}`);
|
|
150
|
+
}
|
|
151
|
+
return lines.join("\n");
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function renderRoster(roster: string): string {
|
|
155
|
+
return `ROSTER\n${roster || "(none)"}`;
|
|
78
156
|
}
|
|
79
157
|
|
|
80
158
|
/** One registration shared by leader and worker; binding chooses the data source. */
|
|
@@ -83,8 +161,18 @@ export function registerTaskListTool(pi: ExtensionAPI): void {
|
|
|
83
161
|
name: "task_list",
|
|
84
162
|
promptSnippet: "Read the shared task board",
|
|
85
163
|
label: "Task Board",
|
|
86
|
-
description: "Read board
|
|
164
|
+
description: "Read the current session board as grouped task state plus a compact roster. Pending tasks with met dependencies are claimable.",
|
|
87
165
|
parameters: TaskListParams,
|
|
166
|
+
renderShell: "self",
|
|
167
|
+
renderCall: emptyToolCall,
|
|
168
|
+
renderResult(result, options, theme, context) {
|
|
169
|
+
const count = detailField<number>(result.details, "count") ?? 0;
|
|
170
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
171
|
+
"board",
|
|
172
|
+
`${count} task${count === 1 ? "" : "s"}`,
|
|
173
|
+
{ label: "listed" },
|
|
174
|
+
));
|
|
175
|
+
},
|
|
88
176
|
async execute() {
|
|
89
177
|
const binding = workerBinding();
|
|
90
178
|
const tasks = binding ? loadBoardTasks(binding) : listTasks();
|
|
@@ -92,21 +180,41 @@ export function registerTaskListTool(pi: ExtensionAPI): void {
|
|
|
92
180
|
const roster = binding
|
|
93
181
|
? readRoster(binding.rosterFile)
|
|
94
182
|
.filter((entry) => entry.status !== "stopped")
|
|
95
|
-
.map((entry) => `@${entry.name} (${entry.agent}, ${entry.status})`)
|
|
183
|
+
.map((entry) => `@${entry.name} (${entry.agent}, ${entry.status}${entry.assignment ? `, ${entry.assignment.kind} ${entry.assignment.id}` : ""})`)
|
|
96
184
|
.join("\n")
|
|
97
185
|
: livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
|
|
98
|
-
|
|
186
|
+
const leaderHint = !binding && !roster && tasks.some((task) => task.status === "pending")
|
|
187
|
+
? "NEXT · leader: teammate_spawn; workers then use task_claim"
|
|
188
|
+
: tasks.some((task) => task.status === "pending")
|
|
189
|
+
? "NEXT · workers use task_claim for pending/claimable tasks"
|
|
190
|
+
: "NEXT · no claimable work";
|
|
191
|
+
return {
|
|
192
|
+
content: [{ type: "text", text: `${renderTaskBoard(tasks)}\n${leaderHint}\n\n${renderRoster(roster)}` }],
|
|
193
|
+
details: { count: tasks.length },
|
|
194
|
+
};
|
|
99
195
|
},
|
|
100
196
|
});
|
|
101
197
|
}
|
|
102
198
|
|
|
103
|
-
export function registerWorkerCapabilities(pi: ExtensionAPI):
|
|
199
|
+
export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
200
|
+
const disclosure = createWorkerToolDisclosure(pi);
|
|
104
201
|
pi.registerTool({
|
|
105
202
|
name: "send_message",
|
|
106
203
|
promptSnippet: "Send a message to the leader or a teammate",
|
|
107
204
|
label: "Send Message",
|
|
108
205
|
description: "The only messaging primitive. Use to=\"leader\" for reports; use a teammate name for direct peer mail. status is valid only for leader reports.",
|
|
109
206
|
parameters: SendMessageParams,
|
|
207
|
+
renderShell: "self",
|
|
208
|
+
renderCall: emptyToolCall,
|
|
209
|
+
renderResult(result, options, theme, context) {
|
|
210
|
+
const to = String((context.args as { to?: string }).to ?? "");
|
|
211
|
+
|
|
212
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
213
|
+
"message",
|
|
214
|
+
detailField<"steered" | "queued">(result.details, "outcome") ?? "queued",
|
|
215
|
+
{ label: `to @${to}` },
|
|
216
|
+
));
|
|
217
|
+
},
|
|
110
218
|
async execute(_toolCallId, params) {
|
|
111
219
|
const binding = workerBinding();
|
|
112
220
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
@@ -118,10 +226,15 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
118
226
|
spawnId: binding.spawnId,
|
|
119
227
|
body: params.message,
|
|
120
228
|
status: params.status,
|
|
229
|
+
timestamp: Date.now(),
|
|
121
230
|
});
|
|
122
|
-
return {
|
|
123
|
-
|
|
124
|
-
|
|
231
|
+
return {
|
|
232
|
+
content: [{ type: "text", text: params.status
|
|
233
|
+
? `MESSAGING\nREPORT · to=leader · status=${params.status}\nNEXT · harness will deliver this report`
|
|
234
|
+
: 'MESSAGING\nREPORT · to=leader · status=in_progress\nNEXT · send status="completed" or status="failed" to end the assignment' }],
|
|
235
|
+
details: { to: LEADER_RECIPIENT, status: params.status ?? "in_progress", outcome: "queued" },
|
|
236
|
+
terminate: params.status === "completed" || params.status === "failed",
|
|
237
|
+
};
|
|
125
238
|
}
|
|
126
239
|
if (params.status) throw new Error('status is valid only when to="leader".');
|
|
127
240
|
if (params.to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
|
|
@@ -135,7 +248,10 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
135
248
|
subject: messageTitle(params.message),
|
|
136
249
|
body: params.message,
|
|
137
250
|
});
|
|
138
|
-
return {
|
|
251
|
+
return {
|
|
252
|
+
content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${params.to}\nNEXT · harness will route the inbox message into a recipient turn` }],
|
|
253
|
+
details: { to: params.to, outcome: "queued" },
|
|
254
|
+
};
|
|
139
255
|
},
|
|
140
256
|
});
|
|
141
257
|
|
|
@@ -143,6 +259,16 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
143
259
|
|
|
144
260
|
pi.registerTool({
|
|
145
261
|
name: "task_claim",
|
|
262
|
+
renderShell: "self",
|
|
263
|
+
renderCall: emptyToolCall,
|
|
264
|
+
renderResult(result, options, theme, context) {
|
|
265
|
+
const taskId = String((context.args as { taskId?: string }).taskId ?? "first claimable");
|
|
266
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
267
|
+
"board",
|
|
268
|
+
taskId,
|
|
269
|
+
{ label: "claim queued" },
|
|
270
|
+
));
|
|
271
|
+
},
|
|
146
272
|
promptSnippet: "Self-claim a pending board task",
|
|
147
273
|
label: "Claim Task",
|
|
148
274
|
description: "Atomically claim a pending task whose dependencies are met. Omit taskId to claim the first claimable task.",
|
|
@@ -160,6 +286,11 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
160
286
|
}
|
|
161
287
|
for (const task of candidates) {
|
|
162
288
|
if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
|
|
289
|
+
const rejected = claimRejection(binding, task);
|
|
290
|
+
if (rejected) {
|
|
291
|
+
if (params.taskId) throw new Error(rejected);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
163
294
|
const won = createTaskIntent(binding.claimsDir, task.id, {
|
|
164
295
|
taskId: task.id,
|
|
165
296
|
worker: binding.worker,
|
|
@@ -167,7 +298,10 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
167
298
|
timestamp: Date.now(),
|
|
168
299
|
});
|
|
169
300
|
if (won) {
|
|
170
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
content: [{ type: "text", text: `BOARD · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness Claim accepted feedback; do not start work until it arrives` }],
|
|
303
|
+
details: { taskId: task.id, subject: task.subject, worker: binding.worker },
|
|
304
|
+
};
|
|
171
305
|
}
|
|
172
306
|
if (params.taskId) throw new Error(`Task "${task.id}" was claimed by someone else first.`);
|
|
173
307
|
}
|
|
@@ -177,6 +311,17 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
177
311
|
|
|
178
312
|
pi.registerTool({
|
|
179
313
|
name: "task_submit",
|
|
314
|
+
renderShell: "self",
|
|
315
|
+
renderCall: emptyToolCall,
|
|
316
|
+
renderResult(result, options, theme, context) {
|
|
317
|
+
const taskId = String((context.args as { taskId?: string }).taskId ?? "task");
|
|
318
|
+
const status = String((context.args as { status?: string }).status ?? "submitted");
|
|
319
|
+
return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
|
|
320
|
+
"board",
|
|
321
|
+
taskId,
|
|
322
|
+
{ label: status },
|
|
323
|
+
));
|
|
324
|
+
},
|
|
180
325
|
promptSnippet: "Submit a claimed task outcome",
|
|
181
326
|
label: "Submit Task",
|
|
182
327
|
description: "Submit a task you claimed. completed runs its verify gate; failed releases the task back to the board.",
|
|
@@ -193,7 +338,25 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): void {
|
|
|
193
338
|
timestamp: Date.now(),
|
|
194
339
|
});
|
|
195
340
|
if (!won) throw new Error(`A submission for "${params.taskId}" is already pending.`);
|
|
196
|
-
|
|
341
|
+
disclosure.reset();
|
|
342
|
+
const task = loadBoardTasks(binding).find((candidate) => candidate.id === params.taskId);
|
|
343
|
+
const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
|
|
344
|
+
const verify = params.status === "completed"
|
|
345
|
+
? task?.verify
|
|
346
|
+
? "VERIFY · queued (task gate)"
|
|
347
|
+
: roleVerify
|
|
348
|
+
? "VERIFY · queued (role gate)"
|
|
349
|
+
: "VERIFY · none configured"
|
|
350
|
+
: "VERIFY · skipped (failed submission)";
|
|
351
|
+
const next = params.status === "completed"
|
|
352
|
+
? "NEXT · wait for the harness result"
|
|
353
|
+
: "NEXT · task returns to pending";
|
|
354
|
+
return {
|
|
355
|
+
content: [{ type: "text", text: `BOARD · current session\nSUBMITTED · ${params.taskId} · ${params.status}\n${verify}\n${next}` }],
|
|
356
|
+
details: { taskId: params.taskId, status: params.status, verify: Boolean(task?.verify || roleVerify) },
|
|
357
|
+
};
|
|
197
358
|
},
|
|
198
359
|
});
|
|
360
|
+
|
|
361
|
+
return disclosure;
|
|
199
362
|
}
|
package/src/worktree.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Layout: `<repoRoot>/.pi/worktrees/teammate-<taskId>/`, branch
|
|
6
6
|
* `teammate/<taskId>`. On completion the worker's diff is captured against
|
|
7
|
-
* the base commit, then the worktree
|
|
7
|
+
* the base commit, then the worktree directory is removed while the branch
|
|
8
|
+
* stays so captured work remains retrievable.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
@@ -104,15 +105,50 @@ export function captureWorktreeDiff(setup: WorktreeSetup): WorktreeDiffResult {
|
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/**
|
|
107
|
-
*
|
|
108
|
-
*
|
|
108
|
+
* Commit any remaining worktree changes onto the branch. Staging alone dies
|
|
109
|
+
* with the worktree directory; only a commit makes the kept branch carry the
|
|
110
|
+
* captured work. A clean tree succeeds without a commit.
|
|
109
111
|
*/
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
112
|
+
function commitRemainingWork(setup: WorktreeSetup): { ok: boolean; error?: string } {
|
|
113
|
+
const add = runGit(setup.path, ["add", "-A"]);
|
|
114
|
+
if (add.status !== 0) return { ok: false, error: add.stderr.trim() || "git add failed" };
|
|
115
|
+
const result = spawnSync("git", ["-C", setup.path, "commit", "-m", "agent-teams: capture teammate work"], { encoding: "utf-8" });
|
|
116
|
+
if (result.status === 0) return { ok: true };
|
|
117
|
+
const output = `${result.stderr ?? ""}${result.stdout ?? ""}`;
|
|
118
|
+
if (/nothing to commit/i.test(output)) return { ok: true };
|
|
119
|
+
return { ok: false, error: output.trim() || "git commit failed" };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Remove the worktree directory but keep the branch: remaining changes are
|
|
124
|
+
* committed first so the captured diff stays retrievable with
|
|
125
|
+
* `git diff <baseCommit>..<branch>` after cleanup. Pass `{ deleteBranch: true }`
|
|
126
|
+
* for teardowns where no work was captured (a failed spawn). When the commit
|
|
127
|
+
* fails in keep-branch mode the directory is PRESERVED and returned in the
|
|
128
|
+
* error — removing it would destroy the only copy of the work. Errors are
|
|
129
|
+
* returned rather than thrown so task completion is never blocked by cleanup
|
|
130
|
+
* failure.
|
|
131
|
+
*/
|
|
132
|
+
export function cleanupWorktree(
|
|
133
|
+
setup: WorktreeSetup,
|
|
134
|
+
options?: { deleteBranch?: boolean },
|
|
135
|
+
): { ok: boolean; error?: string } {
|
|
113
136
|
const errors: string[] = [];
|
|
137
|
+
if (!options?.deleteBranch) {
|
|
138
|
+
const commit = commitRemainingWork(setup);
|
|
139
|
+
if (!commit.ok) {
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
error: `commit: ${commit.error}; worktree left in place at ${setup.path} so the work is not lost`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const remove = runGit(setup.repoRoot, ["worktree", "remove", "--force", setup.path]);
|
|
114
147
|
if (remove.status !== 0) errors.push(`worktree remove: ${remove.stderr.trim()}`);
|
|
115
|
-
if (
|
|
148
|
+
if (options?.deleteBranch) {
|
|
149
|
+
const branch = runGit(setup.repoRoot, ["branch", "-D", setup.branch]);
|
|
150
|
+
if (branch.status !== 0) errors.push(`branch delete: ${branch.stderr.trim()}`);
|
|
151
|
+
}
|
|
116
152
|
try {
|
|
117
153
|
runGit(setup.repoRoot, ["worktree", "prune"]);
|
|
118
154
|
} catch {
|
|
@@ -121,7 +157,8 @@ export function cleanupWorktree(setup: WorktreeSetup): { ok: boolean; error?: st
|
|
|
121
157
|
return errors.length > 0 ? { ok: false, error: errors.join("; ") } : { ok: true };
|
|
122
158
|
}
|
|
123
159
|
|
|
124
|
-
/** Best-effort cleanup used when a spawn fails before the worker starts
|
|
160
|
+
/** Best-effort cleanup used when a spawn fails before the worker starts:
|
|
161
|
+
* nothing was captured, so the empty branch goes too. */
|
|
125
162
|
export function discardWorktree(setup: WorktreeSetup): void {
|
|
126
|
-
cleanupWorktree(setup);
|
|
163
|
+
cleanupWorktree(setup, { deleteBranch: true });
|
|
127
164
|
}
|