@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
@@ -11,6 +11,10 @@ import * as Limits from "./limits.js";
11
11
  const RUN_DELIVERY_SCHEMA = "run-delivery-v1";
12
12
  const MEMBER_FIELDS = new Set([
13
13
  "artifacts",
14
+ "parent_run",
15
+ "parent_run_instance_id",
16
+ "parent_state_dir",
17
+ "output",
14
18
  "run",
15
19
  "run_instance_id",
16
20
  "state_dir",
@@ -107,8 +111,32 @@ function parseMember(value) {
107
111
  const record = asRecord(value, "Completion batch member");
108
112
  assertExactFields(record, MEMBER_FIELDS, "Completion batch member");
109
113
  const parsedArtifacts = artifacts(record.artifacts);
114
+ const parentRun = record.parent_run === undefined
115
+ ? undefined
116
+ : boundedString(record.parent_run, "Completion batch member parent_run", 120);
117
+ const parentRunInstanceId = record.parent_run_instance_id === undefined
118
+ ? undefined
119
+ : boundedString(record.parent_run_instance_id, "Completion batch member parent_run_instance_id", 256);
120
+ const parentStateDir = record.parent_state_dir === undefined
121
+ ? undefined
122
+ : boundedString(record.parent_state_dir, "Completion batch member parent_state_dir", 4_096);
123
+ if (Boolean(parentRun) !== Boolean(parentRunInstanceId) ||
124
+ Boolean(parentRun) !== Boolean(parentStateDir)) {
125
+ throw new Error("Completion batch member parent lineage is incomplete");
126
+ }
127
+ const output = record.output === undefined
128
+ ? undefined
129
+ : boundedString(record.output, "Completion batch member output", 4_000);
110
130
  return {
111
131
  ...(parsedArtifacts ? { artifacts: parsedArtifacts } : {}),
132
+ ...(output ? { output } : {}),
133
+ ...(parentRun
134
+ ? {
135
+ parent_run: parentRun,
136
+ parent_run_instance_id: parentRunInstanceId,
137
+ parent_state_dir: parentStateDir,
138
+ }
139
+ : {}),
112
140
  run: boundedString(record.run, "Completion batch member run", 120),
113
141
  run_instance_id: boundedString(record.run_instance_id, "Completion batch member run_instance_id", 256),
114
142
  state_dir: boundedString(record.state_dir, "Completion batch member state_dir", 4_096),
@@ -566,13 +594,79 @@ function compactModelText(value, limit) {
566
594
  const compact = value.replaceAll(/\s+/g, " ").replaceAll("`", "'").trim();
567
595
  return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
568
596
  }
569
- function formatCompletionMember(member) {
597
+ function memberIdentity(member) {
598
+ return `${member.state_dir}\0${member.run_instance_id}`;
599
+ }
600
+ function completionDepth(member, byIdentity) {
601
+ const visited = new Set();
602
+ let current = member;
603
+ let depth = 0;
604
+ while (current.parent_state_dir && current.parent_run_instance_id) {
605
+ const identity = `${current.parent_state_dir}\0${current.parent_run_instance_id}`;
606
+ if (visited.has(identity))
607
+ break;
608
+ visited.add(identity);
609
+ const parent = byIdentity.get(identity);
610
+ if (!parent)
611
+ break;
612
+ depth += 1;
613
+ current = parent;
614
+ }
615
+ return depth;
616
+ }
617
+ function orderCompletionForest(members) {
618
+ const byIdentity = new Map(members.map((member) => [memberIdentity(member), member]));
619
+ const children = new Map();
620
+ const roots = [];
621
+ for (const member of members) {
622
+ const parentIdentity = member.parent_state_dir && member.parent_run_instance_id
623
+ ? `${member.parent_state_dir}\0${member.parent_run_instance_id}`
624
+ : undefined;
625
+ if (!parentIdentity || !byIdentity.has(parentIdentity)) {
626
+ roots.push(member);
627
+ continue;
628
+ }
629
+ const siblings = children.get(parentIdentity) ?? [];
630
+ siblings.push(member);
631
+ children.set(parentIdentity, siblings);
632
+ }
633
+ const ordered = [];
634
+ const visited = new Set();
635
+ const visit = (member) => {
636
+ const identity = memberIdentity(member);
637
+ if (visited.has(identity))
638
+ return;
639
+ visited.add(identity);
640
+ ordered.push(member);
641
+ for (const child of children.get(identity) ?? [])
642
+ visit(child);
643
+ };
644
+ for (const root of roots)
645
+ visit(root);
646
+ for (const member of members)
647
+ visit(member);
648
+ return ordered;
649
+ }
650
+ function formatModelOutput(value, indent) {
651
+ return value
652
+ .replaceAll("`", "'")
653
+ .split(/\r?\n/u)
654
+ .map((line) => line.trimEnd())
655
+ .filter((line, index, lines) => line || (index > 0 && index < lines.length - 1))
656
+ .map((line) => `${indent} ${line}`)
657
+ .join("\n");
658
+ }
659
+ function formatCompletionMember(member, byIdentity) {
570
660
  const summary = compactModelText(member.summary, 200);
571
661
  const artifactEntries = Object.entries(member.artifacts ?? {}).slice(0, 4);
572
662
  const artifactText = artifactEntries.length === 0
573
663
  ? ""
574
664
  : `; artifacts: ${artifactEntries.map(([name, path]) => `${compactModelText(name, 120)}=\`${compactModelText(path, 320)}\``).join(", ")}`;
575
- return `- \`${compactModelText(member.run, 120)}\` — \`${member.status}\`: ${summary}${artifactText}`;
665
+ const indent = " ".repeat(Math.min(completionDepth(member, byIdentity), 8));
666
+ const output = member.output
667
+ ? `\n${indent} Output:\n${formatModelOutput(member.output, indent)}`
668
+ : "";
669
+ return `${indent}- \`${compactModelText(member.run, 120)}\` — \`${member.status}\`: ${summary}${artifactText}${output}`;
576
670
  }
577
671
  function formatStatusCounts(members) {
578
672
  const counts = new Map();
@@ -594,9 +688,13 @@ export function formatRunCompletionBatchMessage(batch) {
594
688
  `Window: \`${terminalTimes[0]}\` → \`${terminalTimes.at(-1)}\``,
595
689
  `Statuses: \`${formatStatusCounts(safe.members)}\``,
596
690
  ];
597
- const candidateRows = safe.members
691
+ const byIdentity = new Map(safe.members.map((member) => [
692
+ memberIdentity(member),
693
+ member,
694
+ ]));
695
+ const candidateRows = orderCompletionForest(safe.members)
598
696
  .slice(0, Limits.RUN_DELIVERY_MODEL_MAX_MEMBERS)
599
- .map(formatCompletionMember);
697
+ .map((member) => formatCompletionMember(member, byIdentity));
600
698
  const rows = [];
601
699
  for (const row of candidateRows) {
602
700
  const omitted = safe.members.length - rows.length - 1;
@@ -9,18 +9,18 @@ import * as Observability from "./observability.js";
9
9
  import * as Paths from "./paths.js";
10
10
  import * as Pi from "./pi.js";
11
11
  import * as RunDelivery from "./run-delivery.js";
12
+ import * as RunDeliveryLineage from "./run-delivery-lineage.js";
12
13
  export function createRunUiRuntime(deps) {
13
14
  let activeContext;
14
15
  let activeOwnerId;
15
16
  let animationInterval;
16
17
  let deliveryTimeout;
17
18
  let notifyTimeout;
18
- let recoverQueuedBatch = false;
19
19
  let recoverQueuedSteers = false;
20
20
  let running = false;
21
21
  let lastWatcherDiagnosticId = 0;
22
22
  const observation = Observability.createRunUiObservationState();
23
- const deliveryRecoveryDiagnostics = new Set();
23
+ const deliveryObservation = Observability.createRunUiObservationState();
24
24
  const retirementAttempts = new Set();
25
25
  const steerDiagnostics = new Set();
26
26
  const close = () => {
@@ -45,9 +45,7 @@ export function createRunUiRuntime(deps) {
45
45
  if (deliveryTimeout)
46
46
  clearTimeout(deliveryTimeout);
47
47
  deliveryTimeout = undefined;
48
- recoverQueuedBatch = false;
49
48
  recoverQueuedSteers = false;
50
- deliveryRecoveryDiagnostics.clear();
51
49
  steerDiagnostics.clear();
52
50
  if (animationInterval)
53
51
  clearInterval(animationInterval);
@@ -173,12 +171,11 @@ export function createRunUiRuntime(deps) {
173
171
  }
174
172
  }
175
173
  };
176
- const admitCompletionTransitions = (ownerId, transitions) => {
174
+ const admitCompletionTransitions = (ownerId, snapshot) => {
177
175
  const existing = journal(ownerId).completion_batch;
178
176
  if (existing)
179
177
  return true;
180
- const members = Observability.collectRunCompletionBatchMembers(transitions)
181
- .slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
178
+ const members = Observability.collectRunCompletionBatchMembers(snapshot.transitions, snapshot.summary.runs).slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
182
179
  if (members.length === 0)
183
180
  return false;
184
181
  RunDelivery.admitRunCompletionBatch({
@@ -189,6 +186,10 @@ export function createRunUiRuntime(deps) {
189
186
  return true;
190
187
  };
191
188
  const isIdle = (ctx) => typeof ctx.isIdle !== "function" || ctx.isIdle();
189
+ const isDeliveryRoot = (ownerId) => {
190
+ const inheritedOwner = RunDeliveryLineage.getProcessDeliveryOwnerId();
191
+ return inheritedOwner === undefined || inheritedOwner === ownerId;
192
+ };
192
193
  let flushCompletionBatch = (_ctx) => false;
193
194
  const flushSteers = (ctx, ownerId) => {
194
195
  const recovering = recoverQueuedSteers;
@@ -273,10 +274,19 @@ export function createRunUiRuntime(deps) {
273
274
  return false;
274
275
  const sink = Pi.createNotificationSink(deps.pi, ctx);
275
276
  retireCandidateRuns(ctx, snapshot.summary);
276
- const hasCompletionCandidates = Observability.collectRunCompletionBatchMembers(snapshot.transitions).length > 0;
277
- const hasCompletionBatch = Boolean(journal(ownerId).completion_batch);
277
+ const deliverySnapshot = isDeliveryRoot(ownerId)
278
+ ? Observability.readRunDeliverySnapshot(deliveryObservation, ownerId)
279
+ : undefined;
280
+ const hasCompletionCandidates = deliverySnapshot
281
+ ? Observability.collectRunCompletionBatchMembers(deliverySnapshot.transitions, deliverySnapshot.summary.runs).length > 0
282
+ : false;
283
+ const hasCompletionBatch = isDeliveryRoot(ownerId) &&
284
+ Boolean(journal(ownerId).completion_batch);
278
285
  admitSteerEvents(ctx, ownerId, snapshot.attentionEvents);
279
286
  Observability.pruneRunUiObservationState(observation, snapshot);
287
+ if (deliverySnapshot) {
288
+ Observability.pruneRunUiObservationState(deliveryObservation, deliverySnapshot);
289
+ }
280
290
  if (!terminalOnly) {
281
291
  Observability.deliverRunAttentionNotifications(snapshot.attentionEvents.filter((event) => !Observability.isRunSteerAttentionEvent(event)), sink);
282
292
  }
@@ -333,27 +343,34 @@ export function createRunUiRuntime(deps) {
333
343
  },
334
344
  });
335
345
  flushCompletionBatch = (ctx) => {
336
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx)
337
- return false;
338
346
  const ownerId = activeOwnerId;
339
- if (!ownerId)
347
+ if (!running || !ownerId)
348
+ return false;
349
+ try {
350
+ if (Pi.getSessionId(ctx) !== ownerId)
351
+ return false;
352
+ }
353
+ catch {
340
354
  return false;
355
+ }
341
356
  if (flushSteers(ctx, ownerId))
342
357
  return true;
358
+ if (!isDeliveryRoot(ownerId))
359
+ return false;
343
360
  let batch = journal(ownerId).completion_batch;
344
361
  if (!batch) {
345
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
346
- admitCompletionTransitions(ownerId, snapshot.transitions);
347
- Observability.pruneRunUiObservationState(observation, snapshot);
362
+ const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
363
+ admitCompletionTransitions(ownerId, snapshot);
364
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
348
365
  batch = journal(ownerId).completion_batch;
349
366
  }
350
367
  if (!batch)
351
368
  return false;
352
369
  if (batch.phase === "presented") {
353
370
  finishPresentedBatch(ownerId, batch);
354
- const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
355
- admitCompletionTransitions(ownerId, snapshot.transitions);
356
- Observability.pruneRunUiObservationState(observation, snapshot);
371
+ const snapshot = Observability.readRunDeliverySnapshot(deliveryObservation, ownerId);
372
+ admitCompletionTransitions(ownerId, snapshot);
373
+ Observability.pruneRunUiObservationState(deliveryObservation, snapshot);
357
374
  batch = journal(ownerId).completion_batch;
358
375
  if (!batch)
359
376
  return false;
@@ -362,27 +379,16 @@ export function createRunUiRuntime(deps) {
362
379
  return true;
363
380
  const content = RunDelivery.formatRunCompletionBatchMessage(batch);
364
381
  if (batch.phase === "queued") {
365
- if (!recoverQueuedBatch)
366
- return true;
367
- recoverQueuedBatch = false;
368
- const evidence = Pi.inspectRunCompletionBatchSessionEvidence(ctx, batch.batch_id, content);
369
- if (evidence.status === "present")
370
- return true;
371
- if (evidence.status !== "absent") {
372
- const diagnosticKey = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
373
- if (!deliveryRecoveryDiagnostics.has(diagnosticKey)) {
374
- deliveryRecoveryDiagnostics.add(diagnosticKey);
375
- ctx.ui.notify(`Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains queued.`, "warning");
376
- }
377
- return true;
378
- }
379
- if (!RunDelivery.resetRunCompletionBatchPending({
382
+ if (!RunDelivery.markRunCompletionBatchPresented({
380
383
  batchId: batch.batch_id,
381
384
  ownerId,
382
385
  tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
383
386
  }))
384
387
  return true;
385
- batch = journal(ownerId).completion_batch;
388
+ const accepted = journal(ownerId).completion_batch;
389
+ if (accepted)
390
+ finishPresentedBatch(ownerId, accepted);
391
+ return flushCompletionBatch(ctx);
386
392
  }
387
393
  if (!isIdle(ctx))
388
394
  return true;
@@ -405,19 +411,33 @@ export function createRunUiRuntime(deps) {
405
411
  })) {
406
412
  throw new Error("Completion batch changed before queue acknowledgment");
407
413
  }
408
- recoverQueuedBatch = false;
414
+ if (!RunDelivery.markRunCompletionBatchPresented({
415
+ batchId: batch.batch_id,
416
+ ownerId,
417
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
418
+ })) {
419
+ throw new Error("Completion batch changed before durable acceptance");
420
+ }
421
+ const accepted = journal(ownerId).completion_batch;
422
+ if (accepted)
423
+ finishPresentedBatch(ownerId, accepted);
424
+ ctx.ui.notify(`Actor completions ready: ${batch.members.length}`, "info");
409
425
  return true;
410
426
  };
411
427
  return {
412
428
  close,
413
429
  flushCompletionBatch,
414
430
  projectContext(messages, ctx) {
415
- if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) {
431
+ const ownerId = activeOwnerId;
432
+ if (!running || !ownerId)
416
433
  return messages;
434
+ try {
435
+ if (Pi.getSessionId(ctx) !== ownerId)
436
+ return messages;
417
437
  }
418
- const ownerId = activeOwnerId;
419
- if (!ownerId)
438
+ catch {
420
439
  return messages;
440
+ }
421
441
  const steerContext = Pi.dedupeRunSteerContext(messages);
422
442
  let contextMessages = steerContext.messages;
423
443
  for (const steer of journal(ownerId).steers) {
@@ -486,7 +506,6 @@ export function createRunUiRuntime(deps) {
486
506
  close();
487
507
  activeContext = ctx;
488
508
  activeOwnerId = ownerId;
489
- recoverQueuedBatch = true;
490
509
  recoverQueuedSteers = true;
491
510
  running = true;
492
511
  try {
@@ -223,6 +223,7 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
223
223
  let rootWatcher;
224
224
  let parentWatcher;
225
225
  let failureNotified = false;
226
+ let notifyAfterReload = false;
226
227
  const setWatchStatus = (watchStatus) => runtime.setWatchStatus?.(watchStatus);
227
228
  const close = () => {
228
229
  const closingRoot = rootWatcher;
@@ -234,6 +235,7 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
234
235
  if (reloadTimeout)
235
236
  clearTimeout(reloadTimeout);
236
237
  reloadTimeout = undefined;
238
+ notifyAfterReload = false;
237
239
  setWatchStatus("closed");
238
240
  };
239
241
  const reportCallbackError = (error) => {
@@ -256,15 +258,19 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
256
258
  reportCallbackError(error);
257
259
  }
258
260
  };
259
- const scheduleReload = (ctx) => {
261
+ const scheduleReload = (ctx, notifyActiveChange = false) => {
260
262
  failureNotified = false;
263
+ notifyAfterReload ||= notifyActiveChange;
261
264
  if (reloadTimeout)
262
265
  clearTimeout(reloadTimeout);
263
266
  reloadTimeout = setTimeout(() => {
264
267
  reloadTimeout = undefined;
265
268
  try {
266
269
  runtime.loadTools(ctx, deps.getResolutionContext?.());
267
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
270
+ if (notifyAfterReload) {
271
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
272
+ }
273
+ notifyAfterReload = false;
268
274
  }
269
275
  catch (error) {
270
276
  notifyFailure(ctx);
@@ -295,7 +301,7 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
295
301
  parentWatcher = undefined;
296
302
  watcher.close();
297
303
  watchRoot(ctx, recipeRoot);
298
- scheduleReload(ctx);
304
+ scheduleReload(ctx, true);
299
305
  });
300
306
  parentWatcher = watcher;
301
307
  setWatchStatus("watching_parent");
@@ -319,17 +325,19 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
319
325
  return;
320
326
  }
321
327
  try {
322
- const watcher = watchPath(recipeRoot, () => {
328
+ const watcher = watchPath(recipeRoot, (_event, changedFile) => {
323
329
  if (rootWatcher !== watcher)
324
330
  return;
325
331
  if (!pathExists(recipeRoot)) {
326
332
  rootWatcher = undefined;
327
333
  watcher.close();
328
- scheduleReload(ctx);
334
+ scheduleReload(ctx, true);
329
335
  watchParent(ctx, recipeRoot);
330
336
  return;
331
337
  }
332
- scheduleReload(ctx);
338
+ const relativeChange = changedFile ? String(changedFile) : "";
339
+ const firstSegment = relativeChange.split(/[\\/]/u)[0];
340
+ scheduleReload(ctx, Boolean(relativeChange) && firstSegment !== "drafts");
333
341
  });
334
342
  rootWatcher = watcher;
335
343
  setWatchStatus("watching_root");
@@ -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
  });