@llblab/pi-actors 0.39.0 → 0.40.0

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 (97) hide show
  1. package/AGENTS.md +10 -3
  2. package/BACKLOG.md +2 -11
  3. package/CHANGELOG.md +45 -0
  4. package/README.md +4 -4
  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.d.ts +1 -1
  16. package/dist/lib/observability.js +7 -4
  17. package/dist/lib/pi.d.ts +1 -1
  18. package/dist/lib/pi.js +2 -2
  19. package/dist/lib/prompts.d.ts +1 -2
  20. package/dist/lib/prompts.js +2 -3
  21. package/dist/lib/recipes-context.js +17 -9
  22. package/dist/lib/recipes-discovery.js +11 -5
  23. package/dist/lib/recipes-references.d.ts +1 -1
  24. package/dist/lib/recipes-references.js +4 -5
  25. package/dist/lib/recipes-usage.d.ts +2 -0
  26. package/dist/lib/recipes-usage.js +35 -21
  27. package/dist/lib/registry.d.ts +0 -2
  28. package/dist/lib/registry.js +33 -10
  29. package/dist/lib/runs-ownership.d.ts +7 -0
  30. package/dist/lib/runs-ownership.js +82 -0
  31. package/dist/lib/runs-process.d.ts +17 -2
  32. package/dist/lib/runs-process.js +99 -11
  33. package/dist/lib/runs-retention.d.ts +3 -0
  34. package/dist/lib/runs-retention.js +18 -3
  35. package/dist/lib/runs-start.d.ts +2 -2
  36. package/dist/lib/runs-start.js +51 -17
  37. package/dist/lib/runs-status.d.ts +1 -1
  38. package/dist/lib/runs-status.js +8 -6
  39. package/dist/lib/runtime.js +69 -13
  40. package/dist/lib/tools-inspect.d.ts +2 -0
  41. package/dist/lib/tools-inspect.js +39 -2
  42. package/dist/lib/tools-register.js +0 -1
  43. package/dist/lib/tools-spawn.js +3 -2
  44. package/dist/lib/tools.d.ts +1 -0
  45. package/dist/lib/tools.js +3 -0
  46. package/dist/pi-actors/index.js +1 -0
  47. package/dist/recipes/subagent-judge.json +2 -1
  48. package/dist/recipes/subagent-merge.json +2 -1
  49. package/dist/recipes/subagent-normalize.json +2 -1
  50. package/dist/recipes/subagent-review-coordinator.json +1 -1
  51. package/dist/recipes/subagent-review.json +2 -1
  52. package/dist/recipes/subagent-verify.json +2 -1
  53. package/dist/scripts/async-runner.mjs +274 -6
  54. package/dist/scripts/build-dist.mjs +14 -1
  55. package/dist/skills/actors/SKILL.md +11 -7
  56. package/dist/skills/swarm/SKILL.md +1 -1
  57. package/docs/actor-messages.md +1 -1
  58. package/docs/async-runs.md +14 -5
  59. package/docs/command-templates.md +4 -2
  60. package/docs/recipe-library.md +1 -0
  61. package/docs/template-recipes.md +5 -7
  62. package/docs/tool-registry.md +4 -2
  63. package/index.ts +18 -7
  64. package/lib/async-runs.ts +138 -19
  65. package/lib/command-templates.ts +132 -13
  66. package/lib/config.ts +0 -4
  67. package/lib/execution.ts +198 -13
  68. package/lib/file-state.ts +106 -3
  69. package/lib/observability.ts +11 -5
  70. package/lib/pi.ts +3 -3
  71. package/lib/prompts.ts +2 -4
  72. package/lib/recipes-context.ts +17 -9
  73. package/lib/recipes-discovery.ts +10 -5
  74. package/lib/recipes-references.ts +5 -6
  75. package/lib/recipes-usage.ts +36 -20
  76. package/lib/registry.ts +43 -13
  77. package/lib/runs-ownership.ts +117 -0
  78. package/lib/runs-process.ts +138 -16
  79. package/lib/runs-retention.ts +22 -2
  80. package/lib/runs-start.ts +89 -31
  81. package/lib/runs-status.ts +15 -6
  82. package/lib/runtime.ts +64 -12
  83. package/lib/tools-inspect.ts +46 -4
  84. package/lib/tools-register.ts +0 -3
  85. package/lib/tools-spawn.ts +5 -5
  86. package/lib/tools.ts +8 -0
  87. package/package.json +2 -2
  88. package/recipes/subagent-judge.json +2 -1
  89. package/recipes/subagent-merge.json +2 -1
  90. package/recipes/subagent-normalize.json +2 -1
  91. package/recipes/subagent-review-coordinator.json +1 -1
  92. package/recipes/subagent-review.json +2 -1
  93. package/recipes/subagent-verify.json +2 -1
  94. package/scripts/async-runner.mjs +274 -6
  95. package/scripts/build-dist.mjs +14 -1
  96. package/skills/actors/SKILL.md +11 -7
  97. package/skills/swarm/SKILL.md +1 -1
