@llblab/pi-actors 0.38.1 → 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 +11 -4
  2. package/BACKLOG.md +2 -11
  3. package/CHANGELOG.md +51 -0
  4. package/README.md +120 -131
  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
@@ -19,7 +19,7 @@ async: true = run through detached lifecycle
19
19
 
20
20
  A recipe wraps one command-template tree. The wrapped `template` keeps the normal command-template semantics: argv splitting, placeholders, defaults, typed args, sequence, `parallel: true`, `when`, delay, retry, failure propagation, recover cleanup, and output selection.
21
21
 
22
- Layer boundary: `imports`, `{ "name": "alias" }` imported-recipe nodes, `{alias.defaults.key}` references, fallback expressions, and recipe-local ternaries are recipe-loading features. They resolve before the command-template graph runs and do not extend the portable Command Template Standard. Typed imports are recipe definitions: they expose the imported recipe's command-template-shaped metadata (`template`, `args`, `defaults`, flags, and `values`), while async-run launch fields such as `async`, `state_dir`, and `retire_when` remain lifecycle configuration for starting a run, not part of the imported execution graph.
22
+ Layer boundary: `imports`, `{ "name": "alias" }` imported-recipe nodes, `{alias.defaults.key}` references, fallback expressions, and recipe-local ternaries are recipe-loading features. They resolve before the command-template graph runs and do not extend the portable Command Template Standard. Typed imports are recipe definitions: they expose the imported recipe's command-template-shaped metadata (`template`, `args`, `defaults`, flags, and `values`), while async-run launch fields such as `async` and `retire_when` remain lifecycle configuration for starting a run, not part of the imported execution graph. Run state directories are runtime-owned and are not recipe or `register_tool` configuration; `{state_dir}` remains an injected run-local value for commands and artifact paths.
23
23
 
24
24
  Packaged recipes are the pi-actors recipe standard library: declarative actor config components that can be imported, launched, inspected, overridden, or composed by user recipes. Treat them as stable building blocks rather than user-local policy.
25
25
 
@@ -109,18 +109,16 @@ Higher-priority files shadow lower-priority files with the same basename. Within
109
109
 
110
110
  ## Usage Metadata
111
111
 
112
- User-owned recipes may accumulate extension-maintained usage metadata:
112
+ User-owned recipe launches may accumulate extension-maintained usage metadata in `.usage/<recipe-filename>.json` sidecars:
113
113
 
114
114
  ```json
115
115
  {
116
- "usage": {
117
- "calls": 12,
118
- "last_called": "2026-05-22T10:30:00.000Z"
119
- }
116
+ "calls": 12,
117
+ "last_called": "2026-05-22T10:30:00.000Z"
120
118
  }
121
119
  ```
122
120
 
123
- The extension increments `usage.calls` and updates `usage.last_called` when it starts that concrete recipe, either through a recipe-backed tool call or a direct async recipe-file run. It also stores a content `usage.fingerprint`; if the authored recipe content changes, the next launch resets `usage.calls` before counting the new launch and records `usage.reset_at`. Agents should treat these fields as cleanup evidence, not as authored recipe contract. Packaged standard-library recipes are not mutated for usage metadata.
121
+ The extension increments `calls` and updates `last_called` when it starts that concrete recipe, either through a recipe-backed tool call or a direct async recipe-file run. The sidecar also stores a content `fingerprint`; if authored recipe content changes, the next launch resets `calls` before counting the new launch and records `reset_at`. Keeping telemetry outside the recipe prevents usage writes from replacing concurrent operator edits; discovery merges sidecar usage into inspection. Agents should treat these fields as cleanup evidence, not as authored recipe contract. Packaged standard-library recipes do not receive usage metadata.
124
122
 
125
123
  There is intentionally no failure counter in the recipe contract. A failed launch can reflect caller misuse, missing runtime values, or an environmental problem rather than recipe uselessness. Cleanup decisions should be explicit operator work: keep as a tool, move out of the agent recipe root to retain recipe-only memory, merge, delete, or archive.
126
124
 
@@ -16,9 +16,9 @@ The registry source is location-discovered recipes, not a live tool-only JSON fi
16
16
  - Recipe identity is the filename basename; `~/.pi/agent/recipes/docs_review.json` and `docs_review.md` both have id/tool name `docs_review`.
