@llblab/pi-actors 0.12.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 (86) hide show
  1. package/AGENTS.md +72 -0
  2. package/BACKLOG.md +38 -0
  3. package/CHANGELOG.md +179 -0
  4. package/README.md +338 -0
  5. package/docs/README.md +21 -0
  6. package/docs/actor-messages.md +149 -0
  7. package/docs/async-runs.md +335 -0
  8. package/docs/command-templates.md +424 -0
  9. package/docs/component-recipes.md +148 -0
  10. package/docs/recipe-library.md +176 -0
  11. package/docs/task-first-recipes.md +233 -0
  12. package/docs/template-recipes.md +285 -0
  13. package/docs/tool-registry.md +142 -0
  14. package/index.ts +198 -0
  15. package/lib/actor-messages.ts +120 -0
  16. package/lib/async-runs.ts +688 -0
  17. package/lib/command-templates.ts +795 -0
  18. package/lib/config.ts +266 -0
  19. package/lib/execution.ts +720 -0
  20. package/lib/file-state.ts +24 -0
  21. package/lib/identity.ts +29 -0
  22. package/lib/observability.ts +525 -0
  23. package/lib/output.ts +123 -0
  24. package/lib/paths.ts +35 -0
  25. package/lib/prompts.ts +75 -0
  26. package/lib/recipe-references.ts +586 -0
  27. package/lib/registry.ts +302 -0
  28. package/lib/runtime.ts +101 -0
  29. package/lib/schema.ts +402 -0
  30. package/lib/temp.ts +44 -0
  31. package/lib/tools.ts +651 -0
  32. package/package.json +52 -0
  33. package/recipes/music-player.json +25 -0
  34. package/recipes/pipeline-architect-coordinator.json +88 -0
  35. package/recipes/pipeline-artifact-report.json +52 -0
  36. package/recipes/pipeline-artifact-write.json +66 -0
  37. package/recipes/pipeline-async-run-ops.json +67 -0
  38. package/recipes/pipeline-checkpoint-continuation.json +57 -0
  39. package/recipes/pipeline-development-tasking.json +73 -0
  40. package/recipes/pipeline-docs-maintenance.json +72 -0
  41. package/recipes/pipeline-media-library.json +51 -0
  42. package/recipes/pipeline-quorum-review.json +72 -0
  43. package/recipes/pipeline-release-readiness.json +83 -0
  44. package/recipes/pipeline-repo-health.json +81 -0
  45. package/recipes/pipeline-research-synthesis.json +87 -0
  46. package/recipes/pipeline-review-readiness.json +49 -0
  47. package/recipes/subagent-artifact.json +26 -0
  48. package/recipes/subagent-checkpoint.json +27 -0
  49. package/recipes/subagent-conflict-report.json +25 -0
  50. package/recipes/subagent-contradiction-map.json +26 -0
  51. package/recipes/subagent-critic.json +28 -0
  52. package/recipes/subagent-evidence-map.json +26 -0
  53. package/recipes/subagent-followup.json +27 -0
  54. package/recipes/subagent-judge.json +26 -0
  55. package/recipes/subagent-merge.json +26 -0
  56. package/recipes/subagent-message.json +29 -0
  57. package/recipes/subagent-normalize.json +24 -0
  58. package/recipes/subagent-plan.json +26 -0
  59. package/recipes/subagent-prompt.json +22 -0
  60. package/recipes/subagent-quorum.json +41 -0
  61. package/recipes/subagent-review-coordinator.json +107 -0
  62. package/recipes/subagent-review.json +30 -0
  63. package/recipes/subagent-task-card.json +28 -0
  64. package/recipes/subagent-tools.json +17 -0
  65. package/recipes/subagent-verify.json +27 -0
  66. package/recipes/subagents-prompts.json +32 -0
  67. package/recipes/utility-actor-message.json +24 -0
  68. package/recipes/utility-artifact-manifest.json +17 -0
  69. package/recipes/utility-artifact-write.json +17 -0
  70. package/recipes/utility-changelog-head.json +12 -0
  71. package/recipes/utility-changelog-section.json +14 -0
  72. package/recipes/utility-git-log.json +12 -0
  73. package/recipes/utility-git-status.json +10 -0
  74. package/recipes/utility-jsonl-tail.json +11 -0
  75. package/recipes/utility-markdown-index.json +15 -0
  76. package/recipes/utility-package-summary.json +12 -0
  77. package/recipes/utility-playlist-build.json +18 -0
  78. package/recipes/utility-playlist-scan.json +12 -0
  79. package/recipes/utility-run-state-files.json +14 -0
  80. package/recipes/utility-run-summary.json +12 -0
  81. package/recipes/utility-validate-recipe.json +14 -0
  82. package/recipes/utility-validation-wrapper.json +14 -0
  83. package/scripts/async-runner.mjs +170 -0
  84. package/scripts/music-player.mjs +637 -0
  85. package/scripts/recipe-utils.mjs +273 -0
  86. package/scripts/validate-recipe.mjs +89 -0
