@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.
- package/AGENTS.md +1 -1
- package/BACKLOG.md +4 -1
- package/CHANGELOG.md +12 -0
- package/README.md +4 -4
- package/dist/lib/async-runs.d.ts +7 -0
- package/dist/lib/async-runs.js +63 -8
- package/dist/lib/file-state.js +15 -1
- package/dist/lib/inspector.js +4 -0
- package/dist/lib/observability.js +2 -5
- package/dist/lib/recipes-discovery.js +3 -1
- package/dist/lib/recipes-references.d.ts +3 -0
- package/dist/lib/recipes-references.js +15 -0
- package/dist/lib/runs-start.d.ts +2 -1
- package/dist/lib/runs-start.js +11 -5
- package/dist/lib/tools-local.js +8 -5
- package/dist/skills/actors/SKILL.md +2 -2
- package/dist/skills/actors/references/persistent-tools.md +1 -1
- package/dist/skills/actors/references/recipes.md +2 -2
- package/dist/skills/actors/references/runs.md +2 -0
- package/dist/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/dist/skills/music-player/SKILL.md +53 -0
- package/dist/skills/music-player/genapps/music-player.mjs +360 -0
- package/{skills/media/recipes/player.json → dist/skills/music-player/recipes/playback.json} +4 -3
- package/dist/skills/music-player/scripts/playback-client.mjs +143 -0
- package/dist/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/docs/async-runs.md +1 -1
- package/docs/recipe-library.md +2 -5
- package/docs/template-recipes.md +7 -0
- package/docs/tool-registry.md +1 -1
- package/lib/async-runs.ts +92 -15
- package/lib/file-state.ts +14 -1
- package/lib/inspector.ts +4 -0
- package/lib/observability.ts +2 -5
- package/lib/recipes-discovery.ts +2 -1
- package/lib/recipes-references.ts +20 -0
- package/lib/runs-start.ts +16 -6
- package/lib/tools-local.ts +6 -4
- package/package.json +1 -1
- package/skills/actors/SKILL.md +2 -2
- package/skills/actors/references/persistent-tools.md +1 -1
- package/skills/actors/references/recipes.md +2 -2
- package/skills/actors/references/runs.md +2 -0
- package/skills/actors/scripts/validate-recipe.mjs +12 -0
- package/skills/music-player/SKILL.md +53 -0
- package/skills/music-player/genapps/music-player.mjs +360 -0
- package/{dist/skills/media/recipes/player.json → skills/music-player/recipes/playback.json} +4 -3
- package/skills/music-player/scripts/playback-client.mjs +143 -0
- package/skills/{media/scripts/music-player.mjs → music-player/scripts/playback.mjs} +596 -53
- package/dist/skills/media/SKILL.md +0 -44
- package/dist/skills/media/recipes/library.json +0 -45
- package/dist/skills/media/recipes/playlist-build.json +0 -15
- package/dist/skills/media/recipes/playlist-scan.json +0 -11
- package/dist/skills/media/scripts/media-utils.mjs +0 -47
- package/skills/media/SKILL.md +0 -44
- package/skills/media/recipes/library.json +0 -45
- package/skills/media/recipes/playlist-build.json +0 -15
- package/skills/media/recipes/playlist-scan.json +0 -11
- package/skills/media/scripts/media-utils.mjs +0 -47
package/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
|
|
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
|
-
|
|
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,18 @@
|
|
|
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
|
+
|
|
5
17
|
## 0.48.1: Persistent Skill Composition
|
|
6
18
|
|
|
7
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.
|
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,
|
|
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=
|
|
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=
|
|
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
|
-
- `
|
|
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:
|
package/dist/lib/async-runs.d.ts
CHANGED
|
@@ -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";
|
package/dist/lib/async-runs.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/dist/lib/file-state.js
CHANGED
|
@@ -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
|
-
|
|
296
|
+
replaceFileWithRetry(tempPath, path);
|
|
283
297
|
}
|
|
284
298
|
catch (error) {
|
|
285
299
|
try {
|
package/dist/lib/inspector.js
CHANGED
|
@@ -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 (
|
|
796
|
+
if (!activeLineKeys.has(key))
|
|
800
797
|
previousLineCounts.delete(key);
|
|
801
798
|
for (const key of seenEventIds.keys())
|
|
802
|
-
if (
|
|
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
|
-
|
|
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
|
: {}),
|
package/dist/lib/runs-start.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|
package/dist/lib/runs-start.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
}
|
package/dist/lib/tools-local.js
CHANGED
|
@@ -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 =
|
|
150
|
-
?
|
|
151
|
-
:
|
|
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 `
|
|
61
|
+
To make `music-player/playback` callable as `music_player` with a default source:
|
|
62
62
|
|
|
63
63
|
```text
|
|
64
64
|
register_tool
|
|
65
65
|
name=music_player
|
|
66
|
-
from=
|
|
66
|
+
from=music-player/playback
|
|
67
67
|
defaults={"source":"~/Music/1MIX"}
|
|
68
68
|
```
|
|
69
69
|
|
|
@@ -7,14 +7,14 @@ A Recipe is a reusable executable definition. Address a maintained component as
|
|
|
7
7
|
Use direct delegation when the root remains fundamentally the same capability under a different persistent name, description, or caller default:
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
|
|
10
|
+
music-player/playback
|
|
11
11
|
→ music_player with a default source
|
|
12
12
|
```
|
|
13
13
|
|
|
14
14
|
For agent-facing persistent specialization, use:
|
|
15
15
|
|
|
16
16
|
```text
|
|
17
|
-
register_tool from=
|
|
17
|
+
register_tool from=music-player/playback defaults={"source":"~/Music/1MIX"}
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
The delegated root inherits async behavior, args and types, defaults, artifacts, Control, and runtime-owned origins. Do not copy those fields into the wrapper.
|
|
@@ -10,6 +10,8 @@ spawn recipe=<skill>/<recipe> values={...} as=run:<id>
|
|
|
10
10
|
|
|
11
11
|
Use the owning capability Skill to choose the Recipe and capability-specific values. Retain the returned Run id. A spawn result reports `launch_kind: "spawn"`; it is never evidence of registered-tool invocation.
|
|
12
12
|
|
|
13
|
+
A rare Skill Recipe may declare `singleton: true`. Do not pass `as`: the runtime derives `run:<skill>` plus the canonical `<skill>/<recipe>` identity, allows at most one singleton Recipe per Skill, and returns the same compatible healthy active Run instead of launching a duplicate. Contradictory Recipe identity, startup values, Control, or ownership fails closed. Delegation inherits both singleton identities instead of retargeting them. A terminal result is never reused; after its runner exits, restart keeps the logical Run id but creates a new fenced generation. Continuity still depends on actor-owned validated state, not the Run id alone.
|
|
14
|
+
|
|
13
15
|
## Observe
|
|
14
16
|
|
|
15
17
|
Normally wait for terminal follow-up. Inspect only when requested, when meaningful attention arrives, or when the Run is overdue or blocked:
|
|
@@ -106,6 +106,7 @@ function scanSkillRecipes(target) {
|
|
|
106
106
|
if (!existsSync(recipeDir)) continue;
|
|
107
107
|
const direct = readdirSync(recipeDir, { withFileTypes: true });
|
|
108
108
|
const byStem = new Map();
|
|
109
|
+
const singletonFiles = [];
|
|
109
110
|
for (const entry of direct) {
|
|
110
111
|
const path = join(recipeDir, entry.name);
|
|
111
112
|
if (entry.isDirectory()) {
|
|
@@ -120,6 +121,7 @@ function scanSkillRecipes(target) {
|
|
|
120
121
|
}
|
|
121
122
|
if (!entry.isFile() || !/\.(?:json|md)$/.test(entry.name)) continue;
|
|
122
123
|
files.push(path);
|
|
124
|
+
if (readRawRecipeConfig(path)?.singleton === true) singletonFiles.push(path);
|
|
123
125
|
const stem = basename(entry.name, extname(entry.name));
|
|
124
126
|
const previous = byStem.get(stem);
|
|
125
127
|
if (previous) {
|
|
@@ -130,6 +132,13 @@ function scanSkillRecipes(target) {
|
|
|
130
132
|
});
|
|
131
133
|
} else byStem.set(stem, path);
|
|
132
134
|
}
|
|
135
|
+
if (singletonFiles.length > 1) {
|
|
136
|
+
failures.push({
|
|
137
|
+
file: recipeDir,
|
|
138
|
+
ok: false,
|
|
139
|
+
error: `Skill ${skill.name} declares more than one singleton Recipe: ${singletonFiles.map((file) => basename(file)).join(", ")}`,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
133
142
|
}
|
|
134
143
|
let skillContext = packageSkillContext;
|
|
135
144
|
try {
|
|
@@ -227,6 +236,8 @@ function qaDiagnostics(file, config) {
|
|
|
227
236
|
const warnings = [];
|
|
228
237
|
if (config.mailbox !== undefined)
|
|
229
238
|
diagnostics.push("recipe.mailbox was removed; use control actions and Trace events");
|
|
239
|
+
if (config.singleton === true && config.async !== true)
|
|
240
|
+
diagnostics.push("singleton: requires async: true");
|
|
230
241
|
diagnostics.push(...validateArtifactDeclarations(config));
|
|
231
242
|
diagnostics.push(...validatePortablePaths(config));
|
|
232
243
|
diagnostics.push(...validateHelperPaths(file, config));
|
|
@@ -260,6 +271,7 @@ function validateFile(file, qa = false, skillContext = packageSkillContext) {
|
|
|
260
271
|
ok: qaOk(qaReport),
|
|
261
272
|
name: config.name ?? "",
|
|
262
273
|
async: Boolean(config.async),
|
|
274
|
+
singleton: Boolean(config.singleton),
|
|
263
275
|
args: Array.isArray(config.args) ? config.args : [],
|
|
264
276
|
defaults:
|
|
265
277
|
config.defaults && typeof config.defaults === "object"
|
|
@@ -0,0 +1,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.
|