@llblab/pi-actors 0.42.3 → 0.43.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 (279) hide show
  1. package/AGENTS.md +127 -175
  2. package/BACKLOG.md +189 -1
  3. package/CHANGELOG.md +183 -292
  4. package/README.md +115 -276
  5. package/dist/fixtures/protocol/control-endpoint.json +6 -0
  6. package/dist/fixtures/protocol/control-record.json +9 -0
  7. package/dist/fixtures/protocol/recipe-summary.json +4 -12
  8. package/dist/fixtures/protocol/trace-event.json +9 -0
  9. package/dist/index.js +1 -1
  10. package/dist/lib/async-runs.d.ts +15 -38
  11. package/dist/lib/async-runs.js +173 -111
  12. package/dist/lib/automatic-review-runtime.d.ts +1 -1
  13. package/dist/lib/automatic-review-runtime.js +5 -5
  14. package/dist/lib/command-templates.d.ts +2 -0
  15. package/dist/lib/command-templates.js +38 -4
  16. package/dist/lib/control-projection.d.ts +20 -0
  17. package/dist/lib/control-projection.js +66 -0
  18. package/dist/lib/control.d.ts +15 -0
  19. package/dist/lib/control.js +97 -0
  20. package/dist/lib/draft-sleep.js +3 -3
  21. package/dist/lib/execution-sessions.d.ts +17 -0
  22. package/dist/lib/execution-sessions.js +85 -0
  23. package/dist/lib/file-state.d.ts +2 -0
  24. package/dist/lib/file-state.js +114 -46
  25. package/dist/lib/inspector-actions.d.ts +2 -2
  26. package/dist/lib/inspector-actions.js +2 -2
  27. package/dist/lib/inspector-command.js +3 -3
  28. package/dist/lib/inspector-overlay.d.ts +54 -70
  29. package/dist/lib/inspector-overlay.js +576 -910
  30. package/dist/lib/inspector.d.ts +3 -71
  31. package/dist/lib/inspector.js +19 -665
  32. package/dist/lib/limits.d.ts +7 -3
  33. package/dist/lib/limits.js +7 -3
  34. package/dist/lib/observability.d.ts +16 -16
  35. package/dist/lib/observability.js +43 -82
  36. package/dist/lib/prompts.d.ts +1 -1
  37. package/dist/lib/prompts.js +3 -3
  38. package/dist/lib/recipe-control.d.ts +7 -0
  39. package/dist/lib/recipe-control.js +43 -0
  40. package/dist/lib/recipes-discovery.js +2 -0
  41. package/dist/lib/recipes-references.d.ts +1 -14
  42. package/dist/lib/recipes-references.js +6 -21
  43. package/dist/lib/review-control.d.ts +1 -1
  44. package/dist/lib/review-control.js +4 -5
  45. package/dist/lib/review-projection.js +1 -5
  46. package/dist/lib/run-ui-runtime.js +2 -2
  47. package/dist/lib/runs-control-delivery.d.ts +28 -0
  48. package/dist/lib/runs-control-delivery.js +150 -0
  49. package/dist/lib/runs-controls.d.ts +37 -0
  50. package/dist/lib/runs-controls.js +146 -0
  51. package/dist/lib/runs-retention.d.ts +7 -0
  52. package/dist/lib/runs-retention.js +27 -3
  53. package/dist/lib/runs-start.js +4 -2
  54. package/dist/lib/runs-status.js +11 -6
  55. package/dist/lib/runs-trace.d.ts +24 -0
  56. package/dist/lib/runs-trace.js +102 -0
  57. package/dist/lib/runtime-identity.d.ts +7 -0
  58. package/dist/lib/runtime-identity.js +35 -0
  59. package/dist/lib/runtime-notifier.d.ts +1 -1
  60. package/dist/lib/runtime-notifier.js +1 -1
  61. package/dist/lib/runtime-triage.d.ts +29 -0
  62. package/dist/lib/runtime-triage.js +76 -0
  63. package/dist/lib/tool-review-scheduler.js +7 -7
  64. package/dist/lib/tools-inspect.d.ts +3 -3
  65. package/dist/lib/tools-inspect.js +241 -707
  66. package/dist/lib/tools-local.js +2 -10
  67. package/dist/lib/tools-message.d.ts +6 -7
  68. package/dist/lib/tools-message.js +95 -396
  69. package/dist/lib/tools-response.d.ts +1 -5
  70. package/dist/lib/tools-response.js +5 -48
  71. package/dist/lib/tools-spawn.js +16 -28
  72. package/dist/lib/tools.d.ts +1 -1
  73. package/dist/lib/tools.js +2 -3
  74. package/dist/lib/trace-projection.d.ts +22 -0
  75. package/dist/lib/trace-projection.js +185 -0
  76. package/dist/recipes/draft-review.json +0 -10
  77. package/dist/recipes/lens-swarm.json +0 -14
  78. package/dist/recipes/music-player.json +10 -19
  79. package/dist/recipes/pipeline-architect-coordinator.json +0 -11
  80. package/dist/recipes/pipeline-artifact-bundle.json +1 -22
  81. package/dist/recipes/pipeline-artifact-report.json +1 -18
  82. package/dist/recipes/pipeline-artifact-write.json +1 -18
  83. package/dist/recipes/pipeline-async-run-ops.json +0 -12
  84. package/dist/recipes/pipeline-checkpoint-continuation.json +0 -14
  85. package/dist/recipes/pipeline-development-tasking.json +0 -12
  86. package/dist/recipes/pipeline-docs-maintenance.json +0 -12
  87. package/dist/recipes/pipeline-media-library.json +0 -12
  88. package/dist/recipes/pipeline-quorum-review.json +0 -12
  89. package/dist/recipes/pipeline-release-readiness.json +0 -12
  90. package/dist/recipes/pipeline-release-summary.json +0 -12
  91. package/dist/recipes/pipeline-repo-health.json +0 -12
  92. package/dist/recipes/pipeline-research-synthesis.json +0 -11
  93. package/dist/recipes/pipeline-review-readiness.json +0 -12
  94. package/dist/recipes/resource-locker.json +27 -0
  95. package/dist/recipes/subagent-artifact.json +0 -9
  96. package/dist/recipes/subagent-checkpoint.json +0 -10
  97. package/dist/recipes/subagent-conflict-report.json +0 -11
  98. package/dist/recipes/subagent-contradiction-map.json +0 -11
  99. package/dist/recipes/subagent-critic.json +0 -11
  100. package/dist/recipes/subagent-evidence-map.json +0 -11
  101. package/dist/recipes/subagent-followup.json +0 -10
  102. package/dist/recipes/subagent-judge.json +0 -11
  103. package/dist/recipes/subagent-merge.json +0 -11
  104. package/dist/recipes/subagent-normalize.json +0 -11
  105. package/dist/recipes/subagent-plan.json +0 -11
  106. package/dist/recipes/subagent-preflight.json +0 -11
  107. package/dist/recipes/subagent-prompt.json +0 -10
  108. package/dist/recipes/subagent-quorum.json +0 -10
  109. package/dist/recipes/subagent-review-coordinator.json +0 -14
  110. package/dist/recipes/subagent-review.json +0 -11
  111. package/dist/recipes/subagent-task-card.json +0 -11
  112. package/dist/recipes/subagent-tools.json +0 -10
  113. package/dist/recipes/subagent-verify.json +0 -11
  114. package/dist/recipes/subagents-prompts.json +0 -10
  115. package/dist/recipes/tool-review.json +0 -10
  116. package/dist/scripts/async-runner.mjs +25 -25
  117. package/dist/scripts/conformance.mjs +4 -2
  118. package/dist/scripts/locker.mjs +196 -69
  119. package/dist/scripts/music-player.mjs +162 -159
  120. package/dist/scripts/recipe-utils.mjs +6 -96
  121. package/dist/scripts/release-gates.mjs +91 -0
  122. package/dist/scripts/validate-recipe.mjs +8 -57
  123. package/dist/skills/actors/SKILL.md +57 -265
  124. package/dist/skills/swarm/SKILL.md +10 -34
  125. package/docs/0.43-baseline.md +39 -0
  126. package/docs/README.md +4 -6
  127. package/docs/actor-inspector.md +26 -64
  128. package/docs/async-runs.md +81 -328
  129. package/docs/command-templates.md +8 -118
  130. package/docs/recipe-library.md +55 -182
  131. package/docs/releasing.md +28 -0
  132. package/docs/template-recipes.md +76 -289
  133. package/docs/tool-registry.md +41 -161
  134. package/fixtures/protocol/control-endpoint.json +6 -0
  135. package/fixtures/protocol/control-record.json +9 -0
  136. package/fixtures/protocol/recipe-summary.json +4 -12
  137. package/fixtures/protocol/trace-event.json +9 -0
  138. package/index.ts +1 -1
  139. package/lib/async-runs.ts +218 -204
  140. package/lib/automatic-review-runtime.ts +7 -7
  141. package/lib/command-templates.ts +44 -4
  142. package/lib/control-projection.ts +105 -0
  143. package/lib/control.ts +117 -0
  144. package/lib/draft-sleep.ts +3 -3
  145. package/lib/execution-sessions.ts +111 -0
  146. package/lib/file-state.ts +84 -64
  147. package/lib/inspector-actions.ts +2 -2
  148. package/lib/inspector-command.ts +3 -3
  149. package/lib/inspector-overlay.ts +617 -1126
  150. package/lib/inspector.ts +46 -979
  151. package/lib/limits.ts +7 -3
  152. package/lib/observability.ts +60 -101
  153. package/lib/prompts.ts +3 -3
  154. package/lib/recipe-control.ts +52 -0
  155. package/lib/recipes-discovery.ts +2 -0
  156. package/lib/recipes-references.ts +9 -45
  157. package/lib/review-control.ts +4 -5
  158. package/lib/review-projection.ts +1 -5
  159. package/lib/run-ui-runtime.ts +2 -2
  160. package/lib/runs-control-delivery.ts +209 -0
  161. package/lib/runs-controls.ts +213 -0
  162. package/lib/runs-retention.ts +38 -3
  163. package/lib/runs-start.ts +4 -2
  164. package/lib/runs-status.ts +11 -6
  165. package/lib/runs-trace.ts +136 -0
  166. package/lib/runtime-identity.ts +39 -0
  167. package/lib/runtime-notifier.ts +1 -1
  168. package/lib/runtime-triage.ts +120 -0
  169. package/lib/tool-review-scheduler.ts +7 -7
  170. package/lib/tools-inspect.ts +283 -900
  171. package/lib/tools-local.ts +2 -12
  172. package/lib/tools-message.ts +112 -520
  173. package/lib/tools-response.ts +5 -64
  174. package/lib/tools-spawn.ts +16 -32
  175. package/lib/tools.ts +5 -6
  176. package/lib/trace-projection.ts +244 -0
  177. package/package.json +2 -1
  178. package/recipes/draft-review.json +0 -10
  179. package/recipes/lens-swarm.json +0 -14
  180. package/recipes/music-player.json +10 -19
  181. package/recipes/pipeline-architect-coordinator.json +0 -11
  182. package/recipes/pipeline-artifact-bundle.json +1 -22
  183. package/recipes/pipeline-artifact-report.json +1 -18
  184. package/recipes/pipeline-artifact-write.json +1 -18
  185. package/recipes/pipeline-async-run-ops.json +0 -12
  186. package/recipes/pipeline-checkpoint-continuation.json +0 -14
  187. package/recipes/pipeline-development-tasking.json +0 -12
  188. package/recipes/pipeline-docs-maintenance.json +0 -12
  189. package/recipes/pipeline-media-library.json +0 -12
  190. package/recipes/pipeline-quorum-review.json +0 -12
  191. package/recipes/pipeline-release-readiness.json +0 -12
  192. package/recipes/pipeline-release-summary.json +0 -12
  193. package/recipes/pipeline-repo-health.json +0 -12
  194. package/recipes/pipeline-research-synthesis.json +0 -11
  195. package/recipes/pipeline-review-readiness.json +0 -12
  196. package/recipes/resource-locker.json +27 -0
  197. package/recipes/subagent-artifact.json +0 -9
  198. package/recipes/subagent-checkpoint.json +0 -10
  199. package/recipes/subagent-conflict-report.json +0 -11
  200. package/recipes/subagent-contradiction-map.json +0 -11
  201. package/recipes/subagent-critic.json +0 -11
  202. package/recipes/subagent-evidence-map.json +0 -11
  203. package/recipes/subagent-followup.json +0 -10
  204. package/recipes/subagent-judge.json +0 -11
  205. package/recipes/subagent-merge.json +0 -11
  206. package/recipes/subagent-normalize.json +0 -11
  207. package/recipes/subagent-plan.json +0 -11
  208. package/recipes/subagent-preflight.json +0 -11
  209. package/recipes/subagent-prompt.json +0 -10
  210. package/recipes/subagent-quorum.json +0 -10
  211. package/recipes/subagent-review-coordinator.json +0 -14
  212. package/recipes/subagent-review.json +0 -11
  213. package/recipes/subagent-task-card.json +0 -11
  214. package/recipes/subagent-tools.json +0 -10
  215. package/recipes/subagent-verify.json +0 -11
  216. package/recipes/subagents-prompts.json +0 -10
  217. package/recipes/tool-review.json +0 -10
  218. package/scripts/async-runner.mjs +25 -25
  219. package/scripts/conformance.mjs +4 -2
  220. package/scripts/locker.mjs +196 -69
  221. package/scripts/music-player.mjs +162 -159
  222. package/scripts/recipe-utils.mjs +6 -96
  223. package/scripts/release-gates.mjs +91 -0
  224. package/scripts/validate-recipe.mjs +8 -57
  225. package/skills/actors/SKILL.md +57 -265
  226. package/skills/swarm/SKILL.md +10 -34
  227. package/dist/fixtures/protocol/actor-message-branch.json +0 -13
  228. package/dist/fixtures/protocol/mailbox-contract.json +0 -15
  229. package/dist/fixtures/protocol/room-message.json +0 -11
  230. package/dist/fixtures/protocol/room-roster.json +0 -11
  231. package/dist/fixtures/protocol/run-inbox-message.json +0 -9
  232. package/dist/fixtures/protocol/run-outbox-event.json +0 -9
  233. package/dist/lib/mailbox-loop.d.ts +0 -41
  234. package/dist/lib/mailbox-loop.js +0 -60
  235. package/dist/lib/messages.d.ts +0 -25
  236. package/dist/lib/messages.js +0 -122
  237. package/dist/lib/rooms.d.ts +0 -104
  238. package/dist/lib/rooms.js +0 -647
  239. package/dist/lib/runs-mailbox.d.ts +0 -25
  240. package/dist/lib/runs-mailbox.js +0 -146
  241. package/dist/lib/runs-messages.d.ts +0 -15
  242. package/dist/lib/runs-messages.js +0 -179
  243. package/dist/lib/runs-outbox.d.ts +0 -41
  244. package/dist/lib/runs-outbox.js +0 -87
  245. package/dist/lib/tools-mailbox.d.ts +0 -8
  246. package/dist/lib/tools-mailbox.js +0 -48
  247. package/dist/recipes/actor-worker.json +0 -39
  248. package/dist/recipes/coordinator-locker.json +0 -45
  249. package/dist/recipes/locker.json +0 -45
  250. package/dist/recipes/pipeline-room-swarm.json +0 -50
  251. package/dist/recipes/subagent-message.json +0 -32
  252. package/dist/recipes/utility-actor-message.json +0 -23
  253. package/dist/scripts/actor-worker.mjs +0 -214
  254. package/dist/scripts/coordinator.mjs +0 -799
  255. package/docs/actor-messages.md +0 -225
  256. package/docs/actors-deep-reference.md +0 -66
  257. package/docs/component-recipes.md +0 -148
  258. package/docs/task-first-recipes.md +0 -263
  259. package/fixtures/protocol/actor-message-branch.json +0 -13
  260. package/fixtures/protocol/mailbox-contract.json +0 -15
  261. package/fixtures/protocol/room-message.json +0 -11
  262. package/fixtures/protocol/room-roster.json +0 -11
  263. package/fixtures/protocol/run-inbox-message.json +0 -9
  264. package/fixtures/protocol/run-outbox-event.json +0 -9
  265. package/lib/mailbox-loop.ts +0 -144
  266. package/lib/messages.ts +0 -151
  267. package/lib/rooms.ts +0 -939
  268. package/lib/runs-mailbox.ts +0 -208
  269. package/lib/runs-messages.ts +0 -252
  270. package/lib/runs-outbox.ts +0 -144
  271. package/lib/tools-mailbox.ts +0 -56
  272. package/recipes/actor-worker.json +0 -39
  273. package/recipes/coordinator-locker.json +0 -45
  274. package/recipes/locker.json +0 -45
  275. package/recipes/pipeline-room-swarm.json +0 -50
  276. package/recipes/subagent-message.json +0 -32
  277. package/recipes/utility-actor-message.json +0 -23
  278. package/scripts/actor-worker.mjs +0 -214
  279. package/scripts/coordinator.mjs +0 -799
