@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
@@ -0,0 +1,623 @@
1
+ /**
2
+ * Coordinator delivery persistence.
3
+ * Zones: owner-scoped completion batches, delivery phase fencing, bounded receipts
4
+ * Owns durable delivery state; excludes Run discovery, Pi scheduling, and model formatting.
5
+ */
6
+ import { createHash, randomUUID } from "node:crypto";
7
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { withFileMutationLock, writeTextAtomic } from "./file-state.js";
10
+ import * as Limits from "./limits.js";
11
+ const RUN_DELIVERY_SCHEMA = "run-delivery-v1";
12
+ const MEMBER_FIELDS = new Set([
13
+ "artifacts",
14
+ "run",
15
+ "run_instance_id",
16
+ "state_dir",
17
+ "status",
18
+ "summary",
19
+ "terminal_at",
20
+ ]);
21
+ const BATCH_FIELDS = new Set([
22
+ "attempts",
23
+ "batch_id",
24
+ "created_at",
25
+ "last_error",
26
+ "last_failed_at",
27
+ "members",
28
+ "phase",
29
+ "presented_at",
30
+ "queued_at",
31
+ ]);
32
+ const JOURNAL_FIELDS = new Set([
33
+ "completion_batch",
34
+ "owner_id",
35
+ "receipts",
36
+ "schema",
37
+ "steers",
38
+ ]);
39
+ const RECEIPT_FIELDS = new Set([
40
+ "delivery_id",
41
+ "kind",
42
+ "presented_at",
43
+ ]);
44
+ const STEER_FIELDS = new Set([
45
+ "attempts",
46
+ "content",
47
+ "created_at",
48
+ "event_id",
49
+ "kind",
50
+ "last_error",
51
+ "last_failed_at",
52
+ "level",
53
+ "occurred_at",
54
+ "phase",
55
+ "presented_at",
56
+ "queued_at",
57
+ "run",
58
+ "run_instance_id",
59
+ "state_dir",
60
+ "steer_id",
61
+ ]);
62
+ function assertExactFields(value, fields, label) {
63
+ const unknown = Object.keys(value).find((key) => !fields.has(key));
64
+ if (unknown)
65
+ throw new Error(`${label} has unknown field: ${unknown}`);
66
+ }
67
+ function asRecord(value, label) {
68
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
69
+ throw new Error(`${label} must be an object`);
70
+ }
71
+ return value;
72
+ }
73
+ function boundedString(value, label, maxLength) {
74
+ if (typeof value !== "string" || !value.trim()) {
75
+ throw new Error(`${label} must be a non-empty string`);
76
+ }
77
+ if (value.length > maxLength) {
78
+ throw new Error(`${label} exceeds ${maxLength} characters`);
79
+ }
80
+ return value;
81
+ }
82
+ function timestamp(value, label) {
83
+ const text = boundedString(value, label, 64);
84
+ if (Number.isNaN(Date.parse(text)))
85
+ throw new Error(`${label} must be a timestamp`);
86
+ return text;
87
+ }
88
+ function completionStatus(value) {
89
+ if (value === "done" || value === "failed" || value === "killed" || value === "exited") {
90
+ return value;
91
+ }
92
+ throw new Error("Completion batch member status is invalid");
93
+ }
94
+ function artifacts(value) {
95
+ if (value === undefined)
96
+ return undefined;
97
+ const record = asRecord(value, "Completion batch member artifacts");
98
+ const entries = Object.entries(record);
99
+ if (entries.length > 4)
100
+ throw new Error("Completion batch member has too many artifacts");
101
+ return Object.fromEntries(entries.map(([name, path]) => [
102
+ boundedString(name, "Completion batch artifact name", 120),
103
+ boundedString(path, "Completion batch artifact path", 4_096),
104
+ ]));
105
+ }
106
+ function parseMember(value) {
107
+ const record = asRecord(value, "Completion batch member");
108
+ assertExactFields(record, MEMBER_FIELDS, "Completion batch member");
109
+ const parsedArtifacts = artifacts(record.artifacts);
110
+ return {
111
+ ...(parsedArtifacts ? { artifacts: parsedArtifacts } : {}),
112
+ run: boundedString(record.run, "Completion batch member run", 120),
113
+ run_instance_id: boundedString(record.run_instance_id, "Completion batch member run_instance_id", 256),
114
+ state_dir: boundedString(record.state_dir, "Completion batch member state_dir", 4_096),
115
+ status: completionStatus(record.status),
116
+ summary: boundedString(record.summary, "Completion batch member summary", 1_000),
117
+ terminal_at: timestamp(record.terminal_at, "Completion batch member terminal_at"),
118
+ };
119
+ }
120
+ function parseBatch(value) {
121
+ const record = asRecord(value, "Completion batch");
122
+ assertExactFields(record, BATCH_FIELDS, "Completion batch");
123
+ if (!Array.isArray(record.members) || record.members.length === 0) {
124
+ throw new Error("Completion batch members must be a non-empty array");
125
+ }
126
+ if (record.members.length > Limits.RUN_DELIVERY_BATCH_MAX_MEMBERS) {
127
+ throw new Error("Completion batch exceeds the member limit");
128
+ }
129
+ const members = record.members.map(parseMember);
130
+ const identities = new Set();
131
+ for (const member of members) {
132
+ const identity = `${member.state_dir}\0${member.run_instance_id}`;
133
+ if (identities.has(identity))
134
+ throw new Error("Completion batch has duplicate generation members");
135
+ identities.add(identity);
136
+ }
137
+ const phase = record.phase;
138
+ if (phase !== "pending" && phase !== "queued" && phase !== "presented") {
139
+ throw new Error("Completion batch phase is invalid");
140
+ }
141
+ const queuedAt = record.queued_at === undefined
142
+ ? undefined
143
+ : timestamp(record.queued_at, "Completion batch queued_at");
144
+ const presentedAt = record.presented_at === undefined
145
+ ? undefined
146
+ : timestamp(record.presented_at, "Completion batch presented_at");
147
+ const attempts = record.attempts;
148
+ if (attempts !== undefined &&
149
+ (typeof attempts !== "number" ||
150
+ !Number.isSafeInteger(attempts) ||
151
+ attempts < 0 ||
152
+ attempts > 1_000_000))
153
+ throw new Error("Completion batch attempts are invalid");
154
+ const lastError = record.last_error === undefined
155
+ ? undefined
156
+ : boundedString(record.last_error, "Completion batch last_error", 4_096);
157
+ const lastFailedAt = record.last_failed_at === undefined
158
+ ? undefined
159
+ : timestamp(record.last_failed_at, "Completion batch last_failed_at");
160
+ if (Boolean(lastError) !== Boolean(lastFailedAt)) {
161
+ throw new Error("Completion batch failure evidence is incomplete");
162
+ }
163
+ if (phase === "pending" && (queuedAt || presentedAt)) {
164
+ throw new Error("Pending completion batch cannot have delivery timestamps");
165
+ }
166
+ if (phase === "queued" && (!queuedAt || presentedAt)) {
167
+ throw new Error("Queued completion batch must have only queued_at");
168
+ }
169
+ if (phase === "presented" && (!queuedAt || !presentedAt)) {
170
+ throw new Error("Presented completion batch requires queued_at and presented_at");
171
+ }
172
+ return {
173
+ ...(attempts !== undefined ? { attempts } : {}),
174
+ batch_id: boundedString(record.batch_id, "Completion batch id", 128),
175
+ created_at: timestamp(record.created_at, "Completion batch created_at"),
176
+ ...(lastError ? { last_error: lastError } : {}),
177
+ ...(lastFailedAt ? { last_failed_at: lastFailedAt } : {}),
178
+ members,
179
+ phase,
180
+ ...(queuedAt ? { queued_at: queuedAt } : {}),
181
+ ...(presentedAt ? { presented_at: presentedAt } : {}),
182
+ };
183
+ }
184
+ function parseSteer(value) {
185
+ const record = asRecord(value, "Urgent steer envelope");
186
+ assertExactFields(record, STEER_FIELDS, "Urgent steer envelope");
187
+ const phase = record.phase;
188
+ if (phase !== "pending" && phase !== "queued" && phase !== "presented") {
189
+ throw new Error("Urgent steer phase is invalid");
190
+ }
191
+ const queuedAt = record.queued_at === undefined
192
+ ? undefined
193
+ : timestamp(record.queued_at, "Urgent steer queued_at");
194
+ const presentedAt = record.presented_at === undefined
195
+ ? undefined
196
+ : timestamp(record.presented_at, "Urgent steer presented_at");
197
+ if (phase === "pending" && (queuedAt || presentedAt)) {
198
+ throw new Error("Pending urgent steer cannot have delivery timestamps");
199
+ }
200
+ if (phase === "queued" && (!queuedAt || presentedAt)) {
201
+ throw new Error("Queued urgent steer must have only queued_at");
202
+ }
203
+ if (phase === "presented" && (!queuedAt || !presentedAt)) {
204
+ throw new Error("Presented urgent steer requires queued_at and presented_at");
205
+ }
206
+ const attempts = record.attempts;
207
+ if (attempts !== undefined &&
208
+ (typeof attempts !== "number" ||
209
+ !Number.isSafeInteger(attempts) ||
210
+ attempts < 0 ||
211
+ attempts > 1_000_000))
212
+ throw new Error("Urgent steer attempts are invalid");
213
+ const lastError = record.last_error === undefined
214
+ ? undefined
215
+ : boundedString(record.last_error, "Urgent steer last_error", 4_096);
216
+ const lastFailedAt = record.last_failed_at === undefined
217
+ ? undefined
218
+ : timestamp(record.last_failed_at, "Urgent steer last_failed_at");
219
+ if (Boolean(lastError) !== Boolean(lastFailedAt)) {
220
+ throw new Error("Urgent steer failure evidence is incomplete");
221
+ }
222
+ const content = boundedString(record.content, "Urgent steer content", Limits.RUN_DELIVERY_STEER_MAX_BYTES);
223
+ if (Buffer.byteLength(content, "utf8") > Limits.RUN_DELIVERY_STEER_MAX_BYTES) {
224
+ throw new Error("Urgent steer content exceeds the byte limit");
225
+ }
226
+ const level = record.level;
227
+ if (level !== "info" && level !== "warning" && level !== "error") {
228
+ throw new Error("Urgent steer level is invalid");
229
+ }
230
+ return {
231
+ ...(attempts !== undefined ? { attempts } : {}),
232
+ content,
233
+ created_at: timestamp(record.created_at, "Urgent steer created_at"),
234
+ event_id: boundedString(record.event_id, "Urgent steer event_id", 256),
235
+ kind: boundedString(record.kind, "Urgent steer kind", 256),
236
+ ...(lastError ? { last_error: lastError } : {}),
237
+ ...(lastFailedAt ? { last_failed_at: lastFailedAt } : {}),
238
+ level,
239
+ occurred_at: timestamp(record.occurred_at, "Urgent steer occurred_at"),
240
+ phase,
241
+ ...(presentedAt ? { presented_at: presentedAt } : {}),
242
+ ...(queuedAt ? { queued_at: queuedAt } : {}),
243
+ run: boundedString(record.run, "Urgent steer run", 120),
244
+ run_instance_id: boundedString(record.run_instance_id, "Urgent steer run_instance_id", 256),
245
+ state_dir: boundedString(record.state_dir, "Urgent steer state_dir", 4_096),
246
+ steer_id: boundedString(record.steer_id, "Urgent steer id", 128),
247
+ };
248
+ }
249
+ function parseReceipt(value) {
250
+ const record = asRecord(value, "Run delivery receipt");
251
+ assertExactFields(record, RECEIPT_FIELDS, "Run delivery receipt");
252
+ if (record.kind !== "completion_batch" && record.kind !== "urgent_steer") {
253
+ throw new Error("Run delivery receipt kind is invalid");
254
+ }
255
+ return {
256
+ delivery_id: boundedString(record.delivery_id, "Run delivery receipt id", 128),
257
+ kind: record.kind,
258
+ presented_at: timestamp(record.presented_at, "Run delivery receipt presented_at"),
259
+ };
260
+ }
261
+ function emptyJournal(ownerId) {
262
+ return {
263
+ owner_id: ownerId,
264
+ receipts: [],
265
+ schema: RUN_DELIVERY_SCHEMA,
266
+ steers: [],
267
+ };
268
+ }
269
+ function parseJournal(value, ownerId) {
270
+ const record = asRecord(value, "Run delivery journal");
271
+ assertExactFields(record, JOURNAL_FIELDS, "Run delivery journal");
272
+ if (record.schema !== RUN_DELIVERY_SCHEMA) {
273
+ throw new Error("Run delivery journal schema is invalid");
274
+ }
275
+ if (record.owner_id !== ownerId) {
276
+ throw new Error("Run delivery journal owner does not match the active owner");
277
+ }
278
+ if (!Array.isArray(record.receipts) || record.receipts.length > Limits.RUN_DELIVERY_RECEIPT_LIMIT) {
279
+ throw new Error("Run delivery journal receipts are invalid");
280
+ }
281
+ const receipts = record.receipts.map(parseReceipt);
282
+ if (new Set(receipts.map((receipt) => `${receipt.kind}\0${receipt.delivery_id}`)).size !== receipts.length) {
283
+ throw new Error("Run delivery journal has duplicate receipts");
284
+ }
285
+ const rawSteers = record.steers === undefined ? [] : record.steers;
286
+ if (!Array.isArray(rawSteers) ||
287
+ rawSteers.length > Limits.RUN_DELIVERY_STEER_MAX_ENVELOPES)
288
+ throw new Error("Run delivery journal urgent steers are invalid");
289
+ const steers = rawSteers.map(parseSteer);
290
+ if (new Set(steers.map((steer) => steer.steer_id)).size !== steers.length) {
291
+ throw new Error("Run delivery journal has duplicate urgent steer ids");
292
+ }
293
+ return {
294
+ ...(record.completion_batch !== undefined
295
+ ? { completion_batch: parseBatch(record.completion_batch) }
296
+ : {}),
297
+ owner_id: ownerId,
298
+ receipts,
299
+ schema: RUN_DELIVERY_SCHEMA,
300
+ steers,
301
+ };
302
+ }
303
+ function ownerKey(ownerId) {
304
+ const exact = boundedString(ownerId, "Run delivery owner", 4_096);
305
+ return createHash("sha256").update(exact).digest("hex");
306
+ }
307
+ export function getRunDeliveryJournalPath(tempDir, ownerId) {
308
+ return join(tempDir, "delivery", ownerKey(ownerId), "projection.json");
309
+ }
310
+ function assertNoDeliverySymlinks(tempDir, path) {
311
+ for (const candidate of [join(tempDir, "delivery"), dirname(path), path]) {
312
+ if (existsSync(candidate) && lstatSync(candidate).isSymbolicLink()) {
313
+ throw new Error(`Run delivery state rejects symbolic link: ${candidate}`);
314
+ }
315
+ }
316
+ }
317
+ function readJournalAtPath(path, ownerId) {
318
+ if (!existsSync(path))
319
+ return emptyJournal(ownerId);
320
+ const stat = statSync(path);
321
+ if (!stat.isFile() || stat.size > Limits.RUN_DELIVERY_JOURNAL_MAX_BYTES) {
322
+ throw new Error("Run delivery journal is not a bounded regular file");
323
+ }
324
+ let parsed;
325
+ try {
326
+ parsed = JSON.parse(readFileSync(path, "utf8"));
327
+ }
328
+ catch (error) {
329
+ throw new Error("Run delivery journal is malformed", { cause: error });
330
+ }
331
+ return parseJournal(parsed, ownerId);
332
+ }
333
+ export function readRunDeliveryJournal(tempDir, ownerId) {
334
+ const path = getRunDeliveryJournalPath(tempDir, ownerId);
335
+ assertNoDeliverySymlinks(tempDir, path);
336
+ return readJournalAtPath(path, ownerId);
337
+ }
338
+ function persistJournal(path, journal) {
339
+ const content = `${JSON.stringify(journal, null, 2)}\n`;
340
+ if (Buffer.byteLength(content, "utf8") > Limits.RUN_DELIVERY_JOURNAL_MAX_BYTES) {
341
+ throw new Error("Run delivery journal exceeds the byte limit");
342
+ }
343
+ writeTextAtomic(path, content);
344
+ }
345
+ function mutateJournal(tempDir, ownerId, mutate) {
346
+ const path = getRunDeliveryJournalPath(tempDir, ownerId);
347
+ return withFileMutationLock(path, () => {
348
+ assertNoDeliverySymlinks(tempDir, path);
349
+ const journal = readJournalAtPath(path, ownerId);
350
+ const result = mutate(journal);
351
+ persistJournal(path, parseJournal(journal, ownerId));
352
+ return result;
353
+ });
354
+ }
355
+ export function admitRunCompletionBatch(input) {
356
+ const now = (input.now ?? new Date()).toISOString();
357
+ const batch = parseBatch({
358
+ batch_id: input.batchId ?? randomUUID(),
359
+ created_at: now,
360
+ members: input.members,
361
+ phase: "pending",
362
+ });
363
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
364
+ if (journal.completion_batch) {
365
+ throw new Error("Run delivery journal already has an active completion batch");
366
+ }
367
+ if (journal.receipts.some((receipt) => receipt.delivery_id === batch.batch_id)) {
368
+ throw new Error("Run delivery batch id already has a presented receipt");
369
+ }
370
+ journal.completion_batch = batch;
371
+ return batch;
372
+ });
373
+ }
374
+ export function markRunCompletionBatchQueued(input) {
375
+ const now = (input.now ?? new Date()).toISOString();
376
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
377
+ const batch = journal.completion_batch;
378
+ if (!batch || batch.batch_id !== input.batchId)
379
+ return false;
380
+ if (batch.phase === "queued" || batch.phase === "presented")
381
+ return true;
382
+ batch.phase = "queued";
383
+ batch.queued_at = now;
384
+ return true;
385
+ });
386
+ }
387
+ export function resetRunCompletionBatchPending(input) {
388
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
389
+ const batch = journal.completion_batch;
390
+ if (!batch || batch.batch_id !== input.batchId || batch.phase === "presented") {
391
+ return false;
392
+ }
393
+ if (batch.phase === "pending")
394
+ return true;
395
+ batch.phase = "pending";
396
+ delete batch.queued_at;
397
+ return true;
398
+ });
399
+ }
400
+ export function recordRunCompletionBatchDeliveryFailure(input) {
401
+ const now = (input.now ?? new Date()).toISOString();
402
+ const raw = input.error instanceof Error ? input.error.message : String(input.error);
403
+ const error = raw.replaceAll(/\s+/g, " ").trim().slice(0, 4_096) ||
404
+ "Unknown completion delivery failure";
405
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
406
+ const batch = journal.completion_batch;
407
+ if (!batch || batch.batch_id !== input.batchId || batch.phase !== "pending") {
408
+ return false;
409
+ }
410
+ batch.attempts = Math.min((batch.attempts ?? 0) + 1, 1_000_000);
411
+ batch.last_error = error;
412
+ batch.last_failed_at = now;
413
+ return true;
414
+ });
415
+ }
416
+ export function markRunCompletionBatchPresented(input) {
417
+ const now = (input.now ?? new Date()).toISOString();
418
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
419
+ const batch = journal.completion_batch;
420
+ if (!batch || batch.batch_id !== input.batchId || batch.phase === "pending")
421
+ return false;
422
+ if (batch.phase === "presented")
423
+ return true;
424
+ batch.phase = "presented";
425
+ batch.presented_at = now;
426
+ return true;
427
+ });
428
+ }
429
+ export function finalizeRunCompletionBatch(input) {
430
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
431
+ const batch = journal.completion_batch;
432
+ if (!batch || batch.batch_id !== input.batchId) {
433
+ return journal.receipts.some((receipt) => receipt.delivery_id === input.batchId);
434
+ }
435
+ if (batch.phase !== "presented" || !batch.presented_at)
436
+ return false;
437
+ journal.receipts = [
438
+ ...journal.receipts.filter((receipt) => receipt.delivery_id !== batch.batch_id),
439
+ {
440
+ delivery_id: batch.batch_id,
441
+ kind: "completion_batch",
442
+ presented_at: batch.presented_at,
443
+ },
444
+ ].slice(-Limits.RUN_DELIVERY_RECEIPT_LIMIT);
445
+ delete journal.completion_batch;
446
+ return true;
447
+ });
448
+ }
449
+ export function getRunSteerDeliveryId(input) {
450
+ return createHash("sha256").update(JSON.stringify([
451
+ input.stateDir,
452
+ input.runInstanceId,
453
+ input.eventId,
454
+ ])).digest("hex");
455
+ }
456
+ export function admitRunSteerEnvelope(input) {
457
+ const steer = parseSteer({
458
+ content: input.content,
459
+ created_at: (input.now ?? new Date()).toISOString(),
460
+ event_id: input.eventId,
461
+ kind: input.kind,
462
+ level: input.level,
463
+ occurred_at: input.occurredAt,
464
+ phase: "pending",
465
+ run: input.run,
466
+ run_instance_id: input.runInstanceId,
467
+ state_dir: input.stateDir,
468
+ steer_id: input.steerId ?? getRunSteerDeliveryId(input),
469
+ });
470
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
471
+ const existing = journal.steers.find((item) => item.steer_id === steer.steer_id);
472
+ if (existing) {
473
+ if (existing.content !== steer.content ||
474
+ existing.event_id !== steer.event_id ||
475
+ existing.run_instance_id !== steer.run_instance_id ||
476
+ existing.state_dir !== steer.state_dir)
477
+ throw new Error("Urgent steer id conflicts with a different envelope");
478
+ return existing;
479
+ }
480
+ if (journal.receipts.some((receipt) => receipt.kind === "urgent_steer" && receipt.delivery_id === steer.steer_id)) {
481
+ return undefined;
482
+ }
483
+ if (journal.steers.length >= Limits.RUN_DELIVERY_STEER_MAX_ENVELOPES) {
484
+ throw Object.assign(new Error("Run delivery urgent steer capacity is full"), {
485
+ code: "RUN_STEER_CAPACITY",
486
+ });
487
+ }
488
+ journal.steers.push(steer);
489
+ return steer;
490
+ });
491
+ }
492
+ function mutateSteer(input, mutate) {
493
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
494
+ const steer = journal.steers.find((item) => item.steer_id === input.steerId);
495
+ return steer ? mutate(steer) : false;
496
+ });
497
+ }
498
+ export function markRunSteerQueued(input) {
499
+ const now = (input.now ?? new Date()).toISOString();
500
+ return mutateSteer(input, (steer) => {
501
+ if (steer.phase === "queued" || steer.phase === "presented")
502
+ return true;
503
+ steer.phase = "queued";
504
+ steer.queued_at = now;
505
+ return true;
506
+ });
507
+ }
508
+ export function resetRunSteerPending(input) {
509
+ return mutateSteer(input, (steer) => {
510
+ if (steer.phase === "presented")
511
+ return false;
512
+ if (steer.phase === "pending")
513
+ return true;
514
+ steer.phase = "pending";
515
+ delete steer.queued_at;
516
+ return true;
517
+ });
518
+ }
519
+ export function recordRunSteerDeliveryFailure(input) {
520
+ const now = (input.now ?? new Date()).toISOString();
521
+ const raw = input.error instanceof Error ? input.error.message : String(input.error);
522
+ const error = raw.replaceAll(/\s+/g, " ").trim().slice(0, 4_096) ||
523
+ "Unknown urgent steer delivery failure";
524
+ return mutateSteer(input, (steer) => {
525
+ if (steer.phase !== "pending")
526
+ return false;
527
+ steer.attempts = Math.min((steer.attempts ?? 0) + 1, 1_000_000);
528
+ steer.last_error = error;
529
+ steer.last_failed_at = now;
530
+ return true;
531
+ });
532
+ }
533
+ export function markRunSteerPresented(input) {
534
+ const now = (input.now ?? new Date()).toISOString();
535
+ return mutateSteer(input, (steer) => {
536
+ if (steer.phase === "pending")
537
+ return false;
538
+ if (steer.phase === "presented")
539
+ return true;
540
+ steer.phase = "presented";
541
+ steer.presented_at = now;
542
+ return true;
543
+ });
544
+ }
545
+ export function finalizeRunSteer(input) {
546
+ return mutateJournal(input.tempDir, input.ownerId, (journal) => {
547
+ const index = journal.steers.findIndex((item) => item.steer_id === input.steerId);
548
+ if (index < 0)
549
+ return journal.receipts.some((receipt) => receipt.kind === "urgent_steer" && receipt.delivery_id === input.steerId);
550
+ const steer = journal.steers[index];
551
+ if (steer.phase !== "presented" || !steer.presented_at)
552
+ return false;
553
+ journal.steers.splice(index, 1);
554
+ journal.receipts = [
555
+ ...journal.receipts.filter((receipt) => receipt.kind !== "urgent_steer" || receipt.delivery_id !== steer.steer_id),
556
+ {
557
+ delivery_id: steer.steer_id,
558
+ kind: "urgent_steer",
559
+ presented_at: steer.presented_at,
560
+ },
561
+ ].slice(-Limits.RUN_DELIVERY_RECEIPT_LIMIT);
562
+ return true;
563
+ });
564
+ }
565
+ function compactModelText(value, limit) {
566
+ const compact = value.replaceAll(/\s+/g, " ").replaceAll("`", "'").trim();
567
+ return compact.length > limit ? `${compact.slice(0, limit - 1)}…` : compact;
568
+ }
569
+ function formatCompletionMember(member) {
570
+ const summary = compactModelText(member.summary, 200);
571
+ const artifactEntries = Object.entries(member.artifacts ?? {}).slice(0, 4);
572
+ const artifactText = artifactEntries.length === 0
573
+ ? ""
574
+ : `; artifacts: ${artifactEntries.map(([name, path]) => `${compactModelText(name, 120)}=\`${compactModelText(path, 320)}\``).join(", ")}`;
575
+ return `- \`${compactModelText(member.run, 120)}\` — \`${member.status}\`: ${summary}${artifactText}`;
576
+ }
577
+ function formatStatusCounts(members) {
578
+ const counts = new Map();
579
+ for (const member of members) {
580
+ counts.set(member.status, (counts.get(member.status) ?? 0) + 1);
581
+ }
582
+ return ["done", "failed", "killed", "exited"]
583
+ .filter((status) => counts.has(status))
584
+ .map((status) => `${status}=${counts.get(status)}`)
585
+ .join(" ");
586
+ }
587
+ /** Format one immutable batch for a model-bound custom message. */
588
+ export function formatRunCompletionBatchMessage(batch) {
589
+ const safe = parseBatch(batch);
590
+ const terminalTimes = safe.members.map((member) => member.terminal_at).sort();
591
+ const header = [
592
+ `Actor completions: ${safe.members.length}`,
593
+ `Batch: \`${compactModelText(safe.batch_id, 128)}\``,
594
+ `Window: \`${terminalTimes[0]}\` → \`${terminalTimes.at(-1)}\``,
595
+ `Statuses: \`${formatStatusCounts(safe.members)}\``,
596
+ ];
597
+ const candidateRows = safe.members
598
+ .slice(0, Limits.RUN_DELIVERY_MODEL_MAX_MEMBERS)
599
+ .map(formatCompletionMember);
600
+ const rows = [];
601
+ for (const row of candidateRows) {
602
+ const omitted = safe.members.length - rows.length - 1;
603
+ const candidate = [
604
+ ...header,
605
+ ...rows,
606
+ row,
607
+ ...(omitted > 0 ? [`… ${omitted} more completion(s) retained in batch.`] : []),
608
+ ].join("\n");
609
+ if (Buffer.byteLength(candidate, "utf8") > Limits.RUN_DELIVERY_MODEL_MAX_BYTES)
610
+ break;
611
+ rows.push(row);
612
+ }
613
+ const omitted = safe.members.length - rows.length;
614
+ const message = [
615
+ ...header,
616
+ ...rows,
617
+ ...(omitted > 0 ? [`… ${omitted} more completion(s) retained in batch.`] : []),
618
+ ].join("\n");
619
+ if (Buffer.byteLength(message, "utf8") > Limits.RUN_DELIVERY_MODEL_MAX_BYTES) {
620
+ throw new Error("Run completion batch header exceeds the model byte limit");
621
+ }
622
+ return message;
623
+ }
@@ -8,6 +8,8 @@ import * as Observability from "./observability.ts";
8
8
  import * as Pi from "./pi.ts";
9
9
  export interface RunUiRuntime {
10
10
  close(): void;
11
+ flushCompletionBatch(ctx: Pi.ExtensionContext): boolean;
12
+ projectContext(messages: unknown[], ctx: Pi.ExtensionContext): unknown[];
11
13
  shutdown(eventReason: string, ownerId: string | undefined, ctx?: Pi.ExtensionContext): void;
12
14
  start(ctx: Pi.ExtensionContext, ownerId: string): void;
13
15
  }
@@ -15,6 +17,7 @@ export interface RunUiRuntimeDeps {
15
17
  animationIntervalMs?: number;
16
18
  createRunStateWatcher?: typeof Observability.createRunStateWatcher;
17
19
  createRunTerminalReconciliationLoop?: typeof Observability.createRunTerminalReconciliationLoop;
20
+ deliveryDebounceMs?: number;
18
21
  getActiveContext(): Pi.ExtensionContext | undefined;
19
22
  notificationDelayMs?: number;
20
23
  onCallbackError?: (error: unknown) => void;