@llblab/pi-kit 0.1.13 → 0.3.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 (86) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-actors/AGENTS.md +5 -3
  5. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  6. package/node_modules/@llblab/pi-actors/CHANGELOG.md +15 -0
  7. package/node_modules/@llblab/pi-actors/README.md +5 -3
  8. package/node_modules/@llblab/pi-actors/dist/index.js +4 -1
  9. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +2 -2
  10. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +42 -19
  11. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +2 -1
  12. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +7 -2
  13. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +9 -0
  14. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +9 -0
  15. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +10 -11
  16. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +81 -56
  17. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +31 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +180 -0
  19. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +115 -0
  20. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +623 -0
  21. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +3 -0
  22. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +341 -13
  23. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.d.ts +1 -1
  24. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.js +5 -3
  25. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +16 -0
  26. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +143 -0
  27. package/node_modules/@llblab/pi-actors/dist/lib/temp.js +1 -1
  28. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +3 -1
  29. package/node_modules/@llblab/pi-actors/dist/scripts/async-runner.mjs +5 -19
  30. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +2 -2
  31. package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +1 -1
  32. package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +1 -1
  33. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  34. package/node_modules/@llblab/pi-actors/docs/async-runs.md +8 -4
  35. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +207 -0
  36. package/node_modules/@llblab/pi-actors/index.ts +4 -1
  37. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +42 -21
  38. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +8 -3
  39. package/node_modules/@llblab/pi-actors/lib/limits.ts +9 -0
  40. package/node_modules/@llblab/pi-actors/lib/observability.ts +97 -78
  41. package/node_modules/@llblab/pi-actors/lib/pi.ts +210 -0
  42. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +800 -0
  43. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +370 -18
  44. package/node_modules/@llblab/pi-actors/lib/runs-trace.ts +6 -4
  45. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +153 -0
  46. package/node_modules/@llblab/pi-actors/lib/temp.ts +1 -1
  47. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +4 -1
  48. package/node_modules/@llblab/pi-actors/package.json +3 -3
  49. package/node_modules/@llblab/pi-actors/scripts/async-runner.mjs +5 -19
  50. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +2 -2
  51. package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +1 -1
  52. package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +1 -1
  53. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  54. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +25 -0
  55. package/node_modules/@llblab/pi-telegram/README.md +4 -4
  56. package/node_modules/@llblab/pi-telegram/docs/activity.md +4 -4
  57. package/node_modules/@llblab/pi-telegram/docs/architecture.md +10 -10
  58. package/node_modules/@llblab/pi-telegram/docs/compact-matrix-literal.md +16 -14
  59. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  60. package/node_modules/@llblab/pi-telegram/docs/outbound.md +13 -22
  61. package/node_modules/@llblab/pi-telegram/docs/public-api.md +6 -7
  62. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -5
  63. package/node_modules/@llblab/pi-telegram/docs/voice.md +12 -2
  64. package/node_modules/@llblab/pi-telegram/index.ts +0 -1
  65. package/node_modules/@llblab/pi-telegram/lib/activity.ts +10 -5
  66. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +16 -17
  67. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +1 -1
  68. package/node_modules/@llblab/pi-telegram/lib/commands.ts +16 -6
  69. package/node_modules/@llblab/pi-telegram/lib/config.ts +25 -25
  70. package/node_modules/@llblab/pi-telegram/lib/media.ts +12 -2
  71. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +10 -73
  72. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +9 -8
  73. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +212 -82
  74. package/node_modules/@llblab/pi-telegram/lib/routing.ts +78 -36
  75. package/node_modules/@llblab/pi-telegram/lib/status.ts +23 -4
  76. package/node_modules/@llblab/pi-telegram/lib/turns.ts +10 -1
  77. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1 -1
  78. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  79. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +61 -205
  80. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/capability-adapters.md +27 -0
  81. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/references/layout-and-state.md +35 -0
  82. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +70 -110
  83. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/configuration.md +15 -0
  84. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/delivery-and-threads.md +27 -0
  85. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +14 -0
  86. package/package.json +3 -3
@@ -5,12 +5,16 @@
5
5
  */
6
6
 
7
7
  import * as AsyncRuns from "./async-runs.ts";
