@llblab/pi-actors 0.20.2 → 0.22.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.
Files changed (41) hide show
  1. package/BACKLOG.md +34 -80
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +7 -1
  4. package/dist/index.js +11 -0
  5. package/dist/lib/actor-rooms.d.ts +1 -0
  6. package/dist/lib/actor-rooms.js +33 -1
  7. package/dist/lib/async-runs.d.ts +35 -1
  8. package/dist/lib/async-runs.js +318 -36
  9. package/dist/lib/command-templates.js +8 -1
  10. package/dist/lib/observability.d.ts +15 -0
  11. package/dist/lib/observability.js +103 -18
  12. package/dist/lib/recipe-discovery.js +13 -5
  13. package/dist/lib/recipe-references.js +137 -11
  14. package/dist/lib/runtime-notifier.d.ts +48 -0
  15. package/dist/lib/runtime-notifier.js +137 -0
  16. package/dist/lib/tools.js +18 -9
  17. package/docs/README.md +1 -1
  18. package/docs/actor-messages.md +8 -3
  19. package/docs/async-runs.md +7 -5
  20. package/docs/recipe-library.md +25 -8
  21. package/docs/template-recipes.md +37 -7
  22. package/docs/tool-registry.md +4 -3
  23. package/index.ts +14 -0
  24. package/lib/actor-rooms.ts +46 -1
  25. package/lib/async-runs.ts +433 -49
  26. package/lib/command-templates.ts +8 -1
  27. package/lib/observability.ts +133 -20
  28. package/lib/recipe-discovery.ts +21 -6
  29. package/lib/recipe-references.ts +141 -17
  30. package/lib/runtime-notifier.ts +207 -0
  31. package/lib/tools.ts +36 -13
  32. package/package.json +2 -3
  33. package/recipes/music-player.json +1 -1
  34. package/recipes/pipeline-room-swarm.json +1 -1
  35. package/scripts/coordinator.mjs +276 -135
  36. package/scripts/locker.mjs +87 -28
  37. package/scripts/music-player.mjs +401 -94
  38. package/scripts/validate-recipe.mjs +2 -2
  39. package/skills/actors/SKILL.md +10 -9
  40. package/skills/swarm/SKILL.md +1 -1
  41. package/index.js +0 -19
@@ -2,6 +2,7 @@
2
2
  import { spawn } from "node:child_process";
3
3
  import { existsSync } from "node:fs";
