@llblab/pi-actors 0.48.1 → 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.
Files changed (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +12 -0
  4. package/README.md +4 -4
  5. package/dist/lib/async-runs.d.ts +7 -0
  6. package/dist/lib/async-runs.js +63 -8
  7. package/dist/lib/file-state.js +15 -1
  8. package/dist/lib/inspector.js +4 -0
  9. package/dist/lib/observability.js +2 -5
  10. package/dist/lib/recipes-discovery.js +3 -1
  11. package/dist/lib/recipes-references.d.ts +3 -0
  12. package/dist/lib/recipes-references.js +15 -0
  13. package/dist/lib/runs-start.d.ts +2 -1
  14. package/dist/lib/runs-start.js +11 -5
  15. package/dist/lib/tools-local.js +8 -5
  16. package/dist/skills/actors/SKILL.md +2 -2
  17. package/dist/skills/actors/references/persistent-tools.md +1 -1
  18. package/dist/skills/actors/references/recipes.md +2 -2
  19. package/dist/skills/actors/references/runs.md +2 -0
  20. package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
  21. package/dist/skills/music-player/SKILL.md +53 -0
  22. package/dist/skills/music-player/genapps/music-player.mjs +360 -0
  23. package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
  24. package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
  25. package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  26. package/docs/async-runs.md +1 -1
  27. package/docs/recipe-library.md +2 -5
  28. package/docs/template-recipes.md +7 -0
  29. package/docs/tool-registry.md +1 -1
  30. package/lib/async-runs.ts +92 -15
  31. package/lib/file-state.ts +14 -1
  32. package/lib/inspector.ts +4 -0
  33. package/lib/observability.ts +2 -5
  34. package/lib/recipes-discovery.ts +2 -1
  35. package/lib/recipes-references.ts +20 -0
  36. package/lib/runs-start.ts +16 -6
  37. package/lib/tools-local.ts +6 -4
  38. package/package.json +1 -1
  39. package/skills/actors/SKILL.md +2 -2
  40. package/skills/actors/references/persistent-tools.md +1 -1
  41. package/skills/actors/references/recipes.md +2 -2
  42. package/skills/actors/references/runs.md +2 -0
  43. package/skills/actors/scripts/validate-recipe.mjs +12 -0
  44. package/skills/music-player/SKILL.md +53 -0
  45. package/skills/music-player/genapps/music-player.mjs +360 -0
  46. package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
  47. package/skills/music-player/scripts/playback-client.mjs +143 -0
  48. package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  49. package/dist/skills/media/SKILL.md +0 -44
  50. package/dist/skills/media/recipes/library.json +0 -45
  51. package/dist/skills/media/recipes/playlist-build.json +0 -15
  52. package/dist/skills/media/recipes/playlist-scan.json +0 -11
  53. package/dist/skills/media/scripts/media-utils.mjs +0 -47
  54. package/skills/media/SKILL.md +0 -44
  55. package/skills/media/recipes/library.json +0 -45
  56. package/skills/media/recipes/playlist-build.json +0 -15
  57. package/skills/media/recipes/playlist-scan.json +0 -11
  58. package/skills/media/scripts/media-utils.mjs +0 -47
@@ -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
- - `media/player` consumes playback Controls and emits playback Trace;
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.
@@ -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
- ### Media and controlled services
37
+ ### Music playback and controlled services
38
38
 
39
- - `media/playlist-scan` — shallow unfiltered path inventory.
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.
@@ -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:
@@ -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=media/player defaults={"source":"~/Music/1MIX"}
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
- assertNoActiveRunState(stateDir);
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
- assertNoActiveRunState(stateDir);
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
- renameSync(tempPath, path);
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
  }
@@ -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 (terminalLineKeys.has(key) || !activeLineKeys.has(key)) previousLineCounts.delete(key);
1151
+ if (!activeLineKeys.has(key)) previousLineCounts.delete(key);
1155
1152
  for (const key of seenEventIds.keys())
1156
- if (terminalLineKeys.has(key) || !activeLineKeys.has(key)) seenEventIds.delete(key);
1153
+ if (!activeLineKeys.has(key)) seenEventIds.delete(key);
1157
1154
  }
1158
1155
 
1159
1156
  export function detectRunAttentionEvents(
@@ -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", "transport_context");
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 assertNoActiveRunState(
22
+ export function readActiveOwnedRunState(
23
23
  stateDir: string,
24
24
  readJson: RunJsonReader,
25
25
  _runnerPath: string,
26
- ): void {
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
- const cwd = String(meta.cwd ?? "");
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
- if (identity.valid) {
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
  );
@@ -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
- typeof run_id === "string" && run_id.trim()
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.48.1",
3
+ "version": "0.49.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -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 `media/player` callable as `music_player` with a default source:
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=media/player
66
+ from=music-player/playback
67
67
  defaults={"source":"~/Music/1MIX"}
68
68
  ```
69
69
 
@@ -22,7 +22,7 @@ Do not mix source modes.
22
22
  ```text
23
23
  register_tool
24
24
  name=music_player
25
- from=media/player
25
+ from=music-player/playback
26
26
  defaults={"source":"~/Music/1MIX"}
27
27
  ```
28
28
 
@@ -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
- media/player
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=media/player defaults={"source":"~/Music/1MIX"}
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,53 @@
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
+ ## Playback
11
+
12
+ `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.
13
+
14
+ ```text
15
+ spawn recipe=music-player/playback values={"source":"~/Music","player":"auto"}
16
+ message target=run:music-player action=pause
17
+ message target=run:music-player action=next
18
+ inspect target=run:music-player view=control
19
+ ```
20
+
21
+ 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.
22
+
23
+ ## Controls
24
+
25
+ Use only declared actions: `play`, `pause`, `resume`, `toggle`, `next`, `previous`, `seek`, `volume`, `stop`, and `status`.
26
+
27
+ - `play` and `resume` continue the current checkpointed track selection.
28
+ - `next` and `previous` update the checkpoint before the next backend launch.
29
+ - `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.
30
+ - `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.
31
+ - `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.
32
+ - `stop` ends the live process without silently deleting the saved queue.
33
+
34
+ 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.
35
+
36
+ ## Optional Telegram View
37
+
38
+ > [!NOTE]
39
+ > If a Generative App runtime is installed, this Skill includes a ready Music Player app at `genapps/music-player.mjs`. Use `telegram_bind` to copy and install it as app `music-player`; hot replacement keeps the same app name with `replace: true`.
40
+
41
+ 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.
42
+
43
+ ## Sources And Backends
44
+
45
+ - `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.
46
+ - Directory and playlist resolution is owned by the playback helper; there is no separate public playlist Recipe.
47
+ - `player=auto` selects an available supported backend. Do not install or substitute a player silently.
48
+ - 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.
49
+ - 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.
50
+
51
+ ## Stop Rules
52
+
53
+ 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.