@llblab/pi-actors 0.20.2 → 0.21.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.
@@ -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}=`;
@@ -102,10 +103,34 @@ async function acquireBranchInboxLock(runId, branchName) {
102
103
  return acquireStateLock(`${runStateDir(runId)}/branches/${branchName}`, ".inbox.lock", `Branch inbox ${branchName}`);
103
104
  }
104
105
 
106
+ async function readLockerControl(locker) {
107
+ const controlJson = `${locker.stateDir}/control.json`;
108
+ await waitForPath(controlJson);
109
+ return JSON.parse(await readFile(controlJson, "utf8"));
110
+ }
111
+
112
+ function writeNamedPipeLine(path, line) {
113
+ return new Promise((resolve, reject) => {
114
+ const socket = createConnection(path);
115
+ let settled = false;
116
+ const finish = (error) => {
117
+ if (settled) return;
118
+ settled = true;
119
+ if (error) reject(error);
120
+ else resolve();
121
+ };
122
+ socket.on("error", finish);
123
+ socket.on("connect", () => socket.end(line, () => finish()));
124
+ });
125
+ }
126
+
105
127
  async function writeLockerMessage(locker, message) {
106
128
  if (!locker) return;
107
- await waitForPath(locker.controlPath);
108
- await writeFile(locker.controlPath, `${JSON.stringify(message)}\n`, { flag: "a" });
129
+ const control = locker.control ?? await readLockerControl(locker);
130
+ locker.control = control;
131
+ const line = `${JSON.stringify(message)}\n`;
132
+ if (control.type === "named-pipe") await writeNamedPipeLine(control.path, line);
133
+ else await writeFile(control.path, line, { flag: "a" });
109
134
  await sleep(50);
110
135
  }
111
136
 
@@ -125,8 +150,8 @@ async function startLocker(config) {
125
150
  });
126
151
  child.stdout.on("data", (chunk) => process.stdout.write(`[locker] ${chunk}`));
127
152
  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);
153
+ const locker = { child, stateDir: lockerStateDir };
154
+ locker.control = await readLockerControl(locker);
130
155
  await writeLockerMessage(locker, {
131
156
  type: "lock.enqueue",
132
157
  body: { id: "coordinator-artifact", task: "Own final artifact synthesis", resources: [config.artifactPath || "artifact"] },
@@ -230,11 +255,23 @@ async function writeInboxMessages(inboxPath, messages) {
230
255
  await writeFile(inboxPath, messages.map((message) => JSON.stringify(message)).join("\n") + "\n", "utf8");
231
256
  }
232
257
 
233
- async function claimQueuedInboxMessages(runId, branchName) {
258
+ async function mutateBranchInbox(runId, branchName, mutate, fallback) {
234
259
  const inboxPath = `${runStateDir(runId)}/branches/${branchName}/inbox.jsonl`;
235
260
  const releaseLock = await acquireBranchInboxLock(runId, branchName);
236
261
  try {
237
262
  const messages = await readInboxLines(inboxPath);
263
+ const result = mutate(messages);
264
+ if (result.messages) await writeInboxMessages(inboxPath, result.messages);
265
+ return result.value;
266
+ } catch {
267
+ return fallback;
268
+ } finally {
269
+ await releaseLock();
270
+ }
271
+ }
272
+
273
+ async function claimQueuedInboxMessages(runId, branchName) {
274
+ return mutateBranchInbox(runId, branchName, (messages) => {
238
275
  const claimedAt = new Date().toISOString();
239
276
  const queuedMessages = [];
240
277
  const updated = messages.map((msg, index) => {
@@ -248,31 +285,22 @@ async function claimQueuedInboxMessages(runId, branchName) {
248
285
  queuedMessages.push(claimed);
249
286
  return claimed;
250
287
  });
251
- if (queuedMessages.length > 0) await writeInboxMessages(inboxPath, updated);
252
- return queuedMessages;
253
- } catch {
254
- return [];
255
- } finally {
256
- await releaseLock();
257
- }
288
+ return {
289
+ messages: queuedMessages.length > 0 ? updated : undefined,
290
+ value: queuedMessages,
291
+ };
292
+ }, []);
258
293
  }
259
294
 
260
295
  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);
296
+ await mutateBranchInbox(runId, branchName, (messages) => {
265
297
  const idSet = new Set(ids);
266
298
  const updated = messages.map((msg) => {
267
299
  if (!msg.id || !idSet.has(msg.id)) return msg;
268
300
  return { ...msg, [`${status}_at`]: new Date().toISOString(), status };
269
301
  });
270
- await writeInboxMessages(inboxPath, updated);
271
- } catch {
272
- // Best-effort write
273
- } finally {
274
- await releaseLock();
275
- }
302
+ return { messages: updated, value: undefined };
303
+ });
276
304
  }
277
305
 
278
306
  async function executeParticipantPrompt(role, basePrompt, config) {
@@ -421,6 +449,13 @@ async function runPool(config, locker) {
421
449
  }));
422
450
  }
423
451
 
452
+ const coordinatorModes = {
453
+ consensus: runConsensus,
454
+ fanout: runFanout,
455
+ pipeline: runPipeline,
456
+ pool: runPool,
457
+ };
458
+
424
459
  async function readJsonFile(path) {
425
460
  try {
426
461
  return JSON.parse(await readFile(path, "utf8"));
@@ -464,16 +499,9 @@ const failures = [];
464
499
  const locker = await startLocker(config);
465
500
 
466
501
  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
- }
502
+ const runMode = coordinatorModes[config.mode];
503
+ if (!runMode) throw new Error(`Unknown coordinator mode: ${config.mode}`);
504
+ await runMode(config, locker);
477
505
  await synthesize(config, locker);
478
506
  } catch (globalError) {
479
507
  failures.push(`Global coordinator error: ${globalError.message}`);
@@ -8,7 +8,9 @@ import {
8
8
  writeFileSync,
9
9
  } from "node:fs";
10
10
  import { dirname, join, resolve } from "node:path";
11
+ import { createHash } from "node:crypto";
11
12
  import { spawnSync } from "node:child_process";
13
+ import { createServer } from "node:net";
12
14
  import readline from "node:readline";
13
15
 
14
16
  function parseArgs(argv) {
@@ -49,6 +51,17 @@ function writeJson(path, value) {
49
51
  mkdirSync(dirname(path), { recursive: true });
50
52
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
51
53
  }
54
+ function getControlEndpoint() {
55
+ if (process.platform !== "win32") return { path: controlPath, type: "fifo" };
56
+ const hash = createHash("sha256").update(resolve(stateDir)).digest("hex").slice(0, 20);
57
+ return { path: `\\\\.\\pipe\\pi-actors-locker-${hash}`, type: "named-pipe" };
58
+ }
59
+ function writeControlEndpoint(endpoint) {
60
+ writeJson(join(stateDir, "control.json"), endpoint);
61
+ const runPath = join(stateDir, "run.json");
62
+ const run = readJson(runPath, undefined);
63
+ if (run && typeof run === "object") writeJson(runPath, { ...run, control: endpoint });
64
+ }
52
65
  function journal(event, data = {}) {
53
66
  appendFileSync(
54
67
  journalPath,
@@ -244,30 +257,55 @@ if (mode === "snapshot") {
244
257
  process.exit(0);
245
258
  }
246
259
 
247
- if (!existsSync(controlPath)) {
248
- const result = spawnSync("mkfifo", [controlPath]);
249
- if (result.status !== 0)
250
- throw new Error(`mkfifo failed: ${result.stderr?.toString?.() ?? ""}`);
260
+ function handleLine(line) {
261
+ const message = normalizeMessage(line);
262
+ if (!message) return;
263
+ try {
264
+ handle(message);
265
+ } catch (error) {
266
+ const text = error instanceof Error ? error.message : String(error);
267
+ journal("lock.error", { error: text });
268
+ outbox("lock.error", text, { error: text }, "error");
269
+ }
251
270
  }
252
- writeJson(queuePath, readJson(queuePath, { items: [] }));
253
- writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
254
- journal("lock.started", { leaseMs });
255
- outbox("lock.started", "Locker ready", { leaseMs });
256
271
 
257
- while (true) {
258
- const stream = await import("node:fs").then((fs) =>
259
- fs.createReadStream(controlPath, { encoding: "utf8" }),
260
- );
261
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
262
- for await (const line of rl) {
263
- const message = normalizeMessage(line);
264
- if (!message) continue;
265
- try {
266
- handle(message);
267
- } catch (error) {
268
- const text = error instanceof Error ? error.message : String(error);
269
- journal("lock.error", { error: text });
270
- outbox("lock.error", text, { error: text }, "error");
271
- }
272
+ async function serveFifo(endpoint) {
273
+ if (!existsSync(endpoint.path)) {
274
+ const result = spawnSync("mkfifo", [endpoint.path]);
275
+ if (result.status !== 0)
276
+ throw new Error(`mkfifo failed: ${result.stderr?.toString?.() ?? ""}`);
277
+ }
278
+ writeControlEndpoint(endpoint);
279
+ while (true) {
280
+ const stream = await import("node:fs").then((fs) =>
281
+ fs.createReadStream(endpoint.path, { encoding: "utf8" }),
282
+ );
283
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
284
+ for await (const line of rl) handleLine(line);
272
285
  }
273
286
  }
287
+
288
+ async function serveNamedPipe(endpoint) {
289
+ rmSync(endpoint.path, { force: true });
290
+ const server = createServer((socket) => {
291
+ const rl = readline.createInterface({ input: socket, crlfDelay: Infinity });
292
+ rl.on("line", handleLine);
293
+ });
294
+ await new Promise((resolveReady, rejectReady) => {
295
+ server.once("error", rejectReady);
296
+ server.listen(endpoint.path, () => {
297
+ server.off("error", rejectReady);
298
+ writeControlEndpoint(endpoint);
299
+ resolveReady();
300
+ });
301
+ });
302
+ await new Promise(() => {});
303
+ }
304
+
305
+ const endpoint = getControlEndpoint();
306
+ writeJson(queuePath, readJson(queuePath, { items: [] }));
307
+ writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
308
+ journal("lock.started", { leaseMs, control: endpoint.type });
309
+ outbox("lock.started", "Locker ready", { leaseMs, control: endpoint.type });
310
+ if (endpoint.type === "named-pipe") await serveNamedPipe(endpoint);
311
+ else await serveFifo(endpoint);
@@ -26,7 +26,7 @@ function usage() {
26
26
  console.error(`Usage:
