@llblab/pi-actors 0.52.0 → 0.53.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 (44) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +12 -0
  3. package/README.md +1 -1
  4. package/dist/lib/async-runs.d.ts +3 -0
  5. package/dist/lib/async-runs.js +14 -1
  6. package/dist/lib/command-templates.js +45 -3
  7. package/dist/lib/extension-runtime.js +1 -1
  8. package/dist/lib/observability.d.ts +16 -3
  9. package/dist/lib/observability.js +92 -7
  10. package/dist/lib/pi.d.ts +0 -1
  11. package/dist/lib/pi.js +15 -24
  12. package/dist/lib/run-delivery-lineage.d.ts +17 -0
  13. package/dist/lib/run-delivery-lineage.js +44 -0
  14. package/dist/lib/run-delivery.d.ts +4 -0
  15. package/dist/lib/run-delivery.js +102 -4
  16. package/dist/lib/run-ui-runtime.js +58 -39
  17. package/dist/lib/runtime.js +14 -6
  18. package/dist/skills/actors/SKILL.md +17 -7
  19. package/dist/skills/music-player/SKILL.md +3 -3
  20. package/dist/skills/music-player/genapps/music-player.mjs +6 -4
  21. package/dist/skills/music-player/scripts/playback.mjs +85 -18
  22. package/dist/skills/swarm/SKILL.md +2 -6
  23. package/dist/skills/swarm/references/development-swarm.md +2 -31
  24. package/docs/async-runs.md +1 -1
  25. package/docs/coordinator-delivery.md +18 -23
  26. package/docs/recipe-library.md +1 -1
  27. package/lib/async-runs.ts +18 -1
  28. package/lib/command-templates.ts +41 -3
  29. package/lib/extension-runtime.ts +1 -1
  30. package/lib/observability.ts +119 -5
  31. package/lib/pi.ts +15 -28
  32. package/lib/run-delivery-lineage.ts +68 -0
  33. package/lib/run-delivery.ts +120 -4
  34. package/lib/run-ui-runtime.ts +69 -44
  35. package/lib/runtime.ts +17 -6
  36. package/package.json +1 -1
  37. package/skills/actors/SKILL.md +17 -7
  38. package/skills/music-player/SKILL.md +3 -3
  39. package/skills/music-player/genapps/music-player.mjs +6 -4
  40. package/skills/music-player/scripts/playback.mjs +85 -18
  41. package/skills/swarm/SKILL.md +2 -6
  42. package/skills/swarm/references/development-swarm.md +2 -31
  43. package/dist/skills/music-player/scripts/playback-client.mjs +0 -143
  44. package/skills/music-player/scripts/playback-client.mjs +0 -143
@@ -10,6 +10,7 @@ import * as Observability from "./observability.ts";
10
10
  import * as Paths from "./paths.ts";
11
11
  import * as Pi from "./pi.ts";
12
12
  import * as RunDelivery from "./run-delivery.ts";
13
+ import * as RunDeliveryLineage from "./run-delivery-lineage.ts";
13
14
 
14
15
  export interface RunUiRuntime {
15
16
  close(): void;
@@ -43,12 +44,11 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
43
44
  let animationInterval: NodeJS.Timeout | undefined;
44
45
  let deliveryTimeout: NodeJS.Timeout | undefined;
45
46
  let notifyTimeout: NodeJS.Timeout | undefined;
46
- let recoverQueuedBatch = false;
47
47
  let recoverQueuedSteers = false;
48
48
  let running = false;
49
49
  let lastWatcherDiagnosticId = 0;
50
50
  const observation = Observability.createRunUiObservationState();
51
- const deliveryRecoveryDiagnostics = new Set<string>();
51
+ const deliveryObservation = Observability.createRunUiObservationState();
52
52
  const retirementAttempts = new Set<string>();
53
53
  const steerDiagnostics = new Set<string>();
54
54
 
@@ -70,9 +70,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
70
70
  notifyTimeout = undefined;
71
71
  if (deliveryTimeout) clearTimeout(deliveryTimeout);
72
72
  deliveryTimeout = undefined;
73
- recoverQueuedBatch = false;
74
73
  recoverQueuedSteers = false;
75
- deliveryRecoveryDiagnostics.clear();
76
74
  steerDiagnostics.clear();
77
75
  if (animationInterval) clearInterval(animationInterval);
78
76
  animationInterval = undefined;
@@ -228,12 +226,14 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
228
226
  };
