@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
package/AGENTS.md CHANGED
@@ -102,20 +102,25 @@ Pi host
102
102
  ## Runtime Contract
103
103
 
104
104
  - Register trusted command templates with placeholder-derived args, progressive typed arg declarations, inline/default/`??`/ternary fallback, and split-first command argv construction.
105
+ - Serialize extension-authored recipe mutations with the canonical-path file lock across the complete check/read/write/runtime-update window; keep usage telemetry in locked `.usage/<recipe-filename>.json` sidecars so metadata cannot overwrite authored recipes, and do not replace keyed locking with a broad registry lock.
105
106
  - Keep command templates synchronous and portable; `async: true` is the detached run switch.
106
107
  - Preserve node controls: `when`, positive `timeout`, `delay`, bounded `retry`, `failure`, and `recover` cleanup.
108
+ - Persist every async command's complete byte-exact stdout/stderr under command- and retry-specific run-state paths while keeping returned tails bounded and pipeline stdin complete.
107
109
  - Keep async run state under `~/.pi/agent/tmp/pi-actors/runs` with injected `{run_id}` and `{state_dir}` values.
108
- - Preserve event-driven observability: terminal follow-ups, coordinator-bound outbox messages, branch-aware triangles, process-tree expansion, and bounded body previews.
109
- - Do not restore busy-polling examples, duplicate terminal follow-ups, or duplicate follow-ups for handled `cancel`, `kill`, or control-stop actions.
110
+ - Preserve event-driven observability: durable retrying terminal steering notifications, coordinator-bound outbox messages, branch-aware triangles, process-tree expansion, and bounded body previews. Terminal delivery is at-least-once across the unavoidable send/handled-marker crash window.
111
+ - 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 on operator request, meaningful actor event, or diagnosis of an overdue/stuck run.
112
+ - Do not restore busy-polling examples, duplicate terminal notifications, or duplicate notifications for handled `cancel`, `kill`, or control-stop actions.
110
113
 
111
114
  ## Recipes And Registry
112
115
 
113
116
  - `~/.pi/agent/recipes/*.json` is executable muscle memory: recipes there become persistent tools by location.
114
117
  - Preserve filename identity, atomic writes, explicit operator-gated changes, and local transportability.
118
+ - Recipe live reload must watch the parent when the user recipe root is absent, switch to the root watcher when it appears, and rearm after deletion/rename without polling or duplicate watcher ownership.
115
119
  - Packaged/ad hoc recipes outside the agent root are components, not user tools.
116
120
  - Register existing recipes by importing them from the user-root wrapper and using a `{ "name": "alias" }` template node; do not duplicate a ready recipe's script command, defaults, mailbox, or artifact contract in the wrapper.
117
121
  - Skill-owned scripts must be exposed through skill-owned recipes first. If a local tool needs that capability, import the skill recipe via `{agent}/skills/<skill>/recipes/<recipe>.json` instead of calling `{agent}/skills/<skill>/scripts/*` directly.
118
122
  - Tool definitions use `template`, not `script`, and built-in/core tool names must not be shadowed.
123
+ - Host registrations may remain visible because Pi cannot unregister dynamic definitions, but extension-local `message` and `inspect` lookup must consult the current runtime recipe registry before returning or executing a cached definition.
119
124
  - Packaged recipe growth is demand-driven: prefer reusable components over speculative scenario catalogs.
120
125
  - Recipe templates may point directly at executable helper scripts when the recipe owns that script boundary; keep script executable bits and avoid unnecessary `node` prefixes.
121
126
 
@@ -131,12 +136,14 @@ Pi host
131
136
 
132
137
  ## State, IO, And Safety
133
138
 
134
- - Tool stdout and temp state must stay bounded and local.
139
+ - Tool stdout and temp state must stay bounded and local; preserve complete high-volume streams in spill files with byte/truncation metadata, and never feed a truncated capture tail into pipeline stdin.
135
140
  - Feedback hints must be evidence-backed, bounded, and action-shaped; prefer `next_actions` pointing to existing verbs over prose, and avoid hints when no concrete next step is justified.
136
141
  - Keep tail truncation, full-output temp files, failure formatting, and centralized limits intact.
137
142
  - Published docs must not include machine-local absolute paths.
138
143
  - Any view scanning run directories must apply coordinator/session ownership filters before exposing summaries or previews.
139
144
  - Direct branch messages are active inbox queues; guard branch-local append/status rewrites with the branch inbox lock and keep claim/handled/failed transitions tested.
145
+ - Run-state launch and destructive retention require the runtime ownership marker bound to the canonical directory and run id; reject non-run directories, missing/mismatched markers, and symlink aliases rather than trusting `run.json`.
146
+ - Runner lifecycle and destructive process controls require the persisted cross-platform process identity proof (start time, command, and cwd where available); dead, mismatched, or unsupported proofs stay distinct and fail closed rather than degrading to pid liveness.
140
147
  - Room/branch provenance checks should validate that accepted `from` addresses belong to the addressed run.
141
148
 
142
149
  ## Coordination And Lifecycle
package/BACKLOG.md CHANGED
@@ -37,9 +37,9 @@ Non-goals:
37
37
  - Arbitrary subrooms.
38
38
  - Heavy broker abstraction.
39
39
 
40
- ## Hotfix Backlog
40
+ ## Open Work
41
41
 
42
- No open hotfix items.
42
+ No active implementation or release-preparation work remains. The `0.40.0` release candidate is validated locally; commit, tag, merge, publish, and release actions remain operator-gated rather than backlog work.
43
43
 
44
44
  ## Backlog Curation Rules
45
45
 
@@ -49,10 +49,6 @@ No open hotfix items.
49
49
  - Prefer semantic compression before file splitting: fewer public nouns, consistent outcomes, compact diagnostics, and domain-owned constants/helpers.
