@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.
Files changed (58) hide show
  1. package/AGENTS.md +1 -1
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +16 -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 +57 -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 +57 -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
package/AGENTS.md CHANGED
@@ -60,7 +60,7 @@ Pi host
60
60
  - `observability.ts`, `run-ui-runtime.ts`: Trace attention, terminal reconciliation, and Pi follow-up delivery.
61
61
  - automatic draft/tool review domains: structurally redacted model review, journaled mutation, lineage, recovery, and explicit retry/reset safety.
62
62
 
63
- The bundled Skill Recipe dependency DAG is `artifacts → actors, swarm`, `media → artifacts`, and `project-work → actors, artifacts, swarm`; `actors`, `swarm`, and `recipe-memory` have no cross-Skill Recipe dependencies.
63
+ The bundled Skill Recipe dependency DAG is `artifacts → actors, swarm` and `project-work → actors, artifacts, swarm`; `actors`, `music-player`, `swarm`, and `recipe-memory` have no cross-Skill Recipe dependencies.
64
64
 
65
65
  Scripts remain self-contained when no non-script consumer justifies a TypeScript domain. Command-template script leaves infer `.js`/`.mjs` in order through Node, Bun, or `deno run` and `.sh` through Bash without shell evaluation. Helper-backed Skill Recipes self-locate through runtime-owned `{skill_dir}`. Recipes stay optional, composable, policy-light, and caller-configurable.
66
66
 
package/BACKLOG.md CHANGED
@@ -1,3 +1,6 @@
1
1
  # Project Backlog
2
2
 
3
- No open items.
3
+ - [ ] `Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority.
4
+ - [ ] Publish `PlaybackStatus`, track metadata, duration, read-time position, volume, and supported capabilities under one stable session-scoped bus identity; disappear cleanly when the Run stops or its generation is replaced, and fail soft when the user D-Bus session is unavailable.
5
+ - [ ] Map `Play`, `Pause`, `PlayPause`, `Next`, `Previous`, `Stop`, `Seek`, `SetPosition`, and `Volume` back into the existing generation-fenced music-player Control/helper contract rather than signaling the backend or editing Run state directly.
6
+ - [ ] Validate deterministic D-Bus contract behavior plus a live GNOME smoke showing the media surface, metadata, progress, volume, and controls while preserving backend independence and exact Actor ownership.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.49.1: Maintained Telegram View Routing
8
+
9
+ - `Maintained Telegram View Routing`: Music Player now treats Telegram-originated control intent as a breadcrumb to its ready capability-owned Generative App, preferring bind/invoke over one-shot prompt buttons while preserving Actor playback authority and an explicit model-mediated fallback when the app runtime is unavailable.
10
+
11
+ ## 0.49.0: Skill-Scoped Music Player
12
+
13
+ - `Skill-Scoped Singletons`: Added one optional async singleton Recipe per active Skill with canonical `run:<skill>` and `<skill>/<recipe>` identities, idempotent compatible reuse, lifecycle/process fencing, terminal-generation replacement, delegation-safe identity inheritance, persistent actor-owned state directories, focused inspection, and fail-closed conflicts across Recipe, owner, startup values, and Control.
14
+ - `Terminal Attention Dedupe`: Retains seen Trace-attention identities while terminal Runs remain observable, preventing periodic reconciliation from replaying the same command follow-ups after the terminal transition.
15
+ - `Focused Music Player`: Replaces broad Media with one `music-player/playback` singleton for files, directories, URLs, and playlists. It checkpoints queue and paused intent, reports structured status/Trace, rejects Actor/standalone ownership collisions, and recovers malformed checkpoints explicitly. Live Generative App validation confirmed fresh status and exact terminal Controls across the complete action set.
16
+ - `Absolute Volume Control`: Adds generation-fenced `volume` with `{ "percent": 0..100 }`, direct helper parity, structured status/checkpoint/Trace evidence, and fail-before-admission invalid-input handling. Linux WirePlumber resolves the exact playback stream by process identity and changes it in place; unsupported environments retain the restart fallback. UIs resolve relative steps to one absolute percentage.
17
+ - `Percentage Seeking`: Adds generation-fenced `seek` with `{ "percent": 0..100 }`, bounded duration probing, read-time progress percentage, structured status/checkpoint/Trace evidence, and supported-backend restart at the resolved offset while preserving track and paused/playing intent; unavailable duration or backend support fails explicitly.
18
+ - `Portable Playback Protocol`: Adds actor-neutral foreground `serve` and a pure generation-fenced `playback-client.mjs` for structured status and bounded controls. The Recipe remains the sole managed lifecycle owner; standalone foreground ownership is explicit and mutually exclusive, with no hidden daemon start, adoption, or supervisor handoff.
19
+ - `Optional Generative App`: Ships a ready `genapps/music-player.mjs` adapter for a co-installed generic Generative App runtime. It reports Actor availability and waits for the exact terminal canonical Actor Control for every mutation, including failed-action propagation; Pi remains the lifecycle composition root. Progress and volume use symmetric seven-button `0..90` scales in steps of 15.
20
+
5
21
  ## 0.48.1: Persistent Skill Composition
6
22
 
7
23
  - `Persistent Skill Composition`: Made `register_tool from=<skill>/<recipe>` use Pi's authoritative active-session Skill snapshot across every admitted Skill location and activate synchronous or asynchronous tools from the resolved effective contract. Compact user Recipes retain logical delegation without copied contracts, absolute helper paths, symlinks, or ambient runtime re-resolution.
package/README.md CHANGED
@@ -29,7 +29,7 @@ For local development:
29
29
  pi install /path/to/pi-actors
30
30
  ```
