@llblab/pi-actors 0.39.0 → 0.40.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 (93) hide show
  1. package/AGENTS.md +10 -3
  2. package/BACKLOG.md +4 -11
  3. package/CHANGELOG.md +51 -0
  4. package/README.md +5 -5
  5. package/dist/index.js +6 -4
  6. package/dist/lib/async-runs.d.ts +4 -0
  7. package/dist/lib/async-runs.js +112 -17
  8. package/dist/lib/command-templates.d.ts +9 -0
  9. package/dist/lib/command-templates.js +92 -11
  10. package/dist/lib/config.js +0 -5
  11. package/dist/lib/execution.d.ts +31 -0
  12. package/dist/lib/execution.js +145 -12
  13. package/dist/lib/file-state.d.ts +1 -0
  14. package/dist/lib/file-state.js +91 -3
  15. package/dist/lib/observability.js +5 -2
  16. package/dist/lib/prompts.d.ts +1 -2
  17. package/dist/lib/prompts.js +3 -4
  18. package/dist/lib/recipes-context.js +17 -9
  19. package/dist/lib/recipes-discovery.js +11 -5
  20. package/dist/lib/recipes-references.d.ts +1 -1
  21. package/dist/lib/recipes-references.js +4 -5
  22. package/dist/lib/recipes-usage.d.ts +2 -0
  23. package/dist/lib/recipes-usage.js +35 -21
  24. package/dist/lib/registry.d.ts +0 -2
  25. package/dist/lib/registry.js +33 -10
  26. package/dist/lib/runs-ownership.d.ts +7 -0
  27. package/dist/lib/runs-ownership.js +82 -0
  28. package/dist/lib/runs-process.d.ts +17 -2
  29. package/dist/lib/runs-process.js +99 -11
  30. package/dist/lib/runs-retention.d.ts +3 -0
  31. package/dist/lib/runs-retention.js +18 -3
  32. package/dist/lib/runs-start.d.ts +2 -2
  33. package/dist/lib/runs-start.js +51 -17
  34. package/dist/lib/runs-status.d.ts +1 -1
  35. package/dist/lib/runs-status.js +8 -6
  36. package/dist/lib/runtime.js +69 -13
  37. package/dist/lib/tools-inspect.d.ts +2 -0
  38. package/dist/lib/tools-inspect.js +39 -2
  39. package/dist/lib/tools-register.js +0 -1
  40. package/dist/lib/tools-spawn.js +3 -2
  41. package/dist/lib/tools.d.ts +1 -0
  42. package/dist/lib/tools.js +3 -0
  43. package/dist/pi-actors/index.js +1 -0
  44. package/dist/recipes/subagent-judge.json +2 -1
  45. package/dist/recipes/subagent-merge.json +2 -1
  46. package/dist/recipes/subagent-normalize.json +2 -1
  47. package/dist/recipes/subagent-review-coordinator.json +1 -1
  48. package/dist/recipes/subagent-review.json +2 -1
  49. package/dist/recipes/subagent-verify.json +2 -1
  50. package/dist/scripts/async-runner.mjs +274 -6
  51. package/dist/scripts/build-dist.mjs +14 -1
  52. package/dist/skills/actors/SKILL.md +15 -8
  53. package/dist/skills/swarm/SKILL.md +4 -2
  54. package/docs/actor-messages.md +1 -1
  55. package/docs/async-runs.md +14 -5
  56. package/docs/command-templates.md +4 -2
  57. package/docs/recipe-library.md +1 -0
  58. package/docs/template-recipes.md +5 -7
  59. package/docs/tool-registry.md +4 -2
  60. package/index.ts +18 -7
  61. package/lib/async-runs.ts +138 -19
  62. package/lib/command-templates.ts +132 -13
  63. package/lib/config.ts +0 -4
  64. package/lib/execution.ts +198 -13
  65. package/lib/file-state.ts +106 -3
  66. package/lib/observability.ts +8 -2
  67. package/lib/prompts.ts +3 -5
  68. package/lib/recipes-context.ts +17 -9
  69. package/lib/recipes-discovery.ts +10 -5
  70. package/lib/recipes-references.ts +5 -6
  71. package/lib/recipes-usage.ts +36 -20
  72. package/lib/registry.ts +43 -13
  73. package/lib/runs-ownership.ts +117 -0
  74. package/lib/runs-process.ts +138 -16
  75. package/lib/runs-retention.ts +22 -2
  76. package/lib/runs-start.ts +89 -31
  77. package/lib/runs-status.ts +15 -6
  78. package/lib/runtime.ts +64 -12
  79. package/lib/tools-inspect.ts +46 -4
  80. package/lib/tools-register.ts +0 -3
  81. package/lib/tools-spawn.ts +5 -5
  82. package/lib/tools.ts +8 -0
  83. package/package.json +2 -2
  84. package/recipes/subagent-judge.json +2 -1
  85. package/recipes/subagent-merge.json +2 -1
  86. package/recipes/subagent-normalize.json +2 -1
  87. package/recipes/subagent-review-coordinator.json +1 -1
  88. package/recipes/subagent-review.json +2 -1
  89. package/recipes/subagent-verify.json +2 -1
  90. package/scripts/async-runner.mjs +274 -6
  91. package/scripts/build-dist.mjs +14 -1
  92. package/skills/actors/SKILL.md +15 -8
  93. package/skills/swarm/SKILL.md +4 -2