50
50
  - Preserve signal/noise balance: feedback should be state-backed, compact, and action-shaped; do not add advisory prose just because a surface exists.
51
51
 
52
- ## Minor Backlog
53
-
54
- No open minor items.
55
-
56
52
  The backlog is intentionally pruned to the 20% of work most likely to deliver 80% of value for `pi-actors` as a local actor kernel. Bias toward consolidation, smaller public surface area, and reliability over new feature breadth.
57
53
 
58
54
  ## Explicitly Deferred
@@ -64,11 +60,6 @@ These are valid ideas but not current focus. Reintroduce only with concrete evid
64
60
  - Cross-session force kill or attach/adopt/reparent: useful later, but ownership policy should not change until observability makes current boundaries clear.
65
61
  - Actor address helper CLI: keep diagnostics improving opportunistically inside existing parser/tests.
66
62
  - Golden flow docs and flow conformance runner: useful after the diagnostic and promotion surfaces are stable.
67
- - Documentation refactor: defer until the canonical mailbox loop and worker recipe exist; avoid rewriting docs twice.
68
63
  - Host-level tool unregistration: blocked on host API support.
69
64
  - Branch-local checkpoint semantics: wait for real collaborative branch-runner experiments.
70
65
  - Actor recipe feedback loop: keep advisory and operator-gated after real runs produce evidence.