31
31
 
32
- The package contributes the extension and six capability-owning Skills for actors, artifacts, media, project work, Recipe memory, and swarm orchestration.
32
+ The package contributes the extension and six capability-owning Skills for actors, artifacts, music playback, project work, Recipe memory, and swarm orchestration.
33
33
 
34
34
  ## Public Tools
35
35
 
@@ -40,7 +40,7 @@ Create a Run from an active-Skill Recipe, an explicit Recipe file, or an inline
40
40
  ```text
41
41
  spawn template="sleep 30" as=run:demo
42
42
  spawn recipe=project-work/repo-health values={"repo":"/work/project","model":"provider/model"}
43
- spawn recipe=media/player values={"source":"/music"} as=run:player
43
+ spawn recipe=music-player/playback values={"source":"/music"}
44
44
  spawn template="make test" as=run:test
45
45
  ```
46
46
 
@@ -80,7 +80,7 @@ inspect target=run:test view=trace source=lifecycle lines=40
80
80
  inspect target=run:test view=control
81
81
  inspect target=runtime view=status
82
82
  inspect target=recipes view=status
83
- inspect target=recipes view=doctor identity=media/player
83
+ inspect target=recipes view=doctor identity=music-player/playback
84
84
  inspect target=tool:my_tool view=status