8
+ import * as Limits from "./limits.ts";
8
9
  import * as Observability from "./observability.ts";
9
10
  import * as Paths from "./paths.ts";
10
11
  import * as Pi from "./pi.ts";
12
+ import * as RunDelivery from "./run-delivery.ts";
11
13
 
12
14
  export interface RunUiRuntime {
13
15
  close(): void;
16
+ flushCompletionBatch(ctx: Pi.ExtensionContext): boolean;
17
+ projectContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
14
18
  shutdown(
15
19
  eventReason: string,
16
20
  ownerId: string | undefined,
@@ -24,6 +28,7 @@ export interface RunUiRuntimeDeps {
24
28
  createRunStateWatcher?: typeof Observability.createRunStateWatcher;
25
29
  createRunTerminalReconciliationLoop?:
26
30
  typeof Observability.createRunTerminalReconciliationLoop;
31
+ deliveryDebounceMs?: number;
27
32
  getActiveContext(): Pi.ExtensionContext | undefined;
28
33
  notificationDelayMs?: number;
29
34
  onCallbackError?: (error: unknown) => void;
@@ -36,12 +41,16 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
36
41
  let activeContext: Pi.ExtensionContext | undefined;
37
42
  let activeOwnerId: string | undefined;
38
43
  let animationInterval: NodeJS.Timeout | undefined;
44
+ let deliveryTimeout: NodeJS.Timeout | undefined;
39
45
  let notifyTimeout: NodeJS.Timeout | undefined;
46
+ let recoverQueuedBatch = false;
47
+ let recoverQueuedSteers = false;
40
48
  let running = false;
41
49
  let lastWatcherDiagnosticId = 0;
42
50
  const observation = Observability.createRunUiObservationState();
51
+ const deliveryRecoveryDiagnostics = new Set<string>();
43
52
  const retirementAttempts = new Set<string>();
44
- const terminalNotificationsInFlight = new Set<string>();
53
+ const steerDiagnostics = new Set<string>();
45
54
 
46
55
  const close = (): void => {
47
56
  running = false;
@@ -59,6 +68,12 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
59
68
  }
60
69
  if (notifyTimeout) clearTimeout(notifyTimeout);
61
70
  notifyTimeout = undefined;
71
+ if (deliveryTimeout) clearTimeout(deliveryTimeout);
72
+ deliveryTimeout = undefined;
73
+ recoverQueuedBatch = false;
74
+ recoverQueuedSteers = false;
75
+ deliveryRecoveryDiagnostics.clear();
76
+ steerDiagnostics.clear();
62
77
  if (animationInterval) clearInterval(animationInterval);
63
78
  animationInterval = undefined;
64
79
  };
@@ -107,29 +122,235 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
107
122
  stopAfterCallbackFailure("Run retirement callback", error, ctx),
108
123
  );
109
124
  };
