@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
@@ -69,7 +69,8 @@ export class ActorInspectorOverlay {
69
69
  private detailScroll = 0;
70
70
  private detailTurn?: Inspector.ActorInspectorTurnItem;
71
71
  private feedback?: ActorInspectorOverlayActionResult;
72
- private killConfirmation?: { run: string; runInstanceId: string };
72
+ private killConfirmation?: { run: string; runInstanceId: string; status: string };
73
+ private killDialogChoice: "cancel" | "kill" = "cancel";
73
74
  private readonly readKeys = new Set<string>();
74
75
  private focus: "runs" | "tabs" | "recipe" | "list" | "detail" | "select" = "tabs";
75
76
  private filterControlIndex = 0;
@@ -105,11 +106,21 @@ export class ActorInspectorOverlay {
105
106
  return;
106
107
  }
107
108
  if (this.killConfirmation) {
108
- if (matchesKey(data, "escape") || data.toLowerCase() === "n") {
109
- this.feedback = { ok: false, message: "Kill cancelled." };
110
- this.killConfirmation = undefined;
111
- } else if (matchesKey(data, "return") || data.toLowerCase() === "y") {
109
+ const key = data.toLowerCase();
110
+ if (matchesKey(data, "escape") || key === "n") {
111
+ this.cancelKill();
112
+ } else if (
113
+ matchesKey(data, "left") ||
114
+ matchesKey(data, "right") ||
115
+ matchesKey(data, "tab")
116
+ ) {
117
+ this.killDialogChoice =
118
+ this.killDialogChoice === "cancel" ? "kill" : "cancel";
119
+ } else if (key === "y") {
112
120
  this.confirmKill();
121
+ } else if (matchesKey(data, "return")) {
122
+ if (this.killDialogChoice === "kill") this.confirmKill();
123
+ else this.cancelKill();
113
124
  }
114
125
  this.tui.requestRender();
115
126
  return;
@@ -215,6 +226,8 @@ export class ActorInspectorOverlay {
215
226
  this.ensureSelectedRun(this.runs());
216
227
  const safeWidth = Math.max(24, width);
217
228
  const innerWidth = Math.max(1, safeWidth - 2);
229
+ if (this.killConfirmation)
230
+ return this.renderKillDialog(safeWidth, innerWidth);
218
231
  const lines: string[] = [];
219
232
  lines.push(this.border("╭", " Actor Inspector ", "╮", innerWidth));
220
233
  lines.push(this.row(this.renderRunControl(), innerWidth));
@@ -261,9 +274,7 @@ export class ActorInspectorOverlay {
261
274
  );
262
275
  } else lines.push(this.border("├", "", "┤", innerWidth));
263
276
  this.contentStripeIndices = [];
264
- const baseContent = this.killConfirmation
265
- ? this.renderKillConfirmation()
266
- : this.detailOpen
277
+ const baseContent = this.detailOpen
267
278
  ? this.renderDetail(innerWidth)
268
279
  : this.selectedRun
269
280
  ? this.renderTimeline(innerWidth)
@@ -392,12 +403,21 @@ export class ActorInspectorOverlay {
392
403
  this.killConfirmation = {
393
404
  run: run.run,
394
405
  runInstanceId: run.runInstanceId,
406
+ status: run.status,
395
407
  };
408
+ this.killDialogChoice = "cancel";
409
+ }
410
+
411
+ private cancelKill(): void {
412
+ this.feedback = { ok: false, message: "Kill cancelled." };
413
+ this.killConfirmation = undefined;
414
+ this.killDialogChoice = "cancel";
396
415
  }
397
416
 
398
417
  private confirmKill(): void {
399
418
  const confirmation = this.killConfirmation;
400
419
  this.killConfirmation = undefined;
420
+ this.killDialogChoice = "cancel";
401
421
  if (!confirmation || !this.killRun) {
402
422
  this.feedback = { ok: false, message: "Kill unavailable in this Inspector." };
403
423
  return;
@@ -416,14 +436,40 @@ export class ActorInspectorOverlay {
416
436
  }
417
437
  }
418
438
 
419
- private renderKillConfirmation(): string[] {
420
- return [
421
- this.theme.fg(
422
- "error",
423
- ` Kill running actor run:${this.killConfirmation?.run}?`,
424
- ),
425
- this.theme.fg("muted", " This sends canonical control.kill and cannot be undone."),
439
+ private renderKillDialog(_width: number, innerWidth: number): string[] {
440
+ const confirmation = this.killConfirmation!;
441
+ const totalRows = this.contentViewportRows() + 7;
442
+ const cancel = this.killDialogChoice === "cancel"
443
+ ? this.theme.bg("selectedBg", this.theme.fg("accent", " Cancel "))
444
+ : this.theme.fg("muted", " Cancel ");
445
+ const kill = this.killDialogChoice === "kill"
446
+ ? this.theme.bg("selectedBg", this.theme.fg("error", " Kill actor "))
447
+ : this.theme.fg("error", " Kill actor ");
448
+ const body = [
449
+ "",
450
+ this.theme.fg("error", "Kill this running actor?"),
451
+ "",
452
+ `${this.theme.fg("muted", "Run:")} ${this.theme.fg("accent", `run:${confirmation.run}`)}`,
453
+ `${this.theme.fg("muted", "Current status:")} ${this.theme.fg("warning", confirmation.status)}`,
454
+ "",
455
+ this.theme.fg("text", "This sends canonical control.kill."),
456
+ this.theme.fg("error", "The action is destructive and cannot be undone."),
457
+ "",
458
+ `${cancel} ${kill}`,
459
+ "",
426
460
  ];
461
+ const lines = [this.border("╭", " Confirm Actor Kill ", "╮", innerWidth)];
462
+ const availableRows = Math.max(1, totalRows - 2);
463
+ const topPadding = Math.max(0, Math.floor((availableRows - body.length) / 2));
464
+ const dialogRows = [
465
+ ...Array.from({ length: topPadding }, () => ""),
466
+ ...body,
467
+ ].slice(0, availableRows);
468
+ while (dialogRows.length < availableRows) dialogRows.push("");
469
+ for (const line of dialogRows)
470
+ lines.push(this.row(this.center(line, innerWidth), innerWidth));
471
+ lines.push(this.border("╰", " ←→/tab choose · enter activate · esc cancel ", "╯", innerWidth));
472
+ return lines;
427
473
  }
428
474
 
429
475
  private listItemCount(): number {
@@ -1293,6 +1339,12 @@ export class ActorInspectorOverlay {
1293
1339
  return plain.slice(index);
1294
1340
  }
1295
1341
 
1342
+ private center(content: string, width: number): string {
1343
+ const contentWidth = visibleWidth(content);
1344
+ const leading = Math.max(0, Math.floor((width - contentWidth) / 2));
1345
+ return `${" ".repeat(leading)}${content}`;
1346
+ }
1347
+
1296
1348
  private fit(content: string, width: number): string {
1297
1349
  // A Component render entry must remain exactly one terminal row. Persisted
1298
1350
  // prompts, messages, and tool results may contain line breaks or tabs;
package/lib/inspector.ts CHANGED
@@ -248,7 +248,7 @@ export function readActorInspectorTurns(stateDir: string): ActorInspectorTurnIte
248
248
  withFileTypes: true,
249
249
  })
250
250
  .filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
251
- .map((file) => path.join("sessions", entry.name, file.name))
251
+ .map((file) => path.posix.join("sessions", entry.name, file.name))
252
252
  .filter((file) => !recordedFiles.has(file));
253
253
  return files.length > 0
254
254
  ? [{ id: entry.name, session_files: files, stage: "subagent" }]
@@ -5,9 +5,13 @@
5
5
  */
6
6
 
7
7
  import {
8
+ closeSync,
8
9
  existsSync,
10
+ fstatSync,
11
+ openSync,
9
12
  readdirSync,
10
13
  readFileSync,
14
+ readSync,
11
15
  watch,
12
16
  type FSWatcher,
13
17
  } from "node:fs";
@@ -41,6 +45,7 @@ export interface RunObservation {
41
45
  failures?: number;
42
46
  ownerId?: string;
43
47
  artifacts?: Record<string, string>;
48
+ launchCorrelation?: Record<string, string>;
44
49
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
45
50
  modelPolicy?: Record<string, unknown>;
46
51
  notificationPolicy?: "normal" | "silent";
@@ -48,6 +53,7 @@ export interface RunObservation {
48
53
  terminalHandled?: boolean;
49
54
  retireWhen?: string;
50
55
  run: string;
56
+ semanticResult?: RunTerminalSemanticResult;
51
57
  tool?: string;
52
58
  stateDir?: string;
53
59
  status: RunObservedStatus;
@@ -162,6 +168,19 @@ export function deliverRunTransitionNotifications(
162
168
  transition.to,
163
169
  );
164
170
  }
171
+ } catch (error) {
172
+ if (transition.stateDir) {
173
+ AsyncRuns.recordRunTerminalDeliveryFailure(
174
+ transition.stateDir,
175
+ transition.to,
176
+ error,
177
+ );
178
+ }
179
+ const message = error instanceof Error ? error.message : String(error);
180
+ sink.notify(
181
+ `Actor terminal delivery failed for run:${transition.run}: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`,
182
+ "error",
183
+ );
165
184
  } finally {
166
185
  inFlight.delete(key);
167
186
  }
@@ -420,12 +439,23 @@ export interface RunTransition {
420
439
  run: string;
421
440
  stateDir?: string;
422
441
  artifacts?: Record<string, string>;
442
+ launchCorrelation?: Record<string, string>;
423
443
  launchSource?: AsyncRuns.AsyncRunLaunchSource;
424
444
  modelPolicy?: Record<string, unknown>;
425
445
  recipeFile?: string;
426
446
  terminalHandled?: boolean;
427
447
  to: RunObservedStatus;
428
448
  tool?: string;
449
+ semanticResult?: RunTerminalSemanticResult;
450
+ }
451
+
452
+ export interface RunTerminalSemanticResult {
453
+ body?: string;
454
+ correlationId?: string;
455
+ metadata: Record<string, unknown>;
456
+ summary: string;
457
+ synthesized: boolean;
458
+ type: string;
429
459
  }
430
460
 
431
461
  export interface RunOutboxEvent {
@@ -504,15 +534,145 @@ function scanRunStateDirs(
504
534
  return result;
505
535
  }
506
536
 
537
+ const TERMINAL_RESULT_BYTES = 8 * 1024;
538
+ const TERMINAL_RESULT_CHARS = 4_000;
539
+
540
+ function readBoundedStart(path: string): string {
541
+ if (!existsSync(path)) return "";
542
+ const fd = openSync(path, "r");
543
+ try {
544
+ const size = Math.min(fstatSync(fd).size, TERMINAL_RESULT_BYTES);
545
+ const buffer = Buffer.alloc(size);
546
+ const bytes = readSync(fd, buffer, 0, size, 0);
547
+ const text = buffer.subarray(0, bytes).toString("utf8").trim();
548
+ return text.length > TERMINAL_RESULT_CHARS
549
+ ? `${text.slice(0, TERMINAL_RESULT_CHARS - 1)}…`
550
+ : text;
551
+ } finally {
552
+ closeSync(fd);
553
+ }
554
+ }
555
+
556
+ function semanticBodyFromStdout(stateDir: string): string | undefined {
557
+ const text = readBoundedStart(join(stateDir, "stdout.log"));
558
+ if (!text) return undefined;
559
+ const withoutMarker = text.replace(/^ACTOR_REVIEW_RESULT\s*(?:\r?\n)?/, "").trim();
560
+ return withoutMarker || undefined;
561
+ }
562
+
563
+ function terminalSemanticResult(
564
+ status: Record<string, unknown>,
565
+ stateDir: string,
566
+ observedStatus: RunObservedStatus,
567
+ ): RunTerminalSemanticResult {
568
+ const correlation = status.launch_correlation &&
569
+ typeof status.launch_correlation === "object" &&
570
+ !Array.isArray(status.launch_correlation)
571
+ ? status.launch_correlation as Record<string, unknown>
572
+ : {};
573
+ const correlationId =
574
+ typeof correlation.correlation_id === "string"
575
+ ? correlation.correlation_id
576
+ : typeof correlation.tool_call_id === "string"
577
+ ? correlation.tool_call_id
578
+ : undefined;
579
+ const mailbox = status.mailbox &&
580
+ typeof status.mailbox === "object" &&
581
+ !Array.isArray(status.mailbox)
582
+ ? status.mailbox as Record<string, unknown>
583
+ : {};
584
+ const emits = Array.isArray(mailbox.emits)
585
+ ? mailbox.emits.filter((item): item is string => typeof item === "string")
586
+ : [];
587
+ const outbox = readJsonlFileResilient<Record<string, unknown>>(
588
+ join(stateDir, "outbox.jsonl"),
589
+ ).records;
590
+ const explicit = outbox.findLast((record) => {
591
+ const type = String(record.type ?? record.event ?? "");
592
+ return emits.includes(type) && !["command.done", "run.done", "run.failed"].includes(type);
593
+ });
594
+ if (explicit) {
595
+ const type = String(explicit.type ?? explicit.event);
596
+ return {
597
+ ...(explicit.body === undefined
598
+ ? {} : { body: formatSemanticBody(explicit.body) }),
599
+ ...(typeof explicit.correlation_id === "string"
600
+ ? { correlationId: explicit.correlation_id } : correlationId ? { correlationId } : {}),
601
+ metadata: {
602
+ ...(explicit.metadata &&
603
+ typeof explicit.metadata === "object" &&
604
+ !Array.isArray(explicit.metadata)
605
+ ? explicit.metadata as Record<string, unknown> : {}),
606
+ ...(status.transport_context &&
607
+ typeof status.transport_context === "object" &&
608
+ !Array.isArray(status.transport_context)
609
+ ? {
610
+ transport_context: status.transport_context as Record<string, unknown>,
611
+ } : {}),
612
+ run: String(status.run ?? ""),
613
+ status: observedStatus,
614
+ },
615
+ summary: String(explicit.summary ?? type),
616
+ synthesized: false,
617
+ type,
618
+ };
619
+ }
620
+ const reviewCompleted = observedStatus === "done" && emits.includes("review.completed");
621
+ const result = status.result &&
622
+ typeof status.result === "object" &&
623
+ !Array.isArray(status.result)
624
+ ? status.result as Record<string, unknown>
625
+ : {};
626
+ const type = reviewCompleted
627
+ ? "review.completed"
628
+ : observedStatus === "done" ? "run.done" : "run.failed";
629
+ const stdoutBody = observedStatus === "done"
630
+ ? semanticBodyFromStdout(stateDir) : undefined;
631
+ return {
632
+ ...(stdoutBody
633
+ ? { body: stdoutBody }
634
+ : typeof result.error === "string" ? { body: result.error } : {}),
635
+ ...(correlationId ? { correlationId } : {}),
636
+ metadata: {
637
+ ...(status.transport_context &&
638
+ typeof status.transport_context === "object" &&
639
+ !Array.isArray(status.transport_context)
640
+ ? {
641
+ transport_context: status.transport_context as Record<string, unknown>,
642
+ } : {}),
643
+ run: String(status.run ?? ""), status: observedStatus,
644
+ },
645
+ summary: reviewCompleted ? "Review completed." :
646
+ observedStatus === "done" ? "Run completed." : `Run ${observedStatus}.`,
647
+ synthesized: true,
648
+ type,
649
+ };
650
+ }
651
+
652
+ function formatSemanticBody(body: unknown): string {
653
+ const rendered = typeof body === "string" ? body : JSON.stringify(body);
654
+ const text = typeof rendered === "string" ? rendered : String(body);
655
+ const compact = text.trim();
656
+ return compact.length > TERMINAL_RESULT_CHARS
657
+ ? `${compact.slice(0, TERMINAL_RESULT_CHARS - 1)}…`
658
+ : compact;
659
+ }
660
+
507
661
  function observeRun(stateDir: string): RunObservation | undefined {
508
662
  try {
509
663
  const status = AsyncRuns.getRunStatus(stateDir);
510
664
  const progress = getProgress(status);
511
665
  const run = typeof status.run === "string" ? status.run : undefined;
512
666
  if (!run) return undefined;
667
+ const observedStatus = status.status as RunObservedStatus;
513
668
  return {
514
669
  activeSubagents: toNumber(progress.activeSubagents),
515
670
  completed: toNumber(progress.completed),
671
+ ...(status.launch_correlation &&
672
+ typeof status.launch_correlation === "object" &&
673
+ !Array.isArray(status.launch_correlation)
674
+ ? { launchCorrelation: status.launch_correlation as Record<string, string> }
675
+ : {}),
516
676
  failures: Array.isArray(progress.failures)
517
677
  ? progress.failures.length
518
678
  : undefined,
@@ -542,9 +702,12 @@ function observeRun(stateDir: string): RunObservation | undefined {
542
702
  ...(typeof status.retire_when === "string"
543
703
  ? { retireWhen: status.retire_when }
544
704
  : {}),
705
+ ...(TERMINAL.has(observedStatus)
706
+ ? { semanticResult: terminalSemanticResult(status, stateDir, observedStatus) }
707
+ : {}),
545
708
  run,
546
709
  stateDir,
547
- status: status.status as RunObservedStatus,
710
+ status: observedStatus,
548
711
  ...(typeof status.tool === "string" ? { tool: status.tool } : {}),
549
712
  updatedAt: getUpdatedAt(status),
550
713
  };
@@ -933,9 +1096,11 @@ export function detectRunTransitions(
933
1096
  run: run.run,
934
1097
  ...(run.stateDir ? { stateDir: run.stateDir } : {}),
935
1098
  ...(run.artifacts ? { artifacts: run.artifacts } : {}),
1099
+ ...(run.launchCorrelation ? { launchCorrelation: run.launchCorrelation } : {}),
936
1100
  ...(run.launchSource ? { launchSource: run.launchSource } : {}),
937
1101
  ...(run.modelPolicy ? { modelPolicy: run.modelPolicy } : {}),
938
1102
  ...(run.recipeFile ? { recipeFile: run.recipeFile } : {}),
1103
+ ...(run.semanticResult ? { semanticResult: run.semanticResult } : {}),
939
1104
  ...(run.terminalHandled ? { terminalHandled: true } : {}),
940
1105
  to: run.status,
941
1106
  ...(run.tool ? { tool: run.tool } : {}),
@@ -1195,7 +1360,8 @@ function isUserRecipeFile(file: string | undefined): boolean {
1195
1360
  if (!file) return false;
1196
1361
  const recipeRoot = resolve(Paths.getRecipeRoot());
1197
1362
  const path = resolve(file);
1198
- return path === recipeRoot || path.startsWith(`${recipeRoot}/`);
1363
+ const relation = relative(recipeRoot, path);
1364
+ return relation === "" || (!relation.startsWith("..") && !isAbsolute(relation));
1199
1365
  }
1200
1366
 
1201
1367
  export function shouldSuggestRecipePersistence(
@@ -1245,16 +1411,26 @@ function formatTransitionNextActions(transition: RunTransition): string {
1245
1411
  return `\nNext actions: ${actions.join(" | ")}`;
1246
1412
  }
1247
1413
 
1414
+ function formatTransitionSemanticResult(transition: RunTransition): string {
1415
+ const result = transition.semanticResult;
1416
+ if (!result) return "";
1417
+ const correlation = result.correlationId
1418
+ ? `\nCorrelation: ${result.correlationId}` : "";
1419
+ const body = result.body ? `\nResult:\n${result.body}` : "";
1420
+ return `\nSemantic result: ${result.type} — ${result.summary}${correlation}${body}`;
1421
+ }
1422
+
1248
1423
  export function formatRunTransitionMessage(transition: RunTransition): string {
1249
1424
  const artifacts = formatNamedArtifacts(transition.artifacts);
1250
1425
  const runFiles = formatRunFileList(getRunArtifacts(transition));
1251
1426
  const persistenceSuggestion = formatRecipePersistenceSuggestion(transition);
1252
1427
  const policy = formatTransitionPolicy(transition);
1253
1428
  const nextActions = formatTransitionNextActions(transition);
1429
+ const semanticResult = formatTransitionSemanticResult(transition);
1254
1430
  if (transition.to === "done")
1255
- return `Run ${transition.run} completed successfully.${policy}${artifacts}${runFiles}${nextActions}${persistenceSuggestion}`;
1431
+ return `Run ${transition.run} completed successfully.${semanticResult}${policy}${artifacts}${runFiles}${nextActions}${persistenceSuggestion}`;
1256
1432
  if (transition.to === "failed")
1257
- return `Run ${transition.run} failed.${policy}${artifacts}${runFiles}${nextActions}`;
1433
+ return `Run ${transition.run} failed.${semanticResult}${policy}${artifacts}${runFiles}${nextActions}`;
1258
1434
  if (transition.to === "cancelled")
1259
1435
  return `Run ${transition.run} was cancelled.${policy}${nextActions}`;
1260
1436
  if (transition.to === "killed")
@@ -42,6 +42,7 @@ export function getRunProcessSignalPlan(
42
42
  export interface RunProcessSignalDeps {
43
43
  killProcess?: typeof process.kill;
44
44
  runtimePlatform?: NodeJS.Platform;
45
+ spawnProcess?: typeof spawnSync;
45
46
  verifyIdentity?: (
46
47
  pid: number,
47
48
  expected: RunProcessIdentity,
@@ -70,8 +71,26 @@ export function signalOwnedRunProcess(
70
71
  }
71
72
  const plan = getRunProcessSignalPlan(pid, signal, runtimePlatform);
72
73
  if (plan.command && plan.args) {
73
- const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
74
+ const spawnProcess = deps.spawnProcess ?? spawnSync;
75
+ let result = spawnProcess(plan.command, plan.args, { encoding: "utf8" });
76
+ if (
77
+ runtimePlatform === "win32" &&
78
+ result.status !== 0 &&
79
+ !plan.args.includes("/F")
80
+ ) {
81
+ result = spawnProcess(plan.command, [...plan.args, "/F"], {
82
+ encoding: "utf8",
83
+ });
84
+ }
74
85
  if (result.status !== 0) {
86
+ if (expectedIdentity) {
87
+ const finalProof = (deps.verifyIdentity ?? verifyRunProcessIdentity)(
88
+ pid,
89
+ expectedIdentity,
90
+ runtimePlatform,
91
+ );
92
+ if (finalProof.status === "dead_pid") return plan;
93
+ }
75
94
  throw new Error(
76
95
  result.stderr?.trim() ||
77
96
  result.stdout?.trim() ||
@@ -12,7 +12,8 @@ import {
12
12
  realpathSync,
13
13
  } from "node:fs";
14
14
  import { randomUUID } from "node:crypto";
15
- import { join, resolve } from "node:path";
15
+ import { tmpdir } from "node:os";
16
+ import { isAbsolute, join, relative, resolve } from "node:path";
16
17
 
17
18
  import { writeJsonAtomic } from "./file-state.ts";
18
19
 
@@ -35,13 +36,31 @@ function comparablePath(path: string): string {
35
36
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
36
37
  }
37
38
 
39
+ function isSystemTempRootAlias(resolved: string, canonical: string): boolean {
40
+ const tempRoot = resolve(tmpdir());
41
+ const relativeStateDir = relative(tempRoot, resolved);
42
+ if (
43
+ relativeStateDir === ".." ||
44
+ relativeStateDir.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
45
+ isAbsolute(relativeStateDir)
46
+ ) {
47
+ return false;
48
+ }
49
+ const canonicalTempRoot = realpathSync.native(tempRoot);
50
+ const expectedCanonical = resolve(canonicalTempRoot, relativeStateDir);
51
+ return comparablePath(canonical) === comparablePath(expectedCanonical);
52
+ }
53
+
38
54
  function assertCanonicalDirectory(stateDir: string): string {
39
55
  const resolved = resolve(stateDir);
40
56
  if (lstatSync(resolved).isSymbolicLink()) {
41
57
  throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
42
58
  }
43
- const canonical = realpathSync(resolved);
44
- if (comparablePath(canonical) !== comparablePath(resolved)) {
59
+ const canonical = realpathSync.native(resolved);
60
+ if (
61
+ comparablePath(canonical) !== comparablePath(resolved) &&
62
+ !isSystemTempRootAlias(resolved, canonical)
63
+ ) {
45
64
  throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
46
65
  }
47
66
  return resolved;
@@ -6,7 +6,7 @@
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
8
8
  import { platform } from "node:os";
9
- import { resolve } from "node:path";
9
+ import { posix, win32 } from "node:path";
10
10
 
11
11
  export interface RunProcessIdentity {
12
12
  command: string;
@@ -125,8 +125,9 @@ export function captureRunProcessIdentity(
125
125
  if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
126
126
  return undefined;
127
127
  }
128
- const resolvedCwd = resolve(cwd);
129
- const canonicalCwd = existsSync(resolvedCwd)
128
+ const pathApi = runtimePlatform === "win32" ? win32 : posix;
129
+ const resolvedCwd = pathApi.resolve(cwd);
130
+ const canonicalCwd = runtimePlatform === platform() && existsSync(resolvedCwd)
130
131
  ? realpathSync.native(resolvedCwd)
131
132
  : resolvedCwd;
132
133
  const expectedCwd =
package/lib/runs-start.ts CHANGED
@@ -93,6 +93,7 @@ export function prepareStateDirForStart(
93
93
  "result.json",
94
94
  "stderr.log",
95
95
  "stdout.log",
96
+ "terminal-delivery-failure.json",
96
97
  "terminal-handled.json",
97
98
  ]) {
98
99
  rmSync(join(stateDir, file), { force: true });
@@ -61,6 +61,9 @@ export function buildRunStatus(
61
61
  ? "running"
62
62
  : (getInterruptedRunStatus(stateDir) ?? "exited");
63
63
  const terminalHandled = readJson(join(stateDir, "terminal-handled.json"));
64
+ const terminalDeliveryFailure = readJson(
65
+ join(stateDir, "terminal-delivery-failure.json"),
66
+ );
64
67
  return {
65
68
  ...meta,
66
69
  eventsFile: join(stateDir, "events.jsonl"),
@@ -70,6 +73,8 @@ export function buildRunStatus(
70
73
  process_identity_status: processIdentity.status,
71
74
  progress: readJson(join(stateDir, "progress.json")) || null,
72
75
  result: result || null,
76
+ ...(terminalDeliveryFailure
77
+ ? { terminal_delivery_failure: terminalDeliveryFailure } : {}),
73
78
  ...(terminalHandled ? { terminal_handled: terminalHandled } : {}),
74
79
  state_dir: String(meta.state_dir ?? stateDir),
75
80
  stderrLog: join(stateDir, "stderr.log"),
@@ -7,6 +7,7 @@
7
7
  import { execFileSync } from "node:child_process";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
9
  import { dirname, join } from "node:path";
10
+ import { fileURLToPath } from "node:url";
10
11
 
11
12
  import * as AsyncRuns from "./async-runs.ts";
12
13
  import * as Limits from "./limits.ts";
@@ -269,7 +270,7 @@ function getPiActorsRuntimeStatus(): Record<string, unknown> {
269
270
  } catch {
270
271
  git_commit = undefined;
271
272
  }
272
- const entrypoint = new URL(import.meta.url).pathname;
273
+ const entrypoint = fileURLToPath(import.meta.url);
273
274
  return {
274
275
  automatic_recipe_review: Paths.isAutomaticRecipeReviewEnabled(),
275
276
  entrypoint,
@@ -143,6 +143,14 @@ export function createRuntimeToolDefinition(
143
143
  paramSchema.run_id = Schema.stringSchema(
144
144
  "Optional run id override for this async template recipe invocation.",
145
145
  );
146
+ if (isAsyncRecipe) {
147
+ paramSchema.correlation_id = Schema.stringSchema(
148
+ "Optional workflow correlation id preserved in terminal follow-up delivery.",
149
+ );
150
+ paramSchema.transport_context = Schema.looseObjectSchema(
151
+ "Optional originating transport route preserved for detached terminal follow-up.",
152
+ );
153
+ }
146
154
  return {
147
155
  name: cfg.name,
148
156
  label: cfg.name,
@@ -155,7 +163,7 @@ export function createRuntimeToolDefinition(
155
163
  )
156
164
  : Prompts.formatRegisteredToolPromptSnippet(cfg.template),
157
165
  async execute(
158
- _toolCallId: string,
166
+ toolCallId: string,
159
167
  params: unknown,
160
168
  signal: AbortSignal | undefined,
161
169
  _onUpdate: unknown,
@@ -172,7 +180,7 @@ export function createRuntimeToolDefinition(
172
180
  }
173
181
  if (isAsyncRecipe) {
174
182
  const input = params as Record<string, unknown>;
175
- const { run_id, ...values } = input;
183
+ const { correlation_id, run_id, transport_context, ...values } = input;
176
184
  const base = cfg.recipe ? cfg.recipe : { file: String(cfg.template) };
177
185
  const runId =
178
186
  typeof run_id === "string" && run_id.trim()
@@ -182,6 +190,17 @@ export function createRuntimeToolDefinition(
182
190
  {
183
191
  ...base,
184
192
  launch_source: "tool",
193
+ launch_correlation: {
194
+ ...(typeof correlation_id === "string"
195
+ ? { correlation_id } : {}),
196
+ tool_call_id: toolCallId,
197
+ },
198
+ ...(transport_context &&
199
+ typeof transport_context === "object" &&
200
+ !Array.isArray(transport_context)
201
+ ? {
202
+ transport_context: transport_context as Record<string, unknown>,
203
+ } : {}),
185
204
  ownerId: getRunOwnerId(ctx),
186
205
  run_id: runId,
187
206
  tool: cfg.name,
@@ -144,6 +144,9 @@ export function createSpawnToolDefinition<
144
144
  as: Schema.stringSchema(
145
145
  "Optional actor address for the spawned run, e.g. run:<id>.",
146
146
  ),
147
+ correlation_id: Schema.stringSchema(
148
+ "Optional workflow correlation id preserved in terminal follow-up delivery.",
149
+ ),
147
150
  file: Schema.stringSchema(
148
151
  "Optional template recipe JSON file. Bare names resolve under ~/.pi/agent/recipes.",
149
152
  ),
@@ -162,6 +165,9 @@ export function createSpawnToolDefinition<
162
165
  values: Schema.looseObjectSchema(
163
166
  "Runtime placeholder values passed to the actor.",
164
167
  ),
168
+ transport_context: Schema.looseObjectSchema(
169
+ "Optional originating transport route preserved for detached terminal follow-up, e.g. Telegram chat_id and thread_id.",
170
+ ),
165
171
  verbose: Schema.booleanSchema(
166
172
  "Return full JSON instead of compact text.",
167
173
  ),
@@ -169,7 +175,7 @@ export function createSpawnToolDefinition<
169
175
  [],
170
176
  ),
171
177
  async execute(
172
- _toolCallId: string,
178
+ toolCallId: string,
173
179
  params: unknown,
174
180
  _signal: AbortSignal | undefined,
175
181
  _onUpdate: unknown,
@@ -196,7 +202,14 @@ export function createSpawnToolDefinition<
196
202
  {
197
203
  file: recipe,
198
204
  launch_source: "spawn",
205
+ launch_correlation: {
206
+ ...(typeof input.correlation_id === "string"
207
+ ? { correlation_id: input.correlation_id } : {}),
208
+ tool_call_id: toolCallId,
209
+ },
199
210
  ownerId: getRunOwnerId(ctx),
211
+ ...(input.transport_context
212
+ ? { transport_context: asRecord(input.transport_context) } : {}),
200
213
  run_id: runId,
201
214
  ...(input.template !== undefined
202
215
  ? {