@llblab/pi-kit 0.1.13 → 0.2.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 (70) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-actors/AGENTS.md +5 -3
  4. package/node_modules/@llblab/pi-actors/BACKLOG.md +1 -1
  5. package/node_modules/@llblab/pi-actors/CHANGELOG.md +15 -0
  6. package/node_modules/@llblab/pi-actors/README.md +5 -3
  7. package/node_modules/@llblab/pi-actors/dist/index.js +4 -1
  8. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.d.ts +2 -2
  9. package/node_modules/@llblab/pi-actors/dist/lib/async-runs.js +42 -19
  10. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.d.ts +2 -1
  11. package/node_modules/@llblab/pi-actors/dist/lib/extension-runtime.js +7 -2
  12. package/node_modules/@llblab/pi-actors/dist/lib/limits.d.ts +9 -0
  13. package/node_modules/@llblab/pi-actors/dist/lib/limits.js +9 -0
  14. package/node_modules/@llblab/pi-actors/dist/lib/observability.d.ts +10 -11
  15. package/node_modules/@llblab/pi-actors/dist/lib/observability.js +81 -56
  16. package/node_modules/@llblab/pi-actors/dist/lib/pi.d.ts +31 -0
  17. package/node_modules/@llblab/pi-actors/dist/lib/pi.js +180 -0
  18. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.d.ts +115 -0
  19. package/node_modules/@llblab/pi-actors/dist/lib/run-delivery.js +623 -0
  20. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.d.ts +3 -0
  21. package/node_modules/@llblab/pi-actors/dist/lib/run-ui-runtime.js +341 -13
  22. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.d.ts +1 -1
  23. package/node_modules/@llblab/pi-actors/dist/lib/runs-trace.js +5 -3
  24. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.d.ts +16 -0
  25. package/node_modules/@llblab/pi-actors/dist/lib/session-evidence.js +143 -0
  26. package/node_modules/@llblab/pi-actors/dist/lib/temp.js +1 -1
  27. package/node_modules/@llblab/pi-actors/dist/lib/tools-inspect.js +3 -1
  28. package/node_modules/@llblab/pi-actors/dist/scripts/async-runner.mjs +5 -19
  29. package/node_modules/@llblab/pi-actors/dist/skills/actors/SKILL.md +2 -2
  30. package/node_modules/@llblab/pi-actors/dist/skills/actors/references/runs.md +1 -1
  31. package/node_modules/@llblab/pi-actors/dist/skills/swarm/SKILL.md +1 -1
  32. package/node_modules/@llblab/pi-actors/docs/README.md +1 -0
  33. package/node_modules/@llblab/pi-actors/docs/async-runs.md +8 -4
  34. package/node_modules/@llblab/pi-actors/docs/coordinator-delivery.md +207 -0
  35. package/node_modules/@llblab/pi-actors/index.ts +4 -1
  36. package/node_modules/@llblab/pi-actors/lib/async-runs.ts +42 -21
  37. package/node_modules/@llblab/pi-actors/lib/extension-runtime.ts +8 -3
  38. package/node_modules/@llblab/pi-actors/lib/limits.ts +9 -0
  39. package/node_modules/@llblab/pi-actors/lib/observability.ts +97 -78
  40. package/node_modules/@llblab/pi-actors/lib/pi.ts +210 -0
  41. package/node_modules/@llblab/pi-actors/lib/run-delivery.ts +800 -0
  42. package/node_modules/@llblab/pi-actors/lib/run-ui-runtime.ts +370 -18
  43. package/node_modules/@llblab/pi-actors/lib/runs-trace.ts +6 -4
  44. package/node_modules/@llblab/pi-actors/lib/session-evidence.ts +153 -0
  45. package/node_modules/@llblab/pi-actors/lib/temp.ts +1 -1
  46. package/node_modules/@llblab/pi-actors/lib/tools-inspect.ts +4 -1
  47. package/node_modules/@llblab/pi-actors/package.json +3 -3
  48. package/node_modules/@llblab/pi-actors/scripts/async-runner.mjs +5 -19
  49. package/node_modules/@llblab/pi-actors/skills/actors/SKILL.md +2 -2
  50. package/node_modules/@llblab/pi-actors/skills/actors/references/runs.md +1 -1
  51. package/node_modules/@llblab/pi-actors/skills/swarm/SKILL.md +1 -1
  52. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  53. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  54. package/node_modules/@llblab/pi-telegram/README.md +1 -1
  55. package/node_modules/@llblab/pi-telegram/docs/activity.md +4 -4
  56. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  57. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -2
  58. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -4
  59. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +4 -5
  60. package/node_modules/@llblab/pi-telegram/docs/voice.md +12 -2
  61. package/node_modules/@llblab/pi-telegram/index.ts +0 -1
  62. package/node_modules/@llblab/pi-telegram/lib/activity.ts +3 -5
  63. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -17
  64. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +1 -1
  65. package/node_modules/@llblab/pi-telegram/lib/config.ts +25 -25
  66. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +10 -73
  67. package/node_modules/@llblab/pi-telegram/lib/status.ts +11 -1
  68. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  69. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +5 -3
  70. package/package.json +3 -3