71
-
72
- ## Suggested Milestone Order
73
-
74
- 1. Re-curate after the next real packaged review-swarm dogfood run.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,51 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.40.0: Durable Review and Runtime Hardening
6
+
7
+ - `[Runtime]` Collapsed every natural-language positional fragment in child `pi -p` launches into one inspectable prompt file while preserving Pi options and intentional `@file` attachments, so review stages receive one authoritative user turn and preflight diagnostics resolve the concrete stage.
8
+ - `[Tests]` Made the packaged review-readiness dogfood fake Pi reject fragmented prompt argv, covering preflight, reviewer, verifier, merger, judge, and normalizer transport through the real detached runner.
9
+ - `[Reviews]` Added opt-in `accept_output: review_evidence` semantics and marked packaged reviewer, verifier, merger, judge, and normalizer outputs with `ACTOR_REVIEW_RESULT`, so code-zero format acknowledgements or input requests fail closed instead of satisfying quorum or reaching later stages.
10
+ - `[Diagnostics]` Preserved rejected parallel stdout alongside its semantic rejection reason, allowing degraded review runs to retain accepted and rejected branch evidence without counting placeholders as usable.
11
+ - `[Breaking]` Removed public `spawn.state_dir` overrides and reject direct attempts with a retention-safety diagnostic, keeping every public `run:<id>` actor under the runtime-owned addressable run root.
12
+ - `[Safety]` Added canonical run-state ownership markers and require them before launch reuse, archive, or prune; existing non-run directories, forged metadata, mismatched run ids, and symlink aliases now fail closed before destructive retention.
13
+ - `[Safety]` Added and persisted Linux, macOS, and Windows process-identity proofs with start-time, command, and available cwd evidence; status, state reuse, message delivery, cancellation, kill, and retirement now distinguish dead pids, owner mismatches, unsupported proofs, and valid ownership instead of degrading to liveness alone.
14
+ - `[Execution]` Bounded stdout and stderr tails while child commands are running, spilling complete high-volume streams independently with total byte counts, truncation flags, and diagnostic file paths instead of accumulating unbounded strings in memory.
15
+ - `[Execution]` Propagated capture metadata through foreground results, retries, parallel branch diagnostics, and async run events; sequence and parallel pipelines now fail closed with `incomplete pipeline stdin` instead of feeding truncated tails downstream.
16
+ - `[Registry]` Added cross-process file mutation locks keyed by canonical recipe path and wrapped register, update, delete, and draft-promotion windows; concurrent same-name registrations now have one explicit winner.
17
+ - `[Registry]` Moved user-recipe usage telemetry into locked `.usage/<recipe-filename>.json` sidecars and merge it during discovery, so sibling-process counters remain monotonic while external recipe edits and file-watcher refreshes cannot be overwritten by metadata writes.
18
+ - `[Routing]` Made `message to=tool:<name>` and `inspect tool:<name>` resolve definitions only when the current runtime recipe registry still marks the tool active; deleted/external-removed recipes are revoked immediately and updates route only to the newest extension-local definition despite host-level unregister limitations.
19
+ - `[Retention]` Made pruned artifact preservation collision-safe with readable artifact-name/hash/basename targets and timestamp preservation; duplicate basenames remain distinct, missing optional artifacts stay skipped, and any copy failure aborts before source run state is deleted.
20
+ - `[Registry]` Rearmed recipe live reload through an advisory parent watcher when `~/.pi/agent/recipes` is absent or renamed, switching back to the root watcher on creation and reloading on deletion; first-run creation, recreation, and idempotent shutdown now work without polling or session restart.
21
+ - `[Backlog]` Rewrote the remaining packaged hardening review milestone as ordered open-checkbox tasks with decision-grade evidence, finding classification, reconciliation, and validation exit criteria.
22
+ - `[Review]` Completed the six-lens packaged hardening pipeline above quorum. The normalized verdict was blocked by a reproduced UTF-8 capture defect; other reported concerns remained hypotheses because raw stage outputs were not retained for independent audit. Re-curated the backlog around durable per-stage review evidence before the next release-readiness run.
23
+ - `[Execution]` Preserved raw command bytes across stream chunk boundaries and aligned bounded tails to UTF-8 code-point boundaries, preventing split multibyte output from becoming replacement characters while keeping complete spill files byte-exact.
24
+ - `[Actors]` Added an async-patience invariant to the runtime prompt, project context, and bundled actors skill: when deferred actor output gates the next step, wait for the terminal follow-up instead of scheduling continuation loops, repeatedly inspecting, or mutating the reviewed scope.
25
+ - `[Backlog]` Decomposed durable review provenance into nine ordered open tasks covering byte-exact stage output, evidence manifests, bounded inspection, report references, regression proof, re-review, finding classification, and final context validation.
26
+ - `[Evidence]` Persisted complete stdout and stderr for every async command, including small streams, under collision-safe `captures/command-NNN/attempt-NNN/{stdout,stderr}.log` identities; retries remain distinct while bounded result tails and complete pipeline stdin behavior stay unchanged.
27
+ - `[Evidence]` Added terminal `review-evidence.json` manifests mapping stable command/stage occurrences and repeated branches to prompts, byte-exact attempt files, byte counts, truncation, exit state, semantic marker acceptance, recipe context, and inherited/explicit model policy; manifests update during execution and finish with aligned `done`/`failed` status.
28
+ - `[Inspector]` Exposed owned review-evidence manifests and referenced capture paths through existing `inspect run:<id> view=files|artifacts` responses, bounding command entries by `lines` and reporting total/truncated counts without adding a public verb or bypassing session ownership checks.
29
+ - `[Review Evidence]` Injected stable prior-stage `ACTOR_EVIDENCE_REF` entries into verifier, merger, judge, and normalizer prompts; normalized reports now retain auditable reviewer/downstream references, terminal manifests record cited and missing sources, and a report claiming `complete` fails closed when any required reviewer, verifier, merger, or judge reference is absent.
30
+ - `[Review Dogfood]` Expanded deterministic provenance coverage across small, spilled, UTF-8, retry, parallel, semantic-marker rejection, partial-quorum, and terminal paths; the packaged review fixture now reconstructs reviewer branch identity, accepted/rejected counts, rejected raw output, downstream source references, final degraded status, and exactly-once capture files solely from retained run state.
31
+ - `[Review]` Completed the auditable six-lens `hardening-review-040` run with all 14 stages accepted and every required evidence reference retained. Re-curated confirmed release blockers covering canonical alias locks, live owner-mismatch reuse, symlink-cwd identity, public custom state directories, complete large-pipeline stdin, exact evidence markers, semantic notification alignment, interrupted evidence finalization, and missed terminal replay; kept the cancellation timing race and native platform gaps explicitly unconfirmed.
32
+ - `[Locking]` Keyed mutation locks by filesystem identity: existing targets or nearest existing ancestors are resolved through native `realpath`, nonexistent suffixes are preserved, Windows identities are case-normalized, and stale lock directories are reclaimed only after their persisted owner PID is proven dead. Added a cross-process real-parent/symlink-parent serialization regression.
33
+ - `[Run Safety]` Made live `owner_mismatch` state reuse fail closed in both preflight and state-directory preparation, including terminal-result timing windows; tampered process identity can no longer clear/reuse an occupied run directory while its original runner remains alive.
34
+ - `[Process Identity]` Canonicalized existing launch cwd paths through native `realpath` before matching the runner's process proof, with Windows case normalization; Linux integration now proves a symlinked launch cwd retains a valid, controllable process identity while injected Darwin/Windows contracts remain intact.
35
+ - `[Public Boundary]` Removed lifecycle `state_dir` from `register_tool`, persisted/co-located recipe normalization, generated schemas, result details, serialization, recipe-reference resolution, and prompt copy. Legacy stored values are ignored, and registered async tools now always launch under the runtime-owned run root where `run:<id>` inspection, messaging, indexing, and retention agree.
36
+ - `[Pipeline Dataflow]` Downstream sequence commands now receive complete spilled stdout from sequential and parallel producers while model-facing output, branch reports, and diagnostics remain bounded. Missing or unreadable spill files still fail closed; exact >1 MiB consumer-count regressions cover both composition shapes.
37
+ - `[Review Evidence]` Tightened semantic acceptance to require `ACTOR_REVIEW_RESULT` as the exact first non-whitespace line in both execution policy and persisted evidence manifests; prefixed markers such as `ACTOR_REVIEW_RESULT_BOGUS` now fail with code 65 and remain rejected evidence.
38
+ - `[Review Notifications]` Applied semantic output acceptance before async command accounting, so rejected code-zero review output is consistently recorded as code 65 in `command.done`, terminal progress failures, evidence, and outbox summaries, with follow-up delivery and error severity instead of a false success notification.
39
+ - `[Interrupted Evidence]` Review evidence records now exist before command launch and are finalized as `cancelled` or `killed` by lifecycle control even when the command never returns. Async captures create byte-exact stdout/stderr files at attempt start, preserving small partial streams as well as spills; manifests retain interruption status, signal-derived effective exit code, capture bytes, and semantic interruption state.
40
+ - `[Terminal Delivery]` Terminal actor notifications now use Pi steering with `triggerTurn: true`: busy agents receive completion at the next safe tool boundary and idle agents start a normal turn without a racy manual idle check. Unhandled owned terminal runs retry during same-runtime and replacement reconciliation until `terminal-handled.json` is written after a successful send, without replaying historical outbox traffic. Delivery is honestly at-least-once: a process crash between send and marker persistence may duplicate a notification.
41
+ - `[Cancel Race]` Authoritative terminal result/control state now short-circuits cancel/kill before process-identity probing because no signal will be sent. This removes transient `unsupported proof` classifications while preserving fail-closed identity checks for running processes and derived `exited` states; a 30-run completion/cancel stress regression consistently returns `not running`.
42
+ - `[Release Review]` Completed the final six-lens hardening review with all branches and downstream stages successful and durable evidence under `hardening-review-040-final`. The review blocked release on five bounded correctness findings: large marked-output acceptance, cross-process registry collision state, live aged start locks, same-runtime terminal-delivery retry, and disappearing-run inspection tolerance. Per operator direction, no further reviewer runs are required before release; fixes use local regressions and validation.
43
+ - `[Large Review Output]` Semantic marker acceptance now reads byte-complete captured stdout when the bounded tail is truncated, while returned model-facing output remains bounded. Foreground and async regressions prove valid `ACTOR_REVIEW_RESULT` output above 1 MiB succeeds and persists accepted evidence metadata.
44
+ - `[Registry Collision]` Same-name registration now rereads authoritative recipe state while holding the canonical cross-process mutation lock instead of trusting a process-local tool map. A sibling-process regression proves exactly one create wins and the loser receives the explicit `update=true` collision error without overwriting the recipe.
45
+ - `[Start Lock Ownership]` Run-start locks now persist the starter's process identity and aged locks are reclaimed only when the recorded PID is proven dead. Live, mismatched, unavailable, and malformed ownership proofs remain protected regardless of age; regressions cover both live-owner retention and dead-owner recovery.
46
+ - `[Inspection TOCTOU]` Runtime triage now treats a run that disappears between inventory and status read as a skipped stale entry instead of failing the entire inspection. Injectable inventory/status ports provide a deterministic disappearance regression without weakening ownership filtering for surviving runs.
47
+ - `[Package Entrypoint]` Added a named compiled wrapper at `dist/pi-actors/index.js` and pointed package metadata to it, preserving the compiled runtime while making Pi's extension list identify `pi-actors` instead of the anonymous parent label `dist`.
48
+ - `[Release]` Updated package, lockfile, bundled skill metadata, compiled assets, and changelog identity for the release candidate; validation, conformance, package dry-run, diff checks, and context reconciliation are green.
49
+
5
50
  ## 0.39.0: Actor Kernel Welcome Refresh
