@fradser/pi-agent-teams 0.10.0 → 1.0.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/README.md +132 -64
- package/package.json +13 -5
- package/references/agent-roles.md +23 -13
- package/src/activity.ts +20 -11
- package/src/agent-actions.ts +61 -36
- package/src/agent-host.ts +64 -0
- package/src/capability-tools.ts +34 -0
- package/src/guidance.ts +44 -38
- package/src/index.ts +70 -36
- package/src/recipient.ts +4 -4
- package/src/state.ts +163 -618
- package/src/statefile.ts +28 -244
- package/src/team-machine.ts +825 -534
- package/src/tool-copy.ts +26 -34
- package/src/tools.ts +57 -210
- package/src/types.ts +51 -159
- package/src/ui.ts +85 -59
- package/src/worker.ts +88 -66
- package/src/agents.ts +0 -296
- package/src/leader-reports.ts +0 -92
- package/src/spawner.ts +0 -751
- package/src/work-context.ts +0 -88
- package/src/worker-tools.ts +0 -4
- package/src/worktree.ts +0 -164
package/src/worker.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { isTaskReplaced } from "@fradser/pi-tasks";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
+
* Agent capabilities. A single addressed agent_event primitive covers
|
|
3
4
|
* leader reports and peer mail. Task-list registration is shared by leader
|
|
4
5
|
* and worker processes; claiming and submitting remain worker-only.
|
|
5
6
|
*/
|
|
@@ -8,34 +9,41 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
8
9
|
import * as fs from "node:fs";
|
|
9
10
|
import * as path from "node:path";
|
|
10
11
|
import { truncateTail, type ExtensionAPI, type MessageEndEvent } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
import { setOwnedTools } from "@fradser/pi-kit";
|
|
13
|
+
import { isTaskStatus, resourcesConflict, type TaskStatus } from "@fradser/pi-tasks";
|
|
11
14
|
import { messageRow, workerWorkRow } from "./tool-copy.ts";
|
|
12
15
|
import { emptyToolCall, renderCoordinationRow, textOf } from "./tool-render.ts";
|
|
13
16
|
import { resolveRecipient } from "./recipient.ts";
|
|
14
|
-
import { appendInboxMessage, appendWorkerEvent,
|
|
17
|
+
import { appendInboxMessage, appendWorkerEvent, readRoster } from "./statefile.ts";
|
|
18
|
+
import { createTaskIntent, readTasksFile } from "@fradser/pi-tasks";
|
|
15
19
|
import {
|
|
16
20
|
AgentEventParams,
|
|
17
21
|
LEADER_RECIPIENT,
|
|
18
22
|
messageTitle,
|
|
19
23
|
WorkerWorkToolParams,
|
|
20
|
-
type
|
|
24
|
+
type Task,
|
|
21
25
|
} from "./types.ts";
|
|
22
26
|
|
|
23
|
-
const
|
|
24
|
-
|
|
27
|
+
const TASK_DISCLOSURE_TOOLS = ["task"] as const;
|
|
28
|
+
/** Why the tasks tool is shown to this worker. `held` means it already
|
|
29
|
+
* owns an assignment, which is a fact about this worker rather than a task
|
|
30
|
+
* status — the two were never the same state and must not share a word. */
|
|
31
|
+
type TaskDisclosure = "none" | "notice" | "held";
|
|
25
32
|
|
|
26
33
|
export interface WorkerToolDisclosure {
|
|
27
|
-
update(prompt
|
|
34
|
+
update(prompt?: string): void;
|
|
28
35
|
reset(): void;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
32
|
-
let state:
|
|
39
|
+
let state: TaskDisclosure = "none";
|
|
33
40
|
const apply = () => {
|
|
34
41
|
if (typeof pi.getActiveTools !== "function" || typeof pi.setActiveTools !== "function") return;
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
42
|
+
const revealed = state === "notice" || state === "held" ? ["task"] : [];
|
|
43
|
+
// `work` is re-asserted from the owned set rather than the snapshot, so this
|
|
44
|
+
// disclosure write cannot deactivate a tool the harness has not published
|
|
45
|
+
// yet. See setOwnedTools.
|
|
46
|
+
setOwnedTools(pi, { owned: TASK_DISCLOSURE_TOOLS, toggled: revealed, enabled: revealed.length > 0 });
|
|
39
47
|
};
|
|
40
48
|
return {
|
|
41
49
|
update(prompt) {
|
|
@@ -48,8 +56,8 @@ function createWorkerToolDisclosure(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
|
48
56
|
// able to acknowledge it; hiding work here strands its resource lease.
|
|
49
57
|
if (binding && rosterEntry && rosterEntry.spawnId === binding.spawnId
|
|
50
58
|
&& rosterEntry.status !== "stopped" && assignment) {
|
|
51
|
-
state = "
|
|
52
|
-
} else if (!assignment && prompt
|
|
59
|
+
state = "held";
|
|
60
|
+
} else if (!assignment && prompt?.includes("=== TASK NOTICE ===")) state = "notice";
|
|
53
61
|
else state = "none";
|
|
54
62
|
apply();
|
|
55
63
|
},
|
|
@@ -66,7 +74,7 @@ export interface WorkerBinding {
|
|
|
66
74
|
outbox: string;
|
|
67
75
|
inbox: string;
|
|
68
76
|
rosterFile: string;
|
|
69
|
-
|
|
77
|
+
tasksFile: string;
|
|
70
78
|
claimsDir: string;
|
|
71
79
|
submissionsDir: string;
|
|
72
80
|
}
|
|
@@ -77,7 +85,7 @@ const REQUIRED_ENV = [
|
|
|
77
85
|
"PI_TEAMMATE_OUTBOX_FILE",
|
|
78
86
|
"PI_TEAMMATE_INBOX_FILE",
|
|
79
87
|
"PI_TEAMMATE_ROSTER_FILE",
|
|
80
|
-
"
|
|
88
|
+
"PI_TEAMMATE_TASKS_FILE",
|
|
81
89
|
"PI_TEAMMATE_CLAIMS_DIR",
|
|
82
90
|
"PI_TEAMMATE_SUBMISSIONS_DIR",
|
|
83
91
|
] as const;
|
|
@@ -85,35 +93,40 @@ const REQUIRED_ENV = [
|
|
|
85
93
|
export function workerBinding(): WorkerBinding | undefined {
|
|
86
94
|
const values = REQUIRED_ENV.map((name) => process.env[name]);
|
|
87
95
|
if (values.some((value) => !value)) return undefined;
|
|
88
|
-
const [worker, spawnId, outbox, inbox, rosterFile,
|
|
89
|
-
return { worker, spawnId, outbox, inbox, rosterFile,
|
|
96
|
+
const [worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir] = values as string[];
|
|
97
|
+
return { worker, spawnId, outbox, inbox, rosterFile, tasksFile, claimsDir, submissionsDir };
|
|
90
98
|
}
|
|
91
99
|
|
|
92
|
-
function
|
|
93
|
-
const
|
|
94
|
-
return Object.values(
|
|
100
|
+
function loadWorkerTasks(binding: WorkerBinding): Task[] {
|
|
101
|
+
const tasks = readTasksFile(binding.tasksFile);
|
|
102
|
+
return Object.values(tasks?.tasks ?? {}).map(task => {
|
|
103
|
+
// Workers read the leader's file without restoring the shared store. Legacy
|
|
104
|
+
// files can still be present until its next mutation, so normalize status
|
|
105
|
+
// here without resetting an active participant's holding.
|
|
106
|
+
const raw: unknown = task.status;
|
|
107
|
+
const status = raw === "recovery_hold" ? "blocked"
|
|
108
|
+
: raw === "superseded" ? (task.supersededBy ? "pending" : "blocked")
|
|
109
|
+
: isTaskStatus(raw) ? raw : "blocked";
|
|
110
|
+
return { ...task, status, ...(raw === "superseded" && !task.supersededBy ? { replacementUnresolved: true } : {}) };
|
|
111
|
+
}).sort((a, b) => a.id.localeCompare(b.id));
|
|
95
112
|
}
|
|
96
113
|
|
|
97
|
-
function dependenciesMet(task:
|
|
114
|
+
function dependenciesMet(task: Task, tasks: Map<string, Task>): boolean {
|
|
98
115
|
return task.dependsOn.every((dep) => tasks.get(dep)?.status === "completed");
|
|
99
116
|
}
|
|
100
117
|
|
|
101
|
-
function taskCounts(tasks:
|
|
102
|
-
const counts = { pending: 0,
|
|
103
|
-
for (const task of tasks) counts[task.status] += 1;
|
|
104
|
-
const claimable = tasks.filter((task) => task.status === "pending" &&
|
|
105
|
-
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) ·
|
|
118
|
+
function taskCounts(tasks: Task[], byId: Map<string, Task>): string {
|
|
119
|
+
const counts: Record<TaskStatus, number> = { pending: 0, in_progress: 0, blocked: 0, completed: 0, cancelled: 0 };
|
|
120
|
+
for (const task of tasks) if (!isTaskReplaced(task)) counts[task.status] += 1;
|
|
121
|
+
const claimable = tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId)).length;
|
|
122
|
+
return `tasks=${tasks.length} · pending=${counts.pending} (${claimable} claimable) · in_progress=${counts.in_progress} · blocked=${counts.blocked} · completed=${counts.completed} · cancelled=${counts.cancelled} · superseded=${tasks.filter(isTaskReplaced).length}`;
|
|
106
123
|
}
|
|
107
124
|
|
|
108
|
-
function
|
|
109
|
-
return (left ?? []).some((a) => (right ?? []).some((b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)));
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function claimRejection(binding: WorkerBinding, task: BoardTask): string | undefined {
|
|
125
|
+
function claimRejection(binding: WorkerBinding, task: Task): string | undefined {
|
|
113
126
|
const self = readRoster(binding.rosterFile).find((entry) => entry.name === binding.worker);
|
|
114
127
|
if (self?.assignment) {
|
|
115
128
|
const state = self.assignment.closed ? "closed pending leader reopen" : "active";
|
|
116
|
-
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming
|
|
129
|
+
return `You already own ${state} ${self.assignment.kind} assignment "${self.assignment.id}". Finish it through its matching protocol before claiming task work.`;
|
|
117
130
|
}
|
|
118
131
|
const conflict = readRoster(binding.rosterFile).find((entry) => entry.name !== binding.worker
|
|
119
132
|
&& entry.assignment && !entry.assignment.closed
|
|
@@ -124,14 +137,19 @@ function claimRejection(binding: WorkerBinding, task: BoardTask): string | undef
|
|
|
124
137
|
return undefined;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
|
-
function taskStatusLabel(task:
|
|
128
|
-
|
|
140
|
+
function taskStatusLabel(task: Task, tasks: Map<string, Task>): string {
|
|
141
|
+
// `blocked` is a state of its own, so it is named as one. A task in it
|
|
142
|
+
// was attempted and the attempt did not stand: it is not claimable work, and
|
|
143
|
+
// calling it "pending" hid that from the participant reading this list.
|
|
144
|
+
if (isTaskReplaced(task)) return task.supersededBy ? `replaced-by=${task.supersededBy}` : "replacement-unresolved";
|
|
145
|
+
if (task.status === "blocked") return "blocked";
|
|
146
|
+
if (task.status === "pending") return dependenciesMet(task, tasks) ? "pending/claimable" : "pending/blocked";
|
|
129
147
|
return task.status;
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
export function
|
|
150
|
+
export function renderTaskList(tasks: Task[]): string {
|
|
133
151
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
134
|
-
const lines = [`
|
|
152
|
+
const lines = [`TASKS · current session`, `SUMMARY · ${taskCounts(tasks, byId)}`];
|
|
135
153
|
if (tasks.length === 0) return `${lines.join("\n")}\nTASKS\n(no tasks)`;
|
|
136
154
|
lines.push("TASKS");
|
|
137
155
|
for (const task of tasks) {
|
|
@@ -212,7 +230,7 @@ function createWorkerReports(pi: ExtensionAPI) {
|
|
|
212
230
|
throw new Error("Work authority rejected: this turn belongs to a different assignment. Wait for its fresh assignment marker.");
|
|
213
231
|
}
|
|
214
232
|
const cancelled = cancellation && assignmentId && self.currentTaskId
|
|
215
|
-
&& (self.assignment?.closed ||
|
|
233
|
+
&& (self.assignment?.closed || loadWorkerTasks(binding).some((task) => task.id === self.currentTaskId && isTaskReplaced(task)));
|
|
216
234
|
if ((reportClosed || self.assignment?.closed) && !(cancelled && terminalOutcome !== "failed")) {
|
|
217
235
|
throw new Error("Leader report rejected: this assignment already has a terminal report or is closed. Stop; no repeated acknowledgment is required.");
|
|
218
236
|
}
|
|
@@ -240,7 +258,7 @@ function createWorkerReports(pi: ExtensionAPI) {
|
|
|
240
258
|
pending = undefined;
|
|
241
259
|
} else if (!current) {
|
|
242
260
|
// The harness retired this attempt without a replacement marker: this is a
|
|
243
|
-
// fresh unassigned turn (peer mail,
|
|
261
|
+
// fresh unassigned turn (peer mail, tasks notice, ordinary discussion).
|
|
244
262
|
// Holding the previous terminal state here would dead-end the worker.
|
|
245
263
|
assignmentId = undefined;
|
|
246
264
|
taskId = undefined;
|
|
@@ -300,14 +318,14 @@ function createWorkerReports(pi: ExtensionAPI) {
|
|
|
300
318
|
};
|
|
301
319
|
}
|
|
302
320
|
|
|
303
|
-
export function
|
|
321
|
+
export function registerChildCapabilities(pi: ExtensionAPI): WorkerToolDisclosure {
|
|
304
322
|
const disclosure = createWorkerToolDisclosure(pi);
|
|
305
323
|
const reports = createWorkerReports(pi);
|
|
306
324
|
pi.registerTool({
|
|
307
|
-
name: "
|
|
325
|
+
name: "message",
|
|
308
326
|
promptSnippet: "Send an event or message to a participant",
|
|
309
327
|
label: "Agent Event",
|
|
310
|
-
description: "Communication-only interface across Leader, Worker, and Peers.
|
|
328
|
+
description: "Communication-only interface across Leader, Worker, and Peers. Task completion is reported automatically after settlement or delivered with task action=complete.",
|
|
311
329
|
parameters: AgentEventParams,
|
|
312
330
|
renderShell: "self",
|
|
313
331
|
renderCall: emptyToolCall,
|
|
@@ -325,11 +343,11 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
325
343
|
if (!params.to || params.to === LEADER_RECIPIENT) {
|
|
326
344
|
appendWorkerEvent(binding.outbox, {
|
|
327
345
|
assignmentId: authority.assignmentId, id: randomUUID(), type: "message", worker: binding.worker, spawnId: binding.spawnId,
|
|
328
|
-
body: params.
|
|
346
|
+
body: params.body, status: params.kind ?? "inform", timestamp: Date.now(),
|
|
329
347
|
});
|
|
330
348
|
return {
|
|
331
|
-
content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader ·
|
|
332
|
-
details: { to: LEADER_RECIPIENT,
|
|
349
|
+
content: [{ type: "text", text: `MESSAGING\nREPORT · to=leader · kind=${params.kind ?? "inform"}\nNEXT · harness will deliver this message; Task completion is automatic or uses task complete` }],
|
|
350
|
+
details: { to: LEADER_RECIPIENT, kind: params.kind ?? "inform", outcome: "queued" },
|
|
333
351
|
};
|
|
334
352
|
}
|
|
335
353
|
const recipient = resolveRecipient(params.to, readRoster(binding.rosterFile));
|
|
@@ -339,45 +357,49 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
339
357
|
appendInboxMessage(recipientInbox, {
|
|
340
358
|
id: randomUUID(),
|
|
341
359
|
from: binding.worker,
|
|
342
|
-
subject: messageTitle(params.
|
|
343
|
-
body: params.
|
|
360
|
+
subject: messageTitle(params.body),
|
|
361
|
+
body: params.body,
|
|
344
362
|
...(recipient.spawnId ? { toSpawnId: recipient.spawnId } : {}),
|
|
345
363
|
});
|
|
346
364
|
return {
|
|
347
365
|
content: [{ type: "text", text: `MESSAGING\nQUEUED · to=@${to}\nNEXT · harness will route the inbox message into a recipient turn` }],
|
|
348
|
-
details: { to, outcome: "queued",
|
|
366
|
+
details: { to, outcome: "queued", kind: params.kind ?? "inform" },
|
|
349
367
|
};
|
|
350
368
|
},
|
|
351
369
|
});
|
|
352
370
|
|
|
353
|
-
async function queueWorkClaim(id: string | undefined, presentation: "
|
|
371
|
+
async function queueWorkClaim(id: string | undefined, presentation: "task" | "work") {
|
|
354
372
|
const binding = workerBinding();
|
|
355
373
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
356
374
|
// Claims pass the same attempt authority as sends and submissions: a closed or
|
|
357
|
-
// terminal attempt must not queue new
|
|
375
|
+
// terminal attempt must not queue new task work even when work is re-disclosed.
|
|
358
376
|
reports.authorize(binding);
|
|
359
377
|
const label = presentation === "work" ? "Work Item" : "Task";
|
|
360
378
|
const empty = presentation === "work" ? "No claimable Work Item right now." : "No claimable task right now.";
|
|
361
|
-
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
|
|
362
|
-
const tasks =
|
|
379
|
+
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 tasks again.";
|
|
380
|
+
const tasks = loadWorkerTasks(binding);
|
|
363
381
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
364
382
|
const candidates = id
|
|
365
383
|
? tasks.filter((task) => task.id === id)
|
|
366
|
-
: tasks.filter((task) => task.status === "pending" &&
|
|
384
|
+
: tasks.filter((task) => !isTaskReplaced(task) && task.status === "pending" && dependenciesMet(task, byId));
|
|
367
385
|
if (candidates.length === 0) {
|
|
368
|
-
// Say why nothing is claimable: a
|
|
369
|
-
// Leader recovery must not read as an ordinary empty
|
|
370
|
-
const held =
|
|
386
|
+
// Say why nothing is claimable: a task set whose only pending Work is held for
|
|
387
|
+
// Leader recovery must not read as an ordinary empty tasks.
|
|
388
|
+
const held = loadWorkerTasks(binding).filter((task) => task.status === "blocked");
|
|
371
389
|
if (!id && held.length > 0) {
|
|
372
390
|
throw new Error(`No claimable ${label.toLowerCase()} right now. Held for explicit leader recovery: ${held.map((task) => task.id).join(", ")}.`);
|
|
373
391
|
}
|
|
374
|
-
throw new Error(id ? `${label} "${id}" was not found${presentation === "
|
|
392
|
+
throw new Error(id ? `${label} "${id}" was not found${presentation === "task" ? " in the task list" : ""}.` : empty);
|
|
375
393
|
}
|
|
376
394
|
for (const task of candidates) {
|
|
377
|
-
if (task.
|
|
395
|
+
if (task.status === "blocked") {
|
|
378
396
|
if (id) throw new Error(`Work Item "${task.id}" requires explicit leader Work assignment after failure.`);
|
|
379
397
|
continue;
|
|
380
398
|
}
|
|
399
|
+
if (isTaskReplaced(task)) {
|
|
400
|
+
if (id) throw new Error(task.supersededBy ? `Work Item "${task.id}" was replaced by "${task.supersededBy}".` : `Work Item "${task.id}" has an unresolved replacement target.`);
|
|
401
|
+
continue;
|
|
402
|
+
}
|
|
381
403
|
if (task.status !== "pending" || !dependenciesMet(task, byId)) continue;
|
|
382
404
|
const rejected = claimRejection(binding, task);
|
|
383
405
|
if (rejected) {
|
|
@@ -385,12 +407,12 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
385
407
|
continue;
|
|
386
408
|
}
|
|
387
409
|
const won = createTaskIntent(binding.claimsDir, task.id, {
|
|
388
|
-
taskId: task.id,
|
|
410
|
+
taskId: task.id, participant: binding.worker, spawnId: binding.spawnId, timestamp: Date.now(),
|
|
389
411
|
});
|
|
390
412
|
if (won) {
|
|
391
413
|
const content = presentation === "work"
|
|
392
414
|
? `WORK · current session\nCLAIM INTENT QUEUED · ${task.id} · ${task.subject}\nREQUESTER · @${binding.worker}\nNEXT · wait for harness claim acceptance before starting Work`
|
|
393
|
-
: `
|
|
415
|
+
: `TASKS · 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`;
|
|
394
416
|
const details = presentation === "work"
|
|
395
417
|
? { action: "claim", outcome: "queued", id: task.id, subject: task.subject, worker: binding.worker }
|
|
396
418
|
: { taskId: task.id, subject: task.subject, worker: binding.worker };
|
|
@@ -402,7 +424,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
402
424
|
}
|
|
403
425
|
|
|
404
426
|
pi.registerTool({
|
|
405
|
-
name: "
|
|
427
|
+
name: "task",
|
|
406
428
|
renderShell: "self",
|
|
407
429
|
renderCall: emptyToolCall,
|
|
408
430
|
renderResult(result, options, theme, context) {
|
|
@@ -421,7 +443,7 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
421
443
|
const binding = workerBinding();
|
|
422
444
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
423
445
|
if (params.action === "list") {
|
|
424
|
-
const tasks =
|
|
446
|
+
const tasks = loadWorkerTasks(binding);
|
|
425
447
|
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
426
448
|
const lines = tasks.map((task) => `- ${task.id} · ${taskStatusLabel(task, byId)} · ${task.subject}`);
|
|
427
449
|
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 } });
|
|
@@ -436,20 +458,20 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
436
458
|
},
|
|
437
459
|
});
|
|
438
460
|
|
|
439
|
-
async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "
|
|
461
|
+
async function queueWorkSubmission(input: { taskId: string; status: "completed" | "failed"; result?: string; presentation: "task" | "work" }) {
|
|
440
462
|
const binding = workerBinding();
|
|
441
463
|
if (!binding) throw new Error("This capability is available only inside a spawned teammate.");
|
|
442
464
|
const authority = reports.authorize(binding, input.status === "failed");
|
|
443
465
|
if (!authority.assignmentId || authority.taskId !== input.taskId) throw new Error("This turn does not own the submitted Work Item.");
|
|
444
466
|
const assignmentId = authority.assignmentId;
|
|
445
467
|
const won = createTaskIntent(binding.submissionsDir, input.taskId, {
|
|
446
|
-
taskId: input.taskId,
|
|
468
|
+
taskId: input.taskId, participant: binding.worker, spawnId: binding.spawnId, assignmentId,
|
|
447
469
|
status: input.status, result: input.result, timestamp: Date.now(),
|
|
448
470
|
});
|
|
449
471
|
if (!won) throw new Error(`A submission for "${input.taskId}" is already pending.`);
|
|
450
472
|
reports.close(input.status);
|
|
451
473
|
disclosure.reset();
|
|
452
|
-
const task =
|
|
474
|
+
const task = loadWorkerTasks(binding).find((candidate) => candidate.id === input.taskId);
|
|
453
475
|
const roleVerify = process.env.PI_TEAMMATE_VERIFY_DEFAULT?.trim();
|
|
454
476
|
const verify = input.status === "completed"
|
|
455
477
|
? task?.verify
|
|
@@ -461,9 +483,9 @@ export function registerWorkerCapabilities(pi: ExtensionAPI): WorkerToolDisclosu
|
|
|
461
483
|
const next = "NEXT · End this turn. The harness settles the outcome; do not repeat submission or cancellation acknowledgments.";
|
|
462
484
|
const content = input.presentation === "work"
|
|
463
485
|
? `WORK · current session\nSUBMISSION INTENT QUEUED · ${input.taskId} · ${input.status === "completed" ? "success" : "failed"}\n${verify}\n${next}`
|
|
464
|
-
: `
|
|
486
|
+
: `TASKS · current session\nSUBMITTED · ${input.taskId} · ${input.status}\n${verify}\n${next}`;
|
|
465
487
|
// The subject rides along in details: a worker process never hydrates the
|
|
466
|
-
// leader's in-memory
|
|
488
|
+
// leader's in-memory tasks, so its row cannot look the task up by itself.
|
|
467
489
|
const details = input.presentation === "work"
|
|
468
490
|
? { action: "submit", outcome: "queued", id: input.taskId, subject: task?.subject, status: input.status === "completed" ? "success" : "failed", verify: Boolean(task?.verify || roleVerify) }
|
|
469
491
|
: { taskId: input.taskId, subject: task?.subject, status: input.status, verify: Boolean(task?.verify || roleVerify) };
|
package/src/agents.ts
DELETED
|
@@ -1,296 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Declarative agent definitions: Markdown files with YAML-style frontmatter.
|
|
3
|
-
*
|
|
4
|
-
* Discovery scopes (later scopes override earlier ones for the same name):
|
|
5
|
-
* 1. user — ~/.pi/agent/agents/ (respects PI_CODING_AGENT_DIR)
|
|
6
|
-
* 2. project — <cwd>/.pi/agents/<name>.md
|
|
7
|
-
* 3. project-local — <cwd>/.pi/agents/<name>.local.md (personal override)
|
|
8
|
-
*
|
|
9
|
-
* There are no built-in roles: references/agent-roles.md ships template
|
|
10
|
-
* shapes that the leader consults when generating a new definition on demand.
|
|
11
|
-
*
|
|
12
|
-
* Frontmatter fields:
|
|
13
|
-
* name — unique agent id (required)
|
|
14
|
-
* description — routing contract: when the leader should choose this agent
|
|
15
|
-
* tools — comma-separated list or YAML list of Pi tool ids
|
|
16
|
-
* model — optional provider/model pin, or "inherit" for the leader's
|
|
17
|
-
* current model at spawn time
|
|
18
|
-
* verify — optional role-default completion gate: a review prompt a
|
|
19
|
-
* fresh one-shot reviewer answers with VERDICT: PASS/FAIL
|
|
20
|
-
* worktree — optional role-default Git worktree isolation (true/false)
|
|
21
|
-
* The Markdown body is the worker's role prompt.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import * as fs from "node:fs";
|
|
25
|
-
import * as path from "node:path";
|
|
26
|
-
import { fileURLToPath } from "node:url";
|
|
27
|
-
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
28
|
-
|
|
29
|
-
export type AgentScope = "user" | "project" | "project-local" | "session";
|
|
30
|
-
|
|
31
|
-
export interface AgentDefinition {
|
|
32
|
-
name: string;
|
|
33
|
-
description: string;
|
|
34
|
-
tools: string[];
|
|
35
|
-
model?: string;
|
|
36
|
-
/** Role-default completion gate; task-level verify overrides this. */
|
|
37
|
-
verify?: string;
|
|
38
|
-
/** Whether this role always receives a dedicated Git worktree. */
|
|
39
|
-
worktree: boolean;
|
|
40
|
-
/** The Markdown body, trimmed; used as the teammate's role prompt. */
|
|
41
|
-
prompt: string;
|
|
42
|
-
/** Scope that supplied this definition (precedence: project-local > project > user > bundled). */
|
|
43
|
-
scope: AgentScope;
|
|
44
|
-
/** True only for the shared project scope that is expected to be git-managed.
|
|
45
|
-
* user and project-local definitions are machine/personal by definition. */
|
|
46
|
-
gitManaged: boolean;
|
|
47
|
-
/** Absolute path of the definition file; absent for session-only roles. */
|
|
48
|
-
source?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export interface AgentDefinitionInput {
|
|
52
|
-
name: string;
|
|
53
|
-
description: string;
|
|
54
|
-
tools: string[];
|
|
55
|
-
model?: string;
|
|
56
|
-
verify?: string;
|
|
57
|
-
worktree?: boolean;
|
|
58
|
-
prompt: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Reserved model value meaning "use the leader session's current model",
|
|
62
|
-
* resolved at spawn time (Claude-Code-style inherit). */
|
|
63
|
-
export const MODEL_INHERIT_ALIAS = "inherit";
|
|
64
|
-
|
|
65
|
-
const sessionAgents = new Map<string, AgentDefinition>();
|
|
66
|
-
const AGENT_NAME_PATTERN = /^[a-z][a-z0-9._-]{0,63}$/i;
|
|
67
|
-
|
|
68
|
-
function normalizeAgentInput(input: AgentDefinitionInput): AgentDefinitionInput {
|
|
69
|
-
const name = input.name.trim();
|
|
70
|
-
if (!AGENT_NAME_PATTERN.test(name)) {
|
|
71
|
-
throw new Error(`Invalid agent name "${name}". Use letters, digits, dots, dashes, underscores.`);
|
|
72
|
-
}
|
|
73
|
-
const prompt = input.prompt.trim();
|
|
74
|
-
if (!prompt) throw new Error(`Agent "${name}" prompt must not be empty.`);
|
|
75
|
-
return {
|
|
76
|
-
...input,
|
|
77
|
-
name,
|
|
78
|
-
description: input.description.trim(),
|
|
79
|
-
tools: input.tools.map((tool) => tool.trim()).filter(Boolean),
|
|
80
|
-
model: input.model?.trim() || undefined,
|
|
81
|
-
verify: input.verify?.trim() || undefined,
|
|
82
|
-
worktree: input.worktree ?? false,
|
|
83
|
-
prompt,
|
|
84
|
-
};
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Register a generated role for this session without writing a definition file. */
|
|
88
|
-
export function registerSessionAgent(input: AgentDefinitionInput): AgentDefinition {
|
|
89
|
-
const normalized = normalizeAgentInput(input);
|
|
90
|
-
const definition: AgentDefinition = {
|
|
91
|
-
...normalized,
|
|
92
|
-
worktree: normalized.worktree ?? false,
|
|
93
|
-
scope: "session",
|
|
94
|
-
gitManaged: false,
|
|
95
|
-
source: undefined,
|
|
96
|
-
};
|
|
97
|
-
sessionAgents.set(definition.name, definition);
|
|
98
|
-
return definition;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** Persist a generated role only when the user explicitly requests it. */
|
|
102
|
-
export function persistAgentDefinition(
|
|
103
|
-
input: AgentDefinitionInput,
|
|
104
|
-
scope: "project" | "project-local",
|
|
105
|
-
cwd = process.cwd(),
|
|
106
|
-
): AgentDefinition {
|
|
107
|
-
const normalized = normalizeAgentInput(input);
|
|
108
|
-
const directory = agentsDir("project", cwd);
|
|
109
|
-
fs.mkdirSync(directory, { recursive: true });
|
|
110
|
-
const filename = `${normalized.name}${scope === "project-local" ? LOCAL_DEFINITION_SUFFIX : ".md"}`;
|
|
111
|
-
const source = path.join(directory, filename);
|
|
112
|
-
const frontmatter = [
|
|
113
|
-
"---",
|
|
114
|
-
`name: ${normalized.name}`,
|
|
115
|
-
`description: ${normalized.description.replace(/[\\r\\n]+/g, " ")}`,
|
|
116
|
-
`tools: ${normalized.tools.join(", ")}`,
|
|
117
|
-
...(normalized.model ? [`model: ${normalized.model}`] : []),
|
|
118
|
-
...(normalized.verify ? [`verify: ${normalized.verify.replace(/[\\r\\n]+/g, " ")}`] : []),
|
|
119
|
-
`worktree: ${normalized.worktree ? "true" : "false"}`,
|
|
120
|
-
"---",
|
|
121
|
-
normalized.prompt,
|
|
122
|
-
"",
|
|
123
|
-
].join("\n");
|
|
124
|
-
fs.writeFileSync(source, frontmatter, { encoding: "utf8", mode: 0o600 });
|
|
125
|
-
sessionAgents.delete(normalized.name);
|
|
126
|
-
return {
|
|
127
|
-
...normalized,
|
|
128
|
-
worktree: normalized.worktree ?? false,
|
|
129
|
-
scope,
|
|
130
|
-
gitManaged: scope === "project",
|
|
131
|
-
source,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/** Drop generated session roles; persistent user/project definitions remain discoverable. */
|
|
136
|
-
export function clearSessionAgents(): void {
|
|
137
|
-
sessionAgents.clear();
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Shipped role templates: reference material for generating new definitions
|
|
141
|
-
* on demand — never discovered and never spawnable directly. */
|
|
142
|
-
export const AGENT_REFERENCE_PATH = path.resolve(
|
|
143
|
-
path.dirname(fileURLToPath(import.meta.url)), "..", "references", "agent-roles.md",
|
|
144
|
-
);
|
|
145
|
-
|
|
146
|
-
/** Files named `xxx.local.md` in the project agents directory are personal
|
|
147
|
-
* overrides: same teammate name as `xxx.md`, project-local scope, never git-
|
|
148
|
-
* managed by convention. */
|
|
149
|
-
export const LOCAL_DEFINITION_SUFFIX = ".local.md";
|
|
150
|
-
|
|
151
|
-
function agentsDir(scope: "user" | "project", cwd?: string): string {
|
|
152
|
-
if (scope === "user") {
|
|
153
|
-
return path.join(getAgentDir(), "agents");
|
|
154
|
-
}
|
|
155
|
-
return path.join(cwd || process.cwd(), ".pi", "agents");
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function parseFrontmatter(raw: string): { fields: Record<string, string | string[]>; body: string } {
|
|
159
|
-
const fields: Record<string, string | string[]> = {};
|
|
160
|
-
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/.exec(raw);
|
|
161
|
-
if (!match) return { fields, body: raw.trim() };
|
|
162
|
-
const lines = match[1].split("\n");
|
|
163
|
-
for (let index = 0; index < lines.length; index++) {
|
|
164
|
-
const line = lines[index];
|
|
165
|
-
const separator = line.indexOf(":");
|
|
166
|
-
if (separator <= 0) continue;
|
|
167
|
-
const key = line.slice(0, separator).trim();
|
|
168
|
-
// Strip inline YAML comments (# ...) and surrounding whitespace/quotes.
|
|
169
|
-
let value = line.slice(separator + 1).replace(/\s+#.*$/, "").trim();
|
|
170
|
-
if (key === "tools") {
|
|
171
|
-
if (value === "") {
|
|
172
|
-
// YAML block sequence: "- item" lines, flush-left or indented,
|
|
173
|
-
// possibly interleaved with blank or comment-only lines.
|
|
174
|
-
const items: string[] = [];
|
|
175
|
-
index += 1;
|
|
176
|
-
while (index < lines.length) {
|
|
177
|
-
const trimmed = lines[index].trim();
|
|
178
|
-
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
179
|
-
index += 1;
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
if (!/^-.+/.test(trimmed)) break;
|
|
183
|
-
const rawItem = trimmed.replace(/^-\s*/, "");
|
|
184
|
-
const item = rawItem
|
|
185
|
-
.replace(/\s+#.*$/, "")
|
|
186
|
-
.trim()
|
|
187
|
-
.replace(/^["']|["']$/g, "");
|
|
188
|
-
// "- # comment" is a comment-only entry, not a tool.
|
|
189
|
-
if (item && !rawItem.trimStart().startsWith("#")) items.push(item);
|
|
190
|
-
index += 1;
|
|
191
|
-
}
|
|
192
|
-
index -= 1; // The for-loop steps past the last consumed line.
|
|
193
|
-
fields.tools = items;
|
|
194
|
-
continue;
|
|
195
|
-
}
|
|
196
|
-
const tools = value
|
|
197
|
-
.replace(/^\[|\]$/g, "")
|
|
198
|
-
.split(",")
|
|
199
|
-
.map((tool) => tool.trim().replace(/^["']|["']$/g, ""))
|
|
200
|
-
.filter(Boolean);
|
|
201
|
-
fields.tools = tools;
|
|
202
|
-
continue;
|
|
203
|
-
}
|
|
204
|
-
fields[key] = value.replace(/^["']|["']$/g, "");
|
|
205
|
-
}
|
|
206
|
-
return { fields, body: match[2].trim() };
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function loadDir(baseScope: AgentScope, dir: string, agents: Map<string, AgentDefinition>): void {
|
|
210
|
-
let entries: fs.Dirent[];
|
|
211
|
-
try {
|
|
212
|
-
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
213
|
-
} catch {
|
|
214
|
-
return; // A missing agents directory is not an error.
|
|
215
|
-
}
|
|
216
|
-
// Dedup by name within the directory: plain .md first so a same-name
|
|
217
|
-
// xxx.local.md (personal override) overwrites it in the map.
|
|
218
|
-
const ordered = [...entries].sort((left, right) =>
|
|
219
|
-
Number(left.name.endsWith(LOCAL_DEFINITION_SUFFIX)) - Number(right.name.endsWith(LOCAL_DEFINITION_SUFFIX))
|
|
220
|
-
|| left.name.localeCompare(right.name),
|
|
221
|
-
);
|
|
222
|
-
for (const entry of ordered) {
|
|
223
|
-
if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
|
|
224
|
-
const source = path.join(dir, entry.name);
|
|
225
|
-
let raw: string;
|
|
226
|
-
try {
|
|
227
|
-
raw = fs.readFileSync(source, "utf8");
|
|
228
|
-
} catch {
|
|
229
|
-
continue;
|
|
230
|
-
}
|
|
231
|
-
const { fields, body } = parseFrontmatter(raw);
|
|
232
|
-
const isLocal = baseScope === "project" && entry.name.endsWith(LOCAL_DEFINITION_SUFFIX);
|
|
233
|
-
const baseName = isLocal
|
|
234
|
-
? entry.name.slice(0, -LOCAL_DEFINITION_SUFFIX.length)
|
|
235
|
-
: entry.name.replace(/\.md$/, "");
|
|
236
|
-
const name = typeof fields.name === "string" ? fields.name : baseName;
|
|
237
|
-
if (!name || !body) continue;
|
|
238
|
-
agents.set(name, {
|
|
239
|
-
name,
|
|
240
|
-
description: typeof fields.description === "string" ? fields.description : "",
|
|
241
|
-
tools: Array.isArray(fields.tools) ? fields.tools : [],
|
|
242
|
-
model: typeof fields.model === "string" && fields.model ? fields.model : undefined,
|
|
243
|
-
verify: typeof fields.verify === "string" && fields.verify ? fields.verify : undefined,
|
|
244
|
-
worktree: fields.worktree === "true",
|
|
245
|
-
prompt: body,
|
|
246
|
-
scope: isLocal ? "project-local" : baseScope,
|
|
247
|
-
gitManaged: scopeIsGitManaged(isLocal ? "project-local" : baseScope),
|
|
248
|
-
source,
|
|
249
|
-
});
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
/** Only the shared project layer is expected to be git-managed. */
|
|
254
|
-
function scopeIsGitManaged(scope: AgentScope): boolean {
|
|
255
|
-
return scope === "project";
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* Resolve all agent definitions visible from the given cwd, with later scopes
|
|
260
|
-
* overriding earlier ones per name: project-local > project > user.
|
|
261
|
-
*/
|
|
262
|
-
export function discoverAgents(cwd?: string): Map<string, AgentDefinition> {
|
|
263
|
-
const agents = new Map<string, AgentDefinition>();
|
|
264
|
-
loadDir("user", agentsDir("user", cwd), agents);
|
|
265
|
-
loadDir("project", agentsDir("project", cwd), agents);
|
|
266
|
-
// Generated roles fill gaps only: filesystem scopes keep precedence over
|
|
267
|
-
// session memory.
|
|
268
|
-
for (const [name, definition] of sessionAgents) if (!agents.has(name)) agents.set(name, definition);
|
|
269
|
-
return agents;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Resolve one agent by name, or undefined when no scope defines it. */
|
|
273
|
-
export function resolveAgent(name: string, cwd?: string): AgentDefinition | undefined {
|
|
274
|
-
return discoverAgents(cwd).get(name);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Format discovered agents as Markdown for prompt injection in before_agent_start.
|
|
279
|
-
*/
|
|
280
|
-
export function formatAgentGuidance(cwd?: string): string {
|
|
281
|
-
const agents = discoverAgents(cwd);
|
|
282
|
-
if (agents.size === 0) return "(none defined; pass an inline definition when delegating)";
|
|
283
|
-
const lines: string[] = [];
|
|
284
|
-
for (const agent of agents.values()) {
|
|
285
|
-
const extras = [
|
|
286
|
-
agent.model ? `Model: ${agent.model}` : "",
|
|
287
|
-
agent.verify ? `Verify: ${agent.verify}` : "",
|
|
288
|
-
agent.worktree ? "Worktree: true" : "",
|
|
289
|
-
agent.scope === "session" ? "in-memory" : agent.gitManaged ? "git-managed" : "local",
|
|
290
|
-
].filter(Boolean);
|
|
291
|
-
lines.push(`- **${agent.name}** (${agent.scope})`);
|
|
292
|
-
if (agent.description) lines.push(` ${agent.description}`);
|
|
293
|
-
lines.push(` Tools: ${agent.tools.join(", ") || "(role defaults)"}${extras.length > 0 ? ` | ${extras.join(" | ")}` : ""}`);
|
|
294
|
-
}
|
|
295
|
-
return lines.join("\n");
|
|
296
|
-
}
|