@llblab/pi-actors 0.49.0 → 0.49.2

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.
package/BACKLOG.md CHANGED
@@ -1,6 +1,16 @@
1
1
  # Project Backlog
2
2
 
3
- - [ ] `Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority.
3
+ - [ ] `0.50.0 hardening`: Close the confirmed authorization, bounded-inspection, and operator-truth gaps from the v0.49.1 production-readiness review.
4
+ - [ ] `Run boundary checkpoint`: Public Run operations remain owner-safe and bounded under missing identity and adversarial evidence sizes.
5
+ - [ ] Make Run-specific `inspect` and `message` operations fail closed when the caller session identity is unavailable, keep runtime inventory owner-filtered, and add missing-context plus cross-owner regressions.
6
+ - [ ] Bound session-evidence and artifact-manifest inspection before full-file materialization; use capped/streaming reads or hashing as appropriate and add adversarial-size regressions.
7
+ - [ ] `Operator truth checkpoint`: Public status and recovery guidance exactly match supported runtime behavior.
8
+ - [ ] Route automatic-review status through the canonical policy parser so `0`, `false`, and `off` are reported consistently without case sensitivity.
9
+ - [ ] Replace the removed `session:<id>` and `session:all` recovery hints with supported inspection guidance.
10
+ - [ ] Document exact `message target=run:<id>` examples for runtime-owned `kill`, `archive`, and `prune`, including their state and artifact-preservation constraints.
11
+ - [ ] Pass focused boundary and operator-contract regressions plus full package validation before release.
12
+
13
+ - [ ] `Future minor — Linux MPRIS media integration`: Expose the active `music-player/playback` singleton as one optional generation-fenced MPRIS2 player so GNOME and compatible desktop shells can show current media and native controls without making D-Bus a second playback authority; keep this feature outside the 0.49.2 and 0.50.0 cohorts.
4
14
  - [ ] Publish `PlaybackStatus`, track metadata, duration, read-time position, volume, and supported capabilities under one stable session-scoped bus identity; disappear cleanly when the Run stops or its generation is replaced, and fail soft when the user D-Bus session is unavailable.
5
15
  - [ ] Map `Play`, `Pause`, `PlayPause`, `Next`, `Previous`, `Stop`, `Seek`, `SetPosition`, and `Volume` back into the existing generation-fenced music-player Control/helper contract rather than signaling the backend or editing Run state directly.
6
16
  - [ ] Validate deterministic D-Bus contract behavior plus a live GNOME smoke showing the media surface, metadata, progress, volume, and controls while preserving backend independence and exact Actor ownership.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,14 @@
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.49.2: Stale-Context Lifecycle Hotfix
8
+
9
+ - `Stale-Context Lifecycle Hotfix`: Contains Run UI animation, delayed watcher, reconciliation, error-handler, retirement, and Recipe-reload callbacks so invalidated Pi contexts cannot escape into the host event loop. Session owners are captured while live, stale shutdown cannot close a replacement session, and parent teardown plus shutdown notification remain identity-fenced and no-throw (GitHub issue #125).
10
+
11
+ ## 0.49.1: Maintained Telegram View Routing
12
+
13
+ - `Maintained Telegram View Routing`: Music Player now treats Telegram-originated control intent as a breadcrumb to its ready capability-owned Generative App, preferring bind/invoke over one-shot prompt buttons while preserving Actor playback authority and an explicit model-mediated fallback when the app runtime is unavailable.
14
+
7
15
  ## 0.49.0: Skill-Scoped Music Player
8
16
 
9
17
  - `Skill-Scoped Singletons`: Added one optional async singleton Recipe per active Skill with canonical `run:<skill>` and `<skill>/<recipe>` identities, idempotent compatible reuse, lifecycle/process fencing, terminal-generation replacement, delegation-safe identity inheritance, persistent actor-owned state directories, focused inspection, and fail-closed conflicts across Recipe, owner, startup values, and Control.
@@ -17,6 +17,8 @@ import * as Tools from "./tools.js";
17
17
  import * as ToolsResponse from "./tools-response.js";
18
18
  export function createActorExtensionRuntime(pi) {
19
19
  let activeRunContext;
20
+ let activeRunOwnerId;
21
+ const runOwnerIdsByContext = new WeakMap();
20
22
  const recipeResolutionContextsBySession = new Map();
21
23
  const getRunOwnerId = Pi.getSessionId;
22
24
  const getRecipeResolutionContext = (ctx) => {
@@ -32,9 +34,21 @@ export function createActorExtensionRuntime(pi) {
32
34
  getRunOwnerId,
33
35
  getThinkingLevel: () => pi.getThinkingLevel(),
34
36
  });
35
- const runUiRuntime = RunUiRuntime.createRunUiRuntime({
37
+ let recipeReload;
38
+ let runUiRuntime;
39
+ const closeActiveSessionRuntimes = () => {
40
+ const ownerId = activeRunOwnerId;
41
+ activeRunContext = undefined;
42
+ activeRunOwnerId = undefined;
43
+ runUiRuntime?.close();
44
+ automaticReview.close();
45
+ recipeReload?.close();
46
+ if (ownerId)
47
+ recipeResolutionContextsBySession.delete(ownerId);
48
+ };
49
+ runUiRuntime = RunUiRuntime.createRunUiRuntime({
36
50
  getActiveContext: () => activeRunContext,
37
- getRunOwnerId,
51
+ onCallbackError: closeActiveSessionRuntimes,
38
52
  onRunEvent: automaticReview.schedule,
39
53
  pi,
40
54
  });
@@ -77,10 +91,11 @@ export function createActorExtensionRuntime(pi) {
77
91
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
78
92
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
79
93
  });
80
- const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
94
+ recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
81
95
  getResolutionContext: () => activeRunContext
82
96
  ? getRecipeResolutionContext(activeRunContext)
83
97
  : undefined,
98
+ onCallbackError: closeActiveSessionRuntimes,
84
99
  });
85
100
  return {
86
101
  beforeAgentStart(systemPrompt, skills, ctx) {
@@ -102,25 +117,29 @@ export function createActorExtensionRuntime(pi) {
102
117
  automaticReview.schedule();
103
118
  },
104
119
  onSessionShutdown(reason, ctx) {
105
- recipeResolutionContextsBySession.delete(getRunOwnerId(ctx));
106
- activeRunContext = undefined;
107
- automaticReview.close();
108
- recipeReload.close();
109
- runUiRuntime.shutdown(reason, ctx);
120
+ const ownerId = runOwnerIdsByContext.get(ctx);
121
+ runOwnerIdsByContext.delete(ctx);
122
+ if (activeRunContext === ctx)
123
+ closeActiveSessionRuntimes();
124
+ if (ownerId)
125
+ recipeResolutionContextsBySession.delete(ownerId);
126
+ runUiRuntime.shutdown(reason, ownerId, ctx);
110
127
  },
111
128
  async onSessionStart(ctx) {
112
129
  const sessionId = getRunOwnerId(ctx);
113
130
  recipeResolutionContextsBySession.set(sessionId, RecipeResolution.createEmptyRecipeResolutionContext(sessionId, ctx.cwd));
114
131
  ctx.ui.setWidget("zz-pi-actors-comms", undefined);
115
132
  activeRunContext = ctx;
133
+ activeRunOwnerId = sessionId;
134
+ runOwnerIdsByContext.set(ctx, sessionId);
116
135
  runUiRuntime.close();
117
136
  automaticReview.close();
118
137
  recipeReload.close();
119
138
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
120
- if (activeRunContext !== ctx)
139
+ if (activeRunContext !== ctx || activeRunOwnerId !== sessionId)
121
140
  return;
122
141
  automaticReview.start(ctx);
123
- runUiRuntime.start(ctx);
142
+ runUiRuntime.start(ctx, sessionId);
124
143
  recipeReload.watch(ctx);
125
144
  },
126
145
  registerCoreTools() {
@@ -229,7 +229,12 @@ export function createRunTerminalReconciliationLoop(input) {
229
229
  input.reconcile();
230
230
  }
231
231
  catch (error) {
232
- input.onError?.(error);
232
+ try {
233
+ input.onError?.(error);
234
+ }
235
+ catch {
236
+ /* reconciliation callbacks must never escape into the host event loop */
237
+ }
233
238
  }
234
239
  };
235
240
  const close = () => {
@@ -3,16 +3,23 @@
3
3
  * Zones: run watcher lifecycle, terminal reconciliation, status animation, shutdown teardown
4
4
  * Owns event-driven run UI coordination without owning actor execution semantics.
5
5
  */
6
+ import * as AsyncRuns from "./async-runs.ts";
7
+ import * as Observability from "./observability.ts";
6
8
  import * as Pi from "./pi.ts";
7
9
  export interface RunUiRuntime {
8
10
  close(): void;
9
- shutdown(eventReason: string, ctx: Pi.ExtensionContext): void;
10
- start(ctx: Pi.ExtensionContext): void;
11
+ shutdown(eventReason: string, ownerId: string | undefined, ctx?: Pi.ExtensionContext): void;
12
+ start(ctx: Pi.ExtensionContext, ownerId: string): void;
11
13
  }
12
14
  export interface RunUiRuntimeDeps {
15
+ animationIntervalMs?: number;
16
+ createRunStateWatcher?: typeof Observability.createRunStateWatcher;
17
+ createRunTerminalReconciliationLoop?: typeof Observability.createRunTerminalReconciliationLoop;
13
18
  getActiveContext(): Pi.ExtensionContext | undefined;
14
- getRunOwnerId(ctx: Pi.ExtensionContext): string;
19
+ notificationDelayMs?: number;
20
+ onCallbackError?: (error: unknown) => void;
15
21
  onRunEvent(): void;
16
22
  pi: Pi.ExtensionAPI;
23
+ teardownRunsOwnedByParent?: typeof AsyncRuns.teardownRunsOwnedByParent;
17
24
  }
18
25
  export declare function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime;
@@ -8,22 +8,78 @@ import * as Observability from "./observability.js";
8
8
  import * as Paths from "./paths.js";
9
9
  import * as Pi from "./pi.js";
10
10
  export function createRunUiRuntime(deps) {
11
+ let activeContext;
12
+ let activeOwnerId;
11
13
  let animationInterval;
12
14
  let notifyTimeout;
15
+ let running = false;
13
16
  let lastWatcherDiagnosticId = 0;
14
17
  const observation = Observability.createRunUiObservationState();
15
18
  const retirementAttempts = new Set();
16
19
  const terminalNotificationsInFlight = new Set();
20
+ const close = () => {
21
+ running = false;
22
+ activeContext = undefined;
23
+ activeOwnerId = undefined;
24
+ try {
25
+ watcher.close();
26
+ }
27
+ catch {
28
+ /* cleanup must not escape a host callback */
29
+ }
30
+ try {
31
+ reconciliation.close();
32
+ }
33
+ catch {
34
+ /* cleanup must not escape a host callback */
35
+ }
36
+ if (notifyTimeout)
37
+ clearTimeout(notifyTimeout);
38
+ notifyTimeout = undefined;
39
+ if (animationInterval)
40
+ clearInterval(animationInterval);
41
+ animationInterval = undefined;
42
+ };
43
+ const stopAfterCallbackFailure = (label, error, expectedContext) => {
44
+ if (activeContext !== expectedContext)
45
+ return;
46
+ close();
47
+ try {
48
+ deps.onCallbackError?.(error);
49
+ }
50
+ catch {
51
+ /* host callback containment must remain no-throw */
52
+ }
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ try {
55
+ expectedContext.ui.notify(`Actor ${label} failed: ${message}`, "error");
56
+ }
57
+ catch {
58
+ /* stale context or unavailable UI */
59
+ }
60
+ };
61
+ const runActiveCallback = (label, callback) => {
62
+ if (!running || !activeContext || !activeOwnerId)
63
+ return;
64
+ const ctx = activeContext;
65
+ try {
66
+ if (deps.getActiveContext() !== ctx)
67
+ return;
68
+ callback(ctx, activeOwnerId);
69
+ }
70
+ catch (error) {
71
+ stopAfterCallbackFailure(label, error, ctx);
72
+ }
73
+ };
17
74
  const retireCandidateRuns = (ctx, summary) => {
18
75
  void Observability.executeRunRetirements(summary, {
19
76
  attempted: retirementAttempts,
20
77
  cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
21
78
  notify: (message, level) => ctx.ui.notify(message, level),
22
79
  sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
23
- });
80
+ }).catch((error) => stopAfterCallbackFailure("Run retirement callback", error, ctx));
24
81
  };
25
- const update = (ctx, notify = false, terminalOnly = false) => {
26
- const ownerId = deps.getRunOwnerId(ctx);
82
+ const update = (ctx, ownerId, notify = false, terminalOnly = false) => {
27
83
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
28
84
  ctx.ui.setStatus("zz-pi-actors-runs", snapshot.status ? ctx.ui.theme.fg("dim", snapshot.status) : undefined);
29
85
  if (!notify)
@@ -45,62 +101,55 @@ export function createRunUiRuntime(deps) {
45
101
  }
46
102
  };
47
103
  const scheduleUpdate = () => {
104
+ if (!running)
105
+ return;
48
106
  if (notifyTimeout)
49
107
  clearTimeout(notifyTimeout);
50
108
  notifyTimeout = setTimeout(() => {
51
- const ctx = deps.getActiveContext();
52
- if (!ctx)
53
- return;
54
- watcher.refresh();
55
- update(ctx, true);
56
- deps.onRunEvent();
57
- reportDiagnostics(ctx);
58
- }, 50);
109
+ runActiveCallback("Run watcher callback", (ctx, ownerId) => {
110
+ watcher.refresh();
111
+ update(ctx, ownerId, true);
112
+ deps.onRunEvent();
113
+ reportDiagnostics(ctx);
114
+ });
115
+ }, deps.notificationDelayMs ?? 50);
59
116
  notifyTimeout.unref?.();
60
117
  };
61
- const watcher = Observability.createRunStateWatcher({
118
+ const watcher = (deps.createRunStateWatcher ?? Observability.createRunStateWatcher)({
62
119
  stateRoot: Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
63
120
  onChange: scheduleUpdate,
64
121
  });
65
- const reconciliation = Observability.createRunTerminalReconciliationLoop({
122
+ const reconciliation = (deps.createRunTerminalReconciliationLoop ??
123
+ Observability.createRunTerminalReconciliationLoop)({
66
124
  onError: (error) => {
67
- const ctx = deps.getActiveContext();
68
- if (!ctx)
125
+ if (!running || !activeContext)
69
126
  return;
70
- const message = error instanceof Error ? error.message : String(error);
71
- ctx.ui.notify(`Actor terminal reconciliation failed: ${message}`, "error");
127
+ stopAfterCallbackFailure("terminal reconciliation callback", error, activeContext);
72
128
  },
73
129
  reconcile: () => {
74
- const ctx = deps.getActiveContext();
75
- if (!ctx)
76
- return;
77
- Observability.reconcileRunTerminalNotifications({
78
- inFlight: terminalNotificationsInFlight,
79
- ownerId: deps.getRunOwnerId(ctx),
80
- sink: Pi.createNotificationSink(deps.pi, ctx),
81
- state: observation,
82
- includeAttention: true,
130
+ runActiveCallback("terminal reconciliation callback", (ctx, ownerId) => {
131
+ Observability.reconcileRunTerminalNotifications({
132
+ inFlight: terminalNotificationsInFlight,
133
+ ownerId,
134
+ sink: Pi.createNotificationSink(deps.pi, ctx),
135
+ state: observation,
136
+ includeAttention: true,
137
+ });
138
+ reportDiagnostics(ctx);
83
139
  });
84
- reportDiagnostics(ctx);
85
140
  },
86
- refreshWatcher: () => watcher.refresh(),
141
+ refreshWatcher: () => {
142
+ if (running)
143
+ watcher.refresh();
144
+ },
87
145
  });
88
- const close = () => {
89
- watcher.close();
90
- reconciliation.close();
91
- if (notifyTimeout)
92
- clearTimeout(notifyTimeout);
93
- notifyTimeout = undefined;
94
- if (animationInterval)
95
- clearInterval(animationInterval);
96
- animationInterval = undefined;
97
- };
98
146
  return {
99
147
  close,
100
- shutdown(eventReason, ctx) {
101
- close();
102
- const teardown = AsyncRuns.teardownRunsOwnedByParent(deps.getRunOwnerId(ctx), Paths.EXTENSION_RUNTIME_PATHS.runStateRoot, { trigger: `session_shutdown:${eventReason}` });
103
- if (teardown.failed === 0)
148
+ shutdown(eventReason, ownerId, ctx) {
149
+ if (!ownerId)
150
+ return;
151
+ const teardown = (deps.teardownRunsOwnedByParent ?? AsyncRuns.teardownRunsOwnedByParent)(ownerId, Paths.EXTENSION_RUNTIME_PATHS.runStateRoot, { trigger: `session_shutdown:${eventReason}` });
152
+ if (teardown.failed === 0 || !ctx)
104
153
  return;
105
154
  try {
106
155
  ctx.ui.notify(`Actor shutdown teardown: killed=${teardown.killed} failed=${teardown.failed} skipped=${teardown.skipped} discovery_failed=${teardown.discoveryFailed}. Summary: ${teardown.summaryPath ?? "unavailable"}.`, "warning");
@@ -109,17 +158,25 @@ export function createRunUiRuntime(deps) {
109
158
  /* stale shutdown context */
110
159
  }
111
160
  },
112
- start(ctx) {
161
+ start(ctx, ownerId) {
113
162
  close();
114
- Observability.primeRunAttentionState(observation, deps.getRunOwnerId(ctx));
115
- update(ctx, true, true);
116
- watcher.refresh();
117
- reconciliation.start();
118
- animationInterval = setInterval(() => {
119
- if (deps.getActiveContext() === ctx)
120
- update(ctx);
121
- }, 1000);
122
- animationInterval.unref?.();
163
+ activeContext = ctx;
164
+ activeOwnerId = ownerId;
165
+ running = true;
166
+ try {
167
+ Observability.primeRunAttentionState(observation, ownerId);
168
+ update(ctx, ownerId, true, true);
169
+ watcher.refresh();
170
+ reconciliation.start();
171
+ animationInterval = setInterval(() => {
172
+ runActiveCallback("status animation callback", (current, currentOwnerId) => update(current, currentOwnerId));
173
+ }, deps.animationIntervalMs ?? 1000);
174
+ animationInterval.unref?.();
175
+ }
176
+ catch (error) {
177
+ close();
178
+ throw error;
179
+ }
123
180
  },
124
181
  };
125
182
  }
@@ -63,7 +63,9 @@ export declare function createAutoToolsRuntime(deps: ToolRegistryRuntimeDeps): T
63
63
  export interface RecipeToolReloadWatcherDeps {
64
64
  exists?: (path: string) => boolean;
65
65
  getResolutionContext?: () => RecipeResolutionContext | undefined;
66
+ onCallbackError?: (error: unknown) => void;
66
67
  recipeRoot?: string;
68
+ reloadDelayMs?: number;
67
69
  watchPath?: typeof watch;
68
70
  }
69
71
  export declare function createRecipeToolReloadWatcher(runtime: Pick<ToolRegistryRuntime, "loadTools"> & Partial<Pick<ToolRegistryRuntime, "setWatchStatus">>, deps?: RecipeToolReloadWatcherDeps): RecipeToolReloadWatcher;
@@ -236,21 +236,41 @@ export function createRecipeToolReloadWatcher(runtime, deps = {}) {
236
236
  reloadTimeout = undefined;
237
237
  setWatchStatus("closed");
238
238
  };
239
+ const reportCallbackError = (error) => {
240
+ try {
241
+ deps.onCallbackError?.(error);
242
+ }
243
+ catch {
244
+ /* host callback containment must remain no-throw */
245
+ }
246
+ };
239
247
  const notifyFailure = (ctx) => {
240
248
  if (failureNotified)
241
249
  return;
242
250
  failureNotified = true;
243
251
  setWatchStatus("failed");
244
- ctx.ui.notify("Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.", "warning");
252
+ try {
253
+ ctx.ui.notify("Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.", "warning");
254
+ }
255
+ catch (error) {
256
+ reportCallbackError(error);
257
+ }
245
258
  };
246
259
  const scheduleReload = (ctx) => {
247
260
  failureNotified = false;
248
261
  if (reloadTimeout)
249
262
  clearTimeout(reloadTimeout);
250
263
  reloadTimeout = setTimeout(() => {
251
- runtime.loadTools(ctx, deps.getResolutionContext?.());
252
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
253
- }, 150);
264
+ reloadTimeout = undefined;
265
+ try {
266
+ runtime.loadTools(ctx, deps.getResolutionContext?.());
267
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
268
+ }
269
+ catch (error) {
270
+ notifyFailure(ctx);
271
+ reportCallbackError(error);
272
+ }
273
+ }, deps.reloadDelayMs ?? 150);
254
274
  reloadTimeout.unref?.();
255
275
  };
256
276
  const watchParent = (ctx, recipeRoot) => {
@@ -7,6 +7,10 @@ description: Use for starting, resuming, inspecting, and controlling one persist
7
7
 
8
8
  Use this Skill for one local music playback service. For generic Recipe execution, singleton Run lifecycle, persistent-tool setup, or diagnosis, follow `actors`; this Skill owns only playback-specific selection and controls.
9
9
 
10
+ ## Interaction Routing
11
+
12
+ When a Telegram-originated turn or explicit Telegram-control question makes repeated player controls relevant, prefer the maintained Telegram view described below over synthesizing one-shot prompt buttons. If `telegram_bind` is available, load and follow the active operating guidance that owns Generative Apps, then bind or invoke the capability-owned adapter; do not re-author it or move playback authority into the view. If the runtime is unavailable or binding fails, report that boundary and fall back to ordinary model-mediated controls.
13
+
10
14
  ## Playback
11
15
 
12
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.
@@ -33,10 +37,10 @@ Use only declared actions: `play`, `pause`, `resume`, `toggle`, `next`, `previou
33
37
 
