@mono-agent/web 0.20.11 → 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 (41) hide show
  1. package/README.md +19 -8
  2. package/dist/contracts.d.ts +75 -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 +100 -12
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +531 -65
  30. package/dist/service.js.map +1 -1
  31. package/dist/store.d.ts +97 -6
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +501 -33
  34. package/dist/store.js.map +1 -1
  35. package/package.json +5 -7
  36. package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
  37. package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
  38. package/webapp/dist/index.html +2 -2
  39. package/webapp/dist/sw.js +1 -1
  40. package/webapp/dist/assets/index-BT463dRM.css +0 -1
  41. package/webapp/dist/assets/index-Co-qDQPq.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 = 10;
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. */
@@ -1590,7 +1818,7 @@ export class WebStore {
1590
1818
  id, thread_id, status, text, model, effort, assistant_message_id,
1591
1819
  started_at, finished_at, error_code, error_message
1592
1820
  ) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
1593
- `).run(turnId, threadId, input.prompt, assistantMessageId, now);
1821
+ `).run(turnId, threadId, input.storedPrompt ?? input.prompt, assistantMessageId, now);
1594
1822
  this.database.prepare(`
1595
1823
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
1596
1824
  VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
@@ -1810,7 +2038,7 @@ export class WebStore {
1810
2038
  replaceWholeText(parts, frame.text);
1811
2039
  }
1812
2040
  else if (frame.kind === "event") {
1813
- applyEvent(parts, frame.event);
2041
+ applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey));
1814
2042
  if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
1815
2043
  if (typeof frame.event.data?.model === "string")
1816
2044
  actualModel = frame.event.data.model;
@@ -1834,9 +2062,9 @@ export class WebStore {
1834
2062
  });
1835
2063
  return this.requireMessage(message.id);
1836
2064
  }
1837
- completeTurn(turnId, finalText, metadata, replyParts) {
2065
+ completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
1838
2066
  const runtime = runtimeMetadata(metadata);
1839
- return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
2067
+ return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true);
1840
2068
  }
1841
2069
  failTurn(turnId, error) {
1842
2070
  return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
@@ -2207,6 +2435,7 @@ export class WebStore {
2207
2435
  default_effort TEXT,
2208
2436
  efforts_json TEXT,
2209
2437
  model_options_json TEXT,
2438
+ providers_json TEXT,
2210
2439
  cron_read INTEGER NOT NULL DEFAULT 0,
2211
2440
  cron_actions INTEGER NOT NULL DEFAULT 0,
2212
2441
  ask_by_id INTEGER NOT NULL DEFAULT 0,
@@ -2222,6 +2451,8 @@ export class WebStore {
2222
2451
  archived_at TEXT,
2223
2452
  created_at TEXT NOT NULL,
2224
2453
  updated_at TEXT NOT NULL,
2454
+ run_model TEXT,
2455
+ run_effort TEXT,
2225
2456
  revision INTEGER NOT NULL DEFAULT 1
2226
2457
  );
2227
2458
  CREATE TABLE IF NOT EXISTS turns (
@@ -2324,6 +2555,22 @@ export class WebStore {
2324
2555
  );
2325
2556
  CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
2326
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);
2327
2574
  CREATE TABLE IF NOT EXISTS cron_channels (
2328
2575
  source_id TEXT NOT NULL REFERENCES agents(source_id),
2329
2576
  job_id TEXT NOT NULL,
@@ -2477,6 +2724,41 @@ export class WebStore {
2477
2724
  this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
2478
2725
  }
2479
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
+ }
2480
2762
  if (migrating)
2481
2763
  this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
2482
2764
  }
@@ -2493,6 +2775,36 @@ export class WebStore {
2493
2775
  throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
2494
2776
  }
2495
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
+ }
2496
2808
  /**
2497
2809
  * Schema-v5 adoption runs after every older fixup inside the same
2498
2810
  * BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
@@ -2642,6 +2954,8 @@ export class WebStore {
2642
2954
  "revisions",
2643
2955
  "settings",
2644
2956
  "notification_deliveries",
2957
+ "process_job_wake_deliveries",
2958
+ "monitor_wake_deliveries",
2645
2959
  "cron_channels",
2646
2960
  "cron_channel_deletions",
2647
2961
  "cron_overviews",
@@ -2669,6 +2983,21 @@ export class WebStore {
2669
2983
  throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
2670
2984
  }
2671
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
+ }
2672
3001
  }
2673
3002
  /**
2674
3003
  * Index the messages the streaming gate skipped and nothing settled — the
@@ -2726,17 +3055,17 @@ export class WebStore {
2726
3055
  WHERE status = 'sending'
2727
3056
  `).run(now, now);
2728
3057
  }
2729
- finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3058
+ finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
2730
3059
  const turn = this.requireTurn(turnId);
2731
3060
  if (turn.status !== "running") {
2732
3061
  return this.requireThreadDetail(turn.thread_id);
2733
3062
  }
2734
3063
  this.transaction(() => {
2735
- this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
3064
+ this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush);
2736
3065
  });
2737
3066
  return this.requireThreadDetail(turn.thread_id);
2738
3067
  }