4
4
  import { mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
5
+ import { createConnection } from "node:net";
5
6
 
6
7
  function arg(name, fallback = "") {
7
8
  const prefix = `--${name}=`;
@@ -40,7 +41,8 @@ function parseRoles(raw) {
40
41
  }
41
42
 
42
43
  async function loadRoles(raw, path) {
43
- if (path.trim()) return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
44
+ if (path.trim())
45
+ return normalizeRoles(JSON.parse(await readFile(path, "utf8")));
44
46
  return parseRoles(raw);
45
47
  }
46
48
 
@@ -66,7 +68,8 @@ const STATE_LOCK_TIMEOUT_MS = 5000;
66
68
  async function waitForPath(path, timeoutMs = 5000) {
67
69
  const started = Date.now();
68
70
  while (!existsSync(path)) {
69
- if (Date.now() - started > timeoutMs) throw new Error(`timed out waiting for ${path}`);
71
+ if (Date.now() - started > timeoutMs)
72
+ throw new Error(`timed out waiting for ${path}`);
70
73
  await sleep(50);
71
74
  }
72
75
  }
@@ -78,7 +81,11 @@ async function acquireStateLock(parentDir, name, label) {
78
81
  while (true) {
79
82
  try {
80
83
  await mkdir(lockDir);
81
- await writeFile(`${lockDir}/owner.json`, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`, "utf8");
84
+ await writeFile(
85
+ `${lockDir}/owner.json`,
86
+ `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`,
87
+ "utf8",
88
+ );
82
89
  return async () => rm(lockDir, { recursive: true, force: true });
83
90
  } catch (error) {
84
91
  try {
@@ -99,13 +106,42 @@ async function acquireStateLock(parentDir, name, label) {
99
106
  }
100
107
 
101
108
  async function acquireBranchInboxLock(runId, branchName) {
102
- return acquireStateLock(`${runStateDir(runId)}/branches/${branchName}`, ".inbox.lock", `Branch inbox ${branchName}`);
109
+ return acquireStateLock(
110
+ `${runStateDir(runId)}/branches/${branchName}`,
111
+ ".inbox.lock",
112
+ `Branch inbox ${branchName}`,
113
+ );
114
+ }
115
+
116
+ async function readLockerControl(locker) {
117
+ const controlJson = `${locker.stateDir}/control.json`;
118
+ await waitForPath(controlJson);
119
+ return JSON.parse(await readFile(controlJson, "utf8"));
120
+ }
121
+
122
+ function writeNamedPipeLine(path, line) {
123
+ return new Promise((resolve, reject) => {
124
+ const socket = createConnection(path);
125
+ let settled = false;
126
+ const finish = (error) => {
127
+ if (settled) return;
128
+ settled = true;
129
+ if (error) reject(error);
130
+ else resolve();
131
+ };
132
+ socket.on("error", finish);
133
+ socket.on("connect", () => socket.end(line, () => finish()));
134
+ });
103
135
  }
104
136
 
105
137
  async function writeLockerMessage(locker, message) {
106
138
  if (!locker) return;
107
- await waitForPath(locker.controlPath);
108
- await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
139
+ const control = locker.control ?? (await readLockerControl(locker));
140
+ locker.control = control;
141
+ const line = `${JSON.stringify(message)}\n`;
142
+ if (control.type === "named-pipe")
143
+ await writeNamedPipeLine(control.path, line);
144
+ else await writeFile(control.path, line, { flag: "a" });
109
145
  await sleep(50);
110
146
  }
111
147
 
@@ -113,23 +149,31 @@ async function startLocker(config) {
113
149
  if (!config.locker) return undefined;
114
150
  const lockerStateDir = `${runStateDir(config.runId)}/locker`;
115
151
  await mkdir(lockerStateDir, { recursive: true });
116
- const child = spawn(new URL("./locker.mjs", import.meta.url).pathname, [
117
- "serve",
118
- "--state-dir",
119
- lockerStateDir,
120
- "--lease-ms",
121
- String(config.lockerLeaseMs),
122
- ], {
123
- env: { ...process.env, run_id: config.runId },
124
- stdio: ["ignore", "pipe", "pipe"],
125
- });
152
+ const child = spawn(
153
+ new URL("./locker.mjs", import.meta.url).pathname,
154
+ [
155
+ "serve",
156
+ "--state-dir",
157
+ lockerStateDir,
158
+ "--lease-ms",
159
+ String(config.lockerLeaseMs),
160
+ ],
161
+ {
162
+ env: { ...process.env, run_id: config.runId },
163
+ stdio: ["ignore", "pipe", "pipe"],
164
+ },
165
+ );
126
166
  child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
127
167
  child.stderr.on("data", (chunk) => process.stderr.write(`[locker] ${chunk}`));
128
- const locker = { child, controlPath: `${lockerStateDir}/control.fifo`, stateDir: lockerStateDir };
129
- await waitForPath(locker.controlPath);
168
+ const locker = { child, stateDir: lockerStateDir };
169
+ locker.control = await readLockerControl(locker);
130
170
  await writeLockerMessage(locker, {
131
171
  type: "lock.enqueue",
132
- body: { id: "coordinator-artifact", task: "Own final artifact synthesis", resources: [config.artifactPath || "artifact"] },
172
+ body: {
173
+ id: "coordinator-artifact",
174
+ task: "Own final artifact synthesis",
175
+ resources: [config.artifactPath || "artifact"],
176
+ },
133
177
  });
134
178
  return locker;
135
179
  }
@@ -147,7 +191,8 @@ async function stopLocker(locker) {
147
191
  function runPi(prompt, model, thinking) {
148
192
  return new Promise((resolve) => {
149
193
  const args = [
150
- "--tools", "inspect,message",
194
+ "--tools",
195
+ "inspect,message",
151
196
  "--no-context-files",
152
197
  "--no-skills",
153
198
  "--no-session",
@@ -163,10 +208,16 @@ function runPi(prompt, model, thinking) {
163
208
  const child = spawn("pi", args, { stdio: ["ignore", "pipe", "pipe"] });
164
209
  let stdout = "";
165
210
  let stderr = "";
166
- child.stdout.on("data", (chunk) => { stdout += chunk; });
167
- child.stderr.on("data", (chunk) => { stderr += chunk; });
211
+ child.stdout.on("data", (chunk) => {
212
+ stdout += chunk;
213
+ });
214
+ child.stderr.on("data", (chunk) => {
215
+ stderr += chunk;
216
+ });
168
217
  child.on("close", (code) => resolve({ code, stdout, stderr }));
169
- child.on("error", (error) => resolve({ code: 1, stdout, stderr: String(error) }));
218
+ child.on("error", (error) =>
219
+ resolve({ code: 1, stdout, stderr: String(error) }),
220
+ );
170
221
  });
171
222
  }
172
223
 
@@ -181,9 +232,15 @@ async function readRoomTranscript(config) {
181
232
  .map((line) => {
182
233
  try {
183
234
  const message = JSON.parse(line);
184
- const from = String(message.from || "unknown").replace(/^branch:[^/]+\//, "");
235
+ const from = String(message.from || "unknown").replace(
236
+ /^branch:[^/]+\//,
237
+ "",
238
+ );
185
239
  const type = String(message.type || "message");
186
- const body = typeof message.body === "string" ? message.body : JSON.stringify(message.body ?? "");
240
+ const body =
241
+ typeof message.body === "string"
242
+ ? message.body
243
+ : JSON.stringify(message.body ?? "");
187
244
  return `${from} [${type}]: ${body}`;
188
245
  } catch {
189
246
  return line;
@@ -206,7 +263,13 @@ async function synthesize(config, locker) {
206
263
  const transcript = await readRoomTranscript(config);
207
264
  const prompt = `Synthesize this transcript into a concise Markdown artifact. Mission: ${config.mission}. Include: Title, Consensus, Roles, Protocol, Final Artifact Shape, Next Actions, Open Questions. Use only the transcript evidence below.\n\nTRANSCRIPT:\n${transcript.slice(-24000)}`;
208
265
  const result = await runPi(prompt, config.model, config.thinking);
209
- await writeFile(config.artifactPath, result.stdout.trim() ? result.stdout : "# Synthesis Artifact\n\nNo synthesis output.\n", "utf8");
266
+ await writeFile(
267
+ config.artifactPath,
268
+ result.stdout.trim()
269
+ ? result.stdout
270
+ : "# Synthesis Artifact\n\nNo synthesis output.\n",
271
+ "utf8",
272
+ );
210
273
  if (locker) {
211
274
  await writeLockerMessage(locker, {
212
275
  type: "lock.complete",
@@ -223,73 +286,96 @@ async function synthesize(config, locker) {
223
286
  async function readInboxLines(inboxPath) {
224
287
  if (!existsSync(inboxPath)) return [];
225
288
  const content = await readFile(inboxPath, "utf8");
226
- return content.split("\n").filter(Boolean).map((line) => JSON.parse(line));
289
+ return content
290
+ .split("\n")
291
+ .filter(Boolean)
292
+ .map((line) => JSON.parse(line));
227
293
  }
228
294
 
229
295
  async function writeInboxMessages(inboxPath, messages) {
230
- await writeFile(inboxPath, messages.map((message) => JSON.stringify(message)).join("\n") + "\n", "utf8");
296
+ await writeFile(
297
+ inboxPath,
298
+ messages.map((message) => JSON.stringify(message)).join("\n") + "\n",
299
+ "utf8",
300
+ );
231
301
  }
232
302
 
233
- async function claimQueuedInboxMessages(runId, branchName) {
303
+ async function mutateBranchInbox(runId, branchName, mutate, fallback) {
234
304
  const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
235
305
  const releaseLock = await acquireBranchInboxLock(runId, branchName);
236
306
  try {
237
307
  const messages = await readInboxLines(inboxPath);
238
- const claimedAt = new Date().toISOString();
239
- const queuedMessages = [];
240
- const updated = messages.map((msg, index) => {
241
- if (msg.status !== "queued" && msg.status) return msg;
242
- const claimed = {
243
- ...msg,
244
- claimed_at: claimedAt,
245
- id: msg.id || `legacy-${Date.now()}-${index}`,
246
- status: "claimed",
247
- };
248
- queuedMessages.push(claimed);
249
- return claimed;
250
- });
251
- if (queuedMessages.length > 0) await writeInboxMessages(inboxPath, updated);
252
- return queuedMessages;
308
+ const result = mutate(messages);
309
+ if (result.messages) await writeInboxMessages(inboxPath, result.messages);
310
+ return result.value;
253
311
  } catch {
254
- return [];
312
+ return fallback;
255
313
  } finally {
256
314
  await releaseLock();
257
315
  }
258
316
  }
259
317
 
318
+ async function claimQueuedInboxMessages(runId, branchName) {
319
+ return mutateBranchInbox(
320
+ runId,
321
+ branchName,
322
+ (messages) => {
323
+ const claimedAt = new Date().toISOString();
324
+ const queuedMessages = [];
325
+ const updated = messages.map((msg, index) => {
326
+ if (msg.status !== "queued" && msg.status) return msg;
327
+ const claimed = {
328
+ ...msg,
329
+ claimed_at: claimedAt,
330
+ id: msg.id || `legacy-${Date.now()}-${index}`,
331
+ status: "claimed",
332
+ };
333
+ queuedMessages.push(claimed);
334
+ return claimed;
335
+ });
336
+ return {
337
+ messages: queuedMessages.length > 0 ? updated : undefined,
338
+ value: queuedMessages,
339
+ };
340
+ },
341
+ [],
342
+ );
343
+ }
344
+
260
345
  async function updateInboxMessagesStatus(runId, branchName, ids, status) {
261
- const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
262
- const releaseLock = await acquireBranchInboxLock(runId, branchName);
263
- try {
264
- const messages = await readInboxLines(inboxPath);
346
+ await mutateBranchInbox(runId, branchName, (messages) => {
265
347
  const idSet = new Set(ids);
266
348
  const updated = messages.map((msg) => {
267
349
  if (!msg.id || !idSet.has(msg.id)) return msg;
268
350
  return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
269
351
  });
270
- await writeInboxMessages(inboxPath, updated);
271
- } catch {
272
- // Best-effort write
273
- } finally {
274
- await releaseLock();
275
- }
352
+ return { messages: updated, value: undefined };
353
+ });
276
354
  }
277
355
 
278
356
  async function executeParticipantPrompt(role, basePrompt, config) {
279
357
  const branchName = role.name;
280
- const queuedMessages = await claimQueuedInboxMessages(config.runId, branchName);
358
+ const queuedMessages = await claimQueuedInboxMessages(
359
+ config.runId,
360
+ branchName,
361
+ );
281
362
 
282
363
  let finalPrompt = basePrompt;
283
364
  const claimedIds = [];
284
365
 
285
366
  if (queuedMessages.length > 0) {
286
- let inboxSection = "\n\nDIRECT INBOX MESSAGES FOR YOU (FIFO queue):\n";
367
+ let inboxSection =
368
+ "\n\nDIRECT INBOX MESSAGES FOR YOU (queued mailbox work):\n";
287
369
  for (const msg of queuedMessages) {
288
- const bodyText = typeof msg.body === "string" ? msg.body : JSON.stringify(msg.body ?? "");
370
+ const bodyText =
371
+ typeof msg.body === "string"
372
+ ? msg.body
373
+ : JSON.stringify(msg.body ?? "");
289
374
  inboxSection += `- From: ${msg.from || "unknown"} (Type: ${msg.type || "message"})\n Body: ${bodyText}\n`;
290
375
  if (msg.id) claimedIds.push(msg.id);
291
376
  }
292
- inboxSection += "\nPlease acknowledge and address these direct messages in your response.\n";
377
+ inboxSection +=
378
+ "\nPlease acknowledge and address these direct messages in your response.\n";
293
379
  finalPrompt += inboxSection;
294
380
  }
295
381
 
@@ -297,7 +383,12 @@ async function executeParticipantPrompt(role, basePrompt, config) {
297
383
 
298
384
  if (claimedIds.length > 0) {
299
385
  const finalStatus = result.code === 0 ? "handled" : "failed";
300
- await updateInboxMessagesStatus(config.runId, branchName, claimedIds, finalStatus);
386
+ await updateInboxMessagesStatus(
387
+ config.runId,
388
+ branchName,
389
+ claimedIds,
390
+ finalStatus,
391
+ );
301
392
  }
302
393
 
303
394
  return result;
@@ -308,8 +399,11 @@ async function participantRound(role, round, config) {
308
399
  const address = `branch:${config.runId}/${role.name}`;
309
400
  const prompt = `You are ${displayName} (${address}), ${role.persona}. Mission: ${config.mission}. Round ${round}/${config.rounds}. First call inspect target=${config.room} view=previews lines=30 and inspect target=${config.room} view=contacts. Then call message once to ${config.room} from ${address} type=chat.message. Body: 2-4 sentences that react to a named participant, propose the next coordination step, and refine the shared artifact. Use contacts for peer names and addresses. End stdout with summary <=160 chars.`;
310
401
  const result = await executeParticipantPrompt(role, prompt, config);
311
- process.stdout.write(`[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`);
312
- if (result.stderr.trim()) process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
402
+ process.stdout.write(
403
+ `[${role.name} round ${round}] code=${result.code}\n${result.stdout}\n`,
404
+ );
405
+ if (result.stderr.trim())
406
+ process.stderr.write(`[${role.name} round ${round}] ${result.stderr}\n`);
313
407
  }
314
408
 
315
409
  async function participantJoin(role, config) {
@@ -330,8 +424,11 @@ async function participantLeave(role, config) {
330
424
  async function runConsensus(config, locker) {
331
425
  await Promise.all(config.roles.map((role) => participantJoin(role, config)));
332
426
  for (let round = 1; round <= config.rounds; round += 1) {
333
- await Promise.all(config.roles.map((role) => participantRound(role, round, config)));
334
- if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
427
+ await Promise.all(
428
+ config.roles.map((role) => participantRound(role, round, config)),
429
+ );
430
+ if (round < config.rounds && config.delay > 0)
431
+ await sleep(config.delay * 1000);
335
432
  }
336
433
  await Promise.all(config.roles.map((role) => participantLeave(role, config)));
337
434
  }
@@ -342,7 +439,8 @@ async function runPipeline(config, locker) {
342
439
  await participantJoin(role, config);
343
440
  for (let round = 1; round <= config.rounds; round += 1) {
344
441
  await participantRound(role, round, config);
345
- if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
442
+ if (round < config.rounds && config.delay > 0)
443
+ await sleep(config.delay * 1000);
346
444
  }
347
445
  await participantLeave(role, config);
348
446
  }
@@ -350,14 +448,17 @@ async function runPipeline(config, locker) {
350
448
 
351
449
  // 3. fanout mode: run completely parallel independent processes
352
450
  async function runFanout(config, locker) {
353
- await Promise.all(config.roles.map(async (role) => {
354
- await participantJoin(role, config);
355
- for (let round = 1; round <= config.rounds; round += 1) {
356
- await participantRound(role, round, config);
357
- if (round < config.rounds && config.delay > 0) await sleep(config.delay * 1000);
358
- }
359
- await participantLeave(role, config);
360
- }));
451
+ await Promise.all(
452
+ config.roles.map(async (role) => {
453
+ await participantJoin(role, config);
454
+ for (let round = 1; round <= config.rounds; round += 1) {
455
+ await participantRound(role, round, config);
456
+ if (round < config.rounds && config.delay > 0)
457
+ await sleep(config.delay * 1000);
458
+ }
459
+ await participantLeave(role, config);
460
+ }),
461
+ );
361
462
  }
362
463
 
363
464
  // 4. pool mode: dynamic task pulling from the locker queue
@@ -369,58 +470,75 @@ async function runPool(config, locker) {
369
470
  for (let index = 1; index <= 3; index += 1) {
370
471
  await writeLockerMessage(locker, {
371
472
  type: "lock.enqueue",
372
- body: { id: `subtask-${index}`, task: `Execute subtask ${index} under mission: ${config.mission}`, resources: [`resource-${index}`] },
473
+ body: {
474
+ id: `subtask-${index}`,
475
+ task: `Execute subtask ${index} under mission: ${config.mission}`,
476
+ resources: [`resource-${index}`],
477
+ },
373
478
  });
374
479
  }
375
480
 
376
481
  // Workers concurrently poll task queue
377
- await Promise.all(config.roles.map(async (role) => {
378
- const address = `branch:${config.runId}/${role.name}`;
379
- await participantJoin(role, config);
380
-
381
- while (true) {
382
- // Dequeue/Claim a task from the locker
383
- await writeLockerMessage(locker, {
384
- type: "lock.claim",
385
- body: { owner: role.name },
386
- });
387
- await sleep(100);
388
-
389
- // Check locks and queue state from locker snapshot
390
- const lockerData = await readJsonFile(`${locker.stateDir}/locks.json`);
391
- const assignedTask = Object.values(lockerData).find(lock => lock.owner === role.name);
392
-
393
- if (!assignedTask) {
394
- // No task assigned or queue is empty
395
- break;
396
- }
397
-
398
- const taskId = assignedTask.task;
399
- const prompt = `You are ${role.name}, ${role.persona}. You have been assigned task ${taskId}: "${assignedTask.task}". Solve it as part of the overall mission: ${config.mission}. End your response with a clear summary.`;
400
- const result = await executeParticipantPrompt(role, prompt, config);
401
-
402
- process.stdout.write(`[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`);
482
+ await Promise.all(
483
+ config.roles.map(async (role) => {
484
+ const address = `branch:${config.runId}/${role.name}`;
485
+ await participantJoin(role, config);
486
+
487
+ while (true) {
488
+ // Dequeue/Claim a task from the locker
489
+ await writeLockerMessage(locker, {
490
+ type: "lock.claim",
491
+ body: { owner: role.name },
492
+ });
493
+ await sleep(100);
494
+
495
+ // Check locks and queue state from locker snapshot
496
+ const lockerData = await readJsonFile(`${locker.stateDir}/locks.json`);
497
+ const assignedTask = Object.values(lockerData).find(
498
+ (lock) => lock.owner === role.name,
499
+ );
500
+
501
+ if (!assignedTask) {
502
+ // No task assigned or queue is empty
503
+ break;
504
+ }
403
505
 
404
- // Report task completion back to locker and release resource locks
405
- await writeLockerMessage(locker, {
406
- type: "lock.complete",
407
- body: { id: taskId, result: result.stdout },
408
- });
409
- // Release any locks associated with this resource
410
- for (const [res, lock] of Object.entries(lockerData)) {
411
- if (lock.owner === role.name) {
412
- await writeLockerMessage(locker, {
413
- type: "lock.release",
414
- body: { resource: res },
415
- });
506
+ const taskId = assignedTask.task;
507
+ const prompt = `You are ${role.name}, ${role.persona}. You have been assigned task ${taskId}: "${assignedTask.task}". Solve it as part of the overall mission: ${config.mission}. End your response with a clear summary.`;
508
+ const result = await executeParticipantPrompt(role, prompt, config);
509
+
510
+ process.stdout.write(
511
+ `[${role.name} completed ${taskId}] code=${result.code}\n${result.stdout}\n`,
512
+ );
513
+
514
+ // Report task completion back to locker and release resource locks
515
+ await writeLockerMessage(locker, {
516
+ type: "lock.complete",
517
+ body: { id: taskId, result: result.stdout },
518
+ });
519
+ // Release any locks associated with this resource
520
+ for (const [res, lock] of Object.entries(lockerData)) {
521
+ if (lock.owner === role.name) {
522
+ await writeLockerMessage(locker, {
523
+ type: "lock.release",
524
+ body: { resource: res },
525
+ });
526
+ }
416
527
  }
417
528
  }
418
- }
419
529
 
420
- await participantLeave(role, config);
421
- }));
530
+ await participantLeave(role, config);
531
+ }),
532
+ );
422
533
  }
423
534
 
535
+ const coordinatorModes = {
536
+ consensus: runConsensus,
537
+ fanout: runFanout,
538
+ pipeline: runPipeline,
539
+ pool: runPool,
540
+ };
541
+
424
542
  async function readJsonFile(path) {
425
543
  try {
426
544
  return JSON.parse(await readFile(path, "utf8"));
@@ -430,20 +548,50 @@ async function readJsonFile(path) {
430
548
  }
431
549
 
432
550
  const defaultRoles = [
433
- { name: "mapper", persona: "systems mapper; tracks shared structure and dependencies" },
434
- { name: "memory", persona: "memory keeper; preserves decisions and unresolved questions" },
435
- { name: "risk", persona: "risk scout; challenges weak coordination and missing owners" },
436
- { name: "flow", persona: "flow designer; turns scattered ideas into process rhythm" },
437
- { name: "operator", persona: "operator; converts ideas into concrete next actions" },
438
- { name: "narrative", persona: "narrative synthesizer; keeps the artifact coherent" },
439
- { name: "interface", persona: "interface designer; makes outputs visible and usable" },
440
- { name: "facilitator", persona: "facilitator; asks for consensus and convergence" },
551
+ {
552
+ name: "mapper",
553
+ persona: "systems mapper; tracks shared structure and dependencies",
554
+ },
555
+ {
556
+ name: "memory",
557
+ persona: "memory keeper; preserves decisions and unresolved questions",
558
+ },
559
+ {
560
+ name: "risk",
561
+ persona: "risk scout; challenges weak coordination and missing owners",
562
+ },
563
+ {
564
+ name: "flow",
565
+ persona: "flow designer; turns scattered ideas into process rhythm",
566
+ },
567
+ {
568
+ name: "operator",
569
+ persona: "operator; converts ideas into concrete next actions",
570
+ },
571
+ {
572
+ name: "narrative",
573
+ persona: "narrative synthesizer; keeps the artifact coherent",
574
+ },
575
+ {
576
+ name: "interface",
577
+ persona: "interface designer; makes outputs visible and usable",
578
+ },
579
+ {
580
+ name: "facilitator",
581
+ persona: "facilitator; asks for consensus and convergence",
582
+ },
441
583
  ];
442
584
 
443
585
  const config = {
444
- runId: arg("run-id", process.env.run_id || process.env.RUN_ID || "coordinator-run"),
586
+ runId: arg(
587
+ "run-id",
588
+ process.env.run_id || process.env.RUN_ID || "coordinator-run",
589
+ ),
445
590
  mode: arg("mode", "consensus"), // "consensus" (default/swarm), "pipeline", "fanout", "pool"
446
- mission: arg("mission", "Coordinate a shared artifact and converge on next actions"),
591
+ mission: arg(
592
+ "mission",
593
+ "Coordinate a shared artifact and converge on next actions",
594
+ ),
447
595
  model: arg("model", ""),
448
596
  thinking: arg("thinking", "off"),
449
597
  roles: await loadRoles(arg("roles", ""), arg("roles-path", "")),
@@ -464,16 +612,9 @@ const failures = [];
464
612
  const locker = await startLocker(config);
465
613
 
466
614
  try {
467
- if (config.mode === "pipeline") {
468
- await runPipeline(config, locker);
469
- } else if (config.mode === "fanout") {
470
- await runFanout(config, locker);
471
- } else if (config.mode === "pool") {
472
- await runPool(config, locker);
473
- } else {
474
- // default: consensus / swarm
475
- await runConsensus(config, locker);
476
- }
615
+ const runMode = coordinatorModes[config.mode];
616
+ if (!runMode) throw new Error(`Unknown coordinator mode: ${config.mode}`);
617
+ await runMode(config, locker);
477
618
  await synthesize(config, locker);
478
619
  } catch (globalError) {
479
620
  failures.push(`Global coordinator error: ${globalError.message}`);