@llblab/pi-actors 0.19.10 → 0.20.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 (55) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +40 -1
  3. package/CHANGELOG.md +15 -0
  4. package/dist/lib/actor-inspector-tui.d.ts +55 -0
  5. package/dist/lib/actor-inspector-tui.js +559 -0
  6. package/dist/lib/actor-messages.d.ts +25 -0
  7. package/dist/lib/actor-messages.js +122 -0
  8. package/dist/lib/actor-recipe-context.d.ts +14 -0
  9. package/dist/lib/actor-recipe-context.js +79 -0
  10. package/dist/lib/actor-rooms.d.ts +81 -0
  11. package/dist/lib/actor-rooms.js +468 -0
  12. package/dist/lib/async-runs.d.ts +101 -0
  13. package/dist/lib/async-runs.js +612 -0
  14. package/dist/lib/command-templates.d.ts +70 -0
  15. package/dist/lib/command-templates.js +592 -0
  16. package/dist/lib/config.d.ts +34 -0
  17. package/dist/lib/config.js +226 -0
  18. package/dist/lib/execution.d.ts +63 -0
  19. package/dist/lib/execution.js +450 -0
  20. package/dist/lib/file-state.d.ts +6 -0
  21. package/dist/lib/file-state.js +25 -0
  22. package/dist/lib/identity.d.ts +9 -0
  23. package/dist/lib/identity.js +27 -0
  24. package/dist/lib/observability.d.ts +86 -0
  25. package/dist/lib/observability.js +534 -0
  26. package/dist/lib/output.d.ts +25 -0
  27. package/dist/lib/output.js +89 -0
  28. package/dist/lib/paths.d.ts +11 -0
  29. package/dist/lib/paths.js +28 -0
  30. package/dist/lib/prompts.d.ts +23 -0
  31. package/dist/lib/prompts.js +50 -0
  32. package/dist/lib/recipe-discovery.d.ts +50 -0
  33. package/dist/lib/recipe-discovery.js +317 -0
  34. package/dist/lib/recipe-migration.d.ts +21 -0
  35. package/dist/lib/recipe-migration.js +90 -0
  36. package/dist/lib/recipe-references.d.ts +67 -0
  37. package/dist/lib/recipe-references.js +542 -0
  38. package/dist/lib/recipe-usage.d.ts +6 -0
  39. package/dist/lib/recipe-usage.js +57 -0
  40. package/dist/lib/registry.d.ts +47 -0
  41. package/dist/lib/registry.js +222 -0
  42. package/dist/lib/runtime.d.ts +36 -0
  43. package/dist/lib/runtime.js +126 -0
  44. package/dist/lib/schema.d.ts +48 -0
  45. package/dist/lib/schema.js +355 -0
  46. package/dist/lib/temp.d.ts +10 -0
  47. package/dist/lib/temp.js +90 -0
  48. package/dist/lib/tools.d.ts +39 -0
  49. package/dist/lib/tools.js +982 -0
  50. package/lib/async-runs.ts +20 -4
  51. package/package.json +6 -3
  52. package/scripts/async-runner.mjs +26 -6
  53. package/scripts/validate-recipe.mjs +19 -2
  54. package/skills/actors/SKILL.md +1 -1
  55. package/skills/swarm/SKILL.md +1 -1
