@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/statefile.ts
CHANGED
|
@@ -1,41 +1,34 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Shared session files for the leader-owned snapshot, teammate outboxes and
|
|
3
|
-
* inboxes
|
|
3
|
+
* inboxes. The persistent task storage lives in @fradser/pi-tasks; `sessionKey` comes from
|
|
4
|
+
* pi-kit so both packages agree on which session a directory belongs to.
|
|
4
5
|
*
|
|
5
6
|
* Runtime layout (removed at session shutdown):
|
|
6
7
|
* ~/.pi/agent/teammate/<sessionKey>/state.json leader-owned snapshot
|
|
7
8
|
* ~/.pi/agent/teammate/<sessionKey>/events/*.jsonl per-spawn report outboxes
|
|
8
9
|
* ~/.pi/agent/teammate/<sessionKey>/mail/*.jsonl peer inbox files
|
|
9
|
-
* ~/.pi/agent/teammate/<sessionKey>/roster.json living
|
|
10
|
+
* ~/.pi/agent/teammate/<sessionKey>/roster.json living agents, worker-readable
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
* ~/.pi/agent/tasks/<sessionKey>/board.json leader-owned board file
|
|
13
|
-
* ~/.pi/agent/tasks/<sessionKey>/claims/*.json exclusive-create claim intents
|
|
14
|
-
* ~/.pi/agent/tasks/<sessionKey>/submissions/*.json exclusive-create submit intents
|
|
15
|
-
*
|
|
16
|
-
* Concurrency: the parent is the sole writer of state.json and board.json
|
|
12
|
+
* Concurrency: the parent is the sole writer of state.json and tasks.json
|
|
17
13
|
* (atomic tmp+rename). Teammates append only to their own outbox, append to
|
|
18
|
-
* recipient inboxes, and express
|
|
14
|
+
* recipient inboxes, and express tasks intent through exclusive-create files.
|
|
19
15
|
*/
|
|
20
16
|
|
|
21
|
-
import * as crypto from "node:crypto";
|
|
22
17
|
import * as fs from "node:fs";
|
|
23
18
|
import * as path from "node:path";
|
|
24
19
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
|
-
import {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.slice(0, 16);
|
|
38
|
-
}
|
|
20
|
+
import {
|
|
21
|
+
appendJsonlLine,
|
|
22
|
+
readJsonlBatch,
|
|
23
|
+
safeFileName,
|
|
24
|
+
sessionKey,
|
|
25
|
+
writeJsonAtomic,
|
|
26
|
+
} from "@fradser/pi-kit";
|
|
27
|
+
// Re-exported so consumers of this module keep one import site for session
|
|
28
|
+
// files. The implementations are pi-kit's: an incremental JSONL read and an
|
|
29
|
+
// atomic replace are not agent-teams concepts.
|
|
30
|
+
export { readJsonlBatch, writeJsonAtomic };
|
|
31
|
+
import type { TeamState, WorkerEvent } from "./types.ts";
|
|
39
32
|
|
|
40
33
|
export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
|
|
41
34
|
return path.join(getAgentDir(), "teammate", sessionKey(sessionFile, cwd));
|
|
@@ -52,16 +45,12 @@ export function writeStateFile(file: string, state: TeamState): void {
|
|
|
52
45
|
|
|
53
46
|
// ── Peer mail ─────────────────────────────────────────────────────
|
|
54
47
|
|
|
55
|
-
function safeName(name: string): string {
|
|
56
|
-
return encodeURIComponent(name);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
48
|
export function mailDir(stateFile: string): string {
|
|
60
49
|
return path.join(path.dirname(stateFile), "mail");
|
|
61
50
|
}
|
|
62
51
|
|
|
63
52
|
export function inboxPath(stateFile: string, teammateName: string): string {
|
|
64
|
-
return path.join(mailDir(stateFile), `inbox-${
|
|
53
|
+
return path.join(mailDir(stateFile), `inbox-${safeFileName(teammateName)}.jsonl`);
|
|
65
54
|
}
|
|
66
55
|
|
|
67
56
|
export function rosterPath(stateFile: string): string {
|
|
@@ -70,81 +59,30 @@ export function rosterPath(stateFile: string): string {
|
|
|
70
59
|
|
|
71
60
|
/** Append one message to a teammate inbox. Sent means this write succeeded. */
|
|
72
61
|
export function appendInboxMessage(file: string, message: { id: string; from: string; subject: string; body: string }): void {
|
|
73
|
-
|
|
74
|
-
if (Buffer.byteLength(record, "utf-8") > MAX_INBOX_MESSAGE_BYTES) {
|
|
75
|
-
throw new Error(`Message exceeds ${MAX_INBOX_MESSAGE_BYTES} bytes.`);
|
|
76
|
-
}
|
|
77
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
78
|
-
fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
|
|
62
|
+
appendJsonlLine(file, message, { label: "Message" });
|
|
79
63
|
}
|
|
80
64
|
|
|
81
|
-
/** Publish the worker-readable roster of living
|
|
82
|
-
export function writeRoster(file: string,
|
|
65
|
+
/** Publish the worker-readable roster of living agents. */
|
|
66
|
+
export function writeRoster(file: string, agents: Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }>): void {
|
|
83
67
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
84
|
-
writeJsonAtomic(file, {
|
|
68
|
+
writeJsonAtomic(file, { agents });
|
|
85
69
|
}
|
|
86
70
|
|
|
87
71
|
/** Read the roster from inside a teammate process; unknown roster = empty. */
|
|
88
|
-
export function readRoster(file: string): Array<{ name: string;
|
|
72
|
+
export function readRoster(file: string): Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> {
|
|
89
73
|
try {
|
|
90
|
-
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as {
|
|
91
|
-
return Array.isArray(parsed.
|
|
74
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { agents?: Array<{ name: string; role: string; spawnId?: string; status: string; tools?: string[]; currentTaskId?: string; assignment?: import("./types.ts").WorkerAssignment }> };
|
|
75
|
+
return Array.isArray(parsed.agents) ? parsed.agents : [];
|
|
92
76
|
} catch {
|
|
93
77
|
return [];
|
|
94
78
|
}
|
|
95
79
|
}
|
|
96
80
|
|
|
97
|
-
/** Read complete JSONL records after byteOffset (shared by outboxes and inboxes). */
|
|
98
|
-
export function readJsonlBatch(file: string, byteOffset: number, endOffset = Number.POSITIVE_INFINITY): {
|
|
99
|
-
records: unknown[];
|
|
100
|
-
nextOffset: number;
|
|
101
|
-
diagnostics: string[];
|
|
102
|
-
} {
|
|
103
|
-
let fd: number | undefined;
|
|
104
|
-
try {
|
|
105
|
-
fd = fs.openSync(file, "r");
|
|
106
|
-
const size = fs.fstatSync(fd).size;
|
|
107
|
-
// A truncated/recreated file starts at zero; message ids make replay safe.
|
|
108
|
-
const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
|
|
109
|
-
const toRead = Math.max(0, Math.min(MAX_OUTBOX_READ_BYTES, Math.min(size, endOffset) - offset));
|
|
110
|
-
if (toRead === 0) return { records: [], nextOffset: offset, diagnostics: [] };
|
|
111
|
-
const raw = Buffer.allocUnsafe(toRead);
|
|
112
|
-
const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
|
|
113
|
-
const unread = raw.subarray(0, bytesRead);
|
|
114
|
-
const lastNewline = unread.lastIndexOf(0x0a);
|
|
115
|
-
if (lastNewline < 0) {
|
|
116
|
-
// A line larger than the batch cap is malformed for this protocol; skip
|
|
117
|
-
// this chunk so one sender cannot block draining indefinitely.
|
|
118
|
-
return {
|
|
119
|
-
records: [],
|
|
120
|
-
nextOffset: bytesRead === MAX_OUTBOX_READ_BYTES ? offset + bytesRead : offset,
|
|
121
|
-
diagnostics: ["malformed or unterminated record was consumed"],
|
|
122
|
-
};
|
|
123
|
-
}
|
|
124
|
-
const complete = unread.subarray(0, lastNewline).toString("utf-8");
|
|
125
|
-
const records: unknown[] = [];
|
|
126
|
-
const diagnostics: string[] = [];
|
|
127
|
-
for (const line of complete.split("\n")) {
|
|
128
|
-
if (!line.trim()) continue;
|
|
129
|
-
try {
|
|
130
|
-
records.push(JSON.parse(line));
|
|
131
|
-
} catch {
|
|
132
|
-
diagnostics.push("malformed JSON record was consumed");
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
return { records, nextOffset: offset + lastNewline + 1, diagnostics };
|
|
136
|
-
} catch {
|
|
137
|
-
return { records: [], nextOffset: 0, diagnostics: [] };
|
|
138
|
-
} finally {
|
|
139
|
-
if (fd !== undefined) fs.closeSync(fd);
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
|
|
143
81
|
// ── Report outboxes ───────────────────────────────────────────────
|
|
144
82
|
|
|
145
83
|
/** Per-teammate append-only report log. Its filename cannot escape the dir. */
|
|
146
84
|
export function workerOutboxPath(stateFile: string, workerName: string, spawnId: string): string {
|
|
147
|
-
return path.join(path.dirname(stateFile), "events", `${
|
|
85
|
+
return path.join(path.dirname(stateFile), "events", `${safeFileName(workerName)}.${safeFileName(spawnId)}.jsonl`);
|
|
148
86
|
}
|
|
149
87
|
|
|
150
88
|
/** Delete a drained per-spawn outbox after its final snapshot is published. */
|
|
@@ -154,161 +92,7 @@ export function removeWorkerOutbox(stateFile: string, workerName: string, spawnI
|
|
|
154
92
|
|
|
155
93
|
/** Append one teammate report event. Workers never replace leader state. */
|
|
156
94
|
export function appendWorkerEvent(file: string, event: WorkerEvent): void {
|
|
157
|
-
|
|
158
|
-
if (Buffer.byteLength(record, "utf-8") > MAX_WORKER_EVENT_BYTES) {
|
|
159
|
-
throw new Error(`Worker event exceeds ${MAX_WORKER_EVENT_BYTES} bytes.`);
|
|
160
|
-
}
|
|
161
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
162
|
-
fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
// ── Persistent task board ─────────────────────────────────────────
|
|
166
|
-
|
|
167
|
-
/** Root of all per-session board dirs (`~/.pi/agent/tasks/`). */
|
|
168
|
-
export function tasksRoot(): string {
|
|
169
|
-
return path.join(getAgentDir(), "tasks");
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export function boardDir(sessionFile: string | undefined, cwd: string): string {
|
|
173
|
-
return path.join(tasksRoot(), sessionKey(sessionFile, cwd));
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
export function boardFilePath(sessionFile: string | undefined, cwd: string): string {
|
|
177
|
-
return path.join(boardDir(sessionFile, cwd), "board.json");
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
export function claimsDir(boardDirectory: string): string {
|
|
181
|
-
return path.join(boardDirectory, "claims");
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function submissionsDir(boardDirectory: string): string {
|
|
185
|
-
return path.join(boardDirectory, "submissions");
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function readBoardFile(file: string): { tasks: Record<string, import("./types").BoardTask> } | undefined {
|
|
189
|
-
let raw: string;
|
|
190
|
-
try {
|
|
191
|
-
raw = fs.readFileSync(file, "utf-8");
|
|
192
|
-
} catch (error) {
|
|
193
|
-
// An absent board is a fresh session, not a data-loss event.
|
|
194
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
|
|
195
|
-
throw new Error(`Agent Teams Work snapshot at ${file} could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
196
|
-
}
|
|
197
|
-
let parsed: { runtimeVersion?: number; tasks?: Record<string, import("./types").BoardTask> };
|
|
198
|
-
try {
|
|
199
|
-
parsed = JSON.parse(raw) as { runtimeVersion?: number; tasks?: Record<string, import("./types").BoardTask> };
|
|
200
|
-
} catch {
|
|
201
|
-
// A present but unreadable board must never be treated as an empty board:
|
|
202
|
-
// callers archive the preserved file instead of silently losing work.
|
|
203
|
-
throw new Error(`Agent Teams Work snapshot at ${file} is not valid JSON; refusing to treat it as an empty board.`);
|
|
204
|
-
}
|
|
205
|
-
if (parsed.tasks && parsed.runtimeVersion !== TEAM_RUNTIME_VERSION) {
|
|
206
|
-
throw new Error(`Incompatible Agent Teams runtime snapshot version ${String(parsed.runtimeVersion)}; expected ${TEAM_RUNTIME_VERSION}.`);
|
|
207
|
-
}
|
|
208
|
-
return parsed.tasks ? { tasks: parsed.tasks } : undefined;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
export function writeBoardFile(file: string, tasks: Record<string, import("./types").BoardTask>): void {
|
|
212
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
213
|
-
writeJsonAtomic(file, { runtimeVersion: TEAM_RUNTIME_VERSION, tasks });
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Express a board intent through an exclusive-create marker file. Returns
|
|
218
|
-
* true exactly when this caller won the race for the taskId.
|
|
219
|
-
*
|
|
220
|
-
* Publication is atomic: the record is fully written to a private temp file
|
|
221
|
-
* and then hard-linked into place, so `link()` still fails with EEXIST for a
|
|
222
|
-
* loser while a concurrent reader can never observe a partial record.
|
|
223
|
-
*/
|
|
224
|
-
export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent): boolean {
|
|
225
|
-
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
226
|
-
const file = path.join(dir, `${safeName(taskId)}.json`);
|
|
227
|
-
const payload = JSON.stringify(intent);
|
|
228
|
-
if (Buffer.byteLength(payload, "utf-8") > MAX_INTENT_BYTES) {
|
|
229
|
-
throw new Error(`Task intent exceeds ${MAX_INTENT_BYTES} bytes.`);
|
|
230
|
-
}
|
|
231
|
-
const tmp = path.join(dir, `.${safeName(taskId)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
232
|
-
try {
|
|
233
|
-
fs.writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
234
|
-
fs.linkSync(tmp, file);
|
|
235
|
-
return true;
|
|
236
|
-
} catch (error) {
|
|
237
|
-
if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
|
|
238
|
-
throw error;
|
|
239
|
-
} finally {
|
|
240
|
-
fs.rmSync(tmp, { force: true });
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** An unparseable intent younger than this may still be mid-publish elsewhere. */
|
|
245
|
-
export const INTENT_PUBLISH_GRACE_MS = 30_000;
|
|
246
|
-
|
|
247
|
-
function olderThan(file: string, milliseconds: number): boolean {
|
|
248
|
-
try {
|
|
249
|
-
return Date.now() - fs.statSync(file).mtimeMs > milliseconds;
|
|
250
|
-
} catch {
|
|
251
|
-
return true;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* Drain one pending intent file. Malformed records are consumed and reported
|
|
257
|
-
* so one broken file can never block the queue — but a record that cannot be
|
|
258
|
-
* parsed yet is retried while it is younger than the publish grace, because a
|
|
259
|
-
* destroyed in-flight intent would leave its author waiting forever.
|
|
260
|
-
*/
|
|
261
|
-
export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?: string } {
|
|
262
|
-
let entries: string[] = [];
|
|
263
|
-
try {
|
|
264
|
-
entries = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
265
|
-
} catch {
|
|
266
|
-
return {};
|
|
267
|
-
}
|
|
268
|
-
for (const name of entries) {
|
|
269
|
-
const file = path.join(dir, name);
|
|
270
|
-
let raw: string;
|
|
271
|
-
try {
|
|
272
|
-
raw = fs.readFileSync(file, "utf-8");
|
|
273
|
-
} catch {
|
|
274
|
-
// Raced with a concurrent publish or removal; the next tick retries.
|
|
275
|
-
continue;
|
|
276
|
-
}
|
|
277
|
-
let parsed: Partial<TaskIntent>;
|
|
278
|
-
try {
|
|
279
|
-
parsed = JSON.parse(raw) as Partial<TaskIntent>;
|
|
280
|
-
} catch {
|
|
281
|
-
if (!olderThan(file, INTENT_PUBLISH_GRACE_MS)) continue;
|
|
282
|
-
fs.rmSync(file, { force: true });
|
|
283
|
-
return { diagnostic: `unreadable task intent "${name}" was consumed` };
|
|
284
|
-
}
|
|
285
|
-
fs.rmSync(file, { force: true });
|
|
286
|
-
if (
|
|
287
|
-
typeof parsed.taskId !== "string" || parsed.taskId.trim() === ""
|
|
288
|
-
|| typeof parsed.worker !== "string" || parsed.worker.trim() === ""
|
|
289
|
-
|| typeof parsed.spawnId !== "string" || parsed.spawnId.trim() === ""
|
|
290
|
-
|| typeof parsed.timestamp !== "number" || !Number.isFinite(parsed.timestamp)
|
|
291
|
-
) {
|
|
292
|
-
return { diagnostic: `malformed task intent "${name}" was consumed (requires non-empty taskId/worker/spawnId and finite timestamp)` };
|
|
293
|
-
}
|
|
294
|
-
if (parsed.status !== undefined && parsed.status !== "completed" && parsed.status !== "failed") {
|
|
295
|
-
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission status)` };
|
|
296
|
-
}
|
|
297
|
-
if (typeof parsed.result !== "undefined" && typeof parsed.result !== "string") {
|
|
298
|
-
return { diagnostic: `malformed task intent "${name}" was consumed (invalid submission result)` };
|
|
299
|
-
}
|
|
300
|
-
return { intent: parsed as TaskIntent };
|
|
301
|
-
}
|
|
302
|
-
return {};
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
// ── Atomic JSON IO ────────────────────────────────────────────────
|
|
306
|
-
|
|
307
|
-
function writeJsonAtomic(file: string, value: unknown): void {
|
|
308
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
309
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
310
|
-
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
311
|
-
fs.renameSync(tmp, file);
|
|
95
|
+
appendJsonlLine(file, event, { label: "Worker event" });
|
|
312
96
|
}
|
|
313
97
|
|
|
314
98
|
// ── Expired runtime-dir cleanup ───────────────────────────────────
|
|
@@ -324,7 +108,7 @@ export function removeSessionStateDir(sessionFile: string | undefined, cwd: stri
|
|
|
324
108
|
}
|
|
325
109
|
|
|
326
110
|
/**
|
|
327
|
-
* Sweep runtime dirs whose last write is older than maxAgeMs.
|
|
111
|
+
* Sweep runtime dirs whose last write is older than maxAgeMs. Tasks dirs are
|
|
328
112
|
* never swept. Called on session_start so abandoned sessions never pile up.
|
|
329
113
|
*/
|
|
330
114
|
export function cleanupExpiredStateDirs(maxAgeMs: number): number {
|