@llblab/pi-actors 0.48.0 → 0.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) 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/extension-runtime.js +3 -0
  8. package/dist/lib/file-state.js +15 -1
  9. package/dist/lib/inspector.js +4 -0
  10. package/dist/lib/observability.js +2 -5
  11. package/dist/lib/recipes-discovery.js +3 -1
  12. package/dist/lib/recipes-references.d.ts +3 -0
  13. package/dist/lib/recipes-references.js +15 -0
  14. package/dist/lib/registry.d.ts +2 -0
  15. package/dist/lib/registry.js +8 -4
  16. package/dist/lib/runs-start.d.ts +2 -1
  17. package/dist/lib/runs-start.js +11 -5
  18. package/dist/lib/tools-local.js +12 -8
  19. package/dist/lib/tools.d.ts +1 -0
  20. package/dist/lib/tools.js +1 -0
  21. package/dist/skills/actors/SKILL.md +3 -3
  22. package/dist/skills/actors/references/persistent-tools.md +3 -1
  23. package/dist/skills/actors/references/recipes.md +2 -2
  24. package/dist/skills/actors/references/runs.md +2 -0
  25. package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
  26. package/dist/skills/music-player/SKILL.md +53 -0
  27. package/dist/skills/music-player/genapps/music-player.mjs +360 -0
  28. package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
  29. package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
  30. package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  31. package/docs/async-runs.md +1 -1
  32. package/docs/recipe-library.md +2 -5
  33. package/docs/template-recipes.md +7 -0
  34. package/docs/tool-registry.md +2 -2
  35. package/lib/async-runs.ts +92 -15
  36. package/lib/extension-runtime.ts +4 -0
  37. package/lib/file-state.ts +14 -1
  38. package/lib/inspector.ts +4 -0
  39. package/lib/observability.ts +2 -5
  40. package/lib/recipes-discovery.ts +2 -1
  41. package/lib/recipes-references.ts +20 -0
  42. package/lib/registry.ts +8 -6
  43. package/lib/runs-start.ts +16 -6
  44. package/lib/tools-local.ts +10 -8
  45. package/lib/tools.ts +2 -0
  46. package/package.json +1 -1
  47. package/skills/actors/SKILL.md +3 -3
  48. package/skills/actors/references/persistent-tools.md +3 -1
  49. package/skills/actors/references/recipes.md +2 -2
  50. package/skills/actors/references/runs.md +2 -0
  51. package/skills/actors/scripts/validate-recipe.mjs +12 -0
  52. package/skills/music-player/SKILL.md +53 -0
  53. package/skills/music-player/genapps/music-player.mjs +360 -0
  54. package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
  55. package/skills/music-player/scripts/playback-client.mjs +143 -0
  56. package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
  57. package/dist/skills/media/SKILL.md +0 -44
  58. package/dist/skills/media/recipes/library.json +0 -45
  59. package/dist/skills/media/recipes/playlist-build.json +0 -15
  60. package/dist/skills/media/recipes/playlist-scan.json +0 -11
  61. package/dist/skills/media/scripts/media-utils.mjs +0 -47
  62. package/skills/media/SKILL.md +0 -44
  63. package/skills/media/recipes/library.json +0 -45
  64. package/skills/media/recipes/playlist-build.json +0 -15
  65. package/skills/media/recipes/playlist-scan.json +0 -11
  66. 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.0: Skill-Scoped Music Player
8
+
9
+ - `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.
10
+ - `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.
11
+ - `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.
12
+ - `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.
13
+ - `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.
14
+ - `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.
15
+ - `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.
16
+
17
+ ## 0.48.1: Persistent Skill Composition
18
+
19
+ - `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.
20
+
5
21
  ## 0.48.0: Host-Coordinated Swarms
6
22
 