2739
- finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3068
+ finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false) {
2740
3069
  const turn = this.requireTurn(turnId);
2741
3070
  if (turn.status !== "running")
2742
3071
  return;
@@ -2768,7 +3097,9 @@ export class WebStore {
2768
3097
  // Projected cron turns are agent-owned scheduler state. Restart recovery
2769
3098
  // still settles the local projection, but only web-owned turns may emit a
2770
3099
  // Web Push terminal notification from this service.
2771
- if (thread.trigger?.kind !== "cron" && recentEnoughForRecoveredInterruption) {
3100
+ if (thread.trigger?.kind !== "cron"
3101
+ && recentEnoughForRecoveredInterruption
3102
+ && !(status === "complete" && suppressResponsePush)) {
2772
3103
  const kind = status === "complete"
2773
3104
  ? "response.ready"
2774
3105
  : status === "cancelled"
@@ -2831,6 +3162,8 @@ export class WebStore {
2831
3162
  runState,
2832
3163
  canSend: row.can_send === 1,
2833
3164
  canUpload: row.can_upload === 1,
3165
+ runModel: row.run_model,
3166
+ runEffort: row.run_effort,
2834
3167
  };
2835
3168
  }
2836
3169
  mapMessage(row) {
@@ -3124,6 +3457,7 @@ function isValidVapidKeyPair(publicKey, privateKey) {
3124
3457
  function threadSelectSql(suffix) {
3125
3458
  return `
3126
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,
3127
3461
  cc.job_id AS cron_job_id, cc.configured AS cron_configured,
3128
3462
  CASE WHEN t.trigger_kind = 'cron' THEN 0
3129
3463
  WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
@@ -3460,6 +3794,7 @@ function mapAgent(row) {
3460
3794
  const models = parseStringArray(row.models_json);
3461
3795
  const efforts = parseStringArray(row.efforts_json);
3462
3796
  const modelOptions = parseRecord(row.model_options_json);
3797
+ const providers = parseProviderSummary(row.providers_json);
3463
3798
  return {
3464
3799
  sourceId: row.source_id,
3465
3800
  label: row.label,
@@ -3472,6 +3807,7 @@ function mapAgent(row) {
3472
3807
  ...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
3473
3808
  ...(efforts === undefined ? {} : { efforts }),
3474
3809
  ...(modelOptions === undefined ? {} : { modelOptions }),
3810
+ ...(providers === undefined ? {} : { providers }),
3475
3811
  ...(row.cron_read === 1
3476
3812
  ? { cron: { read: true, actions: row.cron_actions === 1 } }
3477
3813
  : {}),
@@ -3519,12 +3855,26 @@ export function toWebAttachment(attachment) {
3519
3855
  ...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
3520
3856
  };
3521
3857
  }
3522
- 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) {
3523
3871
  if (event.type === "assistant_thought") {
3524
3872
  appendTextPart(parts, "reasoning", event.text);
3525
3873
  return;
3526
3874
  }
3527
3875
  if (event.type === "tool_call_started") {
3876
+ if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
3877
+ return;
3528
3878
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3529
3879
  const subagent = subagentOf(event);
3530
3880
  if (subagent !== undefined) {
@@ -3566,6 +3916,11 @@ function applyEvent(parts, event) {
3566
3916
  return;
3567
3917
  }
3568
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
+ }
3569
3924
  const status = event.isError === true ? "failed" : "complete";
3570
3925
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3571
3926
  const executionMs = canonicalExecutionMs(event.executionMs);
@@ -3626,6 +3981,33 @@ function applyEvent(parts, event) {
3626
3981
  }
3627
3982
  parts.push({ type: "telemetry", event: event.type, data: event });
3628
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
+ }
3629
4011
  function contextCompactionOperationId(value) {
3630
4012
  if (value === null || typeof value !== "object" || Array.isArray(value))
3631
4013
  return undefined;
@@ -3781,19 +4163,26 @@ function upsertToolCall(parts, next, historyUpdate) {
3781
4163
  parts[index] = updated;
3782
4164
  }
3783
4165
  /**
3784
- * Whether a stored part reaches the transcript at all. The console renders
3785
- * exactly one kind of telemetry context compaction and drops the rest in
3786
- * `convertPart`, so every other telemetry part is invisible between two runs of
3787
- * 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.
3788
4169
  */
3789
- 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;
3790
4176
  if (part.type !== "telemetry")
3791
4177
  return true;
3792
4178
  const event = part.data;
3793
4179
  if (event === null || typeof event !== "object" || Array.isArray(event))
3794
4180
  return false;
3795
4181
  const record = event;
3796
- 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");
3797
4186
  }
3798
4187
  /**
3799
4188
  * Append a streamed delta to the trailing run of `type`.
@@ -3812,7 +4201,7 @@ function appendTextPart(parts, type, delta) {
3812
4201
  const part = parts[index];
3813
4202
  if (part === undefined)
3814
4203
  break;
3815
- if (!isRenderedPart(part))
4204
+ if (!separatesStreamedText(part))
3816
4205
  continue;
3817
4206
  if (part.type !== type)
3818
4207
  break;
@@ -4230,6 +4619,9 @@ function parseParts(value) {
4230
4619
  if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
4231
4620
  throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
4232
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
+ }
4233
4625
  quoteFromParts(parts);
4234
4626
  return parts;
4235
4627
  }
@@ -4453,6 +4845,50 @@ function isWebMessagePart(value) {
4453
4845
  return false;
4454
4846
  }
4455
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
+ }
4456
4892
  if (part.type === "telemetry")
4457
4893
  return typeof part.event === "string";
4458
4894
  if (part.type === "error")
@@ -4575,6 +5011,38 @@ function parseStringArray(value) {
4575
5011
  return undefined;
4576
5012
  }
4577
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
+ }
4578
5046
  function parseRecord(value) {
4579
5047
  if (value === null)
4580
5048
  return undefined;