@llblab/pi-actors 0.23.0 → 0.24.1

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 (158) hide show
  1. package/AGENTS.md +125 -60
  2. package/BACKLOG.md +133 -176
  3. package/CHANGELOG.md +27 -0
  4. package/README.md +13 -0
  5. package/dist/fixtures/protocol/actor-message-branch.json +13 -0
  6. package/dist/fixtures/protocol/artifact-manifest.json +9 -0
  7. package/dist/fixtures/protocol/mailbox-contract.json +15 -0
  8. package/dist/fixtures/protocol/recipe-summary.json +16 -0
  9. package/dist/fixtures/protocol/room-message.json +11 -0
  10. package/dist/fixtures/protocol/room-roster.json +11 -0
  11. package/dist/fixtures/protocol/run-inbox-message.json +9 -0
  12. package/dist/fixtures/protocol/run-outbox-event.json +9 -0
  13. package/dist/fixtures/protocol/run-state.json +10 -0
  14. package/dist/index.js +3 -2
  15. package/dist/lib/actor-inspector-tui.js +5 -32
  16. package/dist/lib/actor-rooms.d.ts +8 -0
  17. package/dist/lib/actor-rooms.js +64 -20
  18. package/dist/lib/actor-worker.d.ts +13 -0
  19. package/dist/lib/actor-worker.js +86 -0
  20. package/dist/lib/async-runner.d.ts +5 -0
  21. package/dist/lib/async-runner.js +134 -0
  22. package/dist/lib/async-runs.js +9 -28
  23. package/dist/lib/conformance.d.ts +12 -0
  24. package/dist/lib/conformance.js +28 -0
  25. package/dist/lib/coordinator.d.ts +5 -0
  26. package/dist/lib/coordinator.js +574 -0
  27. package/dist/lib/locker.d.ts +5 -0
  28. package/dist/lib/locker.js +310 -0
  29. package/dist/lib/mailbox-loop.d.ts +41 -0
  30. package/dist/lib/mailbox-loop.js +62 -0
  31. package/dist/lib/observability.d.ts +2 -2
  32. package/dist/lib/observability.js +51 -53
  33. package/dist/lib/prompts.d.ts +1 -1
  34. package/dist/lib/prompts.js +1 -1
  35. package/dist/lib/recipe-references.js +25 -2
  36. package/dist/lib/recipe-utils.d.ts +5 -0
  37. package/dist/lib/recipe-utils.js +385 -0
  38. package/dist/lib/runtime-notifier.js +3 -7
  39. package/dist/lib/state-readers.d.ts +21 -0
  40. package/dist/lib/state-readers.js +74 -0
  41. package/dist/lib/tools.js +1 -1
  42. package/dist/lib/validate-recipe.d.ts +6 -0
  43. package/dist/lib/validate-recipe.js +104 -0
  44. package/dist/recipes/actor-worker.json +35 -0
  45. package/dist/recipes/coordinator-locker.json +45 -0
  46. package/dist/recipes/lens-swarm.json +66 -0
  47. package/dist/recipes/locker.json +45 -0
  48. package/dist/recipes/music-player.json +38 -0
  49. package/dist/recipes/pipeline-architect-coordinator.json +95 -0
  50. package/dist/recipes/pipeline-artifact-bundle.json +100 -0
  51. package/dist/recipes/pipeline-artifact-report.json +58 -0
  52. package/dist/recipes/pipeline-artifact-write.json +72 -0
  53. package/dist/recipes/pipeline-async-run-ops.json +70 -0
  54. package/dist/recipes/pipeline-checkpoint-continuation.json +67 -0
  55. package/dist/recipes/pipeline-development-tasking.json +81 -0
  56. package/dist/recipes/pipeline-docs-maintenance.json +80 -0
  57. package/dist/recipes/pipeline-media-library.json +59 -0
  58. package/dist/recipes/pipeline-quorum-review.json +79 -0
  59. package/dist/recipes/pipeline-release-readiness.json +110 -0
  60. package/dist/recipes/pipeline-release-summary.json +88 -0
  61. package/dist/recipes/pipeline-repo-health.json +89 -0
  62. package/dist/recipes/pipeline-research-synthesis.json +94 -0
  63. package/dist/recipes/pipeline-review-readiness.json +54 -0
  64. package/dist/recipes/pipeline-room-swarm.json +50 -0
  65. package/dist/recipes/subagent-artifact.json +32 -0
  66. package/dist/recipes/subagent-checkpoint.json +33 -0
  67. package/dist/recipes/subagent-conflict-report.json +32 -0
  68. package/dist/recipes/subagent-contradiction-map.json +33 -0
  69. package/dist/recipes/subagent-critic.json +35 -0
  70. package/dist/recipes/subagent-evidence-map.json +33 -0
  71. package/dist/recipes/subagent-followup.json +33 -0
  72. package/dist/recipes/subagent-judge.json +33 -0
  73. package/dist/recipes/subagent-merge.json +33 -0
  74. package/dist/recipes/subagent-message.json +34 -0
  75. package/dist/recipes/subagent-normalize.json +31 -0
  76. package/dist/recipes/subagent-plan.json +33 -0
  77. package/dist/recipes/subagent-prompt.json +28 -0
  78. package/dist/recipes/subagent-quorum.json +43 -0
  79. package/dist/recipes/subagent-review-coordinator.json +114 -0
  80. package/dist/recipes/subagent-review.json +37 -0
  81. package/dist/recipes/subagent-task-card.json +35 -0
  82. package/dist/recipes/subagent-tools.json +27 -0
  83. package/dist/recipes/subagent-verify.json +34 -0
  84. package/dist/recipes/subagents-prompts.json +51 -0
  85. package/dist/recipes/utility-actor-message.json +23 -0
  86. package/dist/recipes/utility-artifact-manifest.json +16 -0
  87. package/dist/recipes/utility-artifact-write.json +16 -0
  88. package/dist/recipes/utility-changelog-head.json +11 -0
  89. package/dist/recipes/utility-changelog-section.json +13 -0
  90. package/dist/recipes/utility-coordinator-lock-snapshot.json +13 -0
  91. package/dist/recipes/utility-git-log.json +11 -0
  92. package/dist/recipes/utility-git-status.json +9 -0
  93. package/dist/recipes/utility-jsonl-tail.json +10 -0
  94. package/dist/recipes/utility-markdown-index.json +14 -0
  95. package/dist/recipes/utility-package-summary.json +11 -0
  96. package/dist/recipes/utility-playlist-build.json +17 -0
  97. package/dist/recipes/utility-playlist-scan.json +11 -0
  98. package/dist/recipes/utility-run-ops-snapshot.json +17 -0
  99. package/dist/recipes/utility-run-state-files.json +13 -0
  100. package/dist/recipes/utility-run-summary.json +11 -0
  101. package/dist/recipes/utility-skill-summary.json +13 -0
  102. package/dist/recipes/utility-validate-recipe.json +13 -0
  103. package/dist/recipes/utility-validation-wrapper.json +13 -0
  104. package/dist/scripts/actor-worker.mjs +31 -0
  105. package/dist/scripts/async-runner.mjs +31 -0
  106. package/dist/scripts/build-dist.mjs +33 -0
  107. package/dist/scripts/conformance.mjs +33 -0
  108. package/dist/scripts/coordinator.mjs +31 -0
  109. package/dist/scripts/locker.mjs +33 -0
  110. package/dist/scripts/music-player.mjs +964 -0
  111. package/dist/scripts/recipe-utils.mjs +31 -0
  112. package/dist/scripts/validate-recipe.mjs +34 -0
  113. package/dist/skills/actors/SKILL.md +377 -0
  114. package/dist/skills/swarm/SKILL.md +467 -0
  115. package/dist/skills/swarm/references/development-swarm.md +596 -0
  116. package/docs/actor-messages.md +2 -2
  117. package/docs/async-runs.md +11 -0
  118. package/docs/template-recipes.md +1 -1
  119. package/fixtures/protocol/actor-message-branch.json +13 -0
  120. package/fixtures/protocol/artifact-manifest.json +9 -0
  121. package/fixtures/protocol/mailbox-contract.json +15 -0
  122. package/fixtures/protocol/recipe-summary.json +16 -0
  123. package/fixtures/protocol/room-message.json +11 -0
  124. package/fixtures/protocol/room-roster.json +11 -0
  125. package/fixtures/protocol/run-inbox-message.json +9 -0
  126. package/fixtures/protocol/run-outbox-event.json +9 -0
  127. package/fixtures/protocol/run-state.json +10 -0
  128. package/index.ts +3 -0
  129. package/lib/actor-inspector-tui.ts +11 -34
  130. package/lib/actor-rooms.ts +88 -18
  131. package/lib/actor-worker.ts +118 -0
  132. package/lib/async-runner.ts +173 -0
  133. package/lib/async-runs.ts +12 -22
  134. package/lib/conformance.ts +46 -0
  135. package/lib/coordinator.ts +664 -0
  136. package/lib/locker.ts +340 -0
  137. package/lib/mailbox-loop.ts +148 -0
  138. package/lib/observability.ts +24 -19
  139. package/lib/prompts.ts +1 -1
  140. package/lib/recipe-references.ts +37 -2
  141. package/lib/recipe-utils.ts +486 -0
  142. package/lib/runtime-notifier.ts +4 -6
  143. package/lib/state-readers.ts +93 -0
  144. package/lib/tools.ts +1 -1
  145. package/lib/validate-recipe.ts +110 -0
  146. package/package.json +10 -2
  147. package/recipes/actor-worker.json +35 -0
  148. package/recipes/pipeline-quorum-review.json +12 -7
  149. package/scripts/actor-worker.mjs +31 -0
  150. package/scripts/async-runner.mjs +11 -201
  151. package/scripts/build-dist.mjs +33 -0
  152. package/scripts/conformance.mjs +21 -35
  153. package/scripts/coordinator.mjs +15 -625
  154. package/scripts/locker.mjs +20 -332
  155. package/scripts/recipe-utils.mjs +17 -477
  156. package/scripts/validate-recipe.mjs +18 -121
  157. package/skills/actors/SKILL.md +8 -3
  158. package/skills/swarm/SKILL.md +3 -1
