@llblab/pi-actors 0.42.3 → 0.43.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 (252) hide show
  1. package/AGENTS.md +121 -175
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +113 -276
  4. package/dist/fixtures/protocol/control-endpoint.json +6 -0
  5. package/dist/fixtures/protocol/control-record.json +9 -0
  6. package/dist/fixtures/protocol/recipe-summary.json +4 -12
  7. package/dist/fixtures/protocol/trace-event.json +9 -0
  8. package/dist/lib/async-runs.d.ts +14 -38
  9. package/dist/lib/async-runs.js +158 -108
  10. package/dist/lib/control.d.ts +12 -0
  11. package/dist/lib/control.js +84 -0
  12. package/dist/lib/execution-sessions.d.ts +17 -0
  13. package/dist/lib/execution-sessions.js +85 -0
  14. package/dist/lib/file-state.d.ts +1 -0
  15. package/dist/lib/file-state.js +17 -5
  16. package/dist/lib/inspector-actions.d.ts +2 -2
  17. package/dist/lib/inspector-actions.js +2 -2
  18. package/dist/lib/inspector-command.js +3 -3
  19. package/dist/lib/inspector-overlay.d.ts +52 -70
  20. package/dist/lib/inspector-overlay.js +532 -905
  21. package/dist/lib/inspector.d.ts +3 -71
  22. package/dist/lib/inspector.js +19 -665
  23. package/dist/lib/limits.d.ts +4 -2
  24. package/dist/lib/limits.js +4 -2
  25. package/dist/lib/observability.d.ts +16 -16
  26. package/dist/lib/observability.js +43 -82
  27. package/dist/lib/prompts.d.ts +1 -1
  28. package/dist/lib/prompts.js +2 -2
  29. package/dist/lib/recipe-control.d.ts +7 -0
  30. package/dist/lib/recipe-control.js +39 -0
  31. package/dist/lib/recipes-discovery.js +2 -0
  32. package/dist/lib/recipes-references.d.ts +1 -14
  33. package/dist/lib/recipes-references.js +6 -21
  34. package/dist/lib/review-projection.js +1 -5
  35. package/dist/lib/run-ui-runtime.js +2 -2
  36. package/dist/lib/runs-control-delivery.d.ts +21 -0
  37. package/dist/lib/runs-control-delivery.js +127 -0
  38. package/dist/lib/runs-controls.d.ts +35 -0
  39. package/dist/lib/runs-controls.js +144 -0
  40. package/dist/lib/runs-retention.d.ts +7 -0
  41. package/dist/lib/runs-retention.js +27 -3
  42. package/dist/lib/runs-start.js +4 -2
  43. package/dist/lib/runs-status.js +11 -6
  44. package/dist/lib/runs-trace.d.ts +24 -0
  45. package/dist/lib/runs-trace.js +98 -0
  46. package/dist/lib/runtime-notifier.d.ts +1 -1
  47. package/dist/lib/runtime-notifier.js +1 -1
  48. package/dist/lib/tools-inspect.d.ts +3 -3
  49. package/dist/lib/tools-inspect.js +203 -708
  50. package/dist/lib/tools-local.js +2 -10
  51. package/dist/lib/tools-message.d.ts +7 -7
  52. package/dist/lib/tools-message.js +95 -396
  53. package/dist/lib/tools-response.d.ts +1 -4
  54. package/dist/lib/tools-response.js +5 -39
  55. package/dist/lib/tools-spawn.js +16 -28
  56. package/dist/lib/tools.js +1 -2
  57. package/dist/lib/trace-projection.d.ts +22 -0
  58. package/dist/lib/trace-projection.js +165 -0
  59. package/dist/recipes/draft-review.json +0 -10
  60. package/dist/recipes/lens-swarm.json +0 -14
  61. package/dist/recipes/music-player.json +10 -19
  62. package/dist/recipes/pipeline-architect-coordinator.json +0 -11
  63. package/dist/recipes/pipeline-artifact-bundle.json +1 -22
  64. package/dist/recipes/pipeline-artifact-report.json +1 -18
  65. package/dist/recipes/pipeline-artifact-write.json +1 -18
  66. package/dist/recipes/pipeline-async-run-ops.json +0 -12
  67. package/dist/recipes/pipeline-checkpoint-continuation.json +0 -14
  68. package/dist/recipes/pipeline-development-tasking.json +0 -12
  69. package/dist/recipes/pipeline-docs-maintenance.json +0 -12
  70. package/dist/recipes/pipeline-media-library.json +0 -12
  71. package/dist/recipes/pipeline-quorum-review.json +0 -12
  72. package/dist/recipes/pipeline-release-readiness.json +0 -12
  73. package/dist/recipes/pipeline-release-summary.json +0 -12
  74. package/dist/recipes/pipeline-repo-health.json +0 -12
  75. package/dist/recipes/pipeline-research-synthesis.json +0 -11
  76. package/dist/recipes/pipeline-review-readiness.json +0 -12
  77. package/dist/recipes/resource-locker.json +27 -0
  78. package/dist/recipes/subagent-artifact.json +0 -9
  79. package/dist/recipes/subagent-checkpoint.json +0 -10
  80. package/dist/recipes/subagent-conflict-report.json +0 -11
  81. package/dist/recipes/subagent-contradiction-map.json +0 -11
  82. package/dist/recipes/subagent-critic.json +0 -11
  83. package/dist/recipes/subagent-evidence-map.json +0 -11
  84. package/dist/recipes/subagent-followup.json +0 -10
  85. package/dist/recipes/subagent-judge.json +0 -11
  86. package/dist/recipes/subagent-merge.json +0 -11
  87. package/dist/recipes/subagent-normalize.json +0 -11
  88. package/dist/recipes/subagent-plan.json +0 -11
  89. package/dist/recipes/subagent-preflight.json +0 -11
  90. package/dist/recipes/subagent-prompt.json +0 -10
  91. package/dist/recipes/subagent-quorum.json +0 -10
  92. package/dist/recipes/subagent-review-coordinator.json +0 -14
  93. package/dist/recipes/subagent-review.json +0 -11
  94. package/dist/recipes/subagent-task-card.json +0 -11
  95. package/dist/recipes/subagent-tools.json +0 -10
  96. package/dist/recipes/subagent-verify.json +0 -11
  97. package/dist/recipes/subagents-prompts.json +0 -10
  98. package/dist/recipes/tool-review.json +0 -10
  99. package/dist/scripts/async-runner.mjs +25 -25
  100. package/dist/scripts/conformance.mjs +4 -2
  101. package/dist/scripts/locker.mjs +200 -66
  102. package/dist/scripts/music-player.mjs +159 -150
  103. package/dist/scripts/recipe-utils.mjs +6 -96
  104. package/dist/scripts/release-gates.mjs +60 -0
  105. package/dist/scripts/validate-recipe.mjs +3 -53
  106. package/dist/skills/actors/SKILL.md +53 -266
  107. package/dist/skills/swarm/SKILL.md +11 -33
  108. package/docs/0.43-baseline.md +44 -0
  109. package/docs/README.md +3 -3
  110. package/docs/actor-inspector.md +26 -64
  111. package/docs/actors-deep-reference.md +92 -50
  112. package/docs/async-runs.md +81 -328
  113. package/docs/command-templates.md +2 -2
  114. package/docs/component-recipes.md +30 -133
  115. package/docs/recipe-library.md +57 -182
  116. package/docs/task-first-recipes.md +10 -12
  117. package/docs/template-recipes.md +76 -289
  118. package/docs/tool-registry.md +41 -161
  119. package/fixtures/protocol/control-endpoint.json +6 -0
  120. package/fixtures/protocol/control-record.json +9 -0
  121. package/fixtures/protocol/recipe-summary.json +4 -12
  122. package/fixtures/protocol/trace-event.json +9 -0
  123. package/lib/async-runs.ts +202 -201
  124. package/lib/control.ts +102 -0
  125. package/lib/execution-sessions.ts +111 -0
  126. package/lib/file-state.ts +17 -4
  127. package/lib/inspector-actions.ts +2 -2
  128. package/lib/inspector-command.ts +3 -3
  129. package/lib/inspector-overlay.ts +577 -1121
  130. package/lib/inspector.ts +46 -979
  131. package/lib/limits.ts +4 -2
  132. package/lib/observability.ts +60 -101
  133. package/lib/prompts.ts +2 -2
  134. package/lib/recipe-control.ts +45 -0
  135. package/lib/recipes-discovery.ts +2 -0
  136. package/lib/recipes-references.ts +9 -45
  137. package/lib/review-projection.ts +1 -5
  138. package/lib/run-ui-runtime.ts +2 -2
  139. package/lib/runs-control-delivery.ts +181 -0
  140. package/lib/runs-controls.ts +204 -0
  141. package/lib/runs-retention.ts +38 -3
  142. package/lib/runs-start.ts +4 -2
  143. package/lib/runs-status.ts +11 -6
  144. package/lib/runs-trace.ts +132 -0
  145. package/lib/runtime-notifier.ts +1 -1
  146. package/lib/tools-inspect.ts +240 -901
  147. package/lib/tools-local.ts +2 -12
  148. package/lib/tools-message.ts +112 -519
  149. package/lib/tools-response.ts +5 -52
  150. package/lib/tools-spawn.ts +16 -32
  151. package/lib/tools.ts +1 -2
  152. package/lib/trace-projection.ts +221 -0
  153. package/package.json +2 -1
  154. package/recipes/draft-review.json +0 -10
  155. package/recipes/lens-swarm.json +0 -14
  156. package/recipes/music-player.json +10 -19
  157. package/recipes/pipeline-architect-coordinator.json +0 -11
  158. package/recipes/pipeline-artifact-bundle.json +1 -22
  159. package/recipes/pipeline-artifact-report.json +1 -18
  160. package/recipes/pipeline-artifact-write.json +1 -18
  161. package/recipes/pipeline-async-run-ops.json +0 -12
  162. package/recipes/pipeline-checkpoint-continuation.json +0 -14
  163. package/recipes/pipeline-development-tasking.json +0 -12
  164. package/recipes/pipeline-docs-maintenance.json +0 -12
  165. package/recipes/pipeline-media-library.json +0 -12
  166. package/recipes/pipeline-quorum-review.json +0 -12
  167. package/recipes/pipeline-release-readiness.json +0 -12
  168. package/recipes/pipeline-release-summary.json +0 -12
  169. package/recipes/pipeline-repo-health.json +0 -12
  170. package/recipes/pipeline-research-synthesis.json +0 -11
  171. package/recipes/pipeline-review-readiness.json +0 -12
  172. package/recipes/resource-locker.json +27 -0
  173. package/recipes/subagent-artifact.json +0 -9
  174. package/recipes/subagent-checkpoint.json +0 -10
  175. package/recipes/subagent-conflict-report.json +0 -11
  176. package/recipes/subagent-contradiction-map.json +0 -11
  177. package/recipes/subagent-critic.json +0 -11
  178. package/recipes/subagent-evidence-map.json +0 -11
  179. package/recipes/subagent-followup.json +0 -10
  180. package/recipes/subagent-judge.json +0 -11
  181. package/recipes/subagent-merge.json +0 -11
  182. package/recipes/subagent-normalize.json +0 -11
  183. package/recipes/subagent-plan.json +0 -11
  184. package/recipes/subagent-preflight.json +0 -11
  185. package/recipes/subagent-prompt.json +0 -10
  186. package/recipes/subagent-quorum.json +0 -10
  187. package/recipes/subagent-review-coordinator.json +0 -14
  188. package/recipes/subagent-review.json +0 -11
  189. package/recipes/subagent-task-card.json +0 -11
  190. package/recipes/subagent-tools.json +0 -10
  191. package/recipes/subagent-verify.json +0 -11
  192. package/recipes/subagents-prompts.json +0 -10
  193. package/recipes/tool-review.json +0 -10
  194. package/scripts/async-runner.mjs +25 -25
  195. package/scripts/conformance.mjs +4 -2
  196. package/scripts/locker.mjs +200 -66
  197. package/scripts/music-player.mjs +159 -150
  198. package/scripts/recipe-utils.mjs +6 -96
  199. package/scripts/release-gates.mjs +60 -0
  200. package/scripts/validate-recipe.mjs +3 -53
  201. package/skills/actors/SKILL.md +53 -266
  202. package/skills/swarm/SKILL.md +11 -33
  203. package/dist/fixtures/protocol/actor-message-branch.json +0 -13
  204. package/dist/fixtures/protocol/mailbox-contract.json +0 -15
  205. package/dist/fixtures/protocol/room-message.json +0 -11
  206. package/dist/fixtures/protocol/room-roster.json +0 -11
  207. package/dist/fixtures/protocol/run-inbox-message.json +0 -9
  208. package/dist/fixtures/protocol/run-outbox-event.json +0 -9
  209. package/dist/lib/mailbox-loop.d.ts +0 -41
  210. package/dist/lib/mailbox-loop.js +0 -60
  211. package/dist/lib/messages.d.ts +0 -25
  212. package/dist/lib/messages.js +0 -122
  213. package/dist/lib/rooms.d.ts +0 -104
  214. package/dist/lib/rooms.js +0 -647
  215. package/dist/lib/runs-mailbox.d.ts +0 -25
  216. package/dist/lib/runs-mailbox.js +0 -146
  217. package/dist/lib/runs-messages.d.ts +0 -15
  218. package/dist/lib/runs-messages.js +0 -179
  219. package/dist/lib/runs-outbox.d.ts +0 -41
  220. package/dist/lib/runs-outbox.js +0 -87
  221. package/dist/lib/tools-mailbox.d.ts +0 -8
  222. package/dist/lib/tools-mailbox.js +0 -48
  223. package/dist/recipes/actor-worker.json +0 -39
  224. package/dist/recipes/coordinator-locker.json +0 -45
  225. package/dist/recipes/locker.json +0 -45
  226. package/dist/recipes/pipeline-room-swarm.json +0 -50
  227. package/dist/recipes/subagent-message.json +0 -32
  228. package/dist/recipes/utility-actor-message.json +0 -23
  229. package/dist/scripts/actor-worker.mjs +0 -214
  230. package/dist/scripts/coordinator.mjs +0 -799
  231. package/docs/actor-messages.md +0 -225
  232. package/fixtures/protocol/actor-message-branch.json +0 -13
  233. package/fixtures/protocol/mailbox-contract.json +0 -15
  234. package/fixtures/protocol/room-message.json +0 -11
  235. package/fixtures/protocol/room-roster.json +0 -11
  236. package/fixtures/protocol/run-inbox-message.json +0 -9
  237. package/fixtures/protocol/run-outbox-event.json +0 -9
  238. package/lib/mailbox-loop.ts +0 -144
  239. package/lib/messages.ts +0 -151
  240. package/lib/rooms.ts +0 -939
  241. package/lib/runs-mailbox.ts +0 -208
  242. package/lib/runs-messages.ts +0 -252
  243. package/lib/runs-outbox.ts +0 -144
  244. package/lib/tools-mailbox.ts +0 -56
  245. package/recipes/actor-worker.json +0 -39
  246. package/recipes/coordinator-locker.json +0 -45
  247. package/recipes/locker.json +0 -45
  248. package/recipes/pipeline-room-swarm.json +0 -50
  249. package/recipes/subagent-message.json +0 -32
  250. package/recipes/utility-actor-message.json +0 -23
  251. package/scripts/actor-worker.mjs +0 -214
  252. package/scripts/coordinator.mjs +0 -799
@@ -1,18 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * Packaged local music-player actor helper.
4
+ * Packaged local music-player controlled-service helper.
5
5
  *
6
6
  * This script backs the standard music-player recipe. It scans local music
7
7
  * sources, builds playback queues, launches an available backend player, and
8
- * consumes run-mailbox control messages such as play, pause, next, previous,
8
+ * consumes generation-bound Controls such as play, pause, next, previous,
9
9
  * stop, and status.
10
10
  *
11
- * Keep the helper focused on one maintained player actor implementation; recipe
11
+ * Keep the helper focused on one maintained controlled Run implementation; Recipe
12
12
  * metadata and invocation arguments choose source paths and backend behavior.
13
13
  */
14
14
 
15
- import { spawn } from "node:child_process";
15
+ import { spawn, spawnSync } from "node:child_process";
16
16
  import { randomUUID } from "node:crypto";
17
17
  import {
18
18
  accessSync,
@@ -26,6 +26,7 @@ import {
26
26
  watch,
27
27
  writeFileSync,
28
28
  } from "node:fs";
29
+ import { createServer } from "node:net";
29
30
  import { homedir } from "node:os";
30
31
  import {
31
32
  basename,
@@ -36,6 +37,21 @@ import {
36
37
  join,
37
38
  resolve,
38
39
  } from "node:path";
40
+ import { fileURLToPath, pathToFileURL } from "node:url";
41
+
42
+ function packageRoot() {
43
+ return dirname(dirname(fileURLToPath(import.meta.url)));
44
+ }
45
+
46
+ async function importRuntimeModule(name) {
47
+ const root = packageRoot();
48
+ const compiled = join(root, "dist", "lib", `${name}.js`);
49
+ const source = join(root, "lib", `${name}.ts`);
50
+ return await import(pathToFileURL(existsSync(compiled) ? compiled : source).href);
51
+ }
52
+
53
+ const { acquireFileMutationLock, writeTextAtomic } =
54
+ await importRuntimeModule("file-state");
39
55
 
40
56
  const AUDIO_EXTENSIONS = new Set([
41
57
  ".aac",
@@ -55,7 +71,6 @@ const CONTROL_COMMANDS = new Set([
55
71
  "toggle",
56
72
  "next",
57
73
  "previous",
58
- "prev",
59
74
  "stop",
60
75
  "status",
61
76
  ]);
@@ -66,9 +81,9 @@ function usage() {
66
81
  music-player.mjs <pause|resume|toggle|next|previous|stop|status> <state-dir>
67
82
  music-player.mjs control <state-dir> <play|pause|toggle|next|previous|stop|status>
68
83
 
69
- Runs a small foreground music player so pi-actors can own it as an actor run.
70
- Actor message bodies are adapted to queued mailbox commands in <state-dir>/inbox.jsonl.
71
- Prefer message to=run:<run> type=player.<command> body=<command>, or use direct control commands below.
84
+ Runs a foreground music player so pi-actors can own it as a controlled Run.
85
+ Controls use canonical records in <state-dir>/controls.jsonl.
86
+ Prefer message target=run:<run> action=<command>, or use direct control commands below.
72
87
  Supported players: auto, mpv, afplay, ffplay, cvlc, play, wmp.
73
88
  `);
74
89
  }
@@ -404,20 +419,16 @@ function readText(path) {
404
419
  }
405
420
  }
406
421
 
407
- function emitPlayerEvent(ctx, type, summary, body = {}) {
422
+ function emitPlayerEvent(ctx, kind, summary, data = {}) {
408
423
  writeText(
409
424
  ctx.eventFile,
410
425
  `${JSON.stringify({
411
- body,
412
- data: body,
413
- delivery: "log",
414
- event: type,
415
- from: `run:${basename(ctx.stateDir)}`,
426
+ data,
427
+ id: randomUUID(),
428
+ kind,
416
429
  level: "info",
417
430
  summary,
418
- to: "coordinator",
419
431
  ts: new Date().toISOString(),
420
- type,
421
432
  })}\n`,
422
433
  "a",
423
434
  );
@@ -468,13 +479,20 @@ function setState(ctx, state) {
468
479
  function sendSignalToCurrent(ctx, signal) {
469
480
  const pid = Number(readText(ctx.pidFile).trim());
470
481
  if (!Number.isInteger(pid) || pid <= 0) return;
471
- if (process.platform !== "win32") {
472
- try {
473
- process.kill(-pid, signal);
474
- return;
475
- } catch {
476
- // Fall through to the direct child fallback.
482
+ if (process.platform === "win32") {
483
+ if (signal === "SIGTERM") {
484
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], {
485
+ stdio: "ignore",
486
+ windowsHide: true,
487
+ });
477
488
  }
489
+ return;
490
+ }
491
+ try {
492
+ process.kill(-pid, signal);
493
+ return;
494
+ } catch {
495
+ // Fall through to the direct child fallback.
478
496
  }
479
497
  try {
480
498
  process.kill(pid, signal);
@@ -539,6 +557,7 @@ function handleControl(ctx, input) {
539
557
  controlCurrentPlayback(ctx, "stop");
540
558
  break;
541
559
  case "stop":
560
+ ctx.stopping = true;
542
561
  writeText(ctx.commandFile, "stop");
543
562
  controlCurrentPlayback(ctx, "stop");
544
563
  break;
@@ -563,45 +582,42 @@ function writeJsonFile(path, value) {
563
582
  writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`, "utf8");
564
583
  }
565
584
 
566
- function updateRunControlMetadata(ctx) {
567
- const path = runJsonFile(ctx);
568
- const run = readJsonFile(path, undefined);
569
- if (!run || typeof run !== "object") return;
570
- writeJsonFile(path, {
571
- ...run,
572
- control: { path: ctx.inboxFile, type: "mailbox" },
585
+ async function startControlServer(ctx, wakeControlLoop) {
586
+ if (!ctx.runInstanceId) return undefined;
587
+ const path = process.platform === "win32"
588
+ ? `\\\\.\\pipe\\pi-actors-music-${String(process.env.run_id ?? process.pid).replaceAll(/[^A-Za-z0-9_-]/g, "-")}`
589
+ : join(ctx.stateDir, "control.sock");
590
+ if (process.platform !== "win32") rmSync(path, { force: true });
591
+ const server = createServer((socket) => {
592
+ socket.on("data", wakeControlLoop);
593
+ socket.resume();
594
+ });
595
+ await new Promise((resolveReady, rejectReady) => {
596
+ server.once("error", rejectReady);
597
+ server.listen(path, () => {
598
+ server.off("error", rejectReady);
599
+ writeJsonFile(join(ctx.stateDir, "control-endpoint.json"), {
600
+ path,
601
+ ready_at: new Date().toISOString(),
602
+ run_instance_id: ctx.runInstanceId,
603
+ type: "named-pipe",
604
+ });
605
+ resolveReady();
606
+ });
573
607
  });
608
+ return () => {
609
+ server.close();
610
+ if (process.platform !== "win32") rmSync(path, { force: true });
611
+ };
574
612
  }
575
613
 
576
- function acquireInboxLock(ctx) {
577
- const lockDir = join(ctx.stateDir, ".inbox.lock");
578
- const started = Date.now();
579
- while (true) {
580
- try {
581
- mkdirSync(lockDir);
582
- writeJsonFile(join(lockDir, "owner.json"), {
583
- created_at: new Date().toISOString(),
584
- pid: process.pid,
585
- });
586
- return () => rmSync(lockDir, { recursive: true, force: true });
587
- } catch (error) {
588
- try {
589
- const stat = statSync(lockDir);
590
- if (Date.now() - stat.mtimeMs > 5 * 60 * 1000) {
591
- rmSync(lockDir, { recursive: true, force: true });
592
- continue;
593
- }
594
- } catch {
595
- continue;
596
- }
597
- if (Date.now() - started > 5000) throw error;
598
- }
599
- }
614
+ function acquireControlsLock(ctx) {
615
+ return acquireFileMutationLock(ctx.controlsFile);
600
616
  }
601
617
 
602
- function readInboxMessages(ctx) {
603
- if (!exists(ctx.inboxFile)) return [];
604
- return readFileSync(ctx.inboxFile, "utf8")
618
+ function readControls(ctx) {
619
+ if (!exists(ctx.controlsFile)) return [];
620
+ return readFileSync(ctx.controlsFile, "utf8")
605
621
  .split("\n")
606
622
  .filter((line) => line.trim())
607
623
  .map((line) => {
@@ -614,45 +630,26 @@ function readInboxMessages(ctx) {
614
630
  .filter(Boolean);
615
631
  }
616
632
 
617
- function writeInboxMessages(ctx, messages) {
618
- writeFileSync(
619
- ctx.inboxFile,
620
- messages.length
621
- ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n`
633
+ function writeControls(ctx, controls) {
634
+ writeTextAtomic(
635
+ ctx.controlsFile,
636
+ controls.length
637
+ ? `${controls.map((control) => JSON.stringify(control)).join("\n")}\n`
622
638
  : "",
623
- "utf8",
624
639
  );
625
640
  }
626
641
 
627
- function commandFromControlToken(token) {
628
- if (CONTROL_COMMANDS.has(token)) return token;
629
- if (token === "control.stop" || token === "control.cancel") return "stop";
630
- const match = /^player\.(.+)$/.exec(token);
631
- if (match && CONTROL_COMMANDS.has(match[1])) return match[1];
632
- return undefined;
633
- }
634
-
635
- function commandFromInboxMessage(message) {
636
- if (typeof message.type === "string") {
637
- const command = commandFromControlToken(message.type.trim());
638
- if (command) return command;
639
- }
640
- if (typeof message.body === "string") {
641
- const command = message.body.trim();
642
- if (CONTROL_COMMANDS.has(command)) return command;
643
- }
644
- if (message.body && typeof message.body === "object") {
645
- const command = String(message.body.command ?? "").trim();
646
- if (CONTROL_COMMANDS.has(command)) return command;
647
- }
648
- return undefined;
642
+ function commandFromControl(control) {
643
+ if (typeof control.action !== "string") return undefined;
644
+ const action = control.action.trim();
645
+ return CONTROL_COMMANDS.has(action) ? action : undefined;
649
646
  }
650
647
 
651
648
  function runtimeWakeFile(ctx) {
652
649
  return join(ctx.stateDir, "wake.jsonl");
653
650
  }
654
651
 
655
- function notifyMailboxWake(ctx, reason = "run.message") {
652
+ function notifyControlWake(ctx, reason = "control.queued") {
656
653
  try {
657
654
  writeText(
658
655
  runtimeWakeFile(ctx),
@@ -667,86 +664,85 @@ function notifyMailboxWake(ctx, reason = "run.message") {
667
664
  "a",
668
665
  );
669
666
  } catch {
670
- // Wake records are advisory; the inbox remains the durable source of truth.
667
+ // Wake records are advisory; the Control journal remains authoritative.
671
668
  }
672
669
  }
673
670
 
674
- function appendInboxCommand(ctx, command) {
675
- const release = acquireInboxLock(ctx);
671
+ function appendControl(ctx, action) {
672
+ const release = acquireControlsLock(ctx);
676
673
  try {
677
- const ts = new Date().toISOString();
678
- const messages = readInboxMessages(ctx);
679
- messages.push({
680
- body: command,
681
- from: "coordinator",
674
+ const run = readJsonFile(runJsonFile(ctx), {});
675
+ const controls = readControls(ctx);
676
+ controls.push({
677
+ action,
682
678
  id: randomUUID(),
683
- queued_at: ts,
684
- received_at: ts,
679
+ queued_at: new Date().toISOString(),
680
+ run_instance_id: run.run_instance_id,
685
681
  status: "queued",
686
- to: `run:${basename(ctx.stateDir)}`,
687
- type: `player.${command}`,
688
682
  });
689
- writeInboxMessages(ctx, messages);
683
+ writeControls(ctx, controls);
690
684
  } finally {
691
685
  release();
692
686
  }
693
- notifyMailboxWake(ctx);
687
+ notifyControlWake(ctx);
694
688
  }
695
689
 
696
- function claimInboxCommands(ctx) {
697
- const release = acquireInboxLock(ctx);
690
+ function claimControls(ctx) {
691
+ const release = acquireControlsLock(ctx);
698
692
  try {
699
- const messages = readInboxMessages(ctx);
693
+ const controls = readControls(ctx);
700
694
  const commands = [];
701
695
  let changed = false;
702
696
  const claimedAt = new Date().toISOString();
703
- for (const message of messages) {
704
- if (message.status !== "queued") continue;
705
- const command = commandFromInboxMessage(message);
697
+ for (const control of controls) {
698
+ if (control.status !== "queued" && control.status !== "delivered") continue;
699
+ const command =
700
+ control.run_instance_id === ctx.runInstanceId
701
+ ? commandFromControl(control)
702
+ : undefined;
706
703
  if (!command) {
707
- message.failed_at = claimedAt;
708
- message.status = "failed";
709
- message.error = "Unsupported music-player command";
704
+ control.failed_at = claimedAt;
705
+ control.status = "failed";
706
+ control.error = "Unsupported or stale music-player Control";
710
707
  changed = true;
711
708
  continue;
712
709
  }
713
- message.claimed_at = claimedAt;
714
- message.claimed_by = `run:${basename(ctx.stateDir)}`;
715
- message.status = "claimed";
716
- commands.push({ command, id: message.id });
710
+ control.claimed_at = claimedAt;
711
+ control.status = "claimed";
712
+ commands.push({ command, id: control.id });
717
713
  changed = true;
718
714
  }
719
- if (changed) writeInboxMessages(ctx, messages);
715
+ if (changed) writeControls(ctx, controls);
720
716
  return commands;
721
717
  } finally {
722
718
  release();
723
719
  }
724
720
  }
725
721
 
726
- function finalizeInboxCommand(ctx, id, status, error) {
722
+ function finalizeControl(ctx, id, status, error) {
727
723
  if (!id) return;
728
- const release = acquireInboxLock(ctx);
724
+ const release = acquireControlsLock(ctx);
729
725
  try {
730
- const messages = readInboxMessages(ctx);
726
+ const controls = readControls(ctx);
731
727
  const timestamp = new Date().toISOString();
732
728
  let changed = false;
733
- for (const message of messages) {
734
- if (message.id !== id) continue;
735
- message.status = status;
736
- if (status === "handled") message.handled_at = timestamp;
737
- else message.failed_at = timestamp;
738
- if (error) message.error = error;
729
+ for (const control of controls) {
730
+ if (control.id !== id || control.status !== "claimed") continue;
731
+ control.status = status;
732
+ if (status === "handled") control.handled_at = timestamp;
733
+ else control.failed_at = timestamp;
734
+ if (error) control.error = error;
739
735
  changed = true;
740
736
  }
741
- if (changed) writeInboxMessages(ctx, messages);
737
+ if (changed) writeControls(ctx, controls);
742
738
  } finally {
743
739
  release();
744
740
  }
745
741
  }
746
742
 
747
- function inboxSignature(ctx) {
743
+ function controlsSignature(ctx) {
748
744
  try {
749
- const stat = statSync(ctx.inboxFile);
745
+ const stat = statSync(ctx.controlsFile);
750
746
  return `${stat.size}:${stat.mtimeMs}`;
751
747
  } catch {
752
748
  return "missing";
@@ -757,19 +753,21 @@ function startControlLoop(ctx) {
757
753
  let closed = false;
758
754
  let dirty = true;
759
755
  let watcher;
760
- try {
761
- watcher = watch(ctx.stateDir, { persistent: false }, (_eventType, file) => {
762
- const name = file ? String(file) : "";
763
- if (
764
- !name ||
765
- name === basename(ctx.inboxFile) ||
766
- name === basename(runtimeWakeFile(ctx))
767
- ) {
768
- dirty = true;
769
- }
770
- });
771
- } catch {
772
- // fs.watch is advisory; the signature poll below is the portable fallback.
756
+ if (process.platform !== "win32") {
757
+ try {
758
+ watcher = watch(ctx.stateDir, { persistent: false }, (_eventType, file) => {
759
+ const name = file ? String(file) : "";
760
+ if (
761
+ !name ||
762
+ name === basename(ctx.controlsFile) ||
763
+ name === basename(runtimeWakeFile(ctx))
764
+ ) {
765
+ dirty = true;
766
+ }
767
+ });
768
+ } catch {
769
+ // fs.watch is advisory; signature checks remain the portable fallback.
770
+ }
773
771
  }
774
772
  const close = () => {
775
773
  closed = true;
@@ -778,37 +776,43 @@ function startControlLoop(ctx) {
778
776
  const promise = (async () => {
779
777
  let lastSignature = "";
780
778
  while (!ctx.stopping && !closed) {
781
- const signature = inboxSignature(ctx);
779
+ const signature = controlsSignature(ctx);
782
780
  if (dirty || signature !== lastSignature) {
783
781
  dirty = false;
784
- for (const { command, id } of claimInboxCommands(ctx)) {
782
+ for (const { command, id } of claimControls(ctx)) {
785
783
  try {
786
784
  handleControl(ctx, command);
787
- finalizeInboxCommand(ctx, id, "handled");
785
+ finalizeControl(ctx, id, "handled");
788
786
  } catch (error) {
789
- finalizeInboxCommand(ctx, id, "failed", error.message);
787
+ finalizeControl(ctx, id, "failed", error.message);
790
788
  }
791
789
  }
792
- lastSignature = inboxSignature(ctx);
790
+ lastSignature = controlsSignature(ctx);
793
791
  continue;
794
792
  }
795
793
  await sleep(250);
796
794
  }
797
795
  })();
798
- return { close, promise };
796
+ return { close, promise, wake: () => { dirty = true; } };
799
797
  }
800
798
 
801
799
  function playOne(ctx, player, volume, track, index, count) {
802
800
  return new Promise((resolveDone) => {
801
+ if (ctx.stopping) {
802
+ resolveDone();
803
+ return;
804
+ }
803
805
  rmSync(ctx.playerControlFile, { force: true });
804
806
  const [command, args] = playerCommand(ctx, player, volume, track);
805
807
  writeStatus(ctx, "playing", index, count, track, player, "");
806
808
  const child = spawn(command, args, {
809
+ detached: process.platform === "win32",
807
810
  stdio: ["ignore", "inherit", "inherit"],
808
811
  });
809
812
  ctx.child = child;
810
813
  const pid = child.pid || "";
811
814
  if (pid) writeText(ctx.pidFile, String(pid));
815
+ if (ctx.stopping) controlCurrentPlayback(ctx, "stop");
812
816
  writeStatus(ctx, "playing", index, count, track, player, pid);
813
817
  emitTrackEvent(ctx, index, count, track, player);
814
818
  child.once("error", (error) => {
@@ -851,13 +855,17 @@ async function playMain(args) {
851
855
  ),
852
856
  );
853
857
  mkdirSync(stateDir, { recursive: true });
858
+ const startupRun = readJsonFile(runJsonFile({ stateDir }), {});
854
859
  const ctx = {
855
860
  commandFile: join(stateDir, "command.txt"),
856
861
  current: undefined,
857
- eventFile: join(stateDir, "outbox.jsonl"),
858
- inboxFile: join(stateDir, "inbox.jsonl"),
862
+ eventFile: join(stateDir, "trace.jsonl"),
863
+ controlsFile: join(stateDir, "controls.jsonl"),
859
864
  pidFile: join(stateDir, "current.pid"),
860
865
  playerControlFile: join(stateDir, "player-control.txt"),
866
+ runInstanceId: typeof startupRun.run_instance_id === "string"
867
+ ? startupRun.run_instance_id
868
+ : undefined,
861
869
  stateDir,
862
870
  stateFile: join(stateDir, "player-state.txt"),
863
871
  statusFile: join(stateDir, "status.txt"),
@@ -872,6 +880,7 @@ async function playMain(args) {
872
880
  const player = selectPlayer(playerArg);
873
881
  const tracks = loadPlaylist(sourceArg);
874
882
  let controlLoop;
883
+ let closeControlServer;
875
884
  const cleanup = () => {
876
885
  ctx.stopping = true;
877
886
  writeText(ctx.stateFile, "stopped");
@@ -881,6 +890,7 @@ async function playMain(args) {
881
890
  } catch {}
882
891
  }
883
892
  controlLoop?.close();
893
+ closeControlServer?.();
884
894
  rmSync(ctx.pidFile, { force: true });
885
895
  rmSync(ctx.playerControlFile, { force: true });
886
896
  };
@@ -897,8 +907,8 @@ async function playMain(args) {
897
907
  process.exit(129);
898
908
  });
899
909
  try {
900
- updateRunControlMetadata(ctx);
901
910
  controlLoop = startControlLoop(ctx);
911
+ closeControlServer = await startControlServer(ctx, controlLoop.wake);
902
912
  setState(ctx, "playing");
903
913
  console.error(
904
914
  `music-player: player=${player} loop=${loop} volume=${volume} tracks=${tracks.length} state_dir=${stateDir}`,
@@ -952,8 +962,8 @@ function controlMain(args) {
952
962
  );
953
963
  return;
954
964
  }
955
- appendInboxCommand(
956
- { inboxFile: join(stateDir, "inbox.jsonl"), stateDir },
965
+ appendControl(
966
+ { controlsFile: join(stateDir, "controls.jsonl"), stateDir },
957
967
  command,
958
968
  );
959
969
  console.log(`music-player: command=${command} queued state_dir=${stateDir}`);
@@ -966,7 +976,6 @@ const directControlCommands = new Set([
966
976
  "toggle",
967
977
  "next",
968
978
  "previous",
969
- "prev",
970
979
  "stop",
971
980
  "status",
972
981
  ]);