@llblab/pi-actors 0.21.0 → 0.22.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/BACKLOG.md +39 -0
- package/CHANGELOG.md +13 -1
- package/dist/lib/actor-rooms.js +18 -0
- package/dist/lib/async-runs.d.ts +19 -1
- package/dist/lib/async-runs.js +201 -10
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +13 -4
- package/docs/actor-messages.md +5 -3
- package/docs/async-runs.md +4 -2
- package/docs/recipe-library.md +11 -4
- package/lib/actor-rooms.ts +23 -0
- package/lib/async-runs.ts +261 -15
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +26 -5
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/scripts/coordinator.mjs +227 -114
- package/scripts/locker.mjs +30 -9
- package/scripts/music-player.mjs +401 -94
- package/skills/actors/SKILL.md +1 -1
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
package/scripts/music-player.mjs
CHANGED
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { spawn
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
accessSync,
|
|
5
|
-
closeSync,
|
|
6
6
|
constants,
|
|
7
7
|
existsSync,
|
|
8
8
|
mkdirSync,
|
|
9
|
-
openSync,
|
|
10
9
|
readdirSync,
|
|
11
10
|
readFileSync,
|
|
12
|
-
readSync,
|
|
13
11
|
rmSync,
|
|
14
12
|
statSync,
|
|
13
|
+
watch,
|
|
15
14
|
writeFileSync,
|
|
16
|
-
writeSync,
|
|
17
15
|
} from "node:fs";
|
|
18
16
|
import { homedir } from "node:os";
|
|
19
17
|
import {
|
|
@@ -26,7 +24,16 @@ import {
|
|
|
26
24
|
resolve,
|
|
27
25
|
} from "node:path";
|
|
28
26
|
|
|
29
|
-
const AUDIO_EXTENSIONS = new Set([
|
|
27
|
+
const AUDIO_EXTENSIONS = new Set([
|
|
28
|
+
".aac",
|
|
29
|
+
".aif",
|
|
30
|
+
".aiff",
|
|
31
|
+
".flac",
|
|
32
|
+
".m4a",
|
|
33
|
+
".mp3",
|
|
34
|
+
".ogg",
|
|
35
|
+
".wav",
|
|
36
|
+
]);
|
|
30
37
|
const PLAYLIST_EXTENSIONS = new Set([".m3u", ".m3u8", ".txt"]);
|
|
31
38
|
const CONTROL_COMMANDS = new Set([
|
|
32
39
|
"play",
|
|
@@ -47,9 +54,9 @@ function usage() {
|
|
|
47
54
|
music-player.mjs control <state-dir> <play|pause|toggle|next|previous|stop|status>
|
|
48
55
|
|
|
49
56
|
Runs a small foreground music player so pi-actors can own it as an actor run.
|
|
50
|
-
Actor message bodies are adapted to
|
|
57
|
+
Actor message bodies are adapted to queued mailbox commands in <state-dir>/inbox.jsonl.
|
|
51
58
|
Prefer message to=run:<run> type=player.<command> body=<command>, or use direct control commands below.
|
|
52
|
-
Supported players: auto, mpv, ffplay, cvlc, play.
|
|
59
|
+
Supported players: auto, mpv, afplay, ffplay, cvlc, play, wmp.
|
|
53
60
|
`);
|
|
54
61
|
}
|
|
55
62
|
|
|
@@ -58,15 +65,6 @@ function fail(message, code = 1) {
|
|
|
58
65
|
process.exit(code);
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
function ensureUnixFifoSupport() {
|
|
62
|
-
if (process.platform === "win32") {
|
|
63
|
-
fail(
|
|
64
|
-
"Unix FIFO controls are not available on native Windows; use WSL/Linux/macOS or a recipe-specific Windows transport.",
|
|
65
|
-
70,
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
68
|
function sleep(ms) {
|
|
71
69
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
72
70
|
}
|
|
@@ -105,15 +103,6 @@ function isFile(path) {
|
|
|
105
103
|
}
|
|
106
104
|
}
|
|
107
105
|
|
|
108
|
-
function isFifo(path) {
|
|
109
|
-
try {
|
|
110
|
-
const mode = statSync(path).mode & constants.S_IFMT;
|
|
111
|
-
return mode === constants.S_IFIFO;
|
|
112
|
-
} catch {
|
|
113
|
-
return false;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
106
|
function have(command) {
|
|
118
107
|
const paths = (process.env.PATH || "").split(delimiter).filter(Boolean);
|
|
119
108
|
const extensions =
|
|
@@ -158,15 +147,61 @@ function normalizeVolume(value) {
|
|
|
158
147
|
return Math.min(Number(value), 100);
|
|
159
148
|
}
|
|
160
149
|
|
|
150
|
+
function powershellCommand() {
|
|
151
|
+
return have("powershell") ? "powershell.exe" : undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function windowsMediaPlayerExecutable() {
|
|
155
|
+
if (process.platform !== "win32") return undefined;
|
|
156
|
+
const roots = [
|
|
157
|
+
process.env.ProgramFiles,
|
|
158
|
+
process.env["ProgramFiles(x86)"],
|
|
159
|
+
process.env.SystemDrive ? join(process.env.SystemDrive, "Program Files") : undefined,
|
|
160
|
+
process.env.SystemDrive
|
|
161
|
+
? join(process.env.SystemDrive, "Program Files (x86)")
|
|
162
|
+
: undefined,
|
|
163
|
+
];
|
|
164
|
+
for (const root of roots.filter(Boolean)) {
|
|
165
|
+
const candidate = join(root, "Windows Media Player", "wmplayer.exe");
|
|
166
|
+
if (exists(candidate)) return candidate;
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function havePlayer(player) {
|
|
172
|
+
if (player === "wmp") {
|
|
173
|
+
return (
|
|
174
|
+
process.platform === "win32" &&
|
|
175
|
+
Boolean(powershellCommand()) &&
|
|
176
|
+
Boolean(windowsMediaPlayerExecutable())
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
return have(player);
|
|
180
|
+
}
|
|
181
|
+
|
|
161
182
|
function selectPlayer(requested) {
|
|
162
183
|
let selected = requested;
|
|
163
184
|
if (selected === "auto") {
|
|
164
|
-
|
|
185
|
+
let candidates;
|
|
186
|
+
if (process.platform === "win32") {
|
|
187
|
+
candidates = ["wmp", "mpv", "ffplay", "cvlc"];
|
|
188
|
+
} else if (process.platform === "darwin") {
|
|
189
|
+
candidates = ["mpv", "afplay", "ffplay", "cvlc", "play"];
|
|
190
|
+
} else {
|
|
191
|
+
candidates = ["mpv", "ffplay", "cvlc", "play"];
|
|
192
|
+
}
|
|
193
|
+
selected = candidates.find(havePlayer) || selected;
|
|
165
194
|
}
|
|
166
|
-
if (!["mpv", "ffplay", "cvlc", "play"].includes(selected)) {
|
|
195
|
+
if (!["mpv", "afplay", "ffplay", "cvlc", "play", "wmp"].includes(selected)) {
|
|
167
196
|
fail(`unsupported player: ${requested}`, 2);
|
|
168
197
|
}
|
|
169
|
-
if (!
|
|
198
|
+
if (selected === "wmp" && !havePlayer(selected)) {
|
|
199
|
+
fail(
|
|
200
|
+
"player not found: wmp requires native Windows, powershell.exe, and wmplayer.exe under Program Files/Windows Media Player",
|
|
201
|
+
127,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (!havePlayer(selected)) fail(`player not found: ${selected}`, 127);
|
|
170
205
|
return selected;
|
|
171
206
|
}
|
|
172
207
|
|
|
@@ -232,7 +267,63 @@ function loadPlaylist(source) {
|
|
|
232
267
|
return tracks;
|
|
233
268
|
}
|
|
234
269
|
|
|
235
|
-
function
|
|
270
|
+
function windowsMediaPlayerCommand(ctx, volume, track) {
|
|
271
|
+
const command = powershellCommand();
|
|
272
|
+
const wmplayer = windowsMediaPlayerExecutable();
|
|
273
|
+
if (!command || !wmplayer) {
|
|
274
|
+
fail(
|
|
275
|
+
"Windows Media Player backend requires powershell.exe and wmplayer.exe",
|
|
276
|
+
127,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
const script = String.raw`
|
|
280
|
+
$ErrorActionPreference = 'Stop'
|
|
281
|
+
$track = $args[0]
|
|
282
|
+
$volume = [int]$args[1]
|
|
283
|
+
$controlFile = $args[2]
|
|
284
|
+
$wmplayerExe = $args[3]
|
|
285
|
+
if (-not (Test-Path -LiteralPath $wmplayerExe)) { throw "wmplayer.exe not found: $wmplayerExe" }
|
|
286
|
+
$player = New-Object -ComObject WMPlayer.OCX
|
|
287
|
+
$player.settings.volume = [Math]::Min([Math]::Max($volume, 0), 100)
|
|
288
|
+
$player.URL = $track
|
|
289
|
+
$player.controls.play()
|
|
290
|
+
try {
|
|
291
|
+
while ($true) {
|
|
292
|
+
Start-Sleep -Milliseconds 100
|
|
293
|
+
if (Test-Path -LiteralPath $controlFile) {
|
|
294
|
+
$control = (Get-Content -LiteralPath $controlFile -Raw -ErrorAction SilentlyContinue).Trim().ToLowerInvariant()
|
|
295
|
+
Clear-Content -LiteralPath $controlFile -ErrorAction SilentlyContinue
|
|
296
|
+
switch ($control) {
|
|
297
|
+
'play' { $player.controls.play() }
|
|
298
|
+
'pause' { $player.controls.pause() }
|
|
299
|
+
'stop' { $player.controls.stop(); break }
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if ($player.playState -eq 1 -or $player.playState -eq 8) { break }
|
|
303
|
+
}
|
|
304
|
+
} finally {
|
|
305
|
+
$player.close()
|
|
306
|
+
}
|
|
307
|
+
`;
|
|
308
|
+
return [
|
|
309
|
+
command,
|
|
310
|
+
[
|
|
311
|
+
"-NoLogo",
|
|
312
|
+
"-NoProfile",
|
|
313
|
+
"-NonInteractive",
|
|
314
|
+
"-ExecutionPolicy",
|
|
315
|
+
"Bypass",
|
|
316
|
+
"-Command",
|
|
317
|
+
script,
|
|
318
|
+
track,
|
|
319
|
+
String(volume),
|
|
320
|
+
ctx.playerControlFile,
|
|
321
|
+
wmplayer,
|
|
322
|
+
],
|
|
323
|
+
];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function playerCommand(ctx, player, volume, track) {
|
|
236
327
|
switch (player) {
|
|
237
328
|
case "mpv":
|
|
238
329
|
return [
|
|
@@ -245,6 +336,11 @@ function playerCommand(player, volume, track) {
|
|
|
245
336
|
track,
|
|
246
337
|
],
|
|
247
338
|
];
|
|
339
|
+
case "afplay":
|
|
340
|
+
return [
|
|
341
|
+
"afplay",
|
|
342
|
+
["-v", String(Math.min(Math.max(volume / 100, 0), 1)), track],
|
|
343
|
+
];
|
|
248
344
|
case "ffplay":
|
|
249
345
|
return [
|
|
250
346
|
"ffplay",
|
|
@@ -274,6 +370,8 @@ function playerCommand(player, volume, track) {
|
|
|
274
370
|
];
|
|
275
371
|
case "play":
|
|
276
372
|
return ["play", ["-q", track]];
|
|
373
|
+
case "wmp":
|
|
374
|
+
return windowsMediaPlayerCommand(ctx, volume, track);
|
|
277
375
|
default:
|
|
278
376
|
fail(`unsupported player: ${player}`, 2);
|
|
279
377
|
}
|
|
@@ -342,19 +440,39 @@ function setState(ctx, state) {
|
|
|
342
440
|
function sendSignalToCurrent(ctx, signal) {
|
|
343
441
|
const pid = Number(readText(ctx.pidFile).trim());
|
|
344
442
|
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
443
|
+
if (process.platform !== "win32") {
|
|
444
|
+
try {
|
|
445
|
+
process.kill(-pid, signal);
|
|
446
|
+
return;
|
|
447
|
+
} catch {
|
|
448
|
+
// Fall through to the direct child fallback.
|
|
449
|
+
}
|
|
350
450
|
}
|
|
351
451
|
try {
|
|
352
|
-
process.kill(
|
|
452
|
+
process.kill(pid, signal);
|
|
353
453
|
} catch {
|
|
354
454
|
// Best effort control signal.
|
|
355
455
|
}
|
|
356
456
|
}
|
|
357
457
|
|
|
458
|
+
function controlCurrentPlayback(ctx, command) {
|
|
459
|
+
if (ctx.current?.player === "wmp") {
|
|
460
|
+
writeText(ctx.playerControlFile, command);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
switch (command) {
|
|
464
|
+
case "play":
|
|
465
|
+
sendSignalToCurrent(ctx, "SIGCONT");
|
|
466
|
+
break;
|
|
467
|
+
case "pause":
|
|
468
|
+
sendSignalToCurrent(ctx, "SIGSTOP");
|
|
469
|
+
break;
|
|
470
|
+
case "stop":
|
|
471
|
+
sendSignalToCurrent(ctx, "SIGTERM");
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
358
476
|
function handleControl(ctx, input) {
|
|
359
477
|
const command = input.trim().toLowerCase();
|
|
360
478
|
if (!command) return;
|
|
@@ -366,85 +484,279 @@ function handleControl(ctx, input) {
|
|
|
366
484
|
case "play":
|
|
367
485
|
case "resume":
|
|
368
486
|
setState(ctx, "playing");
|
|
369
|
-
|
|
487
|
+
controlCurrentPlayback(ctx, "play");
|
|
370
488
|
break;
|
|
371
489
|
case "pause":
|
|
372
490
|
setState(ctx, "paused");
|
|
373
|
-
|
|
491
|
+
controlCurrentPlayback(ctx, "pause");
|
|
374
492
|
break;
|
|
375
493
|
case "toggle": {
|
|
376
494
|
const current = readText(ctx.stateFile).trim();
|
|
377
495
|
if (current === "paused") {
|
|
378
496
|
setState(ctx, "playing");
|
|
379
|
-
|
|
497
|
+
controlCurrentPlayback(ctx, "play");
|
|
380
498
|
} else {
|
|
381
499
|
setState(ctx, "paused");
|
|
382
|
-
|
|
500
|
+
controlCurrentPlayback(ctx, "pause");
|
|
383
501
|
}
|
|
384
502
|
break;
|
|
385
503
|
}
|
|
386
504
|
case "next":
|
|
387
505
|
writeText(ctx.commandFile, "next");
|
|
388
|
-
|
|
506
|
+
controlCurrentPlayback(ctx, "stop");
|
|
389
507
|
break;
|
|
390
508
|
case "previous":
|
|
391
509
|
case "prev":
|
|
392
510
|
writeText(ctx.commandFile, "previous");
|
|
393
|
-
|
|
511
|
+
controlCurrentPlayback(ctx, "stop");
|
|
394
512
|
break;
|
|
395
513
|
case "stop":
|
|
396
514
|
writeText(ctx.commandFile, "stop");
|
|
397
|
-
|
|
515
|
+
controlCurrentPlayback(ctx, "stop");
|
|
398
516
|
break;
|
|
399
517
|
case "status":
|
|
400
518
|
break;
|
|
401
519
|
}
|
|
402
520
|
}
|
|
403
521
|
|
|
404
|
-
function
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
522
|
+
function runJsonFile(ctx) {
|
|
523
|
+
return join(ctx.stateDir, "run.json");
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function readJsonFile(path, fallback) {
|
|
527
|
+
try {
|
|
528
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
529
|
+
} catch {
|
|
530
|
+
return fallback;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function writeJsonFile(path, value) {
|
|
535
|
+
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function updateRunControlMetadata(ctx) {
|
|
539
|
+
const path = runJsonFile(ctx);
|
|
540
|
+
const run = readJsonFile(path, undefined);
|
|
541
|
+
if (!run || typeof run !== "object") return;
|
|
542
|
+
writeJsonFile(path, {
|
|
543
|
+
...run,
|
|
544
|
+
control: { path: ctx.inboxFile, type: "mailbox" },
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function acquireInboxLock(ctx) {
|
|
549
|
+
const lockDir = join(ctx.stateDir, ".inbox.lock");
|
|
550
|
+
const started = Date.now();
|
|
551
|
+
while (true) {
|
|
552
|
+
try {
|
|
553
|
+
mkdirSync(lockDir);
|
|
554
|
+
writeJsonFile(join(lockDir, "owner.json"), {
|
|
555
|
+
created_at: new Date().toISOString(),
|
|
556
|
+
pid: process.pid,
|
|
557
|
+
});
|
|
558
|
+
return () => rmSync(lockDir, { recursive: true, force: true });
|
|
559
|
+
} catch (error) {
|
|
560
|
+
try {
|
|
561
|
+
const stat = statSync(lockDir);
|
|
562
|
+
if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) {
|
|
563
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
} catch {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (Date.now() - started > 5000) throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function readInboxMessages(ctx) {
|
|
575
|
+
if (!exists(ctx.inboxFile)) return [];
|
|
576
|
+
return readFileSync(ctx.inboxFile, "utf8")
|
|
577
|
+
.split("\n")
|
|
578
|
+
.filter((line) => line.trim())
|
|
579
|
+
.map((line) => {
|
|
580
|
+
try {
|
|
581
|
+
return JSON.parse(line);
|
|
582
|
+
} catch {
|
|
583
|
+
return undefined;
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
.filter(Boolean);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function writeInboxMessages(ctx, messages) {
|
|
590
|
+
writeFileSync(
|
|
591
|
+
ctx.inboxFile,
|
|
592
|
+
messages.length
|
|
593
|
+
? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
|
|
594
|
+
: "",
|
|
595
|
+
"utf8",
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function commandFromInboxMessage(message) {
|
|
600
|
+
if (
|
|
601
|
+
typeof message.body === "string" &&
|
|
602
|
+
CONTROL_COMMANDS.has(message.body.trim())
|
|
603
|
+
) {
|
|
604
|
+
return message.body.trim();
|
|
605
|
+
}
|
|
606
|
+
if (message.body && typeof message.body === "object") {
|
|
607
|
+
const command = String(message.body.command ?? "").trim();
|
|
608
|
+
if (CONTROL_COMMANDS.has(command)) return command;
|
|
609
|
+
}
|
|
610
|
+
if (typeof message.type === "string") {
|
|
611
|
+
if (message.type === "control.stop" || message.type === "control.cancel")
|
|
612
|
+
return "stop";
|
|
613
|
+
const match = /^player\.(.+)$/.exec(message.type);
|
|
614
|
+
if (match && CONTROL_COMMANDS.has(match[1])) return match[1];
|
|
615
|
+
}
|
|
616
|
+
return undefined;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function runtimeWakeFile(ctx) {
|
|
620
|
+
return join(ctx.stateDir, "wake.jsonl");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function notifyMailboxWake(ctx, reason = "run.message") {
|
|
624
|
+
try {
|
|
625
|
+
writeText(
|
|
626
|
+
runtimeWakeFile(ctx),
|
|
627
|
+
`${JSON.stringify({
|
|
628
|
+
actor: `run:${basename(ctx.stateDir)}`,
|
|
629
|
+
id: randomUUID(),
|
|
630
|
+
metadata: { command: "music-player" },
|
|
631
|
+
reason,
|
|
632
|
+
state_dir: ctx.stateDir,
|
|
633
|
+
ts: new Date().toISOString(),
|
|
634
|
+
})}\n`,
|
|
635
|
+
"a",
|
|
636
|
+
);
|
|
637
|
+
} catch {
|
|
638
|
+
// Wake records are advisory; the inbox remains the durable source of truth.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function appendInboxCommand(ctx, command) {
|
|
643
|
+
const release = acquireInboxLock(ctx);
|
|
644
|
+
try {
|
|
645
|
+
const ts = new Date().toISOString();
|
|
646
|
+
const messages = readInboxMessages(ctx);
|
|
647
|
+
messages.push({
|
|
648
|
+
body: command,
|
|
649
|
+
from: "coordinator",
|
|
650
|
+
id: randomUUID(),
|
|
651
|
+
queued_at: ts,
|
|
652
|
+
received_at: ts,
|
|
653
|
+
status: "queued",
|
|
654
|
+
to: `run:${basename(ctx.stateDir)}`,
|
|
655
|
+
type: `player.${command}`,
|
|
656
|
+
});
|
|
657
|
+
writeInboxMessages(ctx, messages);
|
|
658
|
+
} finally {
|
|
659
|
+
release();
|
|
660
|
+
}
|
|
661
|
+
notifyMailboxWake(ctx);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function claimInboxCommands(ctx) {
|
|
665
|
+
const release = acquireInboxLock(ctx);
|
|
666
|
+
try {
|
|
667
|
+
const messages = readInboxMessages(ctx);
|
|
668
|
+
const commands = [];
|
|
669
|
+
let changed = false;
|
|
670
|
+
const claimedAt = new Date().toISOString();
|
|
671
|
+
for (const message of messages) {
|
|
672
|
+
if (message.status !== "queued") continue;
|
|
673
|
+
const command = commandFromInboxMessage(message);
|
|
674
|
+
if (!command) {
|
|
675
|
+
message.failed_at = claimedAt;
|
|
676
|
+
message.status = "failed";
|
|
677
|
+
message.error = "Unsupported music-player command";
|
|
678
|
+
changed = true;
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
message.claimed_at = claimedAt;
|
|
682
|
+
message.claimed_by = `run:${basename(ctx.stateDir)}`;
|
|
683
|
+
message.status = "claimed";
|
|
684
|
+
commands.push({ command, id: message.id });
|
|
685
|
+
changed = true;
|
|
686
|
+
}
|
|
687
|
+
if (changed) writeInboxMessages(ctx, messages);
|
|
688
|
+
return commands;
|
|
689
|
+
} finally {
|
|
690
|
+
release();
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function finalizeInboxCommand(ctx, id, status, error) {
|
|
695
|
+
if (!id) return;
|
|
696
|
+
const release = acquireInboxLock(ctx);
|
|
697
|
+
try {
|
|
698
|
+
const messages = readInboxMessages(ctx);
|
|
699
|
+
const timestamp = new Date().toISOString();
|
|
700
|
+
let changed = false;
|
|
701
|
+
for (const message of messages) {
|
|
702
|
+
if (message.id !== id) continue;
|
|
703
|
+
message.status = status;
|
|
704
|
+
if (status === "handled") message.handled_at = timestamp;
|
|
705
|
+
else message.failed_at = timestamp;
|
|
706
|
+
if (error) message.error = error;
|
|
707
|
+
changed = true;
|
|
708
|
+
}
|
|
709
|
+
if (changed) writeInboxMessages(ctx, messages);
|
|
710
|
+
} finally {
|
|
711
|
+
release();
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function inboxSignature(ctx) {
|
|
716
|
+
try {
|
|
717
|
+
const stat = statSync(ctx.inboxFile);
|
|
718
|
+
return `${stat.size}:${stat.mtimeMs}`;
|
|
719
|
+
} catch {
|
|
720
|
+
return "missing";
|
|
411
721
|
}
|
|
412
722
|
}
|
|
413
723
|
|
|
414
724
|
function startControlLoop(ctx) {
|
|
415
|
-
makeFifo(ctx.controlFifo);
|
|
416
|
-
const fd = openSync(ctx.controlFifo, constants.O_RDWR | constants.O_NONBLOCK);
|
|
417
|
-
let carry = "";
|
|
418
725
|
let closed = false;
|
|
726
|
+
let dirty = true;
|
|
727
|
+
let watcher;
|
|
728
|
+
try {
|
|
729
|
+
watcher = watch(ctx.stateDir, { persistent: false }, (_eventType, file) => {
|
|
730
|
+
const name = file ? String(file) : "";
|
|
731
|
+
if (!name || name === basename(ctx.inboxFile) || name === basename(runtimeWakeFile(ctx))) {
|
|
732
|
+
dirty = true;
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
} catch {
|
|
736
|
+
// fs.watch is advisory; the signature poll below is the portable fallback.
|
|
737
|
+
}
|
|
419
738
|
const close = () => {
|
|
420
|
-
if (closed) return;
|
|
421
739
|
closed = true;
|
|
422
|
-
|
|
423
|
-
closeSync(fd);
|
|
424
|
-
} catch {}
|
|
740
|
+
watcher?.close();
|
|
425
741
|
};
|
|
426
742
|
const promise = (async () => {
|
|
427
|
-
|
|
428
|
-
while (!ctx.stopping) {
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
} catch (error) {
|
|
440
|
-
if (ctx.stopping || closed) break;
|
|
441
|
-
if (["EAGAIN", "EWOULDBLOCK"].includes(error.code)) {
|
|
442
|
-
await sleep(50);
|
|
443
|
-
continue;
|
|
743
|
+
let lastSignature = "";
|
|
744
|
+
while (!ctx.stopping && !closed) {
|
|
745
|
+
const signature = inboxSignature(ctx);
|
|
746
|
+
if (dirty || signature !== lastSignature) {
|
|
747
|
+
dirty = false;
|
|
748
|
+
for (const { command, id } of claimInboxCommands(ctx)) {
|
|
749
|
+
try {
|
|
750
|
+
handleControl(ctx, command);
|
|
751
|
+
finalizeInboxCommand(ctx, id, "handled");
|
|
752
|
+
} catch (error) {
|
|
753
|
+
finalizeInboxCommand(ctx, id, "failed", error.message);
|
|
754
|
+
}
|
|
444
755
|
}
|
|
445
|
-
|
|
446
|
-
|
|
756
|
+
lastSignature = inboxSignature(ctx);
|
|
757
|
+
continue;
|
|
447
758
|
}
|
|
759
|
+
await sleep(250);
|
|
448
760
|
}
|
|
449
761
|
})();
|
|
450
762
|
return { close, promise };
|
|
@@ -452,9 +764,11 @@ function startControlLoop(ctx) {
|
|
|
452
764
|
|
|
453
765
|
function playOne(ctx, player, volume, track, index, count) {
|
|
454
766
|
return new Promise((resolveDone) => {
|
|
455
|
-
|
|
767
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
768
|
+
const [command, args] = playerCommand(ctx, player, volume, track);
|
|
456
769
|
writeStatus(ctx, "playing", index, count, track, player, "");
|
|
457
770
|
const child = spawn(command, args, {
|
|
771
|
+
detached: process.platform !== "win32",
|
|
458
772
|
stdio: ["ignore", "inherit", "inherit"],
|
|
459
773
|
});
|
|
460
774
|
ctx.child = child;
|
|
@@ -483,7 +797,6 @@ function readAndClearCommand(ctx) {
|
|
|
483
797
|
}
|
|
484
798
|
|
|
485
799
|
async function playMain(args) {
|
|
486
|
-
ensureUnixFifoSupport();
|
|
487
800
|
const [
|
|
488
801
|
sourceArg,
|
|
489
802
|
loopArg = "true",
|
|
@@ -505,19 +818,20 @@ async function playMain(args) {
|
|
|
505
818
|
mkdirSync(stateDir, { recursive: true });
|
|
506
819
|
const ctx = {
|
|
507
820
|
commandFile: join(stateDir, "command.txt"),
|
|
508
|
-
controlFifo: join(stateDir, "control.fifo"),
|
|
509
821
|
current: undefined,
|
|
510
822
|
eventFile: join(stateDir, "outbox.jsonl"),
|
|
823
|
+
inboxFile: join(stateDir, "inbox.jsonl"),
|
|
511
824
|
pidFile: join(stateDir, "current.pid"),
|
|
825
|
+
playerControlFile: join(stateDir, "player-control.txt"),
|
|
512
826
|
stateDir,
|
|
513
827
|
stateFile: join(stateDir, "player-state.txt"),
|
|
514
828
|
statusFile: join(stateDir, "status.txt"),
|
|
515
829
|
statusJsonFile: join(stateDir, "player.json"),
|
|
516
830
|
stopping: false,
|
|
517
831
|
};
|
|
518
|
-
rmSync(ctx.controlFifo, { force: true });
|
|
519
832
|
rmSync(ctx.commandFile, { force: true });
|
|
520
833
|
rmSync(ctx.pidFile, { force: true });
|
|
834
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
521
835
|
const loop = parseBool(loopArg);
|
|
522
836
|
const volume = normalizeVolume(volumeArg);
|
|
523
837
|
const player = selectPlayer(playerArg);
|
|
@@ -533,7 +847,7 @@ async function playMain(args) {
|
|
|
533
847
|
}
|
|
534
848
|
controlLoop?.close();
|
|
535
849
|
rmSync(ctx.pidFile, { force: true });
|
|
536
|
-
rmSync(ctx.
|
|
850
|
+
rmSync(ctx.playerControlFile, { force: true });
|
|
537
851
|
};
|
|
538
852
|
process.once("SIGTERM", () => {
|
|
539
853
|
cleanup();
|
|
@@ -548,6 +862,7 @@ async function playMain(args) {
|
|
|
548
862
|
process.exit(129);
|
|
549
863
|
});
|
|
550
864
|
try {
|
|
865
|
+
updateRunControlMetadata(ctx);
|
|
551
866
|
controlLoop = startControlLoop(ctx);
|
|
552
867
|
setState(ctx, "playing");
|
|
553
868
|
console.error(
|
|
@@ -600,19 +915,11 @@ function controlMain(args) {
|
|
|
600
915
|
);
|
|
601
916
|
return;
|
|
602
917
|
}
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
fd = openSync(fifo, constants.O_WRONLY | constants.O_NONBLOCK);
|
|
609
|
-
writeSync(fd, command.endsWith("\n") ? command : `${command}\n`);
|
|
610
|
-
} catch (error) {
|
|
611
|
-
fail(`control fifo is not ready: ${fifo}: ${error.message}`, 75);
|
|
612
|
-
} finally {
|
|
613
|
-
if (fd !== undefined) closeSync(fd);
|
|
614
|
-
}
|
|
615
|
-
console.log(`music-player: command=${command} sent state_dir=${stateDir}`);
|
|
918
|
+
appendInboxCommand(
|
|
919
|
+
{ inboxFile: join(stateDir, "inbox.jsonl"), stateDir },
|
|
920
|
+
command,
|
|
921
|
+
);
|
|
922
|
+
console.log(`music-player: command=${command} queued state_dir=${stateDir}`);
|
|
616
923
|
}
|
|
617
924
|
|
|
618
925
|
const [mode, ...rest] = process.argv.slice(2);
|
package/skills/actors/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: actors
|
|
3
3
|
description: Highest-density practical guide for pi-actors. Read this skill whenever prompt and tools are not enough for spawn, message, inspect, actor runs, tools, recipes, command templates, async lifecycle, mailboxes, artifacts, and local orchestration mechanics.
|
|
4
4
|
metadata:
|
|
5
|
-
version: 0.
|
|
5
|
+
version: 0.22.0
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Actors (pi-actors)
|
package/skills/swarm/SKILL.md
CHANGED