@@ -4,19 +4,25 @@
4
4
  * Owns event-driven run UI coordination without owning actor execution semantics.
5
5
  */
6
6
  import * as AsyncRuns from "./async-runs.js";
7
+ import * as Limits from "./limits.js";
7
8
  import * as Observability from "./observability.js";
8
9
  import * as Paths from "./paths.js";
9
10
  import * as Pi from "./pi.js";
11
+ import * as RunDelivery from "./run-delivery.js";
10
12
  export function createRunUiRuntime(deps) {
11
13
  let activeContext;
12
14
  let activeOwnerId;
13
15
  let animationInterval;
16
+ let deliveryTimeout;
14
17
  let notifyTimeout;
18
+ let recoverQueuedBatch = false;
19
+ let recoverQueuedSteers = false;
15
20
  let running = false;
16
21
  let lastWatcherDiagnosticId = 0;
17
22
  const observation = Observability.createRunUiObservationState();
23
+ const deliveryRecoveryDiagnostics = new Set();
18
24
  const retirementAttempts = new Set();
19
- const terminalNotificationsInFlight = new Set();
25
+ const steerDiagnostics = new Set();
20
26
  const close = () => {
21
27
  running = false;
22
28
  activeContext = undefined;
@@ -36,6 +42,13 @@ export function createRunUiRuntime(deps) {
36
42
  if (notifyTimeout)
37
43
  clearTimeout(notifyTimeout);
38
44
  notifyTimeout = undefined;
45
+ if (deliveryTimeout)
46
+ clearTimeout(deliveryTimeout);
47
+ deliveryTimeout = undefined;
48
+ recoverQueuedBatch = false;
49
+ recoverQueuedSteers = false;
50
+ deliveryRecoveryDiagnostics.clear();
51
+ steerDiagnostics.clear();
39
52
  if (animationInterval)
40
53
  clearInterval(animationInterval);
41
54
  animationInterval = undefined;
@@ -79,18 +92,199 @@ export function createRunUiRuntime(deps) {
79
92
  sendStop: async (candidate) => AsyncRuns.cancelRun(candidate.stateDir),
80
93
  }).catch((error) => stopAfterCallbackFailure("Run retirement callback", error, ctx));
81
94
  };
95
+ const journal = (ownerId) => RunDelivery.readRunDeliveryJournal(Paths.EXTENSION_RUNTIME_PATHS.tempDir, ownerId);
96
+ const finishPresentedBatch = (ownerId, batch) => {
97
+ if (batch.phase !== "presented")
98
+ return false;
99
+ for (const member of batch.members) {
100
+ AsyncRuns.markRunTerminalNotificationHandled(member.state_dir, member.status, member.run_instance_id);
101
+ }
102
+ return RunDelivery.finalizeRunCompletionBatch({
103
+ batchId: batch.batch_id,
104
+ ownerId,
105
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
106
+ });
107
+ };
108
+ const recoverPresentedBatch = (ownerId) => {
109
+ const batch = journal(ownerId).completion_batch;
110
+ if (batch?.phase === "presented")
111
+ finishPresentedBatch(ownerId, batch);
112
+ };
113
+ const finishPresentedSteer = (ownerId, steer) => {
114
+ if (steer.phase !== "presented")
115
+ return false;
116
+ AsyncRuns.markRunSteerPresentationHandled(steer.state_dir, steer.run_instance_id, steer.event_id, steer.steer_id);
117
+ return RunDelivery.finalizeRunSteer({
118
+ ownerId,
119
+ steerId: steer.steer_id,
120
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
121
+ });
122
+ };
123
+ const recoverPresentedSteers = (ownerId) => {
124
+ for (const steer of journal(ownerId).steers) {
125
+ if (steer.phase === "presented")
126
+ finishPresentedSteer(ownerId, steer);
127
+ }
128
+ };
129
+ const boundedSteerContent = (event) => {
130
+ const text = Observability.formatRunAttentionMessage(event);
131
+ if (Buffer.byteLength(text, "utf8") <= Limits.RUN_DELIVERY_STEER_MAX_BYTES) {
132
+ return text;
133
+ }
134
+ let end = Math.min(text.length, Limits.RUN_DELIVERY_STEER_MAX_BYTES - 3);
135
+ while (end > 0 && Buffer.byteLength(text.slice(0, end), "utf8") >
136
+ Limits.RUN_DELIVERY_STEER_MAX_BYTES - 3)
137
+ end -= 1;
138
+ return `${text.slice(0, end)}…`;
139
+ };
140
+ const admitSteerEvents = (ctx, ownerId, events) => {
141
+ for (const event of events.filter(Observability.isRunSteerAttentionEvent)) {
142
+ if (!event.runInstanceId) {
143
+ Observability.retryRunAttentionEvent(observation, event);
144
+ const key = `${event.stateDir}:${event.id}:missing_generation`;
145
+ if (!steerDiagnostics.has(key)) {
146
+ steerDiagnostics.add(key);
147
+ ctx.ui.notify(`Actor urgent steer ${event.id} remains retryable because its Run generation is unavailable.`, "warning");
148
+ }
149
+ continue;
150
+ }
151
+ try {
152
+ RunDelivery.admitRunSteerEnvelope({
153
+ content: boundedSteerContent(event),
154
+ eventId: event.id,
155
+ kind: event.kind,
156
+ level: event.level,
157
+ occurredAt: event.ts,
158
+ ownerId,
159
+ run: event.run,
160
+ runInstanceId: event.runInstanceId,
161
+ stateDir: event.stateDir,
162
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
163
+ });
164
+ }
165
+ catch (error) {
166
+ Observability.retryRunAttentionEvent(observation, event);
167
+ const message = error instanceof Error ? error.message : String(error);
168
+ const key = `${event.stateDir}:${event.id}:${message}`;
169
+ if (!steerDiagnostics.has(key)) {
170
+ steerDiagnostics.add(key);
171
+ ctx.ui.notify(`Actor urgent steer ${event.id} remains retryable: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`, "warning");
172
+ }
173
+ }
174
+ }
175
+ };
176
+ const admitCompletionTransitions = (ownerId, transitions) => {
177
+ const existing = journal(ownerId).completion_batch;
178
+ if (existing)
179
+ return true;
180
+ const members = Observability.collectRunCompletionBatchMembers(transitions)
181
+ .slice(0, Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS);
182
+ if (members.length === 0)
183
+ return false;
184
+ RunDelivery.admitRunCompletionBatch({
185
+ members,
186
+ ownerId,
187
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
188
+ });
189
+ return true;
190
+ };
191
+ const isIdle = (ctx) => typeof ctx.isIdle !== "function" || ctx.isIdle();
192
+ let flushCompletionBatch = (_ctx) => false;
193
+ const flushSteers = (ctx, ownerId) => {
194
+ const recovering = recoverQueuedSteers;
195
+ for (const steer of journal(ownerId).steers) {
196
+ if (steer.phase === "presented") {
197
+ finishPresentedSteer(ownerId, steer);
198
+ continue;
199
+ }
200
+ let phase = steer.phase;
201
+ if (phase === "queued") {
202
+ if (!recovering)
203
+ continue;
204
+ const evidence = Pi.inspectRunSteerSessionEvidence(ctx, {
205
+ content: steer.content,
206
+ eventId: steer.event_id,
207
+ steerId: steer.steer_id,
208
+ });
209
+ if (evidence.status === "present")
210
+ continue;
211
+ if (evidence.status !== "absent") {
212
+ const key = `${steer.steer_id}:${evidence.status}:${evidence.reason ?? ""}`;
213
+ if (!steerDiagnostics.has(key)) {
214
+ steerDiagnostics.add(key);
215
+ ctx.ui.notify(`Actor urgent steer recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Event ${steer.event_id} remains queued.`, "warning");
216
+ }
217
+ continue;
218
+ }
219
+ if (!RunDelivery.resetRunSteerPending({
220
+ ownerId,
221
+ steerId: steer.steer_id,
222
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
223
+ }))
224
+ continue;
225
+ phase = "pending";
226
+ }
227
+ if (phase !== "pending")
228
+ continue;
229
+ try {
230
+ Pi.sendRunSteer(deps.pi, {
231
+ content: steer.content,
232
+ eventId: steer.event_id,
233
+ steerId: steer.steer_id,
234
+ });
235
+ }
236
+ catch (error) {
237
+ RunDelivery.recordRunSteerDeliveryFailure({
238
+ error,
239
+ ownerId,
240
+ steerId: steer.steer_id,
241
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
242
+ });
243
+ const message = error instanceof Error ? error.message : String(error);
244
+ ctx.ui.notify(`Actor urgent steer delivery failed for event ${steer.event_id}: ${message.replaceAll(/\s+/g, " ").slice(0, 240)}`, "error");
245
+ continue;
246
+ }
247
+ RunDelivery.markRunSteerQueued({
248
+ ownerId,
249
+ steerId: steer.steer_id,
250
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
251
+ });
252
+ }
253
+ recoverQueuedSteers = false;
254
+ return journal(ownerId).steers.length > 0;
255
+ };
256
+ const scheduleCompletionFlush = () => {
257
+ if (!running)
258
+ return;
259
+ if (deliveryTimeout)
260
+ clearTimeout(deliveryTimeout);
261
+ deliveryTimeout = setTimeout(() => {
262
+ deliveryTimeout = undefined;
263
+ runActiveCallback("completion delivery callback", (ctx) => {
264
+ flushCompletionBatch(ctx);
265
+ });
266
+ }, deps.deliveryDebounceMs ?? 100);
267
+ deliveryTimeout.unref?.();
268
+ };
82
269
  const update = (ctx, ownerId, notify = false, terminalOnly = false) => {
83
270
  const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
84
271
  ctx.ui.setStatus("zz-pi-actors-runs", snapshot.status ? ctx.ui.theme.fg("dim", snapshot.status) : undefined);
85
272
  if (!notify)
86
- return;
273
+ return false;
87
274
  const sink = Pi.createNotificationSink(deps.pi, ctx);
88
275
  retireCandidateRuns(ctx, snapshot.summary);
89
- Observability.deliverRunTransitionNotifications(snapshot.transitions, sink, terminalNotificationsInFlight);
276
+ const hasCompletionCandidates = Observability.collectRunCompletionBatchMembers(snapshot.transitions).length > 0;
277
+ const hasCompletionBatch = Boolean(journal(ownerId).completion_batch);
278
+ admitSteerEvents(ctx, ownerId, snapshot.attentionEvents);
90
279
  Observability.pruneRunUiObservationState(observation, snapshot);
91
280
  if (!terminalOnly) {
92
- Observability.deliverRunAttentionNotifications(snapshot.attentionEvents, sink);
281
+ Observability.deliverRunAttentionNotifications(snapshot.attentionEvents.filter((event) => !Observability.isRunSteerAttentionEvent(event)), sink);
282
+ }
283
+ const hasSteers = flushSteers(ctx, ownerId);
284
+ if ((hasCompletionBatch || hasCompletionCandidates) && isIdle(ctx)) {
285
+ scheduleCompletionFlush();
93
286
  }
287
+ return hasCompletionBatch || hasCompletionCandidates || hasSteers;
94
288
  };
95
289
  const reportDiagnostics = (ctx) => {
96
290
  for (const diagnostic of watcher.getDiagnostics()) {
@@ -108,8 +302,9 @@ export function createRunUiRuntime(deps) {
108
302
  notifyTimeout = setTimeout(() => {
109
303
  runActiveCallback("Run watcher callback", (ctx, ownerId) => {
110
304
  watcher.refresh();
111
- update(ctx, ownerId, true);
112
- deps.onRunEvent();
305
+ const completionDeferred = update(ctx, ownerId, true);
306
+ if (!completionDeferred)
307
+ deps.onRunEvent();
113
308
  reportDiagnostics(ctx);
114
309
  });
115
310
  }, deps.notificationDelayMs ?? 50);
@@ -128,13 +323,7 @@ export function createRunUiRuntime(deps) {
128
323
  },
129
324
  reconcile: () => {
130
325
  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
- });
326
+ update(ctx, ownerId, true);
138
327
  reportDiagnostics(ctx);
139
328
  });
140
329
  },
