@mono-agent/web 0.20.10 → 0.20.14

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 (43) hide show
  1. package/README.md +19 -8
  2. package/dist/contracts.d.ts +82 -2
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js.map +1 -1
  5. package/dist/effort-ladder.d.ts +97 -0
  6. package/dist/effort-ladder.d.ts.map +1 -0
  7. package/dist/effort-ladder.js +114 -0
  8. package/dist/effort-ladder.js.map +1 -0
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/notification-client.d.ts +11 -2
  13. package/dist/notification-client.d.ts.map +1 -1
  14. package/dist/notification-client.js +1 -1
  15. package/dist/notification-client.js.map +1 -1
  16. package/dist/notification-ingress.d.ts +8 -1
  17. package/dist/notification-ingress.d.ts.map +1 -1
  18. package/dist/notification-ingress.js +28 -6
  19. package/dist/notification-ingress.js.map +1 -1
  20. package/dist/operator-client.d.ts +10 -1
  21. package/dist/operator-client.d.ts.map +1 -1
  22. package/dist/operator-client.js +110 -2
  23. package/dist/operator-client.js.map +1 -1
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +52 -3
  26. package/dist/server.js.map +1 -1
  27. package/dist/service.d.ts +117 -12
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +661 -69
  30. package/dist/service.js.map +1 -1
  31. package/dist/store.d.ts +124 -6
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +543 -34
  34. package/dist/store.js.map +1 -1
  35. package/package.json +5 -7
  36. package/webapp/dist/assets/{assistant-ui-CxqQJmH9.js → assistant-ui-BzN2E6n6.js} +1 -1
  37. package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
  38. package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
  39. package/webapp/dist/assets/{markdown-8pwMczg6.js → markdown-Vq23xgh7.js} +1 -1
  40. package/webapp/dist/index.html +4 -4
  41. package/webapp/dist/sw.js +1 -1
  42. package/webapp/dist/assets/index-DG1ELH22.css +0 -1
  43. package/webapp/dist/assets/index-j6VTEsJ5.js +0 -155
package/dist/store.js CHANGED
@@ -3,7 +3,7 @@ import { chmod, lstat, readdir, unlink } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { DatabaseSync } from "node:sqlite";
5
5
  import { isDeepStrictEqual } from "node:util";
