@llblab/pi-actors 0.42.0 → 0.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/BACKLOG.md +1 -5
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +2 -0
  4. package/dist/lib/async-runs.d.ts +12 -0
  5. package/dist/lib/async-runs.js +46 -5
  6. package/dist/lib/command-templates.js +1 -1
  7. package/dist/lib/inspector-overlay.d.ts +4 -1
  8. package/dist/lib/inspector-overlay.js +71 -15
  9. package/dist/lib/inspector.js +1 -1
  10. package/dist/lib/observability.d.ts +12 -0
  11. package/dist/lib/observability.js +149 -5
  12. package/dist/lib/runs-control.d.ts +2 -0
  13. package/dist/lib/runs-control.js +14 -1
  14. package/dist/lib/runs-ownership.js +17 -3
  15. package/dist/lib/runs-process.js +4 -3
  16. package/dist/lib/runs-start.js +1 -0
  17. package/dist/lib/runs-status.js +3 -0
  18. package/dist/lib/tools-inspect.js +2 -1
  19. package/dist/lib/tools-local.js +17 -2
  20. package/dist/lib/tools-spawn.js +10 -1
  21. package/dist/scripts/async-runner.mjs +9 -9
  22. package/dist/scripts/build-dist.mjs +6 -1
  23. package/dist/scripts/conformance.mjs +6 -1
  24. package/dist/scripts/recipe-utils.mjs +3 -3
  25. package/dist/skills/actors/SKILL.md +1 -1
  26. package/dist/skills/swarm/SKILL.md +1 -1
  27. package/docs/actor-inspector.md +1 -1
  28. package/docs/async-runs.md +7 -1
  29. package/docs/tool-registry.md +2 -0
  30. package/lib/async-runs.ts +65 -5
  31. package/lib/command-templates.ts +1 -1
  32. package/lib/inspector-overlay.ts +67 -15
  33. package/lib/inspector.ts +1 -1
  34. package/lib/observability.ts +180 -4
  35. package/lib/runs-control.ts +20 -1
  36. package/lib/runs-ownership.ts +22 -3
  37. package/lib/runs-process.ts +4 -3
  38. package/lib/runs-start.ts +1 -0
  39. package/lib/runs-status.ts +5 -0
  40. package/lib/tools-inspect.ts +2 -1
  41. package/lib/tools-local.ts +21 -2
  42. package/lib/tools-spawn.ts +14 -1
  43. package/package.json +4 -3
  44. package/scripts/async-runner.mjs +9 -9
  45. package/scripts/build-dist.mjs +6 -1
  46. package/scripts/conformance.mjs +6 -1
  47. package/scripts/recipe-utils.mjs +3 -3
  48. package/skills/actors/SKILL.md +1 -1
  49. package/skills/swarm/SKILL.md +1 -1
package/BACKLOG.md CHANGED
@@ -1,7 +1,3 @@
1
1
  # Project Backlog
2
2
 
3
- ## Open Work
4
-
5
- ### Windows filesystem fencing verification
6
-
7
- - [ ] Add Windows CI and native NTFS regressions for consolidation root identity, directory junction/reparse-point substitution, lifecycle locks, and recovery without requiring privileged symbolic-link creation. Keep bigint device/inode capture, document filesystems that report weak or zero file identity, and evaluate a native handle-relative mutation layer only if real Windows evidence justifies expanding beyond Node’s portable process-crash and trusted-state-tree contract.
3
+ No open items.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.42.1: Terminal Delivery and Cross-platform Validation
6
+
7
+ - `[Terminal Delivery]` Added one bounded semantic terminal result with durable launch/tool-call correlation and bounded adapter-provided transport context. Explicit advertised semantic envelopes win; successful accepted reviews that advertise `review.completed` deterministically synthesize it when absent, while failed runs include their terminal error. Watcher and reconciliation delivery share the existing live in-flight dedupe guard, failed sends persist bounded retry evidence without writing the handled marker, and status exposes the latest failure. Impact: coordinators and Telegram-style chat/thread adapters can retain the exact launch/result relationship across detached completion instead of receiving only run-file metadata.
8
+ - `[Inspector Kill Dialog]` Replaced the inline kill prompt with a dedicated responsive confirmation overlay showing the exact run and current status, explicit destructive copy, Cancel-first focus, arrow/Tab selection, Enter activation, and direct Y/N/Escape behavior. Exact owner, generation, and running status still revalidate through canonical control after confirmation, and bounded feedback returns to the Inspector. Impact: destructive actor termination now has an unmistakable, keyboard-predictable safety boundary.
9
+ - `[Cross-platform CI]` Added identical release-validation matrix jobs for Ubuntu, macOS, and Windows with fail-fast disabled, bounded timeouts, pinned Node setup, npm caching, and `npm ci`; dependency audit now runs once in a separate Ubuntu job. Added invariants that keep audit outside platform-neutral validation. Impact: the package has one baseline release path across all supported host filesystems and shells.
10
+ - `[macOS Run Ownership]` Recognized the exact platform-owned `/var` to `/private/var` temporary-root alias without weakening nested-parent or leaf-symlink rejection. Impact: run-state ownership remains fail-closed for caller-controlled aliases while macOS temporary directories pass canonical ownership checks.
11
+ - `[Portable Build and Cleanup]` Invoked local JavaScript entrypoints through Node instead of relying on shell-specific executable resolution, recognized both path separators when resolving run state, converted file URLs with the platform API, made path assertions native, serialized release suites that exercise shared watchers, and cancelled live fixtures before removing their state. Impact: Windows paths and builds remain shell-free while macOS and Windows cleanup no longer races active worker writes.
12
+ - `[Windows Filesystem Fencing]` Added native-only NTFS regressions for bigint consolidation-root identity, unprivileged directory-junction alias locking, lifecycle locking, draft-root reparse substitution, and trusted-root recovery replacement. Documented weak/zero device-inode filesystems and retained the portable realpath/root-identity/CAS contract without preemptively introducing a native handle-relative mutation layer. Impact: the Windows runner now owns concrete evidence for the deeper filesystem boundary rather than inheriting Unix symlink assumptions.
13
+ - `[Cross-platform Validation Evidence]` Completed the release-validation matrix on Ubuntu, macOS, and Windows in GitHub Actions run `30322093465`, including native NTFS bigint root identity, directory-junction mutation/lifecycle locking, draft-root junction substitution, and trusted-root replacement recovery regressions. The failures found while establishing the matrix were portable path, command, process-tree, temporary-root alias, line-ending, and fixture-lifecycle gaps; no residual trusted-state-tree substitution window was reproduced, so a native handle-relative mutation layer is not warranted. Impact: the release boundary now has direct platform evidence for the portable process-crash and trusted-state-tree contract.
14
+
5
15
  ## 0.42.0: Automatic Recipe Evolution and Recipe-first Inspector