@@ -5,9 +5,9 @@
5
5
  */
6
6
 
7
7
  import { spawn } from "node:child_process";
8
- import { appendFileSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
8
+ import { accessSync, appendFileSync, constants, mkdirSync, mkdtempSync, statSync, writeFileSync } from "node:fs";
9
9
  import { homedir, tmpdir } from "node:os";
10
- import { isAbsolute, join, resolve as resolvePath } from "node:path";
10
+ import { basename, delimiter, extname, isAbsolute, join, resolve as resolvePath } from "node:path";
11
11
 
12
12
  export type CommandTemplateFailureScope = "continue" | "branch" | "root";
13
13
 
@@ -259,7 +259,7 @@ function getLeafCommandTemplateRiskLabels(
259
259
  const command = getExecutableName(parts[0]);
260
260
  const args = parts.slice(1);
261
261
  const labels = new Set<CommandTemplateRiskLabel>();
262
- if (["bash", "sh", "zsh", "fish"].includes(command)) {
262
+ if (["bash", "sh", "zsh", "fish"].includes(command) || command.endsWith(".sh")) {
263
263
  labels.add("risk.shell");
264
264
  if (hasAnyFlag(args, ["-c"])) labels.add("risk.eval");
265
265
  }
@@ -602,6 +602,46 @@ export function expandCommandTemplateExecutable(
602
602
  return command;
603
603
  }
604
604
 
605
+ export type CommandTemplateExecutableResolver = (name: string) => string | undefined;
606
+
607
+ function resolveExecutableOnPath(name: string): string | undefined {
608
+ if (name === "node" && /^node(?:\.exe)?$/i.test(basename(process.execPath)))
609
+ return process.execPath;
610
+ const pathValue = Object.entries(process.env)
611
+ .find(([key]) => key.toLowerCase() === "path")?.[1] ?? "";
612
+ const suffixes = process.platform === "win32" ? [".exe", ".com"] : [""];
613
+ for (const entry of pathValue.split(delimiter).filter(Boolean)) {
614
+ const directory = entry.replace(/^"|"$/g, "");
615
+ for (const suffix of suffixes) {
616
+ const candidate = join(directory, `${name}${suffix}`);
617
+ try {
618
+ accessSync(candidate, constants.X_OK);
619
+ if (statSync(candidate).isFile()) return candidate;
620
+ } catch {}
621
+ }
622
+ }
623
+ return undefined;
624
+ }
625
+
626
+ export function resolveCommandTemplateScriptInvocation(
627
+ invocation: CommandTemplateInvocation,
628
+ resolveExecutable: CommandTemplateExecutableResolver = resolveExecutableOnPath,
629
+ ): CommandTemplateInvocation {
630
+ const extension = extname(invocation.command).toLowerCase();
631
+ const interpreters = extension === ".js" || extension === ".mjs"
632
+ ? [["node"], ["bun"], ["deno", "run"]]
633
+ : extension === ".sh" ? [["bash"]] : [];
634
+ if (interpreters.length === 0) return invocation;
635
+ for (const [name, ...prefix] of interpreters) {
636
+ const executable = resolveExecutable(name);
637
+ if (executable)
638
+ return { command: executable, args: [...prefix, invocation.command, ...invocation.args] };
639
+ }
640
+ throw new Error(
641
+ `No interpreter available for command-template ${extension} script; tried ${interpreters.map(([name]) => name).join(", ")}.`,
642
+ );
643
+ }
644
+
605
645
  function evaluateCommandTemplateExpression(
606
646
  expression: string,
607
647
  values: Record<string, unknown>,
@@ -1072,5 +1112,5 @@ export function buildCommandTemplateInvocation(
1072
1112
  ),
1073
1113
  )
1074
1114
  .filter((part) => part !== "");
1075
- return { command, args };
1115
+ return resolveCommandTemplateScriptInvocation({ command, args });
1076
1116
  }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Model-facing Run Control projection.
3
+ * Zones: structured redaction, bounded Control input/error, decision-useful evidence
4
+ * Owns safe Control reads; durable journals and lifecycle transitions remain in runs-controls.
5
+ */
6
+
7
+ import * as Limits from "./limits.ts";
8
+ import type { RunControlRecord, RunControlStatus } from "./runs-controls.ts";
9
+ import * as SessionEvidence from "./session-evidence.ts";
10
+
11
+ export interface ProjectedRunControl {
12
+ action: string;
13
+ claimed_at?: string;
14
+ delivered_at?: string;
15
+ error?: string;
16
+ failed_at?: string;
17
+ handled_at?: string;
18
+ id: string;
19
+ input?: unknown;
20
+ queued_at: string;
21
+ run_instance_id: string;
22
+ status: RunControlStatus;
23
+ }
24
+
25
+ const OPTIONAL_TIMESTAMP_FIELDS = [
26
+ "claimed_at",
27
+ "delivered_at",
28
+ "failed_at",
29
+ "handled_at",
30
+ ] as const;
31
+ type OptionalTimestampField = (typeof OPTIONAL_TIMESTAMP_FIELDS)[number];
32
+
33
+ function boundedRedactedString(value: string, maxChars: number): string {
34
+ const redacted = SessionEvidence.redactSessionEvidenceValue(value, maxChars);
35
+ return typeof redacted === "string"
36
+ ? redacted
37
+ : String(redacted).slice(0, maxChars);
38
+ }
39
+
40
+ function boundedRedactedValue(value: unknown): unknown {
41
+ const redacted = SessionEvidence.redactSessionEvidenceValue(
42
+ value,
43
+ Limits.INSPECTOR_BODY_PREVIEW_CHARS,
44
+ );
45
+ let serialized: string | undefined;
46
+ try {
47
+ serialized = JSON.stringify(redacted);
48
+ } catch {
49
+ return "[UNSERIALIZABLE]";
50
+ }
51
+ if (serialized === undefined) return "[UNSERIALIZABLE]";
52
+ if (serialized.length <= Limits.INSPECTOR_BODY_PREVIEW_CHARS) return redacted;
53
+ let preview = serialized.slice(0, Limits.INSPECTOR_BODY_PREVIEW_CHARS);
54
+ let bounded = { preview: `${preview}…`, truncated: true };
55
+ while (
56
+ preview &&
57
+ JSON.stringify(bounded).length > Limits.INSPECTOR_BODY_PREVIEW_CHARS
58
+ ) {
59
+ const excess = JSON.stringify(bounded).length - Limits.INSPECTOR_BODY_PREVIEW_CHARS;
60
+ preview = preview.slice(0, Math.max(0, preview.length - Math.max(1, excess)));
61
+ bounded = { preview: `${preview}…`, truncated: true };
62
+ }
63
+ return bounded;
64
+ }
65
+
66
+ export function projectRunControl(
67
+ control: RunControlRecord,
68
+ ): ProjectedRunControl {
69
+ const timestamps = Object.fromEntries(
70
+ OPTIONAL_TIMESTAMP_FIELDS.flatMap((field) => {
71
+ const value = control[field];
72
+ return typeof value === "string"
73
+ ? [[field, boundedRedactedString(value, Limits.COMPACT_PREVIEW_CHARS)] as const]
74
+ : [];
75
+ }),
76
+ ) as Partial<Pick<ProjectedRunControl, OptionalTimestampField>>;
77
+ return {
78
+ action: boundedRedactedString(control.action, Limits.COMPACT_PREVIEW_CHARS),
79
+ ...timestamps,
80
+ ...(typeof control.error === "string"
81
+ ? {
82
+ error: boundedRedactedString(
83
+ control.error,
84
+ Limits.INSPECTOR_BODY_PREVIEW_CHARS,
85
+ ),
86
+ }
87
+ : {}),
88
+ id: boundedRedactedString(control.id, Limits.COMPACT_PREVIEW_CHARS),
89
+ ...(control.input !== undefined
90
+ ? { input: boundedRedactedValue(control.input) }
91
+ : {}),
92
+ queued_at: boundedRedactedString(
93
+ control.queued_at,
94
+ Limits.COMPACT_PREVIEW_CHARS,
95
+ ),
96
+ run_instance_id: boundedRedactedString(
97
+ control.run_instance_id,
98
+ Limits.COMPACT_PREVIEW_CHARS,
99
+ ),
100
+ status: boundedRedactedString(
101
+ control.status,
102
+ Limits.COMPACT_PREVIEW_CHARS,
103
+ ) as RunControlStatus,
104
+ };
105
+ }
package/lib/control.ts ADDED
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Run Control request contract.
3
+ * Zones: public request validation, Run/runtime target normalization, input bounds
4
+ * Owns pure Control validation; journaling, delivery, and lifecycle mutation stay in adapters.
5
+ */
6
+
7
+ import * as Limits from "./limits.ts";
8
+
9
+ export interface ControlRequest {
10
+ target: `run:${string}` | "runtime";
11
+ action: string;
12
+ input?: unknown;
13
+ verbose?: boolean;
14
+ }
15
+
16
+ const RUN_ID_PATTERN = /^[A-Za-z0-9_.-]+$/;
17
+ const ACTION_PATTERN = /^[a-z][a-z0-9_-]*(?:\.[a-z0-9_-]+)*$/;
18
+ const CONTROL_FIELDS = new Set(["action", "input", "target", "verbose"]);
19
+ const REMOVED_FIELDS = new Set([
20
+ "body",
21
+ "correlation_id",
22
+ "from",
23
+ "metadata",
24
+ "reply_to",
25
+ "summary",
26
+ "to",
27
+ "type",
28
+ ]);
29
+
30
+ export function isControlAction(value: string): boolean {
31
+ return ACTION_PATTERN.test(value);
32
+ }
33
+
34
+ export function normalizeControlAction(value: unknown): string {
35
+ if (typeof value !== "string" || !value.trim()) {
36
+ throw new Error("control.action is required");
37
+ }
38
+ const action = value.trim();
39
+ if (!isControlAction(action)) {
40
+ throw new Error(`invalid control action: ${action}`);
41
+ }
42
+ if (action.length > Limits.CONTROL_ACTION_MAX_LENGTH) {
43
+ throw new Error(
44
+ `control.action exceeds ${Limits.CONTROL_ACTION_MAX_LENGTH} ASCII characters`,
45
+ );
46
+ }
47
+ return action;
48
+ }
49
+
50
+ export function normalizeControlInput(input: unknown): unknown {
51
+ let serialized: string | undefined;
52
+ try {
53
+ serialized = JSON.stringify(input);
54
+ } catch {
55
+ throw new Error("control.input must be JSON-serializable");
56
+ }
57
+ if (serialized === undefined) {
58
+ throw new Error("control.input must be JSON-serializable");
59
+ }
60
+ const bytes = Buffer.byteLength(serialized);
61
+ if (bytes > Limits.CONTROL_INPUT_MAX_BYTES) {
62
+ throw new Error(
63
+ `control.input exceeds ${Limits.CONTROL_INPUT_MAX_BYTES} serialized bytes`,
64
+ );
65
+ }
66
+ return JSON.parse(serialized) as unknown;
67
+ }
68
+
69
+ function normalizeTarget(value: unknown): ControlRequest["target"] {
70
+ if (typeof value !== "string" || !value.trim()) {
71
+ throw new Error("control.target is required");
72
+ }
73
+ const target = value.trim();
74
+ if (target === "runtime") return target;
75
+ if (!target.startsWith("run:")) {
76
+ throw new Error(
77
+ `unsupported control target: ${target}; use run:<id> or runtime`,
78
+ );
79
+ }
80
+ const run = target.slice(4);
81
+ if (!RUN_ID_PATTERN.test(run)) {
82
+ throw new Error(`invalid control Run target: ${target}`);
83
+ }
84
+ return `run:${run}`;
85
+ }
86
+
87
+ export function normalizeControlRequest(input: unknown): ControlRequest {
88
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
89
+ throw new Error("control request must be an object");
90
+ }
91
+ const record = input as Record<string, unknown>;
92
+ const fields = Object.keys(record);
93
+ const removed = fields.filter((field) => REMOVED_FIELDS.has(field));
94
+ if (removed.length > 0) {
95
+ throw new Error(
96
+ `actor-message fields are removed: ${removed.sort().join(", ")}; use target, action, input, verbose`,
97
+ );
98
+ }
99
+ const unknown = fields.filter((field) => !CONTROL_FIELDS.has(field));
100
+ if (unknown.length > 0) {
101
+ throw new Error(`unsupported control fields: ${unknown.sort().join(", ")}`);
102
+ }
103
+ const target = normalizeTarget(record.target);
104
+ const action = normalizeControlAction(record.action);
105
+ if (record.verbose !== undefined && typeof record.verbose !== "boolean") {
106
+ throw new Error("control.verbose must be a boolean");
107
+ }
108
+ const normalizedInput = record.input === undefined
109
+ ? undefined
110
+ : normalizeControlInput(record.input);
111
+ return {
112
+ target,
113
+ action,
114
+ ...(normalizedInput !== undefined ? { input: normalizedInput } : {}),
115
+ ...(record.verbose !== undefined ? { verbose: record.verbose } : {}),
116
+ };
117
+ }
@@ -498,7 +498,7 @@ export function createDraftSleepScheduler(
498
498
  ...existing,
499
499
  failedStage: processed.stage ?? "result_processing",
500
500
  lastError: processed.error ?? "automatic draft review processing failed",
501
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
501
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
502
502
  phase: "processing_failed",
503
503
  processingAttempts: (existing.processingAttempts ?? 0) + 1,
504
504
  updatedAt: now().toISOString(),
@@ -509,7 +509,7 @@ export function createDraftSleepScheduler(
509
509
  ...existing,
510
510
  failedStage: processed.stage ?? "review_result",
511
511
  lastError: processed.error ?? "automatic draft reviewer failed",
512
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
512
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
513
513
  phase: "failed",
514
514
  runId: undefined,
515
515
  updatedAt: now().toISOString(),
@@ -552,7 +552,7 @@ export function createDraftSleepScheduler(
552
552
  attempts: (prior?.batchId === batch.batchId ? prior.attempts : 0) + 1,
553
553
  failedStage: "review_launch",
554
554
  lastError: error instanceof Error ? error.message : String(error),
555
- nextAction: "message to=tool:pi-actors type=review.retry body={\"scope\":\"draft\"}",
555
+ nextAction: "message target=runtime action=review.retry input={\"scope\":\"draft\"}",
556
556
  batchId: batch.batchId,
557
557
  inputPath: batch.inputPath,
558
558
  phase: "failed",
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Run-owned execution session evidence.
3
+ * Zones: execution manifest session discovery, canonical containment, rich turn correlation
4
+ * Owns reusable agent-turn loading; Trace and TUI projection stay in their adapters.
5
+ */
6
+
7
+ import * as fs from "node:fs";
8
+ import * as path from "node:path";
9
+
10
+ import * as SessionEvidence from "./session-evidence.ts";
11
+ import { readJsonFileResilient } from "./state-readers.ts";
12
+
13
+ export interface ExecutionTurnItem extends SessionEvidence.SessionEvidenceTurn {
14
+ commandId: string;
15
+ diagnostics: string[];
16
+ promptBytes?: number;
17
+ promptFile?: string;
18
+ recipeContext?: unknown;
19
+ sessionFile: string;
20
+ sessionTruncated: boolean;
21
+ stage?: string;
22
+ }
23
+
24
+ function ownedSessionPath(
25
+ stateDir: string,
26
+ sessionFile: string,
27
+ ): string | undefined {
28
+ if (path.isAbsolute(sessionFile)) return undefined;
29
+ const sessionsRoot = path.resolve(stateDir, "sessions");
30
+ const resolved = path.resolve(stateDir, sessionFile);
31
+ if (!resolved.startsWith(`${sessionsRoot}${path.sep}`)) return undefined;
32
+ try {
33
+ const canonicalRoot = fs.realpathSync(sessionsRoot);
34
+ const canonicalFile = fs.realpathSync(resolved);
35
+ return canonicalFile.startsWith(`${canonicalRoot}${path.sep}`)
36
+ ? canonicalFile
37
+ : undefined;
38
+ } catch {
39
+ return undefined;
40
+ }
41
+ }
42
+
43
+ export function readExecutionTurns(stateDir: string): ExecutionTurnItem[] {
44
+ const execution = readJsonFileResilient<Record<string, unknown>>(
45
+ path.join(stateDir, "execution.json"),
46
+ {},
47
+ ).value;
48
+ const commands = Array.isArray(execution.commands)
49
+ ? (execution.commands as Array<Record<string, unknown>>)
50
+ : [];
51
+ const recordedFiles = new Set(
52
+ commands.flatMap((command) =>
53
+ (Array.isArray(command.session_files) ? command.session_files : []).filter(
54
+ (file): file is string => typeof file === "string",
55
+ ),
56
+ ),
57
+ );
58
+ let discoveredCommands: Array<Record<string, unknown>> = [];
59
+ try {
60
+ const sessionsDir = path.join(stateDir, "sessions");
61
+ discoveredCommands = fs
62
+ .readdirSync(sessionsDir, { withFileTypes: true })
63
+ .filter((entry) => entry.isDirectory())
64
+ .flatMap((entry) => {
65
+ const files = fs
66
+ .readdirSync(path.join(sessionsDir, entry.name), {
67
+ withFileTypes: true,
68
+ })
69
+ .filter((file) => file.isFile() && file.name.endsWith(".jsonl"))
70
+ .map((file) => path.posix.join("sessions", entry.name, file.name))
71
+ .filter((file) => !recordedFiles.has(file));
72
+ return files.length > 0
73
+ ? [{ id: entry.name, session_files: files, stage: "subagent" }]
74
+ : [];
75
+ });
76
+ } catch {
77
+ discoveredCommands = [];
78
+ }
79
+ return [...commands, ...discoveredCommands]
80
+ .flatMap((command) =>
81
+ (Array.isArray(command.session_files) ? command.session_files : [])
82
+ .filter((file): file is string => typeof file === "string")
83
+ .flatMap((file) => {
84
+ const sessionPath = ownedSessionPath(stateDir, file);
85
+ if (!sessionPath) return [];
86
+ const session = SessionEvidence.readSessionEvidence(sessionPath);
87
+ return session.turns.map((turn) => ({
88
+ ...turn,
89
+ commandId: String(command.id ?? "unknown"),
90
+ diagnostics: session.diagnostics.map((item) =>
91
+ `${item.line === undefined ? "" : `line ${item.line}: `}${item.message}`,
92
+ ),
93
+ ...(typeof command.prompt_bytes === "number"
94
+ ? { promptBytes: command.prompt_bytes }
95
+ : {}),
96
+ ...(typeof command.prompt_file === "string"
97
+ ? { promptFile: command.prompt_file }
98
+ : {}),
99
+ ...(command.recipe_context !== undefined
100
+ ? { recipeContext: command.recipe_context }
101
+ : {}),
102
+ sessionFile: file,
103
+ sessionTruncated: session.truncated,
104
+ ...(typeof command.stage === "string"
105
+ ? { stage: command.stage }
106
+ : {}),
107
+ }));
108
+ }),
109
+ )
110
+ .map((turn, index) => ({ ...turn, index: index + 1 }));
111
+ }
package/lib/file-state.ts CHANGED
@@ -4,29 +4,28 @@
4
4
  * Owns generic durable JSON file writes shared by registry config and async run state.
5
5
  */
6
6
 
7
+ import { spawnSync } from "node:child_process";
7
8
  import { createHash, randomUUID } from "node:crypto";
8
- import {
9
- existsSync,
10
- mkdirSync,
11
- readFileSync,
12
- realpathSync,
13
- renameSync,
14
- rmSync,
15
- statSync,
16
- unlinkSync,
17
- writeFileSync,
18
- } from "node:fs";
9
+ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
19
10
  import { tmpdir } from "node:os";
20
11
  import { basename, dirname, join, parse, resolve } from "node:path";
21
12
 
22
- const FILE_MUTATION_LOCK_TIMEOUT_MS = 5000;
13
+ const FILE_MUTATION_LOCK_TIMEOUT_MS = process.platform === "win32" ? 15000 : 5000;
23
14
  const FILE_MUTATION_LOCK_STALE_MS = 30000;
24
15
  const FILE_MUTATION_LOCK_ROOT = join(tmpdir(), "pi-actors-file-locks");
25
16
 
26
17
  function canonicalMutationPath(path: string): string {
27
18
  const absolute = resolve(path);
28
- const suffix: string[] = [];
29
- let existing = absolute;
19
+ try {
20
+ if (lstatSync(absolute).isSymbolicLink()) {
21
+ const target = realpathSync.native(absolute);
22
+ return process.platform === "win32" ? target.toLowerCase() : target;
23
+ }
24
+ } catch {
25
+ /* Materialization may race lock-key derivation; canonicalize through the parent. */
26
+ }
27
+ const suffix: string[] = [basename(absolute)];
28
+ let existing = dirname(absolute);
30
29
  while (!existsSync(existing)) {
31
30
  const parent = dirname(existing);
32
31
  if (parent === existing || existing === parse(existing).root) break;
@@ -47,6 +46,16 @@ export function mutationLockPath(path: string): string {
47
46
  return join(FILE_MUTATION_LOCK_ROOT, `${key}.lock`);
48
47
  }
49
48
 
49
+ function isZombieProcess(pid: number): boolean {
50
+ if (process.platform === "linux") try {
51
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
52
+ return stat.slice(stat.lastIndexOf(")") + 2).startsWith("Z ");
53
+ } catch { return false; }
54
+ if (process.platform !== "darwin") return false;
55
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "stat="], { encoding: "utf8" });
56
+ return result.status === 0 && result.stdout.trim().startsWith("Z");
57
+ }
58
+
50
59
  function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
51
60
  try {
52
61
  const owner = JSON.parse(
@@ -56,6 +65,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
56
65
  if (!Number.isInteger(pid) || pid <= 0) return "unknown";
57
66
  try {
58
67
  process.kill(pid, 0);
68
+ if (Date.now() - statSync(lockPath).mtimeMs > 50 && isZombieProcess(pid)) return "dead";
59
69
  return "alive";
60
70
  } catch (error) {
61
71
  return (error as NodeJS.ErrnoException).code === "ESRCH"
@@ -68,6 +78,7 @@ function lockOwnerStatus(lockPath: string): "alive" | "dead" | "unknown" {
68
78
  }
69
79
 
70
80
  export interface FileMutationLockOptions {
81
+ onBeforeLockPublish?(): void;
71
82
  onBeforeReclaimRemove?(): void;
72
83
  onContention?(): void;
73
84
  onRemovalContention?(): void;
@@ -84,6 +95,28 @@ function readLockToken(lockPath: string): string | undefined {
84
95
  }
85
96
  }
86
97
 
98
+ function prepareLockBoundary(lockPath: string, token: string, onBeforePublish?: () => void): string {
99
+ const pendingPath = `${lockPath}.${process.pid}.${token}.pending`;
100
+ try {
101
+ mkdirSync(pendingPath);
102
+ writeFileSync(join(pendingPath, "owner.json"), `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`, "utf8");
103
+ onBeforePublish?.();
104
+ return pendingPath;
105
+ } catch (error) {
106
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
107
+ throw error;
108
+ }
109
+ }
110
+
111
+ function removeLockBoundary(lockPath: string, token: string | undefined): boolean {
112
+ if (readLockToken(lockPath) !== token) return false;
113
+ if (process.platform === "win32") try { rmSync(lockPath, { recursive: true, force: true, maxRetries: 20, retryDelay: 5 }); return true; } catch { return false; }
114
+ const removingPath = `${lockPath}.${process.pid}.${randomUUID()}.removing`;
115
+ try { renameSync(lockPath, removingPath); } catch { return false; }
116
+ try { rmSync(removingPath, { recursive: true, force: true }); } catch {}
117
+ return true;
118
+ }
119
+
87
120
  function tryReclaimRemovalBoundary(reclaimPath: string): void {
88
121
  if (!existsSync(reclaimPath)) return;
89
122
  try {
@@ -95,7 +128,7 @@ function tryReclaimRemovalBoundary(reclaimPath: string): void {
95
128
  (ownerStatus === "unknown" && age > FILE_MUTATION_LOCK_STALE_MS)) &&
96
129
  readLockToken(reclaimPath) === inspectedToken
97
130
  ) {
98
- rmSync(reclaimPath, { recursive: true, force: true });
131
+ removeLockBoundary(reclaimPath, inspectedToken);
99
132
  }
100
133
  } catch {
101
134
  /* another contender changed the boundary */
@@ -108,29 +141,20 @@ function withRemovalBoundary(
108
141
  ): boolean {
109
142
  const reclaimPath = `${lockPath}.reclaim`;
110
143
  const token = randomUUID();
144
+ const pendingPath = prepareLockBoundary(reclaimPath, token);
111
145
  try {
112
- mkdirSync(reclaimPath);
146
+ try { renameSync(pendingPath, reclaimPath); } catch {
147
+ tryReclaimRemovalBoundary(reclaimPath);
148
+ return false;
149
+ }
113
150
  try {
114
- writeFileSync(
115
- join(reclaimPath, "owner.json"),
116
- `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
117
- "utf8",
118
- );
119
- } catch (error) {
120
- rmSync(reclaimPath, { recursive: true, force: true });
121
- throw error;
151
+ action();
152
+ return true;
153
+ } finally {
154
+ removeLockBoundary(reclaimPath, token);
122
155
  }
123
- } catch {
124
- tryReclaimRemovalBoundary(reclaimPath);
125
- return false;
126
- }
127
- try {
128
- action();
129
- return true;
130
156
  } finally {
131
- if (readLockToken(reclaimPath) === token) {
132
- rmSync(reclaimPath, { recursive: true, force: true });
133
- }
157
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
134
158
  }
135
159
  }
136
160
 
@@ -153,8 +177,7 @@ function tryReclaimMutationLock(
153
177
  readLockToken(lockPath) === inspectedToken
154
178
  ) {
155
179
  options.onBeforeReclaimRemove?.();
156
- rmSync(lockPath, { recursive: true, force: true });
157
- reclaimed = true;
180
+ reclaimed = removeLockBoundary(lockPath, inspectedToken);
158
181
  }
159
182
  })) {
160
183
  return false;
@@ -170,34 +193,29 @@ export function acquireFileMutationLock(
170
193
  const lockPath = mutationLockPath(path);
171
194
  const deadline = Date.now() + FILE_MUTATION_LOCK_TIMEOUT_MS;
172
195
  const token = randomUUID();
196
+ const pendingPath = prepareLockBoundary(lockPath, token, options.onBeforeLockPublish);
173
197
  let contentionReported = false;
174
- for (;;) {
175
- try {
176
- mkdirSync(lockPath);
198
+ try {
199
+ for (;;) {
177
200
  try {
178
- writeFileSync(
179
- join(lockPath, "owner.json"),
180
- `${JSON.stringify({ pid: process.pid, token, acquired_at: new Date().toISOString() })}\n`,
181
- "utf8",
182
- );
201
+ renameSync(pendingPath, lockPath);
202
+ break;
183
203
  } catch (error) {
184
- rmSync(lockPath, { recursive: true, force: true });
185
- throw error;
186
- }
187
- break;
188
- } catch (error) {
189
- if (!contentionReported) {
190
- contentionReported = true;
191
- options.onContention?.();
192
- }
193
- tryReclaimMutationLock(lockPath, options);
194
- if (Date.now() >= deadline) {
195
- throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
196
- cause: error,
197
- });
204
+ if (!contentionReported) {
205
+ contentionReported = true;
206
+ options.onContention?.();
207
+ }
208
+ tryReclaimMutationLock(lockPath, options);
209
+ if (Date.now() >= deadline) {
210
+ throw new Error(`Timed out waiting for file mutation lock: ${canonicalMutationPath(path)}`, {
211
+ cause: error,
212
+ });
213
+ }
214
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
198
215
  }
199
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
200
216
  }
217
+ } finally {
218
+ try { rmSync(pendingPath, { recursive: true, force: true }); } catch {}
201
219
  }
202
220
  let released = false;
203
221
  return () => {
@@ -207,9 +225,7 @@ export function acquireFileMutationLock(
207
225
  let removalContentionReported = false;
208
226
  while (
209
227
  !withRemovalBoundary(lockPath, () => {
210
- if (readLockToken(lockPath) === token) {
211
- rmSync(lockPath, { recursive: true, force: true });
212
- }
228
+ removeLockBoundary(lockPath, token);
213
229
  })
214
230
  ) {
215
231
  if (!removalContentionReported) {
@@ -235,11 +251,11 @@ export function withFileMutationLock<T>(
235
251
  }
236
252
  }
237
253
 
238
- export function writeJsonAtomic(path: string, value: unknown): void {
254
+ export function writeTextAtomic(path: string, content: string): void {
239
255
  mkdirSync(dirname(path), { recursive: true });
240
256
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
241
257
  try {
242
- writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
258
+ writeFileSync(tempPath, content, "utf8");
243
259
  renameSync(tempPath, path);
244
260
  } catch (error) {
245
261
  try {
@@ -250,3 +266,7 @@ export function writeJsonAtomic(path: string, value: unknown): void {
250
266
  throw error;
251
267
  }
252
268
  }
269
+
270
+ export function writeJsonAtomic(path: string, value: unknown): void {
271
+ writeTextAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
272
+ }