@llblab/pi-actors 0.39.0 → 0.40.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 (97) hide show
  1. package/AGENTS.md +10 -3
  2. package/BACKLOG.md +2 -11
  3. package/CHANGELOG.md +45 -0
  4. package/README.md +4 -4
  5. package/dist/index.js +6 -4
  6. package/dist/lib/async-runs.d.ts +4 -0
  7. package/dist/lib/async-runs.js +112 -17
  8. package/dist/lib/command-templates.d.ts +9 -0
  9. package/dist/lib/command-templates.js +92 -11
  10. package/dist/lib/config.js +0 -5
  11. package/dist/lib/execution.d.ts +31 -0
  12. package/dist/lib/execution.js +145 -12
  13. package/dist/lib/file-state.d.ts +1 -0
  14. package/dist/lib/file-state.js +91 -3
  15. package/dist/lib/observability.d.ts +1 -1
  16. package/dist/lib/observability.js +7 -4
  17. package/dist/lib/pi.d.ts +1 -1
  18. package/dist/lib/pi.js +2 -2
  19. package/dist/lib/prompts.d.ts +1 -2
  20. package/dist/lib/prompts.js +2 -3
  21. package/dist/lib/recipes-context.js +17 -9
  22. package/dist/lib/recipes-discovery.js +11 -5
  23. package/dist/lib/recipes-references.d.ts +1 -1
  24. package/dist/lib/recipes-references.js +4 -5
  25. package/dist/lib/recipes-usage.d.ts +2 -0
  26. package/dist/lib/recipes-usage.js +35 -21
  27. package/dist/lib/registry.d.ts +0 -2
  28. package/dist/lib/registry.js +33 -10
  29. package/dist/lib/runs-ownership.d.ts +7 -0
  30. package/dist/lib/runs-ownership.js +82 -0
  31. package/dist/lib/runs-process.d.ts +17 -2
  32. package/dist/lib/runs-process.js +99 -11
  33. package/dist/lib/runs-retention.d.ts +3 -0
  34. package/dist/lib/runs-retention.js +18 -3
  35. package/dist/lib/runs-start.d.ts +2 -2
  36. package/dist/lib/runs-start.js +51 -17
  37. package/dist/lib/runs-status.d.ts +1 -1
  38. package/dist/lib/runs-status.js +8 -6
  39. package/dist/lib/runtime.js +69 -13
  40. package/dist/lib/tools-inspect.d.ts +2 -0
  41. package/dist/lib/tools-inspect.js +39 -2
  42. package/dist/lib/tools-register.js +0 -1
  43. package/dist/lib/tools-spawn.js +3 -2
  44. package/dist/lib/tools.d.ts +1 -0
  45. package/dist/lib/tools.js +3 -0
  46. package/dist/pi-actors/index.js +1 -0
  47. package/dist/recipes/subagent-judge.json +2 -1
  48. package/dist/recipes/subagent-merge.json +2 -1
  49. package/dist/recipes/subagent-normalize.json +2 -1
  50. package/dist/recipes/subagent-review-coordinator.json +1 -1
  51. package/dist/recipes/subagent-review.json +2 -1
  52. package/dist/recipes/subagent-verify.json +2 -1
  53. package/dist/scripts/async-runner.mjs +274 -6
  54. package/dist/scripts/build-dist.mjs +14 -1
  55. package/dist/skills/actors/SKILL.md +11 -7
  56. package/dist/skills/swarm/SKILL.md +1 -1
  57. package/docs/actor-messages.md +1 -1
  58. package/docs/async-runs.md +14 -5
  59. package/docs/command-templates.md +4 -2
  60. package/docs/recipe-library.md +1 -0
  61. package/docs/template-recipes.md +5 -7
  62. package/docs/tool-registry.md +4 -2
  63. package/index.ts +18 -7
  64. package/lib/async-runs.ts +138 -19
  65. package/lib/command-templates.ts +132 -13
  66. package/lib/config.ts +0 -4
  67. package/lib/execution.ts +198 -13
  68. package/lib/file-state.ts +106 -3
  69. package/lib/observability.ts +11 -5
  70. package/lib/pi.ts +3 -3
  71. package/lib/prompts.ts +2 -4
  72. package/lib/recipes-context.ts +17 -9
  73. package/lib/recipes-discovery.ts +10 -5
  74. package/lib/recipes-references.ts +5 -6
  75. package/lib/recipes-usage.ts +36 -20
  76. package/lib/registry.ts +43 -13
  77. package/lib/runs-ownership.ts +117 -0
  78. package/lib/runs-process.ts +138 -16
  79. package/lib/runs-retention.ts +22 -2
  80. package/lib/runs-start.ts +89 -31
  81. package/lib/runs-status.ts +15 -6
  82. package/lib/runtime.ts +64 -12
  83. package/lib/tools-inspect.ts +46 -4
  84. package/lib/tools-register.ts +0 -3
  85. package/lib/tools-spawn.ts +5 -5
  86. package/lib/tools.ts +8 -0
  87. package/package.json +2 -2
  88. package/recipes/subagent-judge.json +2 -1
  89. package/recipes/subagent-merge.json +2 -1
  90. package/recipes/subagent-normalize.json +2 -1
  91. package/recipes/subagent-review-coordinator.json +1 -1
  92. package/recipes/subagent-review.json +2 -1
  93. package/recipes/subagent-verify.json +2 -1
  94. package/scripts/async-runner.mjs +274 -6
  95. package/scripts/build-dist.mjs +14 -1
  96. package/skills/actors/SKILL.md +11 -7
  97. package/skills/swarm/SKILL.md +1 -1
