@llblab/pi-kit 0.1.5 → 0.1.7

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 (60) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  4. package/node_modules/@llblab/pi-actors/CHANGELOG.md +14 -1
  5. package/node_modules/@llblab/pi-actors/README.md +19 -3
  6. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +29 -10
  7. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.d.ts +3 -0
  8. package/node_modules/@llblab/pi-actors/dist/lib/inspector-overlay.js +120 -38
  9. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +1 -0
  10. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +1 -0
  11. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +6 -1
  12. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +10 -3
  13. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +109 -52
  14. package/node_modules/@llblab/pi-actors/dist/lib/runs-artifacts.js +24 -4
  15. package/node_modules/@llblab/pi-actors/dist/lib/runtime.d.ts +2 -0
  16. package/node_modules/@llblab/pi-actors/dist/lib/runtime.js +24 -4
  17. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +1 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +3 -2
  19. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.d.ts +5 -1
  20. package/node_modules/@llblab/pi-actors/dist/lib/state-readers.js +30 -3
  21. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.d.ts +1 -0
  22. package/node_modules/@llblab/pi-actors/dist/lib/tools-access.js +13 -11
  23. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +5 -5
  24. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.d.ts +1 -0
  25. package/node_modules/@llblab/pi-actors/dist/lib/tools-message.js +3 -1
  26. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  27. package/node_modules/@llblab/pi-actors/docs/actor-inspector.md +21 -5
  28. package/node_modules/@llblab/pi-actors/docs/async-runs.md +16 -4
  29. package/node_modules/@llblab/pi-actors/docs/command-templates.md +5 -4
  30. package/node_modules/@llblab/pi-actors/docs/inspection.md +83 -0
  31. package/node_modules/@llblab/pi-actors/docs/recipe-library.md +1 -1
  32. package/node_modules/@llblab/pi-actors/docs/template-recipes.md +225 -66
  33. package/node_modules/@llblab/pi-actors/docs/tool-registry.md +24 -3
  34. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +26 -10
  35. package/node_modules/@llblab/pi-actors/lib/inspector-overlay.ts +161 -33
  36. package/node_modules/@llblab/pi-actors/lib/limits.ts +1 -0
  37. package/node_modules/@llblab/pi-actors/lib/observability.ts +5 -1
  38. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +128 -50
  39. package/node_modules/@llblab/pi-actors/lib/runs-artifacts.ts +34 -4
  40. package/node_modules/@llblab/pi-actors/lib/runtime.ts +26 -7
  41. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +7 -2
  42. package/node_modules/@llblab/pi-actors/lib/state-readers.ts +45 -3
  43. package/node_modules/@llblab/pi-actors/lib/tools-access.ts +26 -12
  44. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +9 -5
  45. package/node_modules/@llblab/pi-actors/lib/tools-message.ts +8 -1
  46. package/node_modules/@llblab/pi-actors/package.json +1 -1
  47. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  48. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +17 -0
  49. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  50. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  51. package/node_modules/@llblab/pi-telegram/index.ts +25 -0
  52. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +11 -0
  53. package/node_modules/@llblab/pi-telegram/lib/journal.ts +192 -9
  54. package/node_modules/@llblab/pi-telegram/lib/queue.ts +14 -0
  55. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +52 -2
  56. package/node_modules/@llblab/pi-telegram/lib/status.ts +3 -3
  57. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +5 -1
  58. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  59. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +14 -6
  60. package/package.json +4 -4
@@ -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
  }
@@ -4,7 +4,12 @@
4
4
  */
5
5
 
6
6
  import { createHash } from "node:crypto";
7
- import { readFileSync } from "node:fs";
7
+ import {
8
+ closeSync,
9
+ fstatSync,
10
+ openSync,
11
+ readSync,
12
+ } from "node:fs";
8
13
 
9
14
  import { substituteCommandTemplateToken } from "./command-templates.ts";
10
15
 
@@ -22,6 +27,31 @@ export interface RunArtifactManifestEntry {
22
27
  size?: number;
23
28
  }
24
29
 
