@llblab/pi-actors 0.42.0 → 0.42.2

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 (54) hide show
  1. package/AGENTS.md +2 -2
  2. package/BACKLOG.md +1 -5
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +1 -1
  5. package/dist/lib/async-runs.d.ts +12 -0
  6. package/dist/lib/async-runs.js +53 -12
  7. package/dist/lib/command-templates.js +1 -1
  8. package/dist/lib/inspector-overlay.d.ts +5 -1
  9. package/dist/lib/inspector-overlay.js +104 -36
  10. package/dist/lib/inspector.js +1 -1
  11. package/dist/lib/observability.d.ts +12 -1
  12. package/dist/lib/observability.js +159 -80
  13. package/dist/lib/prompts.d.ts +1 -1
  14. package/dist/lib/prompts.js +1 -1
  15. package/dist/lib/runs-control.d.ts +2 -0
  16. package/dist/lib/runs-control.js +14 -1
  17. package/dist/lib/runs-ownership.js +17 -3
  18. package/dist/lib/runs-process.js +4 -3
  19. package/dist/lib/runs-start.js +1 -0
  20. package/dist/lib/runs-status.js +3 -0
  21. package/dist/lib/tools-inspect.js +2 -1
  22. package/dist/lib/tools-local.js +17 -2
  23. package/dist/lib/tools-spawn.js +10 -1
  24. package/dist/scripts/async-runner.mjs +24 -24
  25. package/dist/scripts/build-dist.mjs +6 -1
  26. package/dist/scripts/conformance.mjs +6 -1
  27. package/dist/scripts/recipe-utils.mjs +3 -3
  28. package/dist/skills/actors/SKILL.md +3 -3
  29. package/dist/skills/swarm/SKILL.md +1 -1
  30. package/docs/actor-inspector.md +3 -3
  31. package/docs/async-runs.md +10 -4
  32. package/docs/recipe-library.md +1 -1
  33. package/docs/tool-registry.md +2 -0
  34. package/lib/async-runs.ts +72 -12
  35. package/lib/command-templates.ts +1 -1
  36. package/lib/inspector-overlay.ts +129 -36
  37. package/lib/inspector.ts +1 -1
  38. package/lib/observability.ts +194 -76
  39. package/lib/prompts.ts +1 -1
  40. package/lib/runs-control.ts +20 -1
  41. package/lib/runs-ownership.ts +22 -3
  42. package/lib/runs-process.ts +4 -3
  43. package/lib/runs-start.ts +1 -0
  44. package/lib/runs-status.ts +5 -0
  45. package/lib/tools-inspect.ts +2 -1
  46. package/lib/tools-local.ts +21 -2
  47. package/lib/tools-spawn.ts +14 -1
  48. package/package.json +4 -3
  49. package/scripts/async-runner.mjs +24 -24
  50. package/scripts/build-dist.mjs +6 -1
  51. package/scripts/conformance.mjs +6 -1
  52. package/scripts/recipe-utils.mjs +3 -3
  53. package/skills/actors/SKILL.md +3 -3
  54. package/skills/swarm/SKILL.md +1 -1
@@ -135,7 +135,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
135
135
  try {
136
136
  return readdirSync(sessionDir, { withFileTypes: true })
137
137
  .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
138
- .map((entry) => relative(stateDir, join(sessionDir, entry.name)))
138
+ .map((entry) => relative(stateDir, join(sessionDir, entry.name)).replaceAll("\\", "/"))
139
139
  .sort();
140
140
  } catch {
141
141
  return [];
@@ -167,12 +167,12 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
167
167
  ...(recipeContext ? { recipe_context: recipeContext } : {}),
168
168
  command: commandDetail,
169
169
  ...(materialized.promptFile
170
- ? { prompt_file: relative(stateDir, materialized.promptFile) }
170
+ ? { prompt_file: relative(stateDir, materialized.promptFile).replaceAll("\\", "/") }
171
171
  : {}),
172
172
  ...(materialized.promptBytes
173
173
  ? { prompt_bytes: materialized.promptBytes }
174
174
  : {}),
175
- ...(sessionDir ? { session_dir: relative(stateDir, sessionDir) } : {}),
175
+ ...(sessionDir ? { session_dir: relative(stateDir, sessionDir).replaceAll("\\", "/") } : {}),
176
176
  attempts: [],
177
177
  semantic_acceptance:
178
178
  options?.evidenceContext?.acceptOutput === "review_evidence" ||
@@ -209,11 +209,11 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
209
209
  attempts.push({
210
210
  attempt,
211
211
  stdout: {
212
- path: relative(stateDir, stdoutFile),
212
+ path: relative(stateDir, stdoutFile).replaceAll("\\", "/"),
213
213
  bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
214
214
  },
215
215
  stderr: {
216
- path: relative(stateDir, stderrFile),
216
+ path: relative(stateDir, stderrFile).replaceAll("\\", "/"),
217
217
  bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
218
218
  },
219
219
  });
@@ -248,12 +248,12 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
248
248
  ...(recipeContext ? { recipe_context: recipeContext } : {}),
249
249
  command: commandDetail,
250
250
  ...(materialized.promptFile
251
- ? { prompt_file: relative(stateDir, materialized.promptFile) }
251
+ ? { prompt_file: relative(stateDir, materialized.promptFile).replaceAll("\\", "/") }
252
252
  : {}),
253
253
  ...(materialized.promptBytes
254
254
  ? { prompt_bytes: materialized.promptBytes }
255
255
  : {}),
256
- ...(sessionDir ? { session_dir: relative(stateDir, sessionDir) } : {}),
256
+ ...(sessionDir ? { session_dir: relative(stateDir, sessionDir).replaceAll("\\", "/") } : {}),
257
257
  ...(commandSessionFiles(sessionDir).length > 0
258
258
  ? { session_files: commandSessionFiles(sessionDir) }
259
259
  : {}),
@@ -386,7 +386,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
386
386
  command: commandDetail,
387
387
  ...(materialized.promptFile ? { prompt_file: materialized.promptFile } : {}),
388
388
  ...(materialized.promptBytes ? { prompt_bytes: materialized.promptBytes } : {}),
389
- ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir) } : {}),
389
+ ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir).replaceAll("\\", "/") } : {}),
390
390
  });
