@fradser/pi-agent-teams 0.8.0 → 0.8.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/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 the stall notice threshold. */
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
- return silence !== undefined && STALL_NOTICE_MS > 0 && silence >= STALL_NOTICE_MS
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
- lines.push(` -> [${report.subject}] ${new Date(report.timestamp).toLocaleString()}`, ...indent(cap(report.body)), "");
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
- lines.push(` ${arrow} [${mail.subject}] ${new Date(mail.timestamp).toLocaleString()}`, ...indent(cap(mail.body)), "");
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
- border,
432
- style.accent(truncateToWidth(`teammate model ${teamDefault ?? "auto (Pi default)"}`, width)),
433
- "",
434
- truncateToWidth(`${style.accent("❯ ")}${picker.query()}▏ ${style.dim(`${results.length} models`)}`, Math.max(10, width - 1)),
435
- ...rows,
436
- "",
437
- truncateToWidth(style.dim("type to filter · ↑↓ select · enter set/clear · esc cancel"), Math.max(10, width - 1)),
438
- border,
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.notify("Teammate model cleared — Pi picks its default", "info");
473
+ notifyPi(ctx.ui, "Teammate model cleared — Pi picks its default", "info");
460
474
  } else {
461
475
  setTeamDefaultModel(modelLabel(picked));
462
- ctx.ui.notify(`Teammate model set to ${modelLabel(picked)} for this session`, "info");
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 === "claimed"
541
- ? theme.fg("warning", "◐ claimed")
542
- : style.dim("○ pending");
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
- border,
593
- style.accent(truncateToWidth(headerLine(), width)),
594
- "",
595
- ...content.slice(listOffset, listOffset + viewport)
596
- .map((line) => truncateToWidth(line.text, Math.max(10, width - 1))),
597
- "",
598
- truncateToWidth(footer, Math.max(10, width - 1)),
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
- const footer = style.dim(` ${detail.range} · ↑↓ scroll · pgup/pgdn page · home/end jump · esc back · q close`);
616
- const lines = [
617
- border,
618
- style.accent(truncateToWidth(`agent-teams ${detailTitle()}`, width)),
619
- "",
620
- ...detail.lines.map((line) => ` ${line}`),
621
- "",
622
- footer,
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.notify("No models are available in the model registry.", "warning");
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.notify(result.body, "info");
757
- else ctx.ui.notify(result.error, "error");
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 { ExtensionAPI } from "@earendil-works/pi-coding-agent";
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
- return tasks.map((task) => {
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
- ? ` | depends on ${task.dependsOn.join(", ")} (${dependenciesMet(task, byId) ? "met" : "unmet"})`
146
+ ? ` · depends=${task.dependsOn.join(",")} (${dependenciesMet(task, byId) ? "met" : "blocked"})`
72
147
  : "";
73
- const holder = task.claimedBy ? ` by @${task.claimedBy}` : "";
74
- const verify = task.verify ? ` | verify: ${task.verify}` : "";
75
- const description = task.description ? `\n ${task.description.slice(0, 2000)}` : "";
76
- return `- [${task.id}] ${task.status}${holder}: ${task.subject}${deps}${verify}${description}`;
77
- }).join("\n");
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 tasks with status, dependencies, and verify gates. Pending tasks with met dependencies are claimable.",
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
- return { content: [{ type: "text", text: `${renderTaskBoard(tasks)}\n\nRoster:\n${roster || "(none)"}` }], details: {} };
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): void {
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 { content: [{ type: "text", text: params.status
123
- ? "Queued message to leader."
124
- : 'Queued message to leader. Note: without status="completed" or status="failed" this report does not end your assignment.' }], details: {} };
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 { content: [{ type: "text", text: `Delivered to @${params.to}'s inbox.` }], details: {} };
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 { content: [{ type: "text", text: `You won [${task.id}] "${task.subject}". Submit with task_submit.` }], details: {} };
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
- return { content: [{ type: "text", text: `Submitted ${params.status} for [${params.taskId}].` }], details: {} };
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 and branch are removed.
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
- * Remove the worktree and its branch. Errors are returned rather than thrown
108
- * so task completion is never blocked by cleanup failure.
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
- export function cleanupWorktree(setup: WorktreeSetup): { ok: boolean; error?: string } {
111
- const remove = runGit(setup.repoRoot, ["worktree", "remove", "--force", setup.path]);
112
- const branch = runGit(setup.repoRoot, ["branch", "-D", setup.branch]);
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 (branch.status !== 0) errors.push(`branch delete: ${branch.stderr.trim()}`);
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
  }