17
17
  - Same-id JSON shadows Markdown in the same priority layer.
18
18
 
19
- Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` on user-owned recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning rather than an older file history. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
19
+ Because the user recipe directory is sticky agent muscle memory, runtime launches update `usage.calls`, `usage.last_called`, and a content `usage.fingerprint` in `.usage/<recipe-filename>.json` sidecars rather than rewriting authored recipe files. If authored recipe content changes, the next launch resets `usage.calls` and records `usage.reset_at` before counting the launch, so usage evidence follows the current recipe meaning without racing operator edits. Discovery and file-watcher refresh merge sidecar usage into inspect summaries. `inspect target=recipes view=summary verbose=true` includes usage metadata and operator-gated cleanup recommendations for invalid, shadowed, disabled, component-only, unused, or overriding recipes. Recommended actions stay explicit: keep as a tool/component, enable, merge, fix, delete, or archive. The extension does not maintain a failure counter and agents should not silently clean tools during unrelated work.
20
20
 
21
- `register_tool` is the preferred agent-facing mutation API. It creates, updates, and deletes recipe files in `~/.pi/agent/recipes`; agents do not need to edit the files directly for normal registration. Direct file edits are still valid for operators and advanced agents. Runtime behavior is reactive: file creation, deletion, or edits in the user recipe root trigger validation and tool-set refresh, with invalid recipes surfaced as diagnostics rather than silently ignored.
21
+ `register_tool` is the preferred agent-facing mutation API. It creates, updates, and deletes recipe files in `~/.pi/agent/recipes`; agents do not need to edit the files directly for normal registration. Extension-authored register, update, delete, draft-promotion, and usage-metadata mutations hold a cross-process lock keyed by filesystem-canonical recipe identity across the complete check/read/write/runtime-update window. Existing targets or the nearest existing parent are resolved through `realpath`, so real and symlink aliases serialize while unrelated recipes remain independent; stale locks are reclaimed only after their owner is proven dead. Direct file edits are still valid for operators and advanced agents. Runtime behavior is reactive: file creation, deletion, or edits in the user recipe root trigger validation and tool-set refresh, with invalid recipes surfaced as diagnostics rather than silently ignored. If the recipe root does not exist at session start, an advisory parent watcher detects its creation and switches to the normal root watcher; deletion or rename rearms the parent watcher without polling.
22
22
 
23
23
  Inspect the loaded pi-actors runtime and discovered registry with:
24
24
 
@@ -36,6 +36,8 @@ The recipe summary reports active, shadowed, invalid, disabled, and diagnostic e
36
36
 
37
37
  Routine shadowing is quiet. If a bare `spawn` recipe launch already fails because an invalid or `disabled: true` user recipe blocks a lower-priority fallback, the launch error adds compact tokens such as `reason=shadowed_invalid` or `reason=shadowed_disabled`, `active_path`, `blocked_fallback`, and `hint=inspect_recipes_doctor`.
38
38
 
39
+ Pi cannot currently unregister an already published dynamic tool definition from the complete host registry. The extension therefore gates its own `message to=tool:<name>` and `inspect tool:<name>` lookup through the current recipe registry: deleting or externally removing a recipe immediately makes those routes inactive, and recipe updates replace the extension-local executable definition even if stale host metadata remains visible until reload.
40
+
39
41
  ## Registering Tools
40
42
 
41
43
  `register_tool` is the interactive API for listing, creating, updating, or deleting persistent tools. Call it without arguments to list registered tools.
package/index.ts CHANGED
@@ -36,7 +36,11 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
36
36
  AsyncRuns.sendRunMessage(candidate.stateDir, "stop"),
37
37
  });
38
38
  };
39
- const updateRunUi = (ctx: Pi.ExtensionContext, notify = false): void => {
39
+ const updateRunUi = (
40
+ ctx: Pi.ExtensionContext,
41
+ notify = false,
42
+ terminalOnly = false,
43
+ ): void => {
40
44
  const ownerId = getRunOwnerId(ctx);
41
45
  const snapshot = Observability.readRunUiSnapshot(runUi, ownerId);
42
46
  ctx.ui.setStatus(
@@ -78,10 +82,12 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
78
82
  notificationSink,
79
83
  );
80
84
  Observability.pruneRunUiObservationState(runUi, snapshot);
81
- Observability.deliverRunOutboxNotifications(
82
- snapshot.outboxEvents,
83
- notificationSink,
84
- );
85
+ if (!terminalOnly) {
86
+ Observability.deliverRunOutboxNotifications(
87
+ snapshot.outboxEvents,
88
+ notificationSink,
89
+ );
90
+ }
85
91
  };
86
92
  const closeRunWatchers = (): void => {
87
93
  runWatcher.close();
@@ -144,7 +150,7 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
144
150
  activeRunContext = ctx;
145
151
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
146
152
  runtime.loadTools(ctx);
147
- updateRunUi(ctx);
153
+ updateRunUi(ctx, true, true);
148
154
  closeRunWatchers();
149
155
  recipeReload.close();
150
156
  runWatcher.refresh();
@@ -204,7 +210,12 @@ export default function toolRegistryExtension(pi: Pi.ExtensionAPI) {
204
210
  Tools.createCoreActorToolDefinitions<Pi.ExtensionContext>({
205
211
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
206
212
  getActiveTools: () => pi.getActiveTools(),
207
- getRuntimeTool: (name) => actorToolDefinitions.get(name),
213
+ getRuntimeTool: (name) =>
214
+ Tools.resolveActiveRuntimeTool(
215
+ name,
216
+ runtime.getTools(),
217
+ (activeName) => actorToolDefinitions.get(activeName),
218
+ ),
208
219
  registryRuntime: runtime,
209
220
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
210
221
  }).map(withCurrentThinkingContext),
package/lib/async-runs.ts CHANGED
@@ -10,11 +10,12 @@ import {
10
10
  mkdirSync,
11
11
  openSync,
12
12
  readFileSync,
13
+ readdirSync,
13
14
  rmSync,
14
15
  statSync,
15
16
  writeFileSync,
16
17
  } from "node:fs";
17
- import { basename, dirname, extname, join, resolve } from "node:path";
18
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
18
19
  import { fileURLToPath } from "node:url";
19
20
 
20
21
  import type {
@@ -50,11 +51,12 @@ import {
50
51
  parseRunOutboxEventLine,
51
52
  type RunOutboxEvent,
52
53
  } from "./runs-outbox.ts";
54
+ import { claimRunStateDirectory } from "./runs-ownership.ts";
53
55
  import { archiveTerminalRun, pruneTerminalRun } from "./runs-retention.ts";
54
56
  import {
55
- isAlive,
56
- isWithinRunnerIdentityGrace,
57
- pidMatchesRun,
57
+ captureRunProcessIdentity,
58
+ verifyRunProcessIdentity,
59
+ type RunProcessIdentity,
58
60
  } from "./runs-process.ts";
59
61
  import * as RunsStart from "./runs-start.ts";
60
62
  import * as RunsIndex from "./runs-index.ts";
@@ -105,6 +107,7 @@ export interface AsyncRunStartParams {
105
107
  when?: boolean | string;
106
108
  timeout?: number | string;
107
109
  delay?: number | string;
110
+ accept_output?: "review_evidence";
108
111
  output?: string;
109
112
  artifacts?: Record<string, RunArtifactDeclaration>;
110
113
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
@@ -146,6 +149,7 @@ export interface AsyncRunMeta {
146
149
  control?: AsyncRunControlEndpoint;
147
150
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
148
151
  model_policy?: CurrentPolicyProvenance;
152
+ process_identity?: RunProcessIdentity;
149
153
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
150
154
  retire_when?: "children_terminal";
151
155
  }
@@ -196,6 +200,7 @@ function resolveRunTemplate(params: AsyncRunStartParams): {
196
200
  "when",
197
201
  "timeout",
198
202
  "delay",
203
+ "accept_output",
199
204
  "output",
200
205
  "retry",
201
206
  "failure",
@@ -445,6 +450,7 @@ export function startRun(
445
450
  mkdirSync(stateDir, { recursive: true });
446
451
  const releaseStartLock = acquireStateStartLock(stateDir);
447
452
  try {
453
+ claimRunStateDirectory(stateDir, run);
448
454
  assertNoActiveRunState(stateDir);
449
455
  prepareStateDirForStart(stateDir);
450
456
  const stdout = join(stateDir, "stdout.log");
@@ -512,6 +518,13 @@ export function startRun(
512
518
  closeSync(outFd);
513
519
  closeSync(errFd);
514
520
  meta.pid = child.pid ?? 0;
521
+ const processIdentity = captureRunProcessIdentity(
522
+ meta.pid,
523
+ cwd,
524
+ stateDir,
525
+ RUNNER_PATH,
526
+ );
527
+ if (processIdentity) meta.process_identity = processIdentity;
515
528
  writeJsonAtomic(join(stateDir, "run.json"), meta);
516
529
  writeJsonAtomic(join(stateDir, "progress.json"), {
517
530
  completed: 0,
@@ -704,15 +717,23 @@ export async function sendRunMessage(
704
717
  const status = getRunStatus(runOrDir);
705
718
  const stateDir = String(status.state_dir);
706
719
  const run = String(status.run ?? runOrDir);
707
- if (status.status !== "running")
708
- throw new Error(`Run is not running: ${run}`);
709
720
  const pid = Number(status.pid || 0);
710
- if (!pid || !isAlive(pid)) throw new Error(`Run pid is not alive: ${run}`);
711
- if (
712
- !pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH) &&
713
- !isWithinRunnerIdentityGrace(status, RUNNER_IDENTITY_GRACE_MS)
714
- )
715
- throw new Error(`Run pid owner mismatch: ${run}`);
721
+ const identity = verifyRunProcessIdentity(
722
+ pid,
723
+ status.process_identity as RunProcessIdentity | undefined,
724
+ );
725
+ if (status.status !== "running") {
726
+ if (
727
+ identity.status === "owner_mismatch" ||
728
+ identity.status === "unsupported_proof"
729
+ ) {
730
+ throw new Error(`Run process identity ${identity.status}: ${run}`);
731
+ }
732
+ throw new Error(`Run is not running: ${run}`);
733
+ }
734
+ if (!identity.valid) {
735
+ throw new Error(`Run process identity ${identity.status}: ${run}`);
736
+ }
716
737
  return deliverRunMessage(status, run, stateDir, message, options);
717
738
  }
718
739
 
@@ -734,6 +755,66 @@ function markTerminalProgress(
734
755
  );
735
756
  }
736
757
 
758
+ function finalizeInterruptedReviewEvidence(
759
+ stateDir: string,
760
+ phase: "cancelled" | "killed",
761
+ signal: NodeJS.Signals,
762
+ ): void {
763
+ const evidencePath = join(stateDir, "review-evidence.json");
764
+ const manifest = readJson(evidencePath);
765
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return;
766
+ const record = manifest as Record<string, unknown>;
767
+ if (!Array.isArray(record.commands)) return;
768
+ const completedAt = new Date().toISOString();
769
+ const effectiveExitCode = signal === "SIGKILL" ? 137 : 143;
770
+ const commands = record.commands.map((command) => {
771
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
772
+ return command;
773
+ }
774
+ const entry = command as Record<string, unknown>;
775
+ if (entry.status !== "running" || typeof entry.id !== "string") return entry;
776
+ const captureDir = join(stateDir, "captures", entry.id);
777
+ const attempts = existsSync(captureDir)
778
+ ? readdirSync(captureDir)
779
+ .filter((name) => /^attempt-\d+$/.test(name))
780
+ .sort()
781
+ .map((name, index) => {
782
+ const attemptDir = join(captureDir, name);
783
+ const stdoutFile = join(attemptDir, "stdout.log");
784
+ const stderrFile = join(attemptDir, "stderr.log");
785
+ return {
786
+ attempt: index + 1,
787
+ stdout: {
788
+ path: relative(stateDir, stdoutFile),
789
+ bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
790
+ },
791
+ stderr: {
792
+ path: relative(stateDir, stderrFile),
793
+ bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
794
+ },
795
+ };
796
+ })
797
+ : [];
798
+ return {
799
+ ...entry,
800
+ status: phase,
801
+ completed_at: completedAt,
802
+ attempts,
803
+ effective_exit_code: effectiveExitCode,
804
+ killed: true,
805
+ ...(entry.semantic_acceptance === "pending"
806
+ ? { semantic_acceptance: "interrupted" }
807
+ : {}),
808
+ };
809
+ });
810
+ writeJsonAtomic(evidencePath, {
811
+ ...record,
812
+ status: phase,
813
+ commands,
814
+ updated_at: completedAt,
815
+ });
816
+ }
817
+
737
818
  function stopRun(
738
819
  runOrDir: string,
739
820
  signal: NodeJS.Signals,
@@ -742,12 +823,34 @@ function stopRun(
742
823
  const status = getRunStatus(runOrDir);
743
824
  const pid = Number(status.pid || 0);
744
825
  const stateDir = String(status.state_dir);
745
- if (status.status !== "running")
826
+ if (status.status !== "running" && status.status !== "exited") {
827
+ return { stopped: false, reason: "not running", status };
828
+ }
829
+ const identity = verifyRunProcessIdentity(
830
+ pid,
831
+ status.process_identity as RunProcessIdentity | undefined,
832
+ );
833
+ if (status.status === "exited") {
834
+ if (
835
+ identity.status === "owner_mismatch" ||
836
+ identity.status === "unsupported_proof"
837
+ ) {
838
+ return {
839
+ stopped: false,
840
+ reason: identity.status.replaceAll("_", " "),
841
+ process_identity_status: identity.status,
842
+ status,
843
+ };
844
+ }
746
845
  return { stopped: false, reason: "not running", status };
747
- if (!pid || !isAlive(pid))
748
- return { stopped: false, reason: "pid not alive", status };
749
- if (!pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH)) {
750
- return { stopped: false, reason: "pid owner mismatch", status };
846
+ }
847
+ if (!identity.valid) {
848
+ return {
849
+ stopped: false,
850
+ reason: identity.status.replaceAll("_", " "),
851
+ process_identity_status: identity.status,
852
+ status,
853
+ };
751
854
  }
752
855
  const signalResult = signalOwnedRunProcess(pid, signal);
753
856
  writeFileSync(
@@ -756,11 +859,27 @@ function stopRun(
756
859
  { flag: "a" },
757
860
  );
758
861
  markTerminalHandled(stateDir, { event, signal });
759
- if (event === "run.kill") markTerminalProgress(stateDir, "killed");
760
- if (event === "run.cancel") markTerminalProgress(stateDir, "cancelled");
862
+ if (event === "run.kill") {
863
+ finalizeInterruptedReviewEvidence(stateDir, "killed", signal);
864
+ markTerminalProgress(stateDir, "killed");
865
+ }
866
+ if (event === "run.cancel") {
867
+ finalizeInterruptedReviewEvidence(stateDir, "cancelled", signal);
868
+ markTerminalProgress(stateDir, "cancelled");
869
+ }
761
870
  return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
762
871
  }
763
872
 
873
+ export function markRunTerminalNotificationHandled(
874
+ stateDir: string,
875
+ status: string,
876
+ ): void {
877
+ markTerminalHandled(stateDir, {
878
+ event: "run.notification",
879
+ status,
880
+ });
881
+ }
882
+
764
883
  export function cancelRun(runOrDir: string): Record<string, unknown> {
765
884
  const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
766
885
  return Object.hasOwn(result, "stopped")
@@ -5,8 +5,9 @@
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process";
8
- import { homedir } from "node:os";
9
- import { isAbsolute, resolve } from "node:path";
8
+ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
9
+ import { homedir, tmpdir } from "node:os";
10
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
10
11
 
11
12
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
12
13
 
@@ -30,6 +31,7 @@ export interface CommandTemplateObjectConfig {
30
31
  defaults?: Record<string, unknown>;
31
32
  timeout?: number | string;
32
33
  delay?: number | string;
34
+ accept_output?: "review_evidence";
33
35
  output?: string;
34
36
  retry?: number | string;
35
37
  failure?: CommandTemplateFailureScope;
@@ -60,6 +62,8 @@ export interface CommandTemplateExecOptions {
60
62
  stdin?: string;
61
63
  killGrace?: number;
62
64
  retry?: number;
65
+ captureDir?: string;
66
+ captureLimitBytes?: number;
63
67
  }
64
68
 
65
69
  export interface CommandTemplateExecResult {
@@ -67,6 +71,12 @@ export interface CommandTemplateExecResult {
67
71
  stderr: string;
68
72
  code: number;
69
73
  killed: boolean;
74
+ stdoutBytes?: number;
75
+ stderrBytes?: number;
76
+ stdoutFile?: string;
77
+ stderrFile?: string;
78
+ stdoutTruncated?: boolean;
79
+ stderrTruncated?: boolean;
70
80
  }
71
81
 
72
82
  export type CommandTemplateRiskLabel =
@@ -80,6 +90,8 @@ export type CommandTemplateRiskLabel =
80
90
  | "risk.platform_specific"
81
91
  | "risk.secret_touching";
82
92
 
93
+ const DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES = 1024 * 1024;
94
+
83
95
  const COMMAND_TEMPLATE_RISK_LABEL_ORDER: CommandTemplateRiskLabel[] = [
84
96
  "risk.shell",
85
97
  "risk.eval",
@@ -584,9 +596,9 @@ export function expandCommandTemplateExecutable(
584
596
  cwd: string,
585
597
  ): string {
586
598
  if (command === "~") return homedir();
587
- if (command.startsWith("~/")) return resolve(homedir(), command.slice(2));
599
+ if (command.startsWith("~/")) return resolvePath(homedir(), command.slice(2));
588
600
  if (command.includes("/") && !isAbsolute(command))
589
- return resolve(cwd, command);
601
+ return resolvePath(cwd, command);
590
602
  return command;
591
603
  }
592
604
 
@@ -840,13 +852,83 @@ export async function execCommandTemplate(
840
852
  killed: false,
841
853
  };
842
854
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
843
- const result = await execCommandTemplateOnce(command, args, options);
855
+ const attemptOptions = options.captureDir
856
+ ? {
857
+ ...options,
858
+ captureDir: join(
859
+ options.captureDir,
860
+ `attempt-${String(attempt).padStart(3, "0")}`,
861
+ ),
862
+ }
863
+ : options;
864
+ const result = await execCommandTemplateOnce(command, args, attemptOptions);
844
865
  if (result.code === 0) return result;
845
866
  lastResult = result;
846
867
  }
847
868
  return lastResult;
848
869
  }
849
870
 
871
+ interface BoundedCommandCapture {
872
+ append(value: Buffer | string): void;
873
+ result(): {
874
+ bytes: number;
875
+ content: string;
876
+ file?: string;
877
+ truncated: boolean;
878
+ };
879
+ }
880
+
881
+ function trimCaptureTail(value: Buffer, limit: number): Buffer {
882
+ if (value.length <= limit) return value;
883
+ let start = value.length - limit;
884
+ while (start < value.length && (value[start]! & 0xc0) === 0x80) start += 1;
885
+ return value.subarray(start);
886
+ }
887
+
888
+ function createBoundedCommandCapture(
889
+ stream: "stdout" | "stderr",
890
+ limit: number,
891
+ getCaptureDir: () => string,
892
+ persistCompleteStream: boolean,
893
+ ): BoundedCommandCapture {
894
+ let bytes = 0;
895
+ let content: Buffer = Buffer.alloc(0);
896
+ let file = persistCompleteStream
897
+ ? join(getCaptureDir(), `${stream}.log`)
898
+ : undefined;
899
+ if (file) writeFileSync(file, Buffer.alloc(0));
900
+ let truncated = false;
901
+ return {
902
+ append(value) {
903
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
904
+ bytes += chunk.length;
905
+ if (bytes > limit) truncated = true;
906
+ if (!file && bytes <= limit) {
907
+ content = Buffer.concat([content, chunk]);
908
+ return;
909
+ }
910
+ if (!file) {
911
+ file = join(getCaptureDir(), `${stream}.log`);
912
+ writeFileSync(file, content);
913
+ }
914
+ appendFileSync(file, chunk);
915
+ content = trimCaptureTail(Buffer.concat([content, chunk]), limit);
916
+ },
917
+ result() {
918
+ if (!file && persistCompleteStream) {
919
+ file = join(getCaptureDir(), `${stream}.log`);
920
+ writeFileSync(file, content);
921
+ }
922
+ return {
923
+ bytes,
924
+ content: content.toString("utf8"),
925
+ ...(file ? { file } : {}),
926
+ truncated,
927
+ };
928
+ },
929
+ };
930
+ }
931
+
850
932
  function execCommandTemplateOnce(
851
933
  command: string,
852
934
  args: string[],
@@ -858,8 +940,32 @@ function execCommandTemplateOnce(
858
940
  shell: false,
859
941
  stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
860
942
  });
861
- let stdout = "";
862
- let stderr = "";
943
+ const captureLimit = Math.max(
944
+ 1,
945
+ options.captureLimitBytes ?? DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES,
946
+ );
947
+ let captureDir: string | undefined;
948
+ const getCaptureDir = (): string => {
949
+ if (captureDir) return captureDir;
950
+ captureDir = options.captureDir
951
+ ? resolvePath(options.captureDir)
952
+ : mkdtempSync(join(tmpdir(), "pi-actors-command-"));
953
+ mkdirSync(captureDir, { recursive: true });
954
+ return captureDir;
955
+ };
956
+ const persistCompleteStreams = options.captureDir !== undefined;
957
+ const stdoutCapture = createBoundedCommandCapture(
958
+ "stdout",
959
+ captureLimit,
960
+ getCaptureDir,
961
+ persistCompleteStreams,
962
+ );
963
+ const stderrCapture = createBoundedCommandCapture(
964
+ "stderr",
965
+ captureLimit,
966
+ getCaptureDir,
967
+ persistCompleteStreams,
968
+ );
863
969
  let killed = false;
864
970
  let settled = false;
865
971
  let timeoutId: NodeJS.Timeout | undefined;
@@ -879,7 +985,20 @@ function execCommandTemplateOnce(
879
985
  if (killTimeoutId) clearTimeout(killTimeoutId);
880
986
  if (options.signal)
881
987
  options.signal.removeEventListener("abort", killProcess);
882
- resolve({ stdout, stderr, code, killed });
988
+ const stdout = stdoutCapture.result();
989
+ const stderr = stderrCapture.result();
990
+ resolve({
991
+ stdout: stdout.content,
992
+ stderr: stderr.content,
993
+ code,
994
+ killed,
995
+ stdoutBytes: stdout.bytes,
996
+ stderrBytes: stderr.bytes,
997
+ ...(stdout.file ? { stdoutFile: stdout.file } : {}),
998
+ ...(stderr.file ? { stderrFile: stderr.file } : {}),
999
+ ...(stdout.truncated ? { stdoutTruncated: true } : {}),
1000
+ ...(stderr.truncated ? { stderrTruncated: true } : {}),
1001
+ });
883
1002
  };
884
1003
  if (options.signal) {
885
1004
  if (options.signal.aborted) killProcess();
@@ -888,16 +1007,16 @@ function execCommandTemplateOnce(
888
1007
  }
889
1008
  if (options.timeout !== undefined && options.timeout > 0)
890
1009
  timeoutId = setTimeout(killProcess, options.timeout);
891
- proc.stdout?.on("data", (data) => {
892
- stdout += data.toString();
1010
+ proc.stdout?.on("data", (data: Buffer) => {
1011
+ stdoutCapture.append(data);
893
1012
  });
894
- proc.stderr?.on("data", (data) => {
895
- stderr += data.toString();
1013
+ proc.stderr?.on("data", (data: Buffer) => {
1014
+ stderrCapture.append(data);
896
1015
  });
897
1016
  proc.stdin?.on("error", () => {});
898
1017
  if (options.stdin !== undefined) proc.stdin?.end(options.stdin);
899
1018
  proc.on("error", (error) => {
900
- stderr += error instanceof Error ? error.message : String(error);
1019
+ stderrCapture.append(error instanceof Error ? error.message : String(error));
901
1020
  settle(1);
902
1021
  });
903
1022
  proc.on("close", (code) => {
package/lib/config.ts CHANGED
@@ -47,7 +47,6 @@ export function serializeTools(
47
47
  entry.defaults = cfg.storedDefaults;
48
48
  if (cfg.recipe?.name) entry.name = cfg.recipe.name;
49
49
  if (cfg.recipe?.async !== undefined) entry.async = cfg.recipe.async;
50
- if (cfg.recipe?.state_dir) entry.state_dir = cfg.recipe.state_dir;
51
50
  if (cfg.recipe?.values) entry.values = cfg.recipe.values;
52
51
  if (cfg.template) entry.template = cfg.template;
53
52
  result[name] = entry;
@@ -145,9 +144,6 @@ export function normalizeStoredTool(
145
144
  ? {
146
145
  name: recipeName,
147
146
  ...(typeof record.async === "boolean" ? { async: record.async } : {}),
148
- ...(typeof record.state_dir === "string" && record.state_dir.trim()
149
- ? { state_dir: record.state_dir.trim() }
150
- : {}),
151
147
  template,
152
148
  ...(record.values &&
153
149
  typeof record.values === "object" &&