27
27
  validate-recipe.mjs <recipe-file-or-dir> [--all]
28
28
 
29
- Validates one template recipe file, or all *.json files in a directory when --all is set.`);
29
+ Validates one template recipe file, or all *.json/*.md files in a directory when --all is set.`);
30
30
  }
31
31
 
32
32
  function expandPath(value) {
@@ -56,7 +56,7 @@ function recipeFiles(target, all) {
56
56
  throw new Error(`Recipe path is not a file or directory: ${target}`);
57
57
  if (!all) throw new Error("Directory validation requires --all.");
58
58
  return readdirSync(target)
59
- .filter((file) => file.endsWith(".json"))
59
+ .filter((file) => file.endsWith(".json") || file.endsWith(".md"))
60
60
  .sort((a, b) => a.localeCompare(b))
61
61
  .map((file) => resolve(target, file));
62
62
  }
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.20.2
5
+ version: 0.21.0
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -228,11 +228,11 @@ Priority for same-id recipes:
228
228
  1. No recipe: no capability.
229
229
  2. Packaged pi-actors recipe: standard-library declarative actor component.
230
230
  3. Explicit ad hoc user recipe file outside `~/.pi/agent/recipes`.
231
- 4. User recipe in `~/.pi/agent/recipes/*.json`: highest-priority operator tool surface.
231
+ 4. User recipe in `~/.pi/agent/recipes/*.json` or `*.md`: highest-priority operator tool surface.
232
232
 
233
- Only matching filename ids compete. Higher priority shadows lower priority. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
233
+ Only matching filename ids compete. Higher priority shadows lower priority; within one priority layer, same-id JSON shadows Markdown. An invalid or `disabled: true` higher-priority recipe blocks fallback so the agent does not silently run standard-library behavior when a user override is broken or intentionally disabled.
234
234
 
235
- Muscle-memory lens: `~/.pi/agent/recipes/*.json` is the agent's capability memory. Every recipe in that directory becomes an easy-to-call tool automatically and survives into later sessions. Agents grow this memory either by calling `register_tool`, which writes recipe files there under the hood, or by deliberately editing those recipe files. Treat this directory like `MEMORY.md` for executable habits: useful local patterns belong there; packaged recipes elsewhere are reusable components, not tools.
235
+ Muscle-memory lens: `~/.pi/agent/recipes/*.json` and `*.md` are the agent's capability memory. Every recipe in that directory becomes an easy-to-call tool automatically and survives into later sessions. Agents grow this memory either by calling `register_tool`, which writes recipe files there under the hood, or by deliberately editing those recipe files. Treat this directory like `MEMORY.md` for executable habits: useful local patterns belong there; packaged recipes elsewhere are reusable components, not tools.
236
236
 
237
237
  Usage lens: user recipes may carry extension-maintained launch metadata such as `usage.calls` and `usage.last_called`. The extension increments the counter when it starts that concrete recipe; agents should not hand-edit counters as part of normal recipe maintenance. Treat usage as evidence for usefulness analysis: heavily used recipes are good candidates for promotion, documentation, or stronger tests; unused recipes are cleanup candidates. Do not use failure counts as a primary usefulness signal because failures may reflect bad caller judgment rather than bad recipes. Do not delete or demote solely from counters without operator approval.
238
238
 
@@ -240,7 +240,7 @@ Cleanup rule: periodically inspect `~/.pi/agent/recipes` as the live muscle-memo
240
240
 
241
241
  ## Registered Tools
242
242
 
243
- `register_tool` persists trusted local capabilities as recipe files in `~/.pi/agent/recipes/*.json`.
243
+ `register_tool` persists trusted local capabilities as recipe files in `~/.pi/agent/recipes/*.json`; hand-authored Markdown recipes in the same directory are also discovered as tools.
244
244
 
245
245
  Use it when a command/template/recipe should become durable agent muscle memory. Prefer typed args or placeholder-derived args; use `update=true` for replacement and `template=null` or `template=""` for deletion. `register_tool` should create/update/delete recipe files in the user recipe root; direct file editing is allowed but is the lower-level path.
246
246
 
@@ -250,7 +250,7 @@ Tool templates may be:
250
250
  - A file-backed recipe name/path.
251
251
  - A complete recipe body, optionally `async: true`.
252
252
 
253
- The user recipe root is the default tool set by location; packaged recipes are lower-priority standard-library components and are not tools unless copied or registered into the agent recipe root. Ideal runtime behavior is reactive: create/edit/delete recipe files, validate them, then connect valid tools or surface diagnostics without requiring agents to hand-maintain a separate registry.
253
+ The user recipe root is the default tool set by location. It accepts canonical JSON recipes and literate Markdown recipes with frontmatter plus fenced `template`/`json recipe` blocks; same-id JSON shadows Markdown in the same priority layer. Packaged recipes are lower-priority standard-library components and are not tools unless copied or registered into the agent recipe root. Ideal runtime behavior is reactive: create/edit/delete recipe files, validate them, then connect valid tools or surface diagnostics without requiring agents to hand-maintain a separate registry.
254
254
 
255
255
  ## Recipe Navigator
256
256
 
@@ -258,8 +258,8 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
258
258
 
259
259
  ### Coordination and Services
260
260
 
261
- - [`coordinator-locker`](../../recipes/coordinator-locker.json): queue + acquire/renew/release lease locks + journaled coordinator messages.
262
- - [`locker`](../../recipes/locker.json): modular queue + acquire/renew/release lease locks + journaled locker messages.
261
+ - [`coordinator-locker`](../../recipes/coordinator-locker.json): queue + acquire/renew/release lease locks + journaled coordinator messages + platform-adapted control metadata.
262
+ - [`locker`](../../recipes/locker.json): modular queue + acquire/renew/release lease locks + journaled locker messages + platform-adapted control metadata.
263
263
  - [`utility-coordinator-lock-snapshot`](../../recipes/utility-coordinator-lock-snapshot.json): one-shot JSON snapshot of a coordinator-locker state directory.
264
264
  - [`music-player`](../../recipes/music-player.json): background local/URL/directory/playlist playback actor controlled by messages.
265
265
 
@@ -280,7 +280,7 @@ Use packaged recipes by name with `spawn file=<name>` for async actors, or regis
280
280
  - [`pipeline-docs-maintenance`](../../recipes/pipeline-docs-maintenance.json): docs index/review/planning → maintenance artifact.
281
281
  - Artifacts: [`pipeline-artifact-report`](../../recipes/pipeline-artifact-report.json), [`pipeline-artifact-write`](../../recipes/pipeline-artifact-write.json), [`pipeline-artifact-bundle`](../../recipes/pipeline-artifact-bundle.json).
282
282
  - Review gates: [`pipeline-quorum-review`](../../recipes/pipeline-quorum-review.json), [`pipeline-review-readiness`](../../recipes/pipeline-review-readiness.json).
283
- - Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
283
+ - Task-first workflows: [`pipeline-architect-coordinator`](../../recipes/pipeline-architect-coordinator.json), [`pipeline-research-synthesis`](../../recipes/pipeline-research-synthesis.json), [`pipeline-development-tasking`](../../recipes/pipeline-development-tasking.json), [`pipeline-checkpoint-continuation`](../../recipes/pipeline-checkpoint-continuation.json), [`pipeline-media-library`](../../recipes/pipeline-media-library.json), [`pipeline-room-swarm`](../../recipes/pipeline-room-swarm.json). For room swarms, choose `mode` from `consensus`, `pipeline`, `fanout`, or `pool`; prefer `roles_path` for custom role JSON and keep role `name` ASCII-safe for branch addresses. Use `locker=true` when the swarm needs a coordinator-locker-backed artifact lock and journal.
284
284
 
285
285
  ### Utilities
286
286
 
@@ -339,6 +339,7 @@ Keep the split clean: methodology chooses coordination shape; pi-actors supplies
339
339
  - Sending domain messages without checking `mailbox`.
340
340
  - Expecting current room messages to wake prompt-only subagents; use direct branch messages or a runner protocol for initiating work.
341
341
  - Reading only stdout and missing actor messages/artifacts.
342
+ - Assuming every packaged message-controlled script is native-Windows-ready; core run control is platform-adapted, but Unix-tool scripts must be migrated recipe by recipe.
342
343
  - Baking local absolute paths into published docs or reusable recipes.
343
344
  - Creating recipes that perform external side effects without explicit operator gates.
344
345
  - Letting project insights live only in chat instead of updating BACKLOG/CHANGELOG/docs and, when agent behavior changes, the packaged skill or prompt guidance.
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent orchestration with scoped locks and quorum consensus. Use for multi-model review, parallel scoped work, delegated audit, and coordinated subagent execution.
4
4
  metadata:
5
- version: 0.20.2
5
+ version: 0.21.0
6
6
  ---
7
7
 
8
8
  # Swarm