@@ -58,6 +58,7 @@ export interface TemplateRecipeDefinition {
58
58
  when?: boolean | string;
59
59
  timeout?: number | string;
60
60
  delay?: number | string;
61
+ accept_output?: "review_evidence";
61
62
  output?: string;
62
63
  artifacts?: Record<string, string>;
63
64
  mailbox?: TemplateRecipeMailbox;
@@ -72,7 +73,6 @@ export interface TemplateRecipeDefinition {
72
73
 
73
74
  export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
74
75
  async?: boolean;
75
- state_dir?: string;
76
76
  }
77
77
 
78
78
  interface ImportedRecipe {
@@ -263,6 +263,7 @@ function getRecipeCommandTemplate(
263
263
  "when",
264
264
  "timeout",
265
265
  "delay",
266
+ "accept_output",
266
267
  "output",
267
268
  "retry",
268
269
  "failure",
@@ -999,11 +1000,6 @@ export function readResolvedRecipeConfig(
999
1000
  : delegated?.async === false
1000
1001
  ? { async: false }
1001
1002
  : {}),
1002
- ...(typeof substituted.state_dir === "string"
1003
- ? { state_dir: substituted.state_dir }
1004
- : typeof delegated?.state_dir === "string"
1005
- ? { state_dir: delegated.state_dir }
1006
- : {}),
1007
1003
  ...(Object.keys(imports).length > 0
1008
1004
  ? { imports: getRecipeImports(raw) }
1009
1005
  : {}),
@@ -1040,6 +1036,9 @@ export function readResolvedRecipeConfig(
1040
1036
  typeof substituted.delay === "string"
1041
1037
  ? { delay: substituted.delay }
1042
1038
  : {}),
1039
+ ...(substituted.accept_output === "review_evidence"
1040
+ ? { accept_output: substituted.accept_output }
1041
+ : {}),
1043
1042
  ...(typeof substituted.output === "string"
1044
1043
  ? { output: substituted.output }
1045
1044
  : {}),
@@ -6,8 +6,9 @@
6
6
 
7
7
  import { createHash } from "node:crypto";
8
8
  import { existsSync, readFileSync } from "node:fs";
9
+ import { basename, dirname, join } from "node:path";
9
10
 
10
- import { writeJsonAtomic } from "./file-state.ts";
11
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
11
12
 
12
13
  interface RecipesUsageRecord {
13
14
  calls?: number;
@@ -47,6 +48,19 @@ function getRecipeFingerprint(raw: Record<string, unknown>): string {
47
48
 
48
49
  export type RecipeLaunchKind = "direct" | "spawn" | "tool";
49
50
 
51
+ export function getRecipeUsagePath(path: string): string {
52
+ return join(dirname(path), ".usage", `${basename(path)}.json`);
53
+ }
54
+
55
+ export function readRecipeUsage(path: string): Record<string, unknown> | undefined {
56
+ try {
57
+ const value = JSON.parse(readFileSync(getRecipeUsagePath(path), "utf8"));
58
+ return isRecord(value) ? value : undefined;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
50
64
  function launchCounterKey(
51
65
  kind: RecipeLaunchKind,
52
66
  ): "direct_calls" | "spawn_calls" | "tool_calls" {
@@ -59,19 +73,21 @@ export function recordRecipeLaunch(
59
73
  kind: RecipeLaunchKind = "direct",
60
74
  ): boolean {
61
75
  if (!existsSync(path)) return false;
62
- try {
63
- const raw = JSON.parse(readFileSync(path, "utf8"));
64
- if (!isRecord(raw)) return false;
65
- const usage: RecipesUsageRecord = isRecord(raw.usage) ? raw.usage : {};
66
- const fingerprint = getRecipeFingerprint(raw);
67
- const changed =
68
- typeof usage.fingerprint === "string" &&
69
- usage.fingerprint !== fingerprint;
70
- const nowIso = now.toISOString();
71
- const counterKey = launchCounterKey(kind);
72
- writeJsonAtomic(path, {
73
- ...raw,
74
- usage: {
76
+ const usagePath = getRecipeUsagePath(path);
77
+ return withFileMutationLock(usagePath, () => {
78
+ try {
79
+ const raw = JSON.parse(readFileSync(path, "utf8"));
80
+ if (!isRecord(raw)) return false;
81
+ const storedUsage = readRecipeUsage(path);
82
+ const usage: RecipesUsageRecord =
83
+ storedUsage ?? (isRecord(raw.usage) ? raw.usage : {});
84
+ const fingerprint = getRecipeFingerprint(raw);
85
+ const changed =
86
+ typeof usage.fingerprint === "string" &&
87
+ usage.fingerprint !== fingerprint;
88
+ const nowIso = now.toISOString();
89
+ const counterKey = launchCounterKey(kind);
90
+ writeJsonAtomic(usagePath, {
75
91
  ...usage,
76
92
  calls: (changed ? 0 : normalizeCalls(usage.calls)) + 1,
77
93
  [counterKey]: (changed ? 0 : normalizeCalls(usage[counterKey])) + 1,
@@ -84,10 +100,10 @@ export function recordRecipeLaunch(
84
100
  reset_reason: "recipe content fingerprint changed",
85
101
  }
86
102
  : {}),
87
- },
88
- });
89
- return true;
90
- } catch {
91
- return false;
92
- }
103
+ });
104
+ return true;
105
+ } catch {
106
+ return false;
107
+ }
108
+ });
93
109
  }
package/lib/registry.ts CHANGED
@@ -4,13 +4,13 @@
4
4
  * Owns register/update/delete validation, persistence, runtime side effects, and result payloads
5
5
  */
6
6
 
7
- import { existsSync, mkdirSync, unlinkSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
8
8
  import { dirname, join, relative, resolve } from "node:path";
9
9
 
10
10
  import * as CommandTemplates from "./command-templates.ts";
11
11
  import * as Config from "./config.ts";
12
12
  import * as ExecutionOutput from "./execution-output.ts";
13
- import { writeJsonAtomic } from "./file-state.ts";
13
+ import { withFileMutationLock, writeJsonAtomic } from "./file-state.ts";
14
14
  import * as Identity from "./identity.ts";
15
15
  import * as Paths from "./paths.ts";
16
16
  import * as RecipesReferences from "./recipes-references.ts";
@@ -20,7 +20,6 @@ export interface RegisterToolInput {
20
20
  name?: string;
21
21
  description?: string;
22
22
  async?: boolean;
23
- state_dir?: string;
24
23
  template?: CommandTemplates.CommandTemplateValue | null;
25
24
  draft?: string;
26
25
  args?: string;
@@ -36,7 +35,6 @@ export interface RegisterToolResultDetails {
36
35
  draft?: string;
37
36
  promoted?: boolean;
38
37
  recipeName?: string;
39
- state_dir?: string;
40
38
  template?: CommandTemplates.CommandTemplateValue;
41
39
  templateWarnings?: string[];
42
40
  tool: string;
@@ -194,7 +192,6 @@ function persistToolRecipe<TContext>(
194
192
  writeJsonAtomic(path, {
195
193
  description: cfg.description,
196
194
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
197
- ...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
198
195
  ...(cfg.storedArgs ? { args: cfg.storedArgs } : {}),
199
196
  ...(cfg.storedDefaults ? { defaults: cfg.storedDefaults } : {}),
200
197
  ...(cfg.recipe?.values ? { values: cfg.recipe.values } : {}),
@@ -238,6 +235,28 @@ function deleteTool<TContext>(
238
235
  };
239
236
  }
240
237
 
238
+ function readAuthoritativeStoredTool<TContext>(
239
+ deps: RegisterToolRuntimeDeps<TContext>,
240
+ name: string,
241
+ ): { exists: boolean; tool?: Config.RegisteredTool } {
242
+ const path = getToolRecipePath(deps, name);
243
+ if (!existsSync(path)) return { exists: false };
244
+ try {
245
+ const normalized = Config.normalizeStoredTool(
246
+ name,
247
+ JSON.parse(readFileSync(path, "utf8")),
248
+ deps.reservedToolNames,
249
+ );
250
+ if (!normalized.cfg) return { exists: true };
251
+ return {
252
+ exists: true,
253
+ tool: { ...normalized.cfg, sourcePath: path },
254
+ };
255
+ } catch {
256
+ return { exists: true };
257
+ }
258
+ }
259
+
241
260
  function getInputTemplate(
242
261
  value: CommandTemplates.CommandTemplateValue | null | undefined,
243
262
  ): CommandTemplates.CommandTemplateValue | null | undefined {
@@ -303,9 +322,6 @@ function buildConfig(
303
322
  ? {
304
323
  name: inputRecipe,
305
324
  ...(typeof input.async === "boolean" ? { async: input.async } : {}),
306
- ...(typeof input.state_dir === "string" && input.state_dir.trim()
307
- ? { state_dir: input.state_dir.trim() }
308
- : {}),
309
325
  template: finalTemplate,
310
326
  ...(input.values && typeof input.values === "object"
311
327
  ? { values: input.values }
@@ -359,11 +375,11 @@ function buildConfig(
359
375
  };
360
376
  }
361
377
 
362
- export async function executeRegisterTool<TContext>(
378
+ function executeRegisterToolUnlocked<TContext>(
363
379
  params: unknown,
364
380
  ctx: TContext,
365
381
  deps: RegisterToolRuntimeDeps<TContext>,
366
- ): Promise<RegisterToolResult> {
382
+ ): RegisterToolResult {
367
383
  const input = params as RegisterToolInput;
368
384
  if (!input.name) return listTools(deps);
369
385
  const name = Identity.normalizeToolName(input.name);
@@ -382,10 +398,11 @@ export async function executeRegisterTool<TContext>(
382
398
  if (templateProvided && (template === null || template === ""))
383
399
  return deleteTool(name, ctx, deps);
384
400
  const tools = deps.getTools();
385
- const existing = tools.get(name);
401
+ const authoritative = readAuthoritativeStoredTool(deps, name);
402
+ const existing = authoritative.tool ?? tools.get(name);
386
403
  const blocker = deps.getToolNameBlocker(name);
387
404
  if (blocker) throw new Error(ExecutionOutput.formatToolText(blocker));
388
- if (existing && !input.update) {
405
+ if ((authoritative.exists || existing) && !input.update) {
389
406
  throw new Error(
390
407
  ExecutionOutput.formatToolText(
391
408
  `Tool "${name}" already registered. Use update=true to overwrite.`,
@@ -437,10 +454,23 @@ export async function executeRegisterTool<TContext>(
437
454
  defaults: cfg.defaults,
438
455
  ...(cfg.recipe?.async !== undefined ? { async: cfg.recipe.async } : {}),
439
456
  ...(cfg.recipe?.name ? { recipeName: cfg.recipe.name } : {}),
440
- ...(cfg.recipe?.state_dir ? { state_dir: cfg.recipe.state_dir } : {}),
441
457
  ...(cfg.template ? { template: cfg.template } : {}),
442
458
  ...(templateWarnings.length > 0 ? { templateWarnings } : {}),
443
459
  tool: name,
444
460
  },
445
461
  };
446
462
  }
463
+
464
+ export async function executeRegisterTool<TContext>(
465
+ params: unknown,
466
+ ctx: TContext,
467
+ deps: RegisterToolRuntimeDeps<TContext>,
468
+ ): Promise<RegisterToolResult> {
469
+ const input = params as RegisterToolInput;
470
+ if (!input.name) return executeRegisterToolUnlocked(params, ctx, deps);
471
+ const name = Identity.normalizeToolName(input.name);
472
+ if (!name) return executeRegisterToolUnlocked(params, ctx, deps);
473
+ return withFileMutationLock(getToolRecipePath(deps, name), () =>
474
+ executeRegisterToolUnlocked(params, ctx, deps),
475
+ );
476
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Async run state-directory ownership.
3
+ * Owns the marker proof required before launch and destructive retention.
4
+ */
5
+
6
+ import {
7
+ existsSync,
8
+ lstatSync,
9
+ mkdirSync,
10
+ readdirSync,
11
+ readFileSync,
12
+ realpathSync,
13
+ } from "node:fs";
14
+ import { randomUUID } from "node:crypto";
15
+ import { join, resolve } from "node:path";
16
+
17
+ import { writeJsonAtomic } from "./file-state.ts";
18
+
19
+ export const RUN_STATE_OWNERSHIP_FILE = ".pi-actors-run-state.json";
20
+
21
+ interface RunStateOwnershipMarker {
22
+ created_at: string;
23
+ ownership_token: string;
24
+ run: string;
25
+ state_dir: string;
26
+ version: 1;
27
+ }
28
+
29
+ function markerPath(stateDir: string): string {
30
+ return join(stateDir, RUN_STATE_OWNERSHIP_FILE);
31
+ }
32
+
33
+ function comparablePath(path: string): string {
34
+ const resolved = resolve(path);
35
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
36
+ }
37
+
38
+ function assertCanonicalDirectory(stateDir: string): string {
39
+ const resolved = resolve(stateDir);
40
+ if (lstatSync(resolved).isSymbolicLink()) {
41
+ throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
42
+ }
43
+ const canonical = realpathSync(resolved);
44
+ if (comparablePath(canonical) !== comparablePath(resolved)) {
45
+ throw new Error(`Run state directory has an ambiguous symlink alias: ${resolved}`);
46
+ }
47
+ return resolved;
48
+ }
49
+
50
+ function readMarker(stateDir: string): RunStateOwnershipMarker | undefined {
51
+ try {
52
+ const value = JSON.parse(readFileSync(markerPath(stateDir), "utf8")) as Partial<RunStateOwnershipMarker>;
53
+ if (
54
+ value.version !== 1 ||
55
+ typeof value.run !== "string" ||
56
+ typeof value.state_dir !== "string" ||
57
+ typeof value.ownership_token !== "string" ||
58
+ !value.ownership_token
59
+ ) {
60
+ return undefined;
61
+ }
62
+ return value as RunStateOwnershipMarker;
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ }
67
+
68
+ function assertMarkerMatches(
69
+ marker: RunStateOwnershipMarker | undefined,
70
+ stateDir: string,
71
+ run: string,
72
+ ): RunStateOwnershipMarker {
73
+ if (!marker) {
74
+ throw new Error(`Run state ownership marker is missing or invalid: ${stateDir}`);
75
+ }
76
+ if (marker.state_dir !== stateDir || marker.run !== run) {
77
+ throw new Error(`Run state ownership marker does not match run ${run}: ${stateDir}`);
78
+ }
79
+ return marker;
80
+ }
81
+
82
+ export function claimRunStateDirectory(stateDir: string, run: string): string {
83
+ const resolved = resolve(stateDir);
84
+ if (existsSync(resolved) && lstatSync(resolved).isSymbolicLink()) {
85
+ throw new Error(`Run state directory cannot be a symlink: ${resolved}`);
86
+ }
87
+ mkdirSync(resolved, { recursive: true });
88
+ const canonical = assertCanonicalDirectory(resolved);
89
+ const existing = readMarker(canonical);
90
+ if (existing) {
91
+ assertMarkerMatches(existing, canonical, run);
92
+ return canonical;
93
+ }
94
+ const existingEntries = readdirSync(canonical).filter(
95
+ (entry) => entry !== ".start.lock",
96
+ );
97
+ if (existingEntries.length > 0) {
98
+ throw new Error(`Refusing to claim existing non-run directory: ${canonical}`);
99
+ }
100
+ writeJsonAtomic(markerPath(canonical), {
101
+ created_at: new Date().toISOString(),
102
+ ownership_token: randomUUID(),
103
+ run,
104
+ state_dir: canonical,
105
+ version: 1,
106
+ } satisfies RunStateOwnershipMarker);
107
+ return canonical;
108
+ }
109
+
110
+ export function assertOwnedRunStateDirectory(
111
+ stateDir: string,
112
+ run: string,
113
+ ): string {
114
+ const canonical = assertCanonicalDirectory(stateDir);
115
+ assertMarkerMatches(readMarker(canonical), canonical, run);
116
+ return canonical;
117
+ }
@@ -1,12 +1,36 @@
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
5
 
6
- import { existsSync, readFileSync, readlinkSync } from "node:fs";
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync, readFileSync, readlinkSync, realpathSync } from "node:fs";
7
8
  import { platform } from "node:os";
8
9
  import { resolve } from "node:path";
9
10
 
11
+ export interface RunProcessIdentity {
12
+ command: string;
13
+ cwd?: string;
14
+ platform: NodeJS.Platform;
15
+ start_time: string;
16
+ }
17
+
18
+ export type RunProcessIdentityStatus =
19
+ | "valid"
20
+ | "dead_pid"
21
+ | "owner_mismatch"
22
+ | "unsupported_proof";
23
+
24
+ export interface RunProcessIdentityResult {
25
+ status: RunProcessIdentityStatus;
26
+ valid: boolean;
27
+ }
28
+
29
+ type ProcessIdentityReader = (
30
+ pid: number,
31
+ runtimePlatform: NodeJS.Platform,
32
+ ) => RunProcessIdentity | undefined;
33
+
10
34
  export function isAlive(pid: number): boolean {
11
35
  try {
12
36
  process.kill(pid, 0);
@@ -16,25 +40,123 @@ export function isAlive(pid: number): boolean {
16
40
  }
17
41
  }
18
42
 
19
- export function pidMatchesRun(
43
+ function readLinuxIdentity(pid: number): RunProcessIdentity | undefined {
44
+ if (!existsSync(`/proc/${pid}`)) return undefined;
45
+ try {
46
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
47
+ const close = stat.lastIndexOf(")");
48
+ const fields = stat.slice(close + 2).trim().split(/\s+/);
49
+ const startTime = fields[19];
50
+ if (!startTime) return undefined;
51
+ return {
52
+ command: readFileSync(`/proc/${pid}/cmdline`, "utf8").replaceAll("\0", " ").trim(),
53
+ cwd: readlinkSync(`/proc/${pid}/cwd`),
54
+ platform: "linux",
55
+ start_time: startTime,
56
+ };
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ function readDarwinIdentity(pid: number): RunProcessIdentity | undefined {
63
+ const result = spawnSync(
64
+ "ps",
65
+ ["-p", String(pid), "-o", "lstart=", "-o", "command="],
66
+ { encoding: "utf8" },
67
+ );
68
+ if (result.status !== 0) return undefined;
69
+ const match = result.stdout.trim().match(/^(.{24})\s+(.+)$/s);
70
+ if (!match) return undefined;
71
+ return {
72
+ command: match[2].trim(),
73
+ platform: "darwin",
74
+ start_time: match[1].trim(),
75
+ };
76
+ }
77
+
78
+ function readWindowsIdentity(pid: number): RunProcessIdentity | undefined {
79
+ const command = [
80
+ `$p = Get-CimInstance Win32_Process -Filter \"ProcessId = ${pid}\";`,
81
+ "if ($null -eq $p) { exit 3 };",
82
+ "$p | Select-Object CreationDate,ExecutablePath,CommandLine | ConvertTo-Json -Compress",
83
+ ].join(" ");
84
+ const result = spawnSync(
85
+ "powershell.exe",
86
+ ["-NoProfile", "-NonInteractive", "-Command", command],
87
+ { encoding: "utf8", windowsHide: true },
88
+ );
89
+ if (result.status !== 0) return undefined;
90
+ try {
91
+ const value = JSON.parse(result.stdout) as Record<string, unknown>;
92
+ if (typeof value.CreationDate !== "string") return undefined;
93
+ const executable = typeof value.ExecutablePath === "string" ? value.ExecutablePath : "";
94
+ const commandLine = typeof value.CommandLine === "string" ? value.CommandLine : "";
95
+ return {
96
+ command: `${executable}\n${commandLine}`.trim(),
97
+ platform: "win32",
98
+ start_time: value.CreationDate,
99
+ };
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ export function readProcessIdentity(
106
+ pid: number,
107
+ runtimePlatform: NodeJS.Platform = platform(),
108
+ ): RunProcessIdentity | undefined {
109
+ if (runtimePlatform === "linux") return readLinuxIdentity(pid);
110
+ if (runtimePlatform === "darwin") return readDarwinIdentity(pid);
111
+ if (runtimePlatform === "win32") return readWindowsIdentity(pid);
112
+ return undefined;
113
+ }
114
+
115
+ export function captureRunProcessIdentity(
20
116
  pid: number,
21
117
  cwd: string,
22
118
  stateDir: string,
23
119
  runnerPath: string,
24
- ): boolean {
25
- if (platform() !== "linux" || !existsSync(`/proc/${pid}`))
26
- return isAlive(pid);
27
- try {
28
- const procCwd = readlinkSync(`/proc/${pid}/cwd`);
29
- const cmdline = readFileSync(`/proc/${pid}/cmdline`, "utf8");
30
- return (
31
- procCwd === resolve(cwd) &&
32
- cmdline.includes(runnerPath) &&
33
- cmdline.includes(stateDir)
34
- );
35
- } catch {
36
- return false;
120
+ runtimePlatform: NodeJS.Platform = platform(),
121
+ reader: ProcessIdentityReader = readProcessIdentity,
122
+ ): RunProcessIdentity | undefined {
123
+ const identity = reader(pid, runtimePlatform);
124
+ if (!identity) return undefined;
125
+ if (!identity.command.includes(runnerPath) || !identity.command.includes(stateDir)) {
126
+ return undefined;
127
+ }
128
+ const resolvedCwd = resolve(cwd);
129
+ const canonicalCwd = existsSync(resolvedCwd)
130
+ ? realpathSync.native(resolvedCwd)
131
+ : resolvedCwd;
132
+ const expectedCwd =
133
+ runtimePlatform === "win32" ? canonicalCwd.toLowerCase() : canonicalCwd;
134
+ const actualCwd =
135
+ runtimePlatform === "win32" && identity.cwd
136
+ ? identity.cwd.toLowerCase()
137
+ : identity.cwd;
138
+ if (actualCwd && actualCwd !== expectedCwd) return undefined;
139
+ return identity;
140
+ }
141
+
142
+ export function verifyRunProcessIdentity(
143
+ pid: number,
144
+ expected: RunProcessIdentity | undefined,
145
+ runtimePlatform: NodeJS.Platform = platform(),
146
+ reader: ProcessIdentityReader = readProcessIdentity,
147
+ alive: (pid: number) => boolean = isAlive,
148
+ ): RunProcessIdentityResult {
149
+ if (!alive(pid)) return { status: "dead_pid", valid: false };
150
+ if (!expected || expected.platform !== runtimePlatform) {
151
+ return { status: "unsupported_proof", valid: false };
37
152
  }
153
+ const current = reader(pid, runtimePlatform);
154
+ if (!current) return { status: "unsupported_proof", valid: false };
155
+ const valid =
156
+ current.start_time === expected.start_time &&
157
+ current.command === expected.command &&
158
+ current.cwd === expected.cwd;
159
+ return { status: valid ? "valid" : "owner_mismatch", valid };
38
160
  }
39
161
 
40
162
  export function isWithinRunnerIdentityGrace(
@@ -3,6 +3,7 @@
3
3
  * Owns terminal-run archive and prune filesystem behavior.
4
4
  */
5
5
 
6
+ import { createHash } from "node:crypto";
6
7
  import { cpSync, mkdirSync, renameSync, rmSync } from "node:fs";
7
8
  import { basename, dirname, join } from "node:path";
8
9
 
@@ -12,6 +13,16 @@ import {
12
13
  type RunArtifactDeclaration,
13
14
  } from "./runs-artifacts.ts";
14
15
  import { safeRunId } from "./runs-identity.ts";
16
+ import { assertOwnedRunStateDirectory } from "./runs-ownership.ts";
17
+
18
+ function retainedArtifactFilename(name: string, path: string): string {
19
+ const readableName = name.replace(/[^A-Za-z0-9_.-]+/g, "_") || "artifact";
20
+ const identity = createHash("sha256")
21
+ .update(`${name}\0${path}`)
22
+ .digest("hex")
23
+ .slice(0, 8);
24
+ return `${readableName}-${identity}--${basename(path)}`;
25
+ }
15
26
 
16
27
  function archivePathFor(run: string, stateDir: string): string {
17
28
  const archiveRoot = join(dirname(stateDir), "archived");
@@ -27,6 +38,7 @@ export function archiveTerminalRun(
27
38
  ): Record<string, unknown> {
28
39
  const stateDir = String(status.state_dir);
29
40
  const run = String(status.run ?? basename(stateDir));
41
+ assertOwnedRunStateDirectory(stateDir, run);
30
42
  const archiveDir = archivePathFor(run, stateDir);
31
43
  renameSync(stateDir, archiveDir);
32
44
  mkdirSync(stateDir, { recursive: true });
@@ -45,9 +57,11 @@ export function archiveTerminalRun(
45
57
  export function pruneTerminalRun(
46
58
  status: Record<string, unknown>,
47
59
  options: { preserveArtifacts?: boolean } = {},
60
+ deps: { copyArtifact?: typeof cpSync } = {},
48
61
  ): Record<string, unknown> {
49
62
  const stateDir = String(status.state_dir);
50
63
  const run = String(status.run ?? basename(stateDir));
64
+ assertOwnedRunStateDirectory(stateDir, run);
51
65
  const manifest = resolveArtifactManifest(
52
66
  status.artifacts as Record<string, RunArtifactDeclaration> | undefined,
53
67
  );
@@ -61,8 +75,14 @@ export function pruneTerminalRun(
61
75
  mkdirSync(preserveRoot, { recursive: true });
62
76
  for (const [name, artifact] of Object.entries(manifest)) {
63
77
  if (!artifact.exists) continue;
64
- const target = join(preserveRoot, basename(artifact.path));
65
- cpSync(artifact.path, target, { force: true });
78
+ const target = join(
79
+ preserveRoot,
80
+ retainedArtifactFilename(name, artifact.path),
81
+ );
82
+ (deps.copyArtifact ?? cpSync)(artifact.path, target, {
83
+ force: true,
84
+ preserveTimestamps: true,
85
+ });
66
86
  preserved[name] = target;
67
87
  }
68
88
  }