@fradser/pi-agent-teams 0.8.3 → 0.9.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/src/worker.ts CHANGED
@@ -1,27 +1,26 @@
1
1
  /**
2
- * Teammate capabilities. A single addressed send_message primitive covers
2
+ * Teammate capabilities. A single addressed agent_event primitive covers
3
3
  * leader reports and peer mail. Task-list registration is shared by leader
4
4
  * and worker processes; claiming and submitting remain worker-only.
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import * as fs from "node:fs";
8
9
  import * as path from "node:path";
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";
12
- import { livingTeammates, listTasks } from "./state.ts";
10
+ import { truncateTail, type ExtensionAPI, type MessageEndEvent } from "@earendil-works/pi-coding-agent";
11
+ import { messageRow, workerWorkRow } from "./tool-copy.ts";
12
+ import { emptyToolCall, renderCoordinationRow, textOf } from "./tool-render.ts";
13
+ import { resolveRecipient } from "./recipient.ts";
13
14
  import { appendInboxMessage, appendWorkerEvent, createTaskIntent, readBoardFile, readRoster } from "./statefile.ts";
14
15
  import {
16
+ AgentEventParams,
15
17
  LEADER_RECIPIENT,
16
18
  messageTitle,
17
- SendMessageParams,
18
- TaskClaimParams,
19
- TaskListParams,
20
- TaskSubmitParams,
19
+ WorkerWorkToolParams,
21
20
  type BoardTask,
22
21
  } from "./types.ts";
23
22
 
24
- const BOARD_DISCLOSURE_TOOLS = ["task_list", "task_claim", "task_submit"] as const;
23
+ const BOARD_DISCLOSURE_TOOLS = ["work"] as const;
25
24
  type BoardDisclosure = "none" | "notice" | "claimed";
26
25
 
27
26
  export interface WorkerToolDisclosure {
@@ -35,11 +34,7 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
35
34
  if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
36
35
  const active = pi.getActiveTools();
37
36
  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
- : [];
37
+ const revealed = state === "notice" || state === "claimed" ? ["work"] : [];
43
38
  pi.setActiveTools([...withoutBoardControls, ...revealed]);
44
39
  };
45
40
  return {
@@ -49,7 +44,7 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
49
44
  ? readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)
50
45
  : undefined;
51
46
  const assignment = rosterEntry?.assignment;
52
- if (assignment?.kind === "board" && !assignment.closed) state = "claimed";
47
+ if (assignment && !assignment.closed) state = "claimed";
53
48
  else if (!assignment && prompt.includes("=== BOARD NOTICE ===")) state = "notice";
54
49
  else state = "none";
55
50
  apply();
@@ -90,12 +85,6 @@ export function workerBinding(): WorkerBinding | undefined {
90
85
  return { worker, spawnId, outbox, inbox, rosterFile, boardFile, claimsDir, submissionsDir };
91
86
  }
92
87
 
93
- function livingRecipients(binding: WorkerBinding): Set<string> {
94
- return new Set(readRoster(binding.rosterFile)
95
- .filter((entry) => entry.status !== "stopped")
96
- .map((entry) => entry.name));
97
- }
98
-
99
88
  function loadBoardTasks(binding: WorkerBinding): BoardTask[] {
100
89
  const board = readBoardFile(binding.boardFile);
101
90
  return Object.values(board?.tasks ?? {}).sort((a, b) => a.id.localeCompare(b.id));
@@ -151,212 +140,290 @@ export function renderTaskBoard(tasks: BoardTask[]): string {
151
140
  return lines.join("\n");
152
141
  }
153
142
 
154
- function renderRoster(roster: string): string {
155
- return `ROSTER\n${roster || "(none)"}`;
143
+ type AssistantResponse = Extract<MessageEndEvent["message"], { role: "assistant" }>;
144
+ type AutomaticResult = { status: "completed" | "failed"; body: string };
145
+
146
+ function executionResult(message?: AssistantResponse): AutomaticResult {
147
+ if (message?.stopReason === "error" || message?.stopReason === "aborted") {
148
+ return { status: "failed", body: message.errorMessage?.trim() || `Execution ${message.stopReason} without a final answer.` };
149
+ }
150
+ if (message?.stopReason === "length") return { status: "failed", body: "Execution reached the response length limit without a complete final answer." };
151
+ if (message && message.stopReason !== "stop" && message.stopReason !== "toolUse") {
152
+ return { status: "failed", body: `Execution settled with an incomplete response (${message.stopReason}) and no final answer.` };
153
+ }
154
+ const body = message?.content.filter((part) => part.type === "text").map((part) => part.text).join("\n") ?? "";
155
+ if (message?.stopReason === "stop" && !message.content.some((part) => part.type === "toolCall") && body.trim()) {
156
+ return { status: "completed", body };
157
+ }
158
+ return { status: "failed", body: "Execution settled without a final answer." };
156
159
  }
157
160
 
158
- /** One registration shared by leader and worker; binding chooses the data source. */
159
- export function registerTaskListTool(pi: ExtensionAPI): void {
160
- pi.registerTool({
161
- name: "task_list",
162
- promptSnippet: "Read the shared task board",
163
- label: "Task Board",
164
- description: "Read the current session board as grouped task state plus a compact roster. Pending tasks with met dependencies are claimable.",
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
- },
176
- async execute() {
177
- const binding = workerBinding();
178
- const tasks = binding ? loadBoardTasks(binding) : listTasks();
179
- // Both sides get a roster tail: peer discovery is zero-cost for workers.
180
- const roster = binding
181
- ? readRoster(binding.rosterFile)
182
- .filter((entry) => entry.status !== "stopped")
183
- .map((entry) => `@${entry.name} (${entry.agent}, ${entry.status}${entry.assignment ? `, ${entry.assignment.kind} ${entry.assignment.id}` : ""})`)
184
- .join("\n")
185
- : livingTeammates().map((t) => `@${t.name} (${t.agent}, ${t.status}${t.currentTaskId ? `, task ${t.currentTaskId}` : ""})`).join("\n");
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
- };
195
- },
161
+ function automaticResultBody(binding: WorkerBinding, assignmentId: string, body: string): string {
162
+ if (Buffer.byteLength(JSON.stringify(body), "utf-8") <= 48 * 1024) return body;
163
+ const key = createHash("sha256").update(binding.spawnId).update("\0").update(assignmentId).digest("hex");
164
+ const file = path.join(path.dirname(binding.outbox), `${key}.result.txt`);
165
+ const temporary = `${file}.${process.pid}.tmp`;
166
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
167
+ try {
168
+ fs.writeFileSync(temporary, body, { encoding: "utf-8", mode: 0o600 });
169
+ fs.renameSync(temporary, file);
170
+ } finally {
171
+ fs.rmSync(temporary, { force: true });
172
+ }
173
+ const preview = truncateTail(body, { maxBytes: 8 * 1024, maxLines: 200 });
174
+ return `${preview.content}\n\n[Result truncated; ${preview.totalBytes} bytes total]\nFull result: ${file}`;
175
+ }
176
+
177
+ function createWorkerReports(pi: ExtensionAPI) {
178
+ const initial = workerBinding();
179
+ const initialAssignment = initial ? readRoster(initial.rosterFile).find((entry) => entry.name === initial.worker)?.assignment : undefined;
180
+ let assignmentId = initialAssignment?.id;
181
+ let reportClosed = initialAssignment?.closed === true;
182
+ let pending: { binding: WorkerBinding; assignmentId: string; timestamp: number; awaitingFinal?: boolean; outcome?: AutomaticResult } | undefined;
183
+ const finalizedMessages = new WeakSet<AssistantResponse>();
184
+ const currentAssignment = (binding: WorkerBinding) => {
185
+ const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
186
+ const assignment = self?.assignment;
187
+ return !reportClosed && self?.spawnId === binding.spawnId && self.status !== "stopped"
188
+ && assignment && assignment.id === assignmentId && !assignment.closed ? assignment : undefined;
189
+ };
190
+ const close = () => {
191
+ reportClosed = true;
192
+ pending = undefined;
193
+ };
194
+ const reportAssignment = (binding: WorkerBinding) => {
195
+ const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
196
+ if (current?.kind === "board" && current.id !== assignmentId) {
197
+ assignmentId = current.id;
198
+ reportClosed = false;
199
+ pending = undefined;
200
+ }
201
+ if (reportClosed) throw new Error("Leader report rejected: this assignment already has a terminal report. Wait for an explicit leader assignment.");
202
+ return assignmentId;
203
+ };
204
+ pi.on("message_start", ({ message }) => {
205
+ const binding = workerBinding();
206
+ if (!binding) return;
207
+ if (message.role === "user") {
208
+ const content = message.content;
209
+ const text = typeof content === "string" ? content : content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
210
+ const marker = /^\[agent-teams-assignment:([^\n]+)\]\n/.exec(text);
211
+ const current = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment;
212
+ if (marker) {
213
+ const id = marker[1] === "none" ? undefined : marker[1];
214
+ if (id !== current?.id) return;
215
+ if (id !== assignmentId || current?.kind === "board") reportClosed = current?.closed === true;
216
+ assignmentId = id;
217
+ pending = undefined;
218
+ }
219
+ const assignment = currentAssignment(binding);
220
+ if (assignment) pending = { binding, assignmentId: assignment.id, timestamp: message.timestamp };
221
+ } else if (message.role === "assistant" && pending && message.timestamp >= pending.timestamp) {
222
+ pending.timestamp = message.timestamp;
223
+ pending.awaitingFinal = true;
224
+ pending.outcome = undefined;
225
+ }
226
+ });
227
+ pi.on("agent_start", () => {
228
+ if (pending) {
229
+ pending.outcome = undefined;
230
+ pending.awaitingFinal = false;
231
+ }
232
+ });
233
+ pi.on("message_end", ({ message }) => {
234
+ if (message.role !== "assistant" || !pending?.awaitingFinal || message.timestamp < pending.timestamp
235
+ || finalizedMessages.has(message) || !currentAssignment(pending.binding)) return;
236
+ finalizedMessages.add(message);
237
+ pending.timestamp = message.timestamp;
238
+ pending.awaitingFinal = false;
239
+ pending.outcome = executionResult(message);
240
+ });
241
+ pi.on("agent_settled", (_event, ctx) => {
242
+ if (!pending || !ctx.isIdle()) return;
243
+ const binding = workerBinding();
244
+ if (!binding || binding.worker !== pending.binding.worker || binding.spawnId !== pending.binding.spawnId
245
+ || binding.outbox !== pending.binding.outbox || pending.assignmentId !== assignmentId || !currentAssignment(binding)) return;
246
+ const result = pending.outcome ?? executionResult();
247
+ appendWorkerEvent(binding.outbox, {
248
+ id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
249
+ assignmentId: pending.assignmentId, timestamp: Date.now(), status: result.status,
250
+ body: automaticResultBody(binding, pending.assignmentId, result.body),
251
+ });
252
+ close();
196
253
  });
254
+ return {
255
+ close,
256
+ send(binding: WorkerBinding, body: string, status: import("./types.ts").WorkerReportEvent["status"]) {
257
+ appendWorkerEvent(binding.outbox, {
258
+ assignmentId: reportAssignment(binding), id: randomUUID(), type: "message",
259
+ worker: binding.worker, spawnId: binding.spawnId, body, status, timestamp: Date.now(),
260
+ });
261
+ const isTerminal = status === "completed" || status === "failed";
262
+ if (isTerminal) close();
263
+ return isTerminal;
264
+ },
265
+ reset() { pending = undefined; },
266
+ };
197
267
  }
198
268
 
199
269
  export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
200
270
  const disclosure = createWorkerToolDisclosure(pi);
271
+ const reports = createWorkerReports(pi);
201
272
  pi.registerTool({
202
- name: "send_message",
203
- promptSnippet: "Send a message to the leader or a teammate",
204
- label: "Send Message",
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.",
206
- parameters: SendMessageParams,
273
+ name: "agent_event",
274
+ promptSnippet: "Send an event or message to a participant",
275
+ label: "Agent Event",
276
+ description: "Communication-only interface across Leader, Worker, and Peers. Work completion is reported automatically after settlement or uses work action=submit.",
277
+ parameters: AgentEventParams,
207
278
  renderShell: "self",
208
279
  renderCall: emptyToolCall,
209
280
  renderResult(result, options, theme, context) {
210
- const to = String((context.args as { to?: string }).to ?? "");
211
-
212
- return renderLifecycleResult(result, options, theme, context, eventToolLifecycle(
281
+ return renderCoordinationRow(
282
+ result, options, theme, context,
213
283
  "message",
214
- detailField<"steered" | "queued">(result.details, "outcome") ?? "queued",
215
- { label: `to @${to}` },
216
- ));
284
+ messageRow(context.args as { to?: string; message?: string }, result.details, { isError: context.isError }),
285
+ );
217
286
  },
218
287
  async execute(_toolCallId, params) {
219
288
  const binding = workerBinding();
220
289
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
221
- if (params.to === LEADER_RECIPIENT) {
290
+ if (!params.to || params.to === LEADER_RECIPIENT) {
222
291
  appendWorkerEvent(binding.outbox, {
223
- id: randomUUID(),
224
- type: "message",
225
- worker: binding.worker,
226
- spawnId: binding.spawnId,
227
- body: params.message,
228
- status: params.status,
229
- timestamp: Date.now(),
292
+ assignmentId: readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
293
+ body: params.message, status: params.intent ?? "inform", timestamp: Date.now(),
230
294
  });
231
295
  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",
296
+ content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader · intent=${params.intent ?? "inform"}\nNEXT · harness will deliver this message; Work completion is automatic or uses work submit` }],
297
+ details: { to: LEADER_RECIPIENT, intent: params.intent ?? "inform", outcome: "queued" },
237
298
  };
238
299
  }
239
- if (params.status) throw new Error('status is valid only when to="leader".');
240
- if (params.to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
241
- if (!livingRecipients(binding).has(params.to)) {
242
- throw new Error(`No living teammate named "${params.to}". Check the roster or ask the leader.`);
243
- }
244
- const recipientInbox = path.join(path.dirname(binding.inbox), `inbox-${encodeURIComponent(params.to)}.jsonl`);
300
+ const recipient = resolveRecipient(params.to, readRoster(binding.rosterFile));
301
+ const to = recipient.name;
302
+ if (to === binding.worker) throw new Error("You are already the recipient — no need to message yourself.");
303
+ const recipientInbox = path.join(path.dirname(binding.inbox), `inbox-${encodeURIComponent(to)}.jsonl`);
245
304
  appendInboxMessage(recipientInbox, {
246
305
  id: randomUUID(),
247
306
  from: binding.worker,
248
307
  subject: messageTitle(params.message),
249
308
  body: params.message,
309
+ ...(recipient.spawnId ? { toSpawnId: recipient.spawnId } : {}),
250
310
  });
251
311
  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" },
312
+ content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${to}\nNEXT · harness will route the inbox message into a recipient turn` }],
313
+ details: { to, outcome: "queued", intent: params.intent ?? "inform" },
254
314
  };
255
315
  },
256
316
  });