6
16
 
7
17
  - `Recipe-first Inspector`: The Inspector now opens on a bounded, redacted `Recipe` document for the selected owned run, showing authored identity, resolved launch template and values, composition, policy, mailbox, and artifacts without following mutable external recipe paths. `Messages` and `Turns` remain adjacent tabs; Turns use one scrollable detail level without duplicated recipe context, and documents/timelines/details support arrows plus PageUp/PageDown. Nested values render as compact indented text with label-first, separator-aware wrapping and consistent lowercase hints. Recipe documents retain ↑-at-top/← navigation to tabs, and ← now also returns a scrolled Messages or Turns list directly to its tabs without resetting the selected row. Turns retain one cached evidence snapshot while list/detail navigation remains active, eliminating repeated full session-file parsing on every arrow key while preserving periodic refresh when focus returns to higher-level controls.
package/README.md CHANGED
@@ -295,6 +295,8 @@ Packaged recipes are building blocks. Use `spawn file=<recipe>` for maintained p
295
295
 
296
296
  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 follow-up notification. The agent should ask first and never auto-save.
297
297
 
298
+ Terminal completion returns one bounded semantic result to the originating coordinator, not only run-state paths. `spawn` and saved async tools preserve their tool-call correlation automatically; callers may add `correlation_id` plus bounded `transport_context` when an adapter must retain an exact detached route, including Telegram `chat_id`/`thread_id`. Recipes that advertise `review.completed` either emit that envelope explicitly or receive one synthesized from successfully accepted review output. Watcher and periodic reconciliation share one in-flight guard, while failed sends remain retryable and visible in run status.
299
+
298
300
  ## Platform support
299
301
 
300
302
  Core actor state, inspection, foreground tools, and basic async runs are portable Node.js behavior. Run-local messaging and stop/kill use platform adapters under the same `message` API.
@@ -18,6 +18,7 @@ export interface AsyncRunControlEndpoint {
18
18
  path: string;
19
19
  type: "fifo" | "mailbox" | "named-pipe";
20
20
  }