391
391
  progressRunning();
392
392
  const captureDir = join(stateDir, "captures", commandId);
@@ -457,7 +457,7 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
457
457
  ...captureDetails(result),
458
458
  ...(materialized.promptFile ? { prompt_file: materialized.promptFile } : {}),
459
459
  ...(materialized.promptBytes ? { prompt_bytes: materialized.promptBytes } : {}),
460
- ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir) } : {}),
460
+ ...(session.sessionDir ? { session_dir: relative(stateDir, session.sessionDir).replaceAll("\\", "/") } : {}),
461
461
  ...(commandSessionFiles(session.sessionDir).length > 0
462
462
  ? { session_files: commandSessionFiles(session.sessionDir) }
463
463
  : {}),
@@ -517,6 +517,11 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
517
517
  };
518
518
  throw error;
519
519
  }
520
+ writeEvidenceManifest("done");
521
+ progress("done", {
522
+ completed: 1,
523
+ failures: result.details.nonCriticalFailures || [],
524
+ });
520
525
  writeJsonAtomic(resultPath, {
521
526
  code: result.details.code,
522
527
  command: result.details.command,
@@ -525,26 +530,11 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
525
530
  truncated: result.details.truncated,
526
531
  completedAt: new Date().toISOString(),
527
532
  });
528
- writeEvidenceManifest("done");
529
- progress("done", {
530
- completed: 1,
531
- failures: result.details.nonCriticalFailures || [],
532
- });
533
533
  event("run.done", { code: result.details.code });
