@llblab/pi-actors 0.39.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/AGENTS.md +10 -3
  2. package/BACKLOG.md +4 -11
  3. package/CHANGELOG.md +51 -0
  4. package/README.md +5 -5
  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.js +5 -2
  16. package/dist/lib/prompts.d.ts +1 -2
  17. package/dist/lib/prompts.js +3 -4
  18. package/dist/lib/recipes-context.js +17 -9
  19. package/dist/lib/recipes-discovery.js +11 -5
  20. package/dist/lib/recipes-references.d.ts +1 -1
  21. package/dist/lib/recipes-references.js +4 -5
  22. package/dist/lib/recipes-usage.d.ts +2 -0
  23. package/dist/lib/recipes-usage.js +35 -21
  24. package/dist/lib/registry.d.ts +0 -2
  25. package/dist/lib/registry.js +33 -10
  26. package/dist/lib/runs-ownership.d.ts +7 -0
  27. package/dist/lib/runs-ownership.js +82 -0
  28. package/dist/lib/runs-process.d.ts +17 -2
  29. package/dist/lib/runs-process.js +99 -11
  30. package/dist/lib/runs-retention.d.ts +3 -0
  31. package/dist/lib/runs-retention.js +18 -3
  32. package/dist/lib/runs-start.d.ts +2 -2
  33. package/dist/lib/runs-start.js +51 -17
  34. package/dist/lib/runs-status.d.ts +1 -1
  35. package/dist/lib/runs-status.js +8 -6
  36. package/dist/lib/runtime.js +69 -13
  37. package/dist/lib/tools-inspect.d.ts +2 -0
  38. package/dist/lib/tools-inspect.js +39 -2
  39. package/dist/lib/tools-register.js +0 -1
  40. package/dist/lib/tools-spawn.js +3 -2
  41. package/dist/lib/tools.d.ts +1 -0
  42. package/dist/lib/tools.js +3 -0
  43. package/dist/pi-actors/index.js +1 -0
  44. package/dist/recipes/subagent-judge.json +2 -1
  45. package/dist/recipes/subagent-merge.json +2 -1
  46. package/dist/recipes/subagent-normalize.json +2 -1
  47. package/dist/recipes/subagent-review-coordinator.json +1 -1
  48. package/dist/recipes/subagent-review.json +2 -1
  49. package/dist/recipes/subagent-verify.json +2 -1
  50. package/dist/scripts/async-runner.mjs +274 -6
  51. package/dist/scripts/build-dist.mjs +14 -1
  52. package/dist/skills/actors/SKILL.md +15 -8
  53. package/dist/skills/swarm/SKILL.md +4 -2
  54. package/docs/actor-messages.md +1 -1
  55. package/docs/async-runs.md +14 -5
  56. package/docs/command-templates.md +4 -2
  57. package/docs/recipe-library.md +1 -0
  58. package/docs/template-recipes.md +5 -7
  59. package/docs/tool-registry.md +4 -2
  60. package/index.ts +18 -7
  61. package/lib/async-runs.ts +138 -19
  62. package/lib/command-templates.ts +132 -13
  63. package/lib/config.ts +0 -4
  64. package/lib/execution.ts +198 -13
  65. package/lib/file-state.ts +106 -3
  66. package/lib/observability.ts +8 -2
  67. package/lib/prompts.ts +3 -5
  68. package/lib/recipes-context.ts +17 -9
  69. package/lib/recipes-discovery.ts +10 -5
  70. package/lib/recipes-references.ts +5 -6
  71. package/lib/recipes-usage.ts +36 -20
  72. package/lib/registry.ts +43 -13
  73. package/lib/runs-ownership.ts +117 -0
  74. package/lib/runs-process.ts +138 -16
  75. package/lib/runs-retention.ts +22 -2
  76. package/lib/runs-start.ts +89 -31
  77. package/lib/runs-status.ts +15 -6
  78. package/lib/runtime.ts +64 -12
  79. package/lib/tools-inspect.ts +46 -4
  80. package/lib/tools-register.ts +0 -3
  81. package/lib/tools-spawn.ts +5 -5
  82. package/lib/tools.ts +8 -0
  83. package/package.json +2 -2
  84. package/recipes/subagent-judge.json +2 -1
  85. package/recipes/subagent-merge.json +2 -1
  86. package/recipes/subagent-normalize.json +2 -1
  87. package/recipes/subagent-review-coordinator.json +1 -1
  88. package/recipes/subagent-review.json +2 -1
  89. package/recipes/subagent-verify.json +2 -1
  90. package/scripts/async-runner.mjs +274 -6
  91. package/scripts/build-dist.mjs +14 -1
  92. package/skills/actors/SKILL.md +15 -8
  93. package/skills/swarm/SKILL.md +4 -2