257
317
 
258
- registerTaskListTool(pi);
318
+ async function queueWorkClaim(id: string | undefined, presentation: "board" | "work") {
319
+ const binding = workerBinding();
320
+ if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
321
+ const label = presentation === "work" ? "Work Item" : "Task";
322
+ const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
323
+ const race = presentation === "work" ? "All candidate Work Items were claimed in the race. Check Work again." : "All candidate tasks were claimed in the race. Check the board again.";
324
+ const tasks = loadBoardTasks(binding);
325
+ const byId = new Map(tasks.map((task) => [task.id, task]));
326
+ const candidates = id
327
+ ? tasks.filter((task) => task.id === id)
328
+ : tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
329
+ if (candidates.length === 0) {
330
+ throw new Error(id ? `${label} "${id}" was not found${presentation === "board" ? " on the board" : ""}.` : empty);
331
+ }
332
+ for (const task of candidates) {
333
+ if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
334
+ const rejected = claimRejection(binding, task);
335
+ if (rejected) {
336
+ if (id) throw new Error(rejected);
337
+ continue;
338
+ }
339
+ const won = createTaskIntent(binding.claimsDir, task.id, {
340
+ taskId: task.id, worker: binding.worker, spawnId: binding.spawnId, timestamp: Date.now(),
341
+ });
342
+ if (won) {
343
+ const content = presentation === "work"
344
+ ? `WORK · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness claim acceptance before starting Work`
345
+ : `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`;
346
+ const details = presentation === "work"
347
+ ? { action: "claim", outcome: "queued", id: task.id, subject: task.subject, worker: binding.worker }
348
+ : { taskId: task.id, subject: task.subject, worker: binding.worker };
349
+ return { content: [{ type: "text" as const, text: content }], details };
350
+ }
351
+ if (id) throw new Error(`${label} "${task.id}" was claimed by someone else first.`);
352
+ }
353
+ throw new Error(race);
354
+ }
259
355
 