@@ -16,23 +16,22 @@ export const REGISTER_TOOL_GUIDELINES = [
16
16
  export const ONBOARDING_SYSTEM_PROMPT = `pi-actors quick model:
17
17
  - Local-first actor memory: persist trusted local capabilities instead of rebuilding shell recipes.
18
18
  - Layers: task -> command template -> recipe/tool -> spawn -> run:<id>; tool:<name> wraps registered capabilities.
19
- - Command templates stay sync: string leaf, array sequence, object node; flags include args/defaults, parallel, concurrency, min_successful, when, timeout, delay, retry, failure, recover, repeat, output.
19
+ - 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.
20
20
  - Placeholders support typed/default args plus {value??fallback} and {flag?yes:no}.
21
21
  - ~/.pi/agent/recipes/*.json is actor muscle memory: every recipe there is auto-registered as an agent tool across sessions; register_tool writes there.
22
22
  - Recipes own template directly and may declare metadata/defaults/imports/mailbox/artifacts; files >1 MiB or import depth >32 fail closed.
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 status/tail/messages/mailbox/files/artifacts intentionally and avoid busy-polling.
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.
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
- - For any non-trivial actor use or pi-actors change, read the bundled actors skill first; for deeper guidance, inspect installed extension sources/docs/recipes because README/docs are not automatically in context.`;
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.`;
30
30
  export const REGISTER_TOOL_PARAM_DESCRIPTIONS = {
31
31
  name: "Tool name in snake_case (e.g., 'transcribe')",
32
32
  description: "Describe what the tool does for the LLM. Required unless deleting; omitted updates keep the old description.",
33
33
  draft: "Promote a draft recipe path from ~/.pi/agent/recipes/drafts into an active named recipe under ~/.pi/agent/recipes. Requires name; use update=true to overwrite.",
34
34
  async: "Set true for a co-located async template recipe. Omit for ordinary command templates or file-backed recipe references.",
35
- state_dir: "Optional async run state directory for a co-located template recipe.",
36
35
  template: "Command template with {arg} or {arg=default} placeholders, or a template recipe JSON path/name. With async, this is the co-located recipe body. Bare recipe names resolve under ~/.pi/agent/recipes. Omitted updates keep the old template. Empty string deletes the tool.",
37
36
  templateArray: "Sequential command-template composition array. Leaves may be strings or objects with template/defaults/timeout/retry/failure/recover.",
38
37
  templateNull: "Delete the tool when template is null.",
@@ -47,10 +47,10 @@ function piOptionConsumesNextArg(arg) {
47
47
  function isPiFileArgument(arg) {
48
48
  return arg.startsWith("@") && arg.length > 1;
49
49
  }
50
- export function findPiPrintPromptIndex(args) {
50
+ function findPiPrintPromptIndexes(args) {
51
51
  let printMode = false;
52
52
  let positionalOnly = false;
53
- let promptIndex;
53
+ const promptIndexes = [];
54
54
  for (let index = 0; index < args.length; index += 1) {
55
55
  const arg = args[index];
56
56
  if (!positionalOnly && arg === "--") {
@@ -68,9 +68,12 @@ export function findPiPrintPromptIndex(args) {
68
68
  }
69
69
  if (!printMode || isPiFileArgument(arg))
70
70
  continue;
71
- promptIndex = index;
71
+ promptIndexes.push(index);
72
72
  }
73
- return promptIndex;
73
+ return promptIndexes;
74
+ }
75
+ export function findPiPrintPromptIndex(args) {
76
+ return findPiPrintPromptIndexes(args).at(-1);
74
77
  }
75
78
  function matchesActorContext(record, context) {
76
79
  if (!context)
@@ -134,14 +137,19 @@ export function appendRecipeContextToPiArgs(command, args, records, context) {
134
137
  export function materializePiPrintPromptArg(command, args, promptFile) {
135
138
  if (!isPiCommand(command))
136
139
  return { args };
137
- const promptIndex = findPiPrintPromptIndex(args);
138
- if (promptIndex === undefined)
140
+ const promptIndexes = findPiPrintPromptIndexes(args);
141
+ if (promptIndexes.length === 0)
139
142
  return { args };
140
- const prompt = args[promptIndex];
143
+ const prompt = promptIndexes.map((index) => args[index]).join(" ");
141
144
  const path = typeof promptFile === "function" ? promptFile() : promptFile;
142
145
  writeFileSync(path, prompt, "utf8");
143
- const next = [...args];
144
- next[promptIndex] = `@${path}`;
146
+ const promptIndexSet = new Set(promptIndexes);
147
+ const firstPromptIndex = promptIndexes[0];
148
+ const next = args.flatMap((arg, index) => {
149
+ if (index === firstPromptIndex)
150
+ return [`@${path}`];
151
+ return promptIndexSet.has(index) ? [] : [arg];
152
+ });
145
153
  return {
146
154
  args: next,
147
155
  promptBytes: Buffer.byteLength(prompt),
@@ -10,6 +10,7 @@ import { join } from "node:path";
10
10
  import * as CommandTemplates from "./command-templates.js";
11
11
  import * as ModelContext from "./model-context.js";
12
12
  import * as RecipesReferences from "./recipes-references.js";
13
+ import * as RecipesUsage from "./recipes-usage.js";
13
14
  import * as Schema from "./schema.js";
14
15
  function assertToolSafeRepeatConfig(config, argTypes, defaults) {
15
16
  if (typeof config === "string" || config === undefined || config === null)
@@ -242,8 +243,13 @@ export function discoverRecipeSources(sources) {
242
243
  export function discoverRecipes(roots) {
243
244
  return discoverRecipeSources(roots.map((root) => ({ root })));
244
245
  }
245
- function recipeUsage(config) {
246
- const usage = config?.usage;
246
+ function recipeUsage(entry) {
247
+ const stored = entry.mutableUsage
248
+ ? RecipesUsage.readRecipeUsage(entry.path)
249
+ : undefined;
250
+ if (stored)
251
+ return stored;
252
+ const usage = entry.config?.usage;
247
253
  return usage && typeof usage === "object" && !Array.isArray(usage)
248
254
  ? usage
249
255
  : undefined;
@@ -273,7 +279,7 @@ function cleanupRecommendation(entry) {
273
279
  actions: ["keep disabled", "delete", "archive"],
274
280
  };
275
281
  }
276
- const usage = recipeUsage(entry.config);
282
+ const usage = recipeUsage(entry);
277
283
  const calls = Number(usage?.calls ?? 0);
278
284
  if (entry.mutableUsage && entry.tool && calls === 0) {
279
285
  return {
@@ -581,8 +587,8 @@ export function summarizeDiscovery(result) {
581
587
  }
582
588
  : {}),
583
589
  ...(entry.config?.imports ? { imports: entry.config.imports } : {}),
584
- ...(recipeUsage(entry.config)
585
- ? { usage: recipeUsage(entry.config) }
590
+ ...(recipeUsage(entry)
591
+ ? { usage: recipeUsage(entry) }
586
592
  : {}),
587
593
  }))
588
594
  .sort((a, b) => a.id.localeCompare(b.id)),
@@ -39,6 +39,7 @@ export interface TemplateRecipeDefinition {
39
39
  when?: boolean | string;
40
40
  timeout?: number | string;
41
41
  delay?: number | string;
42
+ accept_output?: "review_evidence";
42
43
  output?: string;
43
44
  artifacts?: Record<string, string>;
44
45
  mailbox?: TemplateRecipeMailbox;
@@ -52,7 +53,6 @@ export interface TemplateRecipeDefinition {
52
53
  }
53
54
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
54
55
  async?: boolean;
55
- state_dir?: string;
56
56
  }
57
57
  export interface TemplateRecipeContextRecord {
58
58
  alias?: string;
@@ -141,6 +141,7 @@ function getRecipeCommandTemplate(raw) {
141
141
  "when",
142
142
  "timeout",
143
143
  "delay",
144
+ "accept_output",
144
145
  "output",
145
146
  "retry",
146
147
  "failure",
@@ -695,11 +696,6 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
695
696
  : delegated?.async === false
696
697
  ? { async: false }
697
698
  : {}),
698
- ...(typeof substituted.state_dir === "string"
699
- ? { state_dir: substituted.state_dir }
700
- : typeof delegated?.state_dir === "string"
701
- ? { state_dir: delegated.state_dir }
702
- : {}),
703
699
  ...(Object.keys(imports).length > 0
704
700
  ? { imports: getRecipeImports(raw) }
705
701
  : {}),
@@ -736,6 +732,9 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
736
732
  typeof substituted.delay === "string"
737
733
  ? { delay: substituted.delay }
738
734
  : {}),
735
+ ...(substituted.accept_output === "review_evidence"
736
+ ? { accept_output: substituted.accept_output }
737
+ : {}),
739
738
  ...(typeof substituted.output === "string"
740
739
  ? { output: substituted.output }
741
740
  : {}),
@@ -4,4 +4,6 @@
4
4
  * Owns lightweight launch counters for user-owned recipe files
5
5
  */
6
6
  export type RecipeLaunchKind = "direct" | "spawn" | "tool";
7
+ export declare function getRecipeUsagePath(path: string): string;
8
+ export declare function readRecipeUsage(path: string): Record<string, unknown> | undefined;
7
9
  export declare function recordRecipeLaunch(path: string, now?: Date, kind?: RecipeLaunchKind): boolean;
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { createHash } from "node:crypto";
7
7
  import { existsSync, readFileSync } from "node:fs";
8
- import { writeJsonAtomic } from "./file-state.js";
8
+ import { basename, dirname, join } from "node:path";
9
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
9
10
  function isRecord(value) {
10
11
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
11
12
  }
@@ -28,25 +29,38 @@ function getRecipeFingerprint(raw) {
28
29
  const { usage: _usage, ...content } = raw;
29
30
  return createHash("sha256").update(stableStringify(content)).digest("hex");
30
31
  }
32
+ export function getRecipeUsagePath(path) {
33
+ return join(dirname(path), ".usage", `${basename(path)}.json`);
34
+ }
35
+ export function readRecipeUsage(path) {
36
+ try {
37
+ const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
38
+ return isRecord(value) ? value : undefined;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
31
44
  function launchCounterKey(kind) {
32
45
  return `${kind}_calls`;
33
46
  }
34
47
  export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
35
48
  if (!existsSync(path))
36
49
  return false;
37
- try {
38
- const raw = JSON.parse(readFileSync(path, "utf8"));
39
- if (!isRecord(raw))
40
- return false;
41
- const usage = isRecord(raw.usage) ? raw.usage : {};
42
- const fingerprint = getRecipeFingerprint(raw);
43
- const changed = typeof usage.fingerprint === "string" &&
44
- usage.fingerprint !== fingerprint;
45
- const nowIso = now.toISOString();
46
- const counterKey = launchCounterKey(kind);
47
- writeJsonAtomic(path, {
48
- ...raw,
49
- usage: {
50
+ const usagePath = getRecipeUsagePath(path);
51
+ return withFileMutationLock(usagePath, () => {
52
+ try {
53
+ const raw = JSON.parse(readFileSync(path, "utf8"));
54
+ if (!isRecord(raw))
55
+ return false;
56
+ const storedUsage = readRecipeUsage(path);
57
+ const usage = storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
58
+ const fingerprint = getRecipeFingerprint(raw);
59
+ const changed = typeof usage.fingerprint === "string" &&
60
+ usage.fingerprint !== fingerprint;
61
+ const nowIso = now.toISOString();
62
+ const counterKey = launchCounterKey(kind);
63
+ writeJsonAtomic(usagePath, {
50
64
  ...usage,
51
65
  calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
52
66
  [counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
@@ -59,11 +73,11 @@ export function recordRecipeLaunch(path, now = new Date(), kind = "direct") {
59
73
  reset_reason: "recipe content fingerprint changed",
60
74
  }
61
75
  : {}),
62
- },
63
- });
64
- return true;
65
- }
66
- catch {
67
- return false;
68
- }
76
+ });
77
+ return true;
78
+ }
79
+ catch {
80
+ return false;
81
+ }
82
+ });
69
83
  }
@@ -9,7 +9,6 @@ export interface RegisterToolInput {
9
9
  name?: string;
10
10
  description?: string;
11
11
  async?: boolean;
12
- state_dir?: string;
13
12
  template?: CommandTemplates.CommandTemplateValue | null;
14
13
  draft?: string;
15
14
  args?: string;
@@ -24,7 +23,6 @@ export interface RegisterToolResultDetails {
24
23
  draft?: string;
25
24
  promoted?: boolean;
26
25
  recipeName?: string;
27
- state_dir?: string;
28
26
  template?: CommandTemplates.CommandTemplateValue;
29
27
  templateWarnings?: string[];
30
28
  tool: string;
@@ -3,11 +3,12 @@
3
3
  * Zones: registry mutations, persistence, runtime activation
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
6
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
7
7
  import { dirname, join, relative, resolve } from "node:path";
8
8
  import * as CommandTemplates from "./command-templates.js";
9
+ import * as Config from "./config.js";
9
10
  import * as ExecutionOutput from "./execution-output.js";
10
- import { writeJsonAtomic } from "./file-state.js";
11
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.js";
11
12
  import * as Identity from "./identity.js";
12
13
  import * as Paths from "./paths.js";
13
14
  import * as RecipesReferences from "./recipes-references.js";
@@ -94,7 +95,6 @@ function persistToolRecipe(deps, cfg) {
94
95
  writeJsonAtomic(path, {
95
96
  description: cfg.description,
96
97
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
97
- ...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
98
98
  ...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
99
99
  ...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
100
100
  ...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
@@ -125,6 +125,23 @@ function deleteTool(name, ctx, deps) {
125
125
  details: { config: recipePath, tool: name },
126
126
  };
127
127
  }
128
+ function readAuthoritativeStoredTool(deps, name) {
129
+ const path = getToolRecipePath(deps, name);
130
+ if (!existsSync(path))
131
+ return { exists: false };
132
+ try {
133
+ const normalized = Config.normalizeStoredTool(name, JSON.parse(readFileSync(path, "utf8")), deps.reservedToolNames);
134
+ if (!normalized.cfg)
135
+ return { exists: true };
136
+ return {
137
+ exists: true,
138
+ tool: { ...normalized.cfg, sourcePath: path },
139
+ };
140
+ }
141
+ catch {
142
+ return { exists: true };
143
+ }
144
+ }
128
145
  function getInputTemplate(value) {
129
146
  if (typeof value === "string")
130
147
  return value.trim();
@@ -167,9 +184,6 @@ function buildConfig(name, input, existing) {
167
184
  ? {
168
185
  name: inputRecipe,
169
186
  ...(typeof input.async === "boolean" ? { async: input.async } : {}),
170
- ...(typeof input.state_dir === "string" && input.state_dir.trim()
171
- ? { state_dir: input.state_dir.trim() }
172
- : {}),
173
187
  template: finalTemplate,
174
188
  ...(input.values && typeof input.values === "object"
175
189
  ? { values: input.values }
@@ -219,7 +233,7 @@ function buildConfig(name, input, existing) {
219
233
  ...(storedDefaults !== undefined ? { storedDefaults } : {}),
220
234
  };
221
235
  }
222
- export async function executeRegisterTool(params, ctx, deps) {
236
+ function executeRegisterToolUnlocked(params, ctx, deps) {
223
237
  const input = params;
224
238
  if (!input.name)
225
239
  return listTools(deps);
@@ -237,11 +251,12 @@ export async function executeRegisterTool(params, ctx, deps) {
237
251
  if (templateProvided && (template === null || template === ""))
238
252
  return deleteTool(name, ctx, deps);
239
253
  const tools = deps.getTools();
240
- const existing = tools.get(name);
254
+ const authoritative = readAuthoritativeStoredTool(deps, name);
255
+ const existing = authoritative.tool ?? tools.get(name);
241
256
  const blocker = deps.getToolNameBlocker(name);
242
257
  if (blocker)
243
258
  throw new Error(ExecutionOutput.formatToolText(blocker));
244
- if (existing && !input.update) {
259
+ if ((authoritative.exists || existing) && !input.update) {
245
260
  throw new Error(ExecutionOutput.formatToolText(`Tool "${name}" already registered. Use update=true to overwrite.`));
246
261
  }
247
262
  if (template === undefined && !existing) {
@@ -275,10 +290,18 @@ export async function executeRegisterTool(params, ctx, deps) {
275
290
  defaults: cfg.defaults,
276
291
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
277
292
  ...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),
278
- ...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
279
293
  ...(cfg.template ? { template: cfg.template } : {}),
280
294
  ...(templateWarnings.length > 0 ? { templateWarnings } : {}),
281
295
  tool: name,
282
296
  },
283
297
  };
284
298
  }
299
+ export async function executeRegisterTool(params, ctx, deps) {
300
+ const input = params;
301
+ if (!input.name)
302
+ return executeRegisterToolUnlocked(params, ctx, deps);
303
+ const name = Identity.normalizeToolName(input.name);
304
+ if (!name)
305
+ return executeRegisterToolUnlocked(params, ctx, deps);
306
+ return withFileMutationLock(getToolRecipePath(deps, name), () => executeRegisterToolUnlocked(params, ctx, deps));
307
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Async run state-directory ownership.
3
+ * Owns the marker proof required before launch and destructive retention.
4
+ */
5
+ export declare const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
6
+ export declare function claimRunStateDirectory(stateDir: string, run: string): string;
7
+ export declare function assertOwnedRunStateDirectory(stateDir: string, run: string): string;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Async run state-directory ownership.
3
+ * Owns the marker proof required before launch and destructive retention.
4
+ */
5
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, } from "node:fs";
6
+ import { randomUUID } from "node:crypto";
7
+ import { join, resolve } from "node:path";
8
+ import { writeJsonAtomic } from "./file-state.js";
9
+ export const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
10
+ function markerPath(stateDir) {
11
+ return join(stateDir, RUN_STATE_OWNERSHIP_FILE);
12
+ }
13
+ function comparablePath(path) {
14
+ const resolved = resolve(path);
15
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
16
+ }
17
+ function assertCanonicalDirectory(stateDir) {
18
+ const resolved = resolve(stateDir);
19
+ if (lstatSync(resolved).isSymbolicLink()) {
20
+ throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
21
+ }
22
+ const canonical = realpathSync(resolved);
23
+ if (comparablePath(canonical) !== comparablePath(resolved)) {
24
+ throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
25
+ }
26
+ return resolved;
27
+ }
28
+ function readMarker(stateDir) {
29
+ try {
30
+ const value = JSON.parse(readFileSync(markerPath(stateDir), "utf8"));
31
+ if (value.version !== 1 ||
32
+ typeof value.run !== "string" ||
33
+ typeof value.state_dir !== "string" ||
34
+ typeof value.ownership_token !== "string" ||
35
+ !value.ownership_token) {
36
+ return undefined;
37
+ }
38
+ return value;
39
+ }
40
+ catch {
41
+ return undefined;
42
+ }
43
+ }
44
+ function assertMarkerMatches(marker, stateDir, run) {
45
+ if (!marker) {
46
+ throw new Error(`Run state ownership marker is missing or invalid: ${stateDir}`);
47
+ }
48
+ if (marker.state_dir !== stateDir || marker.run !== run) {
49
+ throw new Error(`Run state ownership marker does not match run ${run}: ${stateDir}`);
50
+ }
51
+ return marker;
52
+ }
53
+ export function claimRunStateDirectory(stateDir, run) {
54
+ const resolved = resolve(stateDir);
55
+ if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
56
+ throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
57
+ }
58
+ mkdirSync(resolved, { recursive: true });
59
+ const canonical = assertCanonicalDirectory(resolved);
60
+ const existing = readMarker(canonical);
61
+ if (existing) {
62
+ assertMarkerMatches(existing, canonical, run);
63
+ return canonical;
64
+ }
65
+ const existingEntries = readdirSync(canonical).filter((entry) => entry !== ".start.lock");
66
+ if (existingEntries.length > 0) {
67
+ throw new Error(`Refusing to claim existing non-run directory: ${canonical}`);
68
+ }
69
+ writeJsonAtomic(markerPath(canonical), {
70
+ created_at: new Date().toISOString(),
71
+ ownership_token: randomUUID(),
72
+ run,
73
+ state_dir: canonical,
74
+ version: 1,
75
+ });
76
+ return canonical;
77
+ }
78
+ export function assertOwnedRunStateDirectory(stateDir, run) {
79
+ const canonical = assertCanonicalDirectory(stateDir);
80
+ assertMarkerMatches(readMarker(canonical), canonical, run);
81
+ return canonical;
82
+ }
@@ -1,7 +1,22 @@
1
1
  /**
2
2
  * Async run process identity helpers.
3
- * Owns liveness checks and Linux runner identity matching for run-owned processes.
3
+ * Owns cross-platform liveness and stable runner process identity proofs.
4
4
  */
5
+ export interface RunProcessIdentity {
6
+ command: string;
7
+ cwd?: string;
8
+ platform: NodeJS.Platform;
9
+ start_time: string;
10
+ }
11
+ export type RunProcessIdentityStatus = "valid" | "dead_pid" | "owner_mismatch" | "unsupported_proof";
12
+ export interface RunProcessIdentityResult {
13
+ status: RunProcessIdentityStatus;
14
+ valid: boolean;
15
+ }
16
+ type ProcessIdentityReader = (pid: number, runtimePlatform: NodeJS.Platform) => RunProcessIdentity | undefined;
5
17
  export declare function isAlive(pid: number): boolean;
6
- export declare function pidMatchesRun(pid: number, cwd: string, stateDir: string, runnerPath: string): boolean;
18
+ export declare function readProcessIdentity(pid: number, runtimePlatform?: NodeJS.Platform): RunProcessIdentity | undefined;
19
+ export declare function captureRunProcessIdentity(pid: number, cwd: string, stateDir: string, runnerPath: string, runtimePlatform?: NodeJS.Platform, reader?: ProcessIdentityReader): RunProcessIdentity | undefined;
20
+ export declare function verifyRunProcessIdentity(pid: number, expected: RunProcessIdentity | undefined, runtimePlatform?: NodeJS.Platform, reader?: ProcessIdentityReader, alive?: (pid: number) => boolean): RunProcessIdentityResult;
7
21
  export declare function isWithinRunnerIdentityGrace(meta: Record<string, unknown>, graceMs: number): boolean;
22
+ export {};
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Async run process identity helpers.
3
- * Owns liveness checks and Linux runner identity matching for run-owned processes.
3
+ * Owns cross-platform liveness and stable runner process identity proofs.
4
4
  */
5
- import { existsSync, readFileSync, readlinkSync } from "node:fs";
5
+ import { spawnSync } from "node:child_process";
6
+ import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
6
7
  import { platform } from "node:os";
7
8
  import { resolve } from "node:path";
8
9
  export function isAlive(pid) {
@@ -14,19 +15,106 @@ export function isAlive(pid) {
14
15
  return false;
15
16
  }
16
17
  }
17
- export function pidMatchesRun(pid, cwd, stateDir, runnerPath) {
18
- if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
19
- return isAlive(pid);
18
+ function readLinuxIdentity(pid) {
19
+ if (!existsSync(`/proc/${pid}`))
20
+ return undefined;
20
21
  try {
21
- const procCwd = readlinkSync(`/proc/${pid}/cwd`);
22
- const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
23
- return (procCwd === resolve(cwd) &&
24
- cmdline.includes(runnerPath) &&
25
- cmdline.includes(stateDir));
22
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
23
+ const close = stat.lastIndexOf(")");
24
+ const fields = stat.slice(close + 2).trim().split(/\s+/);
25
+ const startTime = fields[19];
26
+ if (!startTime)
27
+ return undefined;
28
+ return {
29
+ command: readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ").trim(),
30
+ cwd: readlinkSync(`/proc/${pid}/cwd`),
31
+ platform: "linux",
32
+ start_time: startTime,
33
+ };
26
34
  }
27
35
  catch {
28
- return false;
36
+ return undefined;
37
+ }
38
+ }
39
+ function readDarwinIdentity(pid) {
40
+ const result = spawnSync("ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="], { encoding: "utf8" });
41
+ if (result.status !== 0)
42
+ return undefined;
43
+ const match = result.stdout.trim().match(/^(.{24})\s+(.+)$/s);
44
+ if (!match)
45
+ return undefined;
46
+ return {
47
+ command: match[2].trim(),
48
+ platform: "darwin",
49
+ start_time: match[1].trim(),
50
+ };
51
+ }
52
+ function readWindowsIdentity(pid) {
53
+ const command = [
54
+ `$p = Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\";`,
55
+ "if ($null -eq $p) { exit 3 };",
56
+ "$p | Select-Object CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress",
57
+ ].join(" ");
58
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", command], { encoding: "utf8", windowsHide: true });
59
+ if (result.status !== 0)
60
+ return undefined;
61
+ try {
62
+ const value = JSON.parse(result.stdout);
63
+ if (typeof value.CreationDate !== "string")
64
+ return undefined;
65
+ const executable = typeof value.ExecutablePath === "string" ? value.ExecutablePath : "";
66
+ const commandLine = typeof value.CommandLine === "string" ? value.CommandLine : "";
67
+ return {
68
+ command: `${executable}\n${commandLine}`.trim(),
69
+ platform: "win32",
70
+ start_time: value.CreationDate,
71
+ };
72
+ }
73
+ catch {
74
+ return undefined;
75
+ }
76
+ }
77
+ export function readProcessIdentity(pid, runtimePlatform = platform()) {
78
+ if (runtimePlatform === "linux")
79
+ return readLinuxIdentity(pid);
80
+ if (runtimePlatform === "darwin")
81
+ return readDarwinIdentity(pid);
82
+ if (runtimePlatform === "win32")
83
+ return readWindowsIdentity(pid);
84
+ return undefined;
85
+ }
86
+ export function captureRunProcessIdentity(pid, cwd, stateDir, runnerPath, runtimePlatform = platform(), reader = readProcessIdentity) {
87
+ const identity = reader(pid, runtimePlatform);
88
+ if (!identity)
89
+ return undefined;
90
+ if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
91
+ return undefined;
92
+ }
93
+ const resolvedCwd = resolve(cwd);
94
+ const canonicalCwd = existsSync(resolvedCwd)
95
+ ? realpathSync.native(resolvedCwd)
96
+ : resolvedCwd;
97
+ const expectedCwd = runtimePlatform === "win32" ? canonicalCwd.toLowerCase() : canonicalCwd;
98
+ const actualCwd = runtimePlatform === "win32" && identity.cwd
99
+ ? identity.cwd.toLowerCase()
100
+ : identity.cwd;
101
+ if (actualCwd && actualCwd !== expectedCwd)
102
+ return undefined;
103
+ return identity;
104
+ }
105
+ export function verifyRunProcessIdentity(pid, expected, runtimePlatform = platform(), reader = readProcessIdentity, alive = isAlive) {
106
+ if (!alive(pid))
107
+ return { status: "dead_pid", valid: false };
108
+ if (!expected || expected.platform !== runtimePlatform) {
109
+ return { status: "unsupported_proof", valid: false };
29
110
  }
111
+ const current = reader(pid, runtimePlatform);
112
+ if (!current)
113
+ return { status: "unsupported_proof", valid: false };
114
+ const valid = current.start_time === expected.start_time &&
115
+ current.command === expected.command &&
116
+ current.cwd === expected.cwd;
117
+ return { status: valid ? "valid" : "owner_mismatch", valid };
30
118
  }
31
119
  export function isWithinRunnerIdentityGrace(meta, graceMs) {
32
120
  const createdAt = typeof meta.createdAt === "string" ? Date.parse(meta.createdAt) : NaN;
@@ -2,7 +2,10 @@
2
2
  * Async run retention operations.
3
3
  * Owns terminal-run archive and prune filesystem behavior.
4
4
  */
5
+ import { cpSync } from "node:fs";
5
6
  export declare function archiveTerminalRun(status: Record<string, unknown>): Record<string, unknown>;
6
7
  export declare function pruneTerminalRun(status: Record<string, unknown>, options?: {
7
8
  preserveArtifacts?: boolean;
9
+ }, deps?: {
10
+ copyArtifact?: typeof cpSync;
8
11
  }): Record<string, unknown>;