@fradser/pi-agent-teams 0.7.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/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
  }