6
51
 
7
52
  - `[Docs]` Reworked the root README as a product/onboarding entrypoint for the local actor kernel, with clearer positioning, first-run path, feature showcase, recipe-memory model, address/message examples, and practical surface-selection guidance. Impact: new operators can understand when to use `spawn`, `message`, `inspect`, recipes, rooms, and artifacts without reading deep implementation docs first.
package/README.md CHANGED
@@ -56,7 +56,7 @@ Or from git:
56
56
  pi install git:github.com/llblab/pi-actors
57
57
  ```
58
58
 
59
- The npm package is dist-first for JavaScript-only runtimes: Pi metadata points at compiled `dist/` entrypoints and mirrored runtime assets. Source TypeScript and source skills remain packaged for TypeScript-native or checkout-based runtimes.
59
+ The npm package is dist-first for JavaScript-only runtimes: Pi metadata points at the named compiled entrypoint `dist/pi-actors/index.js` plus mirrored runtime assets, so extension discovery identifies `pi-actors` rather than an anonymous `dist` directory. Source TypeScript and source skills remain packaged for TypeScript-native or checkout-based runtimes.
60
60
 
61
61
  ## First run: actor mode in one minute
62
62
 
@@ -125,12 +125,12 @@ Routing comes from `to`, actor ownership, and runtime policy. `type` describes i
125
125
  | --- | --- | --- |
126
126
  | Command templates | Portable command graphs with placeholders, defaults, guards, retries, parallel nodes, recovery, and timeouts | Wrap a trusted local executable without writing a bespoke tool |
127
127
  | Recipes | JSON/Markdown capability specs with metadata, args, defaults, imports, mailbox contracts, artifacts, and async mode | Save a known-good local workflow as reusable muscle memory |
128
- | Async runs | File-backed detached lifecycle, logs, progress, output, cancellation, artifacts, and terminal follow-ups | Let model work, media jobs, services, or pipelines continue after the turn |
128
+ | Async runs | File-backed detached lifecycle, logs, progress, output, cancellation, artifacts, and durable terminal steering notifications | Let model work, media jobs, services, or pipelines continue after the turn |
129
129
  | Message protocol | Typed envelopes across run, tool, branch, room, coordinator, and session targets | Continue, approve, kill, or route work without restarting actors |
130
130
  | Rooms and rosters | Run-local group timeline with actor join/leave, contacts, previews, and branch-aware delivery | Coordinate multiple subagents under one visible run |
131
131
  | Registry and recipe doctor | Discovered tools, overrides, drafts, invalid recipes, and advisory risk labels | Audit local capability memory before using or promoting it |
132
132
  | Draft promotion | Captured ad hoc spawn patterns can become explicit recipes after operator approval | Turn successful improvisation into durable local tools |
133
- | Review/swarm recipes | Maintained packaged pipelines with preflight, quorum knobs, model/thinking inheritance, prompt-file transport, and diagnostics | Delegate reviews without rebuilding fanout commands |
133
+ | Review/swarm recipes | Maintained packaged pipelines with preflight, marked semantic evidence, quorum knobs, model/thinking inheritance, one-turn prompt-file transport, and diagnostics | Delegate reviews without rebuilding fanout commands |
134
134
  | Actor inspector | Compact TUI/debug views for active actor coordination, unread branch inboxes, room messages, and attention markers | Watch only the actor traffic that matters right now |
135
135
  | Packaged recipe QA | Installed-package-safe checks for helper paths, mailbox contracts, platform scope, artifacts, and recipe structure | Keep shipped actor components executable and diagnosable |
136
136
 
@@ -275,7 +275,7 @@ Packaged recipes are building blocks. Use `spawn file=<recipe>` for maintained p
275
275
  | A useful output that should survive context compression | Artifacts |
276
276
  | A repeated local workflow | Recipe/tool memory |
277
277
 
278
- When a directly spawned inline/ad hoc actor or a recipe outside the user recipe root completes successfully, `pi-actors` may send the launching agent a follow-up suggesting promotion. The agent should ask first and never auto-save.
278
+ When a directly spawned inline/ad hoc actor or a recipe outside the user recipe root completes successfully, `pi-actors` may include a promotion suggestion in its terminal steering notification. The agent should ask first and never auto-save.
279
279
 
280
280
  ## Platform support
281
281
 
package/dist/index.js CHANGED
@@ -30,7 +30,7 @@ export default function toolRegistryExtension(pi) {
30
30
  sendStop: (candidate) => AsyncRuns.sendRunMessage(candidate.stateDir, "stop"),
31
31
  });
32
32
  };
33
- const updateRunUi = (ctx, notify = false) => {
33
+ const updateRunUi = (ctx, notify = false, terminalOnly = false) => {
34
34
  const ownerId = getRunOwnerId(ctx);
35
35
  const snapshot = Observability.readRunUiSnapshot(runUi, ownerId);
36
36
  ctx.ui.setStatus("zz-pi-actors-runs", snapshot.status ? ctx.ui.theme.fg("dim", snapshot.status) : undefined);
@@ -62,7 +62,9 @@ export default function toolRegistryExtension(pi) {
62
62
  retireCandidateRuns(ctx, snapshot.summary);
63
63
  Observability.deliverRunTransitionNotifications(snapshot.transitions, notificationSink);
64
64
  Observability.pruneRunUiObservationState(runUi, snapshot);
65
- Observability.deliverRunOutboxNotifications(snapshot.outboxEvents, notificationSink);
65
+ if (!terminalOnly) {
66
+ Observability.deliverRunOutboxNotifications(snapshot.outboxEvents, notificationSink);
67
+ }
66
68
  };
67
69
  const closeRunWatchers = () => {
68
70
  runWatcher.close();
@@ -126,7 +128,7 @@ export default function toolRegistryExtension(pi) {
126
128
  activeRunContext = ctx;
127
129
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
128
130
  runtime.loadTools(ctx);
129
- updateRunUi(ctx);
131
+ updateRunUi(ctx, true, true);
130
132
  closeRunWatchers();
131
133
  recipeReload.close();
132
134
  runWatcher.refresh();
@@ -178,7 +180,7 @@ export default function toolRegistryExtension(pi) {
178
180
  Pi.registerToolDefinitions(pi, Tools.createCoreActorToolDefinitions({
179
181
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
180
182
  getActiveTools: () => pi.getActiveTools(),
181
- getRuntimeTool: (name) => actorToolDefinitions.get(name),
183
+ getRuntimeTool: (name) => Tools.resolveActiveRuntimeTool(name, runtime.getTools(), (activeName) => actorToolDefinitions.get(activeName)),
182
184
  registryRuntime: runtime,
183
185
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
184
186
  }).map(withCurrentThinkingContext));
@@ -7,6 +7,7 @@ import { type CurrentPolicyProvenance } from "./model-context.ts";
7
7
  import * as RecipesReferences from "./recipes-references.ts";
8
8
  import { type RunArtifactDeclaration } from "./runs-artifacts.ts";
9
9
  import { type RunOutboxEvent } from "./runs-outbox.ts";
10
+ import { type RunProcessIdentity } from "./runs-process.ts";
10
11
  import * as RunsIndex from "./runs-index.ts";
11
12
  import { type ProcessRunInboxResult, type RunInboxMessage, type RunInboxStatus } from "./runs-mailbox.ts";
12
13
  import { type SendRunMessageOptions } from "./runs-messages.ts";
@@ -35,6 +36,7 @@ export interface AsyncRunStartParams {
35
36
  when?: boolean | string;
36
37
  timeout?: number | string;
37
38
  delay?: number | string;
39
+ accept_output?: "review_evidence";
38
40
  output?: string;
39
41
  artifacts?: Record<string, RunArtifactDeclaration>;
40
42
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
@@ -68,6 +70,7 @@ export interface AsyncRunMeta {
68
70
  control?: AsyncRunControlEndpoint;
69
71
  mailbox?: RecipesReferences.TemplateRecipeMailbox;
70
72
  model_policy?: CurrentPolicyProvenance;
73
+ process_identity?: RunProcessIdentity;
71
74
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
72
75
  retire_when?: "children_terminal";
73
76
  }
@@ -112,6 +115,7 @@ export type { SendRunMessageOptions } from "./runs-messages.ts";
112
115
  export declare function sendRunMessage(runOrDir: string, message: string, options?: SendRunMessageOptions): Promise<Record<string, unknown>>;
113
116
  export { getRunProcessSignalPlan } from "./runs-control.ts";
114
117
  export type { RunProcessSignalPlan } from "./runs-control.ts";
118
+ export declare function markRunTerminalNotificationHandled(stateDir: string, status: string): void;
115
119
  export declare function cancelRun(runOrDir: string): Record<string, unknown>;
116
120
  export declare function archiveRun(runOrDir: string): Record<string, unknown>;
117
121
  export declare function pruneRun(runOrDir: string, options?: {
@@ -3,8 +3,8 @@
3
3
  * Owns launch, state observation, listing, message/control facade methods, and retention while runs-* subdomains own narrower run internals.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { closeSync, existsSync, mkdirSync, openSync, writeFileSync, } from "node:fs";
7
- import { basename, dirname, extname, join, resolve } from "node:path";
6
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, writeFileSync, } from "node:fs";
7
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { writeJsonAtomic } from "./file-state.js";
10
10
  import { CURRENT_MODEL_VALUE_KEY, CURRENT_THINKING_VALUE_KEY, describeCurrentPolicyProvenance, } from "./model-context.js";
@@ -15,8 +15,9 @@ import { resolveArtifactPaths, } from "./runs-artifacts.js";
15
15
  import { safeRunId } from "./runs-identity.js";
16
16
  import { buildTerminalProgress, markTerminalHandled, signalOwnedRunProcess, } from "./runs-control.js";
17
17
  import { buildRunOutboxEventPayload, parseRunOutboxEventLine, } from "./runs-outbox.js";
18
+ import { claimRunStateDirectory } from "./runs-ownership.js";
18
19
  import { archiveTerminalRun, pruneTerminalRun } from "./runs-retention.js";
19
- import { isAlive, isWithinRunnerIdentityGrace, pidMatchesRun, } from "./runs-process.js";
20
+ import { captureRunProcessIdentity, verifyRunProcessIdentity, } from "./runs-process.js";
20
21
  import * as RunsStart from "./runs-start.js";
21
22
  import * as RunsIndex from "./runs-index.js";
22
23
  import { claimRunInboxMessageInStateDir, parseRunInboxLine, processRunInboxMessagesInStateDir, runInboxFile, updateRunInboxMessageStatusInStateDir, } from "./runs-mailbox.js";
@@ -57,6 +58,7 @@ function resolveRunTemplate(params) {
57
58
  "when",
58
59
  "timeout",
59
60
  "delay",
61
+ "accept_output",
60
62
  "output",
61
63
  "retry",
62
64
  "failure",
@@ -228,6 +230,7 @@ export function startRun(params, cwd) {
228
230
  mkdirSync(stateDir, { recursive: true });
229
231
  const releaseStartLock = acquireStateStartLock(stateDir);
230
232
  try {
233
+ claimRunStateDirectory(stateDir, run);
231
234
  assertNoActiveRunState(stateDir);
232
235
  prepareStateDirForStart(stateDir);
233
236
  const stdout = join(stateDir, "stdout.log");
@@ -289,6 +292,9 @@ export function startRun(params, cwd) {
289
292
  closeSync(outFd);
290
293
  closeSync(errFd);
291
294
  meta.pid = child.pid ?? 0;
295
+ const processIdentity = captureRunProcessIdentity(meta.pid, cwd, stateDir, RUNNER_PATH);
296
+ if (processIdentity)
297
+ meta.process_identity = processIdentity;
292
298
  writeJsonAtomic(join(stateDir, "run.json"), meta);
293
299
  writeJsonAtomic(join(stateDir, "progress.json"), {
294
300
  completed: 0,
@@ -384,14 +390,18 @@ export async function sendRunMessage(runOrDir, message, options = {}) {
384
390
  const status = getRunStatus(runOrDir);
385
391
  const stateDir = String(status.state_dir);
386
392
  const run = String(status.run ?? runOrDir);
387
- if (status.status !== "running")
388
- throw new Error(`Run is not running: ${run}`);
389
393
  const pid = Number(status.pid || 0);
390
- if (!pid || !isAlive(pid))
391
- throw new Error(`Run pid is not alive: ${run}`);
392
- if (!pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH) &&
393
- !isWithinRunnerIdentityGrace(status, RUNNER_IDENTITY_GRACE_MS))
394
- throw new Error(`Run pid owner mismatch: ${run}`);
394
+ const identity = verifyRunProcessIdentity(pid, status.process_identity);
395
+ if (status.status !== "running") {
396
+ if (identity.status === "owner_mismatch" ||
397
+ identity.status === "unsupported_proof") {
398
+ throw new Error(`Run process identity ${identity.status}: ${run}`);
399
+ }
400
+ throw new Error(`Run is not running: ${run}`);
401
+ }
402
+ if (!identity.valid) {
403
+ throw new Error(`Run process identity ${identity.status}: ${run}`);
404
+ }
395
405
  return deliverRunMessage(status, run, stateDir, message, options);
396
406
  }
397
407
  export { getRunProcessSignalPlan } from "./runs-control.js";
@@ -402,26 +412,111 @@ function markTerminalProgress(stateDir, phase) {
402
412
  : undefined;
403
413
  writeJsonAtomic(join(stateDir, "progress.json"), buildTerminalProgress(progress, phase));
404
414
  }
415
+ function finalizeInterruptedReviewEvidence(stateDir, phase, signal) {
416
+ const evidencePath = join(stateDir, "review-evidence.json");
417
+ const manifest = readJson(evidencePath);
418
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest))
419
+ return;
420
+ const record = manifest;
421
+ if (!Array.isArray(record.commands))
422
+ return;
423
+ const completedAt = new Date().toISOString();
424
+ const effectiveExitCode = signal === "SIGKILL" ? 137 : 143;
425
+ const commands = record.commands.map((command) => {
426
+ if (!command || typeof command !== "object" || Array.isArray(command)) {
427
+ return command;
428
+ }
429
+ const entry = command;
430
+ if (entry.status !== "running" || typeof entry.id !== "string")
431
+ return entry;
432
+ const captureDir = join(stateDir, "captures", entry.id);
433
+ const attempts = existsSync(captureDir)
434
+ ? readdirSync(captureDir)
435
+ .filter((name) => /^attempt-\d+$/.test(name))
436
+ .sort()
437
+ .map((name, index) => {
438
+ const attemptDir = join(captureDir, name);
439
+ const stdoutFile = join(attemptDir, "stdout.log");
440
+ const stderrFile = join(attemptDir, "stderr.log");
441
+ return {
442
+ attempt: index + 1,
443
+ stdout: {
444
+ path: relative(stateDir, stdoutFile),
445
+ bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
446
+ },
447
+ stderr: {
448
+ path: relative(stateDir, stderrFile),
449
+ bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
450
+ },
451
+ };
452
+ })
453
+ : [];
454
+ return {
455
+ ...entry,
456
+ status: phase,
457
+ completed_at: completedAt,
458
+ attempts,
459
+ effective_exit_code: effectiveExitCode,
460
+ killed: true,
461
+ ...(entry.semantic_acceptance === "pending"
462
+ ? { semantic_acceptance: "interrupted" }
463
+ : {}),
464
+ };
465
+ });
466
+ writeJsonAtomic(evidencePath, {
467
+ ...record,
468
+ status: phase,
469
+ commands,
470
+ updated_at: completedAt,
471
+ });
472
+ }
405
473
  function stopRun(runOrDir, signal, event) {
406
474
  const status = getRunStatus(runOrDir);
407
475
  const pid = Number(status.pid || 0);
408
476
  const stateDir = String(status.state_dir);
409
- if (status.status !== "running")
477
+ if (status.status !== "running" && status.status !== "exited") {
410
478
  return { stopped: false, reason: "not running", status };
411
- if (!pid || !isAlive(pid))
412
- return { stopped: false, reason: "pid not alive", status };
413
- if (!pidMatchesRun(pid, String(status.cwd), stateDir, RUNNER_PATH)) {
414
- return { stopped: false, reason: "pid owner mismatch", status };
479
+ }
480
+ const identity = verifyRunProcessIdentity(pid, status.process_identity);
481
+ if (status.status === "exited") {
482
+ if (identity.status === "owner_mismatch" ||
483
+ identity.status === "unsupported_proof") {
484
+ return {
485
+ stopped: false,
486
+ reason: identity.status.replaceAll("_", " "),
487
+ process_identity_status: identity.status,
488
+ status,
489
+ };
490
+ }
491
+ return { stopped: false, reason: "not running", status };
492
+ }
493
+ if (!identity.valid) {
494
+ return {
495
+ stopped: false,
496
+ reason: identity.status.replaceAll("_", " "),
497
+ process_identity_status: identity.status,
498
+ status,
499
+ };
415
500
  }
416
501
  const signalResult = signalOwnedRunProcess(pid, signal);
417
502
  writeFileSync(join(stateDir, "events.jsonl"), `${JSON.stringify({ event, pid, signal, ...signalResult, ts: new Date().toISOString() })}\n`, { flag: "a" });
418
503
  markTerminalHandled(stateDir, { event, signal });
419
- if (event === "run.kill")
504
+ if (event === "run.kill") {
505
+ finalizeInterruptedReviewEvidence(stateDir, "killed", signal);
420
506
  markTerminalProgress(stateDir, "killed");
421
- if (event === "run.cancel")
507
+ }
508
+ if (event === "run.cancel") {
509
+ finalizeInterruptedReviewEvidence(stateDir, "cancelled", signal);
422
510
  markTerminalProgress(stateDir, "cancelled");
511
+ }
423
512
  return { stopped: true, pid, signal, ...signalResult, state_dir: stateDir };
424
513
  }
514
+ export function markRunTerminalNotificationHandled(stateDir, status) {
515
+ markTerminalHandled(stateDir, {
516
+ event: "run.notification",
517
+ status,
518
+ });
519
+ }
425
520
  export function cancelRun(runOrDir) {
426
521
  const result = stopRun(runOrDir, "SIGTERM", "run.cancel");
427
522
  return Object.hasOwn(result, "stopped")
@@ -23,6 +23,7 @@ export interface CommandTemplateObjectConfig {
23
23
  defaults?: Record<string, unknown>;
24
24
  timeout?: number | string;
25
25
  delay?: number | string;
26
+ accept_output?: "review_evidence";
26
27
  output?: string;
27
28
  retry?: number | string;
28
29
  failure?: CommandTemplateFailureScope;
@@ -45,12 +46,20 @@ export interface CommandTemplateExecOptions {
45
46
  stdin?: string;
46
47
  killGrace?: number;
47
48
  retry?: number;
49
+ captureDir?: string;
50
+ captureLimitBytes?: number;
48
51
  }
49
52
  export interface CommandTemplateExecResult {
50
53
  stdout: string;
51
54
  stderr: string;
52
55
  code: number;
53
56
  killed: boolean;
57
+ stdoutBytes?: number;
58
+ stderrBytes?: number;
59
+ stdoutFile?: string;
60
+ stderrFile?: string;
61
+ stdoutTruncated?: boolean;
62
+ stderrTruncated?: boolean;
54
63
  }
55
64
  export type CommandTemplateRiskLabel = "risk.shell" | "risk.eval" | "risk.broad_fs_write" | "risk.destructive_fs" | "risk.network" | "risk.external_side_effect" | "risk.long_running" | "risk.platform_specific" | "risk.secret_touching";
56
65
  export type CommandTemplateExecCommand = (command: string, args: string[], options?: CommandTemplateExecOptions) => Promise<CommandTemplateExecResult>;
@@ -4,8 +4,10 @@
4
4
  * Owns portable command-template parsing, expansion, risk checks, retries, timeouts, and direct execution.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
- import { homedir } from "node:os";
8
- import { isAbsolute, resolve } from "node:path";
7
+ import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { homedir, tmpdir } from "node:os";
9
+ import { isAbsolute, join, resolve as resolvePath } from "node:path";
10
+ const DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES = 1024 * 1024;
9
11
  const COMMAND_TEMPLATE_RISK_LABEL_ORDER = [
10
12
  "risk.shell",
11
13
  "risk.eval",
@@ -386,9 +388,9 @@ export function expandCommandTemplateExecutable(command, cwd) {
386
388
  if (command === "~")
387
389
  return homedir();
388
390
  if (command.startsWith("~/"))
389
- return resolve(homedir(), command.slice(2));
391
+ return resolvePath(homedir(), command.slice(2));
390
392
  if (command.includes("/") && !isAbsolute(command))
391
- return resolve(cwd, command);
393
+ return resolvePath(cwd, command);
392
394
  return command;
393
395
  }
394
396
  function evaluateCommandTemplateExpression(expression, values) {
@@ -592,13 +594,67 @@ export async function execCommandTemplate(command, args, options = {}) {
592
594
  killed: false,
593
595
  };
594
596
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
595
- const result = await execCommandTemplateOnce(command, args, options);
597
+ const attemptOptions = options.captureDir
598
+ ? {
599
+ ...options,
600
+ captureDir: join(options.captureDir, `attempt-${String(attempt).padStart(3, "0")}`),
601
+ }
602
+ : options;
603
+ const result = await execCommandTemplateOnce(command, args, attemptOptions);
596
604
  if (result.code === 0)
597
605
  return result;
598
606
  lastResult = result;
599
607
  }
600
608
  return lastResult;
601
609
  }
610
+ function trimCaptureTail(value, limit) {
611
+ if (value.length <= limit)
612
+ return value;
613
+ let start = value.length - limit;
614
+ while (start < value.length && (value[start] & 0xc0) === 0x80)
615
+ start += 1;
616
+ return value.subarray(start);
617
+ }
618
+ function createBoundedCommandCapture(stream, limit, getCaptureDir, persistCompleteStream) {
619
+ let bytes = 0;
620
+ let content = Buffer.alloc(0);
621
+ let file = persistCompleteStream
622
+ ? join(getCaptureDir(), `${stream}.log`)
623
+ : undefined;
624
+ if (file)
625
+ writeFileSync(file, Buffer.alloc(0));
626
+ let truncated = false;
627
+ return {
628
+ append(value) {
629
+ const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value);
630
+ bytes += chunk.length;
631
+ if (bytes > limit)
632
+ truncated = true;
633
+ if (!file && bytes <= limit) {
634
+ content = Buffer.concat([content, chunk]);
635
+ return;
636
+ }
637
+ if (!file) {
638
+ file = join(getCaptureDir(), `${stream}.log`);
639
+ writeFileSync(file, content);
640
+ }
641
+ appendFileSync(file, chunk);
642
+ content = trimCaptureTail(Buffer.concat([content, chunk]), limit);
643
+ },
644
+ result() {
645
+ if (!file && persistCompleteStream) {
646
+ file = join(getCaptureDir(), `${stream}.log`);
647
+ writeFileSync(file, content);
648
+ }
649
+ return {
650
+ bytes,
651
+ content: content.toString("utf8"),
652
+ ...(file ? { file } : {}),
653
+ truncated,
654
+ };
655
+ },
656
+ };
657
+ }
602
658
  function execCommandTemplateOnce(command, args, options = {}) {
603
659
  return new Promise((resolve) => {
604
660
  const proc = spawn(command, args, {
@@ -606,8 +662,20 @@ function execCommandTemplateOnce(command, args, options = {}) {
606
662
  shell: false,
607
663
  stdio: [options.stdin === undefined ? "ignore" : "pipe", "pipe", "pipe"],
608
664
  });
609
- let stdout = "";
610
- let stderr = "";
665
+ const captureLimit = Math.max(1, options.captureLimitBytes ?? DEFAULT_COMMAND_CAPTURE_LIMIT_BYTES);
666
+ let captureDir;
667
+ const getCaptureDir = () => {
668
+ if (captureDir)
669
+ return captureDir;
670
+ captureDir = options.captureDir
671
+ ? resolvePath(options.captureDir)
672
+ : mkdtempSync(join(tmpdir(), "pi-actors-command-"));
673
+ mkdirSync(captureDir, { recursive: true });
674
+ return captureDir;
675
+ };
676
+ const persistCompleteStreams = options.captureDir !== undefined;
677
+ const stdoutCapture = createBoundedCommandCapture("stdout", captureLimit, getCaptureDir, persistCompleteStreams);
678
+ const stderrCapture = createBoundedCommandCapture("stderr", captureLimit, getCaptureDir, persistCompleteStreams);
611
679
  let killed = false;
612
680
  let settled = false;
613
681
  let timeoutId;
@@ -632,7 +700,20 @@ function execCommandTemplateOnce(command, args, options = {}) {
632
700
  clearTimeout(killTimeoutId);
633
701
  if (options.signal)
634
702
  options.signal.removeEventListener("abort", killProcess);
635
- resolve({ stdout, stderr, code, killed });
703
+ const stdout = stdoutCapture.result();
704
+ const stderr = stderrCapture.result();
705
+ resolve({
706
+ stdout: stdout.content,
707
+ stderr: stderr.content,
708
+ code,
709
+ killed,
710
+ stdoutBytes: stdout.bytes,
711
+ stderrBytes: stderr.bytes,
712
+ ...(stdout.file ? { stdoutFile: stdout.file } : {}),
713
+ ...(stderr.file ? { stderrFile: stderr.file } : {}),
714
+ ...(stdout.truncated ? { stdoutTruncated: true } : {}),
715
+ ...(stderr.truncated ? { stderrTruncated: true } : {}),
716
+ });
636
717
  };
637
718
  if (options.signal) {
638
719
  if (options.signal.aborted)
@@ -643,16 +724,16 @@ function execCommandTemplateOnce(command, args, options = {}) {
643
724
  if (options.timeout !== undefined && options.timeout > 0)
644
725
  timeoutId = setTimeout(killProcess, options.timeout);
645
726
  proc.stdout?.on("data", (data) => {
646
- stdout += data.toString();
727
+ stdoutCapture.append(data);
647
728
  });
648
729
  proc.stderr?.on("data", (data) => {
649
- stderr += data.toString();
730
+ stderrCapture.append(data);
650
731
  });
651
732
  proc.stdin?.on("error", () => { });
652
733
  if (options.stdin !== undefined)
653
734
  proc.stdin?.end(options.stdin);
654
735
  proc.on("error", (error) => {
655
- stderr += error instanceof Error ? error.message : String(error);
736
+ stderrCapture.append(error instanceof Error ? error.message : String(error));
656
737
  settle(1);
657
738
  });
658
739
  proc.on("close", (code) => {