@llblab/pi-actors 0.20.2 → 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.
Files changed (41) hide show
  1. package/BACKLOG.md +34 -80
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +7 -1
  4. package/dist/index.js +11 -0
  5. package/dist/lib/actor-rooms.d.ts +1 -0
  6. package/dist/lib/actor-rooms.js +33 -1
  7. package/dist/lib/async-runs.d.ts +35 -1
  8. package/dist/lib/async-runs.js +318 -36
  9. package/dist/lib/command-templates.js +8 -1
  10. package/dist/lib/observability.d.ts +15 -0
  11. package/dist/lib/observability.js +103 -18
  12. package/dist/lib/recipe-discovery.js +13 -5
  13. package/dist/lib/recipe-references.js +137 -11
  14. package/dist/lib/runtime-notifier.d.ts +48 -0
  15. package/dist/lib/runtime-notifier.js +137 -0
  16. package/dist/lib/tools.js +18 -9
  17. package/docs/README.md +1 -1
  18. package/docs/actor-messages.md +8 -3
  19. package/docs/async-runs.md +7 -5
  20. package/docs/recipe-library.md +25 -8
  21. package/docs/template-recipes.md +37 -7
  22. package/docs/tool-registry.md +4 -3
  23. package/index.ts +14 -0
  24. package/lib/actor-rooms.ts +46 -1
  25. package/lib/async-runs.ts +433 -49
  26. package/lib/command-templates.ts +8 -1
  27. package/lib/observability.ts +133 -20
  28. package/lib/recipe-discovery.ts +21 -6
  29. package/lib/recipe-references.ts +141 -17
  30. package/lib/runtime-notifier.ts +207 -0
  31. package/lib/tools.ts +36 -13
  32. package/package.json +2 -3
  33. package/recipes/music-player.json +1 -1
  34. package/recipes/pipeline-room-swarm.json +1 -1
  35. package/scripts/coordinator.mjs +276 -135
  36. package/scripts/locker.mjs +87 -28
  37. package/scripts/music-player.mjs +401 -94
  38. package/scripts/validate-recipe.mjs +2 -2
  39. package/skills/actors/SKILL.md +10 -9
  40. package/skills/swarm/SKILL.md +1 -1
  41. package/index.js +0 -19
@@ -1,19 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { spawn, spawnSync } from "node:child_process";
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([".mp3", ".ogg", ".wav", ".flac", ".m4a"]);
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 newline-delimited commands at <state-dir>/control.fifo.
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
- selected = ["mpv", "ffplay", "cvlc", "play"].find(have) || selected;
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 (!have(selected)) fail(`player not found: ${selected}`, 127);
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 playerCommand(player, volume, track) {
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
- try {
346
- process.kill(pid, signal);
347
- return;
348
- } catch {
349
- // Fall through to process-group fallback.
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(-pid, signal);
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
- sendSignalToCurrent(ctx, "SIGCONT");
487
+ controlCurrentPlayback(ctx, "play");
370
488
  break;
371
489
  case "pause":
372
490
  setState(ctx, "paused");
373
- sendSignalToCurrent(ctx, "SIGSTOP");
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
- sendSignalToCurrent(ctx, "SIGCONT");
497
+ controlCurrentPlayback(ctx, "play");
380
498
  } else {
381
499
  setState(ctx, "paused");
382
- sendSignalToCurrent(ctx, "SIGSTOP");
500
+ controlCurrentPlayback(ctx, "pause");
383
501
  }
384
502
  break;
385
503
  }
386
504
  case "next":
387
505
  writeText(ctx.commandFile, "next");
388
- sendSignalToCurrent(ctx, "SIGTERM");
506
+ controlCurrentPlayback(ctx, "stop");
389
507
  break;
390
508
  case "previous":
391
509
  case "prev":
392
510
  writeText(ctx.commandFile, "previous");
393
- sendSignalToCurrent(ctx, "SIGTERM");
511
+ controlCurrentPlayback(ctx, "stop");
394
512
  break;
395
513
  case "stop":
396
514
  writeText(ctx.commandFile, "stop");
397
- sendSignalToCurrent(ctx, "SIGTERM");
515
+ controlCurrentPlayback(ctx, "stop");
398
516
  break;
399
517
  case "status":
400
518
  break;
401
519
  }
402
520
  }
403
521
 
404
- function makeFifo(path) {
405
- rmSync(path, { force: true });
406
- const result = spawnSync("mkfifo", [path], { encoding: "utf8" });
407
- if (result.status !== 0) {
408
- const error =
409
- result.stderr.trim() || result.error?.message || "mkfifo failed";
410
- fail(error, result.status || 1);
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
- try {
423
- closeSync(fd);
424
- } catch {}
740
+ watcher?.close();
425
741
  };
426
742
  const promise = (async () => {
427
- const buffer = Buffer.alloc(4096);
428
- while (!ctx.stopping) {
429
- try {
430
- const bytes = readSync(fd, buffer, 0, buffer.length, null);
431
- if (bytes > 0) {
432
- carry += buffer.subarray(0, bytes).toString("utf8");
433
- const lines = carry.split("\n");
434
- carry = lines.pop() || "";
435
- for (const line of lines) handleControl(ctx, line);
436
- } else {
437
- await sleep(50);
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
- console.error(`music-player: control loop error: ${error.message}`);
446
- await sleep(250);
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
- const [command, args] = playerCommand(player, volume, track);
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.controlFifo, { force: true });
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
- ensureUnixFifoSupport();
604
- const fifo = join(stateDir, "control.fifo");
605
- if (!isFifo(fifo)) fail(`control fifo not found: ${fifo}`, 75);
606
- let fd;
607
- try {
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);
@@ -26,7 +26,7 @@ function usage() {
26
26
  console.error(`Usage:
27
27
  validate-recipe.mjs <recipe-file-or-dir> [--all]
28
28
 
29
- Validates one template recipe file, or all *.json files in a directory when --all is set.`);
29
+ Validates one template recipe file, or all *.json/*.md files in a directory when --all is set.`);
30
30
  }
31
31
 
32
32
  function expandPath(value) {
@@ -56,7 +56,7 @@ function recipeFiles(target, all) {
56
56
  throw new Error(`Recipe path is not a file or directory: ${target}`);
57
57
  if (!all) throw new Error("Directory validation requires --all.");
58
58
  return readdirSync(target)
59
- .filter((file) => file.endsWith(".json"))
59
+ .filter((file) => file.endsWith(".json") || file.endsWith(".md"))
60
60
  .sort((a, b) => a.localeCompare(b))
61
61
  .map((file) => resolve(target, file));
62
62
  }