@llblab/pi-actors 0.48.1 → 0.49.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 +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/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/runs-start.d.ts +2 -1
- package/dist/lib/runs-start.js +11 -5
- package/dist/lib/tools-local.js +8 -5
- package/dist/skills/actors/SKILL.md +2 -2
- package/dist/skills/actors/references/persistent-tools.md +1 -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 +57 -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 +1 -1
- package/lib/async-runs.ts +92 -15
- 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/runs-start.ts +16 -6
- package/lib/tools-local.ts +6 -4
- package/package.json +1 -1
- package/skills/actors/SKILL.md +2 -2
- package/skills/actors/references/persistent-tools.md +1 -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 +57 -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.
|
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/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/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
|
@@ -150,6 +150,7 @@ export function createRuntimeToolDefinition(
|
|
|
150
150
|
const isAsyncRecipe = cfg.recipe
|
|
151
151
|
? cfg.recipe.async === true
|
|
152
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!;
|
|
@@ -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/package.json
CHANGED
package/skills/actors/SKILL.md
CHANGED
|
@@ -58,12 +58,12 @@ A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn
|
|
|
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
|
|
|
@@ -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"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: music-player
|
|
3
|
+
description: Use for starting, resuming, inspecting, and controlling one persistent local music playback actor from caller-approved files, directories, URLs, or playlists.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Music Player
|
|
7
|
+
|
|
8
|
+
Use this Skill for one local music playback service. For generic Recipe execution, singleton Run lifecycle, persistent-tool setup, or diagnosis, follow `actors`; this Skill owns only playback-specific selection and controls.
|
|
9
|
+
|
|
10
|
+
## Interaction Routing
|
|
11
|
+
|
|
12
|
+
When a Telegram-originated turn or explicit Telegram-control question makes repeated player controls relevant, prefer the maintained Telegram view described below over synthesizing one-shot prompt buttons. If `telegram_bind` is available, load and follow the active operating guidance that owns Generative Apps, then bind or invoke the capability-owned adapter; do not re-author it or move playback authority into the view. If the runtime is unavailable or binding fails, report that boundary and fall back to ordinary model-mediated controls.
|
|
13
|
+
|
|
14
|
+
## Playback
|
|
15
|
+
|
|
16
|
+
`music-player/playback` is a singleton async controlled service with the canonical address `run:music-player`. The Recipe is the sole lifecycle owner: it starts the service, supervises it, and stops playback when the Run closes. The service owns queue, backend, checkpoint, and playback state. `playback-client.mjs` is a pure actor-neutral RPC client; it never starts, adopts, supervises, or signals a service. The executable also supports explicit foreground `serve` for development or a caller-owned standalone host, but Actor and standalone ownership of one state directory are mutually exclusive.
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
spawn recipe=music-player/playback values={"source":"~/Music","player":"auto"}
|
|
20
|
+
message target=run:music-player action=pause
|
|
21
|
+
message target=run:music-player action=next
|
|
22
|
+
inspect target=run:music-player view=control
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A repeated compatible spawn returns the healthy active singleton instead of launching a second player. A terminal or dead singleton restarts under the same Run id with a fresh fenced generation and restores its validated playback checkpoint when the source and configuration still match.
|
|
26
|
+
|
|
27
|
+
## Controls
|
|
28
|
+
|
|
29
|
+
Use only declared actions: `play`, `pause`, `resume`, `toggle`, `next`, `previous`, `seek`, `volume`, `stop`, and `status`.
|
|
30
|
+
|
|
31
|
+
- `play` and `resume` continue the current checkpointed track selection.
|
|
32
|
+
- `next` and `previous` update the checkpoint before the next backend launch.
|
|
33
|
+
- `seek` accepts Control input `{ "percent": 0..100 }`, resolves the current track duration, and restarts a supported backend at that percentage while preserving track identity and paused/playing intent. It fails when duration or backend seeking is unavailable.
|
|
34
|
+
- `volume` accepts Control input `{ "percent": 0..100 }` and sets any integer percentage. On Linux with WirePlumber, the helper resolves the exact current playback stream by process identity and changes its volume in place so the track continues; when no safe in-place control exists, it restarts the current track under the new volume while preserving paused/playing intent. UI adapters may expose coarse relative steps but must send the resolved absolute percentage.
|
|
35
|
+
- `status` is read-only and exposes bounded machine-readable player state, including the current absolute volume and a duration-derived progress percentage projected at read time.
|
|
36
|
+
- `stop` ends the live process without silently deleting the saved queue.
|
|
37
|
+
|
|
38
|
+
External local views use the actor-neutral playback client against the canonical service state directory. They must not read or edit Run files, signal playback processes, construct Actor Control records, or import pi-actors internals. The client validates bounded commands, exact service generation, structured responses, and active endpoint ownership before returning success.
|
|
39
|
+
|
|
40
|
+
## Maintained Telegram View
|
|
41
|
+
|
|
42
|
+
> [!NOTE]
|
|
43
|
+
> This Skill includes a ready Music Player Generative App at `genapps/music-player.mjs`. When `telegram_bind` is available and Telegram interaction is relevant, use it to copy and install the app as `music-player`; hot replacement keeps the same app name with `replace: true`.
|
|
44
|
+
|
|
45
|
+
Bind with absolute `control`, `stateDir`, and `node` arguments. The adapter reports whether the Actor control surface is actually available. Every mutating button queues a canonical Actor Control through `playback.mjs` and waits for that exact record to become handled or failed, so terminal evidence remains visible in the Run inspector and failures reach Telegram; bounded status projection is read-only. The adapter neither imports extension internals nor starts playback. Its stopped-state Start button returns to Pi so the composition root can spawn `music-player/playback`, while active controls remain deterministic Generative App actions that bypass the model. `pi-telegram` owns only the generic Generative App runtime.
|
|
46
|
+
|
|
47
|
+
## Sources And Backends
|
|
48
|
+
|
|
49
|
+
- `source` must name caller-approved local music, a readable directory, a playlist file, a URL, or an explicit `|`-separated list. Do not broaden it to unrelated directories.
|
|
50
|
+
- Directory and playlist resolution is owned by the playback helper; there is no separate public playlist Recipe.
|
|
51
|
+
- `player=auto` selects an available supported backend. Do not install or substitute a player silently.
|
|
52
|
+
- The checkpoint preserves source, resolved queue, current index/track, loop, volume, backend, and playback state. It does not promise within-track position restoration unless the selected backend can prove it.
|
|
53
|
+
- A changed source rebuilds the queue. Missing or corrupt checkpoint data fails visibly or rebuilds only under the helper's explicit recovery contract; never claim continuity without evidence.
|
|
54
|
+
|
|
55
|
+
## Stop Rules
|
|
56
|
+
|
|
57
|
+
Stop if the source is missing, unreadable, contains no playable audio, or no supported backend is available. Do not claim playback from process start alone; confirm through Run evidence or `status`. Prefer the declared `stop` action for a responsive player. If the service is unresponsive, return to `actors` for bounded Run recovery rather than shell process control.
|