534
534
  } catch (error) {
535
535
  const message = error instanceof Error ? error.message : String(error);
536
536
  const details = error && typeof error === "object" ? error.details : undefined;
537
537
  appendFileSync(stderrPath, `${message}\n`);
538
- writeJsonAtomic(resultPath, {
539
- code: typeof details?.code === "number" ? details.code : 1,
540
- error: message,
541
- killed: Boolean(details?.killed),
542
- ...(Array.isArray(details?.branches) ? { branches: details.branches } : {}),
543
- ...(details?.failureReason ? { failure_reason: details.failureReason } : {}),
544
- ...(meta.model_policy ? { model_policy: meta.model_policy } : {}),
545
- ...(details?.softQuorum ? { soft_quorum: details.softQuorum } : {}),
546
- completedAt: new Date().toISOString(),
547
- });
548
538
  writeEvidenceManifest("failed");
549
539
  progress("failed", {
550
540
  completed: 0,
@@ -555,6 +545,16 @@ export async function runAsyncRunner(stateDir = process.argv[2]) {
555
545
  : [{ message }],
556
546
  ...(details?.failureReason ? { failureReason: details.failureReason } : {}),
557
547
  });
548
+ writeJsonAtomic(resultPath, {
549
+ code: typeof details?.code === "number" ? details.code : 1,
550
+ error: message,
551
+ killed: Boolean(details?.killed),
552
+ ...(Array.isArray(details?.branches) ? { branches: details.branches } : {}),
553
+ ...(details?.failureReason ? { failure_reason: details.failureReason } : {}),
554
+ ...(meta.model_policy ? { model_policy: meta.model_policy } : {}),
555
+ ...(details?.softQuorum ? { soft_quorum: details.softQuorum } : {}),
556
+ completedAt: new Date().toISOString(),
557
+ });
558
558
  event("run.failed", {
559
559
  error: message,
560
560
  ...(details?.failureReason ? { failure_reason: details.failureReason } : {}),
@@ -20,13 +20,18 @@ import { join } from "node:path";
20
20
 
21
21
  function run(command, args) {
22
22
  const result = spawnSync(command, args, { stdio: "inherit" });
23
+ if (result.error) throw result.error;
23
24
  if (result.status !== 0) process.exit(result.status ?? 1);
24
25
  }
25
26
 
26
27
  rmSync("dist", { recursive: true, force: true });
27
28
  mkdirSync("dist", { recursive: true });
28
29
 
29
- run("tsc", ["-p", "tsconfig.build.json"]);
30
+ run(process.execPath, [
31
+ join("node_modules", "typescript", "bin", "tsc"),
32
+ "-p",
33
+ "tsconfig.build.json",
34
+ ]);
30
35
 
31
36
  mkdirSync(join("dist", "pi-actors"), { recursive: true });
32
37
  writeFileSync(
@@ -28,7 +28,12 @@ function packageRoot() {
28
28
 
29
29
  const result = spawnSync(
30
30
  process.execPath,
31
- ["--experimental-strip-types", "--test", ...conformanceSuites],
31
+ [
32
+ "--experimental-strip-types",
33
+ "--test",
34
+ "--test-concurrency=1",
35
+ ...conformanceSuites,
36
+ ],
32
37
  { cwd: packageRoot(), encoding: "utf8", stdio: "pipe" },
33
38
  );
34
39
 
@@ -16,7 +16,7 @@ import {
16
16
  statSync,
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
- import { dirname, extname, join, relative, resolve } from "node:path";
19
+ import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
20
20
 
21
21
  function usage() {
22
22
  console.error(`Usage:
@@ -101,7 +101,7 @@ function collectRunSummary(rootValue) {
101
101
  const root = resolve(
102
102
  rootValue.replace(/^~(?=\/|$)/, process.env.HOME ?? "~"),
103
103
  );
104
- const files = walkFiles(root, 2).filter((file) => file.endsWith("/run.json"));
104
+ const files = walkFiles(root, 2).filter((file) => basename(file) === "run.json");
105
105
  const rows = [];
106
106
  for (const file of files) {
107
107
  const run = readJson(file);
@@ -118,7 +118,7 @@ function collectRunSummary(rootValue) {
118
118
  const progress = readJson(join(runDir, "progress.json"));
119
119
  const result = readJson(join(runDir, "result.json"));
120
120
  rows.push({
121
- run: run.run_id ?? run.run ?? relative(root, file).split("/")[0],
121
+ run: run.run_id ?? run.run ?? relative(root, file).split(sep)[0],
122
122
  status: getRunStatus(run, progress, result),
123
123
  recipe: run.recipe ?? run.recipe_file ?? "",
124
124
  updated:
@@ -2,7 +2,7 @@
2
2
  name: actors
3
3
  description: Required practical guide for non-trivial pi-actors use, including parallel actor launches, subagent fanout, and autonomous coordinator workflows. Read before using or changing spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
4
4
  metadata:
5
- version: 0.42.0
5
+ version: 0.42.2
6
6
  ---
7
7
 
8
8
  # Actors (pi-actors)
@@ -69,7 +69,7 @@ Rules:
69
69
  - Command-template strings execute directly without a shell. Operators such as `&&`, `||`, pipes, redirects, and `cd` remain literal argv unless an explicit trusted shell is the executable. Prefer absolute paths or template arrays for sequencing; put non-trivial shell behavior in a reviewed script.
70
70
  - Use `file`/`recipe` for saved recipes; bare names resolve under `~/.pi/agent/recipes`.
71
71
  - Use inline `template` for one-off experiments; promote useful repeats to recipes.
72
- - When a successful actor follow-up suggests persistence, decide whether the pattern deserves durable tool memory; call `register_tool` yourself only when the evidence is strong, and ask before writing the user recipe root.
72
+ - Terminal follow-up context contains only run id, status, one base path, and relative artifact names. Inspect the run for contents; semantic output and correlation remain in non-LLM details and state. Decide whether a successful pattern deserves durable tool memory only after inspection, and ask before writing the user recipe root.
73
73
  - Use stable `as` names when you will inspect or message the actor later.
74
74
  - Public run state is runtime-owned; do not pass custom `state_dir` paths. This keeps `run:<id>` addressability and retention on one boundary.
75
75
  - `async: true` on the recipe is the detached run switch.
@@ -126,7 +126,7 @@ Views:
126
126
  - `artifacts`: declared artifact paths/status plus the same bounded owned review-evidence manifest when present.
127
127
  - `recipes` target: registry summary for active, shadowed, invalid, disabled, and diagnostic recipe entries.
128
128
 
129
- Let terminal notifications arrive. They queue through Pi's follow-up delivery mode, so a busy coordinator finishes its current work before receiving concurrently completed actor results; the host's `followUpMode` controls whether queued results arrive together or one at a time. File watching accelerates delivery, while a bounded ten-second terminal-only reconciliation pass recovers missed or failed watcher activity without replaying outbox traffic; watcher degradation and rearm remain visible diagnostics. When a deferred actor result gates the next step, wait for that terminal follow-up instead of scheduling continuation loops, repeatedly inspecting, or mutating the actor's reviewed scope. Idle coordinators still start a normal turn through `triggerTurn: true`. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue or stuck run.
129
+ Let terminal notifications arrive. They queue through Pi's follow-up delivery mode, so a busy coordinator finishes its current work before receiving concurrently completed actor results; the host's `followUpMode` controls whether queued results arrive together or one at a time. Their LLM context content stays limited to run id, status, one base path, and relative artifact names; inspect state for raw output while correlation and semantic details remain outside LLM context. File watching accelerates delivery, while a bounded ten-second terminal-only reconciliation pass recovers missed or failed watcher activity without replaying outbox traffic; watcher degradation and rearm remain visible diagnostics. When a deferred actor result gates the next step, wait for that terminal follow-up instead of scheduling continuation loops, repeatedly inspecting, or mutating the actor's reviewed scope. Idle coordinators still start a normal turn through `triggerTurn: true`. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue or stuck run.
130
130
 
131
131
  ## Runtime Communication Rules
132
132
 
@@ -2,7 +2,7 @@
2
2
  name: swarm
3
3
  description: Subagent and actor orchestration with scoped locks, fanout, and quorum consensus. Use before launching multiple parallel actors or subagents for independent implementation, artifact generation, review, delegated audit, coordinated execution, or any workflow that needs autonomous coordinator decomposition and integration.
4
4
  metadata:
5
- version: 0.42.0
5
+ version: 0.42.2
6
6
  ---
7
7
 
8
8
  # Swarm
@@ -29,9 +29,9 @@ Escape Close (or cancel the active options popup)
29
29
 
30
30
  Navigation stays bounded by available actions. `↑` on Run does nothing because no higher control exists. `↓` on Tabs enters the timeline only when it contains rows. Empty timelines therefore never receive focus.
31
31
 
32
- `K` appears only while Run is focused and the selected owned run reports `running`. It opens an in-overlay destructive confirmation; `Y`/Enter confirms and `N`/Escape cancels. Confirmation captures the immutable run generation and routes expected owner/generation through canonical `control.kill`; control compares both while serialized against same-directory restart, so terminal, ownership, or replacement-generation races reject without signaling. Success, cancellation, rejection, and failure remain bounded in the content area; terminal runs expose no Kill hint and reject a stale keypress.
32
+ `K` appears only while Run is focused and the selected owned run reports `running`. It replaces the Inspector with a dedicated responsive `Confirm Actor Kill` overlay that names the exact `run:<id>`, shows its current status, and states that canonical `control.kill` is destructive and irreversible. Cancel owns initial focus; ←/→/Tab moves between Cancel and Kill actor, Enter activates the focused choice, `Y` confirms directly, and `N`/Escape cancels. Confirmation captures the immutable run generation and routes expected owner/generation through canonical `control.kill`; control compares owner, generation, and running status while serialized against same-directory restart, so terminal, ownership, or replacement-generation races reject without signaling. After the dialog closes, success, cancellation, rejection, and failure remain bounded in the Inspector content area; terminal runs expose no Kill hint and reject a stale keypress.
33
33
 
34
- Selection and focus remain separate visual states. Accent-blue text marks the current tab, active filter popup, and applied option. The Run control uses `← … →` markers plus a light neutral background to show both focus and horizontal cycling; menus and timeline rows retain the single `▶` focus marker, while selected tabs retain brackets. Opening a popup keeps its parent filter blue so the relationship remains visible. The footer uses accent color only for key names and arrows; descriptions remain muted.
34
+ Selection and focus remain separate visual states. Accent-blue text marks the current tab, active filter popup, and applied option. The Run control uses `← … →` markers plus a light neutral background to show both focus and horizontal cycling; menus and timeline rows retain the single `▶` focus marker, while selected tabs retain brackets. Opening a popup keeps its parent filter blue so the relationship remains visible. Key hints live directly in the bottom overlay border rather than a dedicated body row: border-accent `─` connectors run through and between them instead of bullet glyphs, while key names and arrows retain blue accent color and descriptions use the border accent.
35
35
 
36
36
  The top Run control aligns vertically with the tab labels, names the selected owned run, and colors its textual lifecycle status semantically. ←/→ cycles owned runs directly with wraparound, while Enter opens the complete owned-run list immediately beneath the control. That run list starts one cell farther left than the filter menus so its border aligns with the Run control rather than the tab/filter grid. It still overlays the tab row rather than leaving a detached gap. The timeline no longer renders run metadata as a data row.
37
37
 
@@ -39,7 +39,7 @@ Filters live behind their tab rather than occupying a permanent row. Non-default
39
39
 
40
40
  Nested menus overlay rather than replace the timeline. Only rows and columns containing menu borders or values occlude underlying cells. When adjacent menus have different heights, the unused corner remains transparent and preserves the separator, striped background, and timeline data beneath it. Every run, filter, and nested value menu is viewport-bounded: ↑/↓ moves through the complete option set, the visible window follows focus, and `↑`/`↓` border markers disclose hidden options above or below without growing past the available inspector rows.
41
41
 
42
- The overlay uses most of the available terminal width and height and reduces its content/menu viewport on shorter terminals. The bordered header keeps all three tabs visible, while the body shows the selected run and its current status above the active document or evidence rows. Run, Message, and Turn lists place the newest retained item directly below their control; a newly opened Inspector therefore selects the latest owned run, and ↓ moves backward in time toward older entries. Run options, Messages, and Turns all use compact descending `#N` labels, providing one timestamp-free time axis without repeating type words on every row. Evidence rows retain stable alternating backgrounds based on their absolute timeline position, including while scrolling: even rows keep the dark overlay background, while odd rows use the neutral `customMessageBg` stripe. Unused viewport padding stays on the plain overlay background instead of drawing fake striped rows beneath the last item. The footer exposes the active keys. Messages retain attention markers and unread filtering and open into bounded detail without leaving the overlay. The overlay refreshes while visible and distinguishes true empty timelines from filtered-empty results; filtered-empty copy points back to Enter on the active tab without moving focus.
42
+ The overlay uses most of the available terminal width and height and reduces its content/menu viewport on shorter terminals. Its border-embedded key rail replaces the former three-row footer, returning two rows to a viewport that now caps at 24 rows. The bordered header keeps all three tabs visible, while the body shows the selected run and its current status above the active document or evidence rows. Run, Message, and Turn lists place the newest retained item directly below their control; a newly opened Inspector therefore selects the latest owned run, and ↓ moves backward in time toward older entries. Run options, Messages, and Turns all use compact descending `#N` labels, providing one timestamp-free time axis without repeating type words on every row. Evidence rows retain stable alternating backgrounds based on their absolute timeline position, including while scrolling: even rows keep the dark overlay background, while odd rows use the neutral `customMessageBg` stripe. Unused viewport padding stays on the plain overlay background instead of drawing fake striped rows beneath the last item. The bottom frame exposes the active keys. Messages retain attention markers and unread filtering and open into bounded detail without leaving the overlay. The overlay refreshes while visible and distinguishes true empty timelines from filtered-empty results; filtered-empty copy points back to Enter on the active tab without moving focus.
43
43
 
44
44
  ## Recipe Document
45
45
 
@@ -83,21 +83,27 @@ Use `run_id` on async recipe tools or `as: "run:<id>"` on `spawn` when the calle
83
83
 
84
84
  Review commands that require semantic evidence apply marker acceptance before command completion accounting. Rejected code-zero output is reported consistently as a failed command in events, progress, evidence, and outbox delivery; it cannot emit a success-level completion notification. Evidence records are written before command launch and lifecycle cancellation or kill finalizes any running record with its interrupted state, effective exit code, and attempt capture paths. Async attempt stdout/stderr files exist from attempt start, so even small partial streams remain auditable when a command never returns.
85
85
 
86
+ Terminal follow-up content stays deliberately minimal: run id, status, one base path, and relative artifact names only. With declared artifacts, `Base` names their common directory and `Artifacts` lists bounded relative names; without them, `Base` names the run state directory. It never embeds stdout, stderr, semantic body, terminal error, model policy, persistence advice, completion type, or an inspect command into LLM context. The follow-up's non-LLM details retain one bounded semantic result, launch/tool-call correlation, and optional bounded scalar `transport_context`; a transport adapter can preserve an exact route such as `{ "transport": "telegram", "chat_id": 123456, "thread_id": 77 }`. When a recipe advertises `review.completed`, an explicit matching outbox envelope wins; otherwise a successful accepted review result deterministically synthesizes one from the bounded beginning of `stdout.log`. Failed runs retain their bounded terminal error as `run.failed` details.
87
+
88
+ Watcher acceleration and periodic reconciliation share one live in-flight guard. Delivery remains at-least-once across the send/handled-marker crash window, but reentrant watcher/reconciliation races do not create parallel sends. A send failure leaves the run unhandled for retry, notifies the active operator, and persists bounded attempts/error/status evidence in `terminal-delivery-failure.json`; `getRunStatus` exposes the latest record as `terminal_delivery_failure`.
89
+
86
90
  ## State Files
87
91
 
88
92
  Use ordinary files under the extension temp directory so status tools stay simple and inspectable:
89
93
 
90
94
  - `.pi-actors-run-state.json`: runtime ownership marker binding the run id to the canonical state directory; launch reuse and destructive retention fail closed when it is absent, invalid, mismatched, or reached through a symlink alias. State reuse also fails closed whenever the persisted process identity mismatches a still-live pid, preventing corrupted metadata from admitting overlapping runners.
91
- - `run.json`: pid, cross-platform `process_identity` proof (start time, command, and canonical cwd where available), optional source metadata (`launch_source`, `tool`, `recipe`, `recipe_file`), command-template config, cwd, coordinator owner id, values, named `artifacts`, mailbox metadata, created time, and state dir. Existing launch cwd aliases are resolved through native `realpath` before proof matching, so symlinked working directories do not degrade control to `unsupported_proof`.
95
+ - `run.json`: pid, cross-platform `process_identity` proof (start time, command, and canonical cwd where available), optional source metadata (`launch_source`, `tool`, `recipe`, `recipe_file`), `launch_correlation`, bounded scalar `transport_context`, command-template config, cwd, coordinator owner id, values, named `artifacts`, mailbox metadata, created time, and state dir. Existing launch cwd aliases are resolved through native `realpath` before proof matching, so symlinked working directories do not degrade control to `unsupported_proof`.
92
96
  - `communication.json`: compact actor communication snapshot with self/root/parent, default-room, member, and contact hints for room-aware scripts and agents.
93
97
  - `progress.json`: phase, active command count, completed count, failures, updated time, and optional `model_policy` provenance for inherited/explicit model and thinking values.
94
98
  - `events.jsonl`: append-only implementation lifecycle log.
95
- - `outbox.jsonl`: implementation storage for actor-message envelopes used by `inspect view=messages`, coordinator notifications, or follow-up context. Coordinator follow-ups preserve bounded `body` previews plus message metadata for decision points.
99
+ - `outbox.jsonl`: implementation storage for actor-message envelopes used by `inspect view=messages`, coordinator notifications, or follow-up context. Script-authored decision-point follow-ups may preserve bounded `body` previews plus message metadata; automatic terminal follow-ups stay limited to run id, status, one base path, and relative artifact names.
96
100
  - `stdout.log` and `stderr.log`: detached process output.
97
101
  - `prompts/command-NNN.md`: state-owned prompt files that collapse child `pi -p` natural-language positional fragments and appended recipe context into one authoritative `@file` prompt while preserving intentional file/image arguments.
98
102
  - `captures/command-NNN/attempt-NNN/{stdout,stderr}.log`: complete byte-exact command streams, retained even below the bounded in-memory capture limit and separated across retries.
99
103
  - `review-evidence.json`: stable command/stage manifest linking prompts, repeated branches, capture attempts, byte counts, exit state, semantic marker acceptance, recipe context, and model/thinking policy; terminal status aligns with the run. Review pipelines inject prior-stage `ACTOR_EVIDENCE_REF` values into downstream prompts, record cited/missing report sources, and fail closed if a normalized report claims `complete` without every required reviewer, verifier, merger, and judge reference.
100
- - `result.json`: final code, killed flag, output selector, and optional full-output path.
104
+ - `result.json`: final code, killed flag, output selector, and optional full-output path. It publishes only after terminal `progress.json` and `review-evidence.json`, so readers never observe a result before its terminal state.
105
+ - `terminal-delivery-failure.json`: latest bounded failed follow-up attempt count, status, error, and timestamp; a later successful retry writes `terminal-handled.json`.
106
+ - `terminal-handled.json`: durable proof that terminal follow-up delivery or an explicit terminal control completed; notification delivery writes it only after the follow-up send returns successfully.
101
107
 
102
108
  Public `spawn` always uses the runtime-owned run root; caller-selected state directories are rejected so `run:<id>` addressing and retention share one boundary. Internal adapters may still supply isolated state directories for deterministic fixtures, but those are not part of the public actor contract. Every launched runner also persists a process identity proof and revalidates it for status, state reuse, message delivery, cancellation, kill, and retirement; dead pids, reused-pid owner mismatches, and unavailable platform proofs remain distinct diagnostics and destructive controls fail closed.
103
109
 
@@ -137,7 +143,7 @@ The core loop is:
137
143
  { "recipe": "music-player.json", "as": "run:music" }
138
144
  ```
139
145
 
140
- 2. Let terminal completion, `command.done`, and script-authored follow-up messages reach the launching coordinator automatically. When a directly spawned inline/ad hoc actor or a recipe outside `~/.pi/agent/recipes` completes successfully, the coordinator follow-up tells the agent to offer recipe persistence only as a question to the operator; it must not auto-save.
146
+ 2. Let terminal completion, `command.done`, and script-authored follow-up messages reach the launching coordinator automatically. Terminal completion gives the coordinator only run id, status, a base path, and relative artifact names; inspect the run when result content changes the next decision. Decide whether a successful pattern deserves recipe persistence only after inspection and operator confirmation.
141
147
 
142
148
  3. Respond with explicit run-local messages when needed:
143
149
 
@@ -135,7 +135,7 @@ The repeatable smoke surface is the normal validation suite:
135
135
  npm test
136
136
  ```
137
137
 
138
- The scenario coverage is intentionally local-first and bounded: shared room coordination and roster snapshots (`rooms` / `tools` tests), direct branch delivery and claim/handle transitions (`tools` and coordinator tests), inspector navigation (`inspector` tests), recipe context injection (`recipes-context` / async-runs tests), recipe persistence suggestions (`observability` tests), and opt-in retirement candidate/execution smoke (`observability` / async-runs tests). These scenarios exercise public `spawn` / `message` / `inspect` behavior or the packaged script surfaces rather than relying on manual swarm demos.
138
+ The scenario coverage is intentionally local-first and bounded: shared room coordination and roster snapshots (`rooms` / `tools` tests), direct branch delivery and claim/handle transitions (`tools` and coordinator tests), inspector navigation (`inspector` tests), recipe context injection (`recipes-context` / async-runs tests), compact terminal follow-up delivery (`observability` tests), and opt-in retirement candidate/execution smoke (`observability` / async-runs tests). These scenarios exercise public `spawn` / `message` / `inspect` behavior or the packaged script surfaces rather than relying on manual swarm demos.
139
139
 
140
140
  ## Music Player
141
141
 
@@ -22,6 +22,8 @@ Because the user recipe directory is sticky agent muscle memory, runtime launche
22
22
 
23
23
  `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.
24
24
 
25
+ Draft-consolidation journals capture root `dev` and `ino` from Node bigint stats and retain them as lossless decimal strings alongside lexical and native-real paths. Some network, virtual, or compatibility filesystems may report weak or zero device/inode identity; those values remain evidence but not a standalone trust claim because recovery also requires unchanged lexical paths, native realpaths, non-reparse directory roots, source/target hashes, and journal CAS. Native Windows regressions use unprivileged NTFS directory junctions to verify canonical mutation/lifecycle locks and fail-closed recovery after draft-root or trusted-root reparse substitution. This evidence supports the current portable process-crash and trusted-state-tree contract; a native handle-relative mutation layer is not justified unless real Windows runs expose a residual substitution window that these independent checks cannot fence.
26
+
25
27
  Inspect the loaded pi-actors runtime and discovered registry with:
26
28
 
27
29
  ```text
package/lib/async-runs.ts CHANGED
@@ -16,7 +16,7 @@ import {
16
16
  statSync,
17
17
  writeFileSync,
18
18
  } from "node:fs";
19
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
19
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
 
22
22
  import type {
@@ -94,6 +94,29 @@ export interface AsyncRunControlEndpoint {
94
94
  type: "fifo" | "mailbox" | "named-pipe";
95
95
  }
96
96
 
97
+ export function normalizeRunTransportContext(
98
+ value: unknown,
99
+ ): Record<string, string | number | boolean> | undefined {
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
101
+ const normalized: Record<string, string | number | boolean> = {};
102
+ for (const [key, item] of Object.entries(
103
+ value as Record<string, unknown>,
104
+ ).slice(0, 16)) {
105
+ const safeKey = key.trim().slice(0, 64);
106
+ if (!safeKey) continue;
107
+ if (typeof item === "string") {
108
+ normalized[safeKey] = item.trim().slice(0, 256);
109
+ continue;
110
+ }
111
+ if (typeof item === "number" && Number.isFinite(item)) {
112
+ normalized[safeKey] = item;
113
+ continue;
114
+ }
115
+ if (typeof item === "boolean") normalized[safeKey] = item;
116
+ }
117
+ return Object.keys(normalized).length ? normalized : undefined;
118
+ }
119
+
97
120
  export interface AsyncRunStartParams {
98
121
  async?: boolean;
99
122
  control?: AsyncRunControlEndpoint;
@@ -102,6 +125,10 @@ export interface AsyncRunStartParams {
102
125
  lifecycleHooks?: {
103
126
  onLockContention?(): void;
104
127
  };
128
+ launch_correlation?: {
129
+ correlation_id?: string;
130
+ tool_call_id?: string;
131
+ };
105
132
  name?: string;
106
133
  ownerId?: string;
107
134
  run_id?: string;
@@ -126,6 +153,7 @@ export interface AsyncRunStartParams {
126
153
  retry?: number | string;
127
154
  failure?: CommandTemplateFailureScope;
128
155
  recover?: CommandTemplateValue;
156
+ transport_context?: Record<string, unknown>;
129
157
  repeat?: number;
130
158
  values?: Record<string, unknown>;
131
159
  policy_values?: Record<string, unknown>;
@@ -140,6 +168,10 @@ export interface AsyncRunMeta {
140
168
  createdAt: string;
141
169
  cwd: string;
142
170
  launch_source?: AsyncRunLaunchSource;
171
+ launch_correlation?: {
172
+ correlation_id?: string;
173
+ tool_call_id?: string;
174
+ };
143
175
  ownerId?: string;
144
176
  pid: number;
145
177
  recipe?: string;
@@ -159,6 +191,7 @@ export interface AsyncRunMeta {
159
191
  process_identity?: RunProcessIdentity;
160
192
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
161
193
  retire_when?: "children_terminal";
194
+ transport_context?: Record<string, unknown>;
162
195
  }
163
196
 
164
197
  const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
@@ -242,7 +275,8 @@ function resolveRecipeFile(file: string): string {
242
275
  function isMutableUsageRecipeFile(file: string): boolean {
243
276
  const userRoot = resolve(DEFAULT_RECIPE_ROOT);
244
277
  const resolved = resolve(file);
245
- return resolved.startsWith(`${userRoot}/`);
278
+ const relation = relative(userRoot, resolved);
279
+ return relation !== "" && !relation.startsWith("..") && !isAbsolute(relation);
246
280
  }
247
281
 
248
282
  function readRecipeFile(file: string): AsyncRunStartParams {
@@ -498,6 +532,9 @@ export function startRun(
498
532
  ...(startParams.defaults || {}),
499
533
  ...values,
500
534
  };
535
+ const transportContext = normalizeRunTransportContext(
536
+ startParams.transport_context,
537
+ );
501
538
  const artifacts = resolveArtifactPaths(startParams.artifacts, outputValues);
502
539
  const meta: AsyncRunMeta = {
503
540
  argv: [process.execPath, ...argv],
@@ -506,6 +543,8 @@ export function startRun(
506
543
  ...(startParams.launch_source
507
544
  ? { launch_source: startParams.launch_source }
508
545
  : {}),
546
+ ...(startParams.launch_correlation
547
+ ? { launch_correlation: startParams.launch_correlation } : {}),
509
548
  ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
510
549
  pid: 0,
511
550
  ...(recipe ? { recipe } : {}),
@@ -530,8 +569,17 @@ export function startRun(
530
569
  ...(startParams.retire_when === "children_terminal"
531
570
  ? { retire_when: "children_terminal" as const }
532
571
  : {}),
572
+ ...(transportContext
573
+ ? { transport_context: transportContext } : {}),
533
574
  };
534
575
  writeJsonAtomic(join(stateDir, "run.json"), meta);
576
+ writeJsonAtomic(join(stateDir, "progress.json"), {
577
+ completed: 0,
578
+ failures: [],
579
+ model_policy: modelPolicy,
580
+ phase: "starting",
581
+ updatedAt: new Date().toISOString(),
582
+ });
535
583
  const child = spawn(process.execPath, argv, {
536
584
  cwd,
537
585
  detached: true,
@@ -548,13 +596,6 @@ export function startRun(
548
596
  );
549
597
  if (processIdentity) meta.process_identity = processIdentity;
550
598
  writeJsonAtomic(join(stateDir, "run.json"), meta);
551
- writeJsonAtomic(join(stateDir, "progress.json"), {
552
- completed: 0,
553
- failures: [],
554
- model_policy: modelPolicy,
555
- phase: "starting",
556
- updatedAt: new Date().toISOString(),
557
- });
558
599
  writeFileSync(
559
600
  join(stateDir, "events.jsonl"),
560
601
  `${JSON.stringify({ event: "run.start", run, run_instance_id: meta.run_instance_id, pid: meta.pid, ts: new Date().toISOString() })}\n`,
@@ -576,7 +617,7 @@ export type {
576
617
 
577
618
  function resolveRunStateDir(runOrDir: string): string {
578
619
  return resolve(
579
- runOrDir.includes("/")
620
+ /[\\/]/u.test(runOrDir)
580
621
  ? runOrDir
581
622
  : join(DEFAULT_STATE_ROOT, safeRunId(runOrDir)),
582
623
  );
@@ -906,11 +947,11 @@ function finalizeInterruptedReviewEvidence(
906
947
  return {
907
948
  attempt: index + 1,
908
949
  stdout: {
909
- path: relative(stateDir, stdoutFile),
950
+ path: relative(stateDir, stdoutFile).replaceAll("\\", "/"),
910
951
  bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
911
952
  },
912
953
  stderr: {
913
- path: relative(stateDir, stderrFile),
954
+ path: relative(stateDir, stderrFile).replaceAll("\\", "/"),
914
955
  bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
915
956
  },
916
957
  };
@@ -1030,6 +1071,25 @@ export function markRunTerminalNotificationHandled(
1030
1071
  });
1031
1072
  }
1032
1073
 
1074
+ export function recordRunTerminalDeliveryFailure(
1075
+ stateDir: string,
1076
+ status: string,
1077
+ error: unknown,
1078
+ ): void {
1079
+ const path = join(stateDir, "terminal-delivery-failure.json");
1080
+ const previous = readJson(path);
1081
+ const message = (error instanceof Error ? error.message : String(error))
1082
+ .replaceAll(/\s+/g, " ")
1083
+ .trim()
1084
+ .slice(0, 500);
1085
+ writeJsonAtomic(path, {
1086
+ attempts: Math.max(0, Number(previous?.attempts ?? 0)) + 1,
1087
+ error: message || "unknown delivery failure",
1088
+ status,
1089
+ ts: new Date().toISOString(),
1090
+ });
1091
+ }
1092
+
1033
1093
  export function cancelRun(
1034
1094
  runOrDir: string,
1035
1095
  expected: RunControlExpectation = {},
@@ -556,7 +556,7 @@ export function splitCommandTemplate(input: string): string[] {
556
556
  let active = false;
557
557
  for (const char of input) {
558
558
  if (escaped) {
559
- current += char;
559
+ current += /[\s'"\\]/u.test(char) ? char : `\\${char}`;
560
560
  escaped = false;
561
561
  active = true;
562
562
  continue;