@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.
- package/AGENTS.md +10 -3
- package/BACKLOG.md +4 -11
- package/CHANGELOG.md +51 -0
- package/README.md +5 -5
- package/dist/index.js +6 -4
- package/dist/lib/async-runs.d.ts +4 -0
- package/dist/lib/async-runs.js +112 -17
- package/dist/lib/command-templates.d.ts +9 -0
- package/dist/lib/command-templates.js +92 -11
- package/dist/lib/config.js +0 -5
- package/dist/lib/execution.d.ts +31 -0
- package/dist/lib/execution.js +145 -12
- package/dist/lib/file-state.d.ts +1 -0
- package/dist/lib/file-state.js +91 -3
- package/dist/lib/observability.js +5 -2
- package/dist/lib/prompts.d.ts +1 -2
- package/dist/lib/prompts.js +3 -4
- package/dist/lib/recipes-context.js +17 -9
- package/dist/lib/recipes-discovery.js +11 -5
- package/dist/lib/recipes-references.d.ts +1 -1
- package/dist/lib/recipes-references.js +4 -5
- package/dist/lib/recipes-usage.d.ts +2 -0
- package/dist/lib/recipes-usage.js +35 -21
- package/dist/lib/registry.d.ts +0 -2
- package/dist/lib/registry.js +33 -10
- package/dist/lib/runs-ownership.d.ts +7 -0
- package/dist/lib/runs-ownership.js +82 -0
- package/dist/lib/runs-process.d.ts +17 -2
- package/dist/lib/runs-process.js +99 -11
- package/dist/lib/runs-retention.d.ts +3 -0
- package/dist/lib/runs-retention.js +18 -3
- package/dist/lib/runs-start.d.ts +2 -2
- package/dist/lib/runs-start.js +51 -17
- package/dist/lib/runs-status.d.ts +1 -1
- package/dist/lib/runs-status.js +8 -6
- package/dist/lib/runtime.js +69 -13
- package/dist/lib/tools-inspect.d.ts +2 -0
- package/dist/lib/tools-inspect.js +39 -2
- package/dist/lib/tools-register.js +0 -1
- package/dist/lib/tools-spawn.js +3 -2
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +3 -0
- package/dist/pi-actors/index.js +1 -0
- package/dist/recipes/subagent-judge.json +2 -1
- package/dist/recipes/subagent-merge.json +2 -1
- package/dist/recipes/subagent-normalize.json +2 -1
- package/dist/recipes/subagent-review-coordinator.json +1 -1
- package/dist/recipes/subagent-review.json +2 -1
- package/dist/recipes/subagent-verify.json +2 -1
- package/dist/scripts/async-runner.mjs +274 -6
- package/dist/scripts/build-dist.mjs +14 -1
- package/dist/skills/actors/SKILL.md +15 -8
- package/dist/skills/swarm/SKILL.md +4 -2
- package/docs/actor-messages.md +1 -1
- package/docs/async-runs.md +14 -5
- package/docs/command-templates.md +4 -2
- package/docs/recipe-library.md +1 -0
- package/docs/template-recipes.md +5 -7
- package/docs/tool-registry.md +4 -2
- package/index.ts +18 -7
- package/lib/async-runs.ts +138 -19
- package/lib/command-templates.ts +132 -13
- package/lib/config.ts +0 -4
- package/lib/execution.ts +198 -13
- package/lib/file-state.ts +106 -3
- package/lib/observability.ts +8 -2
- package/lib/prompts.ts +3 -5
- package/lib/recipes-context.ts +17 -9
- package/lib/recipes-discovery.ts +10 -5
- package/lib/recipes-references.ts +5 -6
- package/lib/recipes-usage.ts +36 -20
- package/lib/registry.ts +43 -13
- package/lib/runs-ownership.ts +117 -0
- package/lib/runs-process.ts +138 -16
- package/lib/runs-retention.ts +22 -2
- package/lib/runs-start.ts +89 -31
- package/lib/runs-status.ts +15 -6
- package/lib/runtime.ts +64 -12
- package/lib/tools-inspect.ts +46 -4
- package/lib/tools-register.ts +0 -3
- package/lib/tools-spawn.ts +5 -5
- package/lib/tools.ts +8 -0
- package/package.json +2 -2
- package/recipes/subagent-judge.json +2 -1
- package/recipes/subagent-merge.json +2 -1
- package/recipes/subagent-normalize.json +2 -1
- package/recipes/subagent-review-coordinator.json +1 -1
- package/recipes/subagent-review.json +2 -1
- package/recipes/subagent-verify.json +2 -1
- package/scripts/async-runner.mjs +274 -6
- package/scripts/build-dist.mjs +14 -1
- package/skills/actors/SKILL.md +15 -8
- package/skills/swarm/SKILL.md +4 -2
package/dist/lib/prompts.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
+
function findPiPrintPromptIndexes(args) {
|
|
51
51
|
let printMode = false;
|
|
52
52
|
let positionalOnly = false;
|
|
53
|
-
|
|
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
|
-
|
|
71
|
+
promptIndexes.push(index);
|
|
72
72
|
}
|
|
73
|
-
return
|
|
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
|
|
138
|
-
if (
|
|
140
|
+
const promptIndexes = findPiPrintPromptIndexes(args);
|
|
141
|
+
if (promptIndexes.length === 0)
|
|
139
142
|
return { args };
|
|
140
|
-
const prompt = args[
|
|
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
|
|
144
|
-
|
|
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(
|
|
246
|
-
const
|
|
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
|
|
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
|
|
585
|
-
? { usage: recipeUsage(entry
|
|
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 {
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
usage.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
76
|
+
});
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
69
83
|
}
|
package/dist/lib/registry.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/registry.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {};
|
package/dist/lib/runs-process.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Async run process identity helpers.
|
|
3
|
-
* Owns liveness
|
|
3
|
+
* Owns cross-platform liveness and stable runner process identity proofs.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
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
|
-
|
|
18
|
-
if (
|
|
19
|
-
return
|
|
18
|
+
function readLinuxIdentity(pid) {
|
|
19
|
+
if (!existsSync(`/proc/${pid}`))
|
|
20
|
+
return undefined;
|
|
20
21
|
try {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
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>;
|