@@ -24,8 +24,6 @@ export function serializeTools(source) {
24
24
  entry.name = cfg.recipe.name;
25
25
  if (cfg.recipe?.async !== undefined)
26
26
  entry.async = cfg.recipe.async;
27
- if (cfg.recipe?.state_dir)
28
- entry.state_dir = cfg.recipe.state_dir;
29
27
  if (cfg.recipe?.values)
30
28
  entry.values = cfg.recipe.values;
31
29
  if (cfg.template)
@@ -113,9 +111,6 @@ export function normalizeStoredTool(key, value, reservedToolNames) {
113
111
  ? {
114
112
  name: recipeName,
115
113
  ...(typeof record.async === "boolean" ? { async: record.async } : {}),
116
- ...(typeof record.state_dir === "string" && record.state_dir.trim()
117
- ? { state_dir: record.state_dir.trim() }
118
- : {}),
119
114
  template,
120
115
  ...(record.values &&
121
116
  typeof record.values === "object" &&
@@ -7,17 +7,33 @@ import * as CommandTemplates from "./command-templates.ts";
7
7
  import type { RegisteredTool } from "./config.ts";
8
8
  export interface ToolExecOptions {
9
9
  actorRecipeContext?: CommandTemplates.CommandTemplateActorRecipeContext;
10
+ evidenceContext?: {
11
+ acceptOutput?: "review_evidence";
12
+ label?: string;
13
+ repeatIndex?: string;
14
+ };
10
15
  cwd?: string;
11
16
  signal?: AbortSignal;
12
17
  stdin?: string;
13
18
  timeout?: number;
14
19
  retry?: number;
20
+ captureDir?: string;
21
+ captureLimitBytes?: number;
15
22
  }
16
23
  export interface ToolExecResult {
17
24
  stdout: string;
18
25
  stderr: string;
19
26
  code: number;
20
27
  killed: boolean;
28
+ stdoutBytes?: number;
29
+ stderrBytes?: number;
30
+ stdoutFile?: string;
31
+ stderrFile?: string;
32
+ evidenceRef?: string;
33
+ stdoutTruncated?: boolean;
34
+ stderrTruncated?: boolean;
35
+ /** Complete internal stdout for downstream pipeline stdin; never returned to model-facing output. */
36
+ pipelineStdout?: string;
21
37
  }
22
38
  export interface BranchReport {
23
39
  code: number;
@@ -28,8 +44,14 @@ export interface BranchReport {
28
44
  status: "done" | "failed" | "timeout";
29
45
  stderr?: string;
30
46
  stderrBytes: number;
47
+ stderrCapturedBytes: number;
48
+ stderrFile?: string;
49
+ stderrTruncated?: boolean;
31
50
  stdout?: string;
32
51
  stdoutBytes: number;
52
+ stdoutCapturedBytes: number;
53
+ stdoutFile?: string;
54
+ stdoutTruncated?: boolean;
33
55
  }
34
56
  export interface SoftQuorumReport {
35
57
  coverage: number;
@@ -50,6 +72,14 @@ export interface RegisteredToolExecutionResult {
50
72
  command: string;
51
73
  fullOutputPath?: string;
52
74
  killed: boolean;
75
+ stderrBytes?: number;
76
+ stderrCapturedBytes?: number;
77
+ stderrFile?: string;
78
+ stderrTruncated?: boolean;
79
+ stdoutBytes?: number;
80
+ stdoutCapturedBytes?: number;
81
+ stdoutFile?: string;
82
+ stdoutTruncated?: boolean;
53
83
  nonCriticalFailures?: Array<{
54
84
  code: number;
55
85
  command: string;
@@ -64,4 +94,5 @@ export interface RegisteredToolExecutionResult {
64
94
  };
65
95
  }
66
96
  export type RegisteredToolExec = (command: string, args: string[], options?: ToolExecOptions) => Promise<ToolExecResult>;
97
+ export declare function applyOutputAcceptancePolicy(result: ToolExecResult, output: string | undefined): Promise<ToolExecResult>;
67
98
  export declare function executeRegisteredTool(cfg: RegisteredTool, params: Record<string, unknown>, exec: RegisteredToolExec, cwd: string, signal?: AbortSignal): Promise<RegisteredToolExecutionResult>;
@@ -3,6 +3,7 @@
3
3
  * Zones: tool execution, command templates, output formatting
4
4
  * Owns command-template invocation execution and pi tool-result payload formatting
5
5
  */
6
+ import { readFile } from "node:fs/promises";
6
7
  import * as CommandTemplates from "./command-templates.js";
7
8
  import { formatFailureOutput, formatOutput, formatToolText, } from "./execution-output.js";
8
9
  import * as Schema from "./schema.js";
@@ -62,6 +63,63 @@ function getBranchStatus(result) {
62
63
  return "done";
63
64
  return result.killed ? "timeout" : "failed";
64
65
  }
66
+ const REVIEW_RESULT_MARKER = "ACTOR_REVIEW_RESULT";
67
+ export async function applyOutputAcceptancePolicy(result, output) {
68
+ if (result.code !== 0 || output !== "review_evidence")
69
+ return result;
70
+ let semanticStdout = result.pipelineStdout ?? result.stdout;
71
+ if (result.pipelineStdout === undefined && result.stdoutTruncated) {
72
+ if (!result.stdoutFile)
73
+ semanticStdout = "";
74
+ else {
75
+ try {
76
+ semanticStdout = await readFile(result.stdoutFile, "utf8");
77
+ }
78
+ catch {
79
+ semanticStdout = "";
80
+ }
81
+ }
82
+ }
83
+ const firstNonWhitespaceLine = semanticStdout
84
+ .split(/\r?\n/)
85
+ .find((line) => line.trim().length > 0);
86
+ if (firstNonWhitespaceLine?.trim() === REVIEW_RESULT_MARKER)
87
+ return result;
88
+ return {
89
+ ...result,
90
+ code: 65,
91
+ stderr: [
92
+ result.stderr,
93
+ `review evidence rejected: missing ${REVIEW_RESULT_MARKER} marker`,
94
+ ].filter(Boolean).join("\n"),
95
+ };
96
+ }
97
+ async function resolvePipelineStdout(result) {
98
+ let stdout = result.pipelineStdout ?? result.stdout;
99
+ if (result.pipelineStdout === undefined && result.stdoutTruncated) {
100
+ if (!result.stdoutFile)
101
+ return undefined;
102
+ try {
103
+ stdout = await readFile(result.stdoutFile, "utf8");
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ return result.evidenceRef
110
+ ? `${stdout}\nACTOR_EVIDENCE_REF: ${result.evidenceRef}`
111
+ : stdout;
112
+ }
113
+ function rejectIncompletePipelineOutput(result) {
114
+ return {
115
+ ...result,
116
+ code: 74,
117
+ stderr: [
118
+ result.stderr,
119
+ `incomplete pipeline stdin: complete stdout unavailable${result.stdoutFile ? `; capture path unreadable: ${result.stdoutFile}` : ""}`,
120
+ ].filter(Boolean).join("\n"),
121
+ };
122
+ }
65
123
  function getBranchFailureReason(result) {
66
124
  if (result.code === 0) {
67
125
  return result.stdout.trim() ? undefined : "empty_output";
@@ -80,13 +138,21 @@ function createBranchReport(label, command, result) {
80
138
  label,
81
139
  status: getBranchStatus(result),
82
140
  ...(result.stderr ? { stderr: result.stderr.slice(-1000) } : {}),
83
- stderrBytes: Buffer.byteLength(result.stderr),
141
+ stderrBytes: result.stderrBytes ?? Buffer.byteLength(result.stderr),
142
+ stderrCapturedBytes: Buffer.byteLength(result.stderr),
143
+ ...(result.stderrFile ? { stderrFile: result.stderrFile } : {}),
144
+ ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
84
145
  ...(result.stdout ? { stdout: result.stdout.slice(-1000) } : {}),
85
- stdoutBytes: Buffer.byteLength(result.stdout),
146
+ stdoutBytes: result.stdoutBytes ?? Buffer.byteLength(result.stdout),
147
+ stdoutCapturedBytes: Buffer.byteLength(result.stdout),
148
+ ...(result.stdoutFile ? { stdoutFile: result.stdoutFile } : {}),
149
+ ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
86
150
  };
87
151
  }
88
152
  function isUsableBranch(branch) {
89
- return branch.status === "done" && branch.stdoutBytes > 0;
153
+ return (branch.status === "done" &&
154
+ branch.stdoutBytes > 0 &&
155
+ branch.stdoutTruncated !== true);
90
156
  }
91
157
  function createSoftQuorum(branches) {
92
158
  if (branches.length === 0)
@@ -231,15 +297,24 @@ function formatParallelStatusHeader(branches, minSuccessful) {
231
297
  return undefined;
232
298
  return `--- parallel_status: ${getParallelStatus(branches, minSuccessful)} usable: ${countUsableBranches(branches)} expected: ${branches.length} minimum: ${minSuccessful} ---`;
233
299
  }
300
+ function stdoutWithEvidenceReference(result) {
301
+ return result.evidenceRef
302
+ ? `${result.stdout}\nACTOR_EVIDENCE_REF: ${result.evidenceRef}`
303
+ : result.stdout;
304
+ }
234
305
  function joinParallelStdout(branches, results, minSuccessful) {
235
306
  const body = results
236
307
  .map((result, index) => {
237
308
  const branch = branches[index];
238
- const header = `--- branch: ${branch.label} status: ${branch.status} ---`;
309
+ const evidence = result.evidenceRef
310
+ ? ` evidence_ref: ${result.evidenceRef}`
311
+ : "";
312
+ const header = `--- branch: ${branch.label} status: ${branch.status}${evidence} ---`;
239
313
  if (branch.status === "done")
240
- return `${header}\n${result.stdout}`;
314
+ return `${header}\n${stdoutWithEvidenceReference(result)}`;
315
+ const stdout = result.stdout ? `\nrejected_stdout: ${result.stdout}` : "";
241
316
  const stderr = branch.stderr ? `\nstderr: ${branch.stderr}` : "";
242
- return `${header}\nexit: ${branch.code}${stderr}`;
317
+ return `${header}\nexit: ${branch.code}${stdout}${stderr}`;
243
318
  })
244
319
  .join("\n");
245
320
  return [formatParallelStatusHeader(branches, minSuccessful), body]
@@ -369,8 +444,18 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
369
444
  if (!Array.isArray(normalized.template)) {
370
445
  const leaf = { ...normalized, ...context };
371
446
  const invocation = CommandTemplates.buildCommandTemplateInvocation(leaf, params, cwd, { emptyMessage: "Tool template produced an empty command." });
372
- const result = await exec(invocation.command, invocation.args, {
447
+ const evidenceContext = {
448
+ ...(normalized.accept_output
449
+ ? { acceptOutput: normalized.accept_output }
450
+ : {}),
451
+ ...(normalized.label ? { label: normalized.label } : {}),
452
+ ...(typeof controlValues.index === "string"
453
+ ? { repeatIndex: controlValues.index }
454
+ : {}),
455
+ };
456
+ const rawResult = await exec(invocation.command, invocation.args, {
373
457
  ...(actorRecipeContext ? { actorRecipeContext } : {}),
458
+ ...(Object.keys(evidenceContext).length > 0 ? { evidenceContext } : {}),
374
459
  cwd,
375
460
  signal,
376
461
  stdin,
@@ -383,6 +468,7 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
383
468
  ? { retry: normalizeRetry(normalized.retry, controlValues) }
384
469
  : {}),
385
470
  });
471
+ const result = await applyOutputAcceptancePolicy(rawResult, normalized.accept_output);
386
472
  return {
387
473
  branches: [],
388
474
  commands: [formatInvocationDetail(invocation)],
@@ -398,6 +484,12 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
398
484
  const concurrency = normalizeConcurrency(normalized.concurrency, controlValues, steps.length);
399
485
  const minSuccessful = normalizeMinSuccessful(normalized.min_successful, controlValues, steps.length);
400
486
  const branchResults = await mapConcurrent(steps, concurrency, (step) => executeTemplateConfig(step, context, params, exec, cwd, signal, stdin, false, actorRecipeContext));
487
+ const branchPipelineStdouts = await Promise.all(branchResults.map((item) => resolvePipelineStdout(item.result)));
488
+ for (const [index, item] of branchResults.entries()) {
489
+ if (item.result.stdoutTruncated && branchPipelineStdouts[index] === undefined) {
490
+ item.result = rejectIncompletePipelineOutput(item.result);
491
+ }
492
+ }
401
493
  const commands = branchResults.flatMap((item) => item.commands);
402
494
  const failures = branchResults.flatMap((item) => item.failures);
403
495
  const branches = branchResults.map((item, index) => createBranchReport(getNodeLabel(steps[index], index), item.commands.at(-1) ?? "<template>", item.result));
@@ -432,8 +524,13 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
432
524
  if (item.result.code === 0)
433
525
  return item.result;
434
526
  addResultFailure(failures, item);
435
- return { ...item.result, code: 0, stdout: "" };
527
+ return { ...item.result, code: 0 };
436
528
  });
529
+ const completeSuccessful = successful.map((item, index) => ({
530
+ ...item,
531
+ stdout: branchPipelineStdouts[index] ?? item.stdout,
532
+ evidenceRef: undefined,
533
+ }));
437
534
  const result = {
438
535
  code: 0,
439
536
  killed: successful.some((item) => item.killed),
@@ -442,6 +539,11 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
442
539
  .filter(Boolean)
443
540
  .join("\n"),
444
541
  stdout: joinParallelStdout(branches, successful, minSuccessful),
542
+ pipelineStdout: joinParallelStdout(branches, completeSuccessful, minSuccessful),
543
+ stdoutBytes: successful.reduce((total, item) => total + (item.stdoutBytes ?? Buffer.byteLength(item.stdout)), 0),
544
+ ...(successful.some((item) => item.stdoutTruncated)
545
+ ? { stdoutTruncated: true }
546
+ : {}),
445
547
  };
446
548
  if (quorumUnmet && nodeFailure === "root") {
447
549
  return {
@@ -504,12 +606,28 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
504
606
  const failures = [];
505
607
  let nextStdin = stdin;
506
608
  let result;
507
- for (const step of steps) {
609
+ for (const [stepIndex, step] of steps.entries()) {
508
610
  const executed = await executeTemplateConfig(step, context, params, exec, cwd, signal, nextStdin, false, actorRecipeContext);
509
611
  branches.push(...executed.branches);
510
612
  commands.push(...executed.commands);
511
613
  failures.push(...executed.failures);
512
- result = executed.result;
614
+ const pipelineStdout = await resolvePipelineStdout(executed.result);
615
+ const incompletePipelineInput = stepIndex < steps.length - 1 && pipelineStdout === undefined;
616
+ result = incompletePipelineInput
617
+ ? rejectIncompletePipelineOutput(executed.result)
618
+ : executed.result;
619
+ executed.result = result;
620
+ if (incompletePipelineInput) {
621
+ addResultFailure(failures, executed);
622
+ return {
623
+ branches,
624
+ commands,
625
+ criticalFailure: true,
626
+ failureScope: "root",
627
+ failures,
628
+ result,
629
+ };
630
+ }
513
631
  if (result.code !== 0) {
514
632
  const failureScope = maxFailureScope(executed.failureScope, executed.criticalFailure ? "root" : undefined, getFailureScope(step), getFailureScope(normalized), isRoot && steps.length === 1 ? "root" : undefined);
515
633
  if (failureScope === "root") {
@@ -537,13 +655,25 @@ async function executeTemplateConfig(config, inherited, params, exec, cwd, signa
537
655
  nextStdin = "";
538
656
  continue;
539
657
  }
540
- nextStdin = result.stdout;
658
+ nextStdin = pipelineStdout;
541
659
  }
542
660
  return { branches, commands, failures, result: result };
543
661
  }
544
662
  async function executeTemplateSteps(cfg, params, exec, cwd, signal) {
545
663
  return executeTemplateConfig(createTemplateConfig(cfg), {}, params, exec, cwd, signal, undefined, true, undefined);
546
664
  }
665
+ function getCaptureDetails(result) {
666
+ return {
667
+ stdoutBytes: result.stdoutBytes ?? Buffer.byteLength(result.stdout),
668
+ stdoutCapturedBytes: Buffer.byteLength(result.stdout),
669
+ stderrBytes: result.stderrBytes ?? Buffer.byteLength(result.stderr),
670
+ stderrCapturedBytes: Buffer.byteLength(result.stderr),
671
+ ...(result.stdoutFile ? { stdoutFile: result.stdoutFile } : {}),
672
+ ...(result.stderrFile ? { stderrFile: result.stderrFile } : {}),
673
+ ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
674
+ ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
675
+ };
676
+ }
547
677
  export async function executeRegisteredTool(cfg, params, exec, cwd, signal) {
548
678
  const executed = await executeTemplateSteps(cfg, Schema.normalizeRuntimeValues(params, cfg.argTypes), exec, cwd, signal);
549
679
  const command = formatCommandDetail(executed.commands);
@@ -556,7 +686,9 @@ export async function executeRegisteredTool(cfg, params, exec, cwd, signal) {
556
686
  branches: executed.branches,
557
687
  code: result.code,
558
688
  command,
689
+ fullOutputPath: result.stdoutFile ?? formatted.fullOutputPath,
559
690
  killed: result.killed,
691
+ ...getCaptureDetails(result),
560
692
  ...(executed.failures.length > 0
561
693
  ? { nonCriticalFailures: executed.failures }
562
694
  : {}),
@@ -575,8 +707,9 @@ export async function executeRegisteredTool(cfg, params, exec, cwd, signal) {
575
707
  details: {
576
708
  code: result.code,
577
709
  command,
578
- fullOutputPath: formatted.fullOutputPath,
710
+ fullOutputPath: result.stdoutFile ?? formatted.fullOutputPath,
579
711
  killed: result.killed,
712
+ ...getCaptureDetails(result),
580
713
  ...(executed.branches.length > 0 ? { branches: executed.branches } : {}),
581
714
  ...(executed.failures.length > 0
582
715
  ? { nonCriticalFailures: executed.failures }
@@ -3,4 +3,5 @@
3
3
  * Zones: file persistence, atomic writes, runtime state support
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
+ export declare function withFileMutationLock<T>(path: string, mutate: () => T): T;
6
7
  export declare function writeJsonAtomic(path: string, value: unknown): void;
@@ -3,9 +3,97 @@
3
3
  * Zones: file persistence, atomic writes, runtime state support
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
- import { randomUUID } from "node:crypto";
7
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
8
- import { dirname } from "node:path";
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { basename, dirname, join, parse, resolve } from "node:path";
10
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
11
+ const FILE_MUTATION_LOCK_STALE_MS = 30000;
12
+ const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
13
+ function canonicalMutationPath(path) {
14
+ const absolute = resolve(path);
15
+ const suffix = [];
16
+ let existing = absolute;
17
+ while (!existsSync(existing)) {
18
+ const parent = dirname(existing);
19
+ if (parent === existing || existing === parse(existing).root)
20
+ break;
21
+ suffix.unshift(basename(existing));
22
+ existing = parent;
23
+ }
24
+ const canonicalAncestor = existsSync(existing)
25
+ ? realpathSync.native(existing)
26
+ : existing;
27
+ const canonical = resolve(canonicalAncestor, ...suffix);
28
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
29
+ }
30
+ function mutationLockPath(path) {
31
+ const key = createHash("sha256")
32
+ .update(canonicalMutationPath(path))
33
+ .digest("hex");
34
+ return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
35
+ }
36
+ function lockOwnerIsDead(lockPath) {
37
+ try {
38
+ const owner = JSON.parse(readFileSync(join(lockPath, "owner.json"), "utf8"));
39
+ const pid = Number(owner.pid);
40
+ if (!Number.isInteger(pid) || pid <= 0)
41
+ return false;
42
+ try {
43
+ process.kill(pid, 0);
44
+ return false;
45
+ }
46
+ catch (error) {
47
+ return error.code === "ESRCH";
48
+ }
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export function withFileMutationLock(path, mutate) {
55
+ mkdirSync(FILE_MUTATION_LOCK_ROOT, { recursive: true });
56
+ const lockPath = mutationLockPath(path);
57
+ const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
58
+ for (;;) {
59
+ try {
60
+ mkdirSync(lockPath);
61
+ try {
62
+ writeFileSync(join(lockPath, "owner.json"), `${JSON.stringify({ pid: process.pid, acquired_at: new Date().toISOString() })}\n`, "utf8");
63
+ }
64
+ catch (error) {
65
+ rmSync(lockPath, { recursive: true, force: true });
66
+ throw error;
67
+ }
68
+ break;
69
+ }
70
+ catch (error) {
71
+ try {
72
+ if (Date.now() - statSync(lockPath).mtimeMs >
73
+ FILE_MUTATION_LOCK_STALE_MS &&
74
+ lockOwnerIsDead(lockPath)) {
75
+ rmSync(lockPath, { recursive: true, force: true });
76
+ continue;
77
+ }
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (Date.now() >= deadline) {
83
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
84
+ cause: error,
85
+ });
86
+ }
87
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
88
+ }
89
+ }
90
+ try {
91
+ return mutate();
92
+ }
93
+ finally {
94
+ rmSync(lockPath, { recursive: true, force: true });
95
+ }
96
+ }
9
97
  export function writeJsonAtomic(path, value) {
10
98
  mkdirSync(dirname(path), { recursive: true });
11
99
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
@@ -50,7 +50,7 @@ export interface RunUiSnapshot {
50
50
  }
51
51
  export interface RunUiNotificationSink {
52
52
  notify(message: string, level: "info" | "warning" | "error"): void;
53
- sendFollowUp(message: {
53
+ sendSteering(message: {
54
54
  customType: string;
55
55
  content: string;
56
56
  display: true;
@@ -37,12 +37,15 @@ export function deliverRunTransitionNotifications(transitions, sink) {
37
37
  sink.notify(text, getRunTransitionNotificationType(transition));
38
38
  if (!shouldSendRunTransitionFollowUp(transition))
39
39
  continue;
40
- sink.sendFollowUp({
40
+ sink.sendSteering({
41
41
  customType: "pi-actors-run",
42
42
  content: text,
43
43
  display: true,
44
44
  details: transition,
45
45
  });
46
+ if (transition.stateDir) {
47
+ AsyncRuns.markRunTerminalNotificationHandled(transition.stateDir, transition.to);
48
+ }
46
49
  }
47
50
  }
48
51
  export function deliverRunOutboxNotifications(events, sink) {
@@ -53,7 +56,7 @@ export function deliverRunOutboxNotifications(events, sink) {
53
56
  sink.notify(text, getRunOutboxNotificationType(event));
54
57
  if (!shouldSendRunOutboxFollowUp(event))
55
58
  continue;
56
- sink.sendFollowUp({
59
+ sink.sendSteering({
57
60
  customType: "pi-actors-run-message",
58
61
  content: text,
59
62
  display: true,
@@ -494,9 +497,9 @@ export function detectRunTransitions(previous, summary) {
494
497
  for (const run of summary.runs) {
495
498
  const key = runObservationKey(run);
496
499
  const old = previous.get(key);
497
- if (old && old !== run.status && TERMINAL.has(run.status)) {
500
+ if (!run.terminalHandled && TERMINAL.has(run.status)) {
498
501
  transitions.push({
499
- from: old,
502
+ from: old ?? "running",
500
503
  run: run.run,
501
504
  ...(run.stateDir ? { stateDir: run.stateDir } : {}),
502
505
  ...(run.artifacts ? { artifacts: run.artifacts } : {}),
package/dist/lib/pi.d.ts CHANGED
@@ -7,7 +7,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
7
7
  export type { ExtensionAPI, ExtensionContext };
8
8
  export interface PiNotificationSink {
9
9
  notify(message: string, level: "info" | "warning" | "error"): void;
10
- sendFollowUp(message: {
10
+ sendSteering(message: {
11
11
  customType: string;
12
12
  content: string;
13
13
  display: true;
package/dist/lib/pi.js CHANGED
@@ -9,8 +9,8 @@ export function getSessionId(ctx) {
9
9
  export function createNotificationSink(pi, ctx) {
10
10
  return {
11
11
  notify: (message, level) => ctx.ui.notify(message, level),
12
- sendFollowUp: (message) => pi.sendMessage(message, {
13
- deliverAs: "followUp",
12
+ sendSteering: (message) => pi.sendMessage(message, {
13
+ deliverAs: "steer",
14
14
  triggerTurn: true,
15
15
  }),
16
16
  };
@@ -6,13 +6,12 @@
6
6
  export declare const REGISTER_TOOL_DESCRIPTION: string;
7
7
  export declare const REGISTER_TOOL_PROMPT_SNIPPET = "Register persistent command templates as agent-callable tools";
8
8
  export declare const REGISTER_TOOL_GUIDELINES: string[];
9
- export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.\n- Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.\n- Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, output.\n- Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.\n- ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.\n- Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.\n- Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.\n- Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.\n- Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.\n- Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.\n- 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.\n- Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.\n- For any non-trivial actor use or pi-actors change, read the bundled actors skill first; for deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.";
9
+ export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.\n- Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.\n- Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, accept_output, output.\n- Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.\n- ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.\n- Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.\n- Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.\n- Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.\n- Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.\n- Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. When a deferred actor result gates the next step, wait for its terminal steering notification; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.\n- 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.\n- Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.\n- For any non-trivial actor use or pi-actors change, read the bundled actors skill first; for deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.";
10
10
  export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
11
11
  readonly name: "Tool name in snake_case (e.g., 'transcribe')";
12
12
  readonly description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.";
13
13
  readonly draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.";
14
14
  readonly async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.";
15
- readonly state_dir: "Optional async run state directory for a co-located template recipe.";
16
15
  readonly template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.";
17
16
  readonly templateArray: "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.";
18
17
  readonly templateNull: "Delete the tool when template is null.";
@@ -16,14 +16,14 @@ export const REGISTER_TOOL_GUIDELINES = [
16
16
  export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
17
17
  - Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.
18
18
  - Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
19
- - Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, output.
19
+ - Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, accept_output, output.
20
20
  - Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
21
21
  - ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.
22
22
  - Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.
23
23
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
24
24
  - Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.
25
25
  - Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.
26
- - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
26
+ - Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. When a deferred actor result gates the next step, wait for its terminal steering notification; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.
27
27
  - 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.
28
28
  - Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.
29
29
  - For any non-trivial actor use or pi-actors change, read the bundled actors skill first; for deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.`;
@@ -32,7 +32,6 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
32
32
  description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
33
33
  draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
34
34
  async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
35
- state_dir: "Optional async run state directory for a co-located template recipe.",
36
35
  template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.",
37
36
  templateArray: "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.",
38
37
  templateNull: "Delete the tool when template is null.",
@@ -47,10 +47,10 @@ function piOptionConsumesNextArg(arg) {
47
47
  function isPiFileArgument(arg) {
48
48
  return arg.startsWith("@") && arg.length > 1;
49
49
  }
50
- export function findPiPrintPromptIndex(args) {
50
+ function findPiPrintPromptIndexes(args) {
51
51
  let printMode = false;
52
52
  let positionalOnly = false;
53
- let promptIndex;
53
+ const promptIndexes = [];
54
54
  for (let index = 0; index < args.length; index += 1) {
55
55
  const arg = args[index];
56
56
  if (!positionalOnly && arg === "--") {
@@ -68,9 +68,12 @@ export function findPiPrintPromptIndex(args) {
68
68
  }
69
69
  if (!printMode || isPiFileArgument(arg))
70
70
  continue;
71
- promptIndex = index;
71
+ promptIndexes.push(index);
72
72
  }
73
- return promptIndex;
73
+ return promptIndexes;
74
+ }
75
+ export function findPiPrintPromptIndex(args) {
76
+ return findPiPrintPromptIndexes(args).at(-1);
74
77
  }
75
78
  function matchesActorContext(record, context) {
76
79
  if (!context)
@@ -134,14 +137,19 @@ export function appendRecipeContextToPiArgs(command, args, records, context) {
134
137
  export function materializePiPrintPromptArg(command, args, promptFile) {
135
138
  if (!isPiCommand(command))
136
139
  return { args };
137
- const promptIndex = findPiPrintPromptIndex(args);
138
- if (promptIndex === undefined)
140
+ const promptIndexes = findPiPrintPromptIndexes(args);
141
+ if (promptIndexes.length === 0)
139
142
  return { args };
140
- const prompt = args[promptIndex];
143
+ const prompt = promptIndexes.map((index) => args[index]).join(" ");
141
144
  const path = typeof promptFile === "function" ? promptFile() : promptFile;
142
145
  writeFileSync(path, prompt, "utf8");
143
- const next = [...args];
144
- next[promptIndex] = `@${path}`;
146
+ const promptIndexSet = new Set(promptIndexes);
147
+ const firstPromptIndex = promptIndexes[0];
148
+ const next = args.flatMap((arg, index) => {
149
+ if (index === firstPromptIndex)
150
+ return [`@${path}`];
151
+ return promptIndexSet.has(index) ? [] : [arg];
152
+ });
145
153
  return {
146
154
  args: next,
147
155
  promptBytes: Buffer.byteLength(prompt),