package/lib/locker.ts ADDED
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Local coordination locker service entrypoint logic.
3
+ * Zones: coordination locks, task leases, platform control endpoint
4
+ */
5
+
6
+ // @ts-nocheck
7
+
8
+ import { spawnSync } from "node:child_process";
9
+ import { createHash } from "node:crypto";
10
+ import {
11
+ appendFileSync,
12
+ existsSync,
13
+ mkdirSync,
14
+ readFileSync,
15
+ rmSync,
16
+ writeFileSync,
17
+ } from "node:fs";
18
+ import { createServer } from "node:net";
19
+ import { dirname, join, resolve } from "node:path";
20
+ import readline from "node:readline";
21
+
22
+ function parseArgs(argv) {
23
+ const args = { mode: "serve", stateDir: "", leaseMs: 600000, lines: 20 };
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ const arg = argv[index];
26
+ if (arg === "serve" || arg === "snapshot") args.mode = arg;
27
+ else if (arg === "--state-dir") args.stateDir = argv[++index] ?? "";
28
+ else if (arg === "--lease-ms")
29
+ args.leaseMs = Number(argv[++index] ?? args.leaseMs);
30
+ else if (arg === "--lines")
31
+ args.lines = Number(argv[++index] ?? args.lines);
32
+ }
33
+ if (!args.stateDir) throw new Error("--state-dir is required");
34
+ if (!Number.isFinite(args.leaseMs) || args.leaseMs <= 0)
35
+ args.leaseMs = 600000;
36
+ if (!Number.isFinite(args.lines) || args.lines <= 0) args.lines = 20;
37
+ return args;
38
+ }
39
+
40
+ export async function runLocker(argv = process.argv.slice(2)) {
41
+ const { mode, stateDir, leaseMs, lines } = parseArgs(argv);
42
+ const queuePath = join(stateDir, "queue.json");
43
+ const locksPath = join(stateDir, "locks.json");
44
+ const journalPath = join(stateDir, "journal.jsonl");
45
+ const outboxPath = join(stateDir, "outbox.jsonl");
46
+ const controlPath = join(stateDir, "control.fifo");
47
+ mkdirSync(stateDir, { recursive: true });
48
+
49
+ function readJson(path, fallback) {
50
+ if (!existsSync(path)) return fallback;
51
+ try {
52
+ return JSON.parse(readFileSync(path, "utf8"));
53
+ } catch {
54
+ return fallback;
55
+ }
56
+ }
57
+ function writeJson(path, value) {
58
+ mkdirSync(dirname(path), { recursive: true });
59
+ writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
60
+ }
61
+ function getControlEndpoint() {
62
+ if (process.platform !== "win32") return { path: controlPath, type: "fifo" };
63
+ const hash = createHash("sha256")
64
+ .update(resolve(stateDir))
65
+ .digest("hex")
66
+ .slice(0, 20);
67
+ return { path: `\\\\.\\pipe\\pi-actors-locker-${hash}`, type: "named-pipe" };
68
+ }
69
+ function writeControlEndpoint(endpoint) {
70
+ writeJson(join(stateDir, "control.json"), endpoint);
71
+ const runPath = join(stateDir, "run.json");
72
+ const run = readJson(runPath, undefined);
73
+ if (run && typeof run === "object")
74
+ writeJson(runPath, { ...run, control: endpoint });
75
+ }
76
+ function journal(event, data = {}) {
77
+ appendFileSync(
78
+ journalPath,
79
+ `${JSON.stringify({ event, ts: new Date().toISOString(), ...data })}\n`,
80
+ );
81
+ }
82
+ function outbox(type, summary, body = {}, level = "info") {
83
+ appendFileSync(
84
+ outboxPath,
85
+ `${JSON.stringify({ to: "coordinator", from: `run:${process.env.run_id ?? "locker"}`, type, event: type, summary, body, data: body, delivery: "followup", level, ts: new Date().toISOString() })}\n`,
86
+ );
87
+ }
88
+ function now() {
89
+ return Date.now();
90
+ }
91
+ function cleanExpiredLocks(locks) {
92
+ const current = now();
93
+ const kept = {};
94
+ for (const [key, lock] of Object.entries(locks)) {
95
+ if (Number(lock.expiresAt) > current) kept[key] = lock;
96
+ else journal("lock.expired", { resource: key, owner: lock.owner });
97
+ }
98
+ return kept;
99
+ }
100
+ function normalizeMessage(line) {
101
+ const trimmed = line.trim();
102
+ if (!trimmed) return undefined;
103
+ if (["stop", "quit", "exit", "cancel"].includes(trimmed.toLowerCase())) {
104
+ return { type: "control.stop", body: {} };
105
+ }
106
+ try {
107
+ return JSON.parse(trimmed);
108
+ } catch {
109
+ return { type: "lock.enqueue", body: { task: trimmed } };
110
+ }
111
+ }
112
+ function tailJournal(count) {
113
+ if (!existsSync(journalPath)) return [];
114
+ return readFileSync(journalPath, "utf8")
115
+ .trimEnd()
116
+ .split("\n")
117
+ .filter(Boolean)
118
+ .slice(-count)
119
+ .map((line) => {
120
+ try {
121
+ return JSON.parse(line);
122
+ } catch {
123
+ return { raw: line };
124
+ }
125
+ });
126
+ }
127
+ function printSnapshot() {
128
+ const locks = cleanExpiredLocks(readJson(locksPath, {}));
129
+ writeJson(locksPath, locks);
130
+ const queue = readJson(queuePath, { items: [] });
131
+ console.log(
132
+ JSON.stringify(
133
+ {
134
+ queueDepth: Array.isArray(queue.items) ? queue.items.length : 0,
135
+ queue,
136
+ locks,
137
+ journal: tailJournal(lines),
138
+ },
139
+ null,
140
+ 2,
141
+ ),
142
+ );
143
+ }
144
+ function nextTask(queue, locks) {
145
+ const items = Array.isArray(queue.items) ? queue.items : [];
146
+ const index = items.findIndex((item) => {
147
+ const resources = Array.isArray(item.resources) ? item.resources : [];
148
+ return resources.every((resource) => !locks[resource]);
149
+ });
150
+ if (index < 0) return undefined;
151
+ return items.splice(index, 1)[0];
152
+ }
153
+ function handle(message) {
154
+ const type = message.type || message.event || "lock.enqueue";
155
+ const body =
156
+ message.body && typeof message.body === "object" ? message.body : message;
157
+ let queue = readJson(queuePath, { items: [] });
158
+ let locks = cleanExpiredLocks(readJson(locksPath, {}));
159
+ if (type === "control.stop" || type === "control.cancel") {
160
+ writeJson(locksPath, locks);
161
+ journal("control.stop", {});
162
+ outbox("lock.stopped", "Locker stopped", {
163
+ queueDepth: queue.items?.length ?? 0,
164
+ });
165
+ process.exit(0);
166
+ }
167
+ if (type === "lock.enqueue" || type === "coord.enqueue") {
168
+ const item = {
169
+ id: body.id || `task-${Date.now()}`,
170
+ task: body.task ?? body,
171
+ resources: body.resources ?? [],
172
+ enqueuedAt: new Date().toISOString(),
173
+ };
174
+ queue.items = [...(queue.items ?? []), item];
175
+ writeJson(queuePath, queue);
176
+ writeJson(locksPath, locks);
177
+ journal("lock.enqueued", { id: item.id, resources: item.resources });
178
+ outbox("lock.enqueued", `Queued task ${item.id}`, {
179
+ id: item.id,
180
+ queueDepth: queue.items.length,
181
+ });
182
+ return;
183
+ }
184
+ if (type === "lock.claim" || type === "coord.claim") {
185
+ const owner = body.owner || message.from || "worker";
186
+ const item = nextTask(queue, locks);
187
+ if (!item) {
188
+ writeJson(queuePath, queue);
189
+ writeJson(locksPath, locks);
190
+ outbox("lock.empty", "No claimable task", {
191
+ owner,
192
+ queueDepth: queue.items?.length ?? 0,
193
+ });
194
+ return;
195
+ }
196
+ for (const resource of item.resources ?? [])
197
+ locks[resource] = { owner, task: item.id, expiresAt: now() + leaseMs };
198
+ writeJson(queuePath, queue);
199
+ writeJson(locksPath, locks);
200
+ journal("lock.assigned", {
201
+ id: item.id,
202
+ owner,
203
+ resources: item.resources,
204
+ });
205
+ outbox("lock.assigned", `Assigned task ${item.id}`, { owner, task: item });
206
+ return;
207
+ }
208
+ if (type === "lock.acquire") {
209
+ const resource = body.resource;
210
+ const owner = body.owner || message.from || "worker";
211
+ if (!resource) throw new Error("lock.acquire body.resource is required");
212
+ if (locks[resource])
213
+ outbox(
214
+ "lock.denied",
215
+ `Lock denied ${resource}`,
216
+ { resource, owner, current: locks[resource] },
217
+ "warning",
218
+ );
219
+ else {
220
+ locks[resource] = { owner, expiresAt: now() + leaseMs };
221
+ outbox("lock.granted", `Lock granted ${resource}`, { resource, owner });
222
+ }
223
+ writeJson(locksPath, locks);
224
+ return;
225
+ }
226
+ if (type === "lock.renew") {
227
+ const resource = body.resource;
228
+ const owner = body.owner || message.from || "worker";
229
+ if (!resource) throw new Error("lock.renew body.resource is required");
230
+ const current = locks[resource];
231
+ if (!current) {
232
+ outbox(
233
+ "lock.denied",
234
+ `Lock renew denied ${resource}`,
235
+ { resource, owner, reason: "missing" },
236
+ "warning",
237
+ );
238
+ } else if (current.owner !== owner) {
239
+ outbox(
240
+ "lock.denied",
241
+ `Lock renew denied ${resource}`,
242
+ { resource, owner, current },
243
+ "warning",
244
+ );
245
+ } else {
246
+ locks[resource] = { ...current, expiresAt: now() + leaseMs };
247
+ outbox("lock.renewed", `Lock renewed ${resource}`, { resource, owner });
248
+ }
249
+ writeJson(locksPath, locks);
250
+ return;
251
+ }
252
+ if (type === "lock.release") {
253
+ const resource = body.resource;
254
+ if (resource) delete locks[resource];
255
+ writeJson(locksPath, locks);
256
+ outbox("lock.released", `Lock released ${resource}`, { resource });
257
+ return;
258
+ }
259
+ if (
260
+ type === "lock.complete" ||
261
+ type === "lock.fail" ||
262
+ type === "coord.complete" ||
263
+ type === "coord.fail"
264
+ ) {
265
+ const eventType = type.startsWith("coord.")
266
+ ? type.replace("coord.", "lock.")
267
+ : type;
268
+ journal(eventType, body);
269
+ outbox(
270
+ eventType,
271
+ `${eventType} ${body.id ?? ""}`.trim(),
272
+ body,
273
+ eventType === "lock.fail" ? "error" : "info",
274
+ );
275
+ writeJson(locksPath, locks);
276
+ writeJson(queuePath, queue);
277
+ return;
278
+ }
279
+ journal("lock.unknown", { type, body });
280
+ outbox("lock.unknown", `Unknown message ${type}`, { type, body }, "warning");
281
+ }
282
+
283
+ if (mode === "snapshot") {
284
+ printSnapshot();
285
+ process.exit(0);
286
+ }
287
+
288
+ function handleLine(line) {
289
+ const message = normalizeMessage(line);
290
+ if (!message) return;
291
+ try {
292
+ handle(message);
293
+ } catch (error) {
294
+ const text = error instanceof Error ? error.message : String(error);
295
+ journal("lock.error", { error: text });
296
+ outbox("lock.error", text, { error: text }, "error");
297
+ }
298
+ }
299
+
300
+ async function serveFifo(endpoint) {
301
+ if (!existsSync(endpoint.path)) {
302
+ const result = spawnSync("mkfifo", [endpoint.path]);
303
+ if (result.status !== 0)
304
+ throw new Error(`mkfifo failed: ${result.stderr?.toString?.() ?? ""}`);
305
+ }
306
+ writeControlEndpoint(endpoint);
307
+ while (true) {
308
+ const stream = await import("node:fs").then((fs) =>
309
+ fs.createReadStream(endpoint.path, { encoding: "utf8" }),
310
+ );
311
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
312
+ for await (const line of rl) handleLine(line);
313
+ }
314
+ }
315
+
316
+ async function serveNamedPipe(endpoint) {
317
+ rmSync(endpoint.path, { force: true });
318
+ const server = createServer((socket) => {
319
+ const rl = readline.createInterface({ input: socket, crlfDelay: Infinity });
320
+ rl.on("line", handleLine);
321
+ });
322
+ await new Promise((resolveReady, rejectReady) => {
323
+ server.once("error", rejectReady);
324
+ server.listen(endpoint.path, () => {
325
+ server.off("error", rejectReady);
326
+ writeControlEndpoint(endpoint);
327
+ resolveReady();
328
+ });
329
+ });
330
+ await new Promise(() => {});
331
+ }
332
+
333
+ const endpoint = getControlEndpoint();
334
+ writeJson(queuePath, readJson(queuePath, { items: [] }));
335
+ writeJson(locksPath, cleanExpiredLocks(readJson(locksPath, {})));
336
+ journal("lock.started", { leaseMs, control: endpoint.type });
337
+ outbox("lock.started", "Locker ready", { leaseMs, control: endpoint.type });
338
+ if (endpoint.type === "named-pipe") await serveNamedPipe(endpoint);
339
+ else await serveFifo(endpoint);
340
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Minimal mailbox loop helpers.
3
+ * Zones: mailbox-consuming actors, run/branch inbox claiming, handler status transitions
4
+ * Owns small reusable primitives for recipe authors; scheduling and task policy stay outside.
5
+ */
6
+
7
+ import {
8
+ claimBranchInboxMessage,
9
+ updateBranchInboxMessageStatus,
10
+ type BranchInboxRecord,
11
+ } from "./actor-rooms.ts";
12
+ import {
13
+ claimRunInboxMessage,
14
+ updateRunInboxMessageStatus,
15
+ type RunInboxMessage,
16
+ } from "./async-runs.ts";
17
+
18
+ export type MailboxLoopMessage = RunInboxMessage | BranchInboxRecord;
19
+
20
+ export type MailboxLoopTarget =
21
+ | {
22
+ kind: "run";
23
+ runOrDir: string;
24
+ }
25
+ | {
26
+ address: string;
27
+ kind: "branch";
28
+ run: string;
29
+ stateDir: string;
30
+ };
31
+
32
+ export interface MailboxLoopClaimOptions {
33
+ owner?: string;
34
+ statuses?: string[];
35
+ }
36
+
37
+ export interface MailboxLoopHandleResult {
38
+ handled: boolean;
39
+ id?: string;
40
+ message?: MailboxLoopMessage;
41
+ target: MailboxLoopTarget;
42
+ }
43
+
44
+ export interface MailboxLoopDrainOptions extends MailboxLoopClaimOptions {
45
+ maxMessages?: number;
46
+ stopOnControl?: boolean;
47
+ }
48
+
49
+ export interface MailboxLoopDrainResult {
50
+ handled: number;
51
+ stopped: boolean;
52
+ target: MailboxLoopTarget;
53
+ }
54
+
55
+ export function isMailboxLoopStopMessage(message: unknown): boolean {
56
+ const type =
57
+ message && typeof message === "object" && "type" in message
58
+ ? (message as { type?: unknown }).type
59
+ : undefined;
60
+ return (
61
+ type === "control.stop" ||
62
+ type === "control.cancel" ||
63
+ type === "control.kill"
64
+ );
65
+ }
66
+
67
+ function messageId(
68
+ message: MailboxLoopMessage | undefined,
69
+ ): string | undefined {
70
+ return typeof message?.id === "string" ? message.id : undefined;
71
+ }
72
+
73
+ export function claimMailboxLoopMessage(
74
+ target: MailboxLoopTarget,
75
+ options: MailboxLoopClaimOptions = {},
76
+ ): MailboxLoopMessage | undefined {
77
+ const owner = options.owner ?? "mailbox-loop";
78
+ const statuses = options.statuses ?? ["queued"];
79
+ return target.kind === "run"
80
+ ? claimRunInboxMessage(target.runOrDir, owner, statuses)
81
+ : claimBranchInboxMessage(
82
+ target.stateDir,
83
+ target.run,
84
+ target.address,
85
+ owner,
86
+ statuses,
87
+ );
88
+ }
89
+
90
+ export function updateMailboxLoopMessageStatus(
91
+ target: MailboxLoopTarget,
92
+ id: string,
93
+ status: "claimed" | "handled" | "failed",
94
+ metadata: Record<string, unknown> = {},
95
+ ): boolean {
96
+ return target.kind === "run"
97
+ ? updateRunInboxMessageStatus(target.runOrDir, id, status, metadata)
98
+ : updateBranchInboxMessageStatus(
99
+ target.stateDir,
100
+ target.run,
101
+ target.address,
102
+ id,
103
+ status,
104
+ metadata,
105
+ );
106
+ }
107
+
108
+ export async function handleMailboxLoopOnce(
109
+ target: MailboxLoopTarget,
110
+ handler: (message: MailboxLoopMessage) => Promise<void> | void,
111
+ options: MailboxLoopClaimOptions = {},
112
+ ): Promise<MailboxLoopHandleResult> {
113
+ const message = claimMailboxLoopMessage(target, options);
114
+ const id = messageId(message);
115
+ if (!message || !id) return { handled: false, target };
116
+ try {
117
+ await handler(message);
118
+ updateMailboxLoopMessageStatus(target, id, "handled");
119
+ return { handled: true, id, message, target };
120
+ } catch (error) {
121
+ updateMailboxLoopMessageStatus(target, id, "failed", {
122
+ error: error instanceof Error ? error.message : String(error),
123
+ });
124
+ throw error;
125
+ }
126
+ }
127
+
128
+ export async function drainMailboxLoopMessages(
129
+ target: MailboxLoopTarget,
130
+ handler: (message: MailboxLoopMessage) => Promise<void> | void,
131
+ options: MailboxLoopDrainOptions = {},
132
+ ): Promise<MailboxLoopDrainResult> {
133
+ const maxMessages = Math.max(1, Math.floor(options.maxMessages ?? 100));
134
+ let handled = 0;
135
+ for (; handled < maxMessages; handled += 1) {
136
+ const result = await handleMailboxLoopOnce(target, handler, options);
137
+ if (!result.handled || !result.message) {
138
+ return { handled, stopped: false, target };
139
+ }
140
+ if (
141
+ options.stopOnControl !== false &&
142
+ isMailboxLoopStopMessage(result.message)
143
+ ) {
144
+ return { handled: handled + 1, stopped: true, target };
145
+ }
146
+ }
147
+ return { handled, stopped: false, target };
148
+ }
@@ -16,6 +16,7 @@ import {
16
16
 
17
17
  import * as AsyncRuns from "./async-runs.ts";
18
18
  import * as Paths from "./paths.ts";
19
+ import { readJsonlFileResilient } from "./state-readers.ts";
19
20
 
20
21
  export type RunObservedStatus =
21
22
  | "running"
@@ -605,15 +606,12 @@ function normalizeOutboxLevel(value: unknown): RunOutboxLevel {
605
606
  return value === "warning" || value === "error" ? value : "info";
606
607
  }
607
608
 
608
- function parseOutboxLine(
609
- line: string,
609
+ function parseOutboxRecord(
610
+ raw: Record<string, unknown>,
610
611
  run: RunObservation,
611
612
  index: number,
612
613
  ): RunOutboxEvent | undefined {
613
614
  if (!run.stateDir) return undefined;
614
- try {
615
- const raw = JSON.parse(line) as Record<string, unknown>;
616
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
617
615
  const event =
618
616
  typeof raw.event === "string" && raw.event.trim()
619
617
  ? raw.event.trim()
@@ -647,17 +645,13 @@ function parseOutboxLine(
647
645
  summary,
648
646
  ts,
649
647
  };
650
- } catch {
651
- return undefined;
652
- }
653
648
  }
654
649
 
655
- function readOutboxLines(run: RunObservation): string[] {
650
+ function readOutboxRecords(run: RunObservation): Record<string, unknown>[] {
656
651
  if (!run.stateDir) return [];
657
- const path = join(run.stateDir, "outbox.jsonl");
658
- if (!existsSync(path)) return [];
659
- const content = readFileSync(path, "utf8").trimEnd();
660
- return content ? content.split("\n") : [];
652
+ return readJsonlFileResilient<Record<string, unknown>>(
653
+ join(run.stateDir, "outbox.jsonl"),
654
+ ).records;
661
655
  }
662
656
 
663
657
  export function pruneRunObservationState(
@@ -665,6 +659,7 @@ export function pruneRunObservationState(
665
659
  previousLineCounts: Map<string, number>,
666
660
  summary: RunSummary,
667
661
  terminalRuns: Iterable<string> = [],
662
+ seenEventIds: Map<string, Set<string>> = new Map(),
668
663
  ): void {
669
664
  const activeRuns = new Set(summary.runs.map((run) => runObservationKey(run)));
670
665
  const terminalRunSet = new Set(terminalRuns);
@@ -685,23 +680,33 @@ export function pruneRunObservationState(
685
680
  previousLineCounts.delete(key);
686
681
  }
687
682
  }
683
+ for (const key of seenEventIds.keys()) {
684
+ if (terminalLineKeys.has(key) || !activeLineKeys.has(key)) {
685
+ seenEventIds.delete(key);
686
+ }
687
+ }
688
688
  }
689
689
 
690
690
  export function detectRunOutboxEvents(
691
691
  previousLineCounts: Map<string, number>,
692
692
  summary: RunSummary,
693
+ seenEventIds: Map<string, Set<string>> = new Map(),
693
694
  ): RunOutboxEvent[] {
694
695
  const events: RunOutboxEvent[] = [];
695
696
  for (const run of summary.runs) {
696
697
  const key = run.stateDir ?? run.run;
697
- const lines = readOutboxLines(run);
698
+ const records = readOutboxRecords(run);
698
699
  const previousCount = previousLineCounts.get(key) ?? 0;
699
- const start = Math.min(previousCount, lines.length);
700
- for (let index = start; index < lines.length; index += 1) {
701
- const event = parseOutboxLine(lines[index], run, index);
702
- if (event) events.push(event);
700
+ const start = Math.min(previousCount, records.length);
701
+ const seen = seenEventIds.get(key) ?? new Set<string>();
702
+ for (let index = start; index < records.length; index += 1) {
703
+ const event = parseOutboxRecord(records[index], run, index);
704
+ if (!event || seen.has(event.id)) continue;
705
+ events.push(event);
706
+ seen.add(event.id);
703
707
  }
704
- previousLineCounts.set(key, lines.length);
708
+ previousLineCounts.set(key, records.length);
709
+ seenEventIds.set(key, seen);
705
710
  }
706
711
  return events;
707
712
  }
package/lib/prompts.ts CHANGED
@@ -29,7 +29,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
29
29
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
30
30
  - Use spawn/message/inspect for actor-level start/send/observe; avoid runtime/FIFO/outbox vocabulary in public guidance.
31
31
  - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
32
- - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.
32
+ - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones, and fix/remove/disable invalid recipes flagged by registry warnings; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.
33
33
  - Foreground tools/templates fit short work; async recipes/runs fit subagents, services, fanout, media, and long pipelines.
34
34
  - Long fanout = parent async recipe wrapping template(parallel:true) and imports; packaged fanout recipes bubble branch completion messages; grow recurring multi-agent workflows as packaged recipes/pipelines, not ad hoc external scripts.
35
35
  - For deeper pi-actors guidance, inspect installed extension sources/docs/recipes; README and docs are not automatically in context.`;
@@ -311,6 +311,35 @@ function parseMarkdownFrontmatterObject(
311
311
  return result;
312
312
  }
313
313
 
314
+ function normalizeMarkdownFrontmatterField(
315
+ key: string,
316
+ value: unknown,
317
+ ): unknown {
318
+ if (key === "args" && typeof value === "string") {
319
+ return value
320
+ .split(",")
321
+ .map((item) => item.trim())
322
+ .filter(Boolean);
323
+ }
324
+ if (key === "defaults" && Array.isArray(value)) {
325
+ return Object.fromEntries(
326
+ value
327
+ .filter((item): item is string => typeof item === "string")
328
+ .map((item) => {
329
+ const separator = item.indexOf(":");
330
+ return separator < 0
331
+ ? [item.trim(), ""]
332
+ : [
333
+ item.slice(0, separator).trim(),
334
+ parseMarkdownScalar(item.slice(separator + 1)),
335
+ ];
336
+ })
337
+ .filter(([name]) => Boolean(name)),
338
+ );
339
+ }
340
+ return value;
341
+ }
342
+
314
343
  function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
315
344
  const result: Record<string, unknown> = {};
316
345
  const lines = value.split(/\r?\n/);
@@ -320,7 +349,10 @@ function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
320
349
  const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
321
350
  if (!match) continue;
322
351
  if (match[2]) {
323
- result[match[1]] = parseMarkdownScalar(match[2]);
352
+ result[match[1]] = normalizeMarkdownFrontmatterField(
353
+ match[1],
354
+ parseMarkdownScalar(match[2]),
355
+ );
324
356
  continue;
325
357
  }
326
358
  const nested: string[] = [];
@@ -328,7 +360,10 @@ function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
328
360
  index += 1;
329
361
  nested.push(lines[index]);
330
362
  }
331
- result[match[1]] = parseMarkdownFrontmatterObject(nested);
363
+ result[match[1]] = normalizeMarkdownFrontmatterField(
364
+ match[1],
365
+ parseMarkdownFrontmatterObject(nested),
366
+ );
332
367
  }
333
368
  return result;
334
369
  }