125
+ const journal = (ownerId: string): RunDelivery.RunDeliveryJournal =>
126
+ RunDelivery.readRunDeliveryJournal(
127
+ Paths.EXTENSION_RUNTIME_PATHS.tempDir,
128
+ ownerId,
129
+ );
130
+ const finishPresentedBatch = (
131
+ ownerId: string,
132
+ batch: RunDelivery.RunCompletionBatch,
133
+ ): boolean => {
134
+ if (batch.phase !== "presented") return false;
135
+ for (const member of batch.members) {
136
+ AsyncRuns.markRunTerminalNotificationHandled(
137
+ member.state_dir,
138
+ member.status,
139
+ member.run_instance_id,
140
+ );
141
+ }
142
+ return RunDelivery.finalizeRunCompletionBatch({
143
+ batchId: batch.batch_id,
144
+ ownerId,
145
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
146
+ });
147
+ };
148
+ const recoverPresentedBatch = (ownerId: string): void => {
149
+ const batch = journal(ownerId).completion_batch;
150
+ if (batch?.phase === "presented") finishPresentedBatch(ownerId, batch);
151
+ };
152
+ const finishPresentedSteer = (
153
+ ownerId: string,
154
+ steer: RunDelivery.RunSteerEnvelope,
155
+ ): boolean => {
156
+ if (steer.phase !== "presented") return false;
157
+ AsyncRuns.markRunSteerPresentationHandled(
158
+ steer.state_dir,
159
+ steer.run_instance_id,
160
+ steer.event_id,
161
+ steer.steer_id,
162
+ );
163
+ return RunDelivery.finalizeRunSteer({
164
+ ownerId,
165
+ steerId: steer.steer_id,
166
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
167
+ });
168
+ };
169
+ const recoverPresentedSteers = (ownerId: string): void => {
170
+ for (const steer of journal(ownerId).steers) {
171
+ if (steer.phase === "presented") finishPresentedSteer(ownerId, steer);
172
+ }
173
+ };
174
+ const boundedSteerContent = (event: Observability.RunAttentionEvent): string => {
175
+ const text = Observability.formatRunAttentionMessage(event);
176
+ if (Buffer.byteLength(text, "utf8") <= Limits.RUN_DELIVERY_STEER_MAX_BYTES) {
177
+ return text;
178
+ }
179
+ let end = Math.min(text.length, Limits.RUN_DELIVERY_STEER_MAX_BYTES - 3);
180
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") >
181
+ Limits.RUN_DELIVERY_STEER_MAX_BYTES - 3) end -= 1;
182
+ return `${text.slice(0, end)}…`;
183
+ };
184
+ const admitSteerEvents = (
185
+ ctx: Pi.ExtensionContext,
186
+ ownerId: string,
187
+ events: Observability.RunAttentionEvent[],
188
+ ): void => {
189
+ for (const event of events.filter(Observability.isRunSteerAttentionEvent)) {
190
+ if (!event.runInstanceId) {
191
+ Observability.retryRunAttentionEvent(observation, event);
192
+ const key = `${event.stateDir}:${event.id}:missing_generation`;
193
+ if (!steerDiagnostics.has(key)) {
194
+ steerDiagnostics.add(key);
195
+ ctx.ui.notify(
196
+ `Actor urgent steer ${event.id} remains retryable because its Run generation is unavailable.`,
197
+ "warning",
198
+ );
199
+ }
200
+ continue;
201
+ }
202
+ try {
203
+ RunDelivery.admitRunSteerEnvelope({
204
+ content: boundedSteerContent(event),
205
+ eventId: event.id,
206
+ kind: event.kind,
207
+ level: event.level,
208
+ occurredAt: event.ts,
209
+ ownerId,
210
+ run: event.run,
211
+ runInstanceId: event.runInstanceId,
212
+ stateDir: event.stateDir,
213
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
214
+ });
215
+ } catch (error) {
216
+ Observability.retryRunAttentionEvent(observation, event);
217
+ const message = error instanceof Error ? error.message : String(error);
218
+ const key = `${event.stateDir}:${event.id}:${message}`;
219
+ if (!steerDiagnostics.has(key)) {
220
+ steerDiagnostics.add(key);
221
+ ctx.ui.notify(
222
+ `Actor urgent steer ${event.id} remains retryable: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`,
223
+ "warning",
224
+ );
225
+ }
226
+ }
227
+ }
228
+ };
229
+ const admitCompletionTransitions = (
230
+ ownerId: string,
231
+ transitions: Observability.RunTransition[],
232
+ ): boolean => {
233
+ const existing = journal(ownerId).completion_batch;
234
+ if (existing) return true;
235
+ const members = Observability.collectRunCompletionBatchMembers(transitions)
236
+ .slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
237
+ if (members.length === 0) return false;
238
+ RunDelivery.admitRunCompletionBatch({
239
+ members,
240
+ ownerId,
241
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
242
+ });
243
+ return true;
244
+ };
245
+ const isIdle = (ctx: Pi.ExtensionContext): boolean =>
246
+ typeof ctx.isIdle !== "function" || ctx.isIdle();
247
+ let flushCompletionBatch = (_ctx: Pi.ExtensionContext): boolean => false;
248
+ const flushSteers = (ctx: Pi.ExtensionContext, ownerId: string): boolean => {
249
+ const recovering = recoverQueuedSteers;
250
+ for (const steer of journal(ownerId).steers) {
251
+ if (steer.phase === "presented") {
252
+ finishPresentedSteer(ownerId, steer);
253
+ continue;
254
+ }
255
+ let phase = steer.phase;
256
+ if (phase === "queued") {
257
+ if (!recovering) continue;
258
+ const evidence = Pi.inspectRunSteerSessionEvidence(ctx, {
259
+ content: steer.content,
260
+ eventId: steer.event_id,
261
+ steerId: steer.steer_id,
262
+ });
263
+ if (evidence.status === "present") continue;
264
+ if (evidence.status !== "absent") {
265
+ const key = `${steer.steer_id}:${evidence.status}:${evidence.reason ?? ""}`;
266
+ if (!steerDiagnostics.has(key)) {
267
+ steerDiagnostics.add(key);
268
+ ctx.ui.notify(
269
+ `Actor urgent steer recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Event ${steer.event_id} remains queued.`,
270
+ "warning",
271
+ );
272
+ }
273
+ continue;
274
+ }
275
+ if (!RunDelivery.resetRunSteerPending({
276
+ ownerId,
277
+ steerId: steer.steer_id,
278
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
279
+ })) continue;
280
+ phase = "pending";
281
+ }
282
+ if (phase !== "pending") continue;
283
+ try {
284
+ Pi.sendRunSteer(deps.pi, {
285
+ content: steer.content,
286
+ eventId: steer.event_id,
287
+ steerId: steer.steer_id,
288
+ });
289
+ } catch (error) {
290
+ RunDelivery.recordRunSteerDeliveryFailure({
291
+ error,
292
+ ownerId,
293
+ steerId: steer.steer_id,
294
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
295
+ });
296
+ const message = error instanceof Error ? error.message : String(error);
297
+ ctx.ui.notify(
298
+ `Actor urgent steer delivery failed for event ${steer.event_id}: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`,
299
+ "error",
300
+ );
301
+ continue;
302
+ }
303
+ RunDelivery.markRunSteerQueued({
304
+ ownerId,
305
+ steerId: steer.steer_id,
306
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
307
+ });
308
+ }
309
+ recoverQueuedSteers = false;
310
+ return journal(ownerId).steers.length > 0;
311
+ };
312
+ const scheduleCompletionFlush = (): void => {
313
+ if (!running) return;
314
+ if (deliveryTimeout) clearTimeout(deliveryTimeout);
315
+ deliveryTimeout = setTimeout(() => {
316
+ deliveryTimeout = undefined;
317
+ runActiveCallback("completion delivery callback", (ctx) => {
318
+ flushCompletionBatch(ctx);
319
+ });
320
+ }, deps.deliveryDebounceMs ?? 100);
321
+ deliveryTimeout.unref?.();
322
+ };
110
323
  const update = (
111
324
  ctx: Pi.ExtensionContext,
112
325
  ownerId: string,
113
326
  notify = false,
114
327
  terminalOnly = false,
115
- ): void => {
328
+ ): boolean => {
116
329
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
117
330
  ctx.ui.setStatus(
118
331
  "zz-pi-actors-runs",
119
332
  snapshot.status ? ctx.ui.theme.fg("dim", snapshot.status) : undefined,
120
333
  );
121
- if (!notify) return;
334
+ if (!notify) return false;
122
335
  const sink = Pi.createNotificationSink(deps.pi, ctx);
123
336
  retireCandidateRuns(ctx, snapshot.summary);
124
- Observability.deliverRunTransitionNotifications(
125
- snapshot.transitions,
126
- sink,
127
- terminalNotificationsInFlight,
128
- );
337
+ const hasCompletionCandidates =
338
+ Observability.collectRunCompletionBatchMembers(snapshot.transitions).length > 0;
339
+ const hasCompletionBatch = Boolean(journal(ownerId).completion_batch);
340
+ admitSteerEvents(ctx, ownerId, snapshot.attentionEvents);
129
341
  Observability.pruneRunUiObservationState(observation, snapshot);
130
342
  if (!terminalOnly) {
131
- Observability.deliverRunAttentionNotifications(snapshot.attentionEvents, sink);
343
+ Observability.deliverRunAttentionNotifications(
344
+ snapshot.attentionEvents.filter((event) =>
345
+ !Observability.isRunSteerAttentionEvent(event)),
346
+ sink,
347
+ );
348
+ }
349
+ const hasSteers = flushSteers(ctx, ownerId);
350
+ if ((hasCompletionBatch || hasCompletionCandidates) && isIdle(ctx)) {
351
+ scheduleCompletionFlush();
132
352
  }
353
+ return hasCompletionBatch || hasCompletionCandidates || hasSteers;
133
354
  };