21
+ export declare function normalizeRunTransportContext(value: unknown): Record<string, string | number | boolean> | undefined;
21
22
  export interface AsyncRunStartParams {
22
23
  async?: boolean;
23
24
  control?: AsyncRunControlEndpoint;
@@ -26,6 +27,10 @@ export interface AsyncRunStartParams {
26
27
  lifecycleHooks?: {
27
28
  onLockContention?(): void;
28
29
  };
30
+ launch_correlation?: {
31
+ correlation_id?: string;
32
+ tool_call_id?: string;
33
+ };
29
34
  name?: string;
30
35
  ownerId?: string;
31
36
  run_id?: string;
@@ -50,6 +55,7 @@ export interface AsyncRunStartParams {
50
55
  retry?: number | string;
51
56
  failure?: CommandTemplateFailureScope;
52
57
  recover?: CommandTemplateValue;
58
+ transport_context?: Record<string, unknown>;
53
59
  repeat?: number;
54
60
  values?: Record<string, unknown>;
55
61
  policy_values?: Record<string, unknown>;
@@ -62,6 +68,10 @@ export interface AsyncRunMeta {
62
68
  createdAt: string;
63
69
  cwd: string;
64
70
  launch_source?: AsyncRunLaunchSource;
71
+ launch_correlation?: {
72
+ correlation_id?: string;
73
+ tool_call_id?: string;
74
+ };
65
75
  ownerId?: string;
66
76
  pid: number;
67
77
  recipe?: string;
@@ -81,6 +91,7 @@ export interface AsyncRunMeta {
81
91
  process_identity?: RunProcessIdentity;
82
92
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
83
93
  retire_when?: "children_terminal";
94
+ transport_context?: Record<string, unknown>;
84
95
  }
85
96
  export { safeRunId } from "./runs-identity.ts";
86
97
  export { resolveArtifactManifest } from "./runs-artifacts.ts";
@@ -136,6 +147,7 @@ export interface RunControlExpectation {
136
147
  runInstanceId?: string;
137
148
  }
138
149
  export declare function markRunTerminalNotificationHandled(stateDir: string, status: string): void;
150
+ export declare function recordRunTerminalDeliveryFailure(stateDir: string, status: string, error: unknown): void;
139
151
  export declare function cancelRun(runOrDir: string, expected?: RunControlExpectation): Record<string, unknown>;
140
152
  export declare function archiveRun(runOrDir: string): Record<string, unknown>;
141
153
  export declare function pruneRun(runOrDir: string, options?: {
@@ -5,7 +5,7 @@
5
5
  import { spawn } from "node:child_process";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, writeFileSync, } from "node:fs";
8
- import { basename, dirname, extname, join, relative, resolve } from "node:path";
8
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { writeJsonAtomic } from "./file-state.js";
11
11
  import { CURRENT_MODEL_VALUE_KEY, CURRENT_THINKING_VALUE_KEY, describeCurrentPolicyProvenance, } from "./model-context.js";
@@ -27,6 +27,27 @@ import { deliverRunMessage, } from "./runs-messages.js";
27
27
  import { buildRunStatus, tailFile, tailLines, } from "./runs-status.js";
28
28
  import { readJsonFileResilient } from "./state-readers.js";
29
29
  const RUNNER_IDENTITY_GRACE_MS = 5000;
30
+ export function normalizeRunTransportContext(value) {
31
+ if (!value || typeof value !== "object" || Array.isArray(value))
32
+ return undefined;
33
+ const normalized = {};
34
+ for (const [key, item] of Object.entries(value).slice(0, 16)) {
35
+ const safeKey = key.trim().slice(0, 64);
36
+ if (!safeKey)
37
+ continue;
38
+ if (typeof item === "string") {
39
+ normalized[safeKey] = item.trim().slice(0, 256);
40
+ continue;
41
+ }
42
+ if (typeof item === "number" && Number.isFinite(item)) {
43
+ normalized[safeKey] = item;
44
+ continue;
45
+ }
46
+ if (typeof item === "boolean")
47
+ normalized[safeKey] = item;
48
+ }
49
+ return Object.keys(normalized).length ? normalized : undefined;
50
+ }
30
51
  const DEFAULT_STATE_ROOT = Paths.getRunStateRoot();
31
52
  const DEFAULT_RECIPE_ROOT = Paths.getRecipeRoot();
32
53
  function packageRoot() {
@@ -91,7 +112,8 @@ function resolveRecipeFile(file) {
91
112
  function isMutableUsageRecipeFile(file) {
92
113
  const userRoot = resolve(DEFAULT_RECIPE_ROOT);
93
114
  const resolved = resolve(file);
94
- return resolved.startsWith(`${userRoot}/`);
115
+ const relation = relative(userRoot, resolved);
116
+ return relation !== "" && !relation.startsWith("..") && !isAbsolute(relation);
95
117
  }
96
118
  function readRecipeFile(file) {
97
119
  const path = resolveRecipeFile(file);
@@ -260,6 +282,7 @@ export function startRun(params, cwd) {
260
282
  ...(startParams.defaults || {}),
261
283
  ...values,
262
284
  };
285
+ const transportContext = normalizeRunTransportContext(startParams.transport_context);
263
286
  const artifacts = resolveArtifactPaths(startParams.artifacts, outputValues);
264
287
  const meta = {
265
288
  argv: [process.execPath, ...argv],
@@ -268,6 +291,8 @@ export function startRun(params, cwd) {
268
291
  ...(startParams.launch_source
269
292
  ? { launch_source: startParams.launch_source }
270
293
  : {}),
294
+ ...(startParams.launch_correlation
295
+ ? { launch_correlation: startParams.launch_correlation } : {}),
271
296
  ...(startParams.ownerId ? { ownerId: startParams.ownerId } : {}),
272
297
  pid: 0,
273
298
  ...(recipe ? { recipe } : {}),
@@ -292,6 +317,8 @@ export function startRun(params, cwd) {
292
317
  ...(startParams.retire_when === "children_terminal"
293
318
  ? { retire_when: "children_terminal" }
294
319
  : {}),
320
+ ...(transportContext
321
+ ? { transport_context: transportContext } : {}),
295
322
  };
296
323
  writeJsonAtomic(join(stateDir, "run.json"), meta);
297
324
  const child = spawn(process.execPath, argv, {
@@ -323,7 +350,7 @@ export function startRun(params, cwd) {
323
350
  }
324
351
  export { parseRunOutboxEventLine } from "./runs-outbox.js";
325
352
  function resolveRunStateDir(runOrDir) {
326
- return resolve(runOrDir.includes("/")
353
+ return resolve(/[\\/]/u.test(runOrDir)
327
354
  ? runOrDir
328
355
  : join(DEFAULT_STATE_ROOT, safeRunId(runOrDir)));
329
356
  }
@@ -515,11 +542,11 @@ function finalizeInterruptedReviewEvidence(stateDir, phase, signal) {
515
542
  return {
516
543
  attempt: index + 1,
517
544
  stdout: {
518
- path: relative(stateDir, stdoutFile),
545
+ path: relative(stateDir, stdoutFile).replaceAll("\\", "/"),
519
546
  bytes: existsSync(stdoutFile) ? statSync(stdoutFile).size : 0,
520
547
  },
521
548
  stderr: {
522
- path: relative(stateDir, stderrFile),
549
+ path: relative(stateDir, stderrFile).replaceAll("\\", "/"),
523
550
  bytes: existsSync(stderrFile) ? statSync(stderrFile).size : 0,
524
551
  },
525
552
  };
@@ -606,6 +633,20 @@ export function markRunTerminalNotificationHandled(stateDir, status) {
606
633
  status,
607
634
  });
608
635
  }
636
+ export function recordRunTerminalDeliveryFailure(stateDir, status, error) {
637
+ const path = join(stateDir, "terminal-delivery-failure.json");
638
+ const previous = readJson(path);
639
+ const message = (error instanceof Error ? error.message : String(error))
640
+ .replaceAll(/\s+/g, " ")
641
+ .trim()
642
+ .slice(0, 500);
643
+ writeJsonAtomic(path, {
644
+ attempts: Math.max(0, Number(previous?.attempts ?? 0)) + 1,
645
+ error: message || "unknown delivery failure",
646
+ status,
647
+ ts: new Date().toISOString(),
648
+ });
649
+ }
609
650
  export function cancelRun(runOrDir, expected = {}) {
610
651
  const result = stopRun(runOrDir, "SIGTERM", "run.cancel", expected);
611
652
  return Object.hasOwn(result, "stopped")
@@ -344,7 +344,7 @@ export function splitCommandTemplate(input) {
344
344
  let active = false;
345
345
  for (const char of input) {
346
346
  if (escaped) {
347
- current += char;
347
+ current += /[\s'"\\]/u.test(char) ? char : `\\${char}`;
348
348
  escaped = false;
349
349
  active = true;
350
350
  continue;
@@ -40,6 +40,7 @@ export declare class ActorInspectorOverlay {
40
40
  private detailTurn?;
41
41
  private feedback?;
42
42
  private killConfirmation?;
43
+ private killDialogChoice;
43
44
  private readonly readKeys;
44
45
  private focus;
45
46
  private filterControlIndex;
@@ -62,8 +63,9 @@ export declare class ActorInspectorOverlay {
62
63
  private selectRun;
63
64
  private cycleRun;
64
65
  private requestKill;
66
+ private cancelKill;
65
67
  private confirmKill;
66
- private renderKillConfirmation;
68
+ private renderKillDialog;
67
69
  private listItemCount;
68
70
  private selectorAnchor;
69
71
  private renderKeyHints;
@@ -108,5 +110,6 @@ export declare class ActorInspectorOverlay {
108
110
  private row;
109
111
  private takeVisiblePrefix;
110
112
  private dropVisiblePrefix;
113
+ private center;
111
114
  private fit;
112
115
  }
@@ -33,6 +33,7 @@ export class ActorInspectorOverlay {
33
33
  detailTurn;
34
34
  feedback;
35
35
  killConfirmation;
36
+ killDialogChoice = "cancel";
36
37
  readKeys = new Set();
37
38
  focus = "tabs";
38
39
  filterControlIndex = 0;
@@ -67,13 +68,25 @@ export class ActorInspectorOverlay {
67
68
  return;
68
69
  }
69
70
  if (this.killConfirmation) {
70
- if (matchesKey(data, "escape") || data.toLowerCase() === "n") {
71
- this.feedback = { ok: false, message: "Kill cancelled." };
72
- this.killConfirmation = undefined;
71
+ const key = data.toLowerCase();
72
+ if (matchesKey(data, "escape") || key === "n") {
73
+ this.cancelKill();
73
74
  }
74
- else if (matchesKey(data, "return") || data.toLowerCase() === "y") {
75
+ else if (matchesKey(data, "left") ||
76
+ matchesKey(data, "right") ||
77
+ matchesKey(data, "tab")) {
78
+ this.killDialogChoice =
79
+ this.killDialogChoice === "cancel" ? "kill" : "cancel";
80
+ }
81
+ else if (key === "y") {
75
82
  this.confirmKill();
76
83
  }
84
+ else if (matchesKey(data, "return")) {
85
+ if (this.killDialogChoice === "kill")
86
+ this.confirmKill();
87
+ else
88
+ this.cancelKill();
89
+ }
77
90
  this.tui.requestRender();
78
91
  return;
79
92
  }
@@ -200,6 +213,8 @@ export class ActorInspectorOverlay {
200
213
  this.ensureSelectedRun(this.runs());
201
214
  const safeWidth = Math.max(24, width);
202
215
  const innerWidth = Math.max(1, safeWidth - 2);
216
+ if (this.killConfirmation)
217
+ return this.renderKillDialog(safeWidth, innerWidth);
203
218
  const lines = [];
204
219
  lines.push(this.border("╭", " Actor Inspector ", "╮", innerWidth));
205
220
  lines.push(this.row(this.renderRunControl(), innerWidth));
@@ -229,13 +244,11 @@ export class ActorInspectorOverlay {
229
244
  else
230
245
  lines.push(this.border("├", "", "┤", innerWidth));
231
246
  this.contentStripeIndices = [];
232
- const baseContent = this.killConfirmation
233
- ? this.renderKillConfirmation()
234
- : this.detailOpen
235
- ? this.renderDetail(innerWidth)
236
- : this.selectedRun
237
- ? this.renderTimeline(innerWidth)
238
- : this.renderRunSelector(innerWidth);
247
+ const baseContent = this.detailOpen
248
+ ? this.renderDetail(innerWidth)
249
+ : this.selectedRun
250
+ ? this.renderTimeline(innerWidth)
251
+ : this.renderRunSelector(innerWidth);
239
252
  const content = this.feedback && !this.killConfirmation
240
253
  ? [
241
254
  this.theme.fg(this.feedback.ok
@@ -337,11 +350,19 @@ export class ActorInspectorOverlay {
337
350
  this.killConfirmation = {
338
351
  run: run.run,
339
352
  runInstanceId: run.runInstanceId,
353
+ status: run.status,
340
354
  };
355
+ this.killDialogChoice = "cancel";
356
+ }
357
+ cancelKill() {
358
+ this.feedback = { ok: false, message: "Kill cancelled." };
359
+ this.killConfirmation = undefined;
360
+ this.killDialogChoice = "cancel";
341
361
  }
342
362
  confirmKill() {
343
363
  const confirmation = this.killConfirmation;
344
364
  this.killConfirmation = undefined;
365
+ this.killDialogChoice = "cancel";
345
366
  if (!confirmation || !this.killRun) {
346
367
  this.feedback = { ok: false, message: "Kill unavailable in this Inspector." };
347
368
  return;
@@ -357,11 +378,41 @@ export class ActorInspectorOverlay {
357
378
  };
358
379
  }
359
380
  }
360
- renderKillConfirmation() {
361
- return [
362
- this.theme.fg("error", ` Kill running actor run:${this.killConfirmation?.run}?`),
363
- this.theme.fg("muted", " This sends canonical control.kill and cannot be undone."),
381
+ renderKillDialog(_width, innerWidth) {
382
+ const confirmation = this.killConfirmation;
383
+ const totalRows = this.contentViewportRows() + 7;
384
+ const cancel = this.killDialogChoice === "cancel"
385
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", " Cancel "))
386
+ : this.theme.fg("muted", " Cancel ");
387
+ const kill = this.killDialogChoice === "kill"
388
+ ? this.theme.bg("selectedBg", this.theme.fg("error", " Kill actor "))
389
+ : this.theme.fg("error", " Kill actor ");
390
+ const body = [
391
+ "",
392
+ this.theme.fg("error", "Kill this running actor?"),
393
+ "",
394
+ `${this.theme.fg("muted", "Run:")} ${this.theme.fg("accent", `run:${confirmation.run}`)}`,
395
+ `${this.theme.fg("muted", "Current status:")} ${this.theme.fg("warning", confirmation.status)}`,
396
+ "",
397
+ this.theme.fg("text", "This sends canonical control.kill."),
398
+ this.theme.fg("error", "The action is destructive and cannot be undone."),
399
+ "",
400
+ `${cancel} ${kill}`,
401
+ "",
364
402
  ];
403
+ const lines = [this.border("╭", " Confirm Actor Kill ", "╮", innerWidth)];
404
+ const availableRows = Math.max(1, totalRows - 2);
405
+ const topPadding = Math.max(0, Math.floor((availableRows - body.length) / 2));
406
+ const dialogRows = [
407
+ ...Array.from({ length: topPadding }, () => ""),
408
+ ...body,
409
+ ].slice(0, availableRows);
410
+ while (dialogRows.length < availableRows)
411
+ dialogRows.push("");
412
+ for (const line of dialogRows)
413
+ lines.push(this.row(this.center(line, innerWidth), innerWidth));
414
+ lines.push(this.border("╰", " ←→/tab choose · enter activate · esc cancel ", "╯", innerWidth));
415
+ return lines;
365
416
  }
366
417
  listItemCount() {
367
418
  if (this.tab === "recipe")
@@ -1062,6 +1113,11 @@ export class ActorInspectorOverlay {
1062
1113
  }
1063
1114
  return plain.slice(index);
1064
1115
  }
1116
+ center(content, width) {
1117
+ const contentWidth = visibleWidth(content);
1118
+ const leading = Math.max(0, Math.floor((width - contentWidth) / 2));
1119
+ return `${" ".repeat(leading)}${content}`;
1120
+ }
1065
1121
  fit(content, width) {
1066
1122
  // A Component render entry must remain exactly one terminal row. Persisted
1067
1123
  // prompts, messages, and tool results may contain line breaks or tabs;
@@ -126,7 +126,7 @@ export function readActorInspectorTurns(stateDir) {
126
126
  withFileTypes: true,
127
127
  })
128
128
  .filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
129
- .map((file) => path.join("sessions", entry.name, file.name))
129
+ .map((file) => path.posix.join("sessions", entry.name, file.name))
130
130
  .filter((file) => !recordedFiles.has(file));
131
131
  return files.length > 0
132
132
  ? [{ id: entry.name, session_files: files, stage: "subagent" }]
@@ -15,6 +15,7 @@ export interface RunObservation {
15
15
  failures?: number;
16
16
  ownerId?: string;
17
17
  artifacts?: Record<string, string>;
18
+ launchCorrelation?: Record<string, string>;
18
19
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
19
20
  modelPolicy?: Record<string, unknown>;
20
21
  notificationPolicy?: "normal" | "silent";
@@ -22,6 +23,7 @@ export interface RunObservation {
22
23
  terminalHandled?: boolean;
23
24
  retireWhen?: string;
24
25
  run: string;
26
+ semanticResult?: RunTerminalSemanticResult;
25
27
  tool?: string;
26
28
  stateDir?: string;
27
29
  status: RunObservedStatus;
@@ -131,12 +133,22 @@ export interface RunTransition {
131
133
  run: string;
132
134
  stateDir?: string;
133
135
  artifacts?: Record<string, string>;
136
+ launchCorrelation?: Record<string, string>;
134
137
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
135
138
  modelPolicy?: Record<string, unknown>;
136
139
  recipeFile?: string;
137
140
  terminalHandled?: boolean;
138
141
  to: RunObservedStatus;
139
142
  tool?: string;
143
+ semanticResult?: RunTerminalSemanticResult;
144
+ }
145
+ export interface RunTerminalSemanticResult {
146
+ body?: string;
147
+ correlationId?: string;
148
+ metadata: Record<string, unknown>;
149
+ summary: string;
150
+ synthesized: boolean;
151
+ type: string;
140
152
  }
141
153
  export interface RunOutboxEvent {
142
154
  body?: unknown;
@@ -3,7 +3,7 @@
3
3
  * Zones: async runtime, ambient UI, diagnostics
4
4
  * Owns ambient summaries, terminal events, and run outbox delivery for detached command-template runs
5
5
  */
6
- import { existsSync, readdirSync, readFileSync, watch, } from "node:fs";
6
+ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, readSync, watch, } from "node:fs";
7
7
  import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
8
8
  import * as AsyncRuns from "./async-runs.js";
9
9
  import * as Paths from "./paths.js";
@@ -54,6 +54,13 @@ export function deliverRunTransitionNotifications(transitions, sink, inFlight =
54
54
  AsyncRuns.markRunTerminalNotificationHandled(transition.stateDir, transition.to);
55
55
  }
56
56
  }
57
+ catch (error) {
58
+ if (transition.stateDir) {
59
+ AsyncRuns.recordRunTerminalDeliveryFailure(transition.stateDir, transition.to, error);
60
+ }
61
+ const message = error instanceof Error ? error.message : String(error);
62
+ sink.notify(`Actor terminal delivery failed for run:${transition.run}: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`, "error");
63
+ }
57
64
  finally {
58
65
  inFlight.delete(key);
59
66
  }
@@ -282,6 +289,121 @@ function scanRunStateDirs(stateRoot, depth = 0, seen = new Set()) {
282
289
  }
283
290
  return result;
284
291
  }
292
+ const TERMINAL_RESULT_BYTES = 8 * 1024;
293
+ const TERMINAL_RESULT_CHARS = 4_000;
294
+ function readBoundedStart(path) {
295
+ if (!existsSync(path))
296
+ return "";
297
+ const fd = openSync(path, "r");
298
+ try {
299
+ const size = Math.min(fstatSync(fd).size, TERMINAL_RESULT_BYTES);
300
+ const buffer = Buffer.alloc(size);
301
+ const bytes = readSync(fd, buffer, 0, size, 0);
302
+ const text = buffer.subarray(0, bytes).toString("utf8").trim();
303
+ return text.length > TERMINAL_RESULT_CHARS
304
+ ? `${text.slice(0, TERMINAL_RESULT_CHARS - 1)}…`
305
+ : text;
306
+ }
307
+ finally {
308
+ closeSync(fd);
309
+ }
310
+ }
311
+ function semanticBodyFromStdout(stateDir) {
312
+ const text = readBoundedStart(join(stateDir, "stdout.log"));
313
+ if (!text)
314
+ return undefined;
315
+ const withoutMarker = text.replace(/^ACTOR_REVIEW_RESULT\s*(?:\r?\n)?/, "").trim();
316
+ return withoutMarker || undefined;
317
+ }
318
+ function terminalSemanticResult(status, stateDir, observedStatus) {
319
+ const correlation = status.launch_correlation &&
320
+ typeof status.launch_correlation === "object" &&
321
+ !Array.isArray(status.launch_correlation)
322
+ ? status.launch_correlation
323
+ : {};
324
+ const correlationId = typeof correlation.correlation_id === "string"
325
+ ? correlation.correlation_id
326
+ : typeof correlation.tool_call_id === "string"
327
+ ? correlation.tool_call_id
328
+ : undefined;
329
+ const mailbox = status.mailbox &&
330
+ typeof status.mailbox === "object" &&
331
+ !Array.isArray(status.mailbox)
332
+ ? status.mailbox
333
+ : {};
334
+ const emits = Array.isArray(mailbox.emits)
335
+ ? mailbox.emits.filter((item) => typeof item === "string")
336
+ : [];
337
+ const outbox = readJsonlFileResilient(join(stateDir, "outbox.jsonl")).records;
338
+ const explicit = outbox.findLast((record) => {
339
+ const type = String(record.type ?? record.event ?? "");
340
+ return emits.includes(type) && !["command.done", "run.done", "run.failed"].includes(type);
341
+ });
342
+ if (explicit) {
343
+ const type = String(explicit.type ?? explicit.event);
344
+ return {
345
+ ...(explicit.body === undefined
346
+ ? {} : { body: formatSemanticBody(explicit.body) }),
347
+ ...(typeof explicit.correlation_id === "string"
348
+ ? { correlationId: explicit.correlation_id } : correlationId ? { correlationId } : {}),
349
+ metadata: {
350
+ ...(explicit.metadata &&
351
+ typeof explicit.metadata === "object" &&
352
+ !Array.isArray(explicit.metadata)
353
+ ? explicit.metadata : {}),
354
+ ...(status.transport_context &&
355
+ typeof status.transport_context === "object" &&
356
+ !Array.isArray(status.transport_context)
357
+ ? {
358
+ transport_context: status.transport_context,
359
+ } : {}),
360
+ run: String(status.run ?? ""),
361
+ status: observedStatus,
362
+ },
363
+ summary: String(explicit.summary ?? type),
364
+ synthesized: false,
365
+ type,
366
+ };
367
+ }
368
+ const reviewCompleted = observedStatus === "done" && emits.includes("review.completed");
369
+ const result = status.result &&
370
+ typeof status.result === "object" &&
371
+ !Array.isArray(status.result)
372
+ ? status.result
373
+ : {};
374
+ const type = reviewCompleted
375
+ ? "review.completed"
376
+ : observedStatus === "done" ? "run.done" : "run.failed";
377
+ const stdoutBody = observedStatus === "done"
378
+ ? semanticBodyFromStdout(stateDir) : undefined;
379
+ return {
380
+ ...(stdoutBody
381
+ ? { body: stdoutBody }
382
+ : typeof result.error === "string" ? { body: result.error } : {}),
383
+ ...(correlationId ? { correlationId } : {}),
384
+ metadata: {
385
+ ...(status.transport_context &&
386
+ typeof status.transport_context === "object" &&
387
+ !Array.isArray(status.transport_context)
388
+ ? {
389
+ transport_context: status.transport_context,
390
+ } : {}),
391
+ run: String(status.run ?? ""), status: observedStatus,
392
+ },
393
+ summary: reviewCompleted ? "Review completed." :
394
+ observedStatus === "done" ? "Run completed." : `Run ${observedStatus}.`,
395
+ synthesized: true,
396
+ type,
397
+ };
398
+ }
399
+ function formatSemanticBody(body) {
400
+ const rendered = typeof body === "string" ? body : JSON.stringify(body);
401
+ const text = typeof rendered === "string" ? rendered : String(body);
402
+ const compact = text.trim();
403
+ return compact.length > TERMINAL_RESULT_CHARS
404
+ ? `${compact.slice(0, TERMINAL_RESULT_CHARS - 1)}…`
405
+ : compact;
406
+ }
285
407
  function observeRun(stateDir) {
286
408
  try {
287
409
  const status = AsyncRuns.getRunStatus(stateDir);
@@ -289,9 +411,15 @@ function observeRun(stateDir) {
289
411
  const run = typeof status.run === "string" ? status.run : undefined;
290
412
  if (!run)
291
413
  return undefined;
414
+ const observedStatus = status.status;
292
415
  return {
293
416
  activeSubagents: toNumber(progress.activeSubagents),
294
417
  completed: toNumber(progress.completed),
418
+ ...(status.launch_correlation &&
419
+ typeof status.launch_correlation === "object" &&
420
+ !Array.isArray(status.launch_correlation)
421
+ ? { launchCorrelation: status.launch_correlation }
422
+ : {}),
295
423
  failures: Array.isArray(progress.failures)
296
424
  ? progress.failures.length
297
425
  : undefined,
@@ -321,9 +449,12 @@ function observeRun(stateDir) {
321
449
  ...(typeof status.retire_when === "string"
322
450
  ? { retireWhen: status.retire_when }
323
451
  : {}),
452
+ ...(TERMINAL.has(observedStatus)
453
+ ? { semanticResult: terminalSemanticResult(status, stateDir, observedStatus) }
454
+ : {}),
324
455
  run,
325
456
  stateDir,
326
- status: status.status,
457
+ status: observedStatus,
327
458
  ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
328
459
  updatedAt: getUpdatedAt(status),
329
460
  };
@@ -626,9 +757,11 @@ export function detectRunTransitions(previous, summary) {
626
757
  run: run.run,
627
758
  ...(run.stateDir ? { stateDir: run.stateDir } : {}),
628
759
  ...(run.artifacts ? { artifacts: run.artifacts } : {}),
760
+ ...(run.launchCorrelation ? { launchCorrelation: run.launchCorrelation } : {}),
629
761
  ...(run.launchSource ? { launchSource: run.launchSource } : {}),
630
762
  ...(run.modelPolicy ? { modelPolicy: run.modelPolicy } : {}),
631
763
  ...(run.recipeFile ? { recipeFile: run.recipeFile } : {}),
764
+ ...(run.semanticResult ? { semanticResult: run.semanticResult } : {}),
632
765
  ...(run.terminalHandled ? { terminalHandled: true } : {}),
633
766
  to: run.status,
634
767
  ...(run.tool ? { tool: run.tool } : {}),
@@ -841,7 +974,8 @@ function isUserRecipeFile(file) {
841
974
  return false;
842
975
  const recipeRoot = resolve(Paths.getRecipeRoot());
843
976
  const path = resolve(file);
844
- return path === recipeRoot || path.startsWith(`${recipeRoot}/`);
977
+ const relation = relative(recipeRoot, path);
978
+ return relation === "" || (!relation.startsWith("..") && !isAbsolute(relation));
845
979
  }
846
980
  export function shouldSuggestRecipePersistence(transition) {
847
981
  if (transition.to !== "done")
@@ -887,16 +1021,26 @@ function formatTransitionNextActions(transition) {
887
1021
  ].filter(Boolean);
888
1022
  return `\nNext actions: ${actions.join(" | ")}`;
889
1023
  }
1024
+ function formatTransitionSemanticResult(transition) {
1025
+ const result = transition.semanticResult;
1026
+ if (!result)
1027
+ return "";
1028
+ const correlation = result.correlationId
1029
+ ? `\nCorrelation: ${result.correlationId}` : "";
1030
+ const body = result.body ? `\nResult:\n${result.body}` : "";
1031
+ return `\nSemantic result: ${result.type} — ${result.summary}${correlation}${body}`;
1032
+ }
890
1033
  export function formatRunTransitionMessage(transition) {
891
1034
  const artifacts = formatNamedArtifacts(transition.artifacts);
892
1035
  const runFiles = formatRunFileList(getRunArtifacts(transition));
893
1036
  const persistenceSuggestion = formatRecipePersistenceSuggestion(transition);
894
1037
  const policy = formatTransitionPolicy(transition);
895
1038
  const nextActions = formatTransitionNextActions(transition);
1039
+ const semanticResult = formatTransitionSemanticResult(transition);
896
1040
  if (transition.to === "done")
897
- return `Run ${transition.run} completed successfully.${policy}${artifacts}${runFiles}${nextActions}${persistenceSuggestion}`;
1041
+ return `Run ${transition.run} completed successfully.${semanticResult}${policy}${artifacts}${runFiles}${nextActions}${persistenceSuggestion}`;
898
1042
  if (transition.to === "failed")
899
- return `Run ${transition.run} failed.${policy}${artifacts}${runFiles}${nextActions}`;
1043
+ return `Run ${transition.run} failed.${semanticResult}${policy}${artifacts}${runFiles}${nextActions}`;
900
1044
  if (transition.to === "cancelled")
901
1045
  return `Run ${transition.run} was cancelled.${policy}${nextActions}`;
902
1046
  if (transition.to === "killed")