6
- import { AGENT_LIVE_INPUT_MAX_CHARACTERS, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
6
+ import { AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
7
7
  import { WEB_MAX_FILES_PER_TURN, WEB_MAX_LIVE_INPUTS_PER_THREAD, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_MAX_TURN_TEXT_CHARACTERS, } from "./contracts.js";
8
8
  import { WebConsoleError } from "./errors.js";
9
9
  import { webPushPreview } from "./push-preview.js";
@@ -118,7 +118,7 @@ export function messageSearchMatchExpression(raw) {
118
118
  export function escapeLikeTerm(raw) {
119
119
  return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
120
120
  }
121
- const WEB_STORAGE_SCHEMA_VERSION = 9;
121
+ const WEB_STORAGE_SCHEMA_VERSION = 15;
122
122
  const MAX_REVISIONS_PER_THREAD = 1_000;
123
123
  export const WEB_THREAD_PAGE_MAX = 200;
124
124
  export const WEB_MESSAGE_PAGE_MAX = 100;
@@ -133,6 +133,14 @@ export class WebStore {
133
133
  database;
134
134
  clock;
135
135
  closed = false;
136
+ /**
137
+ * The agent PROCESS generation each live summary was built from, by source
138
+ * id. Deliberately not a column: it describes the process behind a source id
139
+ * right now, so a value read back from disk after a console restart would be
140
+ * a claim about a process nobody probed. `replaceAgents` is the only writer,
141
+ * and it drops ids discovery no longer reports.
142
+ */
143
+ agentGenerations = new Map();
136
144
  constructor(database, paths, clock) {
137
145
  this.database = database;
138
146
  this.paths = paths;
@@ -179,24 +187,77 @@ export class WebStore {
179
187
  this.closed = true;
180
188
  this.database.close();
181
189
  }
190
+ /**
191
+ * Persist the discovered agent list. Returns whether the change is worth
192
+ * telling clients about.
193
+ *
194
+ * `updatedAt` is a heartbeat timestamp that moves on every discovery poll, so
195
+ * comparing it made this return `true` roughly once every five seconds. At the
196
+ * time, `agents.changed` carried the whole agent list, every model, every model
197
+ * option and every provider. Measured against a 67-agent fleet that was 60 KB
198
+ * per event and 99.5% of all SSE traffic: ~42 MiB/hour to an idle console with
199
+ * nobody using it. The event is now a compact invalidation, but a heartbeat is
200
+ * still not a state change worth making every browser re-bootstrap for.
201
+ *
202
+ * So the two questions are separated. Any difference at all is still written,
203
+ * because the store should hold the freshest heartbeat; only a difference that
204
+ * survives normalizing `updatedAt` is broadcast. Nothing in the console reads
205
+ * an agent's `updatedAt` --- it stays on the wire for clients that want it, it
206
+ * just no longer triggers a fleet-sized frame on its own. `agentGeneration()`
207
+ * already excludes it for the same reason.
208
+ */
182
209
  replaceAgents(agents) {
183
210
  const current = this.listAgents();
184
211
  const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
185
212
  const incomingIds = new Set(agents.map((agent) => agent.sourceId));
186
- const changed = agents.some((agent) => {
213
+ const departed = current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
214
+ const differs = (agent, ignoreHeartbeat) => {
187
215
  const prior = currentById.get(agent.sourceId);
188
- return prior === undefined || !isDeepStrictEqual(prior, { ...agent, pinned: prior.pinned });
189
- }) || current.some((agent) => !incomingIds.has(agent.sourceId) && agent.status !== "offline");
190
- if (!changed)
216
+ if (prior === undefined)
217
+ return true;
218
+ // `pinned` is store-owned and never arrives from discovery; normalizing it
219
+ // keeps a locally pinned agent from looking like an incoming change.
220
+ const next = { ...agent, pinned: prior.pinned };
221
+ if (!ignoreHeartbeat)
222
+ return !isDeepStrictEqual(prior, next);
223
+ return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
224
+ };
225
+ const changed = agents.some((agent) => differs(agent, false)) || departed;
226
+ const notable = agents.some((agent) => differs(agent, true)) || departed;
227
+ // After `current` is read (it carries the PREVIOUS generations) and after
228
+ // both comparisons, so a restart behind an otherwise identical summary
229
+ // still reads as a change and still reaches the browser.
230
+ //
231
+ // And only once the row it describes is actually on disk. Advancing before
232
+ // the transaction made the map a claim the database had not agreed to: a
233
+ // transaction that threw left the NEW generation stitched onto the OLD row,
234
+ // so the retry compared the new summary against a prior that already
235
+ // carried its generation, found only `updatedAt` different, and returned
236
+ // `notable === false`. The restart broadcast was then lost permanently ---
237
+ // not deferred --- and an open console stayed stale until it reconnected.
238
+ const adoptGenerations = () => {
239
+ this.agentGenerations.clear();
240
+ for (const agent of agents) {
241
+ if (agent.generation !== undefined)
242
+ this.agentGenerations.set(agent.sourceId, agent.generation);
243
+ }
244
+ };
245
+ if (!changed) {
246
+ // Nothing to persist, so there is nothing for the adoption to outrun:
247
+ // every incoming generation already equals the one it is replacing, or
248
+ // `changed` would be true. The map is still swept so a departed agent
249
+ // does not leave one behind.
250
+ adoptGenerations();
191
251
  return false;
252
+ }
192
253
  this.transaction(() => {
193
254
  this.database.prepare("UPDATE agents SET status = 'offline'").run();
194
255
  const statement = this.database.prepare(`
195
256
  INSERT INTO agents (
196
257
  source_id, label, status, health, supports_attachments, models_json,
197
258
  default_model, default_effort, efforts_json, model_options_json,
198
- cron_read, cron_actions, ask_by_id, updated_at
199
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
259
+ providers_json, cron_read, cron_actions, ask_by_id, updated_at
260
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
200
261
  ON CONFLICT(source_id) DO UPDATE SET
201
262
  label = excluded.label,
202
263
  status = excluded.status,
@@ -207,24 +268,31 @@ export class WebStore {
207
268
  default_effort = excluded.default_effort,
208
269
  efforts_json = excluded.efforts_json,
209
270
  model_options_json = excluded.model_options_json,
271
+ providers_json = excluded.providers_json,
210
272
  cron_read = excluded.cron_read,
211
273
  cron_actions = excluded.cron_actions,
212
274
  ask_by_id = excluded.ask_by_id,
213
275
  updated_at = excluded.updated_at
214
276
  `);
215
277
  for (const agent of agents) {
216
- statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
278
+ statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, stringifyOptional(agent.models), agent.defaultModel ?? null, agent.defaultEffort ?? null, stringifyOptional(agent.efforts), stringifyOptional(agent.modelOptions), stringifyOptional(agent.providers), agent.cron?.read === true ? 1 : 0, agent.cron?.actions === true ? 1 : 0, agent.supportsAskById === true ? 1 : 0, agent.updatedAt);
217
279
  }
218
280
  });
219
- return true;
281
+ adoptGenerations();
282
+ return notable;
220
283
  }
221
284
  listAgents() {
222
285
  const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
223
- return rows.map(mapAgent);
286
+ return rows.map((row) => this.withGeneration(mapAgent(row)));
224
287
  }
225
288
  getAgent(sourceId) {
226
289
  const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
227
- return row === undefined ? undefined : mapAgent(row);
290
+ return row === undefined ? undefined : this.withGeneration(mapAgent(row));
291
+ }
292
+ /** Stitch the live generation onto a row read back from disk. */
293
+ withGeneration(agent) {
294
+ const generation = this.agentGenerations.get(agent.sourceId);
295
+ return generation === undefined ? agent : { ...agent, generation };
228
296
  }
229
297
  setAgentPinned(sourceId, pinned) {
230
298
  if (this.getAgent(sourceId) === undefined) {
@@ -960,6 +1028,115 @@ export class WebStore {
960
1028
  WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
961
1029
  `).run(input.sourceId, input.jobId, input.deliveryKey);
962
1030
  }
1031
+ /** Durably claim one Monitor wake before touching the operator. */
1032
+ reserveMonitorWake(input) {
1033
+ const monitor = parseMonitorProjection(input.monitor);
1034
+ if (monitor.monitorId !== input.monitorId) {
1035
+ throw new WebConsoleError("invalid_notification", "The Monitor projection does not match its delivery identity.", 409);
1036
+ }
1037
+ const thread = this.database.prepare("SELECT source_id, archived_at, trigger_kind FROM threads WHERE id = ?")
1038
+ .get(input.threadId);
1039
+ if (thread === undefined || thread.source_id !== input.sourceId) {
1040
+ throw new WebConsoleError("invalid_notification", "The Monitor wake does not match its web thread.", 409);
1041
+ }
1042
+ if (thread.archived_at !== null || thread.trigger_kind !== null) {
1043
+ throw new WebConsoleError("thread_archived", "The Monitor wake destination is not an active web conversation.", 409);
1044
+ }
1045
+ const existing = this.database.prepare(`
1046
+ SELECT monitor_id, thread_id, payload_sha256, state, disposition
1047
+ FROM monitor_wake_deliveries WHERE source_id = ? AND delivery_key = ?
1048
+ `).get(input.sourceId, input.deliveryKey);
1049
+ if (existing !== undefined) {
1050
+ if (existing.monitor_id !== input.monitorId
1051
+ || existing.thread_id !== input.threadId
1052
+ || existing.payload_sha256 !== input.payloadSha256) {
1053
+ throw new WebConsoleError("notification_idempotency_conflict", "The Monitor delivery key was already used for a different wake.", 409);
1054
+ }
1055
+ if (existing.state === "completed"
1056
+ && (existing.disposition === "steered" || existing.disposition === "follow_up")) {
1057
+ return { kind: "completed", disposition: existing.disposition };
1058
+ }
1059
+ return { kind: "uncertain" };
1060
+ }
1061
+ this.database.prepare(`
1062
+ INSERT INTO monitor_wake_deliveries (
1063
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
1064
+ state, disposition, turn_id, created_at, completed_at
1065
+ ) VALUES (?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, ?, NULL)
1066
+ `).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
1067
+ return { kind: "new" };
1068
+ }
1069
+ completeMonitorWake(input) {
1070
+ let messageId;
1071
+ this.transaction(() => {
1072
+ const result = this.database.prepare(`
1073
+ UPDATE monitor_wake_deliveries
1074
+ SET state = 'completed', disposition = ?, turn_id = ?, completed_at = ?
1075
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
1076
+ `).run(input.disposition, input.turnId ?? null, this.now(), input.sourceId, input.monitorId, input.deliveryKey);
1077
+ if (result.changes !== 1) {
1078
+ throw new WebConsoleError("notification_reservation_lost", "The Monitor wake reservation was lost.", 409);
1079
+ }
1080
+ if (input.turnId === undefined)
1081
+ return;
1082
+ const turn = this.requireTurn(input.turnId);
1083
+ const reservation = this.database.prepare(`
1084
+ SELECT thread_id, projection_json FROM monitor_wake_deliveries
1085
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ?
1086
+ `).get(input.sourceId, input.monitorId, input.deliveryKey);
1087
+ if (reservation?.thread_id !== turn.thread_id) {
1088
+ throw new WebConsoleError("monitor_origin_mismatch", "The Monitor activity does not belong to this turn.", 409);
1089
+ }
1090
+ if (reservation.projection_json === null)
1091
+ return;
1092
+ let projection;
1093
+ try {
1094
+ projection = parseMonitorProjection(JSON.parse(reservation.projection_json));
1095
+ if (projection.monitorId !== input.monitorId)
1096
+ throw new TypeError("Monitor identity mismatch.");
1097
+ }
1098
+ catch {
1099
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1100
+ }
1101
+ const message = this.requireMessage(turn.assistant_message_id);
1102
+ const parts = [...message.parts];
1103
+ if (!upsertMonitorActivity(parts, projection, input.deliveryKey))
1104
+ return;
1105
+ const now = this.now();
1106
+ this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
1107
+ .run(serializeParts(parts), now, message.id);
1108
+ messageId = message.id;
1109
+ });
1110
+ return messageId === undefined ? undefined : this.requireMessage(messageId);
1111
+ }
1112
+ /** Resolve only an exact Monitor live-input receipt belonging to this turn. */
1113
+ monitorWakeProjection(turnId, deliveryKey) {
1114
+ const row = this.database.prepare(`
1115
+ SELECT deliveries.monitor_id, deliveries.projection_json
1116
+ FROM monitor_wake_deliveries AS deliveries
1117
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1118
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1119
+ WHERE deliveries.delivery_key = ?
1120
+ `).get(turnId, deliveryKey);
1121
+ if (row === undefined || row.projection_json === null)
1122
+ return undefined;
1123
+ try {
1124
+ const projection = parseMonitorProjection(JSON.parse(row.projection_json));
1125
+ if (projection.monitorId !== row.monitor_id)
1126
+ throw new TypeError("Monitor identity mismatch.");
1127
+ return projection;
1128
+ }
1129
+ catch {
1130
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1131
+ }
1132
+ }
1133
+ /** Release a Monitor reservation only before any operator delivery begins. */
1134
+ abandonMonitorWake(input) {
1135
+ this.database.prepare(`
1136
+ DELETE FROM monitor_wake_deliveries
1137
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
1138
+ `).run(input.sourceId, input.monitorId, input.deliveryKey);
1139
+ }
963
1140
  /** Exact retained binding used before proxying a single operator job. */
964
1141
  processJobCardBelongsToThread(sourceId, threadId, jobId) {
965
1142
  return this.database.prepare(`
@@ -1234,14 +1411,44 @@ export class WebStore {
1234
1411
  this.setSetting("current_thread_id", resolved);
1235
1412
  }
1236
1413
  patchThread(id, patch) {
1414
+ return this.transaction(() => this.writeThreadPatch(id, patch));
1415
+ }
1416
+ /**
1417
+ * Compare-and-set: apply `patch` only while the conversation still carries no
1418
+ * run override, and report which way it went.
1419
+ *
1420
+ * The console's one-time adoption of a browser-local override is the caller.
1421
+ * Read and write are ONE `BEGIN IMMEDIATE` here rather than two statements
1422
+ * either side of a service-level check: the process lease
1423
+ * (`state-paths.ts`) is held on a different database file for the service
1424
+ * lifetime, so it makes this process the only *service* writing, not this
1425
+ * statement the only writer of `web.sqlite`. Any other connection to the
1426
+ * state DB -- a maintenance script, a second console pointed at the same
1427
+ * state dir -- could land between a bare read and a bare write, and did in a
1428
+ * probe.
1429
+ */
1430
+ patchThreadIfRunConfigUnset(id, patch) {
1431
+ return this.transaction(() => {
1432
+ const resolved = this.resolveThreadId(id);
1433
+ const current = this.requireThread(resolved);
1434
+ if (current.runModel !== null || current.runEffort !== null) {
1435
+ return { applied: false, thread: { ...current, sourceId: current.sourceId } };
1436
+ }
1437
+ return { applied: true, thread: this.writeThreadPatch(resolved, patch) };
1438
+ });
1439
+ }
1440
+ /** The body of {@link patchThread}. Assumes an open transaction. */
1441
+ writeThreadPatch(id, patch) {
1237
1442
  id = this.resolveThreadId(id);
1238
1443
  const current = this.requireThread(id);
1239
1444
  const now = this.now();
1240
1445
  const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
1241
1446
  const archivedAt = patch.archived === undefined ? undefined : patch.archived ? now : null;
1242
- this.transaction(() => {
1243
- const sets = ["updated_at = ?", "revision = revision + 1"];
1244
- const values = [now];
1447
+ const runModel = patch.model === undefined ? undefined : patch.model;
1448
+ const runEffort = patch.effort === undefined ? undefined : patch.effort;
1449
+ {
1450
+ const sets = [];
1451
+ const values = [];
1245
1452
  if (title !== undefined) {
1246
1453
  sets.push("title = ?", "title_manual = 1");
1247
1454
  values.push(title);
@@ -1250,13 +1457,34 @@ export class WebStore {
1250
1457
  sets.push("archived_at = ?");
1251
1458
  values.push(archivedAt);
1252
1459
  }
1460
+ if (runModel !== undefined) {
1461
+ sets.push("run_model = ?");
1462
+ values.push(runModel);
1463
+ }
1464
+ if (runEffort !== undefined) {
1465
+ sets.push("run_effort = ?");
1466
+ values.push(runEffort);
1467
+ }
1468
+ // A model/effort-only patch must not reorder the sidebar, so `updated_at`
1469
+ // only advances when title or archived state actually changes.
1470
+ if (title !== undefined || archivedAt !== undefined) {
1471
+ sets.push("updated_at = ?");
1472
+ values.push(now);
1473
+ }
1474
+ sets.push("revision = revision + 1");
1253
1475
  values.push(id);
1254
1476
  this.database.prepare(`UPDATE threads SET ${sets.join(", ")} WHERE id = ?`).run(...values);
1255
- this.recordThreadRevision(id, title !== undefined ? "title_changed" : patch.archived ? "archived" : "unarchived", now);
1477
+ this.recordThreadRevision(id, title !== undefined
1478
+ ? "title_changed"
1479
+ : archivedAt !== undefined
1480
+ ? patch.archived
1481
+ ? "archived"
1482
+ : "unarchived"
1483
+ : "run_config_changed", now);
1256
1484
  if (patch.archived === true && this.currentThreadId() === id) {
1257
1485
  this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
1258
1486
  }
1259
- });
1487
+ }
1260
1488
  return { ...this.requireThread(id), sourceId: current.sourceId };
1261
1489
  }
1262
1490
  /** Whether the current interactive thread still accepts agent-proposed titles. */
@@ -1361,6 +1589,34 @@ export class WebStore {
1361
1589
  const row = this.database.prepare("SELECT * FROM attachments WHERE id = ?").get(id);
1362
1590
  return row === undefined ? undefined : mapStoredAttachment(row);
1363
1591
  }
1592
+ /**
1593
+ * Durable copies are keyed by the part that produced them, so re-observing a
1594
+ * message — a reconnect, a replay, a second read of the same thread — cannot
1595
+ * store the same image twice.
1596
+ */
1597
+ static replyAttachmentId(messageId, partId) {
1598
+ return `reply:${encodeURIComponent(messageId)}:${encodeURIComponent(partId)}`;
1599
+ }
1600
+ storedReplyAttachment(messageId, partId) {
1601
+ const attachment = this.getStoredAttachment(WebStore.replyAttachmentId(messageId, partId));
1602
+ return attachment?.origin === "reply" ? attachment : undefined;
1603
+ }
1604
+ /**
1605
+ * Records an already-written durable copy. Committed on arrival and bound to
1606
+ * its thread, so the staged-upload purge and the staged-only delete route both
1607
+ * pass it over, while the thread's own delete cascade still reclaims it.
1608
+ */
1609
+ recordReplyAttachment(input) {
1610
+ const id = WebStore.replyAttachmentId(input.messageId, input.partId);
1611
+ const now = this.now();
1612
+ this.database.prepare(`
1613
+ INSERT INTO attachments (
1614
+ id, thread_id, message_id, name, content_type, size_bytes, kind,
1615
+ status, uploaded, origin, storage_name, created_at, updated_at
1616
+ ) VALUES (?, ?, ?, ?, ?, ?, 'image', 'committed', 1, 'reply', ?, ?, ?)
1617
+ `).run(id, input.threadId, input.messageId, input.name, input.contentType, input.sizeBytes, input.storageName, now, now);
1618
+ return this.requireStoredAttachment(id);
1619
+ }
1364
1620
  stagedUploadUsage() {
1365
1621
  const row = this.database.prepare(`
1366
1622
  SELECT COUNT(*) AS count, COALESCE(SUM(size_bytes), 0) AS bytes
@@ -1562,7 +1818,7 @@ export class WebStore {
1562
1818
  id, thread_id, status, text, model, effort, assistant_message_id,
1563
1819
  started_at, finished_at, error_code, error_message
1564
1820
  ) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
1565
- `).run(turnId, threadId, input.prompt, assistantMessageId, now);
1821
+ `).run(turnId, threadId, input.storedPrompt ?? input.prompt, assistantMessageId, now);
1566
1822
  this.database.prepare(`
1567
1823
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
1568
1824
  VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
@@ -1782,7 +2038,7 @@ export class WebStore {
1782
2038
  replaceWholeText(parts, frame.text);
1783
2039
  }
1784
2040
  else if (frame.kind === "event") {
1785
- applyEvent(parts, frame.event);
2041
+ applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey));
1786
2042
  if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
1787
2043
  if (typeof frame.event.data?.model === "string")
1788
2044
  actualModel = frame.event.data.model;
@@ -1806,9 +2062,9 @@ export class WebStore {
1806
2062
  });
1807
2063
  return this.requireMessage(message.id);
1808
2064
  }
1809
- completeTurn(turnId, finalText, metadata, replyParts) {
2065
+ completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
1810
2066
  const runtime = runtimeMetadata(metadata);
1811
- return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
2067
+ return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true);
1812
2068
  }
1813
2069
  failTurn(turnId, error) {
1814
2070
  return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
@@ -2179,6 +2435,7 @@ export class WebStore {
2179
2435
  default_effort TEXT,
2180
2436
  efforts_json TEXT,
2181
2437
  model_options_json TEXT,
2438
+ providers_json TEXT,
2182
2439
  cron_read INTEGER NOT NULL DEFAULT 0,
2183
2440
  cron_actions INTEGER NOT NULL DEFAULT 0,
2184
2441
  ask_by_id INTEGER NOT NULL DEFAULT 0,
@@ -2194,6 +2451,8 @@ export class WebStore {
2194
2451
  archived_at TEXT,
2195
2452
  created_at TEXT NOT NULL,
2196
2453
  updated_at TEXT NOT NULL,
2454
+ run_model TEXT,
2455
+ run_effort TEXT,
2197
2456
  revision INTEGER NOT NULL DEFAULT 1
2198
2457
  );
2199
2458
  CREATE TABLE IF NOT EXISTS turns (
@@ -2246,6 +2505,7 @@ export class WebStore {
2246
2505
  kind TEXT NOT NULL,
2247
2506
  status TEXT NOT NULL,
2248
2507
  uploaded INTEGER NOT NULL DEFAULT 0,
2508
+ origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply')),
2249
2509
  storage_name TEXT NOT NULL UNIQUE,
2250
2510
  created_at TEXT NOT NULL,
2251
2511
  updated_at TEXT NOT NULL
@@ -2295,6 +2555,22 @@ export class WebStore {
2295
2555
  );
2296
2556
  CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
2297
2557
  ON process_job_wake_deliveries(thread_id, created_at);
2558
+ CREATE TABLE IF NOT EXISTS monitor_wake_deliveries (
2559
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
2560
+ monitor_id TEXT NOT NULL,
2561
+ delivery_key TEXT NOT NULL,
2562
+ thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
2563
+ payload_sha256 TEXT NOT NULL,
2564
+ projection_json TEXT,
2565
+ state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
2566
+ disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
2567
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
2568
+ created_at TEXT NOT NULL,
2569
+ completed_at TEXT,
2570
+ PRIMARY KEY (source_id, delivery_key)
2571
+ );
2572
+ CREATE INDEX IF NOT EXISTS monitor_wake_deliveries_by_thread
2573
+ ON monitor_wake_deliveries(thread_id, created_at);
2298
2574
  CREATE TABLE IF NOT EXISTS cron_channels (
2299
2575
  source_id TEXT NOT NULL REFERENCES agents(source_id),
2300
2576
  job_id TEXT NOT NULL,
@@ -2438,6 +2714,51 @@ export class WebStore {
2438
2714
  // database and idempotent if the sweep is ever re-run.
2439
2715
  if (versionRow.user_version < 9)
2440
2716
  this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
2717
+ // Durable copies of agent-published images share the upload table so that
2718
+ // thread deletion, archival file cleanup, and every existing purge surface
2719
+ // reach them without a second store to keep in sync.
2720
+ if (versionRow.user_version < 10) {
2721
+ const columns = new Set(this.database.prepare("PRAGMA table_info(attachments)").all()
2722
+ .map((column) => column.name));
2723
+ if (!columns.has("origin")) {
2724
+ this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
2725
+ }
2726
+ }
2727
+ // Per-conversation model/effort overrides are server-persisted columns.
2728
+ // Guard on PRAGMA table_info so the ALTER is skipped when the columns
2729
+ // already exist, keeping the migration re-runnable.
2730
+ if (versionRow.user_version < 11) {
2731
+ const columns = new Set(this.database.prepare("PRAGMA table_info(threads)").all()
2732
+ .map((column) => column.name));
2733
+ if (!columns.has("run_model")) {
2734
+ this.database.exec("ALTER TABLE threads ADD COLUMN run_model TEXT");
2735
+ }
2736
+ if (!columns.has("run_effort")) {
2737
+ this.database.exec("ALTER TABLE threads ADD COLUMN run_effort TEXT");
2738
+ }
2739
+ }
2740
+ // The provider summary an agent advertises. Guarded on PRAGMA
2741
+ // table_info so the ALTER is skipped when the column already exists,
2742
+ // keeping the migration re-runnable after an interrupted upgrade.
2743
+ if (versionRow.user_version < 12) {
2744
+ const columns = new Set(this.database.prepare("PRAGMA table_info(agents)").all()
2745
+ .map((column) => column.name));
2746
+ if (!columns.has("providers_json")) {
2747
+ this.database.exec("ALTER TABLE agents ADD COLUMN providers_json TEXT");
2748
+ }
2749
+ }
2750
+ // Schema v13 tied the Monitor idempotency ledger to the conversation
2751
+ // with ON DELETE CASCADE. That erased the tombstone and allowed a
2752
+ // delivery key to name different content after thread deletion.
2753
+ if (versionRow.user_version === 13)
2754
+ this.migrateMonitorWakeDeliveries();
2755
+ if (versionRow.user_version < 15) {
2756
+ const columns = new Set(this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)").all()
2757
+ .map((column) => column.name));
2758
+ if (!columns.has("projection_json")) {
2759
+ this.database.exec("ALTER TABLE monitor_wake_deliveries ADD COLUMN projection_json TEXT");
2760
+ }
2761
+ }
2441
2762
  if (migrating)
2442
2763
  this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
2443
2764
  }
@@ -2454,6 +2775,36 @@ export class WebStore {
2454
2775
  throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
2455
2776
  }
2456
2777
  }
2778
+ /** Preserve Monitor delivery tombstones while making deleted threads threadless. */
2779
+ migrateMonitorWakeDeliveries() {
2780
+ this.database.exec(`
2781
+ CREATE TABLE monitor_wake_deliveries_v14 (
2782
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
2783
+ monitor_id TEXT NOT NULL,
2784
+ delivery_key TEXT NOT NULL,
2785
+ thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
2786
+ payload_sha256 TEXT NOT NULL,
2787
+ projection_json TEXT,
2788
+ state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
2789
+ disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
2790
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
2791
+ created_at TEXT NOT NULL,
2792
+ completed_at TEXT,
2793
+ PRIMARY KEY (source_id, delivery_key)
2794
+ );
2795
+ INSERT INTO monitor_wake_deliveries_v14 (
2796
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
2797
+ state, disposition, turn_id, created_at, completed_at
2798
+ ) SELECT
2799
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, NULL,
2800
+ state, disposition, turn_id, created_at, completed_at
2801
+ FROM monitor_wake_deliveries;
2802
+ DROP TABLE monitor_wake_deliveries;
2803
+ ALTER TABLE monitor_wake_deliveries_v14 RENAME TO monitor_wake_deliveries;
2804
+ CREATE INDEX monitor_wake_deliveries_by_thread
2805
+ ON monitor_wake_deliveries(thread_id, created_at);
2806
+ `);
2807
+ }
2457
2808
  /**
2458
2809
  * Schema-v5 adoption runs after every older fixup inside the same
2459
2810
  * BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
@@ -2603,6 +2954,8 @@ export class WebStore {
2603
2954
  "revisions",
2604
2955
  "settings",
2605
2956
  "notification_deliveries",
2957
+ "process_job_wake_deliveries",
2958
+ "monitor_wake_deliveries",
2606
2959
  "cron_channels",
2607
2960
  "cron_channel_deletions",
2608
2961
  "cron_overviews",
@@ -2630,6 +2983,21 @@ export class WebStore {
2630
2983
  throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
2631
2984
  }
2632
2985
  }
2986
+ const monitorProjections = this.database.prepare(`
2987
+ SELECT monitor_id, projection_json
2988
+ FROM monitor_wake_deliveries
2989
+ WHERE projection_json IS NOT NULL
2990
+ `).all();
2991
+ for (const row of monitorProjections) {
2992
+ try {
2993
+ const projection = parseMonitorProjection(JSON.parse(row.projection_json));
2994
+ if (projection.monitorId !== row.monitor_id)
2995
+ throw new TypeError("Monitor identity mismatch.");
2996
+ }
2997
+ catch {
2998
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
2999
+ }
3000
+ }
2633
3001
  }
2634
3002
  /**
2635
3003
  * Index the messages the streaming gate skipped and nothing settled — the
@@ -2687,17 +3055,17 @@ export class WebStore {
2687
3055
  WHERE status = 'sending'
2688
3056
  `).run(now, now);
2689
3057
  }
2690
- finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3058
+ finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
2691
3059
  const turn = this.requireTurn(turnId);
2692
3060
  if (turn.status !== "running") {
2693
3061
  return this.requireThreadDetail(turn.thread_id);
2694
3062
  }
2695
3063
  this.transaction(() => {
2696
- this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
3064
+ this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush);
2697
3065
  });
2698
3066
  return this.requireThreadDetail(turn.thread_id);
2699
3067
  }
2700
- finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3068
+ finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
2701
3069
  const turn = this.requireTurn(turnId);
2702
3070
  if (turn.status !== "running")
2703
3071
  return;
@@ -2729,7 +3097,9 @@ export class WebStore {
2729
3097
  // Projected cron turns are agent-owned scheduler state. Restart recovery
2730
3098
  // still settles the local projection, but only web-owned turns may emit a
2731
3099
  // Web Push terminal notification from this service.
2732
- if (thread.trigger?.kind !== "cron" && recentEnoughForRecoveredInterruption) {
3100
+ if (thread.trigger?.kind !== "cron"
3101
+ && recentEnoughForRecoveredInterruption
3102
+ && !(status === "complete" && suppressResponsePush)) {
2733
3103
  const kind = status === "complete"
2734
3104
  ? "response.ready"
2735
3105
  : status === "cancelled"
@@ -2792,10 +3162,13 @@ export class WebStore {
2792
3162
  runState,
2793
3163
  canSend: row.can_send === 1,
2794
3164
  canUpload: row.can_upload === 1,
3165
+ runModel: row.run_model,
3166
+ runEffort: row.run_effort,
2795
3167
  };
2796
3168
  }
2797
3169
  mapMessage(row) {
2798
- const attachments = this.database.prepare("SELECT * FROM attachments WHERE message_id = ? ORDER BY created_at, id")
3170
+ const attachments = this.database
3171
+ .prepare("SELECT * FROM attachments WHERE message_id = ? AND origin = 'upload' ORDER BY created_at, id")
2799
3172
  .all(row.id);
2800
3173
  const storedParts = parseParts(row.parts_json);
2801
3174
  const quote = quoteFromParts(storedParts);
@@ -3084,6 +3457,7 @@ function isValidVapidKeyPair(publicKey, privateKey) {
3084
3457
  function threadSelectSql(suffix) {
3085
3458
  return `
3086
3459
  SELECT t.id, t.source_id, t.title, t.title_manual, t.trigger_kind, t.archived_at, t.created_at, t.updated_at, t.revision,
3460
+ t.run_model, t.run_effort,
3087
3461
  cc.job_id AS cron_job_id, cc.configured AS cron_configured,
3088
3462
  CASE WHEN t.trigger_kind = 'cron' THEN 0
3089
3463
  WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
@@ -3420,6 +3794,7 @@ function mapAgent(row) {
3420
3794
  const models = parseStringArray(row.models_json);
3421
3795
  const efforts = parseStringArray(row.efforts_json);
3422
3796
  const modelOptions = parseRecord(row.model_options_json);
3797
+ const providers = parseProviderSummary(row.providers_json);
3423
3798
  return {
3424
3799
  sourceId: row.source_id,
3425
3800
  label: row.label,
@@ -3432,6 +3807,7 @@ function mapAgent(row) {
3432
3807
  ...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
3433
3808
  ...(efforts === undefined ? {} : { efforts }),
3434
3809
  ...(modelOptions === undefined ? {} : { modelOptions }),
3810
+ ...(providers === undefined ? {} : { providers }),
3435
3811
  ...(row.cron_read === 1
3436
3812
  ? { cron: { read: true, actions: row.cron_actions === 1 } }
3437
3813
  : {}),
@@ -3450,6 +3826,7 @@ function mapStoredAttachment(row) {
3450
3826
  kind: row.kind === "image" ? "image" : "document",
3451
3827
  status: row.status === "committed" ? "committed" : "staged",
3452
3828
  uploaded: row.uploaded === 1,
3829
+ origin: row.origin === "reply" ? "reply" : "upload",
3453
3830
  storageName: row.storage_name,
3454
3831
  createdAt: row.created_at,
3455
3832
  updatedAt: row.updated_at,
@@ -3478,12 +3855,26 @@ export function toWebAttachment(attachment) {
3478
3855
  ...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
3479
3856
  };
3480
3857
  }
3481
- function applyEvent(parts, event) {
3858
+ function appliedMonitorWake(event, resolveMonitorWake) {
3859
+ if (resolveMonitorWake === undefined
3860
+ || event.metadata?.liveInput !== true
3861
+ || event.metadata?.synthetic !== true
3862
+ || typeof event.metadata.inputId !== "string") {
3863
+ return undefined;
3864
+ }
3865
+ const projection = resolveMonitorWake(event.metadata.inputId);
3866
+ return projection === undefined
3867
+ ? undefined
3868
+ : { deliveryKey: event.metadata.inputId, projection };
3869
+ }
3870
+ function applyEvent(parts, event, resolveMonitorWake) {
3482
3871
  if (event.type === "assistant_thought") {
3483
3872
  appendTextPart(parts, "reasoning", event.text);
3484
3873
  return;
3485
3874
  }
3486
3875
  if (event.type === "tool_call_started") {
3876
+ if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
3877
+ return;
3487
3878
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3488
3879
  const subagent = subagentOf(event);
3489
3880
  if (subagent !== undefined) {
@@ -3525,6 +3916,11 @@ function applyEvent(parts, event) {
3525
3916
  return;
3526
3917
  }
3527
3918
  if (event.type === "tool_call_completed") {
3919
+ const monitorWake = appliedMonitorWake(event, resolveMonitorWake);
3920
+ if (monitorWake !== undefined) {
3921
+ upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
3922
+ return;
3923
+ }
3528
3924
  const status = event.isError === true ? "failed" : "complete";
3529
3925
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3530
3926
  const executionMs = canonicalExecutionMs(event.executionMs);
@@ -3585,6 +3981,33 @@ function applyEvent(parts, event) {
3585
3981
  }
3586
3982
  parts.push({ type: "telemetry", event: event.type, data: event });
3587
3983
  }
3984
+ function upsertMonitorActivity(parts, projection, deliveryKey) {
3985
+ const index = parts.findIndex((part) => part.type === "monitor-activity");
3986
+ const previous = index < 0 ? undefined : parts[index];
3987
+ const monitors = previous?.monitors ?? [];
3988
+ const monitorIndex = monitors.findIndex((entry) => entry.projection.monitorId === projection.monitorId);
3989
+ const prior = monitorIndex < 0 ? undefined : monitors[monitorIndex];
3990
+ // The stream receipt and the durable delivery settlement can race. Once an
3991
+ // exact key is present, settlement is bookkeeping only: replacing its newer
3992
+ // projection with the reservation's older snapshot would make the activity
3993
+ // row move backwards and emit a duplicate invalidation.
3994
+ if (prior?.deliveryKeys.includes(deliveryKey) === true)
3995
+ return false;
3996
+ const deliveryKeys = prior === undefined ? [deliveryKey] : [...prior.deliveryKeys, deliveryKey];
3997
+ const entry = { projection, deliveryKeys };
3998
+ const nextMonitors = monitorIndex < 0
3999
+ ? [...monitors, entry]
4000
+ : monitors.map((value, at) => at === monitorIndex ? entry : value);
4001
+ const next = { type: "monitor-activity", monitors: nextMonitors };
4002
+ if (index < 0) {
4003
+ parts.push(next);
4004
+ return true;
4005
+ }
4006
+ if (isDeepStrictEqual(previous, next))
4007
+ return false;
4008
+ parts[index] = next;
4009
+ return true;
4010
+ }
3588
4011
  function contextCompactionOperationId(value) {
3589
4012
  if (value === null || typeof value !== "object" || Array.isArray(value))
3590
4013
  return undefined;
@@ -3740,19 +4163,26 @@ function upsertToolCall(parts, next, historyUpdate) {
3740
4163
  parts[index] = updated;
3741
4164
  }
3742
4165
  /**
3743
- * Whether a stored part reaches the transcript at all. The console renders
3744
- * exactly one kind of telemetry context compaction and drops the rest in
3745
- * `convertPart`, so every other telemetry part is invisible between two runs of
3746
- * prose.
4166
+ * Whether a stored part marks a semantic boundary between streamed text runs.
4167
+ * Most telemetry remains invisible; compaction and the content-free assistant
4168
+ * message marker intentionally keep adjacent provider responses separate.
3747
4169
  */
3748
- function isRenderedPart(part) {
4170
+ function separatesStreamedText(part) {
4171
+ // A Monitor acknowledgement may arrive while the provider is still flushing
4172
+ // the preceding message's final text delta. Its compact activity row must not
4173
+ // split that word; the explicit message boundary below separates responses.
4174
+ if (part.type === "monitor-activity")
4175
+ return false;
3749
4176
  if (part.type !== "telemetry")
3750
4177
  return true;
3751
4178
  const event = part.data;
3752
4179
  if (event === null || typeof event !== "object" || Array.isArray(event))
3753
4180
  return false;
3754
4181
  const record = event;
3755
- return record.type === "runtime_telemetry" && record.kind === "context_compaction";
4182
+ return record.type === "runtime_telemetry"
4183
+ && (record.kind === "context_compaction"
4184
+ || record.kind === "context_usage"
4185
+ || record.kind === "assistant_message_boundary");
3756
4186
  }
3757
4187
  /**
3758
4188
  * Append a streamed delta to the trailing run of `type`.
@@ -3771,7 +4201,7 @@ function appendTextPart(parts, type, delta) {
3771
4201
  const part = parts[index];
3772
4202
  if (part === undefined)
3773
4203
  break;
3774
- if (!isRenderedPart(part))
4204
+ if (!separatesStreamedText(part))
3775
4205
  continue;
3776
4206
  if (part.type !== type)
3777
4207
  break;
@@ -4189,6 +4619,9 @@ function parseParts(value) {
4189
4619
  if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
4190
4620
  throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
4191
4621
  }
4622
+ if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
4623
+ throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
4624
+ }
4192
4625
  quoteFromParts(parts);
4193
4626
  return parts;
4194
4627
  }
@@ -4412,6 +4845,50 @@ function isWebMessagePart(value) {
4412
4845
  return false;
4413
4846
  }
4414
4847
  }
4848
+ if (part.type === "monitor-activity") {
4849
+ if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
4850
+ || !Array.isArray(part.monitors)
4851
+ || part.monitors.length === 0
4852
+ || part.monitors.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
4853
+ return false;
4854
+ }
4855
+ const monitorIds = new Set();
4856
+ let deliveryCount = 0;
4857
+ for (const raw of part.monitors) {
4858
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
4859
+ return false;
4860
+ const entry = raw;
4861
+ if (!hasOnlyKeys(entry, new Set(["projection", "deliveryKeys"])) || !Array.isArray(entry.deliveryKeys)) {
4862
+ return false;
4863
+ }
4864
+ let projection;
4865
+ try {
4866
+ projection = parseMonitorProjection(entry.projection);
4867
+ }
4868
+ catch {
4869
+ return false;
4870
+ }
4871
+ if (monitorIds.has(projection.monitorId)
4872
+ || entry.deliveryKeys.length === 0
4873
+ || entry.deliveryKeys.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
4874
+ return false;
4875
+ }
4876
+ monitorIds.add(projection.monitorId);
4877
+ const keys = new Set();
4878
+ for (const key of entry.deliveryKeys) {
4879
+ if (typeof key !== "string"
4880
+ || key.length === 0
4881
+ || key.length > 1_024
4882
+ || /[\u0000-\u001f\u007f]/u.test(key)
4883
+ || keys.has(key)) {
4884
+ return false;
4885
+ }
4886
+ keys.add(key);
4887
+ }
4888
+ deliveryCount += entry.deliveryKeys.length;
4889
+ }
4890
+ return deliveryCount <= AGENT_LIVE_INPUT_MAX_MESSAGES;
4891
+ }
4415
4892
  if (part.type === "telemetry")
4416
4893
  return typeof part.event === "string";
4417
4894
  if (part.type === "error")
@@ -4534,6 +5011,38 @@ function parseStringArray(value) {
4534
5011
  return undefined;
4535
5012
  }
4536
5013
  }
5014
+ /**
5015
+ * Malformed stored JSON must degrade to "this agent advertises no providers",
5016
+ * never throw: `mapAgent` runs on every bootstrap and discovery read, so a
5017
+ * throw here would take the whole console down over one bad row.
5018
+ */
5019
+ function parseProviderSummary(value) {
5020
+ if (value === null)
5021
+ return undefined;
5022
+ let parsed;
5023
+ try {
5024
+ parsed = JSON.parse(value);
5025
+ }
5026
+ catch {
5027
+ return undefined;
5028
+ }
5029
+ if (!Array.isArray(parsed))
5030
+ return undefined;
5031
+ const result = [];
5032
+ for (const raw of parsed) {
5033
+ if (typeof raw !== "object" || raw === null)
5034
+ continue;
5035
+ const entry = raw;
5036
+ if (typeof entry.id !== "string" || entry.id.length === 0)
5037
+ continue;
5038
+ result.push({
5039
+ id: entry.id,
5040
+ label: typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id,
5041
+ ...(entry.configured === true ? { configured: true } : {}),
5042
+ });
5043
+ }
5044
+ return result.length === 0 ? undefined : result;
5045
+ }
4537
5046
  function parseRecord(value) {
4538
5047
  if (value === null)
4539
5048
  return undefined;