@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
@@ -3,8 +3,8 @@
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";
7
- import { basename, dirname, isAbsolute, join, relative, resolve, } from "node:path";
6
+ import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, readSync, watch, } from "node:fs";
7
+ import { basename, dirname, isAbsolute, join, relative, } from "node:path";
8
8
  import * as AsyncRuns from "./async-runs.js";
9
9
  import * as Paths from "./paths.js";
10
10
  import { readJsonlFileResilient } from "./state-readers.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 } : {}),
@@ -825,83 +958,29 @@ export function shouldNotifyRunTransition(transition) {
825
958
  export function shouldSendRunTransitionFollowUp(transition) {
826
959
  return shouldNotifyRunTransition(transition);
827
960
  }
828
- function getRunArtifacts(transition) {
829
- if (!transition.stateDir)
830
- return [];
831
- return [
832
- join(transition.stateDir, "stdout.log"),
833
- join(transition.stateDir, "stderr.log"),
834
- join(transition.stateDir, "result.json"),
835
- join(transition.stateDir, "events.jsonl"),
836
- join(transition.stateDir, "outbox.jsonl"),
837
- ];
838
- }
839
- function isUserRecipeFile(file) {
840
- if (!file)
841
- return false;
842
- const recipeRoot = resolve(Paths.getRecipeRoot());
843
- const path = resolve(file);
844
- return path === recipeRoot || path.startsWith(`${recipeRoot}/`);
845
- }
846
- export function shouldSuggestRecipePersistence(transition) {
847
- if (transition.to !== "done")
848
- return false;
849
- if (isUserRecipeFile(transition.recipeFile))
850
- return false;
851
- return Boolean(transition.recipeFile) || transition.launchSource === "spawn";
852
- }
853
- function formatRecipePersistenceSuggestion(transition) {
854
- if (!shouldSuggestRecipePersistence(transition))
855
- return "";
856
- if (transition.recipeFile) {
857
- return `\nAgent note: this actor completed successfully from recipe ${transition.recipeFile}. If this recipe fits this machine's recurring workflow, ask the operator whether to copy or register it as a durable tool recipe under ~/.pi/agent/recipes. Do not auto-save without confirmation.`;
858
- }
859
- return `\nAgent note: this actor was spawned directly and completed successfully. If this pattern fits this machine's recurring workflow, ask the operator whether to save it as a durable recipe/tool under ~/.pi/agent/recipes with register_tool. Do not auto-save without confirmation.`;
860
- }
861
- function formatTransitionPolicy(transition) {
862
- if (!transition.modelPolicy)
863
- return "";
864
- const axis = (key, label) => {
865
- const value = transition.modelPolicy?.[key];
866
- if (!value || typeof value !== "object" || Array.isArray(value))
867
- return undefined;
868
- const record = value;
869
- const source = typeof record.source === "string" ? record.source : "unused";
870
- if (source === "unused")
871
- return undefined;
872
- const renderedValue = typeof record.value === "string" && record.value.trim()
873
- ? ` (${record.value.trim()})`
874
- : "";
875
- return `${label}: ${source}${renderedValue}`;
876
- };
877
- const lines = [axis("model", "Model"), axis("thinking", "Thinking")].filter((line) => Boolean(line));
878
- return lines.length ? `\nPolicy:\n- ${lines.join("\n- ")}` : "";
879
- }
880
- function formatTransitionNextActions(transition) {
881
- const actions = [
882
- `inspect target=run:${transition.run} view=status`,
883
- transition.to === "done" && Object.keys(transition.artifacts ?? {}).length > 0
884
- ? `inspect target=run:${transition.run} view=artifacts`
885
- : `inspect target=run:${transition.run} view=tail`,
886
- `inspect target=run:${transition.run} view=messages`,
887
- ].filter(Boolean);
888
- return `\nNext actions: ${actions.join(" | ")}`;
961
+ const TERMINAL_FOLLOW_UP_ARTIFACT_LIMIT = 4;
962
+ const TERMINAL_FOLLOW_UP_IDENTIFIER_CHARS = 120;
963
+ const TERMINAL_FOLLOW_UP_PATH_CHARS = 320;
964
+ function compactTerminalText(value, limit) {
965
+ const compact = value.replaceAll(/\s+/g, " ").trim();
966
+ return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
967
+ }
968
+ function formatTerminalPath(path) {
969
+ return `\`${compactTerminalText(path, TERMINAL_FOLLOW_UP_PATH_CHARS)}\``;
970
+ }
971
+ function formatTerminalResultLocations(transition) {
972
+ const artifactPaths = [...new Set(Object.values(transition.artifacts ?? {}).filter((path) => typeof path === "string" && path.length > 0))];
973
+ if (artifactPaths.length === 0)
974
+ return transition.stateDir ? `\nBase: ${formatTerminalPath(transition.stateDir)}` : "";
975
+ const base = commonDirectory(artifactPaths);
976
+ const artifactPreview = artifactPaths
977
+ .slice(0, TERMINAL_FOLLOW_UP_ARTIFACT_LIMIT)
978
+ .map((path) => formatTerminalPath(base ? relativeName(base, path) : path));
979
+ const omitted = artifactPaths.length - artifactPreview.length;
980
+ const artifacts = `${artifactPreview.join(", ")}${omitted > 0 ? ` (+${omitted} more)` : ""}`;
981
+ return `${base ? `\nBase: ${formatTerminalPath(base)}` : ""}\nArtifacts: ${artifacts}`;
889
982
  }
890
983
  export function formatRunTransitionMessage(transition) {
891
- const artifacts = formatNamedArtifacts(transition.artifacts);
892
- const runFiles = formatRunFileList(getRunArtifacts(transition));
893
- const persistenceSuggestion = formatRecipePersistenceSuggestion(transition);
894
- const policy = formatTransitionPolicy(transition);
895
- const nextActions = formatTransitionNextActions(transition);
896
- if (transition.to === "done")
897
- return `Run ${transition.run} completed successfully.${policy}${artifacts}${runFiles}${nextActions}${persistenceSuggestion}`;
898
- if (transition.to === "failed")
899
- return `Run ${transition.run} failed.${policy}${artifacts}${runFiles}${nextActions}`;
900
- if (transition.to === "cancelled")
901
- return `Run ${transition.run} was cancelled.${policy}${nextActions}`;
902
- if (transition.to === "killed")
903
- return `Run ${transition.run} was force-killed.${policy}${nextActions}`;
904
- if (transition.to === "exited")
905
- return `Run ${transition.run} exited before writing a result.${policy}${nextActions}`;
906
- return `Run ${transition.run} finished with status ${transition.to}.${policy}${nextActions}`;
984
+ const run = compactTerminalText(transition.run, TERMINAL_FOLLOW_UP_IDENTIFIER_CHARS);
985
+ return `Run: \`${run}\`\nStatus: \`${transition.to}\`${formatTerminalResultLocations(transition)}`;
907
986
  }
@@ -6,7 +6,7 @@
6
6
  export declare const REGISTER_TOOL_DESCRIPTION: string;
7
7
  export declare const REGISTER_TOOL_PROMPT_SNIPPET = "Register persistent command templates as agent-callable tools";
8
8
  export declare const REGISTER_TOOL_GUIDELINES: string[];
9
- export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.\n- Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.\n- Command templates stay sync and shell-free: string leaves split into executable + argv, so operators such as && are literal arguments; use template arrays for sequencing or an explicit trusted shell/script when shell semantics are required. Flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, accept_output, output.\n- Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.\n- ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.\n- Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.\n- Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.\n- Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.\n- Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.\n- Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. Terminal and coordinator-bound notifications queue as Pi follow-ups so concurrently completed actors can reach the coordinator after current work instead of steering between tool calls. When a deferred actor result gates the next step, wait for its terminal follow-up; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.\n- Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones, and fix/remove/disable invalid recipes flagged by registry warnings; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.\n- Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.\n- For any non-trivial actor use or pi-actors change, read the bundled actors skill first. Before launching multiple actors/subagents for parallel implementation, independent artifact generation, delegated audit, or review, also read the bundled swarm skill; the coordinator owns decomposition, disjoint scopes, launch correctness, integration, and final validation. For deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.";
9
+ export declare const ONBOARDING_SYSTEM_PROMPT = "pi-actors quick model:\n- Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.\n- Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.\n- Command templates stay sync and shell-free: string leaves split into executable + argv, so operators such as && are literal arguments; use template arrays for sequencing or an explicit trusted shell/script when shell semantics are required. Flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, accept_output, output.\n- Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.\n- ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.\n- Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.\n- Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.\n- Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.\n- Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.\n- Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. Terminal and coordinator-bound notifications queue as Pi follow-ups so concurrently completed actors can reach the coordinator after current work instead of steering between tool calls. Terminal follow-up content stays minimal: run, status, one base path, and relative artifact names only; semantic output stays in non-LLM details and run state. When a deferred actor result gates the next step, wait for its terminal follow-up; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.\n- Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones, and fix/remove/disable invalid recipes flagged by registry warnings; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.\n- Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.\n- For any non-trivial actor use or pi-actors change, read the bundled actors skill first. Before launching multiple actors/subagents for parallel implementation, independent artifact generation, delegated audit, or review, also read the bundled swarm skill; the coordinator owns decomposition, disjoint scopes, launch correctness, integration, and final validation. For deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.";
10
10
  export declare const REGISTER_TOOL_PARAM_DESCRIPTIONS: {
11
11
  readonly name: "Tool name in snake_case (e.g., 'transcribe')";
12
12
  readonly description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.";
@@ -23,7 +23,7 @@ export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
23
23
  - Recipe imports are local variables; imported recipes are definitions, not nested async runs; parent async:true creates one run.
24
24
  - Actor-mode trigger: if work may outlive this turn, need steering/follow-up/artifacts, run as a service, fan out, or be resumed/inspected later, use spawn -> message -> inspect instead of ad hoc shell backgrounding.
25
25
  - Use spawn/message/inspect for actor-level start/send/observe; short foreground checks can stay ordinary tools/templates; avoid runtime/FIFO/outbox vocabulary in public guidance.
26
- - Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. Terminal and coordinator-bound notifications queue as Pi follow-ups so concurrently completed actors can reach the coordinator after current work instead of steering between tool calls. When a deferred actor result gates the next step, wait for its terminal follow-up; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.
26
+ - Run state lives under ~/.pi/agent/tmp/pi-actors/runs. Inspect intentionally and avoid busy-polling. Terminal and coordinator-bound notifications queue as Pi follow-ups so concurrently completed actors can reach the coordinator after current work instead of steering between tool calls. Terminal follow-up content stays minimal: run, status, one base path, and relative artifact names only; semantic output stays in non-LLM details and run state. When a deferred actor result gates the next step, wait for its terminal follow-up; do not schedule continuation loops, repeatedly inspect, or mutate its reviewed scope while it runs. Inspect early only for an operator request, a meaningful actor event, or diagnosis of an overdue/stuck run.
27
27
  - Maintain ~/.pi/agent/recipes like MEMORY.md for capabilities: keep useful tools, curate stale ones, and fix/remove/disable invalid recipes flagged by registry warnings; packaged/ad hoc recipes are lower-priority components; offer to save successful recurring patterns only after confirmation.
28
28
  - Prefer maintained packaged recipes/pipelines with spawn file=<recipe> before ad hoc scripts/wrappers; review swarms inherit current model/thinking, preflight before fanout, and expose quorum/concurrency/TTL knobs unless explicit args are passed.
29
29
  - For any non-trivial actor use or pi-actors change, read the bundled actors skill first. Before launching multiple actors/subagents for parallel implementation, independent artifact generation, delegated audit, or review, also read the bundled swarm skill; the coordinator owns decomposition, disjoint scopes, launch correctness, integration, and final validation. For deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.`;
@@ -2,6 +2,7 @@
2
2
  * Async run process control primitives.
3
3
  * Owns: platform signal planning, owned-process signalling, and terminal control markers.
4
4
  */
5
+ import { spawnSync } from "node:child_process";
5
6
  import { type RunProcessIdentity, type RunProcessIdentityResult } from "./runs-process.ts";
6
7
  export interface RunProcessSignalPlan {
7
8
  args?: string[];
@@ -12,6 +13,7 @@ export declare function getRunProcessSignalPlan(pid: number, signal: NodeJS.Sign
12
13
  export interface RunProcessSignalDeps {
13
14
  killProcess?: typeof process.kill;
14
15
  runtimePlatform?: NodeJS.Platform;
16
+ spawnProcess?: typeof spawnSync;
15
17
  verifyIdentity?: (pid: number, expected: RunProcessIdentity, runtimePlatform: NodeJS.Platform) => RunProcessIdentityResult;
16
18
  }
17
19
  export declare function signalOwnedRunProcess(pid: number, signal: NodeJS.Signals, expectedIdentity?: RunProcessIdentity, deps?: RunProcessSignalDeps): RunProcessSignalPlan;
@@ -31,8 +31,21 @@ export function signalOwnedRunProcess(pid, signal, expectedIdentity, deps = {})
31
31
  }
32
32
  const plan = getRunProcessSignalPlan(pid, signal, runtimePlatform);
33
33
  if (plan.command && plan.args) {
34
- const result = spawnSync(plan.command, plan.args, { encoding: "utf8" });
34
+ const spawnProcess = deps.spawnProcess ?? spawnSync;
35
+ let result = spawnProcess(plan.command, plan.args, { encoding: "utf8" });
36
+ if (runtimePlatform === "win32" &&
37
+ result.status !== 0 &&
38
+ !plan.args.includes("/F")) {
39
+ result = spawnProcess(plan.command, [...plan.args, "/F"], {
40
+ encoding: "utf8",
41
+ });
42
+ }
35
43
  if (result.status !== 0) {
44
+ if (expectedIdentity) {
45
+ const finalProof = (deps.verifyIdentity ?? verifyRunProcessIdentity)(pid, expectedIdentity, runtimePlatform);
46
+ if (finalProof.status === "dead_pid")
47
+ return plan;
48
+ }
36
49
  throw new Error(result.stderr?.trim() ||
37
50
  result.stdout?.trim() ||
38
51
  `${plan.command} failed`);
@@ -4,7 +4,8 @@
4
4
  */
5
5
  import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, } from "node:fs";
6
6
  import { randomUUID } from "node:crypto";
7
- import { join, resolve } from "node:path";
7
+ import { tmpdir } from "node:os";
8
+ import { isAbsolute, join, relative, resolve } from "node:path";
8
9
  import { writeJsonAtomic } from "./file-state.js";
9
10
  export const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
10
11
  function markerPath(stateDir) {
@@ -14,13 +15,26 @@ function comparablePath(path) {
14
15
  const resolved = resolve(path);
15
16
  return process.platform === "win32" ? resolved.toLowerCase() : resolved;
16
17
  }
18
+ function isSystemTempRootAlias(resolved, canonical) {
19
+ const tempRoot = resolve(tmpdir());
20
+ const relativeStateDir = relative(tempRoot, resolved);
21
+ if (relativeStateDir === ".." ||
22
+ relativeStateDir.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
23
+ isAbsolute(relativeStateDir)) {
24
+ return false;
25
+ }
26
+ const canonicalTempRoot = realpathSync.native(tempRoot);
27
+ const expectedCanonical = resolve(canonicalTempRoot, relativeStateDir);
28
+ return comparablePath(canonical) === comparablePath(expectedCanonical);
29
+ }
17
30
  function assertCanonicalDirectory(stateDir) {
18
31
  const resolved = resolve(stateDir);
19
32
  if (lstatSync(resolved).isSymbolicLink()) {
20
33
  throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
21
34
  }
22
- const canonical = realpathSync(resolved);
23
- if (comparablePath(canonical) !== comparablePath(resolved)) {
35
+ const canonical = realpathSync.native(resolved);
36
+ if (comparablePath(canonical) !== comparablePath(resolved) &&
37
+ !isSystemTempRootAlias(resolved, canonical)) {
24
38
  throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
25
39
  }
26
40
  return resolved;
@@ -5,7 +5,7 @@
5
5
  import { spawnSync } from "node:child_process";
6
6
  import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
7
7
  import { platform } from "node:os";
8
- import { resolve } from "node:path";
8
+ import { posix, win32 } from "node:path";
9
9
  export function isAlive(pid) {
10
10
  try {
11
11
  process.kill(pid, 0);
@@ -90,8 +90,9 @@ export function captureRunProcessIdentity(pid, cwd, stateDir, runnerPath, runtim
90
90
  if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
91
91
  return undefined;
92
92
  }
93
- const resolvedCwd = resolve(cwd);
94
- const canonicalCwd = existsSync(resolvedCwd)
93
+ const pathApi = runtimePlatform === "win32" ? win32 : posix;
94
+ const resolvedCwd = pathApi.resolve(cwd);
95
+ const canonicalCwd = runtimePlatform === platform() && existsSync(resolvedCwd)
95
96
  ? realpathSync.native(resolvedCwd)
96
97
  : resolvedCwd;
97
98
  const expectedCwd = runtimePlatform === "win32" ? canonicalCwd.toLowerCase() : canonicalCwd;
@@ -52,6 +52,7 @@ export function prepareStateDirForStart(stateDir, readJson, _runnerPath) {
52
52
  "result.json",
53
53
  "stderr.log",
54
54
  "stdout.log",
55
+ "terminal-delivery-failure.json",
55
56
  "terminal-handled.json",
56
57
  ]) {
57
58
  rmSync(join(stateDir, file), { force: true });
@@ -30,6 +30,7 @@ export function buildRunStatus(stateDir, runOrDir, meta, readJson, _runnerPath,
30
30
  ? "running"
31
31
  : (getInterruptedRunStatus(stateDir) ?? "exited");
32
32
  const terminalHandled = readJson(join(stateDir, "terminal-handled.json"));
33
+ const terminalDeliveryFailure = readJson(join(stateDir, "terminal-delivery-failure.json"));
33
34
  return {
34
35
  ...meta,
35
36
  eventsFile: join(stateDir, "events.jsonl"),
@@ -39,6 +40,8 @@ export function buildRunStatus(stateDir, runOrDir, meta, readJson, _runnerPath,
39
40
  process_identity_status: processIdentity.status,
40
41
  progress: readJson(join(stateDir, "progress.json")) || null,
41
42
  result: result || null,
43
+ ...(terminalDeliveryFailure
44
+ ? { terminal_delivery_failure: terminalDeliveryFailure } : {}),
42
45
  ...(terminalHandled ? { terminal_handled: terminalHandled } : {}),
43
46
  state_dir: String(meta.state_dir ?? stateDir),
44
47
  stderrLog: join(stateDir, "stderr.log"),
@@ -6,6 +6,7 @@
6
6
  import { execFileSync } from "node:child_process";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
8
  import { dirname, join } from "node:path";
9
+ import { fileURLToPath } from "node:url";
9
10
  import * as AsyncRuns from "./async-runs.js";
10
11
  import * as Limits from "./limits.js";
11
12
  import * as Messages from "./messages.js";
@@ -229,7 +230,7 @@ function getPiActorsRuntimeStatus() {
229
230
  catch {
230
231
  git_commit = undefined;
231
232
  }
232
- const entrypoint = new URL(import.meta.url).pathname;
233
+ const entrypoint = fileURLToPath(import.meta.url);
233
234
  return {
234
235
  automatic_recipe_review: Paths.isAutomaticRecipeReviewEnabled(),
235
236
  entrypoint,
@@ -100,6 +100,10 @@ export function createRuntimeToolDefinition(cfg, exec) {
100
100
  }
101
101
  if (isAsyncRecipe)
102
102
  paramSchema.run_id = Schema.stringSchema("Optional run id override for this async template recipe invocation.");
103
+ if (isAsyncRecipe) {
104
+ paramSchema.correlation_id = Schema.stringSchema("Optional workflow correlation id preserved in terminal follow-up delivery.");
105
+ paramSchema.transport_context = Schema.looseObjectSchema("Optional originating transport route preserved for detached terminal follow-up.");
106
+ }
103
107
  return {
104
108
  name: cfg.name,
105
109
  label: cfg.name,
@@ -108,7 +112,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
108
112
  promptSnippet: isRecipe
109
113
  ? Prompts.formatRecipeToolPromptSnippet(cfg.recipe?.name ?? String(cfg.template), isAsyncRecipe)
110
114
  : Prompts.formatRegisteredToolPromptSnippet(cfg.template),
111
- async execute(_toolCallId, params, signal, _onUpdate, ctx) {
115
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
112
116
  try {
113
117
  if (cfg.sourcePath &&
114
118
  !RecipesUsage.recordRecipeLaunch(cfg.sourcePath, new Date(), "tool")) {
@@ -116,7 +120,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
116
120
  }
117
121
  if (isAsyncRecipe) {
118
122
  const input = params;
119
- const { run_id, ...values } = input;
123
+ const { correlation_id, run_id, transport_context, ...values } = input;
120
124
  const base = cfg.recipe ? cfg.recipe : { file: String(cfg.template) };
121
125
  const runId = typeof run_id === "string" && run_id.trim()
122
126
  ? run_id.trim()
@@ -124,6 +128,17 @@ export function createRuntimeToolDefinition(cfg, exec) {
124
128
  const meta = AsyncRuns.startRun({
125
129
  ...base,
126
130
  launch_source: "tool",
131
+ launch_correlation: {
132
+ ...(typeof correlation_id === "string"
133
+ ? { correlation_id } : {}),
134
+ tool_call_id: toolCallId,
135
+ },
136
+ ...(transport_context &&
137
+ typeof transport_context === "object" &&
138
+ !Array.isArray(transport_context)
139
+ ? {
140
+ transport_context: transport_context,
141
+ } : {}),
127
142
  ownerId: getRunOwnerId(ctx),
128
143
  run_id: runId,
129
144
  tool: cfg.name,
@@ -95,6 +95,7 @@ export function createSpawnToolDefinition() {
95
95
  parameters: Schema.objectSchema({
96
96
  artifacts: Schema.looseObjectSchema("Optional named artifact paths for the spawned actor."),
97
97
  as: Schema.stringSchema("Optional actor address for the spawned run, e.g. run:<id>."),
98
+ correlation_id: Schema.stringSchema("Optional workflow correlation id preserved in terminal follow-up delivery."),
98
99
  file: Schema.stringSchema("Optional template recipe JSON file. Bare names resolve under ~/.pi/agent/recipes."),
99
100
  recipe: Schema.stringSchema("Alias for file; template recipe JSON file/name to spawn."),
100
101
  template: Schema.unionSchema([
@@ -103,9 +104,10 @@ export function createSpawnToolDefinition() {
103
104
  Schema.looseObjectSchema("Inline command-template object with flags such as parallel, repeat, retry, failure, and nested template."),
104
105
  ]),
105
106
  values: Schema.looseObjectSchema("Runtime placeholder values passed to the actor."),
107
+ transport_context: Schema.looseObjectSchema("Optional originating transport route preserved for detached terminal follow-up, e.g. Telegram chat_id and thread_id."),
106
108
  verbose: Schema.booleanSchema("Return full JSON instead of compact text."),
107
109
  }, []),
108
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
110
+ async execute(toolCallId, params, _signal, _onUpdate, ctx) {
109
111
  const input = asRecord(params);
110
112
  if (input.state_dir !== undefined) {
111
113
  throw new Error("spawn.state_dir is not supported; run state is runtime-owned so run:<id> remains addressable and retention-safe.");
@@ -121,7 +123,14 @@ export function createSpawnToolDefinition() {
121
123
  meta = AsyncRuns.startRun({
122
124
  file: recipe,
123
125
  launch_source: "spawn",
126
+ launch_correlation: {
127
+ ...(typeof input.correlation_id === "string"
128
+ ? { correlation_id: input.correlation_id } : {}),
129
+ tool_call_id: toolCallId,
130
+ },
124
131
  ownerId: getRunOwnerId(ctx),
132
+ ...(input.transport_context
133
+ ? { transport_context: asRecord(input.transport_context) } : {}),
125
134
  run_id: runId,
126
135
  ...(input.template !== undefined
127
136
  ? {