@@ -0,0 +1,612 @@
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
+ import { spawn } from "node:child_process";
7
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, rmSync, statSync, writeFileSync, writeSync, } from "node:fs";
8
+ import { platform } from "node:os";
9
+ import { basename, dirname, extname, join, resolve } from "node:path";
10
+ import { fileURLToPath } from "node:url";
11
+ import { substituteCommandTemplateToken } from "./command-templates.js";
12
+ import { writeJsonAtomic } from "./file-state.js";
13
+ import * as Paths from "./paths.js";
14
+ import * as RecipeReferences from "./recipe-references.js";
15
+ import * as RecipeUsage from "./recipe-usage.js";
16
+ const START_LOCK_MAX_AGE_MS = 5 * 60 * 1000;
17
+ const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
18
+ const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
19
+ function packageRoot() {
20
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
21
+ if (basename(moduleDir) === "lib" && basename(dirname(moduleDir)) === "dist") {
22
+ return dirname(dirname(moduleDir));
23
+ }
24
+ return dirname(moduleDir);
25
+ }
26
+ const PACKAGE_ROOT = packageRoot();
27
+ const RUNNER_PATH = join(PACKAGE_ROOT, "scripts", "async-runner.mjs");
28
+ function asyncRunnerArgv(stateDir) {
29
+ return existsSync(join(PACKAGE_ROOT, "dist", "lib", "execution.js"))
30
+ ? [RUNNER_PATH, stateDir]
31
+ : ["--experimental-strip-types", RUNNER_PATH, stateDir];
32
+ }
33
+ function safeRunId(value) {
34
+ const run = (value || `run-${Date.now()}`).trim();
35
+ if (!/^[A-Za-z0-9_.-]+$/.test(run))
36
+ throw new Error("Run id may contain only letters, numbers, dot, underscore, and dash.");
37
+ return run;
38
+ }
39
+ function resolveArtifactPaths(artifacts, values) {
40
+ if (!artifacts)
41
+ return undefined;
42
+ const resolved = {};
43
+ for (const [key, value] of Object.entries(artifacts)) {
44
+ if (!key.trim())
45
+ continue;
46
+ resolved[key] = substituteCommandTemplateToken(value, values, `recipe artifacts.${key}`);
47
+ }
48
+ return Object.keys(resolved).length > 0 ? resolved : undefined;
49
+ }
50
+ function resolveRunTemplate(params) {
51
+ if (!params.template)
52
+ throw new Error("spawn requires file or template.");
53
+ const envelope = {};
54
+ for (const key of [
55
+ "args",
56
+ "defaults",
57
+ "parallel",
58
+ "label",
59
+ "when",
60
+ "timeout",
61
+ "delay",
62
+ "output",
63
+ "retry",
64
+ "failure",
65
+ "recover",
66
+ "repeat",
67
+ ]) {
68
+ if (params[key] !== undefined)
69
+ envelope[key] = params[key];
70
+ }
71
+ if (Object.keys(envelope).length === 0)
72
+ return { template: params.template };
73
+ if (typeof params.template === "object" && !Array.isArray(params.template)) {
74
+ return { template: { ...envelope, ...params.template } };
75
+ }
76
+ return { template: { ...envelope, template: params.template } };
77
+ }
78
+ function resolveStateDir(params, run) {
79
+ return resolve(params.state_dir || join(DEFAULT_STATE_ROOT, run));
80
+ }
81
+ function assertNoActiveRunState(stateDir) {
82
+ const meta = readJson(join(stateDir, "run.json"));
83
+ if (!meta)
84
+ return;
85
+ const pid = Number(meta.pid || 0);
86
+ const cwd = String(meta.cwd ?? "");
87
+ if (!pid || !isAlive(pid) || !pidMatchesRun(pid, cwd, stateDir))
88
+ return;
89
+ throw new Error(`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`);
90
+ }
91
+ function resolveRecipeFile(file) {
92
+ return RecipeReferences.resolveRecipePath(file, DEFAULT_RECIPE_ROOT);
93
+ }
94
+ function isMutableUsageRecipeFile(file) {
95
+ const userRoot = resolve(DEFAULT_RECIPE_ROOT);
96
+ const resolved = resolve(file);
97
+ return resolved.startsWith(`${userRoot}/`);
98
+ }
99
+ function readRecipeFile(file) {
100
+ const path = resolveRecipeFile(file);
101
+ const raw = RecipeReferences.readRawRecipeConfig(path);
102
+ if (raw && Object.hasOwn(raw, "tool")) {
103
+ throw new Error(`Template recipe cannot define tool; use template in ${path}`);
104
+ }
105
+ const includeActorRecipeContext = raw?.actor_context !== false && raw?.actor_context !== "off";
106
+ const config = RecipeReferences.readResolvedRecipeConfig(path, [], {
107
+ includeActorRecipeContext,
108
+ });
109
+ if (!config) {
110
+ throw new Error(`Template recipe must define template: ${path}`);
111
+ }
112
+ return {
113
+ ...config,
114
+ file: path,
115
+ ...(includeActorRecipeContext ? {} : { actor_context: false }),
116
+ };
117
+ }
118
+ function getRunIdFromFile(file) {
119
+ if (!file)
120
+ return undefined;
121
+ const name = basename(file, extname(file));
122
+ return name || undefined;
123
+ }
124
+ function resolveStartParams(params) {
125
+ if (!params.file)
126
+ return params;
127
+ const fileParams = readRecipeFile(params.file);
128
+ return {
129
+ ...fileParams,
130
+ ...params,
131
+ run_id: params.run_id ||
132
+ fileParams.run_id ||
133
+ fileParams.name ||
134
+ getRunIdFromFile(fileParams.file),
135
+ values: { ...(fileParams.values ?? {}), ...(params.values ?? {}) },
136
+ };
137
+ }
138
+ function readJson(path) {
139
+ try {
140
+ return JSON.parse(readFileSync(path, "utf8"));
141
+ }
142
+ catch {
143
+ return undefined;
144
+ }
145
+ }
146
+ function isAlive(pid) {
147
+ try {
148
+ process.kill(pid, 0);
149
+ return true;
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ function pidMatchesRun(pid, cwd, stateDir) {
156
+ if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
157
+ return isAlive(pid);
158
+ try {
159
+ const procCwd = readlinkSync(`/proc/${pid}/cwd`);
160
+ const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
161
+ return (procCwd === resolve(cwd) &&
162
+ cmdline.includes(RUNNER_PATH) &&
163
+ cmdline.includes(stateDir));
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ }
169
+ function tailFile(path, lines) {
170
+ if (!existsSync(path))
171
+ return "";
172
+ const content = readFileSync(path, "utf8").trimEnd();
173
+ if (!content)
174
+ return "";
175
+ return content.split("\n").slice(-lines).join("\n");
176
+ }
177
+ function tailLines(path, lines) {
178
+ const content = tailFile(path, lines);
179
+ return content ? content.split("\n") : [];
180
+ }
181
+ function getInterruptedRunStatus(stateDir) {
182
+ const events = tailFile(join(stateDir, "events.jsonl"), 200);
183
+ if (!events)
184
+ return undefined;
185
+ for (const line of events.split("\n").reverse()) {
186
+ try {
187
+ const event = JSON.parse(line);
188
+ if (event.event === "run.kill")
189
+ return "killed";
190
+ if (event.event === "run.cancel")
191
+ return "cancelled";
192
+ }
193
+ catch {
194
+ // Ignore malformed event lines.
195
+ }
196
+ }
197
+ return undefined;
198
+ }
199
+ function acquireStateStartLock(stateDir) {
200
+ const lockDir = join(stateDir, ".start.lock");
201
+ try {
202
+ mkdirSync(lockDir);
203
+ writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })}\n`, "utf8");
204
+ }
205
+ catch (error) {
206
+ try {
207
+ const stat = statSync(lockDir);
208
+ if (Date.now() - stat.mtimeMs > START_LOCK_MAX_AGE_MS) {
209
+ rmSync(lockDir, { recursive: true, force: true });
210
+ mkdirSync(lockDir);
211
+ writeFileSync(join(lockDir, "owner.json"), `${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString(), recovered: true })}\n`, "utf8");
212
+ return () => rmSync(lockDir, { recursive: true, force: true });
213
+ }
214
+ }
215
+ catch {
216
+ // Keep the original lock acquisition error below.
217
+ }
218
+ throw new Error(`Run state is already being started: ${stateDir}. Retry after the current start finishes.`, { cause: error });
219
+ }
220
+ return () => rmSync(lockDir, { recursive: true, force: true });
221
+ }
222
+ function prepareStateDirForStart(stateDir) {
223
+ const existing = readJson(join(stateDir, "run.json"));
224
+ const existingPid = Number(existing?.pid || 0);
225
+ const existingCwd = typeof existing?.cwd === "string" ? existing.cwd : undefined;
226
+ const existingResult = readJson(join(stateDir, "result.json"));
227
+ if (!existingResult &&
228
+ existingPid &&
229
+ existingCwd &&
230
+ isAlive(existingPid) &&
231
+ pidMatchesRun(existingPid, existingCwd, stateDir)) {
232
+ throw new Error(`Run is already running: ${String(existing?.run ?? stateDir)}`);
233
+ }
234
+ for (const file of [
235
+ "events.jsonl",
236
+ "inbox.jsonl",
237
+ "outbox.jsonl",
238
+ "progress.json",
239
+ "result.json",
240
+ "stderr.log",
241
+ "stdout.log",
242
+ "terminal-handled.json",
243
+ ]) {
244
+ rmSync(join(stateDir, file), { force: true });
245
+ }
246
+ }
247
+ export function startRun(params, cwd) {
248
+ const startParams = resolveStartParams(params);
249
+ const resolved = resolveRunTemplate(startParams);
250
+ const run = safeRunId(startParams.run_id);
251
+ const stateDir = resolveStateDir(startParams, run);
252
+ assertNoActiveRunState(stateDir);
253
+ mkdirSync(stateDir, { recursive: true });
254
+ const releaseStartLock = acquireStateStartLock(stateDir);
255
+ try {
256
+ assertNoActiveRunState(stateDir);
257
+ prepareStateDirForStart(stateDir);
258
+ const stdout = join(stateDir, "stdout.log");
259
+ const stderr = join(stateDir, "stderr.log");
260
+ const recipeFile = startParams.file
261
+ ? resolveRecipeFile(startParams.file)
262
+ : undefined;
263
+ const recipe = startParams.name || getRunIdFromFile(recipeFile);
264
+ const includeActorRecipeContext = startParams.actor_context !== false && startParams.actor_context !== "off";
265
+ const recipeContextRecords = recipeFile && includeActorRecipeContext
266
+ ? RecipeReferences.buildRecipeContextRecords(recipeFile)
267
+ : undefined;
268
+ if (recipeFile && isMutableUsageRecipeFile(recipeFile)) {
269
+ RecipeUsage.recordRecipeLaunch(recipeFile);
270
+ }
271
+ const outFd = openSync(stdout, "a");
272
+ const errFd = openSync(stderr, "a");
273
+ const argv = asyncRunnerArgv(stateDir);
274
+ const values = {
275
+ ...(startParams.values || {}),
276
+ actor_address: `run:${run}`,
277
+ communication_file: join(stateDir, "communication.json"),
278
+ default_room: `room:${run}`,
279
+ run_id: run,
280
+ state_dir: stateDir,
281
+ };
282
+ const outputValues = {
283
+ ...(startParams.defaults || {}),
284
+ ...values,
285
+ };
286
+ const artifacts = resolveArtifactPaths(startParams.artifacts, outputValues);
287
+ const meta = {
288
+ argv: [process.execPath, ...argv],
289
+ createdAt: new Date().toISOString(),
290
+ cwd,
291
+ ...(startParams.launch_source ? { launch_source: startParams.launch_source } : {}),
292
+ ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
293
+ pid: 0,
294
+ ...(recipe ? { recipe } : {}),
295
+ ...(recipeFile ? { recipe_file: recipeFile } : {}),
296
+ run,
297
+ state_dir: stateDir,
298
+ status: "running",
299
+ ...(startParams.tool ? { tool: startParams.tool } : {}),
300
+ template: resolved.template,
301
+ values,
302
+ ...(artifacts ? { artifacts } : {}),
303
+ ...(startParams.mailbox ? { mailbox: startParams.mailbox } : {}),
304
+ ...(recipeContextRecords && recipeContextRecords.length > 0
305
+ ? { recipe_context_records: recipeContextRecords }
306
+ : {}),
307
+ ...(startParams.retire_when === "children_terminal"
308
+ ? { retire_when: "children_terminal" }
309
+ : {}),
310
+ };
311
+ writeJsonAtomic(join(stateDir, "run.json"), meta);
312
+ const child = spawn(process.execPath, argv, {
313
+ cwd,
314
+ detached: true,
315
+ stdio: ["ignore", outFd, errFd],
316
+ });
317
+ closeSync(outFd);
318
+ closeSync(errFd);
319
+ meta.pid = child.pid ?? 0;
320
+ writeJsonAtomic(join(stateDir, "run.json"), meta);
321
+ writeJsonAtomic(join(stateDir, "progress.json"), {
322
+ completed: 0,
323
+ failures: [],
324
+ phase: "starting",
325
+ updatedAt: new Date().toISOString(),
326
+ });
327
+ writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ event: "run.start", run, pid: meta.pid, ts: new Date().toISOString() })}\n`, { flag: "a" });
328
+ child.unref();
329
+ return meta;
330
+ }
331
+ finally {
332
+ releaseStartLock();
333
+ }
334
+ }
335
+ function normalizeRunOutboxDelivery(value) {
336
+ return value === "notify" || value === "followup" ? value : "log";
337
+ }
338
+ function normalizeRunOutboxLevel(value) {
339
+ return value === "warning" || value === "error" ? value : "info";
340
+ }
341
+ function normalizeRunOutboxEvent(raw, run, stateDir, index) {
342
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
343
+ return undefined;
344
+ const record = raw;
345
+ const event = typeof record.event === "string" && record.event.trim()
346
+ ? record.event.trim()
347
+ : "run.event";
348
+ const summary = typeof record.summary === "string" && record.summary.trim()
349
+ ? record.summary.trim()
350
+ : event;
351
+ const ts = typeof record.ts === "string" && record.ts.trim()
352
+ ? record.ts.trim()
353
+ : new Date(0).toISOString();
354
+ const id = typeof record.id === "string" && record.id.trim()
355
+ ? record.id.trim()
356
+ : `${run}:${index}`;
357
+ return {
358
+ ...(record.body !== undefined ? { body: record.body } : {}),
359
+ ...(typeof record.correlation_id === "string"
360
+ ? { correlation_id: record.correlation_id }
361
+ : {}),
362
+ ...(record.data !== undefined ? { data: record.data } : {}),
363
+ delivery: normalizeRunOutboxDelivery(record.delivery),
364
+ ...(record.metadata &&
365
+ typeof record.metadata === "object" &&
366
+ !Array.isArray(record.metadata)
367
+ ? { metadata: record.metadata }
368
+ : {}),
369
+ event,
370
+ ...(typeof record.from === "string" ? { from: record.from } : {}),
371
+ id,
372
+ level: normalizeRunOutboxLevel(record.level),
373
+ ...(typeof record.reply_to === "string"
374
+ ? { reply_to: record.reply_to }
375
+ : {}),
376
+ run,
377
+ state_dir: stateDir,
378
+ summary,
379
+ ...(typeof record.to === "string" ? { to: record.to } : {}),
380
+ ts,
381
+ ...(typeof record.type === "string" ? { type: record.type } : {}),
382
+ };
383
+ }
384
+ export function parseRunOutboxEventLine(line, run, stateDir, index) {
385
+ try {
386
+ return normalizeRunOutboxEvent(JSON.parse(line), run, stateDir, index);
387
+ }
388
+ catch {
389
+ return undefined;
390
+ }
391
+ }
392
+ export function getRunStatus(runOrDir) {
393
+ const stateDir = resolve(runOrDir.includes("/")
394
+ ? runOrDir
395
+ : join(DEFAULT_STATE_ROOT, safeRunId(runOrDir)));
396
+ const meta = readJson(join(stateDir, "run.json"));
397
+ if (!meta)
398
+ throw new Error(`Run not found: ${runOrDir}`);
399
+ const result = readJson(join(stateDir, "result.json"));
400
+ const pid = Number(meta.pid || 0);
401
+ const aliveOwnedRunner = Boolean(pid &&
402
+ isAlive(pid) &&
403
+ (!Array.isArray(meta.argv) ||
404
+ pidMatchesRun(pid, String(meta.cwd ?? ""), stateDir)));
405
+ const status = result
406
+ ? Number(result.code ?? 0) === 0
407
+ ? "done"
408
+ : "failed"
409
+ : aliveOwnedRunner
410
+ ? "running"
411
+ : (getInterruptedRunStatus(stateDir) ?? "exited");
412
+ const terminalHandled = readJson(join(stateDir, "terminal-handled.json"));
413
+ return {
414
+ ...meta,
415
+ eventsFile: join(stateDir, "events.jsonl"),
416
+ inboxFile: join(stateDir, "inbox.jsonl"),
417
+ outboxFile: join(stateDir, "outbox.jsonl"),
418
+ progress: readJson(join(stateDir, "progress.json")) || null,
419
+ result: result || null,
420
+ ...(terminalHandled ? { terminal_handled: terminalHandled } : {}),
421
+ state_dir: String(meta.state_dir ?? stateDir),
422
+ stderrLog: join(stateDir, "stderr.log"),
423
+ stdoutLog: join(stateDir, "stdout.log"),
424
+ status,
425
+ };
426
+ }
427
+ function matchesStatusFilter(status, filter) {
428
+ if (!filter || filter === "all")
429
+ return true;
430
+ if (filter === "active")
431
+ return status === "running";
432
+ if (filter === "terminal")
433
+ return status !== "running";
434
+ return status === filter;
435
+ }
436
+ export function listRuns(stateRoot = DEFAULT_STATE_ROOT, statusFilter) {
437
+ if (!existsSync(stateRoot))
438
+ return [];
439
+ const runs = [];
440
+ for (const entry of readdirSync(stateRoot, { withFileTypes: true })) {
441
+ if (!entry.isDirectory())
442
+ continue;
443
+ try {
444
+ const stateDir = join(stateRoot, entry.name);
445
+ const status = getRunStatus(stateDir);
446
+ if (!matchesStatusFilter(status.status, statusFilter))
447
+ continue;
448
+ runs.push({
449
+ run: status.run,
450
+ state_dir: stateDir,
451
+ status: status.status,
452
+ ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
453
+ ...(typeof status.recipe === "string" ? { recipe: status.recipe } : {}),
454
+ });
455
+ }
456
+ catch {
457
+ // Ignore malformed run dirs.
458
+ }
459
+ }
460
+ return runs;
461
+ }
462
+ export function tailRun(runOrDir, lines = 40) {
463
+ const status = getRunStatus(runOrDir);
464
+ const stateDir = String(status.state_dir);
465
+ const events = tailFile(join(stateDir, "events.jsonl"), lines);
466
+ if (events)
467
+ return events;
468
+ return (tailFile(join(stateDir, "stdout.log"), lines) ||
469
+ tailFile(join(stateDir, "stderr.log"), lines));
470
+ }
471
+ export function readRunEvents(runOrDir, lines = 40) {
472
+ const status = getRunStatus(runOrDir);
473
+ const stateDir = String(status.state_dir);
474
+ const run = String(status.run ?? runOrDir);
475
+ return tailLines(join(stateDir, "outbox.jsonl"), lines)
476
+ .map((line, index) => parseRunOutboxEventLine(line, run, stateDir, index))
477
+ .filter((event) => Boolean(event));
478
+ }
479
+ export function appendRunOutboxEvent(runOrDir, event) {
480
+ const status = getRunStatus(runOrDir);
481
+ const stateDir = String(status.state_dir);
482
+ const run = String(status.run ?? runOrDir);
483
+ const type = event.type || event.event || "run.message";
484
+ const to = event.to || "coordinator";
485
+ const payload = {
486
+ ...(event.body !== undefined ? { body: event.body } : {}),
487
+ ...(event.correlation_id ? { correlation_id: event.correlation_id } : {}),
488
+ ...(event.data !== undefined ? { data: event.data } : {}),
489
+ delivery: normalizeRunOutboxDelivery(event.delivery ?? (to === "coordinator" ? "followup" : "log")),
490
+ event: type,
491
+ from: event.from || `run:${run}`,
492
+ level: normalizeRunOutboxLevel(event.level),
493
+ ...(event.metadata ? { metadata: event.metadata } : {}),
494
+ ...(event.reply_to ? { reply_to: event.reply_to } : {}),
495
+ summary: event.summary || type,
496
+ to,
497
+ ts: new Date().toISOString(),
498
+ type,
499
+ };
500
+ const line = `${JSON.stringify(payload)}\n`;
501
+ writeFileSync(join(stateDir, "outbox.jsonl"), line, { flag: "a" });
502
+ return {
503
+ bytes: Buffer.byteLength(line),
504
+ outbox: "outbox.jsonl",
505
+ run,
506
+ sent: true,
507
+ state_dir: stateDir,
508
+ };
509
+ }
510
+ export function sendRunMessage(runOrDir, message) {
511
+ if (process.platform === "win32") {
512
+ throw new Error("run actor messages require Unix FIFO support; use WSL/Linux/macOS or a recipe-specific Windows transport.");
513
+ }
514
+ const status = getRunStatus(runOrDir);
515
+ const stateDir = String(status.state_dir);
516
+ const run = String(status.run ?? runOrDir);
517
+ if (status.status !== "running")
518
+ throw new Error(`Run is not running: ${run}`);
519
+ const pid = Number(status.pid || 0);
520
+ if (!pid || !isAlive(pid))
521
+ throw new Error(`Run pid is not alive: ${run}`);
522
+ if (!pidMatchesRun(pid, String(status.cwd), stateDir))
523
+ throw new Error(`Run pid owner mismatch: ${run}`);
524
+ const controlPath = join(stateDir, "control.fifo");
525
+ if (!existsSync(controlPath))
526
+ throw new Error(`Run control FIFO not found: ${controlPath}`);
527
+ const stat = statSync(controlPath);
528
+ if ((stat.mode & constants.S_IFMT) !== constants.S_IFIFO) {
529
+ throw new Error(`Run control endpoint is not a FIFO: ${controlPath}`);
530
+ }
531
+ const payload = message.endsWith("\n") ? message : `${message}\n`;
532
+ let fd;
533
+ try {
534
+ fd = openSync(controlPath, constants.O_WRONLY | constants.O_NONBLOCK);
535
+ const bytes = writeSync(fd, payload);
536
+ const trimmedMessage = message.trim().toLowerCase();
537
+ const terminalMessage = ["stop", "cancel", "quit", "exit"].includes(trimmedMessage);
538
+ const ts = new Date().toISOString();
539
+ writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ bytes, event: "run.message", terminal: terminalMessage || undefined, ts })}\n`, { flag: "a" });
540
+ try {
541
+ const envelope = JSON.parse(message);
542
+ writeFileSync(join(stateDir, "inbox.jsonl"), `${JSON.stringify({ ...envelope, received_at: ts })}\n`, { flag: "a" });
543
+ }
544
+ catch {
545
+ // Plain control lines are already represented in events.jsonl.
546
+ }
547
+ if (terminalMessage) {
548
+ markTerminalHandled(stateDir, {
549
+ event: "run.message",
550
+ message: trimmedMessage,
551
+ });
552
+ }
553
+ return {
554
+ bytes,
555
+ control: "control.fifo",
556
+ run,
557
+ sent: true,
558
+ state_dir: stateDir,
559
+ };
560
+ }
561
+ catch (error) {
562
+ throw new Error(`Run control FIFO is not ready: ${controlPath}: ${error instanceof Error ? error.message : String(error)}`);
563
+ }
564
+ finally {
565
+ if (fd !== undefined)
566
+ closeSync(fd);
567
+ }
568
+ }
569
+ function signalOwnedRunProcess(pid, signal) {
570
+ try {
571
+ process.kill(-pid, signal);
572
+ return { signalTarget: "processGroup" };
573
+ }
574
+ catch {
575
+ process.kill(pid, signal);
576
+ return { signalTarget: "process" };
577
+ }
578
+ }
579
+ function markTerminalHandled(stateDir, details) {
580
+ writeJsonAtomic(join(stateDir, "terminal-handled.json"), {
581
+ ...details,
582
+ ts: new Date().toISOString(),
583
+ });
584
+ }
585
+ function stopRun(runOrDir, signal, event) {
586
+ const status = getRunStatus(runOrDir);
587
+ const pid = Number(status.pid || 0);
588
+ const stateDir = String(status.state_dir);
589
+ if (status.status !== "running")
590
+ return { stopped: false, reason: "not running", status };
591
+ if (!pid || !isAlive(pid))
592
+ return { stopped: false, reason: "pid not alive", status };
593
+ if (!pidMatchesRun(pid, String(status.cwd), stateDir)) {
594
+ return { stopped: false, reason: "pid owner mismatch", status };
595
+ }
596
+ const signalResult = signalOwnedRunProcess(pid, signal);
597
+ writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ event, pid, signal, ...signalResult, ts: new Date().toISOString() })}\n`, { flag: "a" });
598
+ markTerminalHandled(stateDir, { event, signal });
599
+ return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
600
+ }
601
+ export function cancelRun(runOrDir) {
602
+ const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
603
+ return Object.hasOwn(result, "stopped")
604
+ ? { cancelled: result.stopped, ...result }
605
+ : result;
606
+ }
607
+ export function killRun(runOrDir) {
608
+ const result = stopRun(runOrDir, "SIGKILL", "run.kill");
609
+ return Object.hasOwn(result, "stopped")
610
+ ? { killed: result.stopped, ...result }
611
+ : result;
612
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Command-template standard helpers
3
+ * Zones: shared utils, local process execution, automation standard
4
+ * Owns shell-free command-template splitting, placeholder defaults, composition expansion, executable path expansion, and direct execution
5
+ */
6
+ export type CommandTemplateFailureScope = "continue" | "branch" | "root";
7
+ export interface CommandTemplateActorRecipeContext {
8
+ alias?: string;
9
+ file?: string;
10
+ name?: string;
11
+ path?: string;
12
+ role?: string;
13
+ }
14
+ export interface CommandTemplateObjectConfig {
15
+ actorRecipeContext?: CommandTemplateActorRecipeContext;
16
+ label?: string;
17
+ parallel?: boolean;
18
+ when?: boolean | string;
19
+ template?: CommandTemplateValue;
20
+ args?: string[];
21
+ defaults?: Record<string, unknown>;
22
+ timeout?: number | string;
23
+ delay?: number | string;
24
+ output?: string;
25
+ retry?: number | string;
26
+ failure?: CommandTemplateFailureScope;
27
+ recover?: CommandTemplateValue;
28
+ repeat?: number | string;
29
+ }
30
+ export type CommandTemplateValue = string | CommandTemplateConfig[] | CommandTemplateObjectConfig;
31
+ export type CommandTemplateConfig = string | CommandTemplateObjectConfig;
32
+ export interface CommandTemplateLeafConfig extends CommandTemplateObjectConfig {
33
+ template: string;
34
+ }
35
+ export interface CommandTemplateInvocation {
36
+ command: string;
37
+ args: string[];
38
+ }
39
+ export interface CommandTemplateExecOptions {
40
+ cwd?: string;
41
+ timeout?: number;
42
+ signal?: AbortSignal;
43
+ stdin?: string;
44
+ killGrace?: number;
45
+ retry?: number;
46
+ }
47
+ export interface CommandTemplateExecResult {
48
+ stdout: string;
49
+ stderr: string;
50
+ code: number;
51
+ killed: boolean;
52
+ }
53
+ export type CommandTemplateExecCommand = (command: string, args: string[], options?: CommandTemplateExecOptions) => Promise<CommandTemplateExecResult>;
54
+ export declare function normalizeCommandTemplateConfig(config: CommandTemplateConfig): CommandTemplateObjectConfig;
55
+ export declare function resolveInheritedDefaultReferences(ownDefaults: Record<string, unknown> | undefined, inheritedDefaults: Record<string, unknown> | undefined, runtimeValues?: Record<string, unknown>): Record<string, unknown> | undefined;
56
+ export declare function resolveCommandTemplateRepeat(value: number | string | undefined, values?: Record<string, unknown>): number | undefined;
57
+ export declare function isCommandTemplateRepeatPlaceholder(name: string): boolean;
58
+ export declare function getCommandTemplateRepeatDefaults(index: number, repeat: number): Record<string, string>;
59
+ export declare function expandCommandTemplateConfigs(config: CommandTemplateConfig, inherited?: Pick<CommandTemplateObjectConfig, "args" | "defaults">): CommandTemplateLeafConfig[];
60
+ export declare function getCommandTemplateWarnings(config: CommandTemplateConfig): string[];
61
+ export declare function getCommandTemplateDefaults(config: CommandTemplateConfig | undefined): Record<string, string>;
62
+ export declare function splitCommandTemplate(input: string): string[];
63
+ export declare function expandCommandTemplateExecutable(command: string, cwd: string): string;
64
+ export declare function shouldRunCommandTemplateNode(value: boolean | string | undefined, values: Record<string, unknown>): boolean;
65
+ export declare function substituteCommandTemplateToken(token: string, values: Record<string, unknown>, missingLabel?: string, depth?: number): string;
66
+ export declare function execCommandTemplate(command: string, args: string[], options?: CommandTemplateExecOptions): Promise<CommandTemplateExecResult>;
67
+ export declare function buildCommandTemplateInvocation(config: CommandTemplateConfig, values: Record<string, unknown>, cwd: string, options?: {
68
+ emptyMessage?: string;
69
+ missingLabel?: string;
70
+ }): CommandTemplateInvocation;