7
23
  - `Coordinator And Swarm Methodology`: Defined gatewayless host coordination with companion transports as presence only; the coordinator accepts declarative outcomes, creates explicit Runs, stays available, and owns integration/final validation. Reasoning is role-allocated: bounded authors default off, independent reviewers/integrators use medium, and the coordinator selects evidence-worthy fanout. Swarm retains overhead admission, disjoint ownership, isolation, mutation freeze, and event/timer observation.
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
  };
@@ -127,6 +127,9 @@ export function createActorExtensionRuntime(pi) {
127
127
  Pi.registerToolDefinitions(pi, Tools.createCoreActorToolDefinitions({
128
128
  configPath: Paths.EXTENSION_RUNTIME_PATHS.configPath,
129
129
  getActiveTools: () => pi.getActiveTools(),
130
+ getRecipeResolutionContext: () => activeRunContext
131
+ ? getRecipeResolutionContext(activeRunContext)
132
+ : undefined,
130
133
  getRuntimeTool: (name) => Tools.resolveActiveRuntimeTool(name, runtime.getTools(), (activeName) => actorToolDefinitions.get(activeName)),
131
134
  getRuntimeToolStatus: runtime.getToolStatus,
132
135
  handleRuntimeControl: automaticReview.handleControl,
@@ -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,6 +5,7 @@
5
5
  */
6
6
  import * as CommandTemplates from "./command-templates.ts";
7
7
  import * as Config from "./config.ts";
8
+ import * as RecipesContext from "./recipes-context.ts";
8
9
  export interface RegisterToolInput {
9
10
  name?: string;
10
11
  description?: string;
@@ -55,6 +56,7 @@ export interface RegisterToolRuntimeDeps<TContext> {
55
56
  recipeRoot?: string;
56
57
  getToolNameBlocker: (name: string) => string | undefined;
57
58
  getTools: () => Map<string, Config.RegisteredTool>;
59
+ getRecipeResolutionContext?: () => RecipesContext.RecipeResolutionContext | undefined;
58
60
  getActiveTools: () => string[];
59
61
  notify: (ctx: TContext, message: string, type: "info" | "warning" | "error") => void;
60
62
  registerRuntimeTool: (cfg: Config.RegisteredTool) => RuntimeActivation | void;
@@ -233,9 +233,10 @@ function getInputTemplate(value) {
233
233
  throw new Error(ExecutionOutput.formatToolText("Tool template must be a string, object, or sequence."));
234
234
  }
235
235
  function getRegistrationResolutionContext(ctx, deps) {
236
- if (ctx &&
237
- typeof ctx === "object" &&
238
- "recipeResolutionContext" in ctx) {
236
+ const runtimeContext = deps.getRecipeResolutionContext?.();
237
+ if (runtimeContext)
238
+ return runtimeContext;
239
+ if (ctx && typeof ctx === "object" && "recipeResolutionContext" in ctx) {
239
240
  const resolutionContext = ctx.recipeResolutionContext;
240
241
  if (resolutionContext)
241
242
  return resolutionContext;
@@ -415,15 +416,18 @@ function executeRegisterToolUnlocked(params, ctx, deps) {
415
416
  let persisted = false;
416
417
  let activation;
417
418
  let cfg;
419
+ let transactionStage = "persist";
418
420
  try {
419
421
  persistToolRecipe(deps, name, authoredRecipe);
420
422
  persisted = true;
423
+ transactionStage = "persisted_admission";
421
424
  const admitted = RecipesDiscovery.admitUserRecipe(recipePath, resolutionContext);
422
425
  if (!admitted.validated || !admitted.tool) {
423
426
  throw new Error(`Persisted tool recipe admission failed: ${admitted.diagnostics.join("; ")}`);
424
427
  }
425
428
  cfg = admitted.tool;
426
429
  tools.set(name, cfg);
430
+ transactionStage = "runtime_activation";
427
431
  activation = deps.registerRuntimeTool(cfg) ?? undefined;
428
432
  if (activation &&
429
433
  (!activation.host_registered ||
@@ -447,7 +451,7 @@ function executeRegisterToolUnlocked(params, ctx, deps) {
447
451
  tools.delete(name);
448
452
  }
449
453
  deps.setActiveTools(activeBefore);
450
- throw new Error(ExecutionOutput.formatToolText(`Tool registration transaction failed: ${error instanceof Error ? error.message : String(error)}`));
454
+ throw new Error(ExecutionOutput.formatToolText(`Tool registration transaction failed at ${transactionStage} (${resolutionContext.generation}; active Skills: ${activeSkillSummary(resolutionContext)}): ${error instanceof Error ? error.message : String(error)}`));
451
455
  }
452
456
  deps.notify(ctx, `Tool activated: ${name}`, "info");
453
457
  const templateWarnings = CommandTemplates.getCommandTemplateWarnings(typeof cfg.recipe?.template === "object" && !Array.isArray(cfg.recipe.template)
@@ -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
  }
@@ -93,8 +93,10 @@ export function createRuntimeToolDefinition(cfg, exec) {
93
93
  const paramSchema = {};
94
94
  const required = [];
95
95
  const isRecipe = RecipesReferences.isRecipeTool(cfg.template, cfg.recipe);
96
- const isAsyncRecipe = cfg.recipe?.async === true ||
97
- RecipesReferences.isAsyncRecipeReference(cfg.template);
96
+ const isAsyncRecipe = cfg.recipe
97
+ ? cfg.recipe.async === true
98
+ : RecipesReferences.isAsyncRecipeReference(cfg.template);
99
+ const isSingletonRecipe = cfg.recipe?.singleton === true;
98
100
  const recipeTemplate = cfg.recipe?.template ?? RecipesReferences.getRecipeTemplate(cfg.template);
99
101
  const requiredTemplate = recipeTemplate ?? cfg.template;
100
102
  const requiredTemplateConfig = typeof requiredTemplate === "object" && !Array.isArray(requiredTemplate)
@@ -112,7 +114,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
112
114
  const requiredArgs = isRecipe && cfg.storedArgs !== undefined
113
115
  ? new Set(cfg.args.filter((arg) => !Object.hasOwn(cfg.defaults, arg) &&
114
116
  !Object.hasOwn(recipeInlineDefaults, arg)))
115
- : RecipesReferences.isRecipeReference(cfg.template) && !recipeTemplate
117
+ : !cfg.recipe && RecipesReferences.isRecipeReference(cfg.template) && !recipeTemplate
116
118
  ? new Set(cfg.args.filter((arg) => !Object.hasOwn(cfg.defaults, arg)))
117
119
  : Schema.getRequiredToolArgNames(requiredTemplateConfig);
118
120
  for (const arg of cfg.args) {
@@ -122,7 +124,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
122
124
  if (requiredArgs.has(arg))
123
125
  required.push(arg);
124
126
  }
125
- if (isAsyncRecipe)
127
+ if (isAsyncRecipe && !isSingletonRecipe)
126
128
  paramSchema.run_id = Schema.stringSchema("Optional run id override for this async template recipe invocation.");
127
129
  if (isAsyncRecipe) {
128
130
  paramSchema.transport_context = Schema.looseObjectSchema("Optional originating transport route preserved for detached terminal follow-up.");
@@ -145,9 +147,11 @@ export function createRuntimeToolDefinition(cfg, exec) {
145
147
  const input = params;
146
148
  const { run_id, transport_context, ...values } = input;
147
149
  const base = cfg.recipe ? cfg.recipe : { file: String(cfg.template) };
148
- const runId = typeof run_id === "string" && run_id.trim()
149
- ? run_id.trim()
150
- : `${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()}`;
151
155
  const meta = AsyncRuns.startRun({
152
156
  ...base,
153
157
  launch_source: "tool",
@@ -180,7 +184,7 @@ export function createRuntimeToolDefinition(cfg, exec) {
180
184
  return await Execution.executeRegisteredTool(cfg, Schema.normalizeRuntimeValues(ModelContext.withCurrentModelValues(params, ctx), cfg.argTypes), exec, ctx.cwd, signal);
181
185
  }
182
186
  catch (error) {
183
- throw formatRuntimeToolArgumentError(cfg, error, required, isAsyncRecipe);
187
+ throw formatRuntimeToolArgumentError(cfg, error, required, isAsyncRecipe && !isSingletonRecipe);
184
188
  }
185
189
  },
186
190
  };
@@ -14,6 +14,7 @@ export interface ActorToolDefinition {
14
14
  export interface CoreActorToolDefinitionDeps<TContext extends RuntimeToolContext> {
15
15
  configPath: string;
16
16
  getActiveTools: () => string[];
17
+ getRecipeResolutionContext: () => import("./recipes-context.ts").RecipeResolutionContext | undefined;
17
18
  getRuntimeTool: (name: string) => unknown;
18
19
  getRuntimeToolStatus: (name: string) => Record<string, unknown> | undefined;
19
20
  handleRuntimeControl?: (action: string, input: unknown) => Record<string, unknown>;
package/dist/lib/tools.js CHANGED
@@ -30,6 +30,7 @@ export function createCoreActorToolDefinitions(deps) {
30
30
  getActiveTools: deps.getActiveTools,
31
31
  getToolNameBlocker: deps.registryRuntime.getToolNameBlocker,
32
32
  getTools: deps.registryRuntime.getTools,
33
+ getRecipeResolutionContext: deps.getRecipeResolutionContext,
33
34
  notify: deps.registryRuntime.notify,
34
35
  registerRuntimeTool: deps.registryRuntime.registerRuntimeTool,
35
36
  reservedToolNames: RESERVED_TOOL_NAMES,
@@ -52,18 +52,18 @@ direct delegation ≠ named import composition
52
52
  Run Control ≠ actor chat
53
53
  ```
54
54
 
55
- A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn` creates a Run from a Recipe. `register_tool` creates or updates a persistent user tool. A tool is callable in the current session only when activation evidence says `callable_now: true`.
55
+ A Skill Recipe is a maintained component addressed by `<skill>/<recipe>`. `spawn` creates a Run from a Recipe. `register_tool from=<skill>/<recipe>` persists compact logical delegation and activates from the resolved effective contract without copying, symlinking, or ambient re-resolution. A tool is callable in the current session only when activation evidence says `callable_now: true`.
56
56
 
57
57
  `actors` owns generic Recipe/tool/Run mechanics. The owning capability Skill owns capability-specific selection and constraints. `swarm` owns multi-actor decomposition and integration methodology.
58
58
 
59
59
  ## Persistent capability workflow
60
60
 
61
- To make `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,12 +22,14 @@ 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
 
29
29
  `from` means logical direct delegation. The source remains authoritative for async behavior, caller args and types, source defaults, artifacts, Control, and runtime-owned origins. The persistent user Recipe stores only the compact specialization; do not copy inherited fields.
30
30
 
31
+ Resolution uses Pi's authoritative active-session Skill snapshot across every Skill location Pi admits. Registration resolves and validates the maintained source, persists only its logical `<skill>/<recipe>` reference plus caller specialization, and projects the already-resolved effective contract into the runtime tool. Never replace this composition with a copied Recipe, absolute helper path, symlink, or ambient runtime re-resolution.
32
+
31
33
  Use `description` to narrow agent-facing intent when useful. Every supplied default must name a caller-owned source arg and satisfy its type or enum. Never default runtime-owned origins.
32
34
 
33
35
  ## Prove registration
@@ -7,14 +7,14 @@ A Recipe is a reusable executable definition. Address a maintained component as
7
7
  Use direct delegation when the root remains fundamentally the same capability under a different persistent name, description, or caller default:
8
8
 
9
9
  ```text
10
- 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: