@llblab/pi-actors 0.42.3 → 0.43.1

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