260
356
  pi.registerTool({
261
- name: "task_claim",
357
+ name: "work",
262
358
  renderShell: "self",
263
359
  renderCall: emptyToolCall,
264
360
  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
- ));
361
+ const params = context.args as { action?: string; id?: string };
362
+ return renderCoordinationRow(
363
+ result, options, theme, context,
364
+ "work",
365
+ workerWorkRow(params, result.details, textOf(result), { isError: context.isError }),
366
+ );
271
367
  },
272
- promptSnippet: "Self-claim a pending board task",
273
- label: "Claim Task",
274
- description: "Atomically claim a pending task whose dependencies are met. Omit taskId to claim the first claimable task.",
275
- parameters: TaskClaimParams,
276
- async execute(_toolCallId, params) {
368
+ promptSnippet: "Claim pending Work or submit owned Work",
369
+ label: "Work",
370
+ description: "Queue a claim for pending Work or submit the current owned Work Item. The harness remains the authority for claim acceptance and completion.",
371
+ parameters: WorkerWorkToolParams,
372
+ execute(_toolCallId, params) {
277
373
  const binding = workerBinding();
278
374
  if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
279
- const tasks = loadBoardTasks(binding);
280
- const byId = new Map(tasks.map((task) => [task.id, task]));
281
- const candidates = params.taskId
282
- ? tasks.filter((task) => task.id === params.taskId)
283
- : tasks.filter((task) => task.status === "pending" && dependenciesMet(task, byId));
284
- if (candidates.length === 0) {
285
- throw new Error(params.taskId ? `Task "${params.taskId}" was not found on the board.` : "No claimable task right now.");
375
+ if (params.action === "list") {
376
+ const tasks = loadBoardTasks(binding);
377
+ const lines = tasks.map((task) => `- ${task.id} · ${task.status} · ${task.subject}`);
378
+ return Promise.resolve({ content: [{ type: "text", text: `WORK · current session\nWORK ITEMS\n${lines.join("\n") || "(none)"}` }], details: { action: "list", outcome: "listed", count: tasks.length } });
286
379
  }
287
- for (const task of candidates) {
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
- }
294
- const won = createTaskIntent(binding.claimsDir, task.id, {
295
- taskId: task.id,
296
- worker: binding.worker,
297
- spawnId: binding.spawnId,
298
- timestamp: Date.now(),
299
- });
300
- if (won) {
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
- };
305
- }
306
- if (params.taskId) throw new Error(`Task "${task.id}" was claimed by someone else first.`);
380
+ if (params.action === "claim") return queueWorkClaim(params.id, "work");
381
+ const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
382
+ if (!self?.assignment || !self.currentTaskId) throw new Error("You do not own a claimed Work Item.");
383
+ if (params.action === "release") {
384
+ return queueWorkSubmission({ taskId: self.currentTaskId, status: "failed", result: params.result, presentation: "work" });
307
385
  }
308
- throw new Error("All candidate tasks were claimed in the race. Check the board again.");
386
+ return queueWorkSubmission({ taskId: self.currentTaskId, status: params.outcome === "success" ? "completed" : "failed", result: params.result, presentation: "work" });
309
387
  },
310
388
  });
