@fradser/pi-agent-teams 0.5.3 → 0.6.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/.memory/MEMORY.md +3 -1
- package/.memory/node-terminal-followups.md +3 -2
- package/.memory/team-centric-design.md +21 -0
- package/README.md +99 -63
- package/agents/observer.md +1 -1
- package/agents/reviewer.md +4 -2
- package/agents/specialist.md +1 -1
- package/agents/worker.md +3 -3
- package/package.json +3 -3
- package/src/activity.ts +86 -0
- package/src/agents.ts +58 -21
- package/src/follow-up-queue.ts +93 -26
- package/src/guidance.ts +81 -60
- package/src/index.ts +92 -28
- package/src/spawner.ts +194 -350
- package/src/state.ts +252 -565
- package/src/statefile.ts +209 -46
- package/src/team-machine.ts +946 -0
- package/src/tools.ts +177 -173
- package/src/types.ts +153 -189
- package/src/ui.ts +306 -230
- package/src/worker.ts +172 -34
- package/src/worktree.ts +20 -8
- package/.memory/run-centric-design.md +0 -22
- package/src/run-machine.ts +0 -606
- package/src/terminal.ts +0 -34
package/src/statefile.ts
CHANGED
|
@@ -1,32 +1,44 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared session files for the leader-owned
|
|
2
|
+
* Shared session files for the leader-owned snapshot, teammate outboxes and
|
|
3
|
+
* inboxes, and the persistent task board.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Runtime layout (removed at session shutdown):
|
|
6
|
+
* ~/.pi/agent/teammate/<sessionKey>/state.json leader-owned snapshot
|
|
7
|
+
* ~/.pi/agent/teammate/<sessionKey>/events/*.jsonl per-spawn report outboxes
|
|
8
|
+
* ~/.pi/agent/teammate/<sessionKey>/mail/*.jsonl peer inbox files
|
|
9
|
+
* ~/.pi/agent/teammate/<sessionKey>/roster.json living teammates, worker-readable
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
+
* Board layout (persists across restarts, never auto-cleaned):
|
|
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
|
|
17
|
+
* (atomic tmp+rename). Teammates append only to their own outbox, append to
|
|
18
|
+
* recipient inboxes, and express board intent through exclusive-create files.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
import * as crypto from "node:crypto";
|
|
14
22
|
import * as fs from "node:fs";
|
|
15
|
-
import * as os from "node:os";
|
|
16
23
|
import * as path from "node:path";
|
|
17
|
-
import {
|
|
18
|
-
import type {
|
|
24
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import type { TaskIntent, TeamState, WorkerEvent } from "./types.ts";
|
|
19
26
|
|
|
20
27
|
const MAX_WORKER_EVENT_BYTES = 64 * 1024;
|
|
28
|
+
const MAX_INBOX_MESSAGE_BYTES = 64 * 1024;
|
|
21
29
|
const MAX_OUTBOX_READ_BYTES = 256 * 1024;
|
|
30
|
+
const MAX_INTENT_BYTES = 16 * 1024;
|
|
22
31
|
|
|
23
|
-
export function
|
|
24
|
-
|
|
32
|
+
export function sessionKey(sessionFile: string | undefined, cwd: string): string {
|
|
33
|
+
return crypto
|
|
25
34
|
.createHash("sha256")
|
|
26
35
|
.update(sessionFile ?? cwd)
|
|
27
36
|
.digest("hex")
|
|
28
37
|
.slice(0, 16);
|
|
29
|
-
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function sessionStateDir(sessionFile: string | undefined, cwd: string): string {
|
|
41
|
+
return path.join(getAgentDir(), "teammate", sessionKey(sessionFile, cwd));
|
|
30
42
|
}
|
|
31
43
|
|
|
32
44
|
export function stateFilePath(sessionFile: string | undefined, cwd: string): string {
|
|
@@ -34,85 +46,236 @@ export function stateFilePath(sessionFile: string | undefined, cwd: string): str
|
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
/** Write the leader-owned debug snapshot atomically (tmp + rename). */
|
|
37
|
-
export function writeStateFile(file: string, state:
|
|
38
|
-
|
|
39
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
40
|
-
fs.writeFileSync(tmp, JSON.stringify(state), { mode: 0o600 });
|
|
41
|
-
fs.renameSync(tmp, file);
|
|
49
|
+
export function writeStateFile(file: string, state: TeamState): void {
|
|
50
|
+
writeJsonAtomic(file, state);
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
// ── Peer mail ─────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function safeName(name: string): string {
|
|
56
|
+
return encodeURIComponent(name);
|
|
47
57
|
}
|
|
48
58
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
fs.rmSync(workerOutboxPath(stateFile, workerName, runId), { force: true });
|
|
59
|
+
export function mailDir(stateFile: string): string {
|
|
60
|
+
return path.join(path.dirname(stateFile), "mail");
|
|
52
61
|
}
|
|
53
62
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
export function inboxPath(stateFile: string, teammateName: string): string {
|
|
64
|
+
return path.join(mailDir(stateFile), `inbox-${safeName(teammateName)}.jsonl`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function rosterPath(stateFile: string): string {
|
|
68
|
+
return path.join(path.dirname(stateFile), "roster.json");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Append one message to a teammate inbox. Sent means this write succeeded. */
|
|
72
|
+
export function appendInboxMessage(file: string, message: { id: string; from: string; subject: string; body: string }): void {
|
|
73
|
+
const record = `${JSON.stringify(message)}\n`;
|
|
74
|
+
if (Buffer.byteLength(record, "utf-8") > MAX_INBOX_MESSAGE_BYTES) {
|
|
75
|
+
throw new Error(`Message exceeds ${MAX_INBOX_MESSAGE_BYTES} bytes.`);
|
|
59
76
|
}
|
|
60
77
|
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
61
78
|
fs.appendFileSync(file, record, { encoding: "utf-8", mode: 0o600 });
|
|
62
79
|
}
|
|
63
80
|
|
|
64
|
-
/**
|
|
65
|
-
export function
|
|
81
|
+
/** Publish the worker-readable roster of living teammates. */
|
|
82
|
+
export function writeRoster(file: string, teammates: Array<{ name: string; agent: string; status: string }>): void {
|
|
83
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
84
|
+
writeJsonAtomic(file, { teammates });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Read the roster from inside a teammate process; unknown roster = empty. */
|
|
88
|
+
export function readRoster(file: string): Array<{ name: string; agent: string; status: string }> {
|
|
89
|
+
try {
|
|
90
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { teammates?: Array<{ name: string; agent: string; status: string }> };
|
|
91
|
+
return Array.isArray(parsed.teammates) ? parsed.teammates : [];
|
|
92
|
+
} catch {
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Read complete JSONL records after byteOffset (shared by outboxes and inboxes). */
|
|
98
|
+
export function readJsonlBatch(file: string, byteOffset: number): {
|
|
99
|
+
records: unknown[];
|
|
100
|
+
nextOffset: number;
|
|
101
|
+
diagnostics: string[];
|
|
102
|
+
} {
|
|
66
103
|
let fd: number | undefined;
|
|
67
104
|
try {
|
|
68
105
|
fd = fs.openSync(file, "r");
|
|
69
106
|
const size = fs.fstatSync(fd).size;
|
|
70
|
-
// A truncated/recreated
|
|
107
|
+
// A truncated/recreated file starts at zero; message ids make replay safe.
|
|
71
108
|
const offset = byteOffset > size ? 0 : Math.max(0, byteOffset);
|
|
72
109
|
const toRead = Math.min(MAX_OUTBOX_READ_BYTES, size - offset);
|
|
73
|
-
if (toRead === 0) return {
|
|
110
|
+
if (toRead === 0) return { records: [], nextOffset: offset, diagnostics: [] };
|
|
74
111
|
const raw = Buffer.allocUnsafe(toRead);
|
|
75
112
|
const bytesRead = fs.readSync(fd, raw, 0, toRead, offset);
|
|
76
113
|
const unread = raw.subarray(0, bytesRead);
|
|
77
114
|
const lastNewline = unread.lastIndexOf(0x0a);
|
|
78
115
|
if (lastNewline < 0) {
|
|
79
116
|
// A line larger than the batch cap is malformed for this protocol; skip
|
|
80
|
-
// this chunk so one
|
|
81
|
-
return {
|
|
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
|
+
};
|
|
82
123
|
}
|
|
83
124
|
const complete = unread.subarray(0, lastNewline).toString("utf-8");
|
|
84
|
-
const
|
|
125
|
+
const records: unknown[] = [];
|
|
126
|
+
const diagnostics: string[] = [];
|
|
85
127
|
for (const line of complete.split("\n")) {
|
|
86
128
|
if (!line.trim()) continue;
|
|
87
129
|
try {
|
|
88
|
-
|
|
130
|
+
records.push(JSON.parse(line));
|
|
89
131
|
} catch {
|
|
90
|
-
|
|
132
|
+
diagnostics.push("malformed JSON record was consumed");
|
|
91
133
|
}
|
|
92
134
|
}
|
|
93
|
-
return {
|
|
135
|
+
return { records, nextOffset: offset + lastNewline + 1, diagnostics };
|
|
94
136
|
} catch {
|
|
95
|
-
return {
|
|
137
|
+
return { records: [], nextOffset: 0, diagnostics: [] };
|
|
96
138
|
} finally {
|
|
97
139
|
if (fd !== undefined) fs.closeSync(fd);
|
|
98
140
|
}
|
|
99
141
|
}
|
|
100
142
|
|
|
101
|
-
// ──
|
|
143
|
+
// ── Report outboxes ───────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
/** Per-teammate append-only report log. Its filename cannot escape the dir. */
|
|
146
|
+
export function workerOutboxPath(stateFile: string, workerName: string, spawnId: string): string {
|
|
147
|
+
return path.join(path.dirname(stateFile), "events", `${safeName(workerName)}.${safeName(spawnId)}.jsonl`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Delete a drained per-spawn outbox after its final snapshot is published. */
|
|
151
|
+
export function removeWorkerOutbox(stateFile: string, workerName: string, spawnId: string): void {
|
|
152
|
+
fs.rmSync(workerOutboxPath(stateFile, workerName, spawnId), { force: true });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Append one teammate report event. Workers never replace leader state. */
|
|
156
|
+
export function appendWorkerEvent(file: string, event: WorkerEvent): void {
|
|
157
|
+
const record = `${JSON.stringify(event)}\n`;
|
|
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
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as { tasks?: Record<string, import("./types").BoardTask> };
|
|
191
|
+
return parsed.tasks ? { tasks: parsed.tasks } : undefined;
|
|
192
|
+
} catch {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function writeBoardFile(file: string, tasks: Record<string, import("./types").BoardTask>): void {
|
|
198
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
199
|
+
writeJsonAtomic(file, { tasks });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Express a board intent through an exclusive-create marker file. Returns
|
|
204
|
+
* true exactly when this caller won the race for the taskId.
|
|
205
|
+
*/
|
|
206
|
+
export function createTaskIntent(dir: string, taskId: string, intent: TaskIntent): boolean {
|
|
207
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
208
|
+
const file = path.join(dir, `${safeName(taskId)}.json`);
|
|
209
|
+
const payload = JSON.stringify(intent);
|
|
210
|
+
if (Buffer.byteLength(payload, "utf-8") > MAX_INTENT_BYTES) {
|
|
211
|
+
throw new Error(`Task intent exceeds ${MAX_INTENT_BYTES} bytes.`);
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
fs.writeFileSync(file, payload, { encoding: "utf-8", mode: 0o600, flag: "wx" });
|
|
215
|
+
return true;
|
|
216
|
+
} catch (error) {
|
|
217
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
218
|
+
if (code === "EEXIST") return false;
|
|
219
|
+
throw error;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Drain one pending intent file. Malformed records are consumed and reported
|
|
225
|
+
* so one broken file can never block the queue.
|
|
226
|
+
*/
|
|
227
|
+
export function takeTaskIntent(dir: string): { intent?: TaskIntent; diagnostic?: string } {
|
|
228
|
+
let entries: string[] = [];
|
|
229
|
+
try {
|
|
230
|
+
entries = fs.readdirSync(dir).filter((name) => name.endsWith(".json")).sort();
|
|
231
|
+
} catch {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
for (const name of entries) {
|
|
235
|
+
const file = path.join(dir, name);
|
|
236
|
+
try {
|
|
237
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf-8")) as Partial<TaskIntent>;
|
|
238
|
+
fs.rmSync(file, { force: true });
|
|
239
|
+
if (
|
|
240
|
+
typeof parsed.taskId !== "string"
|
|
241
|
+
|| typeof parsed.worker !== "string"
|
|
242
|
+
|| typeof parsed.spawnId !== "string"
|
|
243
|
+
) {
|
|
244
|
+
return { diagnostic: `malformed task intent "${name}" was consumed (missing taskId/worker/spawnId)` };
|
|
245
|
+
}
|
|
246
|
+
return { intent: parsed as TaskIntent };
|
|
247
|
+
} catch {
|
|
248
|
+
fs.rmSync(file, { force: true });
|
|
249
|
+
return { diagnostic: `unreadable task intent "${name}" was consumed` };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ── Atomic JSON IO ────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
function writeJsonAtomic(file: string, value: unknown): void {
|
|
258
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
259
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
260
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
261
|
+
fs.renameSync(tmp, file);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── Expired runtime-dir cleanup ───────────────────────────────────
|
|
102
265
|
|
|
103
|
-
/** Root of all per-session teammate
|
|
266
|
+
/** Root of all per-session teammate runtime dirs (`~/.pi/agent/teammate/`). */
|
|
104
267
|
export function stateDirsRoot(): string {
|
|
105
|
-
return path.join(
|
|
268
|
+
return path.join(getAgentDir(), "teammate");
|
|
106
269
|
}
|
|
107
270
|
|
|
108
|
-
/** Remove the current session's
|
|
271
|
+
/** Remove the current session's runtime dir (called on session_shutdown). */
|
|
109
272
|
export function removeSessionStateDir(sessionFile: string | undefined, cwd: string): void {
|
|
110
273
|
fs.rmSync(sessionStateDir(sessionFile, cwd), { recursive: true, force: true });
|
|
111
274
|
}
|
|
112
275
|
|
|
113
276
|
/**
|
|
114
|
-
* Sweep
|
|
115
|
-
*
|
|
277
|
+
* Sweep runtime dirs whose last write is older than maxAgeMs. Board dirs are
|
|
278
|
+
* never swept. Called on session_start so abandoned sessions never pile up.
|
|
116
279
|
*/
|
|
117
280
|
export function cleanupExpiredStateDirs(maxAgeMs: number): number {
|
|
118
281
|
const root = stateDirsRoot();
|