34
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.
35
39
 
36
- ## Optional Telegram View
40
+ ## Maintained Telegram View
37
41
 
38
42
  > [!NOTE]
39
- > If a Generative App runtime is installed, this Skill includes a ready Music Player app at `genapps/music-player.mjs`. Use `telegram_bind` to copy and install it as app `music-player`; hot replacement keeps the same app name with `replace: true`.
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`.
40
44
 
41
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.
42
46
 
@@ -35,6 +35,8 @@ export function createActorExtensionRuntime(
35
35
  pi: Pi.ExtensionAPI,
36
36
  ): ActorExtensionRuntime {
37
37
  let activeRunContext: Pi.ExtensionContext | undefined;
38
+ let activeRunOwnerId: string | undefined;
39
+ const runOwnerIdsByContext = new WeakMap<Pi.ExtensionContext, string>();
38
40
  const recipeResolutionContextsBySession = new Map<
39
41
  string,
40
42
  RecipeResolution.RecipeResolutionContext
@@ -55,9 +57,20 @@ export function createActorExtensionRuntime(
55
57
  getRunOwnerId,
56
58
  getThinkingLevel: () => pi.getThinkingLevel(),
57
59
  });
58
- const runUiRuntime = RunUiRuntime.createRunUiRuntime({
60
+ let recipeReload: Runtime.RecipeToolReloadWatcher | undefined;
61
+ let runUiRuntime: RunUiRuntime.RunUiRuntime;
62
+ const closeActiveSessionRuntimes = (): void => {
63
+ const ownerId = activeRunOwnerId;
64
+ activeRunContext = undefined;
65
+ activeRunOwnerId = undefined;
66
+ runUiRuntime?.close();
67
+ automaticReview.close();
68
+ recipeReload?.close();
69
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
70
+ };
71
+ runUiRuntime = RunUiRuntime.createRunUiRuntime({
59
72
  getActiveContext: () => activeRunContext,
60
- getRunOwnerId,
73
+ onCallbackError: closeActiveSessionRuntimes,
61
74
  onRunEvent: automaticReview.schedule,
62
75
  pi,
63
76
  });
@@ -102,11 +115,12 @@ export function createActorExtensionRuntime(
102
115
  reservedToolNames: Tools.RESERVED_TOOL_NAMES,
103
116
  setActiveTools: (toolNames) => pi.setActiveTools(toolNames),
104
117
  });
105
- const recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
118
+ recipeReload = Runtime.createRecipeToolReloadWatcher(runtime, {
106
119
  getResolutionContext: () =>
107
120
  activeRunContext
108
121
  ? getRecipeResolutionContext(activeRunContext)
109
122
  : undefined,
123
+ onCallbackError: closeActiveSessionRuntimes,
110
124
  });
111
125
  return {
112
126
  beforeAgentStart(systemPrompt, skills, ctx) {
@@ -131,11 +145,11 @@ export function createActorExtensionRuntime(
131
145
  if (activeRunContext === ctx) automaticReview.schedule();
132
146
  },
133
147
  onSessionShutdown(reason, ctx) {
134
- recipeResolutionContextsBySession.delete(getRunOwnerId(ctx));
135
- activeRunContext = undefined;
136
- automaticReview.close();
137
- recipeReload.close();
138
- runUiRuntime.shutdown(reason, ctx);
148
+ const ownerId = runOwnerIdsByContext.get(ctx);
149
+ runOwnerIdsByContext.delete(ctx);
150
+ if (activeRunContext === ctx) closeActiveSessionRuntimes();
151
+ if (ownerId) recipeResolutionContextsBySession.delete(ownerId);
152
+ runUiRuntime.shutdown(reason, ownerId, ctx);
139
153
  },
140
154
  async onSessionStart(ctx) {
141
155
  const sessionId = getRunOwnerId(ctx);
@@ -145,13 +159,15 @@ export function createActorExtensionRuntime(
145
159
  );
146
160
  ctx.ui.setWidget("zz-pi-actors-comms", undefined);
147
161
  activeRunContext = ctx;
162
+ activeRunOwnerId = sessionId;
163
+ runOwnerIdsByContext.set(ctx, sessionId);
148
164
  runUiRuntime.close();
149
165
  automaticReview.close();
150
166
  recipeReload.close();
151
167
  await Temp.prepareExtensionTempDir(Paths.EXTENSION_RUNTIME_PATHS.tempDir);
152
- if (activeRunContext !== ctx) return;
168
+ if (activeRunContext !== ctx || activeRunOwnerId !== sessionId) return;
153
169
  automaticReview.start(ctx);
154
- runUiRuntime.start(ctx);
170
+ runUiRuntime.start(ctx, sessionId);
155
171
  recipeReload.watch(ctx);
156
172
  },
157
173
  registerCoreTools() {
@@ -418,7 +418,11 @@ export function createRunTerminalReconciliationLoop(input: {
418
418
  input.refreshWatcher();
419
419
  input.reconcile();
420
420
  } catch (error) {
421
- input.onError?.(error);
421
+ try {
422
+ input.onError?.(error);
423
+ } catch {
424
+ /* reconciliation callbacks must never escape into the host event loop */
425
+ }
422
426
  }
423
427
  };
424
428
  const close = (): void => {
@@ -11,25 +11,89 @@ import * as Pi from "./pi.ts";
11
11
 
12
12
  export interface RunUiRuntime {
13
13
  close(): void;
14
- shutdown(eventReason: string, ctx: Pi.ExtensionContext): void;
15
- start(ctx: Pi.ExtensionContext): void;
14
+ shutdown(
15
+ eventReason: string,
16
+ ownerId: string | undefined,
17
+ ctx?: Pi.ExtensionContext,
18
+ ): void;
19
+ start(ctx: Pi.ExtensionContext, ownerId: string): void;
16
20
  }
17
21
 
18
22
  export interface RunUiRuntimeDeps {
23
+ animationIntervalMs?: number;
24
+ createRunStateWatcher?: typeof Observability.createRunStateWatcher;
25
+ createRunTerminalReconciliationLoop?:
26
+ typeof Observability.createRunTerminalReconciliationLoop;
19
27
  getActiveContext(): Pi.ExtensionContext | undefined;
20
- getRunOwnerId(ctx: Pi.ExtensionContext): string;
28
+ notificationDelayMs?: number;
29
+ onCallbackError?: (error: unknown) => void;
21
30
  onRunEvent(): void;
22
31
  pi: Pi.ExtensionAPI;
32
+ teardownRunsOwnedByParent?: typeof AsyncRuns.teardownRunsOwnedByParent;
23
33
  }
24
34
 
25
35
  export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
36
+ let activeContext: Pi.ExtensionContext | undefined;
37
+ let activeOwnerId: string | undefined;
26
38
  let animationInterval: NodeJS.Timeout | undefined;
27
39
  let notifyTimeout: NodeJS.Timeout | undefined;
40
+ let running = false;
28
41
  let lastWatcherDiagnosticId = 0;
29
42
  const observation = Observability.createRunUiObservationState();
30
43
  const retirementAttempts = new Set<string>();
31
44
  const terminalNotificationsInFlight = new Set<string>();
32
45
 
46
+ const close = (): void => {
47
+ running = false;
48
+ activeContext = undefined;
49
+ activeOwnerId = undefined;
50
+ try {
51
+ watcher.close();
52
+ } catch {
53
+ /* cleanup must not escape a host callback */
54
+ }
55
+ try {
56
+ reconciliation.close();
57
+ } catch {
58
+ /* cleanup must not escape a host callback */
59
+ }
60
+ if (notifyTimeout) clearTimeout(notifyTimeout);
61
+ notifyTimeout = undefined;
62
+ if (animationInterval) clearInterval(animationInterval);
63
+ animationInterval = undefined;
64
+ };
65
+ const stopAfterCallbackFailure = (
66
+ label: string,
67
+ error: unknown,
68
+ expectedContext: Pi.ExtensionContext,
69
+ ): void => {
70
+ if (activeContext !== expectedContext) return;
71
+ close();
72
+ try {
73
+ deps.onCallbackError?.(error);
74
+ } catch {
75
+ /* host callback containment must remain no-throw */
76
+ }
77
+ const message = error instanceof Error ? error.message : String(error);
78
+ try {
79
+ expectedContext.ui.notify(`Actor ${label} failed: ${message}`, "error");
80
+ } catch {
81
+ /* stale context or unavailable UI */
82
+ }
83
+ };
84
+ const runActiveCallback = (
85
+ label: string,
86
+ callback: (ctx: Pi.ExtensionContext, ownerId: string) => void,
87
+ ): void => {
88
+ if (!running || !activeContext || !activeOwnerId) return;
89
+ const ctx = activeContext;
90
+ try {
91
+ if (deps.getActiveContext() !== ctx) return;
92
+ callback(ctx, activeOwnerId);
93
+ } catch (error) {
94
+ stopAfterCallbackFailure(label, error, ctx);
95
+ }
96
+ };
33
97
  const retireCandidateRuns = (
34
98
  ctx: Pi.ExtensionContext,
35
99
  summary: Observability.RunSummary,
@@ -39,14 +103,16 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
39
103
  cancelRun: (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
40
104
  notify: (message, level) => ctx.ui.notify(message, level),
41
105
  sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
42
- });
106
+ }).catch((error) =>
107
+ stopAfterCallbackFailure("Run retirement callback", error, ctx),
108
+ );
43
109
  };
44
110
  const update = (
45
111
  ctx: Pi.ExtensionContext,
112
+ ownerId: string,
46
113
  notify = false,
47
114
  terminalOnly = false,
48
115
  ): void => {
49
- const ownerId = deps.getRunOwnerId(ctx);
50
116
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
51
117
  ctx.ui.setStatus(
52
118
  "zz-pi-actors-runs",
@@ -76,61 +142,63 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
76
142
  }
77
143
  };
78
144
  const scheduleUpdate = (): void => {
145
+ if (!running) return;
79
146
  if (notifyTimeout) clearTimeout(notifyTimeout);
80
147
  notifyTimeout = setTimeout(() => {
81
- const ctx = deps.getActiveContext();
82
- if (!ctx) return;
83
- watcher.refresh();
84
- update(ctx, true);
85
- deps.onRunEvent();
86
- reportDiagnostics(ctx);
87
- }, 50);
148
+ runActiveCallback("Run watcher callback", (ctx, ownerId) => {
149
+ watcher.refresh();
150
+ update(ctx, ownerId, true);
151
+ deps.onRunEvent();
152
+ reportDiagnostics(ctx);
153
+ });
154
+ }, deps.notificationDelayMs ?? 50);
88
155
  notifyTimeout.unref?.();
89
156
  };
90
- const watcher = Observability.createRunStateWatcher({
157
+ const watcher = (deps.createRunStateWatcher ?? Observability.createRunStateWatcher)({
91
158
  stateRoot: Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
92
159
  onChange: scheduleUpdate,
93
160
  });
94
- const reconciliation = Observability.createRunTerminalReconciliationLoop({
161
+ const reconciliation = (
162
+ deps.createRunTerminalReconciliationLoop ??
163
+ Observability.createRunTerminalReconciliationLoop
164
+ )({
95
165
  onError: (error) => {
96
- const ctx = deps.getActiveContext();
97
- if (!ctx) return;
98
- const message = error instanceof Error ? error.message : String(error);
99
- ctx.ui.notify(`Actor terminal reconciliation failed: ${message}`, "error");
166
+ if (!running || !activeContext) return;
167
+ stopAfterCallbackFailure(
168
+ "terminal reconciliation callback",
169
+ error,
170
+ activeContext,
171
+ );
100
172
  },
101
173
  reconcile: () => {
102
- const ctx = deps.getActiveContext();
103
- if (!ctx) return;
104
- Observability.reconcileRunTerminalNotifications({
105
- inFlight: terminalNotificationsInFlight,
106
- ownerId: deps.getRunOwnerId(ctx),
107
- sink: Pi.createNotificationSink(deps.pi, ctx),
108
- state: observation,
109
- includeAttention: true,
174
+ runActiveCallback("terminal reconciliation callback", (ctx, ownerId) => {
175
+ Observability.reconcileRunTerminalNotifications({
176
+ inFlight: terminalNotificationsInFlight,
177
+ ownerId,
178
+ sink: Pi.createNotificationSink(deps.pi, ctx),
179
+ state: observation,
180
+ includeAttention: true,
181
+ });
182
+ reportDiagnostics(ctx);
110
183
  });
111
- reportDiagnostics(ctx);
112
184
  },
113
- refreshWatcher: () => watcher.refresh(),
185
+ refreshWatcher: () => {
186
+ if (running) watcher.refresh();
187
+ },
114
188
  });
115
- const close = (): void => {
116
- watcher.close();
117
- reconciliation.close();
118
- if (notifyTimeout) clearTimeout(notifyTimeout);
119
- notifyTimeout = undefined;
120
- if (animationInterval) clearInterval(animationInterval);
121
- animationInterval = undefined;
122
- };
123
189
 
124
190
  return {
125
191
  close,
126
- shutdown(eventReason, ctx) {
127
- close();
128
- const teardown = AsyncRuns.teardownRunsOwnedByParent(
129
- deps.getRunOwnerId(ctx),
192
+ shutdown(eventReason, ownerId, ctx) {
193
+ if (!ownerId) return;
194
+ const teardown = (
195
+ deps.teardownRunsOwnedByParent ?? AsyncRuns.teardownRunsOwnedByParent
196
+ )(
197
+ ownerId,
130
198
  Paths.EXTENSION_RUNTIME_PATHS.runStateRoot,
131
199
  { trigger: `session_shutdown:${eventReason}` },
132
200
  );
133
- if (teardown.failed === 0) return;
201
+ if (teardown.failed === 0 || !ctx) return;
134
202
  try {
135
203
  ctx.ui.notify(
136
204
  `Actor shutdown teardown: killed=${teardown.killed} failed=${teardown.failed} skipped=${teardown.skipped} discovery_failed=${teardown.discoveryFailed}. Summary: ${teardown.summaryPath ?? "unavailable"}.`,
@@ -140,16 +208,26 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
140
208
  /* stale shutdown context */
141
209
  }
142
210
  },
143
- start(ctx) {
211
+ start(ctx, ownerId) {
144
212
  close();
145
- Observability.primeRunAttentionState(observation, deps.getRunOwnerId(ctx));
146
- update(ctx, true, true);
147
- watcher.refresh();
148
- reconciliation.start();
149
- animationInterval = setInterval(() => {
150
- if (deps.getActiveContext() === ctx) update(ctx);
151
- }, 1000);
152
- animationInterval.unref?.();
213
+ activeContext = ctx;
214
+ activeOwnerId = ownerId;
215
+ running = true;
216
+ try {
217
+ Observability.primeRunAttentionState(observation, ownerId);
218
+ update(ctx, ownerId, true, true);
219
+ watcher.refresh();
220
+ reconciliation.start();
221
+ animationInterval = setInterval(() => {
222
+ runActiveCallback("status animation callback", (current, currentOwnerId) =>
223
+ update(current, currentOwnerId),
224
+ );
225
+ }, deps.animationIntervalMs ?? 1000);
226
+ animationInterval.unref?.();
227
+ } catch (error) {
228
+ close();
229
+ throw error;
230
+ }
153
231
  },
154
232
  };
155
233
  }
package/lib/runtime.ts CHANGED
@@ -308,7 +308,9 @@ export function createAutoToolsRuntime(
308
308
  export interface RecipeToolReloadWatcherDeps {
309
309
  exists?: (path: string) => boolean;
310
310
  getResolutionContext?: () => RecipeResolutionContext | undefined;
311
+ onCallbackError?: (error: unknown) => void;
311
312
  recipeRoot?: string;
313
+ reloadDelayMs?: number;
312
314
  watchPath?: typeof watch;
313
315
  }
314
316
 
@@ -336,22 +338,39 @@ export function createRecipeToolReloadWatcher(
336
338
  reloadTimeout = undefined;
337
339
  setWatchStatus("closed");
338
340
  };
341
+ const reportCallbackError = (error: unknown): void => {
342
+ try {
343
+ deps.onCallbackError?.(error);
344
+ } catch {
345
+ /* host callback containment must remain no-throw */
346
+ }
347
+ };
339
348
  const notifyFailure = (ctx: RuntimeContext): void => {
340
349
  if (failureNotified) return;
341
350
  failureNotified = true;
342
351
  setWatchStatus("failed");
343
- ctx.ui.notify(
344
- "Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.",
345
- "warning",
346
- );
352
+ try {
353
+ ctx.ui.notify(
354
+ "Recipe live reload watcher failed; restart the session or use register_tool again to refresh recipe tools.",
355
+ "warning",
356
+ );
357
+ } catch (error) {
358
+ reportCallbackError(error);
359
+ }
347
360
  };
348
361
  const scheduleReload = (ctx: RuntimeContext): void => {
349
362
  failureNotified = false;
350
363
  if (reloadTimeout) clearTimeout(reloadTimeout);
351
364
  reloadTimeout = setTimeout(() => {
352
- runtime.loadTools(ctx, deps.getResolutionContext?.());
353
- ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
354
- }, 150);
365
+ reloadTimeout = undefined;
366
+ try {
367
+ runtime.loadTools(ctx, deps.getResolutionContext?.());
368
+ ctx.ui.notify("Recipe tools refreshed from ~/.pi/agent/recipes", "info");
369
+ } catch (error) {
370
+ notifyFailure(ctx);
371
+ reportCallbackError(error);
372
+ }
373
+ }, deps.reloadDelayMs ?? 150);
355
374
  reloadTimeout.unref?.();
356
375
  };
357
376
  const watchParent = (ctx: RuntimeContext, recipeRoot: string): void => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.49.0",
3
+ "version": "0.49.2",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -7,6 +7,10 @@ description: Use for starting, resuming, inspecting, and controlling one persist
7
7
 
8
8
  Use this Skill for one local music playback service. For generic Recipe execution, singleton Run lifecycle, persistent-tool setup, or diagnosis, follow `actors`; this Skill owns only playback-specific selection and controls.
9
9
 
10
+ ## Interaction Routing
11
+
12
+ When a Telegram-originated turn or explicit Telegram-control question makes repeated player controls relevant, prefer the maintained Telegram view described below over synthesizing one-shot prompt buttons. If `telegram_bind` is available, load and follow the active operating guidance that owns Generative Apps, then bind or invoke the capability-owned adapter; do not re-author it or move playback authority into the view. If the runtime is unavailable or binding fails, report that boundary and fall back to ordinary model-mediated controls.
13
+
10
14
  ## Playback
11
15
 
12
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.
@@ -33,10 +37,10 @@ Use only declared actions: `play`, `pause`, `resume`, `toggle`, `next`, `previou
33
37
 
34
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.
35
39
 
36
- ## Optional Telegram View
40
+ ## Maintained Telegram View
37
41
 
38
42
  > [!NOTE]
39
- > If a Generative App runtime is installed, this Skill includes a ready Music Player app at `genapps/music-player.mjs`. Use `telegram_bind` to copy and install it as app `music-player`; hot replacement keeps the same app name with `replace: true`.
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`.
40
44
 
41
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.
42
46