311
389
 
312
- pi.registerTool({
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
- },
325
- promptSnippet: "Submit a claimed task outcome",
326
- label: "Submit Task",
327
- description: "Submit a task you claimed. completed runs its verify gate; failed releases the task back to the board.",
328
- parameters: TaskSubmitParams,
329
- async execute(_toolCallId, params) {
330
- const binding = workerBinding();
331
- if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
332
- const won = createTaskIntent(binding.submissionsDir, params.taskId, {
333
- taskId: params.taskId,
334
- worker: binding.worker,
335
- spawnId: binding.spawnId,
336
- status: params.status,
337
- result: params.result,
338
- timestamp: Date.now(),
339
- });
340
- if (!won) throw new Error(`A submission for "${params.taskId}" is already pending.`);
390
+ async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "board" | "work" }) {
391
+ const binding = workerBinding();
392
+ if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
393
+ const assignmentId = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker)?.assignment?.id;
394
+ const won = createTaskIntent(binding.submissionsDir, input.taskId, {
395
+ taskId: input.taskId, worker: binding.worker, spawnId: binding.spawnId, assignmentId,
396
+ status: input.status, result: input.result, timestamp: Date.now(),
397
+ });
398
+ if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
399
+ reports.close();
400
+ disclosure.reset();
401
+ const task = loadBoardTasks(binding).find((candidate) => candidate.id === input.taskId);
402
+ const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
403
+ const verify = input.status === "completed"
404
+ ? task?.verify
405
+ ? "VERIFY · queued (task gate)"
406
+ : roleVerify
407
+ ? "VERIFY · queued (role gate)"
408
+ : "VERIFY · none configured"
409
+ : "VERIFY · skipped (failed submission)";
410
+ const next = input.status === "completed" ? "NEXT · wait for the harness result" : input.presentation === "work" ? "NEXT · Work returns to pending" : "NEXT · task returns to pending";
411
+ const content = input.presentation === "work"
412
+ ? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
413
+ : `BOARD · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;
414
+ // The subject rides along in details: a worker process never hydrates the
415
+ // leader's in-memory board, so its row cannot look the task up by itself.
416
+ const details = input.presentation === "work"
417
+ ? { action: "submit", outcome: "queued", id: input.taskId, subject: task?.subject, status: input.status === "completed" ? "success" : "failed", verify: Boolean(task?.verify || roleVerify) }
418
+ : { taskId: input.taskId, subject: task?.subject, status: input.status, verify: Boolean(task?.verify || roleVerify) };
419
+ return { content: [{ type: "text" as const, text: content }], details, terminate: true };
420
+ }
421
+
422
+ return {
423
+ update: disclosure.update,
424
+ reset() {
341
425
  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
- };
426
+ reports.reset();
358
427
  },
359
- });
360
-
361
- return disclosure;
428
+ };
362
429
  }