229
227
  const admitCompletionTransitions = (
230
228
  ownerId: string,
231
- transitions: Observability.RunTransition[],
229
+ snapshot: Observability.RunUiSnapshot,
232
230
  ): boolean => {
233
231
  const existing = journal(ownerId).completion_batch;
234
232
  if (existing) return true;
235
- const members = Observability.collectRunCompletionBatchMembers(transitions)
236
- .slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
233
+ const members = Observability.collectRunCompletionBatchMembers(
234
+ snapshot.transitions,
235
+ snapshot.summary.runs,
236
+ ).slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
237
237
  if (members.length === 0) return false;
238
238
  RunDelivery.admitRunCompletionBatch({
239
239
  members,
@@ -244,6 +244,10 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
244
244
  };
245
245
  const isIdle = (ctx: Pi.ExtensionContext): boolean =>
246
246
  typeof ctx.isIdle !== "function" || ctx.isIdle();
247
+ const isDeliveryRoot = (ownerId: string): boolean => {
248
+ const inheritedOwner = RunDeliveryLineage.getProcessDeliveryOwnerId();
249
+ return inheritedOwner === undefined || inheritedOwner === ownerId;
250
+ };
247
251
  let flushCompletionBatch = (_ctx: Pi.ExtensionContext): boolean => false;
248
252
  const flushSteers = (ctx: Pi.ExtensionContext, ownerId: string): boolean => {
249
253
  const recovering = recoverQueuedSteers;
@@ -334,11 +338,25 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
334
338
  if (!notify) return false;
335
339
  const sink = Pi.createNotificationSink(deps.pi, ctx);
336
340
  retireCandidateRuns(ctx, snapshot.summary);
337
- const hasCompletionCandidates =
338
- Observability.collectRunCompletionBatchMembers(snapshot.transitions).length > 0;
339
- const hasCompletionBatch = Boolean(journal(ownerId).completion_batch);
341
+ const deliverySnapshot = isDeliveryRoot(ownerId)
342
+ ? Observability.readRunDeliverySnapshot(deliveryObservation, ownerId)
343
+ : undefined;
344
+ const hasCompletionCandidates = deliverySnapshot
345
+ ? Observability.collectRunCompletionBatchMembers(
346
+ deliverySnapshot.transitions,
347
+ deliverySnapshot.summary.runs,
348
+ ).length > 0
349
+ : false;
350
+ const hasCompletionBatch = isDeliveryRoot(ownerId) &&
351
+ Boolean(journal(ownerId).completion_batch);
340
352
  admitSteerEvents(ctx, ownerId, snapshot.attentionEvents);
341
353
  Observability.pruneRunUiObservationState(observation, snapshot);
354
+ if (deliverySnapshot) {
355
+ Observability.pruneRunUiObservationState(
356
+ deliveryObservation,
357
+ deliverySnapshot,
358
+ );
359
+ }
342
360
  if (!terminalOnly) {
343
361
  Observability.deliverRunAttentionNotifications(
344
362
  snapshot.attentionEvents.filter((event) =>
@@ -403,54 +421,48 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
403
421
  });
404
422
 
405
423
  flushCompletionBatch = (ctx: Pi.ExtensionContext): boolean => {
406
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) return false;
407
424
  const ownerId = activeOwnerId;
408
- if (!ownerId) return false;
425
+ if (!running || !ownerId) return false;
426
+ try {
427
+ if (Pi.getSessionId(ctx) !== ownerId) return false;
428
+ } catch {
429
+ return false;
430
+ }
409
431
  if (flushSteers(ctx, ownerId)) return true;
432
+ if (!isDeliveryRoot(ownerId)) return false;
410
433
  let batch = journal(ownerId).completion_batch;
411
434
  if (!batch) {
412
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
413
- admitCompletionTransitions(ownerId, snapshot.transitions);
414
- Observability.pruneRunUiObservationState(observation, snapshot);
435
+ const snapshot = Observability.readRunDeliverySnapshot(
436
+ deliveryObservation,
437
+ ownerId,
438
+ );
439
+ admitCompletionTransitions(ownerId, snapshot);
440
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
415
441
  batch = journal(ownerId).completion_batch;
416
442
  }
417
443
  if (!batch) return false;
418
444
  if (batch.phase === "presented") {
419
445
  finishPresentedBatch(ownerId, batch);
420
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
421
- admitCompletionTransitions(ownerId, snapshot.transitions);
422
- Observability.pruneRunUiObservationState(observation, snapshot);
446
+ const snapshot = Observability.readRunDeliverySnapshot(
447
+ deliveryObservation,
448
+ ownerId,
449
+ );
450
+ admitCompletionTransitions(ownerId, snapshot);
451
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
423
452
  batch = journal(ownerId).completion_batch;
424
453
  if (!batch) return false;
425
454
  }
426
455
  if (!isIdle(ctx)) return true;
427
456
  const content = RunDelivery.formatRunCompletionBatchMessage(batch);
428
457
  if (batch.phase === "queued") {
429
- if (!recoverQueuedBatch) return true;
430
- recoverQueuedBatch = false;
431
- const evidence = Pi.inspectRunCompletionBatchSessionEvidence(
432
- ctx,
433
- batch.batch_id,
434
- content,
435
- );
436
- if (evidence.status === "present") return true;
437
- if (evidence.status !== "absent") {
438
- const diagnosticKey = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
439
- if (!deliveryRecoveryDiagnostics.has(diagnosticKey)) {
440
- deliveryRecoveryDiagnostics.add(diagnosticKey);
441
- ctx.ui.notify(
442
- `Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains queued.`,
443
- "warning",
444
- );
445
- }
446
- return true;
447
- }
448
- if (!RunDelivery.resetRunCompletionBatchPending({
458
+ if (!RunDelivery.markRunCompletionBatchPresented({
449
459
  batchId: batch.batch_id,
450
460
  ownerId,
451
461
  tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
452
462
  })) return true;
453
- batch = journal(ownerId).completion_batch!;
463
+ const accepted = journal(ownerId).completion_batch;
464
+ if (accepted) finishPresentedBatch(ownerId, accepted);
465
+ return flushCompletionBatch(ctx);
454
466
  }
455
467
  if (!isIdle(ctx)) return true;
456
468
  try {
@@ -471,7 +483,19 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
471
483
  })) {
472
484
  throw new Error("Completion batch changed before queue acknowledgment");
473
485
  }
474
- recoverQueuedBatch = false;
486
+ if (!RunDelivery.markRunCompletionBatchPresented({
487
+ batchId: batch.batch_id,
488
+ ownerId,
489
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
490
+ })) {
491
+ throw new Error("Completion batch changed before durable acceptance");
492
+ }
493
+ const accepted = journal(ownerId).completion_batch;
494
+ if (accepted) finishPresentedBatch(ownerId, accepted);
495
+ ctx.ui.notify(
496
+ `Actor completions ready: ${batch.members.length}`,
497
+ "info",
498
+ );
475
499
  return true;
476
500
  };
477
501
 
@@ -479,11 +503,13 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
479
503
  close,
480
504
  flushCompletionBatch,
481
505
  projectContext(messages, ctx) {
482
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) {
506
+ const ownerId = activeOwnerId;
507
+ if (!running || !ownerId) return messages;
508
+ try {
509
+ if (Pi.getSessionId(ctx) !== ownerId) return messages;
510
+ } catch {
483
511
  return messages;
484
512
  }
485
- const ownerId = activeOwnerId;
486
- if (!ownerId) return messages;
487
513
  const steerContext = Pi.dedupeRunSteerContext(messages);
488
514
  let contextMessages = steerContext.messages;
489
515
  for (const steer of journal(ownerId).steers) {
@@ -560,7 +586,6 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
560
586
  close();
561
587
  activeContext = ctx;
562
588
  activeOwnerId = ownerId;
563
- recoverQueuedBatch = true;
564
589
  recoverQueuedSteers = true;
565
590
  running = true;
566
591
  try {
package/lib/runtime.ts CHANGED
@@ -325,6 +325,7 @@ export function createRecipeToolReloadWatcher(
325
325
  let rootWatcher: FSWatcher | undefined;
326
326
  let parentWatcher: FSWatcher | undefined;
327
327
  let failureNotified = false;
328
+ let notifyAfterReload = false;
328
329
  const setWatchStatus = (watchStatus: RecipeRegistryStatus["watch_status"]): void =>
329
330
  runtime.setWatchStatus?.(watchStatus);
330
331
  const close = (): void => {
@@ -336,6 +337,7 @@ export function createRecipeToolReloadWatcher(
336
337
  closingParent?.close();
337
338
  if (reloadTimeout) clearTimeout(reloadTimeout);
338
339
  reloadTimeout = undefined;
340
+ notifyAfterReload = false;
339
341
  setWatchStatus("closed");
340
342
  };
341
343
  const reportCallbackError = (error: unknown): void => {
@@ -358,14 +360,21 @@ export function createRecipeToolReloadWatcher(
358
360
  reportCallbackError(error);
359
361
  }
360
362
  };
361
- const scheduleReload = (ctx: RuntimeContext): void => {
363
+ const scheduleReload = (
364
+ ctx: RuntimeContext,
365
+ notifyActiveChange = false,
366
+ ): void => {
362
367
  failureNotified = false;
368
+ notifyAfterReload ||= notifyActiveChange;
363
369
  if (reloadTimeout) clearTimeout(reloadTimeout);
364
370
  reloadTimeout = setTimeout(() => {
365
371
  reloadTimeout = undefined;
366
372
  try {
367
373
  runtime.loadTools(ctx, deps.getResolutionContext?.());
368
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
374
+ if (notifyAfterReload) {
375
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
376
+ }
377
+ notifyAfterReload = false;
369
378
  } catch (error) {
370
379
  notifyFailure(ctx);
371
380
  reportCallbackError(error);
@@ -394,7 +403,7 @@ export function createRecipeToolReloadWatcher(
394
403
  parentWatcher = undefined;
395
404
  watcher.close();
396
405
  watchRoot(ctx, recipeRoot);
397
- scheduleReload(ctx);
406
+ scheduleReload(ctx, true);
398
407
  });
399
408
  parentWatcher = watcher;
400
409
  setWatchStatus("watching_parent");
@@ -415,16 +424,18 @@ export function createRecipeToolReloadWatcher(
415
424
  return;
416
425
  }
417
426
  try {
418
- const watcher = watchPath(recipeRoot, () => {
427
+ const watcher = watchPath(recipeRoot, (_event, changedFile) => {
419
428
  if (rootWatcher !== watcher) return;
420
429
  if (!pathExists(recipeRoot)) {
421
430
  rootWatcher = undefined;
422
431
  watcher.close();
423
- scheduleReload(ctx);
432
+ scheduleReload(ctx, true);
424
433
  watchParent(ctx, recipeRoot);
425
434
  return;
426
435
  }
427
- scheduleReload(ctx);
436
+ const relativeChange = changedFile ? String(changedFile) : "";
437
+ const firstSegment = relativeChange.split(/[\\/]/u)[0];
438
+ scheduleReload(ctx, Boolean(relativeChange) && firstSegment !== "drafts");
428
439
  });
429
440
  rootWatcher = watcher;
430
441
  setWatchStatus("watching_root");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -76,16 +76,26 @@ Then:
76
76
 
77
77
  Use direct delegation for the same maintained capability under a persistent name or narrower defaults. Use named imports only when one Recipe graph contains reusable child nodes. See [persistent tools](./references/persistent-tools.md) and [Recipes](./references/recipes.md).
78
78
 
79
- ## Local coordinator topology
79
+ ## Capability map
80
80
 
81
- There are two distinct multi-instance shapes:
81
+ Recipes remain with the Skill that owns their behavior. Load that Skill for selection and caller inputs; this map is routing, not a copied Recipe contract.
82
82
 
83
- - A gateway-centric system owns ingress, agent-instance creation, routing, and lifecycle outside the agents.
84
- - A host-coordinator system keeps the current Pi instance as the control plane; companion extensions such as Telegram provide presence, while pi-actors creates explicit local Runs for delegated work.
83
+ | Owner | Recipe families |
84
+ | --- | --- |
85
+ | `actors` | `command-validate`, `recipe-validate`, `jsonl-tail`, `run-summary`, `run-state-files`, `run-ops-snapshot`, `resource-locker`, `resource-locker-snapshot` |
86
+ | `swarm` | Lens and quorum reviews, research synthesis, architecture, development tasking, readiness, and supporting `subagent-*` components |
87
+ | `artifacts` | Report, write, manifest, bundle, and supporting file-write |
88
+ | `project-work` | Repository health, docs maintenance, release readiness/summary, Run reports, and supporting deterministic snapshots |
89
+ | `music-player` | The controlled `playback` singleton |
90
+ | `recipe-memory` | Internal automatic reviewers; use its Skill only for diagnosis/recovery |
85
91
 
86
- In host-coordinator mode, the top-level agent receives declarative outcomes, preserves user authority and global context, delegates bounded concrete execution, and owns integration plus final validation. It is not merely another worker after delegation begins. One bounded implementation worker normally runs with reasoning off; consequential output receives a separate reasoning-enabled review. Several independent participants or reviewers additionally use `swarm`.
92
+ Use the `actors/*` helpers only for their narrow requested result or intentional composition. Prefer public `inspect` for ordinary runtime diagnosis and the capability's primary workflow for a complete outcome.
87
93
 
88
- Delegation is not mandatory for every prompt. Work inline when one short bounded act has one natural validation boundary and spawning would add more coordination than isolation, latency hiding, clean context, or continued coordinator availability can repay. For admitted delegation, prefer the settled completion batch and durable Trace/artifacts; inspect on meaningful attention, operator request, or an evidence-based overdue timer rather than busy polling. Treat `attention: "steer"` as an actor-authored urgent semantic checkpoint at Pi's next safe boundary, never as a status-derived completion signal; the later root terminal still arrives through its ordinary completion batch.
94
+ ## Delegation boundary
95
+
96
+ The current Pi instance owns user authority and the final result; pi-actors creates explicit local Runs, while companion transports provide presence rather than hidden instance creation. Keep short single-boundary work inline when delegation adds no value. For several participants, reasoning allocation, quorum, or integration methodology, read `swarm`.
97
+
98
+ Treat `attention: "steer"` as an actor-authored urgent semantic checkpoint at Pi's next safe boundary, never as a status-derived completion signal; the later root terminal still arrives through its ordinary completion batch.
89
99
 
90
100
  ## Run workflow
91
101
 
@@ -97,7 +107,7 @@ Run = Recipe + Trace + Control
97
107
  ```
98
108
 
99
109
  1. Spawn with the exact logical Recipe identity and caller-owned values.
100
- 2. Retain the returned `run:<id>` and normally wait for its settled completion batch instead of polling.
110
+ 2. Retain the returned `run:<id>` and normally wait for the root coordinator's settled completion batch instead of polling. Nested actor completions accumulate under their containing top-level Run and arrive in that one tree-compressed batch; descendant sessions do not need notification options.
101
111
  3. Inspect `view=trace` when retained observations or attention matter.
102
112
  4. Inspect `view=control` before diagnosing service readiness, stale work, or saturation.
103
113
  5. Send `message` only for an action declared and consumed by that controlled Recipe.
@@ -13,7 +13,7 @@ When a Telegram-originated turn or explicit Telegram-control question makes repe
13
13
 
14
14
  ## Playback
15
15
 
16
- `music-player/playback` is a singleton async controlled service with the canonical address `run:music-player`. The Recipe is the sole lifecycle owner: it starts the service, supervises it, and stops playback when the Run closes. The service owns queue, backend, checkpoint, and playback state. `playback-client.mjs` is a pure actor-neutral RPC client; it never starts, adopts, supervises, or signals a service. The executable also supports explicit foreground `serve` for development or a caller-owned standalone host, but Actor and standalone ownership of one state directory are mutually exclusive.
16
+ `music-player/playback` is a singleton async controlled service with the canonical address `run:music-player`. The Recipe is the sole lifecycle owner: it starts the service, supervises it, and stops playback when the Run closes. The service owns queue, backend, checkpoint, and playback state. `scripts/playback.mjs` owns both foreground playback and the bounded control CLI. Its `control <state-dir> <action> [percent]` entrypoint observes or controls an existing owner without starting, adopting, or supervising it. Explicit foreground `serve` supports a caller-owned standalone host without importing the Actor runtime; Actor and standalone ownership of one state directory are mutually exclusive.
17
17
 
18
18
  ```text
19
19
  spawn recipe=music-player/playback values={"source":"~/Music","player":"auto"}
@@ -35,14 +35,14 @@ Use only declared actions: `play`, `pause`, `resume`, `toggle`, `next`, `previou
35
35
  - `status` is read-only and exposes bounded machine-readable player state, including the current absolute volume and a duration-derived progress percentage projected at read time.
36
36
  - `stop` ends the live process without silently deleting the saved queue.
37
37
 
38
- External local views use the actor-neutral playback client against the canonical service state directory. They must not read or edit Run files, signal playback processes, construct Actor Control records, or import pi-actors internals. The client validates bounded commands, exact service generation, structured responses, and active endpoint ownership before returning success.
38
+ External local views use `playback.mjs control <state-dir> <action> [percent]`. For Actor-owned playback, the script validates Run availability, queues canonical Control, and waits for that exact record to become handled or failed. For standalone playback, it sends a bounded generation-fenced command to the service endpoint without creating Run, Control, or Trace state. Views themselves never read or edit Run files, signal processes, construct Control records, or import pi-actors internals. `status` is read-only and reports `actor_available` separately from playback state.
39
39
 
40
40
  ## Maintained Telegram View
41
41
 
42
42
  > [!NOTE]
43
43
  > This Skill includes a ready Music Player Generative App at `genapps/music-player.mjs`. When `telegram_bind` is available and Telegram interaction is relevant, use it to copy and install the app as `music-player`; hot replacement keeps the same app name with `replace: true`.
44
44
 
45
- Bind with absolute `control`, `stateDir`, and `node` arguments. The adapter reports whether the Actor control surface is actually available. Every mutating button queues a canonical Actor Control through `playback.mjs` and waits for that exact record to become handled or failed, so terminal evidence remains visible in the Run inspector and failures reach Telegram; bounded status projection is read-only. The adapter neither imports extension internals nor starts playback. Its stopped-state Start button returns to Pi so the composition root can spawn `music-player/playback`, while active controls remain deterministic Generative App actions that bypass the model. `pi-telegram` owns only the generic Generative App runtime.
45
+ Bind with absolute `control` (the `scripts/playback.mjs` path), `stateDir`, and `node` arguments. This maintained adapter targets Actor-owned playback and uses the unified `control` entrypoint above for status and mutation; it displays controls only when `actor_available` is true. Exact terminal Control evidence remains visible in the Run inspector and failures reach Telegram. The adapter neither imports extension internals nor starts playback. Its stopped-state Start button returns to Pi so the composition root can spawn `music-player/playback`, while active controls remain deterministic Generative App actions that bypass the model. `pi-telegram` owns only the generic Generative App runtime.
46
46
 
47
47
  ## Sources And Backends
48
48
 
@@ -70,7 +70,7 @@ function normalizePlayback(status) {
70
70
  async function readPlayback(adapter, run) {
71
71
  const result = await run({
72
72
  command: adapter.node,
73
- args: [adapter.control, "status", adapter.stateDir],
73
+ args: [adapter.control, "control", adapter.stateDir, "status"],
74
74
  cwd: dirname(adapter.control),
75
75
  timeoutMs: 5_000,
76
76
  });
@@ -188,8 +188,9 @@ async function applyVolume(percent, context) {
188
188
  command: context.state.adapter.node,
189
189
  args: [
190
190
  context.state.adapter.control,
191
- "volume",
191
+ "control",
192
192
  context.state.adapter.stateDir,
193
+ "volume",
193
194
  String(percent),
194
195
  ],
195
196
  cwd: dirname(context.state.adapter.control),
@@ -224,8 +225,9 @@ async function applySeek(percent, context) {
224
225
  command: context.state.adapter.node,
225
226
  args: [
226
227
  context.state.adapter.control,
227
- "seek",
228
+ "control",
228
229
  context.state.adapter.stateDir,
230
+ "seek",
229
231
  String(percent),
230
232
  ],
231
233
  cwd: dirname(context.state.adapter.control),
@@ -253,7 +255,7 @@ async function apply(action, context) {
253
255
  const before = await readPlayback(context.state.adapter, context.run);
254
256
  const result = await context.run({
255
257
  command: context.state.adapter.node,
256
- args: [context.state.adapter.control, action, context.state.adapter.stateDir],
258
+ args: [context.state.adapter.control, "control", context.state.adapter.stateDir, action],
257
259
  cwd: dirname(context.state.adapter.control),
258
260
  timeoutMs: 5_000,
259
261
  });
@@ -27,7 +27,7 @@ import {
27
27
  watch,
28
28
  writeFileSync,
29
29
  } from "node:fs";
30
- import { createServer } from "node:net";
30
+ import { createConnection, createServer } from "node:net";
31
31
  import { homedir } from "node:os";
32
32
  import {
33
33
  basename,
@@ -66,7 +66,7 @@ let updateRunControlStatusInStateDir;
66
66
  let isAlive;
67
67
  let verifyRunProcessIdentity;
68
68
  let appendRunTraceEvent = () => {};
69
- if (actorAdapterEnabled) {
69
+ async function loadActorAdapter() {
70
70
  ({
71
71
  appendRunControlInStateDir,
72
72
  claimRunControlByIdInStateDir,
@@ -110,8 +110,9 @@ function usage() {
110
110
  playback.mjs control <state-dir> <play|pause|toggle|next|previous|seek|volume|stop|status> [percent]
111
111
 
112
112
  Runs a foreground music player so pi-actors can own it as a controlled Run.
113
- Controls use canonical records in <state-dir>/controls.jsonl.
114
- Prefer message target=run:<run> action=<command>, or use direct control commands below.
113
+ Actor-owned controls use canonical records in <state-dir>/controls.jsonl.
114
+ Standalone controls use the generation-fenced playback service endpoint.
115
+ Prefer message target=run:<run> action=<command> for Actors; external adapters use control <state-dir> <action>.
115
116
  Supported players: auto, mpv, afplay, ffplay, cvlc, play, wmp.
116
117
  `);
117
118
  }
@@ -1134,6 +1135,7 @@ function readAndClearCommand(ctx) {
1134
1135
  }
1135
1136
 
1136
1137
  async function playMain(args) {
1138
+ if (actorAdapterEnabled) await loadActorAdapter();
1137
1139
  const [
1138
1140
  sourceArg,
1139
1141
  loopArg = "true",
@@ -1333,30 +1335,74 @@ function projectCurrentProgress(status, nowMs = Date.now()) {
1333
1335
  };
1334
1336
  }
1335
1337
 
1336
- function actorControlAvailability(stateDir) {
1338
+ async function actorControlAvailability(stateDir) {
1337
1339
  const run = readJsonFile(join(stateDir, "run.json"), {});
1338
1340
  const result = readJsonFile(join(stateDir, "result.json"), {});
1339
1341
  const endpoint = readJsonFile(join(stateDir, "control-endpoint.json"), {});
1340
1342
  const playerStatus = readJsonFile(join(stateDir, "player.json"), {});
1343
+ const runInstanceId = typeof run.run_instance_id === "string"
1344
+ ? run.run_instance_id
1345
+ : undefined;
1346
+ // Inactive status must remain readable without an installed Actor runtime,
1347
+ // including standalone state beside metadata from a rejected Actor launch.
1348
+ if (!runInstanceId || typeof result.completedAt === "string" ||
1349
+ endpoint.run_instance_id !== runInstanceId ||
1350
+ !["playing", "paused"].includes(playerStatus.state)) {
1351
+ return { available: false, runInstanceId };
1352
+ }
1353
+ if (!verifyRunProcessIdentity) {
1354
+ await loadActorAdapter();
1355
+ // Re-read authority after the asynchronous import before admitting control.
1356
+ return actorControlAvailability(stateDir);
1357
+ }
1341
1358
  const pid = Number(run.pid || 0);
1342
1359
  const hasProcessIdentity = pid > 0 || run.process_identity !== undefined;
1343
1360
  const processIdentity = pid > 0
1344
1361
  ? verifyRunProcessIdentity(pid, run.process_identity)
1345
1362
  : { valid: false };
1346
- const available =
1347
- typeof run.run_instance_id === "string" &&
1348
- typeof result.completedAt !== "string" &&
1349
- (!hasProcessIdentity || (pid > 0 && isAlive(pid) && processIdentity.valid === true)) &&
1350
- endpoint.run_instance_id === run.run_instance_id &&
1351
- ["playing", "paused"].includes(playerStatus.state);
1352
1363
  return {
1353
- available,
1354
- runInstanceId: typeof run.run_instance_id === "string"
1355
- ? run.run_instance_id
1356
- : undefined,
1364
+ available: !hasProcessIdentity ||
1365
+ (pid > 0 && isAlive(pid) && processIdentity.valid === true),
1366
+ runInstanceId,
1357
1367
  };
1358
1368
  }
1359
1369
 
1370
+ async function sendPlaybackCommand(endpoint, action, input) {
1371
+ const payload = `${JSON.stringify({
1372
+ action,
1373
+ ...(input !== undefined ? { input } : {}),
1374
+ service_instance_id: endpoint.service_instance_id,
1375
+ })}\n`;
1376
+ const response = await new Promise((resolveResponse, rejectResponse) => {
1377
+ const socket = createConnection(endpoint.path);
1378
+ let content = "";
1379
+ const timeout = setTimeout(() => {
1380
+ socket.destroy(new Error("playback service command timed out"));
1381
+ }, 5_000);
1382
+ timeout.unref?.();
1383
+ socket.setEncoding("utf8");
1384
+ socket.on("connect", () => socket.write(payload));
1385
+ socket.on("data", (chunk) => {
1386
+ content += chunk;
1387
+ if (Buffer.byteLength(content, "utf8") > 4096) {
1388
+ socket.destroy(new Error("playback service response is too large"));
1389
+ }
1390
+ });
1391
+ socket.on("close", () => clearTimeout(timeout));
1392
+ socket.on("error", rejectResponse);
1393
+ socket.on("end", () => {
1394
+ try {
1395
+ resolveResponse(JSON.parse(content.trim()));
1396
+ } catch {
1397
+ rejectResponse(new Error("playback service returned invalid JSON"));
1398
+ }
1399
+ });
1400
+ });
1401
+ if (response?.ok !== true) {
1402
+ throw new Error(response?.error || "playback service rejected the command");
1403
+ }
1404
+ }
1405
+
1360
1406
  async function controlMain(args) {
1361
1407
  const stateDir = expandPath(args[0] || "");
1362
1408
  const command = args[1] || "status";
@@ -1365,13 +1411,20 @@ async function controlMain(args) {
1365
1411
  usage();
1366
1412
  process.exit(2);
1367
1413
  }
1368
- mkdirSync(stateDir, { recursive: true });
1414
+ if (!CONTROL_COMMANDS.has(command)) fail(`unsupported command: ${command}`, 2);
1415
+ const endpoint = readJsonFile(join(stateDir, "playback-endpoint.json"), {});
1416
+ // A standalone service retains authority even if an unsuccessful Actor launch
1417
+ // left Run metadata beside its endpoint. Clients never start or adopt it.
1418
+ const actorOwned = endpoint.owner_mode !== "standalone" &&
1419
+ existsSync(join(stateDir, "run.json"));
1369
1420
  if (command === "status") {
1370
1421
  const statusFile = join(stateDir, "player.json");
1371
1422
  const status = exists(statusFile)
1372
1423
  ? readJsonFile(statusFile, { state: "unknown" })
1373
1424
  : { state: "unknown" };
1374
- const actor = actorControlAvailability(stateDir);
1425
+ const actor = actorOwned
1426
+ ? await actorControlAvailability(stateDir)
1427
+ : { available: false };
1375
1428
  process.stdout.write(`${JSON.stringify({
1376
1429
  ...projectCurrentProgress(status),
1377
1430
  actor_available: actor.available,
@@ -1381,7 +1434,7 @@ async function controlMain(args) {
1381
1434
  })}\n`);
1382
1435
  return;
1383
1436
  }
1384
- if (!actorControlAvailability(stateDir).available) {
1437
+ if (actorOwned && !(await actorControlAvailability(stateDir)).available) {
1385
1438
  fail(`Run playback is not active: ${stateDir}`, 3);
1386
1439
  }
1387
1440
  let input;
@@ -1396,6 +1449,20 @@ async function controlMain(args) {
1396
1449
  fail(error instanceof Error ? error.message : String(error), 2);
1397
1450
  }
1398
1451
  }
1452
+ if (!actorOwned) {
1453
+ if (endpoint.owner_mode !== "standalone" ||
1454
+ typeof endpoint.path !== "string" || !endpoint.path ||
1455
+ typeof endpoint.service_instance_id !== "string" || !endpoint.service_instance_id) {
1456
+ fail(`standalone playback service is not active: ${stateDir}`, 3);
1457
+ }
1458
+ try {
1459
+ await sendPlaybackCommand(endpoint, command === "resume" ? "play" : command, input);
1460
+ console.log(`music-player: command=${command} handled state_dir=${stateDir}`);
1461
+ return;
1462
+ } catch (error) {
1463
+ fail(error instanceof Error ? error.message : String(error), 3);
1464
+ }
1465
+ }
1399
1466
  const queued = appendControl(
1400
1467
  { controlsFile: join(stateDir, "controls.jsonl"), stateDir },
1401
1468
  command,
@@ -9,13 +9,9 @@ Use multi-actor execution only when at least two scopes or evidence lenses are m
9
9
 
10
10
  Read `actors` first for generic Recipe, spawn, Run, Trace, Control, artifact, and lifecycle operation. This Skill owns only multi-actor methodology: decomposition, scope ownership, independence, synthesis, integration, and completion proof.
11
11
 
12
- ## Coordinator topology
12
+ ## Coordinator and participants
13
13
 
14
- A swarm can be coordinated without an external gateway. In this model the current host agent is the declarative control plane, the actor kernel creates explicit participant Runs, and companion transports provide ingress or presence without owning hidden agent creation. The coordinator retains user authority, global context, decomposition, shared-surface ownership, integration, and final validation; participants own bounded concrete tasks and report evidence.
15
-
16
- This resembles gateway orchestration in dependency direction but not in ownership: the coordinator is itself an agent instance with inspectable Runs, not an infrastructure service that implicitly creates sessions. Preserve that distinction in prompts, docs, recovery, and target routing.
17
-
18
- Once work is delegated, keep the coordinator available for decisions and integration instead of duplicating participant implementation. Wait for the settled completion batch by default; use meaningful attention or evidence-based timers for overdue work rather than a tight inspection loop.
14
+ The coordinator owns decomposition, shared contracts, integration order, and final validation. Participants own bounded tasks or evidence lenses. Keep the coordinator available for decisions instead of duplicating participant implementation; use `actors` for launch, observation, and lifecycle mechanics.
19
15
 
20
16
  ## Reasoning allocation
21
17
 
@@ -14,38 +14,9 @@ Use a development swarm only when all are true:
14
14
 
15
15
  Do not parallelize implementation when tasks need the same central files, semantic ordering dominates wall-clock time, or the likely conflicts would invalidate the decomposition. Use planning or review first.
16
16
 
17
- ## Coordinator role separation
17
+ ## Roles and reasoning
18
18
 
19
- In a host-coordinator topology, the current Pi instance owns the declarative outcome and participant graph rather than acting as the default implementation worker. It may receive intent through Telegram or another companion extension, but transport does not become a gateway or gain hidden instance-creation authority; participant creation remains an explicit actor-kernel Run.
20
-
21
- The coordinator should:
22
-
23
- - Translate high-level intent into bounded task cards and dependency edges.
24
- - Keep user authority, shared contracts, integration order, and final validation local.
25
- - Remain available for checkpoints, permissions, conflicts, and changing evidence.
26
- - Consume terminal handoffs, durable artifacts, and Trace attention instead of mirroring participant work.
27
- - Check overdue work on an evidence-based timer; never replace event-driven completion with a rapid polling loop.
28
-
29
- A participant should:
30
-
31
- - Own one concrete execution or evidence boundary.
32
- - Avoid global orchestration and undeclared participant creation.
33
- - Return a bounded handoff that lets the coordinator decide without replaying the entire task.
34
-
35
- Use one ordinary Run under `actors` when only one worker is delegated. Activate this development-swarm protocol when two or more participants, parallel ownership, or explicit integration edges exist. Keep trivial single-boundary work inline when delegation overhead has no compensating value.
36
-
37
- ## Reasoning profiles
38
-
39
- | Role | Default | Raise or fan out when |
40
- | --- | --- | --- |
41
- | Bounded implementation author | Reasoning off | The card explicitly owns unresolved diagnosis or design judgement |
42
- | Reviewer | Medium reasoning, clean context | Stakes require independent lenses or repeated judges |
43
- | Synthesizer / integrator | Medium reasoning | Evidence conflicts, shared contracts move, or merge order is semantic |
44
- | Coordinator | Sufficient for decomposition and decisions | Scope, authority, or architecture remains unresolved |
45
-
46
- Prefer independent review after implementation over asking one author thread to implement, retain all local assumptions, and then certify itself. When risk justifies the cost, use multiple independent reviewers: different lenses increase breadth, while repeated judges increase confidence. Preserve minority high-impact findings and merge only evidence-backed conclusions.
47
-
48
- More reviewers are not automatically better. Do not fan out when they would inspect unstable code, share contaminated context, repeat one unsupported claim, or exceed the value of the decision. Never change an already-running participant solely to enforce a newer profile; add a fresh review boundary if evidence remains open.
19
+ Apply [Swarm's coordinator and reasoning contract](../SKILL.md#reasoning-allocation). Task cards record those profiles and the owned execution boundary; participants return evidence without undeclared orchestration. Do not fan out review over unstable code or contaminated context. The sections below specify development-only task cards, ownership transfers, conflict reports, and integration.
49
20
 
50
21
  ## Decompose by ownership
51
22