30
+ function hashArtifactFile(path: string): { sha256: string; size: number } {
31
+ const fd = openSync(path, "r");
32
+ try {
33
+ const size = fstatSync(fd).size;
34
+ const hash = createHash("sha256");
35
+ const chunk = Buffer.allocUnsafe(64 * 1024);
36
+ let position = 0;
37
+ while (position < size) {
38
+ const bytesRead = readSync(
39
+ fd,
40
+ chunk,
41
+ 0,
42
+ Math.min(chunk.byteLength, size - position),
43
+ position,
44
+ );
45
+ if (bytesRead === 0) break;
46
+ hash.update(chunk.subarray(0, bytesRead));
47
+ position += bytesRead;
48
+ }
49
+ return { sha256: hash.digest("hex"), size: position };
50
+ } finally {
51
+ closeSync(fd);
52
+ }
53
+ }
54
+
25
55
  export function resolveArtifactPaths(
26
56
  artifacts: Record<string, RunArtifactDeclaration> | undefined,
27
57
  values: Record<string, unknown>,
@@ -64,7 +94,7 @@ export function resolveArtifactManifest(
64
94
  typeof artifact === "string" ? { path: artifact } : artifact;
65
95
  if (!declaration?.path) continue;
66
96
  try {
67
- const content = readFileSync(declaration.path);
97
+ const hashed = hashArtifactFile(declaration.path);
68
98
  manifest[name] = {
69
99
  exists: true,
70
100
  ...(declaration.kind ? { kind: declaration.kind } : {}),
@@ -75,8 +105,8 @@ export function resolveArtifactManifest(
75
105
  ...(declaration.required !== undefined
76
106
  ? { required: declaration.required }
77
107
  : {}),
78
- sha256: createHash("sha256").update(content).digest("hex"),
79
- size: content.byteLength,
108
+ sha256: hashed.sha256,
109
+ size: hashed.size,
80
110
  };
81
111
  } catch {
82
112
  manifest[name] = {
@@ -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 => {
@@ -55,6 +55,7 @@ export interface SessionEvidence {
55
55
  }
56
56
 
57
57
  export interface SessionEvidenceReadOptions {
58
+ maxBytes?: number;
58
59
  maxTextChars?: number;
59
60
  maxToolCalls?: number;
60
61
  maxTurns?: number;
@@ -195,6 +196,10 @@ export function readSessionEvidence(
195
196
  path: string,
196
197
  options: SessionEvidenceReadOptions = {},
197
198
  ): SessionEvidence {
199
+ const maxBytes = Math.max(
200
+ 1,
201
+ options.maxBytes ?? Limits.SESSION_EVIDENCE_MAX_BYTES,
202
+ );
198
203
  const maxTextChars = Math.max(
199
204
  1,
200
205
  options.maxTextChars ?? Limits.SESSION_EVIDENCE_TEXT_CHARS,
@@ -207,7 +212,7 @@ export function readSessionEvidence(
207
212
  1,
208
213
  options.maxTurns ?? Limits.SESSION_EVIDENCE_MAX_TURNS,
209
214
  );
210
- const read = readJsonlFileResilient<SessionEntry>(path);
215
+ const read = readJsonlFileResilient<SessionEntry>(path, { maxBytes });
211
216
  const diagnostics = [...read.diagnostics];
212
217
  const header = read.records.find((entry) => entry.type === "session");
213
218
  const branch = activeBranch(read.records, path, diagnostics);
@@ -296,7 +301,7 @@ export function readSessionEvidence(
296
301
  path,
297
302
  ...(header ? { session: asRecord(header) } : {}),
298
303
  totalTurns: turns.length,
299
- truncated: turns.length > visibleTurns.length,
304
+ truncated: read.truncated === true || turns.length > visibleTurns.length,
300
305
  turns: visibleTurns,
301
306
  };
302
307
  }
@@ -4,7 +4,13 @@
4
4
  * Owns best-effort JSON and JSONL parsing helpers for operator-facing state reads.
5
5
  */
6
6
 
7
- import { readFileSync } from "node:fs";
7
+ import {
8
+ closeSync,
9
+ fstatSync,
10
+ openSync,
11
+ readFileSync,
12
+ readSync,
13
+ } from "node:fs";
8
14
 
9
15
  export interface StateReadDiagnostic {
10
16
  line?: number;
@@ -20,6 +26,11 @@ export interface JsonReadResult<T> {
20
26
  export interface JsonlReadResult<T> {
21
27
  diagnostics: StateReadDiagnostic[];
22
28
  records: T[];
29
+ truncated?: boolean;
30
+ }
31
+
32
+ export interface JsonlReadOptions {
33
+ maxBytes?: number;
23
34
  }
24
35
 
25
36
  function isEnoent(error: unknown): boolean {
@@ -30,6 +41,31 @@ function diagnosticMessage(error: unknown): string {
30
41
  return error instanceof Error ? error.message : String(error);
31
42
  }
32
43
 
44
+ function readTextFileCapped(path: string, maxBytes: number | undefined): string {
45
+ if (maxBytes === undefined) return readFileSync(path, "utf8");
46
+ const limit = Math.max(0, Math.floor(maxBytes));
47
+ const fd = openSync(path, "r");
48
+ try {
49
+ const size = fstatSync(fd).size;
50
+ if (size > limit) {
51
+ throw Object.assign(
52
+ new Error(`file exceeds bounded read limit (${size} > ${limit} bytes)`),
53
+ { code: "EFBIG" },
54
+ );
55
+ }
56
+ const content = Buffer.allocUnsafe(size);
57
+ let offset = 0;
58
+ while (offset < size) {
59
+ const bytesRead = readSync(fd, content, offset, size - offset, offset);
60
+ if (bytesRead === 0) break;
61
+ offset += bytesRead;
62
+ }
63
+ return content.subarray(0, offset).toString("utf8");
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
33
69
  export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadResult<T> {
34
70
  try {
35
71
  return {
@@ -50,11 +86,14 @@ export function readJsonFileResilient<T>(path: string, fallback: T): JsonReadRes
50
86
  }
51
87
  }
52
88
 
53
- export function readJsonlFileResilient<T>(path: string): JsonlReadResult<T> {
89
+ export function readJsonlFileResilient<T>(
90
+ path: string,
91
+ options: JsonlReadOptions = {},
92
+ ): JsonlReadResult<T> {
54
93
  try {
55
94
  const records: T[] = [];
56
95
  const diagnostics: StateReadDiagnostic[] = [];
57
- const lines = readFileSync(path, "utf8").split("\n");
96
+ const lines = readTextFileCapped(path, options.maxBytes).split("\n");
58
97
  for (const [index, line] of lines.entries()) {
59
98
  if (!line.trim()) continue;
60
99
  try {
@@ -78,6 +117,9 @@ export function readJsonlFileResilient<T>(path: string): JsonlReadResult<T> {
78
117
  },
79
118
  ],
80
119
  records: [],
120
+ ...((error as NodeJS.ErrnoException).code === "EFBIG"
121
+ ? { truncated: true }
122
+ : {}),
81
123
  };
82
124
  }
83
125
  }
@@ -17,8 +17,11 @@ export function getContextSessionId(ctx: unknown): string | undefined {
17
17
  export function requireContextSessionId(ctx: unknown, actor: string): string {
18
18
  const sessionId = getContextSessionId(ctx);
19
19
  if (!sessionId) {
20
- throw new Error(
21
- `${actor} requires a current coordinator session; use session:<id> or session:all for explicit session inventory.`,
20
+ throw Object.assign(
21
+ new Error(
22
+ `${actor} reason=session_unavailable requires a current coordinator session; retry from an active coordinator session.`,
23
+ ),
24
+ { reason: "session_unavailable" },
22
25
  );
23
26
  }
24
27
  return sessionId;
@@ -33,16 +36,13 @@ export function sessionMismatchError(input: {
33
36
  const ownerSession = input.expectedSession ?? "none";
34
37
  const currentSession = input.currentSession ?? "none";
35
38
  const actor = input.run ? `run:${input.run}` : (input.target ?? "session");
36
- const hintTarget = input.expectedSession
37
- ? `session:${input.expectedSession}`
38
- : "session:all";
39
39
  return Object.assign(
40
40
  new Error(
41
- `${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=inspect_session:${input.expectedSession ?? "all"}`,
41
+ `${actor} reason=session_mismatch owner_session=${ownerSession} current_session=${currentSession} hint=inspect_runtime_runs`,
42
42
  ),
43
43
  {
44
44
  current_session: input.currentSession,
45
- hint: `inspect target=${hintTarget} view=status`,
45
+ hint: "inspect target=runtime view=runs",
46
46
  owner_session: input.expectedSession,
47
47
  reason: "session_mismatch",
48
48
  run: input.run,
@@ -51,18 +51,32 @@ export function sessionMismatchError(input: {
51
51
  );
52
52
  }
53
53
 
54
- export function assertRunAccessibleToContext(
54
+ export function assertRunStatusAccessibleToContext(
55
55
  runId: string,
56
+ status: Record<string, unknown>,
56
57
  ctx: unknown,
57
58
  ): Record<string, unknown> {
58
- const status = AsyncRuns.getRunStatus(runId);
59
- const sessionId = getContextSessionId(ctx);
60
- if (sessionId && status.ownerId && status.ownerId !== sessionId) {
59
+ const sessionId = requireContextSessionId(ctx, `run:${runId}`);
60
+ const ownerId = typeof status.ownerId === "string" && status.ownerId
61
+ ? status.ownerId
62
+ : undefined;
63
+ if (ownerId !== sessionId) {
61
64
  throw sessionMismatchError({
62
65
  currentSession: sessionId,
63
- expectedSession: String(status.ownerId),
66
+ expectedSession: ownerId,
64
67
  run: runId,
65
68
  });
66
69
  }
67
70
  return status;
68
71
  }
72
+
73
+ export function assertRunAccessibleToContext(
74
+ runId: string,
75
+ ctx: unknown,
76
+ ): Record<string, unknown> {
77
+ return assertRunStatusAccessibleToContext(
78
+ runId,
79
+ AsyncRuns.getRunStatus(runId),
80
+ ctx,
81
+ );
82
+ }
@@ -42,7 +42,7 @@ export interface InspectToolDeps<TContext = unknown> {
42
42
 
43
43
  function runtimeStatus(): Record<string, unknown> {
44
44
  return {
45
- automatic_review: process.env.PI_ACTORS_AUTOMATIC_REVIEW !== "off",
45
+ automatic_review: Paths.isAutomaticRecipeReviewEnabled(),
46
46
  run_root: Paths.getRunStateRoot(),
47
47
  state_schema: RuntimeIdentity.RUN_STATE_SCHEMA,
48
48
  version: RuntimeIdentity.getPackageVersion(),
@@ -54,7 +54,7 @@ function runtimeRuns(
54
54
  deps: InspectToolDeps,
55
55
  status: string | undefined,
56
56
  ): Record<string, unknown> {
57
- const session = ToolsAccess.getContextSessionId(ctx);
57
+ const session = ToolsAccess.requireContextSessionId(ctx, "runtime Run inventory");
58
58
  const listed = deps.listRuns
59
59
  ? deps.listRuns()
60
60
  : AsyncRuns.listRuns(undefined, status);
@@ -68,8 +68,8 @@ function runtimeRuns(
68
68
  return run;
69
69
  }
70
70
  })
71
- .filter((run) => !session || !run.ownerId || run.ownerId === session);
72
- return { ...(session ? { owner_session: session } : {}), runs };
71
+ .filter((run) => run.ownerId === session);
72
+ return { owner_session: session, runs };
73
73
  }
74
74
 
75
75
  function runtimeTriage(
@@ -463,7 +463,11 @@ function inspectRun(
463
463
  throw new Error("inspect run:<id> supports view=recipe, view=trace, or view=control.");
464
464
  }
465
465
  const status = deps.getRunStatus
466
- ? deps.getRunStatus(run)
466
+ ? ToolsAccess.assertRunStatusAccessibleToContext(
467
+ run,
468
+ deps.getRunStatus(run),
469
+ ctx,
470
+ )
467
471
  : ToolsAccess.assertRunAccessibleToContext(run, ctx);
468
472
  const stateDir = String(status.state_dir);
469
473
  if (view === "recipe") {
@@ -46,6 +46,7 @@ function runNextActions(run: string, result: Record<string, unknown>): string[]
46
46
  }
47
47
 
48
48
  export interface ControlToolDeps {
49
+ getRunStatus?: (run: string) => Record<string, unknown>;
49
50
  handleRuntimeControl?: (
50
51
  action: string,
51
52
  input: unknown,
@@ -94,7 +95,13 @@ export function createControlToolDefinition<TContext = unknown>(
94
95
  result = deps.handleRuntimeControl(request.action, request.input);
95
96
  } else {
96
97
  const run = request.target.slice(4);
97
- const status = ToolsAccess.assertRunAccessibleToContext(run, ctx);
98
+ const status = deps.getRunStatus
99
+ ? ToolsAccess.assertRunStatusAccessibleToContext(
100
+ run,
101
+ deps.getRunStatus(run),
102
+ ctx,
103
+ )
104
+ : ToolsAccess.assertRunAccessibleToContext(run, ctx);
98
105
  const runInstanceId =
99
106
  typeof status.run_instance_id === "string"
100
107
  ? status.run_instance_id
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-actors",
3
- "version": "0.49.1",
3
+ "version": "0.50.0",
4
4
  "private": false,
5
5
  "description": "Local Actor Kernel for Pi",
6
6
  "keywords": [
@@ -81,7 +81,7 @@ Use the relevant local skill before non-trivial work in its domain. Keep skill o
81
81
  - A forwarding delivery id is stable across registration replacement and derives from envelope kind, source `update_id`, and stable recipient binding. Runtime instance and registration generation remain separate attempt fences. Persisted message ownership carries the stable binding so replay can rebind only to its current authenticated registration.
82
82
  - A queued receipt persists its acquiring runtime instance, OS pid/process-birth identity, session generation, acquisition id, and acquisition time. Only exact authority may settle or discard it. Same-process session replacement may reconstruct the claim and the original process may settle after transport ownership moves; a foreign process may neither replay nor settle it through generic removal or a copied acquisition id.
83
83
  - Startup and elapsed time are not owner-death proof; queued authority has no time lease. Dead-owner recovery groups the complete receipt and transactionally rechecks pid liveness plus process-birth identity: only an absent PID or mismatched stable Linux/macOS birth proof returns all sources to `pending`; a matching proof is `alive`, while Windows or inaccessible birth metadata is `unverifiable`, and both non-dead outcomes keep authority queued. The live-transfer contract is authenticated offer → exact-generation bounded payload staging → recipient CAS acceptance → exact receipt-and-owner ACK → donor removal → recipient readiness. The offer freezes donor settlement/recovery; controls rebuild local closures; negative/mismatched pre-acceptance ACK cancels only an unaccepted offer and retains donor work; a lost post-acceptance ACK cannot cancel recipient authority and leaves donor memory frozen for explicit reconciliation.
84
- - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity.
84
+ - Execution failures persist bounded diagnostics and attempt state as `retry-wait`. Automatic retry continues indefinitely with exponential `1s → 2s → 4s → 8s → 16s → 32s → 60s` delay capped at 60 seconds; later independent updates continue draining, durable authority is never silently discarded, and legacy `failed` entries resume automatically at startup. Snapshot-plus-segment journals compact only after 256 unapplied revisions or 4 MiB; snapshot-first cleanup tolerates redundant segments, and empty authority may atomically rebind bot/profile identity. Missing snapshots left by the retired broad temp cleanup rebuild only from a complete provably empty segment chain, while revisionless snapshots may recover from a validated later segment predecessor; otherwise the snapshot and segments move atomically under `tmp/telegram/recovery/` before a fresh journal is published and startup continues with informational recovery evidence.
85
85
  - An unresolved reaction delays only the exact governed queue item identified by chat/message sources, not unrelated queue work. Queue receipt publication follows in-memory append and precedes dispatch request; receipt-bearing turns remain queued until every exact source commits.
86
86
  - The detailed implementation and release gates live in [`docs/architecture.md`](./docs/architecture.md), [`docs/multi-instance-bus.md`](./docs/multi-instance-bus.md), and [`BACKLOG.md`](./BACKLOG.md).
87
87
 
@@ -2,6 +2,23 @@
2
2
 
3
3
  > Each release keeps at most 8 outcome records of at most 512 characters.
4
4
 
5
+ ## 0.36.11: At-Most-Once Prompt Dispatch Hotfix
6
+
7
+ - `At-Most-Once Prompt Dispatch`: Commits each exact durable Telegram receipt synchronously before Pi model admission and blocks dispatch when that commit fails, closing the session/process-replacement replay window that could deliver an already-processed old prompt again; the narrow commit-before-admission crash boundary now favors no duplicate over retry.
8
+
9
+ ## 0.36.10: Transport-Fenced Typing Hotfix
10
+
11
+ - `Typing Authority Fence`: Starts and continues Telegram typing activity only while the Pi instance has direct ownership or a live follower registration, stopping quietly when authority disappears so classic takeover cannot re-arm a stale loop or flood diagnostics with expected follower-registration errors.
12
+
13
+ ## 0.36.9: Telegram Status And Generated Controls Hotfix
14
+
15
+ - `Classic Takeover Status`: Keeps a classic-mode client visibly `disconnected` after another Pi instance takes Telegram ownership, preventing stale transport-side activity errors from overriding the authoritative connection state.
16
+ - `Generated Button Surfaces`: Makes vertical full-width controls the phone-readable default, earns multi-column rows only for compact labels or emoji-only spatial controls, encourages concise semantic labels, and requires safe 2–6 button controls whenever a Telegram reply asks a bounded blocking confirmation or choice instead of leaving an avoidable prose-only feedback step.
17
+
18
+ ## 0.36.8: Durable Journal Recovery
19
+
20
+ - `Durable Journal Recovery`: Restricts age cleanup to UUID-prefixed downloads, repairs missing or revisionless snapshots from validated segment evidence, and otherwise quarantines the snapshot plus segments before publishing a fresh journal, keeping `/telegram-connect` operational with informational recovery evidence instead of manual JSON repair.
21
+
5
22
  ## 0.36.7: Context-Aware Proactive Controls
6
23
 
7
24
  - `Proactive Prompt Buttons`: Plans valid top-level `telegram_button` comments before local/autonomous assistant segments enter Rich or HTML proactive delivery, preserving the ordinary callback store and renderer path instead of silently hiding correct controls.