package/lib/execution.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * Owns command-template invocation execution and pi tool-result payload formatting
5
5
  */
6
6
 
7
+ import { readFile } from "node:fs/promises";
8
+
7
9
  import * as CommandTemplates from "./command-templates.ts";
8
10
  import type { RegisteredTool } from "./config.ts";
9
11
  import {
@@ -15,11 +17,18 @@ import * as Schema from "./schema.ts";
15
17
 
16
18
  export interface ToolExecOptions {
17
19
  actorRecipeContext?: CommandTemplates.CommandTemplateActorRecipeContext;
20
+ evidenceContext?: {
21
+ acceptOutput?: "review_evidence";
22
+ label?: string;
23
+ repeatIndex?: string;
24
+ };
18
25
  cwd?: string;
19
26
  signal?: AbortSignal;
20
27
  stdin?: string;
21
28
  timeout?: number;
22
29
  retry?: number;
30
+ captureDir?: string;
31
+ captureLimitBytes?: number;
23
32
  }
24
33
 
25
34
  export interface ToolExecResult {
@@ -27,6 +36,15 @@ export interface ToolExecResult {
27
36
  stderr: string;
28
37
  code: number;
29
38
  killed: boolean;
39
+ stdoutBytes?: number;
40
+ stderrBytes?: number;
41
+ stdoutFile?: string;
42
+ stderrFile?: string;
43
+ evidenceRef?: string;
44
+ stdoutTruncated?: boolean;
45
+ stderrTruncated?: boolean;
46
+ /** Complete internal stdout for downstream pipeline stdin; never returned to model-facing output. */
47
+ pipelineStdout?: string;
30
48
  }
31
49
 
32
50
  export interface BranchReport {
@@ -38,8 +56,14 @@ export interface BranchReport {
38
56
  status: "done" | "failed" | "timeout";
39
57
  stderr?: string;
40
58
  stderrBytes: number;
59
+ stderrCapturedBytes: number;
60
+ stderrFile?: string;
61
+ stderrTruncated?: boolean;
41
62
  stdout?: string;
42
63
  stdoutBytes: number;
64
+ stdoutCapturedBytes: number;
65
+ stdoutFile?: string;
66
+ stdoutTruncated?: boolean;
43
67
  }
44
68
 
45
69
  export interface SoftQuorumReport {
@@ -59,6 +83,14 @@ export interface RegisteredToolExecutionResult {
59
83
  command: string;
60
84
  fullOutputPath?: string;
61
85
  killed: boolean;
86
+ stderrBytes?: number;
87
+ stderrCapturedBytes?: number;
88
+ stderrFile?: string;
89
+ stderrTruncated?: boolean;
90
+ stdoutBytes?: number;
91
+ stdoutCapturedBytes?: number;
92
+ stdoutFile?: string;
93
+ stdoutTruncated?: boolean;
62
94
  nonCriticalFailures?: Array<{
63
95
  code: number;
64
96
  command: string;
@@ -163,6 +195,66 @@ function getBranchStatus(result: ToolExecResult): BranchReport["status"] {
163
195
  return result.killed ? "timeout" : "failed";
164
196
  }
165
197
 
198
+ const REVIEW_RESULT_MARKER = "ACTOR_REVIEW_RESULT";
199
+
200
+ export async function applyOutputAcceptancePolicy(
201
+ result: ToolExecResult,
202
+ output: string | undefined,
203
+ ): Promise<ToolExecResult> {
204
+ if (result.code !== 0 || output !== "review_evidence") return result;
205
+ let semanticStdout = result.pipelineStdout ?? result.stdout;
206
+ if (result.pipelineStdout === undefined && result.stdoutTruncated) {
207
+ if (!result.stdoutFile) semanticStdout = "";
208
+ else {
209
+ try {
210
+ semanticStdout = await readFile(result.stdoutFile, "utf8");
211
+ } catch {
212
+ semanticStdout = "";
213
+ }
214
+ }
215
+ }
216
+ const firstNonWhitespaceLine = semanticStdout
217
+ .split(/\r?\n/)
218
+ .find((line) => line.trim().length > 0);
219
+ if (firstNonWhitespaceLine?.trim() === REVIEW_RESULT_MARKER) return result;
220
+ return {
221
+ ...result,
222
+ code: 65,
223
+ stderr: [
224
+ result.stderr,
225
+ `review evidence rejected: missing ${REVIEW_RESULT_MARKER} marker`,
226
+ ].filter(Boolean).join("\n"),
227
+ };
228
+ }
229
+
230
+ async function resolvePipelineStdout(
231
+ result: ToolExecResult,
232
+ ): Promise<string | undefined> {
233
+ let stdout = result.pipelineStdout ?? result.stdout;
234
+ if (result.pipelineStdout === undefined && result.stdoutTruncated) {
235
+ if (!result.stdoutFile) return undefined;
236
+ try {
237
+ stdout = await readFile(result.stdoutFile, "utf8");
238
+ } catch {
239
+ return undefined;
240
+ }
241
+ }
242
+ return result.evidenceRef
243
+ ? `${stdout}\nACTOR_EVIDENCE_REF: ${result.evidenceRef}`
244
+ : stdout;
245
+ }
246
+
247
+ function rejectIncompletePipelineOutput(result: ToolExecResult): ToolExecResult {
248
+ return {
249
+ ...result,
250
+ code: 74,
251
+ stderr: [
252
+ result.stderr,
253
+ `incomplete pipeline stdin: complete stdout unavailable${result.stdoutFile ? `; capture path unreadable: ${result.stdoutFile}` : ""}`,
254
+ ].filter(Boolean).join("\n"),
255
+ };
256
+ }
257
+
166
258
  function getBranchFailureReason(
167
259
  result: ToolExecResult,
168
260
  ): string | undefined {
@@ -187,14 +279,24 @@ function createBranchReport(
187
279
  label,
188
280
  status: getBranchStatus(result),
189
281
  ...(result.stderr ? { stderr: result.stderr.slice(-1000) } : {}),
190
- stderrBytes: Buffer.byteLength(result.stderr),
282
+ stderrBytes: result.stderrBytes ?? Buffer.byteLength(result.stderr),
283
+ stderrCapturedBytes: Buffer.byteLength(result.stderr),
284
+ ...(result.stderrFile ? { stderrFile: result.stderrFile } : {}),
285
+ ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
191
286
  ...(result.stdout ? { stdout: result.stdout.slice(-1000) } : {}),
192
- stdoutBytes: Buffer.byteLength(result.stdout),
287
+ stdoutBytes: result.stdoutBytes ?? Buffer.byteLength(result.stdout),
288
+ stdoutCapturedBytes: Buffer.byteLength(result.stdout),
289
+ ...(result.stdoutFile ? { stdoutFile: result.stdoutFile } : {}),
290
+ ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
193
291
  };
194
292
  }
195
293
 
196
294
  function isUsableBranch(branch: BranchReport): boolean {
197
- return branch.status === "done" && branch.stdoutBytes > 0;
295
+ return (
296
+ branch.status === "done" &&
297
+ branch.stdoutBytes > 0 &&
298
+ branch.stdoutTruncated !== true
299
+ );
198
300
  }
199
301
 
200
302
  function createSoftQuorum(
@@ -399,6 +501,12 @@ function formatParallelStatusHeader(
399
501
  )} usable: ${countUsableBranches(branches)} expected: ${branches.length} minimum: ${minSuccessful} ---`;
400
502
  }
401
503
 
504
+ function stdoutWithEvidenceReference(result: ToolExecResult): string {
505
+ return result.evidenceRef
506
+ ? `${result.stdout}\nACTOR_EVIDENCE_REF: ${result.evidenceRef}`
507
+ : result.stdout;
508
+ }
509
+
402
510
  function joinParallelStdout(
403
511
  branches: BranchReport[],
404
512
  results: ToolExecResult[],
@@ -407,10 +515,15 @@ function joinParallelStdout(
407
515
  const body = results
408
516
  .map((result, index) => {
409
517
  const branch = branches[index];
410
- const header = `--- branch: ${branch.label} status: ${branch.status} ---`;
411
- if (branch.status === "done") return `${header}\n${result.stdout}`;
518
+ const evidence = result.evidenceRef
519
+ ? ` evidence_ref: ${result.evidenceRef}`
520
+ : "";
521
+ const header = `--- branch: ${branch.label} status: ${branch.status}${evidence} ---`;
522
+ if (branch.status === "done")
523
+ return `${header}\n${stdoutWithEvidenceReference(result)}`;
524
+ const stdout = result.stdout ? `\nrejected_stdout: ${result.stdout}` : "";
412
525
  const stderr = branch.stderr ? `\nstderr: ${branch.stderr}` : "";
413
- return `${header}\nexit: ${branch.code}${stderr}`;
526
+ return `${header}\nexit: ${branch.code}${stdout}${stderr}`;
414
527
  })
415
528
  .join("\n");
416
529
  return [formatParallelStatusHeader(branches, minSuccessful), body]
@@ -643,8 +756,18 @@ async function executeTemplateConfig(
643
756
  cwd,
644
757
  { emptyMessage: "Tool template produced an empty command." },
645
758
  );
646
- const result = await exec(invocation.command, invocation.args, {
759
+ const evidenceContext = {
760
+ ...(normalized.accept_output
761
+ ? { acceptOutput: normalized.accept_output }
762
+ : {}),
763
+ ...(normalized.label ? { label: normalized.label } : {}),
764
+ ...(typeof controlValues.index === "string"
765
+ ? { repeatIndex: controlValues.index }
766
+ : {}),
767
+ };
768
+ const rawResult = await exec(invocation.command, invocation.args, {
647
769
  ...(actorRecipeContext ? { actorRecipeContext } : {}),
770
+ ...(Object.keys(evidenceContext).length > 0 ? { evidenceContext } : {}),
648
771
  cwd,
649
772
  signal,
650
773
  stdin,
@@ -665,6 +788,10 @@ async function executeTemplateConfig(
665
788
  ? { retry: normalizeRetry(normalized.retry, controlValues) }
666
789
  : {}),
667
790
  });
791
+ const result = await applyOutputAcceptancePolicy(
792
+ rawResult,
793
+ normalized.accept_output,
794
+ );
668
795
  return {
669
796
  branches: [],
670
797
  commands: [formatInvocationDetail(invocation)],
@@ -700,6 +827,14 @@ async function executeTemplateConfig(
700
827
  actorRecipeContext,
701
828
  ),
702
829
  );
830
+ const branchPipelineStdouts = await Promise.all(
831
+ branchResults.map((item) => resolvePipelineStdout(item.result)),
832
+ );
833
+ for (const [index, item] of branchResults.entries()) {
834
+ if (item.result.stdoutTruncated && branchPipelineStdouts[index] === undefined) {
835
+ item.result = rejectIncompletePipelineOutput(item.result);
836
+ }
837
+ }
703
838
  const commands = branchResults.flatMap((item) => item.commands);
704
839
  const failures = branchResults.flatMap((item) => item.failures);
705
840
  const branches = branchResults.map((item, index) =>
@@ -748,9 +883,14 @@ async function executeTemplateConfig(
748
883
  const successful = branchResults.map((item) => {
749
884
  if (item.result.code === 0) return item.result;
750
885
  addResultFailure(failures, item);
751
- return { ...item.result, code: 0, stdout: "" };
886
+ return { ...item.result, code: 0 };
752
887
  });
753
- const result = {
888
+ const completeSuccessful = successful.map((item, index) => ({
889
+ ...item,
890
+ stdout: branchPipelineStdouts[index] ?? item.stdout,
891
+ evidenceRef: undefined,
892
+ }));
893
+ const result: ToolExecResult = {
754
894
  code: 0,
755
895
  killed: successful.some((item) => item.killed),
756
896
  stderr: successful
@@ -758,6 +898,18 @@ async function executeTemplateConfig(
758
898
  .filter(Boolean)
759
899
  .join("\n"),
760
900
  stdout: joinParallelStdout(branches, successful, minSuccessful),
901
+ pipelineStdout: joinParallelStdout(
902
+ branches,
903
+ completeSuccessful,
904
+ minSuccessful,
905
+ ),
906
+ stdoutBytes: successful.reduce(
907
+ (total, item) => total + (item.stdoutBytes ?? Buffer.byteLength(item.stdout)),
908
+ 0,
909
+ ),
910
+ ...(successful.some((item) => item.stdoutTruncated)
911
+ ? { stdoutTruncated: true }
912
+ : {}),
761
913
  };
762
914
  if (quorumUnmet && nodeFailure === "root") {
763
915
  return {
@@ -821,7 +973,7 @@ async function executeTemplateConfig(
821
973
  [];
822
974
  let nextStdin = stdin;
823
975
  let result: ToolExecResult | undefined;
824
- for (const step of steps) {
976
+ for (const [stepIndex, step] of steps.entries()) {
825
977
  const executed = await executeTemplateConfig(
826
978
  step,
827
979
  context,
@@ -836,7 +988,24 @@ async function executeTemplateConfig(
836
988
  branches.push(...executed.branches);
837
989
  commands.push(...executed.commands);
838
990
  failures.push(...executed.failures);
839
- result = executed.result;
991
+ const pipelineStdout = await resolvePipelineStdout(executed.result);
992
+ const incompletePipelineInput =
993
+ stepIndex < steps.length - 1 && pipelineStdout === undefined;
994
+ result = incompletePipelineInput
995
+ ? rejectIncompletePipelineOutput(executed.result)
996
+ : executed.result;
997
+ executed.result = result;
998
+ if (incompletePipelineInput) {
999
+ addResultFailure(failures, executed);
1000
+ return {
1001
+ branches,
1002
+ commands,
1003
+ criticalFailure: true,
1004
+ failureScope: "root",
1005
+ failures,
1006
+ result,
1007
+ };
1008
+ }
840
1009
  if (result.code !== 0) {
841
1010
  const failureScope = maxFailureScope(
842
1011
  executed.failureScope,
@@ -870,7 +1039,7 @@ async function executeTemplateConfig(
870
1039
  nextStdin = "";
871
1040
  continue;
872
1041
  }
873
- nextStdin = result.stdout;
1042
+ nextStdin = pipelineStdout;
874
1043
  }
875
1044
  return { branches, commands, failures, result: result! };
876
1045
  }
@@ -895,6 +1064,19 @@ async function executeTemplateSteps(
895
1064
  );
896
1065
  }
897
1066
 
1067
+ function getCaptureDetails(result: ToolExecResult): Record<string, unknown> {
1068
+ return {
1069
+ stdoutBytes: result.stdoutBytes ?? Buffer.byteLength(result.stdout),
1070
+ stdoutCapturedBytes: Buffer.byteLength(result.stdout),
1071
+ stderrBytes: result.stderrBytes ?? Buffer.byteLength(result.stderr),
1072
+ stderrCapturedBytes: Buffer.byteLength(result.stderr),
1073
+ ...(result.stdoutFile ? { stdoutFile: result.stdoutFile } : {}),
1074
+ ...(result.stderrFile ? { stderrFile: result.stderrFile } : {}),
1075
+ ...(result.stdoutTruncated ? { stdoutTruncated: true } : {}),
1076
+ ...(result.stderrTruncated ? { stderrTruncated: true } : {}),
1077
+ };
1078
+ }
1079
+
898
1080
  export async function executeRegisteredTool(
899
1081
  cfg: RegisteredTool,
900
1082
  params: Record<string, unknown>,
@@ -925,7 +1107,9 @@ export async function executeRegisteredTool(
925
1107
  branches: executed.branches,
926
1108
  code: result.code,
927
1109
  command,
1110
+ fullOutputPath: result.stdoutFile ?? formatted.fullOutputPath,
928
1111
  killed: result.killed,
1112
+ ...getCaptureDetails(result),
929
1113
  ...(executed.failures.length > 0
930
1114
  ? { nonCriticalFailures: executed.failures }
931
1115
  : {}),
@@ -950,8 +1134,9 @@ export async function executeRegisteredTool(
950
1134
  details: {
951
1135
  code: result.code,
952
1136
  command,
953
- fullOutputPath: formatted.fullOutputPath,
1137
+ fullOutputPath: result.stdoutFile ?? formatted.fullOutputPath,
954
1138
  killed: result.killed,
1139
+ ...getCaptureDetails(result),
955
1140
  ...(executed.branches.length > 0 ? { branches: executed.branches } : {}),
956
1141
  ...(executed.failures.length > 0
957
1142
  ? { nonCriticalFailures: executed.failures }
package/lib/file-state.ts CHANGED
@@ -4,9 +4,112 @@
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
6
 
7
- import { randomUUID } from "node:crypto";
8
- import { mkdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
- import { dirname } from "node:path";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import {
9
+ existsSync,
10
+ mkdirSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ renameSync,
14
+ rmSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "node:fs";
19
+ import { tmpdir } from "node:os";
20
+ import { basename, dirname, join, parse, resolve } from "node:path";
21
+
22
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
23
+ const FILE_MUTATION_LOCK_STALE_MS = 30000;
24
+ const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
25
+
26
+ function canonicalMutationPath(path: string): string {
27
+ const absolute = resolve(path);
28
+ const suffix: string[] = [];
29
+ let existing = absolute;
30
+ while (!existsSync(existing)) {
31
+ const parent = dirname(existing);
32
+ if (parent === existing || existing === parse(existing).root) break;
33
+ suffix.unshift(basename(existing));
34
+ existing = parent;
35
+ }
36
+ const canonicalAncestor = existsSync(existing)
37
+ ? realpathSync.native(existing)
38
+ : existing;
39
+ const canonical = resolve(canonicalAncestor, ...suffix);
40
+ return process.platform === "win32" ? canonical.toLowerCase() : canonical;
41
+ }
42
+
43
+ function mutationLockPath(path: string): string {
44
+ const key = createHash("sha256")
45
+ .update(canonicalMutationPath(path))
46
+ .digest("hex");
47
+ return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
48
+ }
49
+
50
+ function lockOwnerIsDead(lockPath: string): boolean {
51
+ try {
52
+ const owner = JSON.parse(
53
+ readFileSync(join(lockPath, "owner.json"), "utf8"),
54
+ ) as { pid?: unknown };
55
+ const pid = Number(owner.pid);
56
+ if (!Number.isInteger(pid) || pid <= 0) return false;
57
+ try {
58
+ process.kill(pid, 0);
59
+ return false;
60
+ } catch (error) {
61
+ return (error as NodeJS.ErrnoException).code === "ESRCH";
62
+ }
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ export function withFileMutationLock<T>(path: string, mutate: () => T): T {
69
+ mkdirSync(FILE_MUTATION_LOCK_ROOT, { recursive: true });
70
+ const lockPath = mutationLockPath(path);
71
+ const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
72
+ for (;;) {
73
+ try {
74
+ mkdirSync(lockPath);
75
+ try {
76
+ writeFileSync(
77
+ join(lockPath, "owner.json"),
78
+ `${JSON.stringify({ pid: process.pid, acquired_at: new Date().toISOString() })}\n`,
79
+ "utf8",
80
+ );
81
+ } catch (error) {
82
+ rmSync(lockPath, { recursive: true, force: true });
83
+ throw error;
84
+ }
85
+ break;
86
+ } catch (error) {
87
+ try {
88
+ if (
89
+ Date.now() - statSync(lockPath).mtimeMs >
90
+ FILE_MUTATION_LOCK_STALE_MS &&
91
+ lockOwnerIsDead(lockPath)
92
+ ) {
93
+ rmSync(lockPath, { recursive: true, force: true });
94
+ continue;
95
+ }
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (Date.now() >= deadline) {
100
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
101
+ cause: error,
102
+ });
103
+ }
104
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
105
+ }
106
+ }
107
+ try {
108
+ return mutate();
109
+ } finally {
110
+ rmSync(lockPath, { recursive: true, force: true });
111
+ }
112
+ }
10
113
 
11
114
  export function writeJsonAtomic(path: string, value: unknown): void {
12
115
  mkdirSync(dirname(path), { recursive: true });
@@ -146,6 +146,12 @@ export function deliverRunTransitionNotifications(
146
146
  display: true,
147
147
  details: transition,
148
148
  });
149
+ if (transition.stateDir) {
150
+ AsyncRuns.markRunTerminalNotificationHandled(
151
+ transition.stateDir,
152
+ transition.to,
153
+ );
154
+ }
149
155
  }
150
156
  }
151
157
 
@@ -747,9 +753,9 @@ export function detectRunTransitions(
747
753
  for (const run of summary.runs) {
748
754
  const key = runObservationKey(run);
749
755
  const old = previous.get(key);
750
- if (old && old !== run.status && TERMINAL.has(run.status)) {
756
+ if (!run.terminalHandled && TERMINAL.has(run.status)) {
751
757
  transitions.push({
752
- from: old,
758
+ from: old ?? "running",
753
759
  run: run.run,
754
760
  ...(run.stateDir ? { stateDir: run.stateDir } : {}),
755
761
  ...(run.artifacts ? { artifacts: run.artifacts } : {}),
package/lib/prompts.ts CHANGED
@@ -22,17 +22,17 @@ export const REGISTER_TOOL_GUIDELINES = [
22
22
  export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
23
23
  - Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.
24
24
  - Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
25
- - 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.
25
+ - Command templates stay sync and shell-free: string leaves split into executable + argv, so operators such as && are literal arguments; use template arrays for sequencing or an explicit trusted shell/script when shell semantics are required. Flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, accept_output, output.
26
26
  - Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
27
27
  - ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.
28
28
  - Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.
29
29
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
30
30
  - 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.
31
31
  - 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.
32
- - Run state lives under ~/.pi/agent/tmp/pi-actors/runs; inspect status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
32
+ - Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. Terminal and coordinator-bound notifications queue as Pi follow-ups so concurrently completed actors can reach the coordinator after current work instead of steering between tool calls. When a deferred actor result gates the next step, wait for its terminal follow-up; 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.
33
33
  - 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.
34
34
  - 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.
35
- - 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.`;
35
+ - For any non-trivial actor use or pi-actors change, read the bundled actors skill first. Before launching multiple actors/subagents for parallel implementation, independent artifact generation, delegated audit, or review, also read the bundled swarm skill; the coordinator owns decomposition, disjoint scopes, launch correctness, integration, and final validation. For deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.`;
36
36
 
37
37
  export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
38
38
  name: "Tool name in snake_case (e.g., 'transcribe')",
@@ -42,8 +42,6 @@ export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
42
42
  "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.",
43
43
  async:
44
44
  "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
45
- state_dir:
46
- "Optional async run state directory for a co-located template recipe.",
47
45
  template:
48
46
  "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.",
49
47
  templateArray:
@@ -63,10 +63,10 @@ function isPiFileArgument(arg: string): boolean {
63
63
  return arg.startsWith("@") && arg.length > 1;
64
64
  }
65
65
 
66
- export function findPiPrintPromptIndex(args: string[]): number | undefined {
66
+ function findPiPrintPromptIndexes(args: string[]): number[] {
67
67
  let printMode = false;
68
68
  let positionalOnly = false;
69
- let promptIndex: number | undefined;
69
+ const promptIndexes: number[] = [];
70
70
  for (let index = 0; index < args.length; index += 1) {
71
71
  const arg = args[index];
72
72
  if (!positionalOnly && arg === "--") {
@@ -82,9 +82,13 @@ export function findPiPrintPromptIndex(args: string[]): number | undefined {
82
82
  continue;
83
83
  }
84
84
  if (!printMode || isPiFileArgument(arg)) continue;
85
- promptIndex = index;
85
+ promptIndexes.push(index);
86
86
  }
87
- return promptIndex;
87
+ return promptIndexes;
88
+ }
89
+
90
+ export function findPiPrintPromptIndex(args: string[]): number | undefined {
91
+ return findPiPrintPromptIndexes(args).at(-1);
88
92
  }
89
93
 
90
94
  function matchesActorContext(
@@ -175,13 +179,17 @@ export function materializePiPrintPromptArg(
175
179
  promptFile: string | (() => string),
176
180
  ): MaterializedPiPrintPromptArgs {
177
181
  if (!isPiCommand(command)) return { args };
178
- const promptIndex = findPiPrintPromptIndex(args);
179
- if (promptIndex === undefined) return { args };
180
- const prompt = args[promptIndex];
182
+ const promptIndexes = findPiPrintPromptIndexes(args);
183
+ if (promptIndexes.length === 0) return { args };
184
+ const prompt = promptIndexes.map((index) => args[index]).join(" ");
181
185
  const path = typeof promptFile === "function" ? promptFile() : promptFile;
182
186
  writeFileSync(path, prompt, "utf8");
183
- const next = [...args];
184
- next[promptIndex] = `@${path}`;
187
+ const promptIndexSet = new Set(promptIndexes);
188
+ const firstPromptIndex = promptIndexes[0];
189
+ const next = args.flatMap((arg, index) => {
190
+ if (index === firstPromptIndex) return [`@${path}`];
191
+ return promptIndexSet.has(index) ? [] : [arg];
192
+ });
185
193
  return {
186
194
  args: next,
187
195
  promptBytes: Buffer.byteLength(prompt),
@@ -14,6 +14,7 @@ import type { RegisteredTool } from "./config.ts";
14
14
  import * as ModelContext from "./model-context.ts";
15
15
  import type { TemplateRecipeConfig } from "./recipes-references.ts";
16
16
  import * as RecipesReferences from "./recipes-references.ts";
17
+ import * as RecipesUsage from "./recipes-usage.ts";
17
18
  import * as Schema from "./schema.ts";
18
19
 
19
20
  export interface DiscoveredRecipe {
@@ -374,9 +375,13 @@ export function discoverRecipes(roots: string[]): RecipesDiscoveryResult {
374
375
  }
375
376
 
376
377
  function recipeUsage(
377
- config: TemplateRecipeConfig | undefined,
378
+ entry: DiscoveredRecipe,
378
379
  ): Record<string, unknown> | undefined {
379
- const usage = (config as { usage?: unknown } | undefined)?.usage;
380
+ const stored = entry.mutableUsage
381
+ ? RecipesUsage.readRecipeUsage(entry.path)
382
+ : undefined;
383
+ if (stored) return stored;
384
+ const usage = (entry.config as { usage?: unknown } | undefined)?.usage;
380
385
  return usage && typeof usage === "object" && !Array.isArray(usage)
381
386
  ? (usage as Record<string, unknown>)
382
387
  : undefined;
@@ -409,7 +414,7 @@ function cleanupRecommendation(
409
414
  actions: ["keep disabled", "delete", "archive"],
410
415
  };
411
416
  }
412
- const usage = recipeUsage(entry.config);
417
+ const usage = recipeUsage(entry);
413
418
  const calls = Number(usage?.calls ?? 0);
414
419
  if (entry.mutableUsage && entry.tool && calls === 0) {
415
420
  return {
@@ -767,8 +772,8 @@ export function summarizeDiscovery(
767
772
  }
768
773
  : {}),
769
774
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
770
- ...(recipeUsage(entry.config)
771
- ? { usage: recipeUsage(entry.config) }
775
+ ...(recipeUsage(entry)
776
+ ? { usage: recipeUsage(entry) }
772
777
  : {}),
773
778
  }))
774
779
  .sort((a, b) => a.id.localeCompare(b.id)),
@@ -58,6 +58,7 @@ export interface TemplateRecipeDefinition {
58
58
  when?: boolean | string;
59
59
  timeout?: number | string;
60
60
  delay?: number | string;
61
+ accept_output?: "review_evidence";
61
62
  output?: string;
62
63
  artifacts?: Record<string, string>;
63
64
  mailbox?: TemplateRecipeMailbox;
@@ -72,7 +73,6 @@ export interface TemplateRecipeDefinition {
72
73
 
73
74
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
74
75
  async?: boolean;
75
- state_dir?: string;
76
76
  }
77
77
 
78
78
  interface ImportedRecipe {
@@ -263,6 +263,7 @@ function getRecipeCommandTemplate(
263
263
  "when",
264
264
  "timeout",
265
265
  "delay",
266
+ "accept_output",
266
267
  "output",
267
268
  "retry",
268
269
  "failure",
@@ -999,11 +1000,6 @@ export function readResolvedRecipeConfig(
999
1000
  : delegated?.async === false
1000
1001
  ? { async: false }
1001
1002
  : {}),
1002
- ...(typeof substituted.state_dir === "string"
1003
- ? { state_dir: substituted.state_dir }
1004
- : typeof delegated?.state_dir === "string"
1005
- ? { state_dir: delegated.state_dir }
1006
- : {}),
1007
1003
  ...(Object.keys(imports).length > 0
1008
1004
  ? { imports: getRecipeImports(raw) }
1009
1005
  : {}),
@@ -1040,6 +1036,9 @@ export function readResolvedRecipeConfig(
1040
1036
  typeof substituted.delay === "string"
1041
1037
  ? { delay: substituted.delay }
1042
1038
  : {}),
1039
+ ...(substituted.accept_output === "review_evidence"
1040
+ ? { accept_output: substituted.accept_output }
1041
+ : {}),
1043
1042
  ...(typeof substituted.output === "string"
1044
1043
  ? { output: substituted.output }
1045
1044
  : {}),