@llblab/pi-actors 0.48.0 → 0.49.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.
- package/AGENTS.md +1 -1
- package/BACKLOG.md +4 -1
- package/CHANGELOG.md +16 -0
- package/README.md +4 -4
- package/dist/lib/async-runs.d.ts +7 -0
- package/dist/lib/async-runs.js +63 -8
- package/dist/lib/extension-runtime.js +3 -0
- package/dist/lib/file-state.js +15 -1
- package/dist/lib/inspector.js +4 -0
- package/dist/lib/observability.js +2 -5
- package/dist/lib/recipes-discovery.js +3 -1
- package/dist/lib/recipes-references.d.ts +3 -0
- package/dist/lib/recipes-references.js +15 -0
- package/dist/lib/registry.d.ts +2 -0
- package/dist/lib/registry.js +8 -4
- package/dist/lib/runs-start.d.ts +2 -1
- package/dist/lib/runs-start.js +11 -5
- package/dist/lib/tools-local.js +12 -8
- package/dist/lib/tools.d.ts +1 -0
- package/dist/lib/tools.js +1 -0
- package/dist/skills/actors/SKILL.md +3 -3
- package/dist/skills/actors/references/persistent-tools.md +3 -1
- package/dist/skills/actors/references/recipes.md +2 -2
- package/dist/skills/actors/references/runs.md +2 -0
- package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/dist/skills/music-player/SKILL.md +53 -0
- package/dist/skills/music-player/genapps/music-player.mjs +360 -0
- package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
- package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
- package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/docs/async-runs.md +1 -1
- package/docs/recipe-library.md +2 -5
- package/docs/template-recipes.md +7 -0
- package/docs/tool-registry.md +2 -2
- package/lib/async-runs.ts +92 -15
- package/lib/extension-runtime.ts +4 -0
- package/lib/file-state.ts +14 -1
- package/lib/inspector.ts +4 -0
- package/lib/observability.ts +2 -5
- package/lib/recipes-discovery.ts +2 -1
- package/lib/recipes-references.ts +20 -0
- package/lib/registry.ts +8 -6
- package/lib/runs-start.ts +16 -6
- package/lib/tools-local.ts +10 -8
- package/lib/tools.ts +2 -0
- package/package.json +1 -1
- package/skills/actors/SKILL.md +3 -3
- package/skills/actors/references/persistent-tools.md +3 -1
- package/skills/actors/references/recipes.md +2 -2
- package/skills/actors/references/runs.md +2 -0
- package/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/skills/music-player/SKILL.md +53 -0
- package/skills/music-player/genapps/music-player.mjs +360 -0
- package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
- package/skills/music-player/scripts/playback-client.mjs +143 -0
- package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/dist/skills/media/SKILL.md +0 -44
- package/dist/skills/media/recipes/library.json +0 -45
- package/dist/skills/media/recipes/playlist-build.json +0 -15
- package/dist/skills/media/recipes/playlist-scan.json +0 -11
- package/dist/skills/media/scripts/media-utils.mjs +0 -47
- package/skills/media/SKILL.md +0 -44
- package/skills/media/recipes/library.json +0 -45
- package/skills/media/recipes/playlist-build.json +0 -15
- package/skills/media/recipes/playlist-scan.json +0 -11
- package/skills/media/scripts/media-utils.mjs +0 -47
package/docs/async-runs.md
CHANGED
|
@@ -123,7 +123,7 @@ Archive and prune apply only to terminal Runs and enforce path containment. Rete
|
|
|
123
123
|
|
|
124
124
|
Packaged controlled services demonstrate the endpoint protocol:
|
|
125
125
|
|
|
126
|
-
- `
|
|
126
|
+
- `music-player/playback` consumes playback Controls and emits playback Trace;
|
|
127
127
|
- `actors/resource-locker` consumes queue/lease actions, emits lock Trace, and atomically retains at most 512 valid journal records within 1 MiB.
|
|
128
128
|
|
|
129
129
|
Shared archive/prune evidence similarly retains at most 256 valid records within 1 MiB under its canonical lock. The obsolete advisory `wake.jsonl` notifier was removed; filesystem watchers and bounded reconciliation observe authoritative state directly.
|
package/docs/recipe-library.md
CHANGED
|
@@ -34,12 +34,9 @@ Callers should own model, thinking, concurrency, quorum, and mission policy. Rev
|
|
|
34
34
|
|
|
35
35
|
Artifact pipelines terminate in files/manifests and result evidence; they do not fabricate communication events.
|
|
36
36
|
|
|
37
|
-
###
|
|
37
|
+
### Music playback and controlled services
|
|
38
38
|
|
|
39
|
-
- `
|
|
40
|
-
- `media/playlist-build` — extension-filtered `paths`, `m3u`, or `inline` playlist output; it does not create a playlist file.
|
|
41
|
-
- `media/library` — filtered playlist plus bounded library-report content; `artifact_path` alone is not durable-write proof.
|
|
42
|
-
- `media/player` — playback service with declared playback actions, `controls.jsonl`, generation-fenced endpoint readiness, state artifact, and playback Trace. Player selection is `player:enum(auto,mpv,afplay,ffplay,cvlc,play,wmp)=auto`.
|
|
39
|
+
- `music-player/playback` — singleton playback service that resolves files, directories, URLs, explicit lists, and playlist files into one persistent queue; it exposes declared playback Controls including arbitrary absolute `volume` percentages, generation-fenced endpoint readiness, structured status, a player-owned continuity checkpoint, and playback Trace. Player selection is `player:enum(auto,mpv,afplay,ffplay,cvlc,play,wmp)=auto`.
|
|
43
40
|
- `actors/resource-locker` — optional queue/lease-lock service with explicit owner/resource input, lock Trace, and a 512-record/1 MiB atomically retained journal.
|
|
44
41
|
|
|
45
42
|
These Recipes declare actor-local Control. Ordinary one-shot Recipes omit it. Helper-backed Skill Recipes self-locate through runtime-owned `{skill_dir}`; callers do not pass package installation roots.
|
package/docs/template-recipes.md
CHANGED
|
@@ -50,6 +50,7 @@ Common Recipe fields:
|
|
|
50
50
|
- `imports` with optional binding defaults/values;
|
|
51
51
|
- `template`;
|
|
52
52
|
- `async`;
|
|
53
|
+
- `singleton: true` for one explicit Skill-owned async service slot;
|
|
53
54
|
- `artifacts`;
|
|
54
55
|
- `control` for actual controlled services;
|
|
55
56
|
- command-template flags such as `parallel`, `concurrency`, `min_successful`, `when`, `timeout`, `delay`, `retry`, `failure`, `recover`, `repeat`, `accept_output`, and `output`;
|
|
@@ -93,6 +94,12 @@ nested skill path -> flatten filename or use explicit file path
|
|
|
93
94
|
|
|
94
95
|
Removed `std:` and `skill:` forms fail with migration guidance; they are not aliases. Root packaged Recipes no longer exist. Flatten a maintained Skill component to a direct filename, or reference a nested/local file explicitly when it is intentionally outside the Skill component namespace.
|
|
95
96
|
|
|
97
|
+
## Singleton Services
|
|
98
|
+
|
|
99
|
+
`singleton: true` is valid only for an async Skill Recipe, and one Skill may declare at most one singleton Recipe. The runtime derives `run:<skill>` and the canonical `<skill>/<recipe>` identity, then rejects a conflicting caller-supplied Run id. A compatible repeated launch returns the healthy active generation; contradictory Recipe identity, ownership, startup values, or Control fails closed. A terminal result is never reused even during runner exit; retry after that process exits starts a fresh `run_instance_id` under the same logical Run id. Actor-owned workload continuity requires a validated state artifact that restart cleanup deliberately preserves; singleton identity alone never proves restored state.
|
|
100
|
+
|
|
101
|
+
Direct Recipe delegation inherits the original singleton Run and Recipe identities, so registered tools and explicit wrappers cannot retarget the service or create parallel aliases.
|
|
102
|
+
|
|
96
103
|
## Control
|
|
97
104
|
|
|
98
105
|
Only a process that consumes actor-local input declares actions:
|
package/docs/tool-registry.md
CHANGED
|
@@ -19,7 +19,7 @@ register_tool name=repo_check template="make check" description="Run repository
|
|
|
19
19
|
Specialize a maintained Recipe without copying its contract:
|
|
20
20
|
|
|
21
21
|
```text
|
|
22
|
-
register_tool name=music_player from=
|
|
22
|
+
register_tool name=music_player from=music-player/playback defaults={"source":"~/Music/1MIX"}
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
`from`, `template`, and `draft` are distinct source modes. `from` accepts exact `<skill>/<recipe>` identity or an explicit `.json` / `.md` path and inherits async behavior, args/types, source defaults, artifacts, Control, and runtime-owned origins. `defaults` may set only effective caller-owned args and must satisfy their types. `template` is only for trusted command definitions; public `values` authoring has been removed in favor of caller defaults or an authored Recipe file.
|
|
@@ -73,7 +73,7 @@ Usage and lineage live in locked metadata ledgers rather than authored Recipe fi
|
|
|
73
73
|
|
|
74
74
|
## Specializing Existing Recipes
|
|
75
75
|
|
|
76
|
-
Use `register_tool from=<skill>/<recipe> defaults={...}` for one maintained capability under a persistent name or narrower defaults.
|
|
76
|
+
Use `register_tool from=<skill>/<recipe> defaults={...}` for one maintained capability under a persistent name or narrower defaults. Pi supplies one authoritative active-session Skill snapshot across global, project, package, settings, CLI, and extension-contributed locations. Registration resolves the source from that snapshot, stores only the logical direct-delegation reference plus caller specialization, and activates the tool from the already-resolved effective contract without ambient re-resolution. It does not copy async, args/types, Control, artifacts, helpers, or runtime-owned `{recipe_dir}`/`{skill_dir}`; symlinks and absolute helper paths are not substitutes. Named imports remain for multi-node Recipe composition, not one-source specialization. Skill Recipes remain components and are never exposed merely because their Skill is active. Install only specific capabilities; internal automatic-review Recipes must not become user-callable tools.
|
|
77
77
|
|
|
78
78
|
## Safety
|
|
79
79
|
|
package/lib/async-runs.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
} from "node:fs";
|
|
19
19
|
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
20
20
|
import { fileURLToPath } from "node:url";
|
|
21
|
+
import { isDeepStrictEqual } from "node:util";
|
|
21
22
|
|
|
22
23
|
import type {
|
|
23
24
|
CommandTemplateFailureScope,
|
|
@@ -124,6 +125,9 @@ export interface AsyncRunStartParams {
|
|
|
124
125
|
name?: string;
|
|
125
126
|
ownerId?: string;
|
|
126
127
|
run_id?: string;
|
|
128
|
+
singleton?: boolean;
|
|
129
|
+
singleton_run_id?: string;
|
|
130
|
+
singleton_recipe_id?: string;
|
|
127
131
|
state_dir?: string;
|
|
128
132
|
tool?: string;
|
|
129
133
|
template?: CommandTemplateValue;
|
|
@@ -187,6 +191,10 @@ export interface AsyncRunMeta {
|
|
|
187
191
|
process_identity?: RunProcessIdentity;
|
|
188
192
|
recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
|
|
189
193
|
retire_when?: "children_terminal";
|
|
194
|
+
reused?: boolean;
|
|
195
|
+
singleton?: boolean;
|
|
196
|
+
singleton_recipe_id?: string;
|
|
197
|
+
singleton_values?: Record<string, unknown>;
|
|
190
198
|
transport_context?: Record<string, unknown>;
|
|
191
199
|
}
|
|
192
200
|
|
|
@@ -260,6 +268,43 @@ function assertNoActiveRunState(stateDir: string): void {
|
|
|
260
268
|
RunsStart.assertNoActiveRunState(stateDir, readJson, RUNNER_PATH);
|
|
261
269
|
}
|
|
262
270
|
|
|
271
|
+
function reuseCompatibleSingletonRun(
|
|
272
|
+
stateDir: string,
|
|
273
|
+
startParams: AsyncRunStartParams,
|
|
274
|
+
singletonValues: Record<string, unknown>,
|
|
275
|
+
): AsyncRunMeta | undefined {
|
|
276
|
+
const existing = RunsStart.readActiveOwnedRunState(
|
|
277
|
+
stateDir,
|
|
278
|
+
readJson,
|
|
279
|
+
RUNNER_PATH,
|
|
280
|
+
);
|
|
281
|
+
if (!existing) return undefined;
|
|
282
|
+
const compatible =
|
|
283
|
+
existing.singleton === true &&
|
|
284
|
+
existing.singleton_recipe_id === startParams.singleton_recipe_id &&
|
|
285
|
+
existing.ownerId === startParams.ownerId &&
|
|
286
|
+
isDeepStrictEqual(existing.singleton_values ?? {}, singletonValues) &&
|
|
287
|
+
isDeepStrictEqual(existing.control ?? [], startParams.control ?? []);
|
|
288
|
+
if (!compatible) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`Active singleton Run ${String(existing.run ?? stateDir)} has incompatible Recipe identity, owner, startup values, or Control contract. Stop it before changing singleton configuration.`,
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return {
|
|
294
|
+
...(existing as unknown as AsyncRunMeta),
|
|
295
|
+
...(startParams.launch_source
|
|
296
|
+
? {
|
|
297
|
+
launch_kind: startParams.launch_source,
|
|
298
|
+
launch_source: startParams.launch_source,
|
|
299
|
+
}
|
|
300
|
+
: {}),
|
|
301
|
+
...(startParams.launch_correlation
|
|
302
|
+
? { launch_correlation: startParams.launch_correlation }
|
|
303
|
+
: {}),
|
|
304
|
+
reused: true,
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
263
308
|
export interface AsyncRunStartOptions {
|
|
264
309
|
skillContext?: RecipesReferences.ActiveSkillRecipeContext;
|
|
265
310
|
}
|
|
@@ -326,10 +371,25 @@ function resolveStartParams(
|
|
|
326
371
|
): AsyncRunStartParams {
|
|
327
372
|
if (!params.file) return params;
|
|
328
373
|
const fileParams = readRecipeFile(params.file, cwd, options);
|
|
374
|
+
const singleton = fileParams.singleton === true;
|
|
375
|
+
if (singleton && fileParams.async !== true) {
|
|
376
|
+
throw new Error("singleton Recipes must declare async: true");
|
|
377
|
+
}
|
|
378
|
+
if (singleton && (!fileParams.singleton_run_id || !fileParams.singleton_recipe_id)) {
|
|
379
|
+
throw new Error("singleton Recipes must resolve one active Skill-owned singleton identity");
|
|
380
|
+
}
|
|
381
|
+
const singletonRun = singleton ? fileParams.singleton_run_id : undefined;
|
|
382
|
+
if (singletonRun && params.run_id && params.run_id !== singletonRun) {
|
|
383
|
+
throw new Error(
|
|
384
|
+
`singleton Recipe run identity is run:${singletonRun}; received run:${params.run_id}`,
|
|
385
|
+
);
|
|
386
|
+
}
|
|
329
387
|
return {
|
|
330
388
|
...fileParams,
|
|
331
389
|
...params,
|
|
390
|
+
...(singleton ? { singleton: true } : {}),
|
|
332
391
|
run_id:
|
|
392
|
+
singletonRun ||
|
|
333
393
|
params.run_id ||
|
|
334
394
|
fileParams.run_id ||
|
|
335
395
|
fileParams.name ||
|
|
@@ -540,14 +600,37 @@ export function startRun(
|
|
|
540
600
|
values,
|
|
541
601
|
modelPolicy,
|
|
542
602
|
);
|
|
543
|
-
|
|
603
|
+
const singletonValues = Object.fromEntries(
|
|
604
|
+
[...declaredArgs].map((key) => [key, values[key]]),
|
|
605
|
+
);
|
|
606
|
+
if (startParams.singleton !== true) assertNoActiveRunState(stateDir);
|
|
544
607
|
mkdirSync(stateDir, { recursive: true });
|
|
545
608
|
const releaseStartLock = acquireStateStartLock(stateDir, {
|
|
546
609
|
onContention: startParams.lifecycleHooks?.onLockContention,
|
|
547
610
|
});
|
|
548
611
|
try {
|
|
549
612
|
claimRunStateDirectory(stateDir, run);
|
|
550
|
-
|
|
613
|
+
if (
|
|
614
|
+
recipeFile &&
|
|
615
|
+
isMutableUsageRecipeFile(recipeFile) &&
|
|
616
|
+
!RecipesUsage.recordRecipeLaunch(
|
|
617
|
+
recipeFile,
|
|
618
|
+
new Date(),
|
|
619
|
+
startParams.launch_source === "tool" ? "tool" : "spawn",
|
|
620
|
+
)
|
|
621
|
+
) {
|
|
622
|
+
throw new Error(
|
|
623
|
+
`Recipe launch rejected because its source changed during activation: ${recipeFile}. Reload recipe tools and retry.`,
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
if (startParams.singleton === true) {
|
|
627
|
+
const existing = reuseCompatibleSingletonRun(
|
|
628
|
+
stateDir,
|
|
629
|
+
startParams,
|
|
630
|
+
singletonValues,
|
|
631
|
+
);
|
|
632
|
+
if (existing) return existing;
|
|
633
|
+
} else assertNoActiveRunState(stateDir);
|
|
551
634
|
prepareStateDirForStart(stateDir);
|
|
552
635
|
const stdout = join(stateDir, "stdout.log");
|
|
553
636
|
const stderr = join(stateDir, "stderr.log");
|
|
@@ -572,19 +655,6 @@ export function startRun(
|
|
|
572
655
|
: record,
|
|
573
656
|
)
|
|
574
657
|
: undefined;
|
|
575
|
-
if (
|
|
576
|
-
recipeFile &&
|
|
577
|
-
isMutableUsageRecipeFile(recipeFile) &&
|
|
578
|
-
!RecipesUsage.recordRecipeLaunch(
|
|
579
|
-
recipeFile,
|
|
580
|
-
new Date(),
|
|
581
|
-
startParams.launch_source === "tool" ? "tool" : "spawn",
|
|
582
|
-
)
|
|
583
|
-
) {
|
|
584
|
-
throw new Error(
|
|
585
|
-
`Recipe launch rejected because its source changed during activation: ${recipeFile}. Reload recipe tools and retry.`,
|
|
586
|
-
);
|
|
587
|
-
}
|
|
588
658
|
const outFd = openSync(stdout, "a");
|
|
589
659
|
const errFd = openSync(stderr, "a");
|
|
590
660
|
const argv = asyncRunnerArgv(stateDir);
|
|
@@ -635,6 +705,13 @@ export function startRun(
|
|
|
635
705
|
...(startParams.retire_when === "children_terminal"
|
|
636
706
|
? { retire_when: "children_terminal" as const }
|
|
637
707
|
: {}),
|
|
708
|
+
...(startParams.singleton === true
|
|
709
|
+
? {
|
|
710
|
+
singleton: true,
|
|
711
|
+
singleton_recipe_id: startParams.singleton_recipe_id,
|
|
712
|
+
singleton_values: singletonValues,
|
|
713
|
+
}
|
|
714
|
+
: {}),
|
|
638
715
|
...(transportContext
|
|
639
716
|
? { transport_context: transportContext } : {}),
|
|
640
717
|
};
|
package/lib/extension-runtime.ts
CHANGED
|
@@ -160,6 +160,10 @@ export function createActorExtensionRuntime(
|
|
|
160
160
|
Tools.createCoreActorToolDefinitions<Pi.ExtensionContext>({
|
|
161
161
|
configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
|
|
162
162
|
getActiveTools: () => pi.getActiveTools(),
|
|
163
|
+
getRecipeResolutionContext: () =>
|
|
164
|
+
activeRunContext
|
|
165
|
+
? getRecipeResolutionContext(activeRunContext)
|
|
166
|
+
: undefined,
|
|
163
167
|
getRuntimeTool: (name) =>
|
|
164
168
|
Tools.resolveActiveRuntimeTool(
|
|
165
169
|
name,
|
package/lib/file-state.ts
CHANGED
|
@@ -267,6 +267,19 @@ export function withFileMutationLock<T>(
|
|
|
267
267
|
}
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
function replaceFileWithRetry(source: string, target: string): void {
|
|
271
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
272
|
+
try {
|
|
273
|
+
renameSync(source, target);
|
|
274
|
+
return;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
277
|
+
if ((code !== "EPERM" && code !== "EBUSY") || attempt >= 19) throw error;
|
|
278
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
270
283
|
export function writeTextAtomic(
|
|
271
284
|
path: string,
|
|
272
285
|
content: string,
|
|
@@ -277,7 +290,7 @@ export function writeTextAtomic(
|
|
|
277
290
|
try {
|
|
278
291
|
writeFileSync(tempPath, content, "utf8");
|
|
279
292
|
options.onBeforeReplace?.();
|
|
280
|
-
|
|
293
|
+
replaceFileWithRetry(tempPath, path);
|
|
281
294
|
} catch (error) {
|
|
282
295
|
try {
|
|
283
296
|
unlinkSync(tempPath);
|
package/lib/inspector.ts
CHANGED
|
@@ -185,6 +185,9 @@ export function readActorInspectorRecipe(
|
|
|
185
185
|
source_kind: primarySourceKind,
|
|
186
186
|
launch_kind: meta.launch_kind ?? meta.launch_source,
|
|
187
187
|
launch_source: meta.launch_source,
|
|
188
|
+
...(meta.singleton === true
|
|
189
|
+
? { singleton: true, singleton_recipe_id: meta.singleton_recipe_id }
|
|
190
|
+
: {}),
|
|
188
191
|
}),
|
|
189
192
|
launch: redactedRecord({
|
|
190
193
|
cwd: meta.cwd,
|
|
@@ -195,6 +198,7 @@ export function readActorInspectorRecipe(
|
|
|
195
198
|
artifacts: meta.artifacts,
|
|
196
199
|
notification_policy: meta.notification_policy,
|
|
197
200
|
retire_when: meta.retire_when,
|
|
201
|
+
singleton_values: meta.singleton_values,
|
|
198
202
|
}),
|
|
199
203
|
};
|
|
200
204
|
}
|
package/lib/observability.ts
CHANGED
|
@@ -1143,17 +1143,14 @@ export function pruneRunObservationState(
|
|
|
1143
1143
|
): void {
|
|
1144
1144
|
const activeRuns = new Set(summary.runs.map((run) => runObservationKey(run)));
|
|
1145
1145
|
const terminalRunSet = new Set(terminalRuns);
|
|
1146
|
-
const terminalLineKeys = new Set(summary.runs
|
|
1147
|
-
.filter((run) => terminalRunSet.has(runObservationKey(run)))
|
|
1148
|
-
.map((run) => runObservationKey(run)));
|
|
1149
1146
|
const activeLineKeys = new Set(summary.runs.map((run) => run.stateDir ?? run.run));
|
|
1150
1147
|
for (const run of terminalRunSet) previousStatuses.delete(run);
|
|
1151
1148
|
for (const run of previousStatuses.keys())
|
|
1152
1149
|
if (!activeRuns.has(run)) previousStatuses.delete(run);
|
|
1153
1150
|
for (const key of previousLineCounts.keys())
|
|
1154
|
-
if (
|
|
1151
|
+
if (!activeLineKeys.has(key)) previousLineCounts.delete(key);
|
|
1155
1152
|
for (const key of seenEventIds.keys())
|
|
1156
|
-
if (
|
|
1153
|
+
if (!activeLineKeys.has(key)) seenEventIds.delete(key);
|
|
1157
1154
|
}
|
|
1158
1155
|
|
|
1159
1156
|
export function detectRunAttentionEvents(
|
package/lib/recipes-discovery.ts
CHANGED
|
@@ -957,7 +957,8 @@ export function summarizeRegisteredToolArgs(tool: RegisteredTool): {
|
|
|
957
957
|
const requiredSet = new Set(required);
|
|
958
958
|
const optional = publicArgs.filter((arg) => !requiredSet.has(arg));
|
|
959
959
|
if (tool.recipe?.async === true) {
|
|
960
|
-
optional.push("run_id"
|
|
960
|
+
if (tool.recipe.singleton !== true) optional.push("run_id");
|
|
961
|
+
optional.push("transport_context");
|
|
961
962
|
}
|
|
962
963
|
return { optional, required };
|
|
963
964
|
}
|
|
@@ -30,6 +30,7 @@ export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
|
|
|
30
30
|
export interface TemplateRecipeDefinition {
|
|
31
31
|
description?: string;
|
|
32
32
|
disabled?: boolean;
|
|
33
|
+
singleton?: boolean;
|
|
33
34
|
imports?: Record<string, TemplateRecipeImport>;
|
|
34
35
|
template: CommandTemplateValue;
|
|
35
36
|
args?: string[];
|
|
@@ -59,6 +60,8 @@ export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
|
|
|
59
60
|
async?: boolean;
|
|
60
61
|
recipe_dir?: string;
|
|
61
62
|
skill_dir?: string;
|
|
63
|
+
singleton_run_id?: string;
|
|
64
|
+
singleton_recipe_id?: string;
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
interface ImportedRecipe {
|
|
@@ -1416,6 +1419,23 @@ export function readResolvedRecipeConfig(
|
|
|
1416
1419
|
: delegated?.async === false
|
|
1417
1420
|
? { async: false }
|
|
1418
1421
|
: {}),
|
|
1422
|
+
...(substituted.singleton === true || delegated?.singleton === true
|
|
1423
|
+
? {
|
|
1424
|
+
singleton: true,
|
|
1425
|
+
singleton_run_id:
|
|
1426
|
+
delegated?.singleton === true
|
|
1427
|
+
? delegated.singleton_run_id
|
|
1428
|
+
: skillDir
|
|
1429
|
+
? basename(skillDir)
|
|
1430
|
+
: undefined,
|
|
1431
|
+
singleton_recipe_id:
|
|
1432
|
+
delegated?.singleton === true
|
|
1433
|
+
? delegated.singleton_recipe_id
|
|
1434
|
+
: skillDir
|
|
1435
|
+
? `${basename(skillDir)}/${recipeName}`
|
|
1436
|
+
: undefined,
|
|
1437
|
+
}
|
|
1438
|
+
: {}),
|
|
1419
1439
|
...(Object.keys(imports).length > 0
|
|
1420
1440
|
? { imports: getRecipeImports(raw) }
|
|
1421
1441
|
: {}),
|
package/lib/registry.ts
CHANGED
|
@@ -73,6 +73,7 @@ export interface RegisterToolRuntimeDeps<TContext> {
|
|
|
73
73
|
recipeRoot?: string;
|
|
74
74
|
getToolNameBlocker: (name: string) => string | undefined;
|
|
75
75
|
getTools: () => Map<string, Config.RegisteredTool>;
|
|
76
|
+
getRecipeResolutionContext?: () => RecipesContext.RecipeResolutionContext | undefined;
|
|
76
77
|
getActiveTools: () => string[];
|
|
77
78
|
notify: (
|
|
78
79
|
ctx: TContext,
|
|
@@ -422,11 +423,9 @@ function getRegistrationResolutionContext<TContext>(
|
|
|
422
423
|
ctx: TContext,
|
|
423
424
|
deps: RegisterToolRuntimeDeps<TContext>,
|
|
424
425
|
): RecipesContext.RecipeResolutionContext {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
"recipeResolutionContext" in ctx
|
|
429
|
-
) {
|
|
426
|
+
const runtimeContext = deps.getRecipeResolutionContext?.();
|
|
427
|
+
if (runtimeContext) return runtimeContext;
|
|
428
|
+
if (ctx && typeof ctx === "object" && "recipeResolutionContext" in ctx) {
|
|
430
429
|
const resolutionContext = (ctx as {
|
|
431
430
|
recipeResolutionContext?: RecipesContext.RecipeResolutionContext;
|
|
432
431
|
}).recipeResolutionContext;
|
|
@@ -697,9 +696,11 @@ function executeRegisterToolUnlocked<TContext>(
|
|
|
697
696
|
let persisted = false;
|
|
698
697
|
let activation: RuntimeActivation | undefined;
|
|
699
698
|
let cfg: Config.RegisteredTool;
|
|
699
|
+
let transactionStage = "persist";
|
|
700
700
|
try {
|
|
701
701
|
persistToolRecipe(deps, name, authoredRecipe);
|
|
702
702
|
persisted = true;
|
|
703
|
+
transactionStage = "persisted_admission";
|
|
703
704
|
const admitted = RecipesDiscovery.admitUserRecipe(
|
|
704
705
|
recipePath,
|
|
705
706
|
resolutionContext,
|
|
@@ -711,6 +712,7 @@ function executeRegisterToolUnlocked<TContext>(
|
|
|
711
712
|
}
|
|
712
713
|
cfg = admitted.tool;
|
|
713
714
|
tools.set(name, cfg);
|
|
715
|
+
transactionStage = "runtime_activation";
|
|
714
716
|
activation = deps.registerRuntimeTool(cfg) ?? undefined;
|
|
715
717
|
if (
|
|
716
718
|
activation &&
|
|
@@ -737,7 +739,7 @@ function executeRegisterToolUnlocked<TContext>(
|
|
|
737
739
|
deps.setActiveTools(activeBefore);
|
|
738
740
|
throw new Error(
|
|
739
741
|
ExecutionOutput.formatToolText(
|
|
740
|
-
`Tool registration transaction failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
742
|
+
`Tool registration transaction failed at ${transactionStage} (${resolutionContext.generation}; active Skills: ${activeSkillSummary(resolutionContext)}): ${error instanceof Error ? error.message : String(error)}`,
|
|
741
743
|
),
|
|
742
744
|
);
|
|
743
745
|
}
|
package/lib/runs-start.ts
CHANGED
|
@@ -19,16 +19,17 @@ import {
|
|
|
19
19
|
|
|
20
20
|
type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
|
|
21
21
|
|
|
22
|
-
export function
|
|
22
|
+
export function readActiveOwnedRunState(
|
|
23
23
|
stateDir: string,
|
|
24
24
|
readJson: RunJsonReader,
|
|
25
25
|
_runnerPath: string,
|
|
26
|
-
):
|
|
26
|
+
): Record<string, unknown> | undefined {
|
|
27
27
|
const meta = readJson(join(stateDir, "run.json"));
|
|
28
|
-
if (!meta) return;
|
|
28
|
+
if (!meta) return undefined;
|
|
29
|
+
const result = readJson(join(stateDir, "result.json"));
|
|
30
|
+
if (typeof result?.completedAt === "string") return undefined;
|
|
29
31
|
const pid = Number(meta.pid || 0);
|
|
30
|
-
|
|
31
|
-
if (!pid || !isAlive(pid)) return;
|
|
32
|
+
if (!pid || !isAlive(pid)) return undefined;
|
|
32
33
|
const identity = verifyRunProcessIdentity(
|
|
33
34
|
pid,
|
|
34
35
|
meta.process_identity as RunProcessIdentity | undefined,
|
|
@@ -43,7 +44,16 @@ export function assertNoActiveRunState(
|
|
|
43
44
|
`Run state process identity proof is unavailable: ${String(meta.run ?? stateDir)}. Refusing to reuse the state directory while pid ${pid} is alive.`,
|
|
44
45
|
);
|
|
45
46
|
}
|
|
46
|
-
|
|
47
|
+
return identity.valid ? meta : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function assertNoActiveRunState(
|
|
51
|
+
stateDir: string,
|
|
52
|
+
readJson: RunJsonReader,
|
|
53
|
+
runnerPath: string,
|
|
54
|
+
): void {
|
|
55
|
+
const meta = readActiveOwnedRunState(stateDir, readJson, runnerPath);
|
|
56
|
+
if (meta) {
|
|
47
57
|
throw new Error(
|
|
48
58
|
`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`,
|
|
49
59
|
);
|
package/lib/tools-local.ts
CHANGED
|
@@ -147,9 +147,10 @@ export function createRuntimeToolDefinition(
|
|
|
147
147
|
const paramSchema: Record<string, JsonSchema> = {};
|
|
148
148
|
const required: string[] = [];
|
|
149
149
|
const isRecipe = RecipesReferences.isRecipeTool(cfg.template, cfg.recipe);
|
|
150
|
-
const isAsyncRecipe =
|
|
151
|
-
cfg.recipe
|
|
152
|
-
RecipesReferences.isAsyncRecipeReference(cfg.template);
|
|
150
|
+
const isAsyncRecipe = cfg.recipe
|
|
151
|
+
? cfg.recipe.async === true
|
|
152
|
+
: RecipesReferences.isAsyncRecipeReference(cfg.template);
|
|
153
|
+
const isSingletonRecipe = cfg.recipe?.singleton === true;
|
|
153
154
|
const recipeTemplate =
|
|
154
155
|
cfg.recipe?.template ?? RecipesReferences.getRecipeTemplate(cfg.template);
|
|
155
156
|
const requiredTemplate = recipeTemplate ?? cfg.template!;
|
|
@@ -177,7 +178,7 @@ export function createRuntimeToolDefinition(
|
|
|
177
178
|
!Object.hasOwn(recipeInlineDefaults, arg),
|
|
178
179
|
),
|
|
179
180
|
)
|
|
180
|
-
: RecipesReferences.isRecipeReference(cfg.template) && !recipeTemplate
|
|
181
|
+
: !cfg.recipe && RecipesReferences.isRecipeReference(cfg.template) && !recipeTemplate
|
|
181
182
|
? new Set(cfg.args.filter((arg) => !Object.hasOwn(cfg.defaults, arg)))
|
|
182
183
|
: Schema.getRequiredToolArgNames(requiredTemplateConfig);
|
|
183
184
|
for (const arg of cfg.args) {
|
|
@@ -185,7 +186,7 @@ export function createRuntimeToolDefinition(
|
|
|
185
186
|
paramSchema[arg] = typedArgSchema(arg, cfg.argTypes?.[arg]);
|
|
186
187
|
if (requiredArgs.has(arg)) required.push(arg);
|
|
187
188
|
}
|
|
188
|
-
if (isAsyncRecipe)
|
|
189
|
+
if (isAsyncRecipe && !isSingletonRecipe)
|
|
189
190
|
paramSchema.run_id = Schema.stringSchema(
|
|
190
191
|
"Optional run id override for this async template recipe invocation.",
|
|
191
192
|
);
|
|
@@ -225,8 +226,9 @@ export function createRuntimeToolDefinition(
|
|
|
225
226
|
const input = params as Record<string, unknown>;
|
|
226
227
|
const { run_id, transport_context, ...values } = input;
|
|
227
228
|
const base = cfg.recipe ? cfg.recipe : { file: String(cfg.template) };
|
|
228
|
-
const runId =
|
|
229
|
-
|
|
229
|
+
const runId = isSingletonRecipe
|
|
230
|
+
? undefined
|
|
231
|
+
: typeof run_id === "string" && run_id.trim()
|
|
230
232
|
? run_id.trim()
|
|
231
233
|
: `${cfg.name}-${Date.now()}`;
|
|
232
234
|
const meta = AsyncRuns.startRun(
|
|
@@ -293,7 +295,7 @@ export function createRuntimeToolDefinition(
|
|
|
293
295
|
cfg,
|
|
294
296
|
error,
|
|
295
297
|
required,
|
|
296
|
-
isAsyncRecipe,
|
|
298
|
+
isAsyncRecipe && !isSingletonRecipe,
|
|
297
299
|
);
|
|
298
300
|
}
|
|
299
301
|
},
|
package/lib/tools.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface CoreActorToolDefinitionDeps<
|
|
|
24
24
|
> {
|
|
25
25
|
configPath: string;
|
|
26
26
|
getActiveTools: () => string[];
|
|
27
|
+
getRecipeResolutionContext: () => import("./recipes-context.ts").RecipeResolutionContext | undefined;
|
|
27
28
|
getRuntimeTool: (name: string) => unknown;
|
|
28
29
|
getRuntimeToolStatus: (name: string) => Record<string, unknown> | undefined;
|
|
29
30
|
handleRuntimeControl?: (
|
|
@@ -68,6 +69,7 @@ export function createCoreActorToolDefinitions<
|
|
|
68
69
|
getActiveTools: deps.getActiveTools,
|
|
69
70
|
getToolNameBlocker: deps.registryRuntime.getToolNameBlocker,
|
|
70
71
|
getTools: deps.registryRuntime.getTools,
|
|
72
|
+
getRecipeResolutionContext: deps.getRecipeResolutionContext,
|
|
71
73
|
notify: deps.registryRuntime.notify,
|
|
72
74
|
registerRuntimeTool: deps.registryRuntime.registerRuntimeTool,
|
|
73
75
|
reservedToolNames: RESERVED_TOOL_NAMES,
|
package/package.json
CHANGED
package/skills/actors/SKILL.md
CHANGED
|
@@ -52,18 +52,18 @@ direct delegation ≠ named import composition
|
|
|
52
52
|
Run Control ≠ actor chat
|
|
53
53
|
```
|
|
54
54
|
|
|
55
|
-
A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn` creates a Run from a Recipe. `register_tool
|
|
55
|
+
A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn` creates a Run from a Recipe. `register_tool from=<skill>/<recipe>` persists compact logical delegation and activates from the resolved effective contract without copying, symlinking, or ambient re-resolution. A tool is callable in the current session only when activation evidence says `callable_now: true`.
|
|
56
56
|
|
|
57
57
|
`actors` owns generic Recipe/tool/Run mechanics. The owning capability Skill owns capability-specific selection and constraints. `swarm` owns multi-actor decomposition and integration methodology.
|
|
58
58
|
|
|
59
59
|
## Persistent capability workflow
|
|
60
60
|
|
|
61
|
-
To make `
|
|
61
|
+
To make `music-player/playback` callable as `music_player` with a default source:
|
|
62
62
|
|
|
63
63
|
```text
|
|
64
64
|
register_tool
|
|
65
65
|
name=music_player
|
|
66
|
-
from=
|
|
66
|
+
from=music-player/playback
|
|
67
67
|
defaults={"source":"~/Music/1MIX"}
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -22,12 +22,14 @@ Do not mix source modes.
|
|
|
22
22
|
```text
|
|
23
23
|
register_tool
|
|
24
24
|
name=music_player
|
|
25
|
-
from=
|
|
25
|
+
from=music-player/playback
|
|
26
26
|
defaults={"source":"~/Music/1MIX"}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
`from` means logical direct delegation. The source remains authoritative for async behavior, caller args and types, source defaults, artifacts, Control, and runtime-owned origins. The persistent user Recipe stores only the compact specialization; do not copy inherited fields.
|
|
30
30
|
|
|
31
|
+
Resolution uses Pi's authoritative active-session Skill snapshot across every Skill location Pi admits. Registration resolves and validates the maintained source, persists only its logical `<skill>/<recipe>` reference plus caller specialization, and projects the already-resolved effective contract into the runtime tool. Never replace this composition with a copied Recipe, absolute helper path, symlink, or ambient runtime re-resolution.
|
|
32
|
+
|
|
31
33
|
Use `description` to narrow agent-facing intent when useful. Every supplied default must name a caller-owned source arg and satisfy its type or enum. Never default runtime-owned origins.
|
|
32
34
|
|
|
33
35
|
## Prove registration
|
|
@@ -7,14 +7,14 @@ A Recipe is a reusable executable definition. Address a maintained component as
|
|
|
7
7
|
Use direct delegation when the root remains fundamentally the same capability under a different persistent name, description, or caller default:
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
|
|
10
|
+
music-player/playback
|
|
11
11
|
→ music_player with a default source
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
For agent-facing persistent specialization, use:
|
|
15
15
|
|
|
16
16
|
```text
|
|
17
|
-
register_tool from=
|
|
17
|
+
register_tool from=music-player/playback defaults={"source":"~/Music/1MIX"}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
The delegated root inherits async behavior, args and types, defaults, artifacts, Control, and runtime-owned origins. Do not copy those fields into the wrapper.
|
|
@@ -10,6 +10,8 @@ spawn recipe=<skill>/<recipe> values={...} as=run:<id>
|
|
|
10
10
|
|
|
11
11
|
Use the owning capability Skill to choose the Recipe and capability-specific values. Retain the returned Run id. A spawn result reports `launch_kind: "spawn"`; it is never evidence of registered-tool invocation.
|
|
12
12
|
|
|
13
|
+
A rare Skill Recipe may declare `singleton: true`. Do not pass `as`: the runtime derives `run:<skill>` plus the canonical `<skill>/<recipe>` identity, allows at most one singleton Recipe per Skill, and returns the same compatible healthy active Run instead of launching a duplicate. Contradictory Recipe identity, startup values, Control, or ownership fails closed. Delegation inherits both singleton identities instead of retargeting them. A terminal result is never reused; after its runner exits, restart keeps the logical Run id but creates a new fenced generation. Continuity still depends on actor-owned validated state, not the Run id alone.
|
|
14
|
+
|
|
13
15
|
## Observe
|
|
14
16
|
|
|
15
17
|
Normally wait for terminal follow-up. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
|
|
@@ -106,6 +106,7 @@ function scanSkillRecipes(target) {
|
|
|
106
106
|
if (!existsSync(recipeDir)) continue;
|
|
107
107
|
const direct = readdirSync(recipeDir, { withFileTypes: true });
|
|
108
108
|
const byStem = new Map();
|
|
109
|
+
const singletonFiles = [];
|
|
109
110
|
for (const entry of direct) {
|
|
110
111
|
const path = join(recipeDir, entry.name);
|
|
111
112
|
if (entry.isDirectory()) {
|
|
@@ -120,6 +121,7 @@ function scanSkillRecipes(target) {
|
|
|
120
121
|
}
|
|
121
122
|
if (!entry.isFile() || !/\.(?:json|md)$/.test(entry.name)) continue;
|
|
122
123
|
files.push(path);
|
|
124
|
+
if (readRawRecipeConfig(path)?.singleton === true) singletonFiles.push(path);
|
|
123
125
|
const stem = basename(entry.name, extname(entry.name));
|
|
124
126
|
const previous = byStem.get(stem);
|
|
125
127
|
if (previous) {
|
|
@@ -130,6 +132,13 @@ function scanSkillRecipes(target) {
|
|
|
130
132
|
});
|
|
131
133
|
} else byStem.set(stem, path);
|
|
132
134
|
}
|
|
135
|
+
if (singletonFiles.length > 1) {
|
|
136
|
+
failures.push({
|
|
137
|
+
file: recipeDir,
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `Skill ${skill.name} declares more than one singleton Recipe: ${singletonFiles.map((file) => basename(file)).join(", ")}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
133
142
|
}
|
|
134
143
|
let skillContext = packageSkillContext;
|
|
135
144
|
try {
|
|
@@ -227,6 +236,8 @@ function qaDiagnostics(file, config) {
|
|
|
227
236
|
const warnings = [];
|
|
228
237
|
if (config.mailbox !== undefined)
|
|
229
238
|
diagnostics.push("recipe.mailbox was removed; use control actions and Trace events");
|
|
239
|
+
if (config.singleton === true && config.async !== true)
|
|
240
|
+
diagnostics.push("singleton: requires async: true");
|
|
230
241
|
diagnostics.push(...validateArtifactDeclarations(config));
|
|
231
242
|
diagnostics.push(...validatePortablePaths(config));
|
|
232
243
|
diagnostics.push(...validateHelperPaths(file, config));
|
|
@@ -260,6 +271,7 @@ function validateFile(file, qa = false, skillContext = packageSkillContext) {
|
|
|
260
271
|
ok: qaOk(qaReport),
|
|
261
272
|
name: config.name ?? "",
|
|
262
273
|
async: Boolean(config.async),
|
|
274
|
+
singleton: Boolean(config.singleton),
|
|
263
275
|
args: Array.isArray(config.args) ? config.args : [],
|
|
264
276
|
defaults:
|
|
265
277
|
config.defaults && typeof config.defaults === "object"
|