@@ -0,0 +1,688 @@
1
+ /**
2
+ * Command-template async run primitives
3
+ * Zones: async runtime, lifecycle, state files
4
+ * Owns detached run state, observation, log tailing, listing, and cancellation safety
5
+ */
6
+
7
+ import { spawn } from "node:child_process";
8
+ import {
9
+ closeSync,
10
+ constants,
11
+ existsSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readdirSync,
15
+ readFileSync,
16
+ readlinkSync,
17
+ rmSync,
18
+ statSync,
19
+ writeFileSync,
20
+ writeSync,
21
+ } from "node:fs";
22
+ import { basename, extname, join, resolve } from "node:path";
23
+
24
+ import type {
25
+ CommandTemplateFailureScope,
26
+ CommandTemplateValue,
27
+ } from "./command-templates.ts";
28
+ import { substituteCommandTemplateToken } from "./command-templates.ts";
29
+ import { writeJsonAtomic } from "./file-state.ts";
30
+ import * as RecipeReferences from "./recipe-references.ts";
31
+ import * as Paths from "./paths.ts";
32
+
33
+ export interface AsyncRunStartParams {
34
+ async?: boolean;
35
+ file?: string;
36
+ name?: string;
37
+ ownerId?: string;
38
+ run_id?: string;
39
+ state_dir?: string;
40
+ tool?: string;
41
+ template?: CommandTemplateValue;
42
+ args?: string[];
43
+ defaults?: Record<string, unknown>;
44
+ parallel?: boolean;
45
+ label?: string;
46
+ when?: boolean | string;
47
+ timeout?: number | string;
48
+ delay?: number | string;
49
+ output?: string;
50
+ artifacts?: Record<string, string>;
51
+ mailbox?: RecipeReferences.TemplateRecipeMailbox;
52
+ retry?: number | string;
53
+ failure?: CommandTemplateFailureScope;
54
+ recover?: CommandTemplateValue;
55
+ repeat?: number;
56
+ values?: Record<string, unknown>;
57
+ cwd?: string;
58
+ }
59
+
60
+ export type AsyncRunStatus =
61
+ | "running"
62
+ | "done"
63
+ | "failed"
64
+ | "exited"
65
+ | "cancelled"
66
+ | "killed";
67
+ export type RunOutboxDelivery = "log" | "notify" | "followup";
68
+ export type RunOutboxLevel = "info" | "warning" | "error";
69
+
70
+ export interface RunOutboxEvent {
71
+ body?: unknown;
72
+ correlation_id?: string;
73
+ data?: unknown;
74
+ delivery: RunOutboxDelivery;
75
+ event: string;
76
+ from?: string;
77
+ id: string;
78
+ level: RunOutboxLevel;
79
+ reply_to?: string;
80
+ run: string;
81
+ state_dir: string;
82
+ summary: string;
83
+ to?: string;
84
+ ts: string;
85
+ type?: string;
86
+ }
87
+
88
+ export interface AsyncRunMeta {
89
+ argv: string[];
90
+ createdAt: string;
91
+ cwd: string;
92
+ ownerId?: string;
93
+ pid: number;
94
+ recipe?: string;
95
+ recipe_file?: string;
96
+ run: string;
97
+ state_dir: string;
98
+ status: AsyncRunStatus;
99
+ tool?: string;
100
+ template: CommandTemplateValue;
101
+ values: Record<string, unknown>;
102
+ artifacts?: Record<string, string>;
103
+ mailbox?: RecipeReferences.TemplateRecipeMailbox;
104
+ }
105
+
106
+ const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
107
+ const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
108
+ const RUNNER_PATH = new URL("../scripts/async-runner.mjs", import.meta.url)
109
+ .pathname;
110
+
111
+ function safeRunId(value: string | undefined): string {
112
+ const run = (value || `run-${Date.now()}`).trim();
113
+ if (!/^[A-Za-z0-9_.-]+$/.test(run))
114
+ throw new Error(
115
+ "Run id may contain only letters, numbers, dot, underscore, and dash.",
116
+ );
117
+ return run;
118
+ }
119
+
120
+ function resolveArtifactPaths(
121
+ artifacts: Record<string, string> | undefined,
122
+ values: Record<string, unknown>,
123
+ ): Record<string, string> | undefined {
124
+ if (!artifacts) return undefined;
125
+ const resolved: Record<string, string> = {};
126
+ for (const [key, value] of Object.entries(artifacts)) {
127
+ if (!key.trim()) continue;
128
+ resolved[key] = substituteCommandTemplateToken(
129
+ value,
130
+ values,
131
+ `recipe artifacts.${key}`,
132
+ );
133
+ }
134
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
135
+ }
136
+
137
+ function resolveRunTemplate(params: AsyncRunStartParams): {
138
+ template: CommandTemplateValue;
139
+ } {
140
+ if (!params.template)
141
+ throw new Error("spawn requires file or template.");
142
+ const envelope: Record<string, unknown> = {};
143
+ for (const key of [
144
+ "args",
145
+ "defaults",
146
+ "parallel",
147
+ "label",
148
+ "when",
149
+ "timeout",
150
+ "delay",
151
+ "output",
152
+ "retry",
153
+ "failure",
154
+ "recover",
155
+ "repeat",
156
+ ] as const) {
157
+ if (params[key] !== undefined) envelope[key] = params[key];
158
+ }
159
+ if (Object.keys(envelope).length === 0) return { template: params.template };
160
+ if (typeof params.template === "object" && !Array.isArray(params.template)) {
161
+ return { template: { ...envelope, ...params.template } };
162
+ }
163
+ return { template: { ...envelope, template: params.template } };
164
+ }
165
+
166
+ function resolveStateDir(params: AsyncRunStartParams, run: string): string {
167
+ return resolve(params.state_dir || join(DEFAULT_STATE_ROOT, run));
168
+ }
169
+
170
+ function resolveRecipeFile(file: string): string {
171
+ return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
172
+ }
173
+
174
+ function readRecipeFile(file: string): AsyncRunStartParams {
175
+ const path = resolveRecipeFile(file);
176
+ const raw = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
177
+ if (Object.hasOwn(raw, "tool")) {
178
+ throw new Error(
179
+ `Template recipe cannot define tool; use template in ${path}`,
180
+ );
181
+ }
182
+ const config = RecipeReferences.readResolvedRecipeConfig(path);
183
+ if (!config) {
184
+ throw new Error(`Template recipe must define template: ${path}`);
185
+ }
186
+ return { ...(config as AsyncRunStartParams), file: path };
187
+ }
188
+
189
+ function getRunIdFromFile(file: string | undefined): string | undefined {
190
+ if (!file) return undefined;
191
+ const name = basename(file, extname(file));
192
+ return name || undefined;
193
+ }
194
+
195
+ function resolveStartParams(params: AsyncRunStartParams): AsyncRunStartParams {
196
+ if (!params.file) return params;
197
+ const fileParams = readRecipeFile(params.file);
198
+ return {
199
+ ...fileParams,
200
+ ...params,
201
+ run_id:
202
+ params.run_id ||
203
+ fileParams.run_id ||
204
+ fileParams.name ||
205
+ getRunIdFromFile(fileParams.file),
206
+ values: { ...(fileParams.values ?? {}), ...(params.values ?? {}) },
207
+ };
208
+ }
209
+
210
+ function readJson(path: string): Record<string, unknown> | undefined {
211
+ try {
212
+ return JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ function isAlive(pid: number): boolean {
219
+ try {
220
+ process.kill(pid, 0);
221
+ return true;
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
227
+ function pidMatchesRun(pid: number, cwd: string, stateDir: string): boolean {
228
+ try {
229
+ const procCwd = readlinkSync(`/proc/${pid}/cwd`);
230
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
231
+ return (
232
+ procCwd === resolve(cwd) &&
233
+ cmdline.includes(RUNNER_PATH) &&
234
+ cmdline.includes(stateDir)
235
+ );
236
+ } catch {
237
+ return false;
238
+ }
239
+ }
240
+
241
+ function tailFile(path: string, lines: number): string {
242
+ if (!existsSync(path)) return "";
243
+ const content = readFileSync(path, "utf8").trimEnd();
244
+ if (!content) return "";
245
+ return content.split("\n").slice(-lines).join("\n");
246
+ }
247
+
248
+ function tailLines(path: string, lines: number): string[] {
249
+ const content = tailFile(path, lines);
250
+ return content ? content.split("\n") : [];
251
+ }
252
+
253
+ function getInterruptedRunStatus(
254
+ stateDir: string,
255
+ ): "cancelled" | "killed" | undefined {
256
+ const events = tailFile(join(stateDir, "events.jsonl"), 200);
257
+ if (!events) return undefined;
258
+ for (const line of events.split("\n").reverse()) {
259
+ try {
260
+ const event = JSON.parse(line) as Record<string, unknown>;
261
+ if (event.event === "run.kill") return "killed";
262
+ if (event.event === "run.cancel") return "cancelled";
263
+ } catch {
264
+ // Ignore malformed event lines.
265
+ }
266
+ }
267
+ return undefined;
268
+ }
269
+
270
+ function prepareStateDirForStart(stateDir: string): void {
271
+ const existing = readJson(join(stateDir, "run.json"));
272
+ const existingPid = Number(existing?.pid || 0);
273
+ const existingCwd =
274
+ typeof existing?.cwd === "string" ? existing.cwd : undefined;
275
+ const existingResult = readJson(join(stateDir, "result.json"));
276
+ if (
277
+ !existingResult &&
278
+ existingPid &&
279
+ existingCwd &&
280
+ isAlive(existingPid) &&
281
+ pidMatchesRun(existingPid, existingCwd, stateDir)
282
+ ) {
283
+ throw new Error(
284
+ `Run is already running: ${String(existing?.run ?? stateDir)}`,
285
+ );
286
+ }
287
+ for (const file of [
288
+ "events.jsonl",
289
+ "outbox.jsonl",
290
+ "progress.json",
291
+ "result.json",
292
+ "stderr.log",
293
+ "stdout.log",
294
+ "terminal-handled.json",
295
+ ]) {
296
+ rmSync(join(stateDir, file), { force: true });
297
+ }
298
+ }
299
+
300
+ export function startRun(
301
+ params: AsyncRunStartParams,
302
+ cwd: string,
303
+ ): AsyncRunMeta {
304
+ const startParams = resolveStartParams(params);
305
+ const resolved = resolveRunTemplate(startParams);
306
+ const run = safeRunId(startParams.run_id);
307
+ const stateDir = resolveStateDir(startParams, run);
308
+ mkdirSync(stateDir, { recursive: true });
309
+ prepareStateDirForStart(stateDir);
310
+ const stdout = join(stateDir, "stdout.log");
311
+ const stderr = join(stateDir, "stderr.log");
312
+ const recipeFile = startParams.file
313
+ ? resolveRecipeFile(startParams.file)
314
+ : undefined;
315
+ const recipe = startParams.name || getRunIdFromFile(recipeFile);
316
+ const outFd = openSync(stdout, "a");
317
+ const errFd = openSync(stderr, "a");
318
+ const argv = ["--experimental-strip-types", RUNNER_PATH, stateDir];
319
+ const values = {
320
+ ...(startParams.values || {}),
321
+ run_id: run,
322
+ state_dir: stateDir,
323
+ };
324
+ const outputValues = {
325
+ ...(startParams.defaults || {}),
326
+ ...values,
327
+ };
328
+ const artifacts = resolveArtifactPaths(startParams.artifacts, outputValues);
329
+ const meta: AsyncRunMeta = {
330
+ argv: [process.execPath, ...argv],
331
+ createdAt: new Date().toISOString(),
332
+ cwd,
333
+ ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
334
+ pid: 0,
335
+ ...(recipe ? { recipe } : {}),
336
+ ...(recipeFile ? { recipe_file: recipeFile } : {}),
337
+ run,
338
+ state_dir: stateDir,
339
+ status: "running",
340
+ ...(startParams.tool ? { tool: startParams.tool } : {}),
341
+ template: resolved.template,
342
+ values,
343
+ ...(artifacts ? { artifacts } : {}),
344
+ ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
345
+ };
346
+ writeJsonAtomic(join(stateDir, "run.json"), meta);
347
+ const child = spawn(process.execPath, argv, {
348
+ cwd,
349
+ detached: true,
350
+ stdio: ["ignore", outFd, errFd],
351
+ });
352
+ closeSync(outFd);
353
+ closeSync(errFd);
354
+ meta.pid = child.pid ?? 0;
355
+ writeJsonAtomic(join(stateDir, "run.json"), meta);
356
+ writeJsonAtomic(join(stateDir, "progress.json"), {
357
+ completed: 0,
358
+ failures: [],
359
+ phase: "starting",
360
+ updatedAt: new Date().toISOString(),
361
+ });
362
+ writeFileSync(
363
+ join(stateDir, "events.jsonl"),
364
+ `${JSON.stringify({ event: "run.start", run, pid: meta.pid, ts: new Date().toISOString() })}\n`,
365
+ { flag: "a" },
366
+ );
367
+ child.unref();
368
+ return meta;
369
+ }
370
+
371
+ function normalizeRunOutboxDelivery(value: unknown): RunOutboxDelivery {
372
+ return value === "notify" || value === "followup" ? value : "log";
373
+ }
374
+
375
+ function normalizeRunOutboxLevel(value: unknown): RunOutboxLevel {
376
+ return value === "warning" || value === "error" ? value : "info";
377
+ }
378
+
379
+ function normalizeRunOutboxEvent(
380
+ raw: unknown,
381
+ run: string,
382
+ stateDir: string,
383
+ index: number,
384
+ ): RunOutboxEvent | undefined {
385
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
386
+ const record = raw as Record<string, unknown>;
387
+ const event =
388
+ typeof record.event === "string" && record.event.trim()
389
+ ? record.event.trim()
390
+ : "run.event";
391
+ const summary =
392
+ typeof record.summary === "string" && record.summary.trim()
393
+ ? record.summary.trim()
394
+ : event;
395
+ const ts =
396
+ typeof record.ts === "string" && record.ts.trim()
397
+ ? record.ts.trim()
398
+ : new Date(0).toISOString();
399
+ const id =
400
+ typeof record.id === "string" && record.id.trim()
401
+ ? record.id.trim()
402
+ : `${run}:${index}`;
403
+ return {
404
+ ...(record.body !== undefined ? { body: record.body } : {}),
405
+ ...(typeof record.correlation_id === "string" ? { correlation_id: record.correlation_id } : {}),
406
+ ...(record.data !== undefined ? { data: record.data } : {}),
407
+ delivery: normalizeRunOutboxDelivery(record.delivery),
408
+ event,
409
+ ...(typeof record.from === "string" ? { from: record.from } : {}),
410
+ id,
411
+ level: normalizeRunOutboxLevel(record.level),
412
+ ...(typeof record.reply_to === "string" ? { reply_to: record.reply_to } : {}),
413
+ run,
414
+ state_dir: stateDir,
415
+ summary,
416
+ ...(typeof record.to === "string" ? { to: record.to } : {}),
417
+ ts,
418
+ ...(typeof record.type === "string" ? { type: record.type } : {}),
419
+ };
420
+ }
421
+
422
+ export function parseRunOutboxEventLine(
423
+ line: string,
424
+ run: string,
425
+ stateDir: string,
426
+ index: number,
427
+ ): RunOutboxEvent | undefined {
428
+ try {
429
+ return normalizeRunOutboxEvent(JSON.parse(line), run, stateDir, index);
430
+ } catch {
431
+ return undefined;
432
+ }
433
+ }
434
+
435
+ export function getRunStatus(runOrDir: string): Record<string, unknown> {
436
+ const stateDir = resolve(
437
+ runOrDir.includes("/")
438
+ ? runOrDir
439
+ : join(DEFAULT_STATE_ROOT, safeRunId(runOrDir)),
440
+ );
441
+ const meta = readJson(join(stateDir, "run.json"));
442
+ if (!meta) throw new Error(`Run not found: ${runOrDir}`);
443
+ const result = readJson(join(stateDir, "result.json"));
444
+ const pid = Number(meta.pid || 0);
445
+ const status: AsyncRunStatus = result
446
+ ? Number(result.code ?? 0) === 0
447
+ ? "done"
448
+ : "failed"
449
+ : isAlive(pid)
450
+ ? "running"
451
+ : (getInterruptedRunStatus(stateDir) ?? "exited");
452
+ const terminalHandled = readJson(join(stateDir, "terminal-handled.json"));
453
+ return {
454
+ ...meta,
455
+ eventsFile: join(stateDir, "events.jsonl"),
456
+ outboxFile: join(stateDir, "outbox.jsonl"),
457
+ progress: readJson(join(stateDir, "progress.json")) || null,
458
+ result: result || null,
459
+ ...(terminalHandled ? { terminal_handled: terminalHandled } : {}),
460
+ state_dir: String(meta.state_dir ?? stateDir),
461
+ stderrLog: join(stateDir, "stderr.log"),
462
+ stdoutLog: join(stateDir, "stdout.log"),
463
+ status,
464
+ };
465
+ }
466
+
467
+ function matchesStatusFilter(
468
+ status: unknown,
469
+ filter: string | undefined,
470
+ ): boolean {
471
+ if (!filter || filter === "all") return true;
472
+ if (filter === "active") return status === "running";
473
+ if (filter === "terminal") return status !== "running";
474
+ return status === filter;
475
+ }
476
+
477
+ export function listRuns(
478
+ stateRoot = DEFAULT_STATE_ROOT,
479
+ statusFilter?: string,
480
+ ): Array<Record<string, unknown>> {
481
+ if (!existsSync(stateRoot)) return [];
482
+ const runs: Array<Record<string, unknown>> = [];
483
+ for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
484
+ if (!entry.isDirectory()) continue;
485
+ try {
486
+ const stateDir = join(stateRoot, entry.name);
487
+ const status = getRunStatus(stateDir);
488
+ if (!matchesStatusFilter(status.status, statusFilter)) continue;
489
+ runs.push({
490
+ run: status.run,
491
+ state_dir: stateDir,
492
+ status: status.status,
493
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
494
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
495
+ });
496
+ } catch {
497
+ // Ignore malformed run dirs.
498
+ }
499
+ }
500
+ return runs;
501
+ }
502
+
503
+ export function tailRun(runOrDir: string, lines = 40): string {
504
+ const status = getRunStatus(runOrDir);
505
+ const stateDir = String(status.state_dir);
506
+ const events = tailFile(join(stateDir, "events.jsonl"), lines);
507
+ if (events) return events;
508
+ return (
509
+ tailFile(join(stateDir, "stdout.log"), lines) ||
510
+ tailFile(join(stateDir, "stderr.log"), lines)
511
+ );
512
+ }
513
+
514
+ export function readRunEvents(runOrDir: string, lines = 40): RunOutboxEvent[] {
515
+ const status = getRunStatus(runOrDir);
516
+ const stateDir = String(status.state_dir);
517
+ const run = String(status.run ?? runOrDir);
518
+ return tailLines(join(stateDir, "outbox.jsonl"), lines)
519
+ .map((line, index) => parseRunOutboxEventLine(line, run, stateDir, index))
520
+ .filter((event): event is RunOutboxEvent => Boolean(event));
521
+ }
522
+
523
+ export function appendRunOutboxEvent(
524
+ runOrDir: string,
525
+ event: {
526
+ body?: unknown;
527
+ correlation_id?: string;
528
+ data?: unknown;
529
+ delivery?: string;
530
+ event?: string;
531
+ from?: string;
532
+ level?: string;
533
+ reply_to?: string;
534
+ summary?: string;
535
+ to?: string;
536
+ type?: string;
537
+ },
538
+ ): Record<string, unknown> {
539
+ const status = getRunStatus(runOrDir);
540
+ const stateDir = String(status.state_dir);
541
+ const run = String(status.run ?? runOrDir);
542
+ const type = event.type || event.event || "run.message";
543
+ const to = event.to || "coordinator";
544
+ const payload = {
545
+ ...(event.body !== undefined ? { body: event.body } : {}),
546
+ ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
547
+ ...(event.data !== undefined ? { data: event.data } : {}),
548
+ delivery: normalizeRunOutboxDelivery(event.delivery ?? (to === "coordinator" ? "followup" : "log")),
549
+ event: type,
550
+ from: event.from || `run:${run}`,
551
+ level: normalizeRunOutboxLevel(event.level),
552
+ ...(event.reply_to ? { reply_to: event.reply_to } : {}),
553
+ summary: event.summary || type,
554
+ to,
555
+ ts: new Date().toISOString(),
556
+ type,
557
+ };
558
+ const line = `${JSON.stringify(payload)}\n`;
559
+ writeFileSync(join(stateDir, "outbox.jsonl"), line, { flag: "a" });
560
+ return {
561
+ bytes: Buffer.byteLength(line),
562
+ outbox: "outbox.jsonl",
563
+ run,
564
+ sent: true,
565
+ state_dir: stateDir,
566
+ };
567
+ }
568
+
569
+ export function sendRunMessage(
570
+ runOrDir: string,
571
+ message: string,
572
+ ): Record<string, unknown> {
573
+ if (process.platform === "win32") {
574
+ throw new Error(
575
+ "run actor messages require Unix FIFO support; use WSL/Linux/macOS or a recipe-specific Windows transport.",
576
+ );
577
+ }
578
+ const status = getRunStatus(runOrDir);
579
+ const stateDir = String(status.state_dir);
580
+ const run = String(status.run ?? runOrDir);
581
+ if (status.status !== "running")
582
+ throw new Error(`Run is not running: ${run}`);
583
+ const pid = Number(status.pid || 0);
584
+ if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
585
+ if (!pidMatchesRun(pid, String(status.cwd), stateDir))
586
+ throw new Error(`Run pid owner mismatch: ${run}`);
587
+ const controlPath = join(stateDir, "control.fifo");
588
+ if (!existsSync(controlPath))
589
+ throw new Error(`Run control FIFO not found: ${controlPath}`);
590
+ const stat = statSync(controlPath);
591
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
592
+ throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
593
+ }
594
+ const payload = message.endsWith("\n") ? message : `${message}\n`;
595
+ let fd: number | undefined;
596
+ try {
597
+ fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
598
+ const bytes = writeSync(fd, payload);
599
+ const trimmedMessage = message.trim().toLowerCase();
600
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
601
+ writeFileSync(
602
+ join(stateDir, "events.jsonl"),
603
+ `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts: new Date().toISOString() })}\n`,
604
+ { flag: "a" },
605
+ );
606
+ if (terminalMessage) {
607
+ markTerminalHandled(stateDir, {
608
+ event: "run.message",
609
+ message: trimmedMessage,
610
+ });
611
+ }
612
+ return {
613
+ bytes,
614
+ control: "control.fifo",
615
+ run,
616
+ sent: true,
617
+ state_dir: stateDir,
618
+ };
619
+ } catch (error) {
620
+ throw new Error(
621
+ `Run control FIFO is not ready: ${controlPath}: ${error instanceof Error ? error.message : String(error)}`,
622
+ );
623
+ } finally {
624
+ if (fd !== undefined) closeSync(fd);
625
+ }
626
+ }
627
+
628
+ function signalOwnedRunProcess(
629
+ pid: number,
630
+ signal: NodeJS.Signals,
631
+ ): { signalTarget: "processGroup" | "process" } {
632
+ try {
633
+ process.kill(-pid, signal);
634
+ return { signalTarget: "processGroup" };
635
+ } catch {
636
+ process.kill(pid, signal);
637
+ return { signalTarget: "process" };
638
+ }
639
+ }
640
+
641
+ function markTerminalHandled(
642
+ stateDir: string,
643
+ details: Record<string, unknown>,
644
+ ): void {
645
+ writeJsonAtomic(join(stateDir, "terminal-handled.json"), {
646
+ ...details,
647
+ ts: new Date().toISOString(),
648
+ });
649
+ }
650
+
651
+ function stopRun(
652
+ runOrDir: string,
653
+ signal: NodeJS.Signals,
654
+ event: string,
655
+ ): Record<string, unknown> {
656
+ const status = getRunStatus(runOrDir);
657
+ const pid = Number(status.pid || 0);
658
+ const stateDir = String(status.state_dir);
659
+ if (status.status !== "running")
660
+ return { stopped: false, reason: "not running", status };
661
+ if (!pid || !isAlive(pid))
662
+ return { stopped: false, reason: "pid not alive", status };
663
+ if (!pidMatchesRun(pid, String(status.cwd), stateDir)) {
664
+ return { stopped: false, reason: "pid owner mismatch", status };
665
+ }
666
+ const signalResult = signalOwnedRunProcess(pid, signal);
667
+ writeFileSync(
668
+ join(stateDir, "events.jsonl"),
669
+ `${JSON.stringify({ event, pid, signal, ...signalResult, ts: new Date().toISOString() })}\n`,
670
+ { flag: "a" },
671
+ );
672
+ markTerminalHandled(stateDir, { event, signal });
673
+ return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
674
+ }
675
+
676
+ export function cancelRun(runOrDir: string): Record<string, unknown> {
677
+ const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
678
+ return Object.hasOwn(result, "stopped")
679
+ ? { cancelled: result.stopped, ...result }
680
+ : result;
681
+ }
682
+
683
+ export function killRun(runOrDir: string): Record<string, unknown> {
684
+ const result = stopRun(runOrDir, "SIGKILL", "run.kill");
685
+ return Object.hasOwn(result, "stopped")
686
+ ? { killed: result.stopped, ...result }
687
+ : result;
688
+ }