85
85
  ```
86
86
 
@@ -203,7 +203,7 @@ Useful entry points include:
203
203
  - `project-work/release-readiness`
204
204
  - `swarm/quorum-review`
205
205
  - `artifacts/bundle`
206
- - `media/player` — controlled playback service
206
+ - `music-player/playback` — singleton controlled playback service
207
207
  - `actors/resource-locker` — optional controlled resource-lock service
208
208
 
209
209
  Validate Recipes with:
@@ -33,6 +33,9 @@ export interface AsyncRunStartParams {
33
33
  name?: string;
34
34
  ownerId?: string;
35
35
  run_id?: string;
36
+ singleton?: boolean;
37
+ singleton_run_id?: string;
38
+ singleton_recipe_id?: string;
36
39
  state_dir?: string;
37
40
  tool?: string;
38
41
  template?: CommandTemplateValue;
@@ -94,6 +97,10 @@ export interface AsyncRunMeta {
94
97
  process_identity?: RunProcessIdentity;
95
98
  recipe_context_records?: RecipesReferences.TemplateRecipeContextRecord[];
96
99
  retire_when?: "children_terminal";
100
+ reused?: boolean;
101
+ singleton?: boolean;
102
+ singleton_recipe_id?: string;
103
+ singleton_values?: Record<string, unknown>;
97
104
  transport_context?: Record<string, unknown>;
98
105
  }
99
106
  export { safeRunId } from "./runs-identity.ts";
@@ -7,6 +7,7 @@ import { randomUUID } from "node:crypto";
7
7
  import { closeSync, existsSync, mkdirSync, openSync, readdirSync, statSync, } from "node:fs";
8
8
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { isDeepStrictEqual } from "node:util";
10
11
  import { writeJsonAtomic } from "./file-state.js";
11
12
  import { CURRENT_MODEL_VALUE_KEY, CURRENT_THINKING_VALUE_KEY, describeCurrentPolicyProvenance, } from "./model-context.js";
12
13
  import * as Paths from "./paths.js";
@@ -105,6 +106,32 @@ function resolveStateDir(params, run) {
105
106
  function assertNoActiveRunState(stateDir) {
106
107
  RunsStart.assertNoActiveRunState(stateDir, readJson, RUNNER_PATH);
107
108
  }
109
+ function reuseCompatibleSingletonRun(stateDir, startParams, singletonValues) {
110
+ const existing = RunsStart.readActiveOwnedRunState(stateDir, readJson, RUNNER_PATH);
111
+ if (!existing)
112
+ return undefined;
113
+ const compatible = existing.singleton === true &&
114
+ existing.singleton_recipe_id === startParams.singleton_recipe_id &&
115
+ existing.ownerId === startParams.ownerId &&
116
+ isDeepStrictEqual(existing.singleton_values ?? {}, singletonValues) &&
117
+ isDeepStrictEqual(existing.control ?? [], startParams.control ?? []);
118
+ if (!compatible) {
119
+ throw new Error(`Active singleton Run ${String(existing.run ?? stateDir)} has incompatible Recipe identity, owner, startup values, or Control contract. Stop it before changing singleton configuration.`);
120
+ }
121
+ return {
122
+ ...existing,
123
+ ...(startParams.launch_source
124
+ ? {
125
+ launch_kind: startParams.launch_source,
126
+ launch_source: startParams.launch_source,
127
+ }
128
+ : {}),
129
+ ...(startParams.launch_correlation
130
+ ? { launch_correlation: startParams.launch_correlation }
131
+ : {}),
132
+ reused: true,
133
+ };
134
+ }
108
135
  function resolveRecipeFile(file, cwd, options) {
109
136
  const path = RecipesReferences.resolveRecipeReferencePath(file, cwd, options.skillContext);
110
137
  if (path)
@@ -147,10 +174,23 @@ function resolveStartParams(params, cwd, options) {
147
174
  if (!params.file)
148
175
  return params;
149
176
  const fileParams = readRecipeFile(params.file, cwd, options);
177
+ const singleton = fileParams.singleton === true;
178
+ if (singleton && fileParams.async !== true) {
179
+ throw new Error("singleton Recipes must declare async: true");
180
+ }
181
+ if (singleton && (!fileParams.singleton_run_id || !fileParams.singleton_recipe_id)) {
182
+ throw new Error("singleton Recipes must resolve one active Skill-owned singleton identity");
183
+ }
184
+ const singletonRun = singleton ? fileParams.singleton_run_id : undefined;
185
+ if (singletonRun && params.run_id && params.run_id !== singletonRun) {
186
+ throw new Error(`singleton Recipe run identity is run:${singletonRun}; received run:${params.run_id}`);
187
+ }
150
188
  return {
151
189
  ...fileParams,
152
190
  ...params,
153
- run_id: params.run_id ||
191
+ ...(singleton ? { singleton: true } : {}),
192
+ run_id: singletonRun ||
193
+ params.run_id ||
154
194
  fileParams.run_id ||
155
195
  fileParams.name ||
156
196
  getRunIdFromFile(fileParams.file),
@@ -282,14 +322,27 @@ export function startRun(params, cwd, options = {}) {
282
322
  values: startParams.policy_values ?? startParams.values ?? {},
283
323
  });
284
324
  assertCurrentPlaceholderReferencesResolved(resolved.template, startParams.defaults, values, modelPolicy);
285
- assertNoActiveRunState(stateDir);
325
+ const singletonValues = Object.fromEntries([...declaredArgs].map((key) => [key, values[key]]));
326
+ if (startParams.singleton !== true)
327
+ assertNoActiveRunState(stateDir);
286
328
  mkdirSync(stateDir, { recursive: true });
287
329
  const releaseStartLock = acquireStateStartLock(stateDir, {
288
330
  onContention: startParams.lifecycleHooks?.onLockContention,
289
331
  });
290
332
  try {
291
333
  claimRunStateDirectory(stateDir, run);
292
- assertNoActiveRunState(stateDir);
334
+ if (recipeFile &&
335
+ isMutableUsageRecipeFile(recipeFile) &&
336
+ !RecipesUsage.recordRecipeLaunch(recipeFile, new Date(), startParams.launch_source === "tool" ? "tool" : "spawn")) {
337
+ throw new Error(`Recipe launch rejected because its source changed during activation: ${recipeFile}. Reload recipe tools and retry.`);
338
+ }
339
+ if (startParams.singleton === true) {
340
+ const existing = reuseCompatibleSingletonRun(stateDir, startParams, singletonValues);
341
+ if (existing)
342
+ return existing;
343
+ }
344
+ else
345
+ assertNoActiveRunState(stateDir);
293
346
  prepareStateDirForStart(stateDir);
294
347
  const stdout = join(stateDir, "stdout.log");
295
348
  const stderr = join(stateDir, "stderr.log");
@@ -307,11 +360,6 @@ export function startRun(params, cwd, options = {}) {
307
360
  }
308
361
  : record)
309
362
  : undefined;
310
- if (recipeFile &&
311
- isMutableUsageRecipeFile(recipeFile) &&
312
- !RecipesUsage.recordRecipeLaunch(recipeFile, new Date(), startParams.launch_source === "tool" ? "tool" : "spawn")) {
313
- throw new Error(`Recipe launch rejected because its source changed during activation: ${recipeFile}. Reload recipe tools and retry.`);
314
- }
315
363
  const outFd = openSync(stdout, "a");
316
364
  const errFd = openSync(stderr, "a");
317
365
  const argv = asyncRunnerArgv(stateDir);
@@ -360,6 +408,13 @@ export function startRun(params, cwd, options = {}) {
360
408
  ...(startParams.retire_when === "children_terminal"
361
409
  ? { retire_when: "children_terminal" }
362
410
  : {}),
411
+ ...(startParams.singleton === true
412
+ ? {
413
+ singleton: true,
414
+ singleton_recipe_id: startParams.singleton_recipe_id,
415
+ singleton_values: singletonValues,
416
+ }
417
+ : {}),
363
418
  ...(transportContext
364
419
  ? { transport_context: transportContext } : {}),
365
420
  };
@@ -273,13 +273,27 @@ export function withFileMutationLock(path, mutate, options = {}) {
273
273
  release();
274
274
  }
275
275
  }
276
+ function replaceFileWithRetry(source, target) {
277
+ for (let attempt = 0;; attempt += 1) {
278
+ try {
279
+ renameSync(source, target);
280
+ return;
281
+ }
282
+ catch (error) {
283
+ const code = error.code;
284
+ if ((code !== "EPERM" && code !== "EBUSY") || attempt >= 19)
285
+ throw error;
286
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
287
+ }
288
+ }
289
+ }
276
290
  export function writeTextAtomic(path, content, options = {}) {
277
291
  mkdirSync(dirname(path), { recursive: true });
278
292
  const tempPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
279
293
  try {
280
294
  writeFileSync(tempPath, content, "utf8");
281
295
  options.onBeforeReplace?.();
282
- renameSync(tempPath, path);
296
+ replaceFileWithRetry(tempPath, path);
283
297
  }
284
298
  catch (error) {
285
299
  try {
@@ -117,6 +117,9 @@ export function readActorInspectorRecipe(stateDir) {
117
117
  source_kind: primarySourceKind,
118
118
  launch_kind: meta.launch_kind ?? meta.launch_source,
119
119
  launch_source: meta.launch_source,
120
+ ...(meta.singleton === true
121
+ ? { singleton: true, singleton_recipe_id: meta.singleton_recipe_id }
122
+ : {}),
120
123
  }),
121
124
  launch: redactedRecord({
122
125
  cwd: meta.cwd,
@@ -127,6 +130,7 @@ export function readActorInspectorRecipe(stateDir) {
127
130
  artifacts: meta.artifacts,
128
131
  notification_policy: meta.notification_policy,
129
132
  retire_when: meta.retire_when,
133
+ singleton_values: meta.singleton_values,
130
134
  }),
131
135
  };
132
136
  }
@@ -786,9 +786,6 @@ function readTraceAttentionRecords(run) {
786
786
  export function pruneRunObservationState(previousStatuses, previousLineCounts, summary, terminalRuns = [], seenEventIds = new Map()) {
787
787
  const activeRuns = new Set(summary.runs.map((run) => runObservationKey(run)));
788
788
  const terminalRunSet = new Set(terminalRuns);
789
- const terminalLineKeys = new Set(summary.runs
790
- .filter((run) => terminalRunSet.has(runObservationKey(run)))
791
- .map((run) => runObservationKey(run)));
792
789
  const activeLineKeys = new Set(summary.runs.map((run) => run.stateDir ?? run.run));
793
790
  for (const run of terminalRunSet)
794
791
  previousStatuses.delete(run);
@@ -796,10 +793,10 @@ export function pruneRunObservationState(previousStatuses, previousLineCounts, s
796
793
  if (!activeRuns.has(run))
797
794
  previousStatuses.delete(run);
798
795
  for (const key of previousLineCounts.keys())
799
- if (terminalLineKeys.has(key) || !activeLineKeys.has(key))
796
+ if (!activeLineKeys.has(key))
800
797
  previousLineCounts.delete(key);
801
798
  for (const key of seenEventIds.keys())
802
- if (terminalLineKeys.has(key) || !activeLineKeys.has(key))
799
+ if (!activeLineKeys.has(key))
803
800
  seenEventIds.delete(key);
804
801
  }
805
802
  export function detectRunAttentionEvents(legacyLineCounts, summary, seenEventIds = new Map(), prime = false) {
@@ -707,7 +707,9 @@ export function summarizeRegisteredToolArgs(tool) {
707
707
  const requiredSet = new Set(required);
708
708
  const optional = publicArgs.filter((arg) => !requiredSet.has(arg));
709
709
  if (tool.recipe?.async === true) {
710
- optional.push("run_id", "transport_context");
710
+ if (tool.recipe.singleton !== true)
711
+ optional.push("run_id");
712
+ optional.push("transport_context");
711
713
  }
712
714
  return { optional, required };
713
715
  }
@@ -14,6 +14,7 @@ export type TemplateRecipeImport = string | TemplateRecipeImportBinding;
14
14
  export interface TemplateRecipeDefinition {
15
15
  description?: string;
16
16
  disabled?: boolean;
17
+ singleton?: boolean;
17
18
  imports?: Record<string, TemplateRecipeImport>;
18
19
  template: CommandTemplateValue;
19
20
  args?: string[];
@@ -42,6 +43,8 @@ export interface TemplateRecipeConfig extends TemplateRecipeDefinition {
42
43
  async?: boolean;
43
44
  recipe_dir?: string;
44
45
  skill_dir?: string;
46
+ singleton_run_id?: string;
47
+ singleton_recipe_id?: string;
45
48
  }
46
49
  export interface TemplateRecipeContextRecord {
47
50
  alias?: string;
@@ -970,6 +970,21 @@ export function readResolvedRecipeConfig(file, stack = [], options = {}) {
970
970
  : delegated?.async === false
971
971
  ? { async: false }
972
972
  : {}),
973
+ ...(substituted.singleton === true || delegated?.singleton === true
974
+ ? {
975
+ singleton: true,
976
+ singleton_run_id: delegated?.singleton === true
977
+ ? delegated.singleton_run_id
978
+ : skillDir
979
+ ? basename(skillDir)
980
+ : undefined,
981
+ singleton_recipe_id: delegated?.singleton === true
982
+ ? delegated.singleton_recipe_id
983
+ : skillDir
984
+ ? `${basename(skillDir)}/${recipeName}`
985
+ : undefined,
986
+ }
987
+ : {}),
973
988
  ...(Object.keys(imports).length > 0
974
989
  ? { imports: getRecipeImports(raw) }
975
990
  : {}),
@@ -5,7 +5,8 @@
5
5
  */
6
6
  import { type FileMutationLockOptions } from "./file-state.ts";
7
7
  type RunJsonReader = (path: string) => Record<string, unknown> | undefined;
8
- export declare function assertNoActiveRunState(stateDir: string, readJson: RunJsonReader, _runnerPath: string): void;
8
+ export declare function readActiveOwnedRunState(stateDir: string, readJson: RunJsonReader, _runnerPath: string): Record<string, unknown> | undefined;
9
+ export declare function assertNoActiveRunState(stateDir: string, readJson: RunJsonReader, runnerPath: string): void;
9
10
  export declare function acquireStateStartLock(stateDir: string, options?: FileMutationLockOptions): () => void;
10
11
  export declare function prepareStateDirForStart(stateDir: string, readJson: RunJsonReader, _runnerPath: string): void;
11
12
  export {};
@@ -7,14 +7,16 @@ import { rmSync } from "node:fs";
7
7
  import { join } from "node:path";
8
8
  import { acquireFileMutationLock, } from "./file-state.js";
9
9
  import { isAlive, verifyRunProcessIdentity, } from "./runs-process.js";
10
- export function assertNoActiveRunState(stateDir, readJson, _runnerPath) {
10
+ export function readActiveOwnedRunState(stateDir, readJson, _runnerPath) {
11
11
  const meta = readJson(join(stateDir, "run.json"));
12
12
  if (!meta)
13
- return;
13
+ return undefined;
14
+ const result = readJson(join(stateDir, "result.json"));
15
+ if (typeof result?.completedAt === "string")
16
+ return undefined;
14
17
  const pid = Number(meta.pid || 0);
15
- const cwd = String(meta.cwd ?? "");
16
18
  if (!pid || !isAlive(pid))
17
- return;
19
+ return undefined;
18
20
  const identity = verifyRunProcessIdentity(pid, meta.process_identity);
19
21
  if (identity.status === "owner_mismatch") {
20
22
  throw new Error(`Run state process identity does not match the live pid: ${String(meta.run ?? stateDir)}. Refusing to reuse the state directory while pid ${pid} is alive.`);
@@ -22,7 +24,11 @@ export function assertNoActiveRunState(stateDir, readJson, _runnerPath) {
22
24
  if (identity.status === "unsupported_proof") {
23
25
  throw new Error(`Run state process identity proof is unavailable: ${String(meta.run ?? stateDir)}. Refusing to reuse the state directory while pid ${pid} is alive.`);
24
26
  }
25
- if (identity.valid) {
27
+ return identity.valid ? meta : undefined;
28
+ }
29
+ export function assertNoActiveRunState(stateDir, readJson, runnerPath) {
30
+ const meta = readActiveOwnedRunState(stateDir, readJson, runnerPath);
31
+ if (meta) {
26
32
  throw new Error(`Run state already has an active owned process: ${String(meta.run ?? stateDir)}. Stop it before reusing the same run_id or state_dir.`);
27
33
  }
28
34
  }
@@ -96,6 +96,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
96
96
  const isAsyncRecipe = cfg.recipe
97
97
  ? cfg.recipe.async === true
98
98
  : RecipesReferences.isAsyncRecipeReference(cfg.template);
99
+ const isSingletonRecipe = cfg.recipe?.singleton === true;
99
100
  const recipeTemplate = cfg.recipe?.template ?? RecipesReferences.getRecipeTemplate(cfg.template);
100
101
  const requiredTemplate = recipeTemplate ?? cfg.template;
101
102
  const requiredTemplateConfig = typeof requiredTemplate === "object" && !Array.isArray(requiredTemplate)
@@ -123,7 +124,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
123
124
  if (requiredArgs.has(arg))
124
125
  required.push(arg);
125
126
  }
126
- if (isAsyncRecipe)
127
+ if (isAsyncRecipe && !isSingletonRecipe)
127
128
  paramSchema.run_id = Schema.stringSchema("Optional run id override for this async template recipe invocation.");
128
129
  if (isAsyncRecipe) {
129
130
  paramSchema.transport_context = Schema.looseObjectSchema("Optional originating transport route preserved for detached terminal follow-up.");
@@ -146,9 +147,11 @@ export function createRuntimeToolDefinition(cfg, exec) {
146
147
  const input = params;
147
148
  const { run_id, transport_context, ...values } = input;
148
149
  const base = cfg.recipe ? cfg.recipe : { file: String(cfg.template) };
149
- const runId = typeof run_id === "string" && run_id.trim()
150
- ? run_id.trim()
151
- : `${cfg.name}-${Date.now()}`;
150
+ const runId = isSingletonRecipe
151
+ ? undefined
152
+ : typeof run_id === "string" && run_id.trim()
153
+ ? run_id.trim()
154
+ : `${cfg.name}-${Date.now()}`;
152
155
  const meta = AsyncRuns.startRun({
153
156
  ...base,
154
157
  launch_source: "tool",
@@ -181,7 +184,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
181
184
  return await Execution.executeRegisteredTool(cfg, Schema.normalizeRuntimeValues(ModelContext.withCurrentModelValues(params, ctx), cfg.argTypes), exec, ctx.cwd, signal);
182
185
  }
183
186
  catch (error) {
184
- throw formatRuntimeToolArgumentError(cfg, error, required, isAsyncRecipe);
187
+ throw formatRuntimeToolArgumentError(cfg, error, required, isAsyncRecipe && !isSingletonRecipe);
185
188
  }
186
189
  },
187
190
  };
@@ -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,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.