@@ -143,8 +332,143 @@ export function createRunUiRuntime(deps) {
143
332
  watcher.refresh();
144
333
  },
145
334
  });
335
+ flushCompletionBatch = (ctx) => {
336
+ if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx)
337
+ return false;
338
+ const ownerId = activeOwnerId;
339
+ if (!ownerId)
340
+ return false;
341
+ if (flushSteers(ctx, ownerId))
342
+ return true;
343
+ let batch = journal(ownerId).completion_batch;
344
+ if (!batch) {
345
+ const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
346
+ admitCompletionTransitions(ownerId, snapshot.transitions);
347
+ Observability.pruneRunUiObservationState(observation, snapshot);
348
+ batch = journal(ownerId).completion_batch;
349
+ }
350
+ if (!batch)
351
+ return false;
352
+ if (batch.phase === "presented") {
353
+ finishPresentedBatch(ownerId, batch);
354
+ const snapshot = Observability.readRunUiSnapshot(observation, ownerId);
355
+ admitCompletionTransitions(ownerId, snapshot.transitions);
356
+ Observability.pruneRunUiObservationState(observation, snapshot);
357
+ batch = journal(ownerId).completion_batch;
358
+ if (!batch)
359
+ return false;
360
+ }
361
+ if (!isIdle(ctx))
362
+ return true;
363
+ const content = RunDelivery.formatRunCompletionBatchMessage(batch);
364
+ if (batch.phase === "queued") {
365
+ if (!recoverQueuedBatch)
366
+ return true;
367
+ recoverQueuedBatch = false;
368
+ const evidence = Pi.inspectRunCompletionBatchSessionEvidence(ctx, batch.batch_id, content);
369
+ if (evidence.status === "present")
370
+ return true;
371
+ if (evidence.status !== "absent") {
372
+ const diagnosticKey = `${batch.batch_id}:${evidence.status}:${evidence.reason ?? ""}`;
373
+ if (!deliveryRecoveryDiagnostics.has(diagnosticKey)) {
374
+ deliveryRecoveryDiagnostics.add(diagnosticKey);
375
+ ctx.ui.notify(`Actor completion recovery is ${evidence.status}: ${evidence.reason ?? "conflicting session evidence"}. Batch ${batch.batch_id} remains queued.`, "warning");
376
+ }
377
+ return true;
378
+ }
379
+ if (!RunDelivery.resetRunCompletionBatchPending({
380
+ batchId: batch.batch_id,
381
+ ownerId,
382
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
383
+ }))
384
+ return true;
385
+ batch = journal(ownerId).completion_batch;
386
+ }
387
+ if (!isIdle(ctx))
388
+ return true;
389
+ try {
390
+ Pi.sendRunCompletionBatch(deps.pi, batch.batch_id, content);
391
+ }
392
+ catch (error) {
393
+ RunDelivery.recordRunCompletionBatchDeliveryFailure({
394
+ batchId: batch.batch_id,
395
+ error,
396
+ ownerId,
397
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
398
+ });
399
+ throw error;
400
+ }
401
+ if (!RunDelivery.markRunCompletionBatchQueued({
402
+ batchId: batch.batch_id,
403
+ ownerId,
404
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
405
+ })) {
406
+ throw new Error("Completion batch changed before queue acknowledgment");
407
+ }
408
+ recoverQueuedBatch = false;
409
+ return true;
410
+ };
146
411
  return {
147
412
  close,
413
+ flushCompletionBatch,
414
+ projectContext(messages, ctx) {
415
+ if (!running || activeContext !== ctx || deps.getActiveContext() !== ctx) {
416
+ return messages;
417
+ }
418
+ const ownerId = activeOwnerId;
419
+ if (!ownerId)
420
+ return messages;
421
+ const steerContext = Pi.dedupeRunSteerContext(messages);
422
+ let contextMessages = steerContext.messages;
423
+ for (const steer of journal(ownerId).steers) {
424
+ const presented = steerContext.steers.get(steer.steer_id);
425
+ if (!presented ||
426
+ presented.eventId !== steer.event_id ||
427
+ presented.content !== steer.content) {
428
+ contextMessages = Pi.removeRunSteerFromContext(contextMessages, steer.steer_id);
429
+ continue;
430
+ }
431
+ if (!RunDelivery.markRunSteerQueued({
432
+ ownerId,
433
+ steerId: steer.steer_id,
434
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
435
+ }))
436
+ continue;
437
+ if (!RunDelivery.markRunSteerPresented({
438
+ ownerId,
439
+ steerId: steer.steer_id,
440
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
441
+ }))
442
+ continue;
443
+ const durable = journal(ownerId).steers.find((item) => item.steer_id === steer.steer_id);
444
+ if (durable)
445
+ finishPresentedSteer(ownerId, durable);
446
+ }
447
+ const projected = Pi.dedupeRunCompletionBatchContext(contextMessages);
448
+ const batch = journal(ownerId).completion_batch;
449
+ if (!batch)
450
+ return projected.messages;
451
+ const content = projected.batches.get(batch.batch_id);
452
+ if (content !== RunDelivery.formatRunCompletionBatchMessage(batch)) {
453
+ return Pi.removeRunCompletionBatchFromContext(projected.messages, batch.batch_id);
454
+ }
455
+ if (!RunDelivery.markRunCompletionBatchQueued({
456
+ batchId: batch.batch_id,
457
+ ownerId,
458
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
459
+ }))
460
+ return projected.messages;
461
+ if (!RunDelivery.markRunCompletionBatchPresented({
462
+ batchId: batch.batch_id,
463
+ ownerId,
464
+ tempDir: Paths.EXTENSION_RUNTIME_PATHS.tempDir,
465
+ }))
466
+ return projected.messages;
467
+ const presented = journal(ownerId).completion_batch;
468
+ if (presented)
469
+ finishPresentedBatch(ownerId, presented);
470
+ return projected.messages;
471
+ },
148
472
  shutdown(eventReason, ownerId, ctx) {
149
473
  if (!ownerId)
150
474
  return;
@@ -162,8 +486,12 @@ export function createRunUiRuntime(deps) {
162
486
  close();
163
487
  activeContext = ctx;
164
488
  activeOwnerId = ownerId;
489
+ recoverQueuedBatch = true;
490
+ recoverQueuedSteers = true;
165
491
  running = true;
166
492
  try {
493
+ recoverPresentedBatch(ownerId);
494
+ recoverPresentedSteers(ownerId);
167
495
  Observability.primeRunAttentionState(observation, ownerId);
168
496
  update(ctx, ownerId, true, true);
169
497
  watcher.refresh();
@@ -11,7 +11,7 @@ export interface TraceEvent {
11
11
  summary?: string;
12
12
  data?: unknown;
13
13
  level?: "info" | "warning" | "error";
14
- attention?: "notify" | "followup";
14
+ attention?: "notify" | "followup" | "steer";
15
15
  }
16
16
  export interface AppendTraceEventInput {
17
17
  kind: string;
@@ -84,8 +84,9 @@ function normalizeTraceEventInput(input) {
84
84
  }
85
85
  if (record.attention !== undefined &&
86
86
  record.attention !== "notify" &&
87
- record.attention !== "followup") {
88
- throw new Error("Trace event attention must be notify or followup");
87
+ record.attention !== "followup" &&
88
+ record.attention !== "steer") {
89
+ throw new Error("Trace event attention must be notify, followup, or steer");
89
90
  }
90
91
  return {
91
92
  kind: record.kind,
@@ -212,7 +213,8 @@ function isTraceEvent(value) {
212
213
  return false;
213
214
  return record.attention === undefined ||
214
215
  record.attention === "notify" ||
215
- record.attention === "followup";
216
+ record.attention === "followup" ||
217
+ record.attention === "steer";
216
218
  }
217
219
  function isCount(value) {
218
220
  return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -42,5 +42,21 @@ export interface SessionEvidenceReadOptions {
42
42
  maxToolCalls?: number;
43
43
  maxTurns?: number;
44
44
  }
45
+ export type ActiveSessionEntryMatch = "present" | "conflict" | undefined;
46
+ export interface ActiveSessionEntryEvidence {
47
+ examinedBytes: number;
48
+ examinedEntries: number;
49
+ reason?: string;
50
+ status: "present" | "absent" | "conflict" | "unknown";
51
+ }
52
+ export interface ActiveSessionEntryEvidenceInput {
53
+ getEntry(id: string): unknown;
54
+ leaf: unknown;
55
+ match(entry: Record<string, unknown>): ActiveSessionEntryMatch;
56
+ maxBytes?: number;
57
+ maxEntries?: number;
58
+ }
59
+ /** Walk only the active parent chain under exact entry and byte ceilings. */
60
+ export declare function inspectBoundedActiveSessionEntries(input: ActiveSessionEntryEvidenceInput): ActiveSessionEntryEvidence;
45
61
  export declare function redactSessionEvidenceValue(value: unknown, maxTextChars?: number, seen?: WeakSet<object>): unknown;
46
62
  export declare function readSessionEvidence(path: string, options?: SessionEvidenceReadOptions): SessionEvidence;
@@ -6,6 +6,149 @@
6
6
  import * as Limits from "./limits.js";
7
7
  import { readJsonlFileResilient, } from "./state-readers.js";
8
8
  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;
9
+ function boundedJsonStringBytes(value) {
10
+ let bytes = Buffer.byteLength(value, "utf8") + 2;
11
+ for (let index = 0; index < value.length; index += 1) {
12
+ const code = value.charCodeAt(index);
13
+ if (code === 0x22 || code === 0x5c)
14
+ bytes += 1;
15
+ else if (code < 0x20)
16
+ bytes += [0x08, 0x09, 0x0a, 0x0c, 0x0d].includes(code) ? 1 : 5;
17
+ else if (code >= 0xd800 && code <= 0xdbff) {
18
+ const next = value.charCodeAt(index + 1);
19
+ if (next >= 0xdc00 && next <= 0xdfff)
20
+ index += 1;
21
+ else
22
+ bytes += 3;
23
+ }
24
+ else if (code >= 0xdc00 && code <= 0xdfff)
25
+ bytes += 3;
26
+ }
27
+ return bytes;
28
+ }
29
+ function boundedJsonBytes(value, ceiling, seen = new WeakSet()) {
30
+ if (value === null)
31
+ return 4;
32
+ if (typeof value === "string")
33
+ return boundedJsonStringBytes(value);
34
+ if (typeof value === "number")
35
+ return Number.isFinite(value) ? String(value).length : 4;
36
+ if (typeof value === "boolean")
37
+ return value ? 4 : 5;
38
+ if (typeof value !== "object")
39
+ return undefined;
40
+ if (seen.has(value))
41
+ return undefined;
42
+ seen.add(value);
43
+ let bytes = 2;
44
+ const values = Array.isArray(value)
45
+ ? value.map((item) => [
46
+ undefined,
47
+ item === undefined || typeof item === "function" || typeof item === "symbol"
48
+ ? null
49
+ : item,
50
+ ])
51
+ : Object.entries(value).filter(([, item]) => item !== undefined && typeof item !== "function" && typeof item !== "symbol");
52
+ for (const [key, item] of values) {
53
+ if (bytes > ceiling)
54
+ break;
55
+ if (bytes > 2)
56
+ bytes += 1;
57
+ if (key !== undefined)
58
+ bytes += boundedJsonStringBytes(key) + 1;
59
+ const itemBytes = boundedJsonBytes(item, ceiling - bytes, seen);
60
+ if (itemBytes === undefined) {
61
+ seen.delete(value);
62
+ return undefined;
63
+ }
64
+ bytes += itemBytes;
65
+ }
66
+ seen.delete(value);
67
+ return bytes;
68
+ }
69
+ /** Walk only the active parent chain under exact entry and byte ceilings. */
70
+ export function inspectBoundedActiveSessionEntries(input) {
71
+ const maxBytes = input.maxBytes ?? Limits.RUN_DELIVERY_SESSION_MAX_BYTES;
72
+ const maxEntries = input.maxEntries ?? Limits.RUN_DELIVERY_SESSION_MAX_ENTRIES;
73
+ if (input.leaf === undefined || input.leaf === null) {
74
+ return { examinedBytes: 0, examinedEntries: 0, status: "absent" };
75
+ }
76
+ const visited = new Set();
77
+ let current = input.leaf;
78
+ let examinedBytes = 0;
79
+ let examinedEntries = 0;
80
+ while (current !== undefined) {
81
+ if (!current || typeof current !== "object" || Array.isArray(current)) {
82
+ return {
83
+ examinedBytes,
84
+ examinedEntries,
85
+ reason: "active session entry is malformed",
86
+ status: "unknown",
87
+ };
88
+ }
89
+ const entry = current;
90
+ if (typeof entry.id !== "string" || !entry.id) {
91
+ return {
92
+ examinedBytes,
93
+ examinedEntries,
94
+ reason: "active session entry id is malformed",
95
+ status: "unknown",
96
+ };
97
+ }
98
+ if (visited.has(entry.id)) {
99
+ return {
100
+ examinedBytes,
101
+ examinedEntries,
102
+ reason: "active session entry cycle detected",
103
+ status: "unknown",
104
+ };
105
+ }
106
+ visited.add(entry.id);
107
+ const bytes = boundedJsonBytes(entry, maxBytes - examinedBytes);
108
+ if (bytes === undefined) {
109
+ return {
110
+ examinedBytes,
111
+ examinedEntries,
112
+ reason: "active session entry is not serializable",
113
+ status: "unknown",
114
+ };
115
+ }
116
+ if (examinedEntries >= maxEntries || examinedBytes + bytes > maxBytes) {
117
+ return {
118
+ examinedBytes,
119
+ examinedEntries,
120
+ reason: "active session evidence exceeds its inspection bound",
121
+ status: "unknown",
122
+ };
123
+ }
124
+ examinedEntries += 1;
125
+ examinedBytes += bytes;
126
+ const matched = input.match(entry);
127
+ if (matched)
128
+ return { examinedBytes, examinedEntries, status: matched };
129
+ if (entry.parentId === null) {
130
+ return { examinedBytes, examinedEntries, status: "absent" };
131
+ }
132
+ if (typeof entry.parentId !== "string" || !entry.parentId) {
133
+ return {
134
+ examinedBytes,
135
+ examinedEntries,
136
+ reason: "active session parent is malformed",
137
+ status: "unknown",
138
+ };
139
+ }
140
+ current = input.getEntry(entry.parentId);
141
+ if (current === undefined) {
142
+ return {
143
+ examinedBytes,
144
+ examinedEntries,
145
+ reason: "active session parent is unavailable",
146
+ status: "unknown",
147
+ };
148
+ }
149
+ }
150
+ return { examinedBytes, examinedEntries, status: "unknown" };
151
+ }
9
152
  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;
10
153
  function asRecord(value) {
11
154
  return value && typeof value === "object" && !Array.isArray(value)
@@ -7,7 +7,7 @@ import { mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
7
7
  import { join } from "node:path";
8
8
  export const DEFAULT_TEMP_MAX_AGE_MS = 24 * 60 * 60 * 1000;
9
9
  export const DEFAULT_RUN_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
10
- export async function cleanupStaleTempEntries(tempDir, maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS, now = Date.now(), preservedEntries = new Set(["runs"])) {
10
+ export async function cleanupStaleTempEntries(tempDir, maxAgeMs = DEFAULT_TEMP_MAX_AGE_MS, now = Date.now(), preservedEntries = new Set(["runs", "delivery"])) {
11
11
  let entries;
12
12
  let removed = 0;
13
13
  try {
@@ -102,7 +102,9 @@ function runtimeTriage(ctx, deps) {
102
102
  if (!stateDir)
103
103
  return [];
104
104
  return RunsTrace.readRunTraceEvents(stateDir)
105
- .filter((event) => event.attention === "notify" || event.attention === "followup")
105
+ .filter((event) => event.attention === "notify" ||
106
+ event.attention === "followup" ||
107
+ event.attention === "steer")
106
108
  .map((event) => ({
107
109
  run: run.run,
108
110
  id: event.id,