134
355
  const reportDiagnostics = (ctx: Pi.ExtensionContext): void => {
135
356
  for (const diagnostic of watcher.getDiagnostics()) {
@@ -147,8 +368,8 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
147
368
  notifyTimeout = setTimeout(() => {
148
369
  runActiveCallback("Run watcher callback", (ctx, ownerId) => {
149
370
  watcher.refresh();
150
- update(ctx, ownerId, true);
151
- deps.onRunEvent();
371
+ const completionDeferred = update(ctx, ownerId, true);
372
+ if (!completionDeferred) deps.onRunEvent();
152
373
  reportDiagnostics(ctx);
153
374
  });
154
375
  }, deps.notificationDelayMs ?? 50);
@@ -172,13 +393,7 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
172
393
  },
173
394
  reconcile: () => {
174
395
  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
- });
396
+ update(ctx, ownerId, true);
182
397
  reportDiagnostics(ctx);
183
398
  });
184
399
  },
@@ -187,8 +402,141 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
187
402
  },
188
403
  });
189
404
 
405
+ flushCompletionBatch = (ctx: Pi.ExtensionContext): boolean => {
406
+ if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) return false;
407
+ const ownerId = activeOwnerId;
408
+ if (!ownerId) return false;
409
+ if (flushSteers(ctx, ownerId)) return true;
410
+ let batch = journal(ownerId).completion_batch;
411
+ if (!batch) {
412
+ const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
413
+ admitCompletionTransitions(ownerId, snapshot.transitions);
414
+ Observability.pruneRunUiObservationState(observation, snapshot);
415
+ batch = journal(ownerId).completion_batch;
416
+ }
417
+ if (!batch) return false;
418
+ if (batch.phase === "presented") {
419
+ finishPresentedBatch(ownerId, batch);
420
+ const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
421
+ admitCompletionTransitions(ownerId, snapshot.transitions);
422
+ Observability.pruneRunUiObservationState(observation, snapshot);
423
+ batch = journal(ownerId).completion_batch;
424
+ if (!batch) return false;
425
+ }
426
+ if (!isIdle(ctx)) return true;
427
+ const content = RunDelivery.formatRunCompletionBatchMessage(batch);
428
+ if (batch.phase === "queued") {
429
+ if (!recoverQueuedBatch) return true;
430
+ recoverQueuedBatch = false;
431
+ const evidence = Pi.inspectRunCompletionBatchSessionEvidence(
432
+ ctx,
433
+ batch.batch_id,
434
+ content,
435
+ );
436
+ if (evidence.status === "present") return true;
437
+ if (evidence.status !== "absent") {
438
+ const diagnosticKey = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
439
+ if (!deliveryRecoveryDiagnostics.has(diagnosticKey)) {
440
+ deliveryRecoveryDiagnostics.add(diagnosticKey);
441
+ ctx.ui.notify(
442
+ `Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains queued.`,
443
+ "warning",
444
+ );
445
+ }
446
+ return true;
447
+ }
448
+ if (!RunDelivery.resetRunCompletionBatchPending({
449
+ batchId: batch.batch_id,
450
+ ownerId,
451
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
452
+ })) return true;
453
+ batch = journal(ownerId).completion_batch!;
454
+ }
455
+ if (!isIdle(ctx)) return true;
456
+ try {
457
+ Pi.sendRunCompletionBatch(deps.pi, batch.batch_id, content);
458
+ } catch (error) {
459
+ RunDelivery.recordRunCompletionBatchDeliveryFailure({
460
+ batchId: batch.batch_id,
461
+ error,
462
+ ownerId,
463
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
464
+ });
465
+ throw error;
466
+ }
467
+ if (!RunDelivery.markRunCompletionBatchQueued({
468
+ batchId: batch.batch_id,
469
+ ownerId,
470
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
471
+ })) {
472
+ throw new Error("Completion batch changed before queue acknowledgment");
473
+ }
474
+ recoverQueuedBatch = false;
475
+ return true;
476
+ };
477
+
190
478
  return {
191
479
  close,
480
+ flushCompletionBatch,
481
+ projectContext(messages, ctx) {
482
+ if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) {
483
+ return messages;
484
+ }
485
+ const ownerId = activeOwnerId;
486
+ if (!ownerId) return messages;
487
+ const steerContext = Pi.dedupeRunSteerContext(messages);
488
+ let contextMessages = steerContext.messages;
489
+ for (const steer of journal(ownerId).steers) {
490
+ const presented = steerContext.steers.get(steer.steer_id);
491
+ if (
492
+ !presented ||
493
+ presented.eventId !== steer.event_id ||
494
+ presented.content !== steer.content
495
+ ) {
496
+ contextMessages = Pi.removeRunSteerFromContext(
497
+ contextMessages,
498
+ steer.steer_id,
499
+ );
500
+ continue;
501
+ }
502
+ if (!RunDelivery.markRunSteerQueued({
503
+ ownerId,
504
+ steerId: steer.steer_id,
505
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
506
+ })) continue;
507
+ if (!RunDelivery.markRunSteerPresented({
508
+ ownerId,
509
+ steerId: steer.steer_id,
510
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
511
+ })) continue;
512
+ const durable = journal(ownerId).steers.find((item) =>
513
+ item.steer_id === steer.steer_id);
514
+ if (durable) finishPresentedSteer(ownerId, durable);
515
+ }
516
+ const projected = Pi.dedupeRunCompletionBatchContext(contextMessages);
517
+ const batch = journal(ownerId).completion_batch;
518
+ if (!batch) return projected.messages;
519
+ const content = projected.batches.get(batch.batch_id);
520
+ if (content !== RunDelivery.formatRunCompletionBatchMessage(batch)) {
521
+ return Pi.removeRunCompletionBatchFromContext(
522
+ projected.messages,
523
+ batch.batch_id,
524
+ );
525
+ }
526
+ if (!RunDelivery.markRunCompletionBatchQueued({
527
+ batchId: batch.batch_id,
528
+ ownerId,
529
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
530
+ })) return projected.messages;
531
+ if (!RunDelivery.markRunCompletionBatchPresented({
532
+ batchId: batch.batch_id,
533
+ ownerId,
534
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
535
+ })) return projected.messages;
536
+ const presented = journal(ownerId).completion_batch;
537
+ if (presented) finishPresentedBatch(ownerId, presented);
538
+ return projected.messages;
539
+ },
192
540
  shutdown(eventReason, ownerId, ctx) {
193
541
  if (!ownerId) return;
194
542
  const teardown = (
@@ -212,8 +560,12 @@ export function createRunUiRuntime(deps: RunUiRuntimeDeps): RunUiRuntime {
212
560
  close();
213
561
  activeContext = ctx;
214
562
  activeOwnerId = ownerId;
563
+ recoverQueuedBatch = true;
564
+ recoverQueuedSteers = true;
215
565
  running = true;
216
566
  try {
567
+ recoverPresentedBatch(ownerId);
568
+ recoverPresentedSteers(ownerId);
217
569
  Observability.primeRunAttentionState(observation, ownerId);
218
570
  update(ctx, ownerId, true, true);
219
571
  watcher.refresh();
@@ -35,7 +35,7 @@ export interface TraceEvent {
35
35
  summary?: string;
36
36
  data?: unknown;
37
37
  level?: "info" | "warning" | "error";
38
- attention?: "notify" | "followup";
38
+ attention?: "notify" | "followup" | "steer";
39
39
  }
40
40
 
41
41
  export interface AppendTraceEventInput {
@@ -167,9 +167,10 @@ function normalizeTraceEventInput(input: unknown): AppendTraceEventInput {
167
167
  if (
168
168
  record.attention !== undefined &&
169
169
  record.attention !== "notify" &&
170
- record.attention !== "followup"
170
+ record.attention !== "followup" &&
171
+ record.attention !== "steer"
171
172
  ) {
172
- throw new Error("Trace event attention must be notify or followup");
173
+ throw new Error("Trace event attention must be notify, followup, or steer");
173
174
  }
174
175
  return {
175
176
  kind: record.kind,
@@ -295,7 +296,8 @@ function isTraceEvent(value: unknown): value is TraceEvent {
295
296
  ) return false;
296
297
  return record.attention === undefined ||
297
298
  record.attention === "notify" ||
298
- record.attention === "followup";
299
+ record.attention === "followup" ||
300
+ record.attention === "steer";
299
301
  }
300
302
 
301
303
  function isCount(value: unknown): value is number {
@@ -61,7 +61,160 @@ export interface SessionEvidenceReadOptions {
61
61
  maxTurns?: number;
62
62
  }
63
63
 
64
+ export type ActiveSessionEntryMatch = "present" | "conflict" | undefined;
65
+ export interface ActiveSessionEntryEvidence {
66
+ examinedBytes: number;
67
+ examinedEntries: number;
68
+ reason?: string;
69
+ status: "present" | "absent" | "conflict" | "unknown";
70
+ }
71
+
72
+ export interface ActiveSessionEntryEvidenceInput {
73
+ getEntry(id: string): unknown;
74
+ leaf: unknown;
75
+ match(entry: Record<string, unknown>): ActiveSessionEntryMatch;
76
+ maxBytes?: number;
77
+ maxEntries?: number;
78
+ }
79
+
64
80
  const SENSITIVE_KEY = /(?:^|[_-])(?:api[-_]?key|authorization|cookie|credential|password|private[-_]?key|secret|secret[-_]?access[-_]?key|token)$|^(?:access|refresh|auth|api)Token$|^(?:clientSecret|privateKey|secretAccessKey)$/i;
81
+
82
+ function boundedJsonStringBytes(value: string): number {
83
+ let bytes = Buffer.byteLength(value, "utf8") + 2;
84
+ for (let index = 0; index < value.length; index += 1) {
85
+ const code = value.charCodeAt(index);
86
+ if (code === 0x22 || code === 0x5c) bytes += 1;
87
+ else if (code < 0x20) bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(code) ? 1 : 5;
88
+ else if (code >= 0xd800 && code <= 0xdbff) {
89
+ const next = value.charCodeAt(index + 1);
90
+ if (next >= 0xdc00 && next <= 0xdfff) index += 1;
91
+ else bytes += 3;
92
+ } else if (code >= 0xdc00 && code <= 0xdfff) bytes += 3;
93
+ }
94
+ return bytes;
95
+ }
96
+
97
+ function boundedJsonBytes(
98
+ value: unknown,
99
+ ceiling: number,
100
+ seen = new WeakSet<object>(),
101
+ ): number | undefined {
102
+ if (value === null) return 4;
103
+ if (typeof value === "string") return boundedJsonStringBytes(value);
104
+ if (typeof value === "number") return Number.isFinite(value) ? String(value).length : 4;
105
+ if (typeof value === "boolean") return value ? 4 : 5;
106
+ if (typeof value !== "object") return undefined;
107
+ if (seen.has(value)) return undefined;
108
+ seen.add(value);
109
+ let bytes = 2;
110
+ const values: Array<[string | undefined, unknown]> = Array.isArray(value)
111
+ ? value.map((item) => [
112
+ undefined,
113
+ item === undefined || typeof item === "function" || typeof item === "symbol"
114
+ ? null
115
+ : item,
116
+ ])
117
+ : Object.entries(value as Record<string, unknown>).filter(([, item]) =>
118
+ item !== undefined && typeof item !== "function" && typeof item !== "symbol");
119
+ for (const [key, item] of values) {
120
+ if (bytes > ceiling) break;
121
+ if (bytes > 2) bytes += 1;
122
+ if (key !== undefined) bytes += boundedJsonStringBytes(key) + 1;
123
+ const itemBytes = boundedJsonBytes(item, ceiling - bytes, seen);
124
+ if (itemBytes === undefined) {
125
+ seen.delete(value);
126
+ return undefined;
127
+ }
128
+ bytes += itemBytes;
129
+ }
130
+ seen.delete(value);
131
+ return bytes;
132
+ }
133
+
134
+ /** Walk only the active parent chain under exact entry and byte ceilings. */
135
+ export function inspectBoundedActiveSessionEntries(
136
+ input: ActiveSessionEntryEvidenceInput,
137
+ ): ActiveSessionEntryEvidence {
138
+ const maxBytes = input.maxBytes ?? Limits.RUN_DELIVERY_SESSION_MAX_BYTES;
139
+ const maxEntries = input.maxEntries ?? Limits.RUN_DELIVERY_SESSION_MAX_ENTRIES;
140
+ if (input.leaf === undefined || input.leaf === null) {
141
+ return { examinedBytes: 0, examinedEntries: 0, status: "absent" };
142
+ }
143
+ const visited = new Set<string>();
144
+ let current: unknown = input.leaf;
145
+ let examinedBytes = 0;
146
+ let examinedEntries = 0;
147
+ while (current !== undefined) {
148
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
149
+ return {
150
+ examinedBytes,
151
+ examinedEntries,
152
+ reason: "active session entry is malformed",
153
+ status: "unknown",
154
+ };
155
+ }
156
+ const entry = current as Record<string, unknown>;
157
+ if (typeof entry.id !== "string" || !entry.id) {
158
+ return {
159
+ examinedBytes,
160
+ examinedEntries,
161
+ reason: "active session entry id is malformed",
162
+ status: "unknown",
163
+ };
164
+ }
165
+ if (visited.has(entry.id)) {
166
+ return {
167
+ examinedBytes,
168
+ examinedEntries,
169
+ reason: "active session entry cycle detected",
170
+ status: "unknown",
171
+ };
172
+ }
173
+ visited.add(entry.id);
174
+ const bytes = boundedJsonBytes(entry, maxBytes - examinedBytes);
175
+ if (bytes === undefined) {
176
+ return {
177
+ examinedBytes,
178
+ examinedEntries,
179
+ reason: "active session entry is not serializable",
180
+ status: "unknown",
181
+ };
182
+ }
183
+ if (examinedEntries >= maxEntries || examinedBytes + bytes > maxBytes) {
184
+ return {
185
+ examinedBytes,
186
+ examinedEntries,
187
+ reason: "active session evidence exceeds its inspection bound",
188
+ status: "unknown",
189
+ };
190
+ }
191
+ examinedEntries += 1;
192
+ examinedBytes += bytes;
193
+ const matched = input.match(entry);
194
+ if (matched) return { examinedBytes, examinedEntries, status: matched };
195
+ if (entry.parentId === null) {
196
+ return { examinedBytes, examinedEntries, status: "absent" };
197
+ }
198
+ if (typeof entry.parentId !== "string" || !entry.parentId) {
199
+ return {
200
+ examinedBytes,
201
+ examinedEntries,
202
+ reason: "active session parent is malformed",
203
+ status: "unknown",
204
+ };
205
+ }
206
+ current = input.getEntry(entry.parentId);
207
+ if (current === undefined) {
208
+ return {
209
+ examinedBytes,
210
+ examinedEntries,
211
+ reason: "active session parent is unavailable",
212
+ status: "unknown",
213
+ };
214
+ }
215
+ }
216
+ return { examinedBytes, examinedEntries, status: "unknown" };
217
+ }
65
218
  const SENSITIVE_TEXT = /(bearer\s+)[A-Za-z0-9._~+/=-]+|["']?\b(api[-_]?key|authorization|clientSecret|cookie|password|private[-_]?key|privateKey|secret|secretAccessKey|token)["']?(\s*[:=]\s*)["']?([^\s,;"'}]+)/gi;
66
219
 
67
220
  function asRecord(value: unknown): Record<string, unknown> {
@@ -14,7 +14,7 @@ export async function cleanupStaleTempEntries(
14
14
  tempDir: string,
15
15
  maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS,
16
16
  now = Date.now(),
17
- preservedEntries = new Set(["runs"]),
17
+ preservedEntries = new Set(["runs", "delivery"]),
18
18
  ): Promise<number> {
19
19
  let entries: Array<{ name: string }>;
20
20
  let removed = 0;
@@ -122,7 +122,10 @@ function runtimeTriage(
122
122
  const stateDir = typeof run.state_dir === "string" ? run.state_dir : undefined;
123
123
  if (!stateDir) return [];
124
124
  return RunsTrace.readRunTraceEvents(stateDir)
125
- .filter((event) => event.attention === "notify" || event.attention === "followup")
125
+ .filter((event) =>
126
+ event.attention === "notify" ||
127
+ event.attention === "followup" ||
128
+ event.attention === "steer")
126
129
  .map((event) => ({
127
130
  run: run.run,
128
131
  id: event.id,