@mono-agent/web 0.20.11 → 0.21.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/README.md +397 -68
  2. package/dist/contracts.d.ts +437 -8
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/cron-reply-context.d.ts +27 -0
  7. package/dist/cron-reply-context.d.ts.map +1 -0
  8. package/dist/cron-reply-context.js +241 -0
  9. package/dist/cron-reply-context.js.map +1 -0
  10. package/dist/discovery.d.ts +1 -0
  11. package/dist/discovery.d.ts.map +1 -1
  12. package/dist/discovery.js +8 -6
  13. package/dist/discovery.js.map +1 -1
  14. package/dist/effort-ladder.d.ts +111 -0
  15. package/dist/effort-ladder.d.ts.map +1 -0
  16. package/dist/effort-ladder.js +155 -0
  17. package/dist/effort-ladder.js.map +1 -0
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/long-lived-fetch.d.ts +5 -0
  23. package/dist/long-lived-fetch.d.ts.map +1 -0
  24. package/dist/long-lived-fetch.js +22 -0
  25. package/dist/long-lived-fetch.js.map +1 -0
  26. package/dist/monitor-reply.d.ts +10 -0
  27. package/dist/monitor-reply.d.ts.map +1 -0
  28. package/dist/monitor-reply.js +67 -0
  29. package/dist/monitor-reply.js.map +1 -0
  30. package/dist/notification-client.d.ts +11 -2
  31. package/dist/notification-client.d.ts.map +1 -1
  32. package/dist/notification-client.js +5 -4
  33. package/dist/notification-client.js.map +1 -1
  34. package/dist/notification-ingress.d.ts +8 -1
  35. package/dist/notification-ingress.d.ts.map +1 -1
  36. package/dist/notification-ingress.js +28 -6
  37. package/dist/notification-ingress.js.map +1 -1
  38. package/dist/operator-client.d.ts +35 -2
  39. package/dist/operator-client.d.ts.map +1 -1
  40. package/dist/operator-client.js +276 -9
  41. package/dist/operator-client.js.map +1 -1
  42. package/dist/server.d.ts +42 -1
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +679 -52
  45. package/dist/server.js.map +1 -1
  46. package/dist/service.d.ts +299 -18
  47. package/dist/service.d.ts.map +1 -1
  48. package/dist/service.js +1915 -160
  49. package/dist/service.js.map +1 -1
  50. package/dist/store-migrations.d.ts +23 -0
  51. package/dist/store-migrations.d.ts.map +1 -0
  52. package/dist/store-migrations.js +212 -0
  53. package/dist/store-migrations.js.map +1 -0
  54. package/dist/store.d.ts +389 -21
  55. package/dist/store.d.ts.map +1 -1
  56. package/dist/store.js +2087 -245
  57. package/dist/store.js.map +1 -1
  58. package/package.json +9 -8
  59. package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
  60. package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
  61. package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
  62. package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
  63. package/webapp/dist/badge-96.png +0 -0
  64. package/webapp/dist/index.html +26 -6
  65. package/webapp/dist/manifest.webmanifest +1 -1
  66. package/webapp/dist/notification-sw.js +3 -1
  67. package/webapp/dist/sw.js +1 -1
  68. package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
  69. package/webapp/dist/assets/index-BT463dRM.css +0 -1
  70. package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/store.js CHANGED
@@ -3,15 +3,20 @@ 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 { normalizeMonitorTerminalReply, hasMonitorReplyContent, monitorReplyText } from "./monitor-reply.js";
7
+ import { AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE, AGENT_LIVE_INPUT_MAX_CHARACTERS, AGENT_LIVE_INPUT_MAX_MESSAGES, MAX_AGENT_REPLY_PARTS, classifyNotifySuppression, NOTHING_TO_REPORT_SENTINEL, parseMonitorProjection, parseProcessJobProjection, } from "@mono-agent/agent-contracts";
7
8
  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";
9
+ import { formatCronReplyContext } from "./cron-reply-context.js";
8
10
  import { WebConsoleError } from "./errors.js";
11
+ import { runWebStorageMigrations, validateWebStorageMigrationRegistry, WEB_STORAGE_SCHEMA_VERSION } from "./store-migrations.js";
9
12
  import { webPushPreview } from "./push-preview.js";
10
13
  import { prepareWebStatePaths } from "./state-paths.js";
11
14
  /**
12
15
  * The searchable text of one message row, derived in SQL so the index cannot
13
16
  * drift from `parts_json`: every write path in this store goes through the
14
- * triggers below rather than remembering to maintain a second copy.
17
+ * triggers below rather than remembering to maintain a second copy. Legacy
18
+ * Monitor history gets an association-verified projection repair at open; its
19
+ * canonical parts remain untouched.
15
20
  *
16
21
  * Only `text` parts are indexed. Reasoning is the agent's working-out and tool
17
22
  * payloads are machine JSON; both would drown a search of what was actually
@@ -118,10 +123,26 @@ export function messageSearchMatchExpression(raw) {
118
123
  export function escapeLikeTerm(raw) {
119
124
  return raw.replaceAll(/[\\%_]/gu, (character) => `\\${character}`);
120
125
  }
121
- const WEB_STORAGE_SCHEMA_VERSION = 10;
122
126
  const MAX_REVISIONS_PER_THREAD = 1_000;
123
127
  export const WEB_THREAD_PAGE_MAX = 200;
128
+ /**
129
+ * What one page is when the caller does not say.
130
+ *
131
+ * A sidebar shows a handful of conversations and pages from there, so both the
132
+ * bootstrap's bucket and the thread-list route answer with this rather than the
133
+ * whole per-bucket cap.
134
+ */
135
+ export const WEB_THREAD_PAGE_DEFAULT = 50;
124
136
  export const WEB_MESSAGE_PAGE_MAX = 100;
137
+ /**
138
+ * What one page of a transcript is when the caller does not say.
139
+ *
140
+ * A conversation read used to answer with the whole cap, which on a tool-heavy
141
+ * thread is hundreds of kilobytes the viewport never shows. The console renders
142
+ * the tail and pages backwards from `messagesNextCursor`, so the default is the
143
+ * screenful rather than the ceiling.
144
+ */
145
+ export const WEB_MESSAGE_PAGE_DEFAULT = 30;
125
146
  const MAX_ACTIVE_PUSH_SUBSCRIPTIONS = 32;
126
147
  const MAX_PENDING_PUSH_DELIVERIES_PER_SUBSCRIPTION = 200;
127
148
  const PUSH_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
@@ -133,6 +154,22 @@ export class WebStore {
133
154
  database;
134
155
  clock;
135
156
  closed = false;
157
+ /**
158
+ * The agent PROCESS generation each live summary was built from, by source
159
+ * id. Deliberately not a column: it describes the process behind a source id
160
+ * right now, so a value read back from disk after a console restart would be
161
+ * a claim about a process nobody probed. `replaceAgents` is the only writer,
162
+ * and it drops ids discovery no longer reports.
163
+ */
164
+ agentGenerations = new Map();
165
+ /**
166
+ * `writeMessageParts` statements by the columns they assign. A streaming
167
+ * answer is written every ~50 ms and the SET list is one of a handful of
168
+ * shapes, so preparing each shape once keeps the hot path off the SQL
169
+ * compiler. Keyed by the generated SQL, which is derived solely from which
170
+ * columns the caller moves.
171
+ */
172
+ partsWriteStatements = new Map();
136
173
  constructor(database, paths, clock) {
137
174
  this.database = database;
138
175
  this.paths = paths;
@@ -166,6 +203,7 @@ export class WebStore {
166
203
  // After recovery, so anything the recovery settled is already indexed by
167
204
  // its own trigger and this only sweeps what genuinely stayed running.
168
205
  store.reindexUnsettledMessages();
206
+ store.reindexLegacyMonitorMessages();
169
207
  return store;
170
208
  }
171
209
  catch (error) {
@@ -177,54 +215,147 @@ export class WebStore {
177
215
  if (this.closed)
178
216
  return;
179
217
  this.closed = true;
218
+ // Drop the cached statements first: they hold native handles onto the
219
+ // connection this is about to close.
220
+ this.partsWriteStatements.clear();
180
221
  this.database.close();
181
222
  }
223
+ /**
224
+ * Persist the discovered agent list. Returns whether the change is worth
225
+ * telling clients about.
226
+ *
227
+ * `updatedAt` is a heartbeat timestamp that moves on every discovery poll, so
228
+ * comparing it made this return `true` roughly once every five seconds. At the
229
+ * time, `agents.changed` carried the whole agent list, every model, every model
230
+ * option and every provider. Measured against a 67-agent fleet that was 60 KB
231
+ * per event and 99.5% of all SSE traffic: ~42 MiB/hour to an idle console with
232
+ * nobody using it. The event is now a compact invalidation, but a heartbeat is
233
+ * still not a state change worth making every browser re-bootstrap for.
234
+ *
235
+ * So the two questions are separated. Any difference at all is still written,
236
+ * because the store should hold the freshest heartbeat; only a difference that
237
+ * survives normalizing `updatedAt` is broadcast. Nothing in the console reads
238
+ * an agent's `updatedAt` --- it stays on the wire for clients that want it, it
239
+ * just no longer triggers a fleet-sized frame on its own. `agentGeneration()`
240
+ * already excludes it for the same reason.
241
+ */
182
242
  replaceAgents(agents) {
183
243
  const current = this.listAgents();
184
244
  const currentById = new Map(current.map((agent) => [agent.sourceId, agent]));
185
245
  const incomingIds = new Set(agents.map((agent) => agent.sourceId));
186
- const changed = agents.some((agent) => {
246
+ // Presence is separate from reachability. An offline summary still belongs
247
+ // in the picker because discovery found its source; an omitted source does
248
+ // not, even when it was already offline before it disappeared.
249
+ const departed = current.some((agent) => !incomingIds.has(agent.sourceId));
250
+ const differs = (agent, ignoreHeartbeat) => {
187
251
  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)
252
+ if (prior === undefined)
253
+ return true;
254
+ // `pinned` is store-owned and never arrives from discovery; normalizing it
255
+ // keeps a locally pinned agent from looking like an incoming change.
256
+ const next = { ...agent, pinned: prior.pinned, runSettings: prior.runSettings };
257
+ if (!ignoreHeartbeat)
258
+ return !isDeepStrictEqual(prior, next);
259
+ return !isDeepStrictEqual({ ...prior, updatedAt: "" }, { ...next, updatedAt: "" });
260
+ };
261
+ const changed = agents.some((agent) => differs(agent, false)) || departed;
262
+ const notable = agents.some((agent) => differs(agent, true)) || departed;
263
+ // After `current` is read (it carries the PREVIOUS generations) and after
264
+ // both comparisons, so a restart behind an otherwise identical summary
265
+ // still reads as a change and still reaches the browser.
266
+ //
267
+ // And only once the row it describes is actually on disk. Advancing before
268
+ // the transaction made the map a claim the database had not agreed to: a
269
+ // transaction that threw left the NEW generation stitched onto the OLD row,
270
+ // so the retry compared the new summary against a prior that already
271
+ // carried its generation, found only `updatedAt` different, and returned
272
+ // `notable === false`. The restart broadcast was then lost permanently ---
273
+ // not deferred --- and an open console stayed stale until it reconnected.
274
+ const adoptGenerations = () => {
275
+ this.agentGenerations.clear();
276
+ for (const agent of agents) {
277
+ if (agent.generation !== undefined)
278
+ this.agentGenerations.set(agent.sourceId, agent.generation);
279
+ }
280
+ };
281
+ if (!changed) {
282
+ // Nothing to persist, so there is nothing for the adoption to outrun:
283
+ // every incoming generation already equals the one it is replacing, or
284
+ // `changed` would be true. The map is still swept so a departed agent
285
+ // does not leave one behind.
286
+ adoptGenerations();
191
287
  return false;
288
+ }
192
289
  this.transaction(() => {
193
- this.database.prepare("UPDATE agents SET status = 'offline'").run();
290
+ // Rows stay as foreign-key parents for retained conversations and
291
+ // delivery ledgers. Discovery presence controls whether they are part of
292
+ // the live console projection; it is restored by the upsert below when a
293
+ // source id returns.
294
+ this.database.prepare("UPDATE agents SET status = 'offline', discovered = 0 WHERE discovered = 1").run();
194
295
  const statement = this.database.prepare(`
195
296
  INSERT INTO agents (
196
- source_id, label, status, health, supports_attachments, models_json,
297
+ source_id, label, status, discovered, health, supports_attachments, supports_provider_auth, models_json,
197
298
  default_model, default_effort, efforts_json, model_options_json,
198
- cron_read, cron_actions, ask_by_id, updated_at
199
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
299
+ providers_json, cron_read, cron_actions, ask_by_id, updated_at
300
+ ) VALUES (?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
200
301
  ON CONFLICT(source_id) DO UPDATE SET
201
302
  label = excluded.label,
202
303
  status = excluded.status,
304
+ discovered = 1,
203
305
  health = excluded.health,
204
306
  supports_attachments = excluded.supports_attachments,
307
+ supports_provider_auth = excluded.supports_provider_auth,
205
308
  models_json = excluded.models_json,
206
309
  default_model = excluded.default_model,
207
310
  default_effort = excluded.default_effort,
208
311
  efforts_json = excluded.efforts_json,
209
312
  model_options_json = excluded.model_options_json,
313
+ providers_json = excluded.providers_json,
210
314
  cron_read = excluded.cron_read,
211
315
  cron_actions = excluded.cron_actions,
212
316
  ask_by_id = excluded.ask_by_id,
213
317
  updated_at = excluded.updated_at
214
318
  `);
215
319
  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);
320
+ statement.run(agent.sourceId, agent.label, agent.status, agent.health ?? null, agent.supportsAttachments ? 1 : 0, agent.supportsProviderAuth === true ? 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
321
  }
218
322
  });
323
+ adoptGenerations();
324
+ return notable;
325
+ }
326
+ /**
327
+ * A failed registry walk is not an authoritative empty registry. Keep every
328
+ * currently discovered source in the console, but make its lack of a live
329
+ * connection explicit until a later successful discovery reconciles it.
330
+ */
331
+ markDiscoveredAgentsOffline() {
332
+ const changed = this.listAgents().some((agent) => agent.status !== "offline");
333
+ this.agentGenerations.clear();
334
+ if (!changed)
335
+ return false;
336
+ this.database.prepare(`
337
+ UPDATE agents SET status = 'offline'
338
+ WHERE discovered = 1 AND status != 'offline'
339
+ `).run();
219
340
  return true;
220
341
  }
221
342
  listAgents() {
222
- const rows = this.database.prepare(agentSelectSql("ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
223
- return rows.map(mapAgent);
343
+ const rows = this.database.prepare(agentSelectSql("WHERE a.discovered = 1 ORDER BY pinned DESC, a.label COLLATE NOCASE, a.source_id")).all();
344
+ return rows.map((row) => this.withGeneration(mapAgent(row)));
224
345
  }
225
346
  getAgent(sourceId) {
226
- const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?")).get(sourceId);
227
- return row === undefined ? undefined : mapAgent(row);
347
+ const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ? AND a.discovered = 1")).get(sourceId);
348
+ return row === undefined ? undefined : this.withGeneration(mapAgent(row));
349
+ }
350
+ getStoredAgent(sourceId) {
351
+ const row = this.database.prepare(agentSelectSql("WHERE a.source_id = ?"))
352
+ .get(sourceId);
353
+ return row === undefined ? undefined : this.withGeneration(mapAgent(row));
354
+ }
355
+ /** Stitch the live generation onto a row read back from disk. */
356
+ withGeneration(agent) {
357
+ const generation = this.agentGenerations.get(agent.sourceId);
358
+ return generation === undefined ? agent : { ...agent, generation };
228
359
  }
229
360
  setAgentPinned(sourceId, pinned) {
230
361
  if (this.getAgent(sourceId) === undefined) {
@@ -240,6 +371,30 @@ export class WebStore {
240
371
  throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
241
372
  return agent;
242
373
  }
374
+ setAgentRunOverride(sourceId, override) {
375
+ if (this.getAgent(sourceId) === undefined) {
376
+ throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
377
+ }
378
+ if (override.model === null && override.effort === null) {
379
+ throw new WebConsoleError("invalid_request", "Choose a model or effort override, or use Revert to config.", 400);
380
+ }
381
+ this.database.prepare(`
382
+ INSERT INTO agent_run_overrides (source_id, model, effort, updated_at)
383
+ VALUES (?, ?, ?, ?)
384
+ ON CONFLICT(source_id) DO UPDATE SET
385
+ model = excluded.model,
386
+ effort = excluded.effort,
387
+ updated_at = excluded.updated_at
388
+ `).run(sourceId, override.model, override.effort, this.now());
389
+ return this.getAgent(sourceId);
390
+ }
391
+ clearAgentRunOverride(sourceId) {
392
+ if (this.getAgent(sourceId) === undefined) {
393
+ throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
394
+ }
395
+ this.database.prepare("DELETE FROM agent_run_overrides WHERE source_id = ?").run(sourceId);
396
+ return this.getAgent(sourceId);
397
+ }
243
398
  reserveNotification(input) {
244
399
  if (this.getAgent(input.sourceId) === undefined) {
245
400
  throw new WebConsoleError("agent_not_found", "The notification agent is no longer available.", 404);
@@ -356,6 +511,14 @@ export class WebStore {
356
511
  let turnId = randomUUID();
357
512
  let assistantMessageId = randomUUID();
358
513
  let completedThreadId = existing.thread_id;
514
+ /**
515
+ * Whether this completion actually MOVED the assistant row.
516
+ *
517
+ * A notification that maps onto a cron run whose message already carries
518
+ * this text writes nothing at all, and naming the row anyway costs every
519
+ * subscribed console one message read for a transcript that did not change.
520
+ */
521
+ let wroteMessage = false;
359
522
  this.transaction(() => {
360
523
  const cronChannel = reservation.jobId === undefined
361
524
  ? undefined
@@ -406,21 +569,26 @@ export class WebStore {
406
569
  id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
407
570
  ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'complete')
408
571
  `).run(assistantMessageId, completedThreadId, turnId, serializeParts([{ type: "text", text: reservation.text }]), now, now);
572
+ wroteMessage = true;
409
573
  }
410
574
  else {
411
575
  turnId = mappedRun.turn_id;
412
576
  assistantMessageId = mappedRun.message_id;
413
577
  const message = this.database.prepare(`
414
- SELECT parts_json FROM messages WHERE id = ? AND thread_id = ? AND turn_id = ?
578
+ SELECT parts_json, cron_suppressed FROM messages WHERE id = ? AND thread_id = ? AND turn_id = ?
415
579
  `).get(assistantMessageId, completedThreadId, turnId);
416
580
  if (message === undefined) {
417
581
  throw new WebConsoleError("storage_corrupt", "A cron run mapping is missing its message.", 500);
418
582
  }
419
- const parts = parseParts(message.parts_json);
420
- if (!parts.some((part) => part.type === "text" && part.text === reservation.text)) {
583
+ const parts = parseParts(message.parts_json).filter((part) => !(part.type === "text" && isSyntheticCronStateText(part.text)))
584
+ .map((part) => part.type === "telemetry" && part.event === "cron_run"
585
+ ? { ...part, data: withoutCronSilentFlag(part.data) } : part);
586
+ if (!parts.some((part) => part.type === "text" && part.text === reservation.text))
421
587
  parts.push({ type: "text", text: reservation.text });
422
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
423
- .run(serializeParts(parts), now, assistantMessageId);
588
+ if (message.cron_suppressed === 1 || serializeParts(parts) !== message.parts_json) {
589
+ this.database.prepare("UPDATE messages SET cron_suppressed = 0 WHERE id = ?").run(assistantMessageId);
590
+ this.writeMessageParts(assistantMessageId, parts, now);
591
+ wroteMessage = true;
424
592
  }
425
593
  }
426
594
  this.database.prepare(`
@@ -431,7 +599,7 @@ export class WebStore {
431
599
  UPDATE notification_deliveries SET thread_id = ?, message_id = ?, completed_at = ?
432
600
  WHERE source_id = ? AND delivery_key = ? AND completed_at IS NULL
433
601
  `).run(completedThreadId, assistantMessageId, now, reservation.sourceId, reservation.deliveryKey);
434
- const agent = this.getAgent(reservation.sourceId);
602
+ const agent = this.getStoredAgent(reservation.sourceId);
435
603
  this.enqueueWebPushEventInTransaction({
436
604
  logicalKey: notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey),
437
605
  kind: "response.ready",
@@ -443,7 +611,12 @@ export class WebStore {
443
611
  notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
444
612
  });
445
613
  });
446
- return { thread: this.requireThread(completedThreadId), duplicate: false };
614
+ return {
615
+ thread: this.requireThread(completedThreadId),
616
+ duplicate: false,
617
+ // Only when there is a write to name. See `wroteMessage`.
618
+ ...(wroteMessage ? { messageId: assistantMessageId } : {}),
619
+ };
447
620
  }
448
621
  /** Persist an agent-authoritative overview without deriving scheduler facts in the console. */
449
622
  syncCronOverviewResult(overview) {
@@ -637,11 +810,184 @@ export class WebStore {
637
810
  storedCronRuns(sourceId, jobId, limit = 100) {
638
811
  const bounded = boundedPageLimit(limit, 100);
639
812
  const rows = this.database.prepare(`
640
- SELECT payload_json FROM cron_run_messages
813
+ SELECT payload_json, message_id FROM cron_run_messages
641
814
  WHERE source_id = ? AND job_id = ?
642
815
  ORDER BY ordered_at DESC, sequence DESC, run_id DESC LIMIT ?
643
816
  `).all(sourceId, jobId, bounded);
644
- return { runs: rows.map((row) => parseStoredCronRun(row.payload_json)) };
817
+ return {
818
+ runs: rows.map((row) => parseStoredCronRun(row.payload_json)),
819
+ messages: [...rows].reverse().flatMap((row) => {
820
+ const message = this.getMessage(row.message_id);
821
+ return message === undefined ? [] : [message];
822
+ }),
823
+ };
824
+ }
825
+ captureCronReplySnapshot(sourceId, jobId, runId, snapshotKind) {
826
+ const row = this.database.prepare(`
827
+ SELECT r.payload_json, m.parts_json, m.cron_suppressed,
828
+ t.text, t.error_code, t.error_message
829
+ FROM cron_run_messages r
830
+ JOIN cron_channels c ON c.source_id = r.source_id AND c.job_id = r.job_id AND c.thread_id = r.thread_id
831
+ JOIN messages m ON m.id = r.message_id AND m.thread_id = r.thread_id
832
+ JOIN turns t ON t.id = r.turn_id AND t.thread_id = r.thread_id
833
+ WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ?
834
+ `).get(sourceId, jobId, runId);
835
+ if (row === undefined) {
836
+ throw new WebConsoleError("cron_reply_run_not_found", "Cron run not found for this agent and job.", 404);
837
+ }
838
+ const run = parseStoredCronRun(row.payload_json);
839
+ if (run.jobId !== jobId || run.runId !== runId) {
840
+ throw new WebConsoleError("storage_corrupt", "Stored cron run identity is inconsistent.", 500);
841
+ }
842
+ if (row.cron_suppressed === 1 || !isTerminalCronRun(run.status)) {
843
+ throw new WebConsoleError("cron_reply_unavailable", "Only visible terminal cron results can be replied to.", 422);
844
+ }
845
+ if (snapshotKind === "summary") {
846
+ return {
847
+ sourceId,
848
+ jobId,
849
+ runId,
850
+ snapshotKind,
851
+ capturedAt: this.now(),
852
+ run,
853
+ text: run.text ?? "",
854
+ ...(run.failureKind === undefined ? {} : { errorCode: run.failureKind }),
855
+ ...(run.error === undefined ? {} : { errorMessage: run.error }),
856
+ sourceFieldsTruncated: run.fieldsTruncated ?? [],
857
+ sourceTruncationKnown: true,
858
+ };
859
+ }
860
+ const telemetry = parseParts(row.parts_json).find((part) => part.type === "telemetry" && part.event === "cron_run");
861
+ const data = record(telemetry?.data);
862
+ if (data?.activityLoaded !== true) {
863
+ throw new WebConsoleError("cron_reply_detail_unavailable", "Cron run detail was not loaded when Reply was activated.", 422);
864
+ }
865
+ const detailFields = Array.isArray(data.detailFieldsTruncated)
866
+ && data.detailFieldsTruncated.every((field) => typeof field === "string")
867
+ ? data.detailFieldsTruncated
868
+ : undefined;
869
+ return {
870
+ sourceId,
871
+ jobId,
872
+ runId,
873
+ snapshotKind,
874
+ capturedAt: this.now(),
875
+ run,
876
+ text: row.text,
877
+ ...(row.error_code === null ? {} : { errorCode: row.error_code }),
878
+ ...(row.error_message === null ? {} : { errorMessage: row.error_message }),
879
+ ...(detailFields === undefined ? {} : { sourceFieldsTruncated: detailFields }),
880
+ sourceTruncationKnown: detailFields !== undefined,
881
+ };
882
+ }
883
+ cronReplyOperation(operationId) {
884
+ return this.transaction(() => {
885
+ const row = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
886
+ .get(operationId);
887
+ return row === undefined ? undefined : this.cronReplyState(row);
888
+ });
889
+ }
890
+ reserveCronReplyOperation(operationId, candidate) {
891
+ return this.transaction(() => {
892
+ const existing = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
893
+ .get(operationId);
894
+ if (existing !== undefined) {
895
+ this.assertCronReplyIdentity(existing, candidate.sourceId, candidate.jobId, candidate.runId);
896
+ return this.cronReplyState(existing);
897
+ }
898
+ const pending = this.database.prepare(`
899
+ SELECT * FROM cron_reply_operations
900
+ WHERE source_id = ? AND job_id = ? AND run_id = ? AND state = 'pending'
901
+ `).get(candidate.sourceId, candidate.jobId, candidate.runId);
902
+ if (pending !== undefined)
903
+ return { kind: "pending", operation: mapCronReplyOperation(pending) };
904
+ // Revalidate only eligibility/identity. The candidate remains the exact
905
+ // activation snapshot and is never replaced by a later detail refresh.
906
+ const eligible = this.database.prepare(`
907
+ SELECT r.payload_json, m.cron_suppressed
908
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id
909
+ WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ?
910
+ `).get(candidate.sourceId, candidate.jobId, candidate.runId);
911
+ if (eligible === undefined || eligible.cron_suppressed === 1
912
+ || !isTerminalCronRun(parseStoredCronRun(eligible.payload_json).status)) {
913
+ throw new WebConsoleError("cron_reply_unavailable", "This cron result is no longer eligible for Reply.", 422);
914
+ }
915
+ const threadId = randomUUID();
916
+ const conversationId = `web:${threadId}`;
917
+ const provenanceMessageId = randomUUID();
918
+ const resultMessageId = randomUUID();
919
+ const idempotencyKey = `web-cron-reply:v1:${createHash("sha256")
920
+ .update(`${candidate.sourceId}\0${candidate.jobId}\0${candidate.runId}\0${operationId}`)
921
+ .digest("hex")}`;
922
+ const snapshotText = formatCronReplyContext(candidate);
923
+ const snapshotSha256 = createHash("sha256").update(snapshotText).digest("hex");
924
+ const title = normalizeTitle(`Reply to ${candidate.jobId} · Run ${String(candidate.run.sequence)}`);
925
+ const override = this.database.prepare("SELECT model, effort FROM agent_run_overrides WHERE source_id = ?")
926
+ .get(candidate.sourceId);
927
+ this.database.prepare(`
928
+ INSERT INTO cron_reply_operations (
929
+ operation_id, source_id, job_id, run_id, thread_id, conversation_id,
930
+ provenance_message_id, result_message_id, idempotency_key, state, snapshot_kind,
931
+ snapshot_text, snapshot_sha256, title, run_model, run_effort, created_at
932
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?)
933
+ `).run(operationId, candidate.sourceId, candidate.jobId, candidate.runId, threadId, conversationId, provenanceMessageId, resultMessageId, idempotencyKey, candidate.snapshotKind, snapshotText, snapshotSha256, title, override?.model ?? null, override?.effort ?? null, candidate.capturedAt);
934
+ const row = this.requireCronReplyOperationRow(operationId);
935
+ return { kind: "reserved", operation: mapCronReplyOperation(row) };
936
+ });
937
+ }
938
+ completeCronReplyOperation(operationId, canonicalStatus) {
939
+ return this.transaction(() => {
940
+ const row = this.requireCronReplyOperationRow(operationId);
941
+ if (row.state !== "pending")
942
+ return this.cronReplyState(row);
943
+ if (row.snapshot_text === null || row.snapshot_sha256 === null || row.title === null
944
+ || row.provenance_message_id === null || row.result_message_id === null
945
+ || createHash("sha256").update(row.snapshot_text).digest("hex") !== row.snapshot_sha256) {
946
+ throw new WebConsoleError("storage_corrupt", "Cron reply reservation is incomplete or changed.", 500);
947
+ }
948
+ const now = this.now();
949
+ this.database.prepare(`
950
+ INSERT INTO threads (
951
+ id, source_id, conversation_id, title, title_manual, archived_at,
952
+ created_at, updated_at, run_model, run_effort, revision
953
+ ) VALUES (?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, 1)
954
+ `).run(row.thread_id, row.source_id, row.conversation_id, row.title, now, now, row.run_model, row.run_effort);
955
+ this.database.prepare(`
956
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
957
+ VALUES (?, ?, NULL, 'system', ?, ?, ?, 'complete')
958
+ `).run(row.provenance_message_id, row.thread_id, serializeParts([{ type: "text", text: AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE }]), now, now);
959
+ this.database.prepare(`
960
+ INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
961
+ VALUES (?, ?, NULL, 'assistant', ?, ?, ?, 'complete')
962
+ `).run(row.result_message_id, row.thread_id, serializeParts([{ type: "text", text: row.snapshot_text }]), now, now);
963
+ this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'cron_reply_imported', ?)")
964
+ .run(row.thread_id, now);
965
+ this.setSetting("current_thread_id", row.thread_id);
966
+ const settled = this.database.prepare(`
967
+ UPDATE cron_reply_operations
968
+ SET state = 'completed', canonical_status = ?, completed_at = ?
969
+ WHERE operation_id = ? AND state = 'pending'
970
+ `).run(canonicalStatus, now, operationId);
971
+ if (settled.changes !== 1)
972
+ return this.cronReplyState(this.requireCronReplyOperationRow(operationId));
973
+ return { kind: "completed", receipt: this.cronReplyReceipt(this.requireCronReplyOperationRow(operationId), false) };
974
+ });
975
+ }
976
+ failCronReplyOperation(operationId, reason) {
977
+ return this.transaction(() => {
978
+ const row = this.requireCronReplyOperationRow(operationId);
979
+ if (row.state !== "pending")
980
+ return this.cronReplyState(row);
981
+ const now = this.now();
982
+ this.database.prepare(`
983
+ UPDATE cron_reply_operations SET state = 'failed', provenance_message_id = NULL,
984
+ result_message_id = NULL, snapshot_text = NULL, snapshot_sha256 = NULL,
985
+ title = NULL, run_model = NULL, run_effort = NULL,
986
+ failure_reason = ?, failed_at = ?
987
+ WHERE operation_id = ? AND state = 'pending'
988
+ `).run(reason.slice(0, 128), now, operationId);
989
+ return this.cronReplyState(this.requireCronReplyOperationRow(operationId));
990
+ });
645
991
  }
646
992
  reconcileCronRuns(sourceId, jobId, runs) {
647
993
  return [...this.reconcileCronRunsResult(sourceId, jobId, runs).messages];
@@ -659,10 +1005,15 @@ export class WebStore {
659
1005
  : parseStoredCronJob(jobRow.payload_json).conversationId;
660
1006
  const ordered = [...runs].sort(compareCronRuns);
661
1007
  if (ordered.length === 0)
662
- return { messages: [], changed: false };
1008
+ return { messages: [], changed: false, writtenMessageIds: [] };
663
1009
  const now = this.now();
664
1010
  const messageIds = [];
1011
+ // Only the rows whose parts this run actually moved -- see
1012
+ // {@link CronRunReconciliationResult.writtenMessageIds}.
1013
+ const written = new Set();
665
1014
  let changed = false;
1015
+ let storedChanged = false;
1016
+ let visibleActivityAt;
666
1017
  this.transaction(() => {
667
1018
  for (const run of ordered) {
668
1019
  if (run.jobId !== jobId) {
@@ -676,24 +1027,30 @@ export class WebStore {
676
1027
  if (mapped !== undefined && (mapped.ordered_at !== run.orderedAt || mapped.sequence !== run.sequence)) {
677
1028
  throw new WebConsoleError("invalid_cron_response", "Cron run ordering identity changed after admission.", 502);
678
1029
  }
679
- const delivered = mapped === undefined
680
- ? this.database.prepare(`
1030
+ const delivered = this.database.prepare(`
681
1031
  SELECT d.message_id, m.turn_id
682
1032
  FROM notification_deliveries d
683
1033
  JOIN messages m ON m.id = d.message_id
684
1034
  WHERE d.source_id = ? AND d.job_id = ? AND d.run_id = ?
685
1035
  AND d.thread_id = ? AND d.completed_at IS NOT NULL
1036
+ AND (? IS NULL OR d.message_id = ?)
686
1037
  ORDER BY d.completed_at DESC LIMIT 1
687
- `).get(sourceId, jobId, run.runId, channel.thread_id)
688
- : undefined;
1038
+ `).get(sourceId, jobId, run.runId, channel.thread_id, mapped?.message_id ?? null, mapped?.message_id ?? null);
689
1039
  const turnId = mapped?.turn_id ?? delivered?.turn_id ?? cronEntityId("turn", sourceId, jobId, run.runId);
690
1040
  const messageId = mapped?.message_id ?? delivered?.message_id ?? cronEntityId("message", sourceId, jobId, run.runId);
691
1041
  messageIds.push(messageId);
692
1042
  const prior = this.database.prepare(`
693
- SELECT thread_id, turn_id, parts_json, created_at, status FROM messages WHERE id = ?
1043
+ SELECT thread_id, turn_id, parts_json, created_at, status, cron_suppressed FROM messages WHERE id = ?
694
1044
  `).get(messageId);
695
1045
  const priorParts = prior === undefined ? [] : parseParts(prior.parts_json);
696
- const parts = cronRunParts(run, priorParts, conversationId);
1046
+ let parts = cronRunParts(prior?.cron_suppressed === 1 && run.status === "succeeded" && run.fieldsTruncated?.includes("text") === true
1047
+ ? { ...run, text: NOTHING_TO_REPORT_SENTINEL } : run, priorParts, conversationId, delivered !== undefined);
1048
+ const suppressed = delivered === undefined && !hasMeaningfulCronContent(parts)
1049
+ && this.database.prepare("SELECT 1 FROM attachments WHERE message_id = ? LIMIT 1").get(messageId) === undefined
1050
+ && (definitelySilentCronRun(run)
1051
+ || (run.status === "succeeded" && run.fieldsTruncated?.includes("text") === true && prior?.cron_suppressed === 1));
1052
+ if (!suppressed)
1053
+ parts = parts.map(clearSilentCronPart);
697
1054
  const serializedParts = serializeParts(parts);
698
1055
  const status = cronMessageStatus(run.status);
699
1056
  const turnStatus = status === "running" ? "running" : status;
@@ -729,7 +1086,7 @@ export class WebStore {
729
1086
  started_at, finished_at, error_code, error_message
730
1087
  ) VALUES (?, ?, ?, ?, NULL, NULL, ?, ?, ?, ?, ?)
731
1088
  `).run(turnId, channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage);
732
- changed = true;
1089
+ storedChanged = true;
733
1090
  }
734
1091
  else if (existingTurn.thread_id !== channel.thread_id
735
1092
  || existingTurn.status !== turnStatus
@@ -744,26 +1101,39 @@ export class WebStore {
744
1101
  started_at = ?, finished_at = ?, error_code = ?, error_message = ?
745
1102
  WHERE id = ?
746
1103
  `).run(channel.thread_id, turnStatus, turnText, messageId, run.orderedAt, finishedAt, turnErrorCode, turnErrorMessage, turnId);
747
- changed = true;
1104
+ storedChanged = true;
748
1105
  }
749
1106
  if (prior === undefined) {
750
1107
  this.database.prepare(`
751
1108
  INSERT INTO messages (
752
- id, thread_id, turn_id, role, parts_json, created_at, updated_at, status
753
- ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?)
754
- `).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status);
755
- changed = true;
1109
+ id, thread_id, turn_id, role, parts_json, created_at, updated_at, status, cron_suppressed
1110
+ ) VALUES (?, ?, ?, 'assistant', ?, ?, ?, ?, ?)
1111
+ `).run(messageId, channel.thread_id, turnId, serializedParts, run.orderedAt, now, status, suppressed ? 1 : 0);
1112
+ written.add(messageId);
1113
+ storedChanged = true;
756
1114
  }
757
1115
  else if (prior.thread_id !== channel.thread_id
758
1116
  || prior.turn_id !== turnId
759
1117
  || prior.parts_json !== serializedParts
760
1118
  || prior.created_at !== run.orderedAt
761
- || prior.status !== status) {
762
- this.database.prepare(`
763
- UPDATE messages SET thread_id = ?, turn_id = ?, parts_json = ?,
764
- created_at = ?, updated_at = ?, status = ? WHERE id = ?
765
- `).run(channel.thread_id, turnId, serializedParts, run.orderedAt, now, status, messageId);
1119
+ || prior.status !== status
1120
+ || prior.cron_suppressed !== Number(suppressed)) {
1121
+ this.writeMessageParts(messageId, parts, now, {
1122
+ threadId: channel.thread_id,
1123
+ turnId,
1124
+ createdAt: run.orderedAt,
1125
+ status,
1126
+ });
1127
+ this.database.prepare("UPDATE messages SET cron_suppressed = ? WHERE id = ?").run(Number(suppressed), messageId);
1128
+ written.add(messageId);
1129
+ storedChanged = true;
1130
+ }
1131
+ if (written.has(messageId) && (!suppressed || prior?.cron_suppressed === 0))
766
1132
  changed = true;
1133
+ if (written.has(messageId) && !suppressed) {
1134
+ const activityAt = run.completedAt ?? run.startedAt ?? run.orderedAt;
1135
+ if (visibleActivityAt === undefined || activityAt > visibleActivityAt)
1136
+ visibleActivityAt = activityAt;
767
1137
  }
768
1138
  // Detail is a message projection, not a replacement for the compact
769
1139
  // page identity. Keeping the existing summary prevents the next
@@ -788,21 +1158,22 @@ export class WebStore {
788
1158
  payload_json = excluded.payload_json,
789
1159
  updated_at = excluded.updated_at
790
1160
  `).run(sourceId, jobId, run.runId, channel.thread_id, turnId, messageId, run.orderedAt, run.sequence, serializedRun, now);
791
- changed = true;
1161
+ storedChanged = true;
792
1162
  }
793
1163
  }
794
- if (changed) {
795
- const newest = ordered.at(-1);
796
- this.database.prepare(`
797
- UPDATE threads SET updated_at = MAX(updated_at, ?), revision = revision + 1 WHERE id = ?
798
- `).run(newest.completedAt ?? newest.startedAt ?? newest.orderedAt, channel.thread_id);
799
- this.recordThreadRevision(channel.thread_id, "cron_runs_reconciled", now);
1164
+ if (storedChanged) {
1165
+ if (changed) {
1166
+ this.database.prepare(`
1167
+ UPDATE threads SET updated_at = MAX(updated_at, COALESCE(?, updated_at)), revision = revision + 1 WHERE id = ?
1168
+ `).run(visibleActivityAt ?? null, channel.thread_id);
1169
+ this.recordThreadRevision(channel.thread_id, "cron_runs_reconciled", now);
1170
+ }
800
1171
  const excess = this.database.prepare(`
801
1172
  SELECT turn_id FROM (
802
- SELECT turn_id, ROW_NUMBER() OVER (
803
- PARTITION BY source_id, job_id ORDER BY ordered_at DESC, sequence DESC, run_id DESC
1173
+ SELECT r.turn_id, ROW_NUMBER() OVER (
1174
+ PARTITION BY r.source_id, r.job_id, m.cron_suppressed ORDER BY r.ordered_at DESC, r.sequence DESC, r.run_id DESC
804
1175
  ) AS retained_row
805
- FROM cron_run_messages WHERE source_id = ? AND job_id = ?
1176
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id WHERE r.source_id = ? AND r.job_id = ?
806
1177
  ) WHERE retained_row > 500
807
1178
  `).all(sourceId, jobId);
808
1179
  const remove = this.database.prepare("DELETE FROM turns WHERE id = ?");
@@ -811,8 +1182,12 @@ export class WebStore {
811
1182
  }
812
1183
  });
813
1184
  return {
814
- messages: [...new Set(messageIds)].map((messageId) => this.requireMessage(messageId)),
1185
+ messages: [...new Set(messageIds)].flatMap((id) => { const message = this.getMessage(id); return message === undefined ? [] : [message]; }),
815
1186
  changed,
1187
+ writtenMessageIds: [...written].filter((id) => this.getMessage(id) !== undefined),
1188
+ suppressedRunIds: runs.filter((run) => this.database.prepare(`SELECT 1 FROM cron_run_messages r
1189
+ JOIN messages m ON m.id = r.message_id WHERE r.source_id = ? AND r.job_id = ? AND r.run_id = ? AND m.cron_suppressed = 1
1190
+ `).get(sourceId, jobId, run.runId) !== undefined).map((run) => run.runId),
816
1191
  };
817
1192
  }
818
1193
  /** Append or update exactly one retained card for a web-origin process job. */
@@ -857,7 +1232,7 @@ export class WebStore {
857
1232
  .run(now, input.threadId);
858
1233
  this.recordThreadRevision(input.threadId, "process_job_card_created", now);
859
1234
  });
860
- return { thread: this.requireThread(input.threadId), duplicate: false };
1235
+ return { thread: this.requireThread(input.threadId), duplicate: false, messageId };
861
1236
  }
862
1237
  if (existing.thread_id !== input.threadId || existing.delivery_key !== input.deliveryKey) {
863
1238
  throw new WebConsoleError("notification_idempotency_conflict", "The process job was already bound to a different web thread or delivery key.", 409);
@@ -897,12 +1272,10 @@ export class WebStore {
897
1272
  if (existing.projection_sha256 === projectionSha256
898
1273
  && responseText === (existing.response_text ?? undefined)
899
1274
  && !replyPartsChanged) {
900
- return { thread, duplicate: true };
1275
+ return { thread, duplicate: true, messageId: existing.message_id };
901
1276
  }
902
1277
  this.transaction(() => {
903
- this.database.prepare(`
904
- UPDATE messages SET parts_json = ?, updated_at = ?, status = ? WHERE id = ?
905
- `).run(serializeParts([processJobPart(projection, responseText), ...nextReplyParts]), now, isTerminalJobState(projection.state) ? "complete" : "running", existing.message_id);
1278
+ this.writeMessageParts(existing.message_id, [processJobPart(projection, responseText), ...nextReplyParts], now, { status: isTerminalJobState(projection.state) ? "complete" : "running" });
906
1279
  this.database.prepare(`
907
1280
  UPDATE process_job_cards
908
1281
  SET projection_sha256 = ?, response_text = ?, updated_at = ?
@@ -912,7 +1285,7 @@ export class WebStore {
912
1285
  .run(now, input.threadId);
913
1286
  this.recordThreadRevision(input.threadId, "process_job_card_updated", now);
914
1287
  });
915
- return { thread: this.requireThread(input.threadId), duplicate: false };
1288
+ return { thread: this.requireThread(input.threadId), duplicate: false, messageId: existing.message_id };
916
1289
  }
917
1290
  /** Durably claim one web process-job wake before touching the operator. */
918
1291
  reserveProcessJobWake(input) {
@@ -943,6 +1316,11 @@ export class WebStore {
943
1316
  `).run(input.sourceId, input.jobId, input.deliveryKey, input.threadId, now);
944
1317
  return { kind: "new" };
945
1318
  }
1319
+ /**
1320
+ * Confirm the wake's delivery path, independently of the associated turn's
1321
+ * eventual outcome. `completed/follow_up` means the exact turn was durably
1322
+ * admitted; it does not mean that turn completed successfully.
1323
+ */
946
1324
  completeProcessJobWake(input) {
947
1325
  const result = this.database.prepare(`
948
1326
  UPDATE process_job_wake_deliveries
@@ -952,13 +1330,220 @@ export class WebStore {
952
1330
  if (result.changes !== 1) {
953
1331
  throw new WebConsoleError("notification_reservation_lost", "The process-job wake reservation was lost.", 409);
954
1332
  }
1333
+ if (input.turnId !== undefined) {
1334
+ const turn = this.requireTurn(input.turnId);
1335
+ if (turn.status === "complete" && this.hasProcessJobTurnAssociation(input.turnId)) {
1336
+ const message = this.requireMessage(turn.assistant_message_id);
1337
+ const normalized = normalizeMonitorTerminalReply(message.parts, true);
1338
+ if (normalized.changed)
1339
+ this.writeMessageParts(message.id, normalized.parts, this.now());
1340
+ this.repairMonitorResponsePush(input.turnId, normalized.parts);
1341
+ return normalized.changed ? this.requireMessage(message.id) : undefined;
1342
+ }
1343
+ }
1344
+ return undefined;
955
1345
  }
956
- /** Release a reservation only while no operator delivery has begun. */
957
- abandonProcessJobWake(input) {
1346
+ /** Bind an accepted host wake to its exact follow-up before crossing the turn boundary. */
1347
+ associateProcessJobWakeTurn(deliveryKey, turnId, pending = true) {
1348
+ const result = this.database.prepare(`
1349
+ UPDATE process_job_wake_deliveries SET turn_id = ?
1350
+ WHERE delivery_key = ? AND state = 'accepted'
1351
+ AND thread_id = (SELECT turns.thread_id FROM turns JOIN threads ON threads.id = turns.thread_id
1352
+ WHERE turns.id = ? AND threads.source_id = process_job_wake_deliveries.source_id)
1353
+ `).run(pending ? turnId : null, deliveryKey, turnId);
1354
+ if (result.changes !== 1) {
1355
+ throw new WebConsoleError("notification_reservation_lost", "The process-job wake turn association was lost.", 409);
1356
+ }
1357
+ }
1358
+ hasProcessJobTurnAssociation(turnId, deliveryKey) {
1359
+ return this.database.prepare(`
1360
+ SELECT 1 FROM process_job_wake_deliveries AS deliveries
1361
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1362
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1363
+ WHERE deliveries.turn_id = turns.id AND
1364
+ (deliveries.state = 'completed' OR (deliveries.state = 'accepted' AND deliveries.delivery_key = ?))
1365
+ `).get(turnId, deliveryKey ?? null) !== undefined;
1366
+ }
1367
+ /** Release only a pending notification hold; the ambiguous delivery reservation remains durable. */
1368
+ releaseProcessJobWakeTurn(deliveryKey, turnId) {
958
1369
  this.database.prepare(`
1370
+ UPDATE process_job_wake_deliveries SET turn_id = NULL
1371
+ WHERE delivery_key = ? AND turn_id = ? AND state = 'accepted'
1372
+ `).run(deliveryKey, turnId);
1373
+ }
1374
+ /** Release a reservation only while no operator delivery has begun, proving the exact claim was removed. */
1375
+ abandonProcessJobWake(input) {
1376
+ const result = this.database.prepare(`
959
1377
  DELETE FROM process_job_wake_deliveries
960
1378
  WHERE source_id = ? AND job_id = ? AND delivery_key = ? AND state = 'accepted'
961
1379
  `).run(input.sourceId, input.jobId, input.deliveryKey);
1380
+ return result.changes === 1;
1381
+ }
1382
+ /** Durably claim one Monitor wake before touching the operator. */
1383
+ reserveMonitorWake(input) {
1384
+ const monitor = parseMonitorProjection(input.monitor);
1385
+ if (monitor.monitorId !== input.monitorId) {
1386
+ throw new WebConsoleError("invalid_notification", "The Monitor projection does not match its delivery identity.", 409);
1387
+ }
1388
+ const thread = this.database.prepare("SELECT source_id, archived_at, trigger_kind FROM threads WHERE id = ?")
1389
+ .get(input.threadId);
1390
+ if (thread === undefined || thread.source_id !== input.sourceId) {
1391
+ throw new WebConsoleError("invalid_notification", "The Monitor wake does not match its web thread.", 409);
1392
+ }
1393
+ if (thread.archived_at !== null || thread.trigger_kind !== null) {
1394
+ throw new WebConsoleError("thread_archived", "The Monitor wake destination is not an active web conversation.", 409);
1395
+ }
1396
+ const existing = this.database.prepare(`
1397
+ SELECT monitor_id, thread_id, payload_sha256, state, disposition
1398
+ FROM monitor_wake_deliveries WHERE source_id = ? AND delivery_key = ?
1399
+ `).get(input.sourceId, input.deliveryKey);
1400
+ if (existing !== undefined) {
1401
+ if (existing.monitor_id !== input.monitorId
1402
+ || existing.thread_id !== input.threadId
1403
+ || existing.payload_sha256 !== input.payloadSha256) {
1404
+ throw new WebConsoleError("notification_idempotency_conflict", "The Monitor delivery key was already used for a different wake.", 409);
1405
+ }
1406
+ if (existing.state === "completed"
1407
+ && (existing.disposition === "steered" || existing.disposition === "follow_up")) {
1408
+ return { kind: "completed", disposition: existing.disposition };
1409
+ }
1410
+ return { kind: "uncertain" };
1411
+ }
1412
+ this.database.prepare(`
1413
+ INSERT INTO monitor_wake_deliveries (
1414
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
1415
+ state, disposition, turn_id, created_at, completed_at
1416
+ ) VALUES (?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, ?, NULL)
1417
+ `).run(input.sourceId, input.monitorId, input.deliveryKey, input.threadId, input.payloadSha256, JSON.stringify(monitor), this.now());
1418
+ return { kind: "new" };
1419
+ }
1420
+ /** Hold terminal pushes while this exact turn's steering receipt is unresolved. */
1421
+ setMonitorWakeSteeringTurn(sourceId, deliveryKey, turnId, pending) {
1422
+ const turn = this.requireTurn(turnId);
1423
+ const changed = this.database.prepare(`
1424
+ UPDATE monitor_wake_deliveries SET turn_id = ?
1425
+ WHERE source_id = ? AND delivery_key = ? AND thread_id = ? AND state = 'accepted'
1426
+ `).run(pending ? turnId : null, sourceId, deliveryKey, turn.thread_id);
1427
+ if (changed.changes !== 1) {
1428
+ throw new WebConsoleError("notification_reservation_lost", "The Monitor steering reservation was lost.", 409);
1429
+ }
1430
+ }
1431
+ completeMonitorWake(input) {
1432
+ let messageId;
1433
+ this.transaction(() => {
1434
+ const result = this.database.prepare(`
1435
+ UPDATE monitor_wake_deliveries
1436
+ SET state = 'completed', disposition = ?, turn_id = ?, completed_at = ?
1437
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
1438
+ `).run(input.disposition, input.turnId ?? null, this.now(), input.sourceId, input.monitorId, input.deliveryKey);
1439
+ if (result.changes !== 1) {
1440
+ throw new WebConsoleError("notification_reservation_lost", "The Monitor wake reservation was lost.", 409);
1441
+ }
1442
+ if (input.turnId === undefined)
1443
+ return;
1444
+ const turn = this.requireTurn(input.turnId);
1445
+ const reservation = this.database.prepare(`
1446
+ SELECT thread_id, projection_json FROM monitor_wake_deliveries
1447
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ?
1448
+ `).get(input.sourceId, input.monitorId, input.deliveryKey);
1449
+ if (reservation?.thread_id !== turn.thread_id) {
1450
+ throw new WebConsoleError("monitor_origin_mismatch", "The Monitor activity does not belong to this turn.", 409);
1451
+ }
1452
+ if (reservation.projection_json === null)
1453
+ return;
1454
+ let projection;
1455
+ try {
1456
+ projection = parseMonitorProjection(JSON.parse(reservation.projection_json));
1457
+ if (projection.monitorId !== input.monitorId)
1458
+ throw new TypeError("Monitor identity mismatch.");
1459
+ }
1460
+ catch {
1461
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1462
+ }
1463
+ const message = this.requireMessage(turn.assistant_message_id);
1464
+ const raw = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
1465
+ .get(message.id);
1466
+ const original = parseParts(raw.parts_json);
1467
+ const normalized = turn.status === "complete" ? normalizeMonitorTerminalReply(original) : { parts: original, changed: false };
1468
+ const parts = normalized.parts;
1469
+ const activityChanged = upsertMonitorActivity(parts, projection, input.deliveryKey);
1470
+ if (turn.status === "complete")
1471
+ this.repairMonitorResponsePush(input.turnId, parts);
1472
+ if (!activityChanged && !normalized.changed)
1473
+ return;
1474
+ this.writeMessageParts(message.id, parts, this.now());
1475
+ messageId = message.id;
1476
+ });
1477
+ return messageId === undefined ? undefined : this.requireMessage(messageId);
1478
+ }
1479
+ hasMonitorTurnAssociation(turnId, deliveryKey) {
1480
+ const rows = this.database.prepare(`
1481
+ SELECT deliveries.delivery_key FROM monitor_wake_deliveries AS deliveries
1482
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1483
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1484
+ WHERE (deliveries.state = 'completed' AND deliveries.turn_id = turns.id
1485
+ AND deliveries.disposition IN ('steered', 'follow_up'))
1486
+ OR (deliveries.state = 'accepted' AND deliveries.delivery_key = ?)
1487
+ `).all(turnId, deliveryKey ?? null);
1488
+ return rows.length > 0;
1489
+ }
1490
+ repairMonitorResponsePush(turnId, parts) {
1491
+ const key = `turn:${turnId}:terminal`;
1492
+ if (hasMonitorReplyContent(parts)) {
1493
+ this.database.prepare("UPDATE push_events SET body = ? WHERE logical_key = ? AND kind = 'response.ready'")
1494
+ .run(webPushPreview(monitorReplyText(parts)), key);
1495
+ }
1496
+ else {
1497
+ const now = this.now();
1498
+ this.database.prepare(`
1499
+ UPDATE push_deliveries SET status = 'suppressed', updated_at = ?, finished_at = ?
1500
+ WHERE status = 'pending' AND event_id IN (
1501
+ SELECT id FROM push_events WHERE logical_key = ? AND kind = 'response.ready'
1502
+ )
1503
+ `).run(now, now, key);
1504
+ }
1505
+ }
1506
+ /** Resolve only an exact Monitor live-input receipt belonging to this turn. */
1507
+ monitorWakeProjection(turnId, deliveryKey) {
1508
+ const row = this.database.prepare(`
1509
+ SELECT deliveries.monitor_id, deliveries.projection_json
1510
+ FROM monitor_wake_deliveries AS deliveries
1511
+ JOIN turns ON turns.id = ? AND turns.thread_id = deliveries.thread_id
1512
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = deliveries.source_id
1513
+ WHERE deliveries.delivery_key = ?
1514
+ `).get(turnId, deliveryKey);
1515
+ if (row === undefined || row.projection_json === null)
1516
+ return undefined;
1517
+ try {
1518
+ const projection = parseMonitorProjection(JSON.parse(row.projection_json));
1519
+ if (projection.monitorId !== row.monitor_id)
1520
+ throw new TypeError("Monitor identity mismatch.");
1521
+ return projection;
1522
+ }
1523
+ catch {
1524
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
1525
+ }
1526
+ }
1527
+ /** Resolve only an exact process-job live-input receipt belonging to this turn. */
1528
+ processJobWakeForTurn(turnId, deliveryKey) {
1529
+ return this.database.prepare(`
1530
+ SELECT cards.job_id AS jobId, cards.delivery_key AS deliveryKey
1531
+ FROM process_job_cards AS cards
1532
+ JOIN process_job_wake_deliveries AS deliveries
1533
+ ON deliveries.source_id = cards.source_id
1534
+ AND deliveries.job_id = cards.job_id
1535
+ AND deliveries.delivery_key = cards.delivery_key
1536
+ JOIN turns ON turns.id = ? AND turns.thread_id = cards.thread_id
1537
+ JOIN threads ON threads.id = turns.thread_id AND threads.source_id = cards.source_id
1538
+ WHERE cards.delivery_key = ? AND deliveries.turn_id = turns.id
1539
+ `).get(turnId, deliveryKey);
1540
+ }
1541
+ /** Release a Monitor reservation only before any operator delivery begins. */
1542
+ abandonMonitorWake(input) {
1543
+ this.database.prepare(`
1544
+ DELETE FROM monitor_wake_deliveries
1545
+ WHERE source_id = ? AND monitor_id = ? AND delivery_key = ? AND state = 'accepted'
1546
+ `).run(input.sourceId, input.monitorId, input.deliveryKey);
962
1547
  }
963
1548
  /** Exact retained binding used before proxying a single operator job. */
964
1549
  processJobCardBelongsToThread(sourceId, threadId, jobId) {
@@ -967,7 +1552,7 @@ export class WebStore {
967
1552
  WHERE source_id = ? AND thread_id = ? AND job_id = ?
968
1553
  `).get(sourceId, threadId, jobId) !== undefined;
969
1554
  }
970
- createThread(sourceId) {
1555
+ createThread(sourceId, explicit = {}) {
971
1556
  const agent = this.getAgent(sourceId);
972
1557
  if (agent === undefined) {
973
1558
  throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
@@ -975,35 +1560,23 @@ export class WebStore {
975
1560
  const id = randomUUID();
976
1561
  const now = this.now();
977
1562
  this.transaction(() => {
1563
+ const override = this.database.prepare(`
1564
+ SELECT model, effort FROM agent_run_overrides WHERE source_id = ?
1565
+ `).get(sourceId);
1566
+ const model = explicit.model === undefined ? override?.model ?? null : explicit.model;
1567
+ const effort = explicit.effort === undefined ? override?.effort ?? null : explicit.effort;
978
1568
  this.database.prepare(`
979
1569
  INSERT INTO threads (
980
1570
  id, source_id, conversation_id, title, title_manual, archived_at,
981
- created_at, updated_at, revision
982
- ) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, 1)
983
- `).run(id, sourceId, `web:${id}`, now, now);
1571
+ created_at, updated_at, run_model, run_effort, revision
1572
+ ) VALUES (?, ?, ?, 'New conversation', 0, NULL, ?, ?, ?, ?, 1)
1573
+ `).run(id, sourceId, `web:${id}`, now, now, model, effort);
984
1574
  this.database.prepare("INSERT INTO revisions (entity_kind, entity_id, revision, event, created_at) VALUES ('thread', ?, 1, 'created', ?)")
985
1575
  .run(id, now);
986
1576
  this.setSetting("current_thread_id", id);
987
1577
  });
988
1578
  return this.requireThread(id);
989
1579
  }
990
- /** Bounded bootstrap: at most one 200-row bucket per (source_id, archived). */
991
- listThreads() {
992
- const rows = this.database.prepare(threadSelectSql(`
993
- WHERE t.id IN (
994
- SELECT id FROM (
995
- SELECT id,
996
- ROW_NUMBER() OVER (
997
- PARTITION BY source_id, CASE WHEN archived_at IS NULL THEN 0 ELSE 1 END
998
- ORDER BY updated_at DESC, id DESC
999
- ) AS bucket_row
1000
- FROM threads
1001
- ) WHERE bucket_row <= ${String(WEB_THREAD_PAGE_MAX)}
1002
- )
1003
- ORDER BY t.updated_at DESC, t.id DESC
1004
- `)).all();
1005
- return rows.map((row) => this.mapThread(row));
1006
- }
1007
1580
  listThreadsPage(input) {
1008
1581
  if (this.getAgent(input.sourceId) === undefined) {
1009
1582
  throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
@@ -1059,7 +1632,7 @@ export class WebStore {
1059
1632
  FROM message_search
1060
1633
  JOIN messages m ON m.rowid = message_search.rowid
1061
1634
  JOIN threads t ON t.id = m.thread_id
1062
- WHERE message_search MATCH ? AND t.source_id = ?
1635
+ WHERE message_search MATCH ? AND t.source_id = ? AND ${visibleMessageSql("m")}
1063
1636
  ORDER BY rank
1064
1637
  LIMIT ?
1065
1638
  `).all(WEB_SEARCH_HIGHLIGHT_OPEN, WEB_SEARCH_HIGHLIGHT_CLOSE, match, input.sourceId, MESSAGE_SEARCH_SCAN_LIMIT + 1);
@@ -1155,12 +1728,12 @@ export class WebStore {
1155
1728
  const row = this.database.prepare(threadSelectSql("WHERE t.id = ?")).get(resolved);
1156
1729
  return row === undefined ? undefined : this.mapThread(row);
1157
1730
  }
1158
- getThreadDetail(id) {
1731
+ getThreadDetail(id, options = {}) {
1159
1732
  const resolved = this.resolveThreadId(id);
1160
1733
  const thread = this.getThread(resolved);
1161
1734
  if (thread === undefined)
1162
1735
  return undefined;
1163
- const page = this.listMessagesPage(resolved);
1736
+ const page = this.listMessagesPage(resolved, { limit: options.limit ?? WEB_MESSAGE_PAGE_DEFAULT });
1164
1737
  return {
1165
1738
  thread,
1166
1739
  messages: page.messages,
@@ -1168,7 +1741,7 @@ export class WebStore {
1168
1741
  };
1169
1742
  }
1170
1743
  getMessage(id) {
1171
- const row = this.database.prepare("SELECT * FROM messages WHERE id = ?")
1744
+ const row = this.database.prepare(`SELECT * FROM messages WHERE id = ? AND ${visibleMessageSql("messages")}`)
1172
1745
  .get(id);
1173
1746
  return row === undefined ? undefined : this.mapMessage(row);
1174
1747
  }
@@ -1193,10 +1766,11 @@ export class WebStore {
1193
1766
  }
1194
1767
  values.push(limit + 1);
1195
1768
  const rows = this.database.prepare(`
1196
- SELECT m.*, ${orderedAt} AS ordered_at, ${rank} AS role_rank, m.rowid AS storage_rowid
1769
+ SELECT m.*, ${orderedAt} AS ordered_at, ${rank} AS role_rank, m.rowid AS storage_rowid,
1770
+ t.finished_at AS turn_finished_at
1197
1771
  FROM messages m
1198
1772
  LEFT JOIN turns t ON t.id = m.turn_id
1199
- WHERE m.thread_id = ? ${beforeSql}
1773
+ WHERE m.thread_id = ? AND ${visibleMessageSql("m")} ${beforeSql}
1200
1774
  ORDER BY ordered_at DESC, role_rank DESC, m.created_at DESC, storage_rowid DESC
1201
1775
  LIMIT ?
1202
1776
  `).all(...values);
@@ -1234,14 +1808,44 @@ export class WebStore {
1234
1808
  this.setSetting("current_thread_id", resolved);
1235
1809
  }
1236
1810
  patchThread(id, patch) {
1811
+ return this.transaction(() => this.writeThreadPatch(id, patch));
1812
+ }
1813
+ /**
1814
+ * Compare-and-set: apply `patch` only while the conversation still carries no
1815
+ * run override, and report which way it went.
1816
+ *
1817
+ * The console's one-time adoption of a browser-local override is the caller.
1818
+ * Read and write are ONE `BEGIN IMMEDIATE` here rather than two statements
1819
+ * either side of a service-level check: the process lease
1820
+ * (`state-paths.ts`) is held on a different database file for the service
1821
+ * lifetime, so it makes this process the only *service* writing, not this
1822
+ * statement the only writer of `web.sqlite`. Any other connection to the
1823
+ * state DB -- a maintenance script, a second console pointed at the same
1824
+ * state dir -- could land between a bare read and a bare write, and did in a
1825
+ * probe.
1826
+ */
1827
+ patchThreadIfRunConfigUnset(id, patch) {
1828
+ return this.transaction(() => {
1829
+ const resolved = this.resolveThreadId(id);
1830
+ const current = this.requireThread(resolved);
1831
+ if (current.runModel !== null || current.runEffort !== null) {
1832
+ return { applied: false, thread: { ...current, sourceId: current.sourceId } };
1833
+ }
1834
+ return { applied: true, thread: this.writeThreadPatch(resolved, patch) };
1835
+ });
1836
+ }
1837
+ /** The body of {@link patchThread}. Assumes an open transaction. */
1838
+ writeThreadPatch(id, patch) {
1237
1839
  id = this.resolveThreadId(id);
1238
1840
  const current = this.requireThread(id);
1239
1841
  const now = this.now();
1240
1842
  const title = patch.title === undefined ? undefined : normalizeTitle(patch.title);
1241
1843
  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];
1844
+ const runModel = patch.model === undefined ? undefined : patch.model;
1845
+ const runEffort = patch.effort === undefined ? undefined : patch.effort;
1846
+ {
1847
+ const sets = [];
1848
+ const values = [];
1245
1849
  if (title !== undefined) {
1246
1850
  sets.push("title = ?", "title_manual = 1");
1247
1851
  values.push(title);
@@ -1250,13 +1854,34 @@ export class WebStore {
1250
1854
  sets.push("archived_at = ?");
1251
1855
  values.push(archivedAt);
1252
1856
  }
1857
+ if (runModel !== undefined) {
1858
+ sets.push("run_model = ?");
1859
+ values.push(runModel);
1860
+ }
1861
+ if (runEffort !== undefined) {
1862
+ sets.push("run_effort = ?");
1863
+ values.push(runEffort);
1864
+ }
1865
+ // A model/effort-only patch must not reorder the sidebar, so `updated_at`
1866
+ // only advances when title or archived state actually changes.
1867
+ if (title !== undefined || archivedAt !== undefined) {
1868
+ sets.push("updated_at = ?");
1869
+ values.push(now);
1870
+ }
1871
+ sets.push("revision = revision + 1");
1253
1872
  values.push(id);
1254
1873
  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);
1874
+ this.recordThreadRevision(id, title !== undefined
1875
+ ? "title_changed"
1876
+ : archivedAt !== undefined
1877
+ ? patch.archived
1878
+ ? "archived"
1879
+ : "unarchived"
1880
+ : "run_config_changed", now);
1256
1881
  if (patch.archived === true && this.currentThreadId() === id) {
1257
1882
  this.database.prepare("DELETE FROM settings WHERE key = 'current_thread_id'").run();
1258
1883
  }
1259
- });
1884
+ }
1260
1885
  return { ...this.requireThread(id), sourceId: current.sourceId };
1261
1886
  }
1262
1887
  /** Whether the current interactive thread still accepts agent-proposed titles. */
@@ -1287,7 +1912,7 @@ export class WebStore {
1287
1912
  });
1288
1913
  return changed ? this.requireThread(id) : undefined;
1289
1914
  }
1290
- async deleteArchivedThread(id) {
1915
+ async deleteArchivedThread(id, options = {}) {
1291
1916
  id = this.resolveThreadId(id);
1292
1917
  const thread = this.requireThread(id);
1293
1918
  if (thread.archivedAt === null) {
@@ -1301,8 +1926,33 @@ export class WebStore {
1301
1926
  const attachments = this.database.prepare("SELECT * FROM attachments WHERE thread_id = ?")
1302
1927
  .all(id);
1303
1928
  this.transaction(() => {
1929
+ if (options.emptyOnly === true) {
1930
+ const hasContent = thread.trigger !== undefined || this.database.prepare(`
1931
+ SELECT 1 FROM messages WHERE thread_id = ?
1932
+ UNION ALL SELECT 1 FROM turns WHERE thread_id = ?
1933
+ UNION ALL SELECT 1 FROM attachments WHERE thread_id = ?
1934
+ UNION ALL SELECT 1 FROM live_inputs WHERE thread_id = ?
1935
+ UNION ALL SELECT 1 FROM web_submissions WHERE thread_id = ?
1936
+ UNION ALL SELECT 1 FROM cron_reply_operations WHERE thread_id = ? AND state = 'completed'
1937
+ UNION ALL SELECT 1 FROM process_job_wake_deliveries WHERE thread_id = ?
1938
+ UNION ALL SELECT 1 FROM monitor_wake_deliveries WHERE thread_id = ?
1939
+ UNION ALL SELECT 1 FROM notification_deliveries WHERE thread_id = ?
1940
+ UNION ALL SELECT 1 FROM push_events WHERE thread_id = ?
1941
+ LIMIT 1
1942
+ `).get(id, id, id, id, id, id, id, id, id, id) !== undefined;
1943
+ if (hasContent) {
1944
+ throw new WebConsoleError("thread_not_empty", "The conversation now contains activity and was kept in Archived.", 409);
1945
+ }
1946
+ }
1304
1947
  const now = this.now();
1305
1948
  this.database.prepare(`
1949
+ UPDATE cron_reply_operations SET state = 'tombstoned', provenance_message_id = NULL,
1950
+ result_message_id = NULL, snapshot_text = NULL, snapshot_sha256 = NULL,
1951
+ title = NULL, run_model = NULL, run_effort = NULL, completed_at = NULL,
1952
+ failure_reason = 'thread_deleted', tombstoned_at = ?
1953
+ WHERE thread_id = ? AND state = 'completed'
1954
+ `).run(now, id);
1955
+ this.database.prepare(`
1306
1956
  UPDATE push_deliveries SET status = 'dropped', updated_at = ?, finished_at = ?, last_error_code = 'thread_deleted'
1307
1957
  WHERE event_id IN (SELECT id FROM push_events WHERE thread_id = ?)
1308
1958
  AND status IN ('pending', 'sending')
@@ -1503,7 +2153,7 @@ export class WebStore {
1503
2153
  if (input.quote.text.trim().length === 0 || input.quote.messageId.trim().length === 0) {
1504
2154
  throw new WebConsoleError("invalid_quote", "Quoted text and its source message are required.", 400);
1505
2155
  }
1506
- const source = this.database.prepare("SELECT id FROM messages WHERE id = ? AND thread_id = ?").get(input.quote.messageId, threadId);
2156
+ const source = this.database.prepare(`SELECT id FROM messages WHERE id = ? AND thread_id = ? AND ${visibleMessageSql("messages")}`).get(input.quote.messageId, threadId);
1507
2157
  if (source === undefined) {
1508
2158
  throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
1509
2159
  }
@@ -1515,10 +2165,10 @@ export class WebStore {
1515
2165
  this.transaction(() => {
1516
2166
  this.database.prepare(`
1517
2167
  INSERT INTO turns (
1518
- id, thread_id, status, text, model, effort, assistant_message_id,
2168
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1519
2169
  started_at, finished_at, error_code, error_message
1520
- ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
1521
- `).run(turnId, threadId, input.text, input.model ?? null, input.effort ?? null, assistantMessageId, now);
2170
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2171
+ `).run(turnId, threadId, input.text, input.model ?? null, input.effort ?? null, input.requestedModel ?? null, input.requestedEffort ?? null, assistantMessageId, now);
1522
2172
  const userParts = [
1523
2173
  ...(input.quote === undefined
1524
2174
  ? []
@@ -1584,17 +2234,38 @@ export class WebStore {
1584
2234
  const turnId = randomUUID();
1585
2235
  const assistantMessageId = randomUUID();
1586
2236
  const now = this.now();
2237
+ if (input.processJobWake !== undefined) {
2238
+ const card = this.database.prepare(`
2239
+ SELECT 1 FROM process_job_cards AS cards
2240
+ JOIN threads ON threads.id = cards.thread_id AND threads.source_id = cards.source_id
2241
+ JOIN process_job_wake_deliveries AS deliveries
2242
+ ON deliveries.source_id = cards.source_id
2243
+ AND deliveries.job_id = cards.job_id
2244
+ AND deliveries.delivery_key = cards.delivery_key
2245
+ WHERE cards.thread_id = ? AND cards.job_id = ? AND cards.delivery_key = ?
2246
+ AND deliveries.state = 'accepted' AND deliveries.turn_id IS NULL
2247
+ `).get(threadId, input.processJobWake.jobId, input.processJobWake.deliveryKey);
2248
+ if (card === undefined) {
2249
+ throw new WebConsoleError("invalid_notification", "The process-job wake does not match its retained card.", 409);
2250
+ }
2251
+ }
2252
+ const initialParts = input.processJobWake === undefined
2253
+ ? []
2254
+ : [{ type: "process-job-wake", ...input.processJobWake }];
1587
2255
  this.transaction(() => {
1588
2256
  this.database.prepare(`
1589
2257
  INSERT INTO turns (
1590
- id, thread_id, status, text, model, effort, assistant_message_id,
2258
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1591
2259
  started_at, finished_at, error_code, error_message
1592
- ) VALUES (?, ?, 'running', ?, NULL, NULL, ?, ?, NULL, NULL, NULL)
1593
- `).run(turnId, threadId, input.prompt, assistantMessageId, now);
2260
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2261
+ `).run(turnId, threadId, input.storedPrompt ?? input.prompt, input.model ?? null, input.effort ?? null, input.requestedModel ?? null, input.requestedEffort ?? null, assistantMessageId, now);
1594
2262
  this.database.prepare(`
1595
2263
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
1596
- VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
1597
- `).run(assistantMessageId, threadId, turnId, now, now);
2264
+ VALUES (?, ?, ?, 'assistant', ?, ?, ?, 'running')
2265
+ `).run(assistantMessageId, threadId, turnId, serializeParts(initialParts), now, now);
2266
+ if (input.processJobWake !== undefined) {
2267
+ this.associateProcessJobWakeTurn(input.processJobWake.deliveryKey, turnId);
2268
+ }
1598
2269
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?").run(now, threadId);
1599
2270
  this.recordThreadRevision(threadId, "background_follow_up_started", now);
1600
2271
  this.setSetting("current_thread_id", threadId);
@@ -1608,7 +2279,7 @@ export class WebStore {
1608
2279
  thread: this.requireThread(threadId),
1609
2280
  };
1610
2281
  }
1611
- reserveLiveInput(threadId, text) {
2282
+ reserveLiveInput(threadId, text, quote, operatorText = text) {
1612
2283
  threadId = this.resolveThreadId(threadId);
1613
2284
  const thread = this.requireThread(threadId);
1614
2285
  if (thread.archivedAt !== null) {
@@ -1622,9 +2293,15 @@ export class WebStore {
1622
2293
  if (text.trim().length === 0) {
1623
2294
  throw new WebConsoleError("empty_turn", "Enter a message.", 400);
1624
2295
  }
1625
- if (text.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
2296
+ if (operatorText.length > AGENT_LIVE_INPUT_MAX_CHARACTERS) {
1626
2297
  throw new WebConsoleError("turn_text_too_large", `A live follow-up may contain at most ${AGENT_LIVE_INPUT_MAX_CHARACTERS} characters.`, 413);
1627
2298
  }
2299
+ if (quote !== undefined) {
2300
+ const source = this.database.prepare(`SELECT id FROM messages WHERE id = ? AND thread_id = ? AND ${visibleMessageSql("messages")}`).get(quote.messageId, threadId);
2301
+ if (quote.text.trim().length === 0 || source === undefined) {
2302
+ throw new WebConsoleError("invalid_quote", "The quoted message does not belong to this conversation.", 400);
2303
+ }
2304
+ }
1628
2305
  const usage = this.database.prepare("SELECT COUNT(*) AS count FROM live_inputs WHERE thread_id = ?").get(threadId);
1629
2306
  if (usage.count >= WEB_MAX_LIVE_INPUTS_PER_THREAD) {
1630
2307
  throw new WebConsoleError("live_input_queue_full", "Too many follow-up messages are waiting.", 429);
@@ -1634,8 +2311,18 @@ export class WebStore {
1634
2311
  const messageId = randomUUID();
1635
2312
  const now = this.now();
1636
2313
  const status = active === undefined ? "queued" : "offered";
2314
+ // An idle forced steer becomes an ordinary turn, so freeze the thread's
2315
+ // selected route now rather than consulting a potentially changed
2316
+ // override when the queue drains. An active turn owns its own route,
2317
+ // including explicit null/default values; never fall through from those
2318
+ // nulls to the thread override.
2319
+ const model = active === undefined ? thread.runModel : active.model;
2320
+ const effort = active === undefined ? thread.runEffort : active.effort;
1637
2321
  const parts = [
1638
2322
  liveInputTelemetry(status === "offered" ? "pending" : "queued"),
2323
+ ...(quote === undefined
2324
+ ? []
2325
+ : [{ type: "telemetry", event: QUOTE_TELEMETRY_EVENT, data: quote }]),
1639
2326
  { type: "text", text },
1640
2327
  ];
1641
2328
  this.transaction(() => {
@@ -1647,7 +2334,7 @@ export class WebStore {
1647
2334
  INSERT INTO live_inputs (
1648
2335
  id, thread_id, message_id, active_turn_id, text, model, effort, status, created_at, updated_at
1649
2336
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1650
- `).run(id, threadId, messageId, active?.id ?? null, text, active?.model ?? null, active?.effort ?? null, status, now, now);
2337
+ `).run(id, threadId, messageId, active?.id ?? null, operatorText, model, effort, status, now, now);
1651
2338
  const title = deriveAutomaticTitle(text, []);
1652
2339
  this.database.prepare(`
1653
2340
  UPDATE threads
@@ -1673,8 +2360,7 @@ export class WebStore {
1673
2360
  const message = this.requireMessage(row.message_id);
1674
2361
  const now = this.now();
1675
2362
  this.transaction(() => {
1676
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
1677
- .run(serializeParts(withLiveInputStatus(message.parts, "applied")), now, row.message_id);
2363
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "applied"), now);
1678
2364
  this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
1679
2365
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1680
2366
  .run(now, row.thread_id);
@@ -1682,7 +2368,36 @@ export class WebStore {
1682
2368
  });
1683
2369
  return this.requireMessage(row.message_id);
1684
2370
  }
1685
- queueLiveInput(id) {
2371
+ storedLiveInput(id) {
2372
+ const row = this.getLiveInput(id);
2373
+ return row === undefined ? undefined : mapLiveInput(row);
2374
+ }
2375
+ markLiveInputUncertain(id) {
2376
+ const row = this.getLiveInput(id);
2377
+ if (row === undefined)
2378
+ return undefined;
2379
+ const message = this.requireMessage(row.message_id);
2380
+ const now = this.now();
2381
+ this.transaction(() => {
2382
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "uncertain"), now, { turnId: null });
2383
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
2384
+ this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
2385
+ .run(now, row.thread_id);
2386
+ this.recordThreadRevision(row.thread_id, "live_input_uncertain", now);
2387
+ });
2388
+ return this.requireMessage(row.message_id);
2389
+ }
2390
+ markLiveInputDispatchStarted(id, activeTurnId) {
2391
+ const now = this.now();
2392
+ return this.transaction(() => {
2393
+ const result = this.database.prepare(`
2394
+ UPDATE live_inputs SET dispatch_started_at = ?, updated_at = ?
2395
+ WHERE id = ? AND status = 'offered' AND active_turn_id = ? AND dispatch_started_at IS NULL
2396
+ `).run(now, now, id, activeTurnId);
2397
+ return result.changes === 1;
2398
+ });
2399
+ }
2400
+ queueLiveInput(id, submissionReason) {
1686
2401
  const row = this.getLiveInput(id);
1687
2402
  if (row === undefined)
1688
2403
  return undefined;
@@ -1690,10 +2405,17 @@ export class WebStore {
1690
2405
  const now = this.now();
1691
2406
  this.transaction(() => {
1692
2407
  this.database.prepare(`
1693
- UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
2408
+ UPDATE live_inputs
2409
+ SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
2410
+ WHERE id = ?
1694
2411
  `).run(now, id);
1695
- this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
1696
- .run(serializeParts(withLiveInputStatus(message.parts, "queued")), now, row.message_id);
2412
+ if (submissionReason !== undefined) {
2413
+ this.database.prepare(`
2414
+ UPDATE web_submissions SET reason = ?
2415
+ WHERE input_id = ? AND outcome = 'live-input' AND reason IS NULL
2416
+ `).run(submissionReason, id);
2417
+ }
2418
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "queued"), now, { turnId: null });
1697
2419
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1698
2420
  .run(now, row.thread_id);
1699
2421
  this.recordThreadRevision(row.thread_id, "live_input_queued", now);
@@ -1707,8 +2429,7 @@ export class WebStore {
1707
2429
  const message = this.requireMessage(row.message_id);
1708
2430
  const now = this.now();
1709
2431
  this.transaction(() => {
1710
- this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?")
1711
- .run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
2432
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, "cancelled"), now, { turnId: null });
1712
2433
  this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(id);
1713
2434
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1714
2435
  .run(now, row.thread_id);
@@ -1723,15 +2444,16 @@ export class WebStore {
1723
2444
  return [];
1724
2445
  const now = this.now();
1725
2446
  this.transaction(() => {
1726
- const update = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
1727
2447
  for (const row of rows) {
1728
2448
  const message = this.requireMessage(row.message_id);
1729
- update.run(serializeParts(withLiveInputStatus(message.parts, "cancelled")), now, row.message_id);
2449
+ this.writeMessageParts(row.message_id, withLiveInputStatus(message.parts, row.dispatch_started_at === null ? "cancelled" : "uncertain"), now, { turnId: null });
1730
2450
  }
1731
2451
  this.database.prepare("DELETE FROM live_inputs WHERE thread_id = ?").run(threadId);
1732
2452
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
1733
2453
  .run(now, threadId);
1734
- this.recordThreadRevision(threadId, "live_inputs_cancelled", now);
2454
+ this.recordThreadRevision(threadId, rows.some((row) => row.dispatch_started_at !== null)
2455
+ ? "live_inputs_cancelled_with_uncertainty"
2456
+ : "live_inputs_cancelled", now);
1735
2457
  });
1736
2458
  return rows.map((row) => this.requireMessage(row.message_id));
1737
2459
  }
@@ -1763,12 +2485,11 @@ export class WebStore {
1763
2485
  this.transaction(() => {
1764
2486
  this.database.prepare(`
1765
2487
  INSERT INTO turns (
1766
- id, thread_id, status, text, model, effort, assistant_message_id,
2488
+ id, thread_id, status, text, model, effort, requested_model, requested_effort, assistant_message_id,
1767
2489
  started_at, finished_at, error_code, error_message
1768
- ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, NULL, NULL, NULL)
1769
- `).run(turnId, threadId, row.text, row.model, row.effort, assistantMessageId, now);
1770
- this.database.prepare("UPDATE messages SET turn_id = ?, parts_json = ?, updated_at = ? WHERE id = ?")
1771
- .run(turnId, serializeParts(withoutLiveInputTelemetry(userMessage.parts)), now, row.message_id);
2490
+ ) VALUES (?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, NULL, NULL, NULL)
2491
+ `).run(turnId, threadId, row.text, row.model, row.effort, row.model, row.effort, assistantMessageId, now);
2492
+ this.writeMessageParts(row.message_id, withoutLiveInputTelemetry(userMessage.parts), now, { turnId });
1772
2493
  this.database.prepare(`
1773
2494
  INSERT INTO messages (id, thread_id, turn_id, role, parts_json, created_at, updated_at, status)
1774
2495
  VALUES (?, ?, ?, 'assistant', '[]', ?, ?, 'running')
@@ -1788,55 +2509,131 @@ export class WebStore {
1788
2509
  thread: this.requireThread(threadId),
1789
2510
  };
1790
2511
  }
1791
- applyStreamFrame(turnId, frame) {
1792
- return this.applyStreamFrames(turnId, [frame]);
1793
- }
1794
2512
  applyStreamFrames(turnId, frames) {
1795
2513
  const turn = this.requireTurn(turnId);
2514
+ // Nothing is written for a turn that already settled, so there is no
2515
+ // version for a delta to name.
1796
2516
  if (turn.status !== "running")
1797
- return this.requireMessage(turn.assistant_message_id);
1798
- const message = this.requireMessage(turn.assistant_message_id);
1799
- const parts = [...message.parts];
1800
- let actualModel;
1801
- let actualEffort;
1802
- for (const frame of frames) {
1803
- if (frame.kind === "status") {
1804
- parts.push({ type: "telemetry", event: "status", data: { text: frame.text } });
1805
- }
1806
- else if (frame.kind === "append") {
1807
- appendTextPart(parts, "text", frame.delta);
1808
- }
1809
- else if (frame.kind === "replace") {
1810
- replaceWholeText(parts, frame.text);
1811
- }
1812
- else if (frame.kind === "event") {
1813
- applyEvent(parts, frame.event);
1814
- if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
1815
- if (typeof frame.event.data?.model === "string")
1816
- actualModel = frame.event.data.model;
1817
- if (typeof frame.event.data?.effort === "string")
1818
- actualEffort = frame.event.data.effort;
2517
+ return { message: this.requireMessage(turn.assistant_message_id) };
2518
+ // The base read, the frames applied to it and the write are ONE atomic
2519
+ // span, as they already are on the finish path. A delta whose ops were
2520
+ // diffed against a version other than the one its `baseSeq` names is
2521
+ // self-consistent and WRONG -- the one corruption a sequence number cannot
2522
+ // expose, because the console would apply it without complaint.
2523
+ const write = this.transaction(() => {
2524
+ const message = this.requireMessage(turn.assistant_message_id);
2525
+ const parts = [...message.parts];
2526
+ let actualModel;
2527
+ let actualEffort;
2528
+ let actualEffectiveEffort;
2529
+ let clearEffort = false;
2530
+ let clearEffectiveEffort = false;
2531
+ let routing = parseRoutingState(turn.routing_json);
2532
+ let attributionChanged = false;
2533
+ for (const frame of frames) {
2534
+ if (frame.kind === "status") {
2535
+ parts.push({ type: "telemetry", event: "status", data: { text: frame.text } });
2536
+ }
2537
+ else if (frame.kind === "append") {
2538
+ appendTextPart(parts, "text", frame.delta);
2539
+ }
2540
+ else if (frame.kind === "replace") {
2541
+ replaceWholeText(parts, frame.text);
2542
+ }
2543
+ else if (frame.kind === "event") {
2544
+ applyEvent(parts, frame.event, (deliveryKey) => this.monitorWakeProjection(turnId, deliveryKey), (deliveryKey) => this.processJobWakeForTurn(turnId, deliveryKey));
2545
+ if (frame.event.type === "runtime_telemetry" && frame.event.kind === "run_config") {
2546
+ const model = canonicalRouteString(frame.event.data?.model);
2547
+ const effort = canonicalRouteString(frame.event.data?.effort, 64);
2548
+ if (model !== undefined)
2549
+ actualModel = model;
2550
+ if (effort !== undefined) {
2551
+ actualEffort = effort;
2552
+ clearEffort = false;
2553
+ }
2554
+ attributionChanged ||= model !== undefined || effort !== undefined;
2555
+ }
2556
+ if (frame.event.type === "runtime_telemetry" && frame.event.kind === "provider_execution_config") {
2557
+ const model = canonicalRouteString(frame.event.data?.model);
2558
+ const effort = canonicalRouteString(frame.event.data?.effort, 64);
2559
+ const effectiveEffort = canonicalRouteString(frame.event.data?.effectiveEffort, 64);
2560
+ if (model !== undefined)
2561
+ actualModel = model;
2562
+ if (effort !== undefined)
2563
+ actualEffort = effort;
2564
+ if (effectiveEffort !== undefined)
2565
+ actualEffectiveEffort = effectiveEffort;
2566
+ clearEffort = effort === undefined;
2567
+ clearEffectiveEffort = effectiveEffort === undefined;
2568
+ attributionChanged ||= model !== undefined || effort !== undefined || effectiveEffort !== undefined;
2569
+ }
2570
+ if (frame.event.type === "provider_status") {
2571
+ if (frame.event.kind === "failover_started") {
2572
+ const from = canonicalRouteString(frame.event.from);
2573
+ const to = canonicalRouteString(frame.event.to);
2574
+ if (from !== undefined && to !== undefined) {
2575
+ const attemptIndex = canonicalRouteIndex(frame.event.attemptIndex);
2576
+ const reason = canonicalRouteString(frame.event.reason, 128);
2577
+ routing = appendRouteTransition(routing, {
2578
+ from,
2579
+ to,
2580
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
2581
+ ...(reason === undefined ? {} : { reason }),
2582
+ });
2583
+ actualModel = to;
2584
+ clearEffort = true;
2585
+ clearEffectiveEffort = true;
2586
+ attributionChanged = true;
2587
+ }
2588
+ }
2589
+ else if (frame.event.kind === "retry_started") {
2590
+ const model = canonicalRouteString(frame.event.model);
2591
+ const retryIndex = canonicalRouteIndex(frame.event.retryIndex);
2592
+ const reason = canonicalRouteString(frame.event.reason, 128);
2593
+ routing = appendRouteRetry(routing, {
2594
+ ...(model === undefined ? {} : { model }),
2595
+ ...(retryIndex === undefined ? {} : { retryIndex }),
2596
+ ...(reason === undefined ? {} : { reason }),
2597
+ });
2598
+ attributionChanged = true;
2599
+ }
2600
+ else if (frame.event.kind === "failover_completed") {
2601
+ const model = canonicalRouteString(frame.event.model);
2602
+ if (model !== undefined) {
2603
+ actualModel = model;
2604
+ attributionChanged = true;
2605
+ }
2606
+ }
2607
+ else if (frame.event.kind === "request_started") {
2608
+ const model = canonicalRouteString(frame.event.model);
2609
+ if (model !== undefined) {
2610
+ actualModel = model;
2611
+ attributionChanged = true;
2612
+ }
2613
+ }
2614
+ }
1819
2615
  }
1820
2616
  }
1821
- }
1822
- const now = this.now();
1823
- this.transaction(() => {
1824
- this.database.prepare("UPDATE messages SET parts_json = ?, updated_at = ? WHERE id = ?")
1825
- .run(serializeParts(parts), now, message.id);
1826
- if (actualModel !== undefined || actualEffort !== undefined) {
2617
+ if (actualModel !== undefined || actualEffort !== undefined || actualEffectiveEffort !== undefined || attributionChanged) {
1827
2618
  this.database.prepare(`
1828
2619
  UPDATE turns SET
1829
2620
  model = CASE WHEN ? IS NULL THEN model ELSE ? END,
1830
- effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
2621
+ effort = CASE WHEN ? = 1 THEN NULL WHEN ? IS NULL THEN effort ELSE ? END,
2622
+ effective_effort = CASE WHEN ? = 1 THEN NULL WHEN ? IS NULL THEN effective_effort ELSE ? END,
2623
+ routing_json = ?
1831
2624
  WHERE id = ?
1832
- `).run(actualModel ?? null, actualModel ?? null, actualEffort ?? null, actualEffort ?? null, turnId);
2625
+ `).run(actualModel ?? null, actualModel ?? null, clearEffort ? 1 : 0, actualEffort ?? null, actualEffort ?? null, clearEffectiveEffort ? 1 : 0, actualEffectiveEffort ?? null, actualEffectiveEffort ?? null, serializeRoutingState(routing), turnId);
1833
2626
  }
2627
+ return {
2628
+ delta: this.writeMessageDelta(message, parts, this.now()),
2629
+ ...(attributionChanged ? { attributionChanged: true } : {}),
2630
+ };
1834
2631
  });
1835
- return this.requireMessage(message.id);
2632
+ return { message: this.requireMessage(turn.assistant_message_id), ...write };
1836
2633
  }
1837
- completeTurn(turnId, finalText, metadata, replyParts) {
2634
+ completeTurn(turnId, finalText, metadata, replyParts, options = {}) {
1838
2635
  const runtime = runtimeMetadata(metadata);
1839
- return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts);
2636
+ return this.finishTurn(turnId, "complete", finalText, undefined, undefined, runtime, replyParts, options.suppressResponsePush === true, options.monitorWakeDeliveryKey);
1840
2637
  }
1841
2638
  failTurn(turnId, error) {
1842
2639
  return this.finishTurn(turnId, error.cancelled === true ? "cancelled" : "failed", undefined, error.code, error.message, undefined);
@@ -2069,6 +2866,16 @@ export class WebStore {
2069
2866
  JOIN push_events e ON e.id = d.event_id
2070
2867
  JOIN push_subscriptions s ON s.id = d.subscription_id
2071
2868
  WHERE d.status = 'pending' AND d.next_attempt_at <= ? AND e.expires_at > ? AND s.state = 'active'
2869
+ AND NOT EXISTS (
2870
+ SELECT 1 FROM monitor_wake_deliveries m
2871
+ WHERE e.kind = 'response.ready' AND m.state = 'accepted' AND m.turn_id IS NOT NULL
2872
+ AND e.logical_key = 'turn:' || m.turn_id || ':terminal'
2873
+ )
2874
+ AND NOT EXISTS (
2875
+ SELECT 1 FROM process_job_wake_deliveries j
2876
+ WHERE e.kind = 'response.ready' AND j.state = 'accepted' AND j.turn_id IS NOT NULL
2877
+ AND e.logical_key = 'turn:' || j.turn_id || ':terminal'
2878
+ )
2072
2879
  ORDER BY d.next_attempt_at, d.created_at, d.rowid
2073
2880
  LIMIT ?
2074
2881
  `).all(now, now, limit);
@@ -2191,6 +2998,7 @@ export class WebStore {
2191
2998
  if (versionRow.user_version < 0) {
2192
2999
  throw new WebConsoleError("storage_corrupt", "Web state schema version is invalid.", 500);
2193
3000
  }
3001
+ validateWebStorageMigrationRegistry();
2194
3002
  const migrating = versionRow.user_version < WEB_STORAGE_SCHEMA_VERSION;
2195
3003
  if (migrating)
2196
3004
  this.database.exec("BEGIN IMMEDIATE");
@@ -2200,18 +3008,28 @@ export class WebStore {
2200
3008
  source_id TEXT PRIMARY KEY,
2201
3009
  label TEXT NOT NULL,
2202
3010
  status TEXT NOT NULL,
3011
+ discovered INTEGER NOT NULL DEFAULT 1 CHECK (discovered IN (0, 1)),
2203
3012
  health TEXT,
2204
3013
  supports_attachments INTEGER NOT NULL DEFAULT 0,
3014
+ supports_provider_auth INTEGER NOT NULL DEFAULT 0 CHECK (supports_provider_auth IN (0, 1)),
2205
3015
  models_json TEXT,
2206
3016
  default_model TEXT,
2207
3017
  default_effort TEXT,
2208
3018
  efforts_json TEXT,
2209
3019
  model_options_json TEXT,
3020
+ providers_json TEXT,
2210
3021
  cron_read INTEGER NOT NULL DEFAULT 0,
2211
3022
  cron_actions INTEGER NOT NULL DEFAULT 0,
2212
3023
  ask_by_id INTEGER NOT NULL DEFAULT 0,
2213
3024
  updated_at TEXT NOT NULL
2214
3025
  );
3026
+ CREATE TABLE IF NOT EXISTS agent_run_overrides (
3027
+ source_id TEXT PRIMARY KEY REFERENCES agents(source_id) ON DELETE CASCADE,
3028
+ model TEXT,
3029
+ effort TEXT,
3030
+ updated_at TEXT NOT NULL,
3031
+ CHECK (model IS NOT NULL OR effort IS NOT NULL)
3032
+ );
2215
3033
  CREATE TABLE IF NOT EXISTS threads (
2216
3034
  id TEXT PRIMARY KEY,
2217
3035
  source_id TEXT NOT NULL REFERENCES agents(source_id),
@@ -2222,6 +3040,8 @@ export class WebStore {
2222
3040
  archived_at TEXT,
2223
3041
  created_at TEXT NOT NULL,
2224
3042
  updated_at TEXT NOT NULL,
3043
+ run_model TEXT,
3044
+ run_effort TEXT,
2225
3045
  revision INTEGER NOT NULL DEFAULT 1
2226
3046
  );
2227
3047
  CREATE TABLE IF NOT EXISTS turns (
@@ -2231,6 +3051,10 @@ export class WebStore {
2231
3051
  text TEXT NOT NULL,
2232
3052
  model TEXT,
2233
3053
  effort TEXT,
3054
+ requested_model TEXT,
3055
+ requested_effort TEXT,
3056
+ effective_effort TEXT,
3057
+ routing_json TEXT NOT NULL DEFAULT '{"transitions":[],"retries":[]}',
2234
3058
  assistant_message_id TEXT NOT NULL,
2235
3059
  started_at TEXT NOT NULL,
2236
3060
  finished_at TEXT,
@@ -2239,6 +3063,7 @@ export class WebStore {
2239
3063
  );
2240
3064
  CREATE UNIQUE INDEX IF NOT EXISTS turns_one_active_per_thread
2241
3065
  ON turns(thread_id) WHERE status = 'running';
3066
+ CREATE INDEX IF NOT EXISTS turns_by_thread_started ON turns(thread_id, started_at);
2242
3067
  CREATE TABLE IF NOT EXISTS messages (
2243
3068
  id TEXT PRIMARY KEY,
2244
3069
  thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -2247,9 +3072,11 @@ export class WebStore {
2247
3072
  parts_json TEXT NOT NULL,
2248
3073
  created_at TEXT NOT NULL,
2249
3074
  updated_at TEXT NOT NULL,
2250
- status TEXT NOT NULL
3075
+ status TEXT NOT NULL,
3076
+ seq INTEGER NOT NULL DEFAULT 0
2251
3077
  );
2252
3078
  CREATE INDEX IF NOT EXISTS messages_by_thread ON messages(thread_id, created_at);
3079
+ CREATE INDEX IF NOT EXISTS messages_by_turn ON messages(turn_id);
2253
3080
  CREATE TABLE IF NOT EXISTS live_inputs (
2254
3081
  id TEXT PRIMARY KEY,
2255
3082
  thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
@@ -2259,11 +3086,63 @@ export class WebStore {
2259
3086
  model TEXT,
2260
3087
  effort TEXT,
2261
3088
  status TEXT NOT NULL CHECK (status IN ('offered', 'queued')),
3089
+ dispatch_started_at TEXT,
2262
3090
  created_at TEXT NOT NULL,
2263
3091
  updated_at TEXT NOT NULL
2264
3092
  );
2265
3093
  CREATE INDEX IF NOT EXISTS live_inputs_by_thread
2266
3094
  ON live_inputs(thread_id, status, created_at);
3095
+ CREATE TABLE IF NOT EXISTS web_submissions (
3096
+ thread_id TEXT NOT NULL REFERENCES threads(id) ON DELETE CASCADE,
3097
+ submission_id TEXT NOT NULL,
3098
+ payload_sha256 TEXT NOT NULL,
3099
+ outcome TEXT NOT NULL CHECK (outcome IN ('turn', 'live-input', 'rejected')),
3100
+ reason TEXT CHECK (reason IN (
3101
+ 'active_attachments_unsupported', 'unsupported_targeting', 'closed_before_dispatch',
3102
+ 'operator_inactive', 'operator_unsupported', 'operator_too_large', 'operator_full', 'operator_invalid',
3103
+ 'mailbox_unsupported', 'mailbox_closed', 'mailbox_failed'
3104
+ )),
3105
+ message_id TEXT REFERENCES messages(id) ON DELETE SET NULL,
3106
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
3107
+ input_id TEXT REFERENCES live_inputs(id) ON DELETE SET NULL,
3108
+ created_at TEXT NOT NULL,
3109
+ PRIMARY KEY (thread_id, submission_id)
3110
+ );
3111
+ CREATE TABLE IF NOT EXISTS cron_reply_operations (
3112
+ operation_id TEXT PRIMARY KEY,
3113
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
3114
+ job_id TEXT NOT NULL,
3115
+ run_id TEXT NOT NULL,
3116
+ thread_id TEXT NOT NULL UNIQUE,
3117
+ conversation_id TEXT NOT NULL UNIQUE,
3118
+ provenance_message_id TEXT UNIQUE,
3119
+ result_message_id TEXT UNIQUE,
3120
+ idempotency_key TEXT NOT NULL UNIQUE,
3121
+ state TEXT NOT NULL CHECK (state IN ('pending', 'completed', 'failed', 'tombstoned')),
3122
+ snapshot_kind TEXT NOT NULL CHECK (snapshot_kind IN ('summary', 'detail')),
3123
+ snapshot_text TEXT,
3124
+ snapshot_sha256 TEXT,
3125
+ title TEXT,
3126
+ run_model TEXT,
3127
+ run_effort TEXT,
3128
+ canonical_status TEXT CHECK (canonical_status IN ('appended', 'duplicate')),
3129
+ failure_reason TEXT,
3130
+ created_at TEXT NOT NULL,
3131
+ completed_at TEXT,
3132
+ failed_at TEXT,
3133
+ tombstoned_at TEXT,
3134
+ CHECK (
3135
+ (state IN ('pending', 'completed') AND snapshot_text IS NOT NULL AND snapshot_sha256 IS NOT NULL
3136
+ AND title IS NOT NULL AND provenance_message_id IS NOT NULL AND result_message_id IS NOT NULL)
3137
+ OR (state IN ('failed', 'tombstoned') AND snapshot_text IS NULL AND snapshot_sha256 IS NULL
3138
+ AND title IS NULL AND provenance_message_id IS NULL AND result_message_id IS NULL)
3139
+ ),
3140
+ CHECK ((state = 'completed') = (completed_at IS NOT NULL)),
3141
+ CHECK ((state = 'failed') = (failed_at IS NOT NULL)),
3142
+ CHECK ((state = 'tombstoned') = (tombstoned_at IS NOT NULL))
3143
+ );
3144
+ CREATE UNIQUE INDEX IF NOT EXISTS cron_reply_operations_one_pending_run
3145
+ ON cron_reply_operations(source_id, job_id, run_id) WHERE state = 'pending';
2267
3146
  CREATE TABLE IF NOT EXISTS attachments (
2268
3147
  id TEXT PRIMARY KEY,
2269
3148
  thread_id TEXT REFERENCES threads(id) ON DELETE CASCADE,
@@ -2324,11 +3203,27 @@ export class WebStore {
2324
3203
  );
2325
3204
  CREATE INDEX IF NOT EXISTS process_job_wake_deliveries_by_thread
2326
3205
  ON process_job_wake_deliveries(thread_id, created_at);
2327
- CREATE TABLE IF NOT EXISTS cron_channels (
3206
+ CREATE TABLE IF NOT EXISTS monitor_wake_deliveries (
2328
3207
  source_id TEXT NOT NULL REFERENCES agents(source_id),
2329
- job_id TEXT NOT NULL,
2330
- thread_id TEXT NOT NULL UNIQUE REFERENCES threads(id) ON DELETE CASCADE,
2331
- configured INTEGER NOT NULL CHECK (configured IN (0, 1)),
3208
+ monitor_id TEXT NOT NULL,
3209
+ delivery_key TEXT NOT NULL,
3210
+ thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
3211
+ payload_sha256 TEXT NOT NULL,
3212
+ projection_json TEXT,
3213
+ state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
3214
+ disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
3215
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
3216
+ created_at TEXT NOT NULL,
3217
+ completed_at TEXT,
3218
+ PRIMARY KEY (source_id, delivery_key)
3219
+ );
3220
+ CREATE INDEX IF NOT EXISTS monitor_wake_deliveries_by_thread
3221
+ ON monitor_wake_deliveries(thread_id, created_at);
3222
+ CREATE TABLE IF NOT EXISTS cron_channels (
3223
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
3224
+ job_id TEXT NOT NULL,
3225
+ thread_id TEXT NOT NULL UNIQUE REFERENCES threads(id) ON DELETE CASCADE,
3226
+ configured INTEGER NOT NULL CHECK (configured IN (0, 1)),
2332
3227
  created_at TEXT NOT NULL,
2333
3228
  updated_at TEXT NOT NULL,
2334
3229
  PRIMARY KEY (source_id, job_id)
@@ -2446,37 +3341,14 @@ export class WebStore {
2446
3341
  ON push_deliveries(status, next_attempt_at, created_at);
2447
3342
  ${MESSAGE_SEARCH_SCHEMA_SQL}
2448
3343
  `);
2449
- if (versionRow.user_version === 1) {
2450
- const columns = this.database.prepare("PRAGMA table_info(threads)").all();
2451
- if (!columns.some((column) => column.name === "trigger_kind")) {
2452
- this.database.exec("ALTER TABLE threads ADD COLUMN trigger_kind TEXT CHECK (trigger_kind IN ('cron', 'webhook'))");
2453
- }
2454
- }
2455
- if (versionRow.user_version < 5)
2456
- this.migrateCronChannels();
2457
- if (versionRow.user_version < 6) {
2458
- const columns = new Set(this.database.prepare("PRAGMA table_info(cron_overviews)").all()
2459
- .map((column) => column.name));
2460
- if (!columns.has("jobs_truncated")) {
2461
- this.database.exec("ALTER TABLE cron_overviews ADD COLUMN jobs_truncated INTEGER NOT NULL DEFAULT 0 CHECK (jobs_truncated IN (0, 1))");
2462
- }
2463
- }
2464
- // The search index derives from parts_json, so an existing database has
2465
- // to be swept once. Migration writes no message, so the triggers above
2466
- // cannot have fired yet; the clear-then-insert is a no-op on a fresh
2467
- // database and idempotent if the sweep is ever re-run.
2468
- if (versionRow.user_version < 9)
2469
- this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL);
2470
- // Durable copies of agent-published images share the upload table so that
2471
- // thread deletion, archival file cleanup, and every existing purge surface
2472
- // reach them without a second store to keep in sync.
2473
- if (versionRow.user_version < 10) {
2474
- const columns = new Set(this.database.prepare("PRAGMA table_info(attachments)").all()
2475
- .map((column) => column.name));
2476
- if (!columns.has("origin")) {
2477
- this.database.exec("ALTER TABLE attachments ADD COLUMN origin TEXT NOT NULL DEFAULT 'upload' CHECK (origin IN ('upload', 'reply'))");
2478
- }
2479
- }
3344
+ runWebStorageMigrations({
3345
+ database: this.database,
3346
+ originalVersion: versionRow.user_version,
3347
+ migrateCronChannels: () => this.migrateCronChannels(),
3348
+ migrateMonitorWakeDeliveries: () => this.migrateMonitorWakeDeliveries(),
3349
+ suppressSilentCronHistory: () => this.suppressSilentCronHistory(),
3350
+ backfillMessageSearch: () => this.database.exec(MESSAGE_SEARCH_BACKFILL_SQL),
3351
+ });
2480
3352
  if (migrating)
2481
3353
  this.database.exec(`PRAGMA user_version = ${WEB_STORAGE_SCHEMA_VERSION}; COMMIT`);
2482
3354
  }
@@ -2493,6 +3365,83 @@ export class WebStore {
2493
3365
  throw new WebConsoleError("storage_corrupt", `Unable to initialize web state: ${error instanceof Error ? error.message : String(error)}`, 500);
2494
3366
  }
2495
3367
  }
3368
+ suppressSilentCronHistory() {
3369
+ const affected = new Set();
3370
+ let after = 0;
3371
+ while (true) {
3372
+ const rows = this.database.prepare(`
3373
+ SELECT r.rowid AS cursor, r.*, m.parts_json, m.cron_suppressed
3374
+ FROM cron_run_messages r JOIN messages m ON m.id = r.message_id
3375
+ WHERE r.rowid > ? ORDER BY r.rowid LIMIT 100
3376
+ `).all(after);
3377
+ if (rows.length === 0)
3378
+ break;
3379
+ for (const row of rows) {
3380
+ const run = parseStoredCronRun(row.payload_json);
3381
+ const parts = parseParts(row.parts_json);
3382
+ if (run.runId !== row.run_id || run.jobId !== row.job_id)
3383
+ throw new Error("Invalid cron mapping identity.");
3384
+ const delivered = this.database.prepare(`SELECT 1 FROM notification_deliveries
3385
+ WHERE source_id = ? AND job_id = ? AND run_id = ? AND message_id = ? AND completed_at IS NOT NULL
3386
+ `).get(row.source_id, row.job_id, row.run_id, row.message_id);
3387
+ const suppressed = definitelySilentCronRun(run) && delivered === undefined && !hasMeaningfulCronContent(parts)
3388
+ && this.database.prepare("SELECT 1 FROM attachments WHERE message_id = ? LIMIT 1").get(row.message_id) === undefined;
3389
+ if (row.cron_suppressed === 0 && suppressed) {
3390
+ this.database.prepare("UPDATE messages SET cron_suppressed = 1 WHERE id = ?").run(row.message_id);
3391
+ affected.add(row.thread_id);
3392
+ }
3393
+ else if (!suppressed) {
3394
+ // Visible ambiguous/delivered rows must not be hidden by old browser
3395
+ // telemetry guards. Keep their content, remove only the stale flag.
3396
+ const visibleParts = parts.map(clearSilentCronPart);
3397
+ if (visibleParts.some((part, index) => part !== parts[index])) {
3398
+ this.database.prepare("UPDATE messages SET parts_json = ? WHERE id = ?").run(serializeParts(visibleParts), row.message_id);
3399
+ affected.add(row.thread_id);
3400
+ }
3401
+ }
3402
+ after = row.cursor;
3403
+ }
3404
+ }
3405
+ for (const id of affected)
3406
+ this.database.prepare("UPDATE threads SET revision = revision + 1 WHERE id = ?").run(id);
3407
+ }
3408
+ /** Preserve Monitor delivery tombstones while making deleted threads threadless. */
3409
+ migrateMonitorWakeDeliveries() {
3410
+ const foreignKeys = this.database.prepare("PRAGMA foreign_key_list(monitor_wake_deliveries)")
3411
+ .all();
3412
+ if (!foreignKeys.some((key) => key.from === "thread_id" && key.on_delete === "CASCADE"))
3413
+ return;
3414
+ const columns = this.database.prepare("PRAGMA table_info(monitor_wake_deliveries)")
3415
+ .all();
3416
+ const projection = columns.some((column) => column.name === "projection_json") ? "projection_json" : "NULL";
3417
+ this.database.exec(`
3418
+ CREATE TABLE monitor_wake_deliveries_v14 (
3419
+ source_id TEXT NOT NULL REFERENCES agents(source_id),
3420
+ monitor_id TEXT NOT NULL,
3421
+ delivery_key TEXT NOT NULL,
3422
+ thread_id TEXT REFERENCES threads(id) ON DELETE SET NULL,
3423
+ payload_sha256 TEXT NOT NULL,
3424
+ projection_json TEXT,
3425
+ state TEXT NOT NULL CHECK (state IN ('accepted', 'completed')),
3426
+ disposition TEXT CHECK (disposition IN ('steered', 'follow_up')),
3427
+ turn_id TEXT REFERENCES turns(id) ON DELETE SET NULL,
3428
+ created_at TEXT NOT NULL,
3429
+ completed_at TEXT,
3430
+ PRIMARY KEY (source_id, delivery_key)
3431
+ );
3432
+ INSERT INTO monitor_wake_deliveries_v14 (
3433
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, projection_json,
3434
+ state, disposition, turn_id, created_at, completed_at
3435
+ ) SELECT
3436
+ source_id, monitor_id, delivery_key, thread_id, payload_sha256, ${projection},
3437
+ state, disposition, turn_id, created_at, completed_at
3438
+ FROM monitor_wake_deliveries;
3439
+ DROP TABLE monitor_wake_deliveries;
3440
+ ALTER TABLE monitor_wake_deliveries_v14 RENAME TO monitor_wake_deliveries;
3441
+ CREATE INDEX monitor_wake_deliveries_by_thread
3442
+ ON monitor_wake_deliveries(thread_id, created_at);
3443
+ `);
3444
+ }
2496
3445
  /**
2497
3446
  * Schema-v5 adoption runs after every older fixup inside the same
2498
3447
  * BEGIN IMMEDIATE transaction. Each operation is guarded by the resulting
@@ -2634,6 +3583,7 @@ export class WebStore {
2634
3583
  }
2635
3584
  const requiredTables = new Set([
2636
3585
  "agents",
3586
+ "agent_run_overrides",
2637
3587
  "threads",
2638
3588
  "turns",
2639
3589
  "messages",
@@ -2642,6 +3592,8 @@ export class WebStore {
2642
3592
  "revisions",
2643
3593
  "settings",
2644
3594
  "notification_deliveries",
3595
+ "process_job_wake_deliveries",
3596
+ "monitor_wake_deliveries",
2645
3597
  "cron_channels",
2646
3598
  "cron_channel_deletions",
2647
3599
  "cron_overviews",
@@ -2669,6 +3621,21 @@ export class WebStore {
2669
3621
  throw new WebConsoleError("storage_corrupt", `Message ${message.id} contains invalid persisted parts.`, 500);
2670
3622
  }
2671
3623
  }
3624
+ const monitorProjections = this.database.prepare(`
3625
+ SELECT monitor_id, projection_json
3626
+ FROM monitor_wake_deliveries
3627
+ WHERE projection_json IS NOT NULL
3628
+ `).all();
3629
+ for (const row of monitorProjections) {
3630
+ try {
3631
+ const projection = parseMonitorProjection(JSON.parse(row.projection_json));
3632
+ if (projection.monitorId !== row.monitor_id)
3633
+ throw new TypeError("Monitor identity mismatch.");
3634
+ }
3635
+ catch {
3636
+ throw new WebConsoleError("storage_corrupt", "A retained Monitor wake projection is invalid.", 500);
3637
+ }
3638
+ }
2672
3639
  }
2673
3640
  /**
2674
3641
  * Index the messages the streaming gate skipped and nothing settled — the
@@ -2680,6 +3647,46 @@ export class WebStore {
2680
3647
  this.database.exec(MESSAGE_SEARCH_UNSETTLED_SQL);
2681
3648
  });
2682
3649
  }
3650
+ /** Repair only the derived search projection of verified legacy Monitor replies.
3651
+ * New completions already normalize before the ordinary indexing triggers run.
3652
+ * Page by rowid so opening a large retained history does not materialize it all.
3653
+ */
3654
+ reindexLegacyMonitorMessages() {
3655
+ const select = this.database.prepare(`
3656
+ SELECT m.rowid AS row_id, m.parts_json FROM messages m
3657
+ JOIN turns ON turns.id = m.turn_id AND turns.thread_id = m.thread_id
3658
+ JOIN threads ON threads.id = m.thread_id
3659
+ WHERE m.rowid > ? AND m.role = 'assistant' AND m.status = 'complete'
3660
+ AND EXISTS (
3661
+ SELECT 1 FROM monitor_wake_deliveries d
3662
+ WHERE d.turn_id = turns.id AND d.thread_id = turns.thread_id
3663
+ AND d.source_id = threads.source_id AND d.state = 'completed'
3664
+ AND d.disposition IN ('steered', 'follow_up')
3665
+ )
3666
+ ORDER BY m.rowid LIMIT 100
3667
+ `);
3668
+ const remove = this.database.prepare("DELETE FROM message_search WHERE rowid = ?");
3669
+ const insert = this.database.prepare(`
3670
+ INSERT INTO message_search(rowid, body)
3671
+ SELECT ?, (${messageSearchBody("m")}) FROM (SELECT ? AS parts_json) m
3672
+ `);
3673
+ let after = 0;
3674
+ while (true) {
3675
+ const rows = select.all(after);
3676
+ if (rows.length === 0)
3677
+ return;
3678
+ this.transaction(() => {
3679
+ for (const row of rows) {
3680
+ const normalized = normalizeMonitorTerminalReply(parseParts(row.parts_json));
3681
+ if (!normalized.changed)
3682
+ continue;
3683
+ remove.run(row.row_id);
3684
+ insert.run(row.row_id, serializeParts(normalized.parts));
3685
+ }
3686
+ });
3687
+ after = rows[rows.length - 1].row_id;
3688
+ }
3689
+ }
2683
3690
  recoverInterruptedTurns() {
2684
3691
  const active = this.listActiveTurnIds();
2685
3692
  for (const turnId of active) {
@@ -2700,17 +3707,28 @@ export class WebStore {
2700
3707
  const threadIds = new Set(rows.map((row) => row.thread_id));
2701
3708
  this.transaction(() => {
2702
3709
  const updateInput = this.database.prepare(`
2703
- UPDATE live_inputs SET status = 'queued', active_turn_id = NULL, updated_at = ? WHERE id = ?
3710
+ UPDATE live_inputs
3711
+ SET status = 'queued', active_turn_id = NULL, dispatch_started_at = NULL, updated_at = ?
3712
+ WHERE id = ?
2704
3713
  `);
2705
- const updateMessage = this.database.prepare("UPDATE messages SET turn_id = NULL, parts_json = ?, updated_at = ? WHERE id = ?");
2706
3714
  for (const row of rows) {
2707
3715
  const persisted = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
2708
3716
  .get(row.message_id);
2709
3717
  if (persisted === undefined) {
2710
3718
  throw new WebConsoleError("storage_corrupt", `Live input ${row.id} has no message.`, 500);
2711
3719
  }
2712
- updateInput.run(now, row.id);
2713
- updateMessage.run(serializeParts(withLiveInputStatus(parseParts(persisted.parts_json), "queued")), now, row.message_id);
3720
+ if (row.dispatch_started_at === null) {
3721
+ updateInput.run(now, row.id);
3722
+ this.database.prepare(`
3723
+ UPDATE web_submissions SET reason = 'closed_before_dispatch'
3724
+ WHERE input_id = ? AND outcome = 'live-input' AND reason IS NULL
3725
+ `).run(row.id);
3726
+ this.writeMessageParts(row.message_id, withLiveInputStatus(parseParts(persisted.parts_json), "queued"), now, { turnId: null });
3727
+ }
3728
+ else {
3729
+ this.writeMessageParts(row.message_id, withLiveInputStatus(parseParts(persisted.parts_json), "uncertain"), now, { turnId: null });
3730
+ this.database.prepare("DELETE FROM live_inputs WHERE id = ?").run(row.id);
3731
+ }
2714
3732
  }
2715
3733
  for (const threadId of threadIds) {
2716
3734
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
@@ -2726,20 +3744,21 @@ export class WebStore {
2726
3744
  WHERE status = 'sending'
2727
3745
  `).run(now, now);
2728
3746
  }
2729
- finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3747
+ finishTurn(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
2730
3748
  const turn = this.requireTurn(turnId);
2731
3749
  if (turn.status !== "running") {
2732
3750
  return this.requireThreadDetail(turn.thread_id);
2733
3751
  }
2734
- this.transaction(() => {
2735
- this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts);
2736
- });
2737
- return this.requireThreadDetail(turn.thread_id);
3752
+ const write = this.transaction(() => this.finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush, monitorWakeDeliveryKey));
3753
+ return {
3754
+ ...this.requireThreadDetail(turn.thread_id),
3755
+ ...(write === undefined ? {} : { write }),
3756
+ };
2738
3757
  }
2739
- finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts) {
3758
+ finishTurnInTransaction(turnId, status, finalText, errorCode, errorMessage, runtime, replyParts, suppressResponsePush = false, monitorWakeDeliveryKey) {
2740
3759
  const turn = this.requireTurn(turnId);
2741
3760
  if (turn.status !== "running")
2742
- return;
3761
+ return undefined;
2743
3762
  const existing = this.requireMessage(turn.assistant_message_id);
2744
3763
  let parts = [...existing.parts];
2745
3764
  if (finalText !== undefined && finalText.length > 0)
@@ -2749,17 +3768,30 @@ export class WebStore {
2749
3768
  if (errorMessage !== undefined) {
2750
3769
  parts.push({ type: "error", ...(errorCode === undefined ? {} : { code: errorCode }), message: errorMessage });
2751
3770
  }
3771
+ const monitorAssociated = status === "complete" && this.hasMonitorTurnAssociation(turnId, monitorWakeDeliveryKey);
3772
+ const processJobAssociated = status === "complete" && this.hasProcessJobTurnAssociation(turnId, monitorWakeDeliveryKey);
3773
+ if (processJobAssociated) {
3774
+ parts = normalizeMonitorTerminalReply(parts, true).parts;
3775
+ suppressResponsePush = !hasMonitorReplyContent(parts);
3776
+ }
3777
+ if (monitorAssociated) {
3778
+ const normalized = normalizeMonitorTerminalReply(parts);
3779
+ parts = normalized.parts;
3780
+ // A suppressed callback may still have streamed the sentinel. Preserve a
3781
+ // preceding answer and rich output even when its terminal reply was empty.
3782
+ suppressResponsePush = !hasMonitorReplyContent(parts);
3783
+ }
2752
3784
  const now = this.now();
2753
3785
  const thread = this.requireThread(turn.thread_id);
2754
- const agent = this.getAgent(thread.sourceId);
3786
+ const agent = this.getStoredAgent(thread.sourceId);
2755
3787
  this.database.prepare(`
2756
3788
  UPDATE turns SET status = ?, finished_at = ?, error_code = ?, error_message = ?,
2757
3789
  model = CASE WHEN ? IS NULL THEN model ELSE ? END,
2758
- effort = CASE WHEN ? IS NULL THEN effort ELSE ? END
3790
+ effort = CASE WHEN ? IS NULL THEN effort ELSE ? END,
3791
+ effective_effort = CASE WHEN ? IS NULL THEN effective_effort ELSE ? END
2759
3792
  WHERE id = ?
2760
- `).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, turnId);
2761
- this.database.prepare("UPDATE messages SET parts_json = ?, status = ?, updated_at = ? WHERE id = ?")
2762
- .run(serializeParts(parts), status, now, existing.id);
3793
+ `).run(status, now, errorCode ?? null, errorMessage ?? null, runtime?.model ?? null, runtime?.model ?? null, runtime?.effort ?? null, runtime?.effort ?? null, runtime?.effectiveEffort ?? null, runtime?.effectiveEffort ?? null, turnId);
3794
+ const delta = this.writeMessageDelta(existing, parts, now, { status });
2763
3795
  this.database.prepare("UPDATE threads SET updated_at = ?, revision = revision + 1 WHERE id = ?")
2764
3796
  .run(now, turn.thread_id);
2765
3797
  this.recordThreadRevision(turn.thread_id, `turn_${status}`, now);
@@ -2768,7 +3800,9 @@ export class WebStore {
2768
3800
  // Projected cron turns are agent-owned scheduler state. Restart recovery
2769
3801
  // still settles the local projection, but only web-owned turns may emit a
2770
3802
  // Web Push terminal notification from this service.
2771
- if (thread.trigger?.kind !== "cron" && recentEnoughForRecoveredInterruption) {
3803
+ if (thread.trigger?.kind !== "cron"
3804
+ && recentEnoughForRecoveredInterruption
3805
+ && !(status === "complete" && suppressResponsePush)) {
2772
3806
  const kind = status === "complete"
2773
3807
  ? "response.ready"
2774
3808
  : status === "cancelled"
@@ -2778,7 +3812,7 @@ export class WebStore {
2778
3812
  : "run.failed";
2779
3813
  const label = agent?.label ?? "mono-agent";
2780
3814
  const body = status === "complete"
2781
- ? parts.filter((part) => part.type === "text")
3815
+ ? monitorAssociated ? monitorReplyText(parts) : parts.filter((part) => part.type === "text")
2782
3816
  .map((part) => part.text)
2783
3817
  .join(" ")
2784
3818
  : status === "cancelled"
@@ -2803,10 +3837,14 @@ export class WebStore {
2803
3837
  notBefore: new Date(new Date(now).getTime() + 3_000).toISOString(),
2804
3838
  });
2805
3839
  }
3840
+ // Re-read rather than reuse `existing`: the settled row is what Task 6
3841
+ // pushes beside the delta, and it carries the turn's finish stamp.
3842
+ return { message: this.requireMessage(existing.id), delta };
2806
3843
  }
2807
3844
  mapThread(row) {
2808
3845
  const runState = this.latestRunState(row.id);
2809
3846
  const preview = this.lastMessagePreview(row.id);
3847
+ const jobActivity = this.jobActivity(row.id);
2810
3848
  return {
2811
3849
  id: row.id,
2812
3850
  sourceId: row.source_id,
@@ -2829,63 +3867,268 @@ export class WebStore {
2829
3867
  ...(preview === undefined ? {} : { lastMessagePreview: preview }),
2830
3868
  messageCount: row.message_count,
2831
3869
  runState,
3870
+ ...(jobActivity === undefined ? {} : { jobActivity }),
2832
3871
  canSend: row.can_send === 1,
2833
3872
  canUpload: row.can_upload === 1,
3873
+ runModel: row.run_model,
3874
+ runEffort: row.run_effort,
3875
+ };
3876
+ }
3877
+ /**
3878
+ * The ONE statement that persists a message's parts.
3879
+ *
3880
+ * Every parts write bumps `seq`, and that count is what makes a content
3881
+ * delta safe to apply: a console holding version N can tell the write that
3882
+ * follows it from one that skipped ahead, and re-read the message instead of
3883
+ * guessing. A write that went straight to `parts_json` would mint content no
3884
+ * delta describes and no sequence number covers, so this is the only place in
3885
+ * the store that names the column. A caller that also moves the row passes
3886
+ * the columns it changes here rather than issuing a second statement.
3887
+ *
3888
+ * Only the two paths a console watches live -- streaming frames and the write
3889
+ * that settles a turn -- go on to build a {@link WebMessageDelta} from this.
3890
+ * Every other caller (a live-input transition, restart recovery, and the
3891
+ * notification, cron-run, process-job and Monitor reconciliations) bumps the
3892
+ * version without describing the change: those writes reach the browser as an
3893
+ * invalidation it answers by re-reading the message, and the new `seq` is what
3894
+ * tells it the re-read is newer than the delta stream it was applying.
3895
+ *
3896
+ * EVERY caller here owes its console a message event -- a delta, or the
3897
+ * `message.changed` naming this row. A subscribed console no longer answers a
3898
+ * conversation summary by re-reading the transcript, so a write announced
3899
+ * only as a summary is one it never sees. `recoverLiveInputs` is the single
3900
+ * exception, and only because it runs at open with no subscriber to tell.
3901
+ */
3902
+ writeMessageParts(id, parts, now, columns = {}) {
3903
+ const assignments = [];
3904
+ const values = [];
3905
+ if (columns.threadId !== undefined) {
3906
+ assignments.push("thread_id = ?");
3907
+ values.push(columns.threadId);
3908
+ }
3909
+ if (columns.turnId !== undefined) {
3910
+ assignments.push("turn_id = ?");
3911
+ values.push(columns.turnId);
3912
+ }
3913
+ assignments.push("parts_json = ?");
3914
+ values.push(serializeParts(parts));
3915
+ if (columns.createdAt !== undefined) {
3916
+ assignments.push("created_at = ?");
3917
+ values.push(columns.createdAt);
3918
+ }
3919
+ assignments.push("updated_at = ?");
3920
+ values.push(now);
3921
+ if (columns.status !== undefined) {
3922
+ assignments.push("status = ?");
3923
+ values.push(columns.status);
3924
+ }
3925
+ const sql = `UPDATE messages SET ${assignments.join(", ")}, seq = seq + 1 WHERE id = ? RETURNING seq`;
3926
+ let statement = this.partsWriteStatements.get(sql);
3927
+ if (statement === undefined) {
3928
+ statement = this.database.prepare(sql);
3929
+ this.partsWriteStatements.set(sql, statement);
3930
+ }
3931
+ const row = statement.get(...values, id);
3932
+ if (row === undefined) {
3933
+ throw new WebConsoleError("storage_corrupt", `Message ${id} is missing from this conversation.`, 500);
3934
+ }
3935
+ return { baseSeq: row.seq - 1, seq: row.seq };
3936
+ }
3937
+ /**
3938
+ * Persist a message's parts and describe the write as a content delta.
3939
+ *
3940
+ * `message` must be the message the new parts were DERIVED from: the diff
3941
+ * compares by reference, so a delta computed against any other read of the
3942
+ * same row would call every part changed.
3943
+ */
3944
+ writeMessageDelta(message, parts, now, columns = {}) {
3945
+ const { baseSeq, seq } = this.writeMessageParts(message.id, parts, now, columns);
3946
+ // The ops describe `message.parts`; `baseSeq` is what the row actually held
3947
+ // when this statement ran. Every caller reads and writes inside one
3948
+ // transaction on a single-writer database, so these agree -- and if they
3949
+ // ever stopped agreeing, the delta would be a self-consistent description
3950
+ // of a version that never existed, which a console applies in silence.
3951
+ // Refusing here turns that into a failure the caller can see.
3952
+ if (baseSeq !== message.seq) {
3953
+ throw new WebConsoleError("storage_corrupt", `Message ${message.id} moved from ${String(message.seq)} to ${String(baseSeq)} while its delta was built.`, 500);
3954
+ }
3955
+ const attribution = message.turnId === undefined
3956
+ ? undefined
3957
+ : runAttribution(this.requireTurn(message.turnId));
3958
+ return {
3959
+ messageId: message.id,
3960
+ baseSeq,
3961
+ seq,
3962
+ status: columns.status ?? message.status,
3963
+ updatedAt: now,
3964
+ ...(attribution === undefined ? {} : { attribution }),
3965
+ ops: diffParts(message.parts, parts),
2834
3966
  };
2835
3967
  }
2836
3968
  mapMessage(row) {
2837
3969
  const attachments = this.database
2838
3970
  .prepare("SELECT * FROM attachments WHERE message_id = ? AND origin = 'upload' ORDER BY created_at, id")
2839
3971
  .all(row.id);
2840
- const storedParts = parseParts(row.parts_json);
3972
+ const rawParts = parseParts(row.parts_json);
3973
+ const storedParts = row.role === "assistant" && row.status === "complete" && row.turn_id !== null
3974
+ && this.hasMonitorTurnAssociation(row.turn_id)
3975
+ ? normalizeMonitorTerminalReply(rawParts).parts : rawParts;
2841
3976
  const quote = quoteFromParts(storedParts);
2842
3977
  const liveInputStatus = liveInputStatusFromParts(storedParts);
3978
+ const role = normalizeRole(row.role);
3979
+ const finishedAt = role === "assistant" ? this.turnFinishedAt(row) : undefined;
3980
+ const attribution = role === "assistant" && row.turn_id !== null
3981
+ ? runAttribution(this.requireTurn(row.turn_id))
3982
+ : undefined;
2843
3983
  return {
2844
3984
  id: row.id,
2845
3985
  threadId: row.thread_id,
2846
3986
  ...(row.turn_id === null ? {} : { turnId: row.turn_id }),
2847
- role: normalizeRole(row.role),
3987
+ role,
2848
3988
  ...(quote === undefined ? {} : { quote }),
2849
3989
  parts: storedParts.filter((part) => part.type !== "telemetry"
2850
3990
  || (part.event !== QUOTE_TELEMETRY_EVENT && part.event !== LIVE_INPUT_TELEMETRY_EVENT)),
2851
3991
  attachments: attachments.map((attachment) => toWebAttachment(mapStoredAttachment(attachment))),
2852
3992
  createdAt: row.created_at,
2853
3993
  updatedAt: row.updated_at,
3994
+ ...(finishedAt === undefined ? {} : { finishedAt }),
2854
3995
  status: normalizeMessageStatus(row.status),
2855
3996
  ...(liveInputStatus === undefined ? {} : { liveInputStatus }),
3997
+ ...(attribution === undefined ? {} : { attribution }),
3998
+ seq: row.seq,
2856
3999
  };
2857
4000
  }
4001
+ /**
4002
+ * The turn's terminal stamp, which `finishTurnInTransaction` writes with the
4003
+ * message status. A page query projects it from its own join; only a
4004
+ * single-row read pays for a lookup.
4005
+ */
4006
+ turnFinishedAt(row) {
4007
+ if (row.turn_id === null)
4008
+ return undefined;
4009
+ if (row.turn_finished_at !== undefined)
4010
+ return row.turn_finished_at ?? undefined;
4011
+ const turn = this.database.prepare("SELECT finished_at FROM turns WHERE id = ?")
4012
+ .get(row.turn_id);
4013
+ return turn?.finished_at ?? undefined;
4014
+ }
2858
4015
  latestRunState(threadId) {
2859
4016
  const row = this.database.prepare("SELECT * FROM turns WHERE thread_id = ? ORDER BY started_at DESC, rowid DESC LIMIT 1")
2860
4017
  .get(threadId);
2861
4018
  if (row === undefined)
2862
4019
  return { status: "idle" };
2863
4020
  const status = normalizeRunStatus(row.status);
4021
+ const attribution = runAttribution(row);
4022
+ // Successful assistant-only turns without visible reply content are host
4023
+ // no-ops, not a new conversation outcome. Keep their real run state while
4024
+ // projecting the prior meaningful outcome for status priority. Derive this
4025
+ // from retained provenance/normalized parts so old stores need no migration.
4026
+ // Match hasMonitorReplyContent without loading transcript bodies into lists.
4027
+ const candidates = status === "complete" ? this.database.prepare(`
4028
+ SELECT t.id, t.status, t.finished_at, t.assistant_message_id,
4029
+ EXISTS (SELECT 1 FROM messages m WHERE m.turn_id = t.id AND m.role = 'user') AS has_user
4030
+ FROM turns t
4031
+ WHERE t.thread_id = ? AND t.status <> 'running' AND (
4032
+ t.status <> 'complete'
4033
+ OR EXISTS (SELECT 1 FROM messages m WHERE m.turn_id = t.id AND m.role = 'user')
4034
+ OR EXISTS (
4035
+ SELECT 1 FROM messages m, json_each(m.parts_json) p
4036
+ WHERE m.id = t.assistant_message_id AND (
4037
+ json_extract(p.value, '$.type') IN ('attachment', 'mcp_app', 'failure')
4038
+ OR (json_extract(p.value, '$.type') = 'text'
4039
+ AND length(trim(json_extract(p.value, '$.text'), ?)) > 0)
4040
+ )
4041
+ )
4042
+ ) ORDER BY t.started_at DESC, t.rowid DESC
4043
+ `).iterate(threadId, "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff") : [];
4044
+ let outcome;
4045
+ for (const rawCandidate of candidates) {
4046
+ const candidate = rawCandidate;
4047
+ // Legacy Monitor rows retain raw sentinel bytes and normalize only on
4048
+ // read. Inspect one associated candidate at a time, never materialize the
4049
+ // transcript or rewrite history merely to derive sidebar status.
4050
+ if (candidate.status === "complete" && candidate.has_user === 0 && this.hasMonitorTurnAssociation(candidate.id)) {
4051
+ const message = this.database.prepare("SELECT parts_json FROM messages WHERE id = ?")
4052
+ .get(candidate.assistant_message_id);
4053
+ if (!hasMonitorReplyContent(normalizeMonitorTerminalReply(parseParts(message.parts_json)).parts))
4054
+ continue;
4055
+ }
4056
+ outcome = candidate;
4057
+ break;
4058
+ }
4059
+ const lastOutcome = status !== "complete" || outcome?.id === row.id ? undefined
4060
+ : outcome === undefined ? null : {
4061
+ status: normalizeRunStatus(outcome.status),
4062
+ ...(outcome.finished_at === null ? {} : { finishedAt: outcome.finished_at }),
4063
+ };
2864
4064
  return {
2865
4065
  id: row.id,
2866
4066
  status,
2867
4067
  startedAt: row.started_at,
4068
+ ...(lastOutcome === undefined ? {} : { lastOutcome }),
2868
4069
  ...(row.finished_at === null ? {} : { finishedAt: row.finished_at }),
2869
4070
  ...(row.error_message === null
2870
4071
  ? {}
2871
4072
  : { error: { ...(row.error_code === null ? {} : { code: row.error_code }), message: row.error_message } }),
2872
4073
  ...(row.model === null ? {} : { model: row.model }),
2873
4074
  ...(row.effort === null ? {} : { effort: row.effort }),
4075
+ ...(attribution === undefined ? {} : { attribution }),
2874
4076
  };
2875
4077
  }
2876
4078
  lastMessagePreview(threadId) {
2877
- const row = this.database.prepare("SELECT parts_json FROM messages WHERE thread_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1")
4079
+ const row = this.database.prepare(`SELECT * FROM messages WHERE thread_id = ? AND ${visibleMessageSql("messages")} ORDER BY created_at DESC, rowid DESC LIMIT 1`)
2878
4080
  .get(threadId);
2879
4081
  if (row === undefined)
2880
4082
  return undefined;
2881
- const text = parseParts(row.parts_json)
2882
- .filter((part) => part.type === "text" || part.type === "reasoning")
2883
- .map((part) => part.text)
4083
+ const text = this.mapMessage(row).parts
4084
+ .flatMap((part) => part.type === "text" ? [part.text]
4085
+ : part.type === "process-job" && part.responseText !== undefined ? [part.responseText] : [])
2884
4086
  .join(" ")
2885
4087
  .replace(/\s+/gu, " ")
2886
4088
  .trim();
2887
4089
  return text.length === 0 ? undefined : text.slice(0, 160);
2888
4090
  }
4091
+ jobActivity(threadId) {
4092
+ // Read only retained job cards, including those behind the message page.
4093
+ // Aggregate in SQLite so neither transcripts nor output tails are loaded
4094
+ // into the listing. These rows already advance the thread's revision.
4095
+ const row = this.database.prepare(`
4096
+ WITH jobs AS (
4097
+ SELECT json_extract(part.value, '$.job.state') AS state,
4098
+ json_extract(part.value, '$.job.timestamps.completedAt') AS completed_at,
4099
+ c.response_text, m.rowid AS ordinal
4100
+ FROM messages m
4101
+ JOIN process_job_cards c ON c.message_id = m.id AND c.thread_id = m.thread_id
4102
+ JOIN json_each(m.parts_json) part
4103
+ WHERE m.thread_id = ? AND json_extract(part.value, '$.type') = 'process-job'
4104
+ )
4105
+ SELECT count(*) AS total,
4106
+ count(*) FILTER (WHERE state = 'queued') AS queued,
4107
+ count(*) FILTER (WHERE state = 'starting') AS starting,
4108
+ count(*) FILTER (WHERE state = 'running') AS running,
4109
+ (SELECT json_object('state', state, 'completedAt', completed_at, 'reply', response_text)
4110
+ FROM jobs WHERE completed_at IS NOT NULL
4111
+ ORDER BY julianday(completed_at) DESC, (state <> 'succeeded') DESC, ordinal DESC
4112
+ LIMIT 1) AS latest_terminal
4113
+ FROM jobs
4114
+ `).get(threadId);
4115
+ if (row.total === 0)
4116
+ return undefined;
4117
+ const terminal = row.latest_terminal === null ? undefined : JSON.parse(row.latest_terminal);
4118
+ const replyPreview = terminal?.reply?.replace(/\s+/gu, " ").trim().slice(0, 160);
4119
+ return {
4120
+ queued: row.queued,
4121
+ starting: row.starting,
4122
+ running: row.running,
4123
+ ...(terminal === undefined ? {} : {
4124
+ latestTerminal: {
4125
+ state: terminal.state,
4126
+ completedAt: terminal.completedAt,
4127
+ ...(replyPreview ? { replyPreview } : {}),
4128
+ },
4129
+ }),
4130
+ };
4131
+ }
2889
4132
  cronChannel(sourceId, jobId) {
2890
4133
  return this.database.prepare(`
2891
4134
  SELECT * FROM cron_channels WHERE source_id = ? AND job_id = ?
@@ -3031,8 +4274,85 @@ export class WebStore {
3031
4274
  this.database.prepare("INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value")
3032
4275
  .run(key, value);
3033
4276
  }
4277
+ transactionDepth = 0;
4278
+ claimWebSubmission(input) {
4279
+ return this.transaction(() => {
4280
+ const threadId = this.resolveThreadId(input.threadId);
4281
+ this.requireThread(threadId);
4282
+ const existing = this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, input.submissionId);
4283
+ if (existing !== undefined) {
4284
+ if (existing.payload_sha256 !== input.payloadSha256) {
4285
+ throw new WebConsoleError("submission_conflict", "Submission id was already used for different content.", 409);
4286
+ }
4287
+ return { created: false, submission: mapWebSubmission(existing) };
4288
+ }
4289
+ const created = input.create();
4290
+ const now = this.now();
4291
+ this.database.prepare(`
4292
+ INSERT INTO web_submissions (
4293
+ thread_id, submission_id, payload_sha256, outcome, reason, message_id, turn_id, input_id, created_at
4294
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
4295
+ `).run(threadId, input.submissionId, input.payloadSha256, created.outcome, created.reason ?? null, created.messageId ?? null, created.turnId ?? null, created.inputId ?? null, now);
4296
+ return {
4297
+ created: true,
4298
+ submission: mapWebSubmission(this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, input.submissionId)),
4299
+ };
4300
+ });
4301
+ }
4302
+ webSubmission(threadId, submissionId) {
4303
+ threadId = this.resolveThreadId(threadId);
4304
+ this.requireThread(threadId);
4305
+ const row = this.database.prepare("SELECT * FROM web_submissions WHERE thread_id = ? AND submission_id = ?").get(threadId, submissionId);
4306
+ return row === undefined ? undefined : mapWebSubmission(row);
4307
+ }
4308
+ requireCronReplyOperationRow(operationId) {
4309
+ const row = this.database.prepare("SELECT * FROM cron_reply_operations WHERE operation_id = ?")
4310
+ .get(operationId);
4311
+ if (row === undefined) {
4312
+ throw new WebConsoleError("cron_reply_operation_not_found", "Cron reply operation not found.", 404);
4313
+ }
4314
+ return row;
4315
+ }
4316
+ assertCronReplyIdentity(row, sourceId, jobId, runId) {
4317
+ if (row.source_id !== sourceId || row.job_id !== jobId || row.run_id !== runId) {
4318
+ throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409);
4319
+ }
4320
+ }
4321
+ cronReplyState(row) {
4322
+ if (row.state === "completed") {
4323
+ return { kind: "completed", receipt: this.cronReplyReceipt(row, true) };
4324
+ }
4325
+ if (row.state === "failed")
4326
+ return { kind: "failed", operation: mapCronReplyOperation(row) };
4327
+ if (row.state === "tombstoned")
4328
+ return { kind: "tombstoned", operation: mapCronReplyOperation(row) };
4329
+ return { kind: "pending", operation: mapCronReplyOperation(row) };
4330
+ }
4331
+ cronReplyReceipt(row, duplicate) {
4332
+ if (row.provenance_message_id === null || row.result_message_id === null) {
4333
+ throw new WebConsoleError("storage_corrupt", "Completed cron reply is missing its projected messages.", 500);
4334
+ }
4335
+ const thread = this.getThread(row.thread_id);
4336
+ const provenance = this.getMessage(row.provenance_message_id);
4337
+ const result = this.getMessage(row.result_message_id);
4338
+ if (thread === undefined || provenance === undefined || result === undefined) {
4339
+ throw new WebConsoleError("storage_corrupt", "Completed cron reply projection is missing.", 500);
4340
+ }
4341
+ return {
4342
+ operationId: row.operation_id,
4343
+ sourceId: row.source_id,
4344
+ jobId: row.job_id,
4345
+ runId: row.run_id,
4346
+ duplicate,
4347
+ thread,
4348
+ messages: [provenance, result],
4349
+ };
4350
+ }
3034
4351
  transaction(operation) {
4352
+ if (this.transactionDepth > 0)
4353
+ return operation();
3035
4354
  this.database.exec("BEGIN IMMEDIATE");
4355
+ this.transactionDepth += 1;
3036
4356
  try {
3037
4357
  const result = operation();
3038
4358
  this.database.exec("COMMIT");
@@ -3042,11 +4362,42 @@ export class WebStore {
3042
4362
  this.database.exec("ROLLBACK");
3043
4363
  throw error;
3044
4364
  }
4365
+ finally {
4366
+ this.transactionDepth -= 1;
4367
+ }
3045
4368
  }
3046
4369
  now() {
3047
4370
  return this.clock().toISOString();
3048
4371
  }
3049
4372
  }
4373
+ function mapWebSubmission(row) {
4374
+ return {
4375
+ threadId: row.thread_id,
4376
+ submissionId: row.submission_id,
4377
+ payloadSha256: row.payload_sha256,
4378
+ outcome: row.outcome,
4379
+ ...(row.reason === null ? {} : { reason: row.reason }),
4380
+ ...(row.message_id === null ? {} : { messageId: row.message_id }),
4381
+ ...(row.turn_id === null ? {} : { turnId: row.turn_id }),
4382
+ ...(row.input_id === null ? {} : { inputId: row.input_id }),
4383
+ };
4384
+ }
4385
+ function mapCronReplyOperation(row) {
4386
+ return {
4387
+ operationId: row.operation_id,
4388
+ sourceId: row.source_id,
4389
+ jobId: row.job_id,
4390
+ runId: row.run_id,
4391
+ threadId: row.thread_id,
4392
+ conversationId: row.conversation_id,
4393
+ idempotencyKey: row.idempotency_key,
4394
+ state: row.state,
4395
+ snapshotKind: row.snapshot_kind,
4396
+ ...(row.snapshot_text === null ? {} : { snapshotText: row.snapshot_text }),
4397
+ ...(row.snapshot_sha256 === null ? {} : { snapshotSha256: row.snapshot_sha256 }),
4398
+ ...(row.failure_reason === null ? {} : { failureReason: row.failure_reason }),
4399
+ };
4400
+ }
3050
4401
  function mapPushSubscriptionStatus(row) {
3051
4402
  const state = row.state === "disabled" || row.state === "expired"
3052
4403
  ? row.state
@@ -3124,12 +4475,13 @@ function isValidVapidKeyPair(publicKey, privateKey) {
3124
4475
  function threadSelectSql(suffix) {
3125
4476
  return `
3126
4477
  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,
4478
+ t.run_model, t.run_effort,
3127
4479
  cc.job_id AS cron_job_id, cc.configured AS cron_configured,
3128
4480
  CASE WHEN t.trigger_kind = 'cron' THEN 0
3129
4481
  WHEN a.status = 'online' OR a.status = 'degraded' THEN 1 ELSE 0 END AS can_send,
3130
4482
  CASE WHEN t.trigger_kind = 'cron' THEN 0
3131
4483
  WHEN (a.status = 'online' OR a.status = 'degraded') AND a.supports_attachments = 1 THEN 1 ELSE 0 END AS can_upload,
3132
- (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id) AS message_count
4484
+ (SELECT COUNT(*) FROM messages m WHERE m.thread_id = t.id AND ${visibleMessageSql("m")}) AS message_count
3133
4485
  FROM threads t JOIN agents a ON a.source_id = t.source_id
3134
4486
  LEFT JOIN cron_channels cc ON cc.thread_id = t.id
3135
4487
  ${suffix}
@@ -3138,11 +4490,14 @@ function threadSelectSql(suffix) {
3138
4490
  function agentSelectSql(suffix) {
3139
4491
  return `
3140
4492
  SELECT a.*,
4493
+ o.model AS override_model,
4494
+ o.effort AS override_effort,
3141
4495
  CASE WHEN EXISTS (
3142
4496
  SELECT 1 FROM settings s
3143
4497
  WHERE s.key = 'agent_pin:' || a.source_id AND s.value = '1'
3144
4498
  ) THEN 1 ELSE 0 END AS pinned
3145
4499
  FROM agents a
4500
+ LEFT JOIN agent_run_overrides o ON o.source_id = a.source_id
3146
4501
  ${suffix}
3147
4502
  `;
3148
4503
  }
@@ -3224,8 +4579,36 @@ function cronMessageStatus(status) {
3224
4579
  return "cancelled";
3225
4580
  return "complete";
3226
4581
  }
3227
- function cronRunParts(run, prior, conversationId) {
3228
- const silent = run.status === "succeeded" && classifyNotifySuppression(run.text) !== "none";
4582
+ function isTerminalCronRun(status) {
4583
+ return status === "succeeded"
4584
+ || status === "failed"
4585
+ || status === "cancelled"
4586
+ || status === "skipped_overlap"
4587
+ || status === "dropped";
4588
+ }
4589
+ /** Presentation queries only; storage validation/recovery and retention stay raw. */
4590
+ function visibleMessageSql(alias) { return `${alias}.cron_suppressed = 0`; }
4591
+ function withoutCronSilentFlag(data) {
4592
+ const { silent: _silent, ...rest } = record(data) ?? {};
4593
+ return rest;
4594
+ }
4595
+ function clearSilentCronPart(part) {
4596
+ return part.type === "telemetry" && part.event === "cron_run" && record(part.data)?.silent === true
4597
+ ? { ...part, data: withoutCronSilentFlag(part.data) } : part;
4598
+ }
4599
+ function definitelySilentCronRun(run) {
4600
+ return run.status === "succeeded" && run.fieldsTruncated?.includes("text") !== true
4601
+ && classifyNotifySuppression(run.text) !== "none";
4602
+ }
4603
+ function hasMeaningfulCronContent(parts) {
4604
+ return parts.some((part) => part.type === "text"
4605
+ ? !isSyntheticCronStateText(part.text) && classifyNotifySuppression(part.text) === "none"
4606
+ : part.type === "attachment" || part.type === "error" || part.type === "mcp_app"
4607
+ || part.type === "failure" || part.type === "process-job" || part.type === "process-job-wake"
4608
+ || part.type === "monitor-activity");
4609
+ }
4610
+ function cronRunParts(run, prior, conversationId, notificationBacked = false) {
4611
+ const silent = definitelySilentCronRun(run);
3229
4612
  const priorCron = prior.find((part) => part.type === "telemetry" && part.event === "cron_run");
3230
4613
  const priorCronData = record(priorCron?.data);
3231
4614
  const priorActivityLoaded = priorCronData?.activityLoaded === true;
@@ -3235,6 +4618,10 @@ function cronRunParts(run, prior, conversationId) {
3235
4618
  const priorActivityEventCount = Number.isSafeInteger(priorCronData?.activityEventCount)
3236
4619
  ? Number(priorCronData?.activityEventCount)
3237
4620
  : priorLoadedEventCount;
4621
+ const priorDetailFieldsTruncated = Array.isArray(priorCronData?.detailFieldsTruncated)
4622
+ && priorCronData.detailFieldsTruncated.every((field) => typeof field === "string")
4623
+ ? priorCronData.detailFieldsTruncated
4624
+ : undefined;
3238
4625
  const activityLoaded = run.projection === "detail"
3239
4626
  || (priorActivityLoaded && priorActivityEventCount === run.eventCount);
3240
4627
  const activityStale = run.projection === "summary"
@@ -3247,10 +4634,12 @@ function cronRunParts(run, prior, conversationId) {
3247
4634
  // synthetic state/identity before rebuilding it, while retaining a genuine
3248
4635
  // notification text that arrived before the operator run projection.
3249
4636
  const retained = prior.filter((part) => !(part.type === "telemetry" && part.event === "cron_run")
3250
- && !(part.type === "text" && isSyntheticCronStateText(part.text)));
4637
+ && !(part.type === "text" && !notificationBacked && isSyntheticCronStateText(part.text)));
3251
4638
  const parts = run.projection === "summary"
3252
4639
  ? [...retained]
3253
- : retained.filter((part) => part.type === "text" || part.type === "error");
4640
+ : retained.filter((part) => part.type === "text" || part.type === "error" || part.type === "attachment"
4641
+ || part.type === "mcp_app" || part.type === "failure" || part.type === "process-job"
4642
+ || part.type === "process-job-wake" || part.type === "monitor-activity");
3254
4643
  for (const event of run.projection === "detail" ? run.events : [])
3255
4644
  applyEvent(parts, event);
3256
4645
  const preserveLoadedText = run.projection === "summary"
@@ -3260,7 +4649,7 @@ function cronRunParts(run, prior, conversationId) {
3260
4649
  && priorActivityLoaded
3261
4650
  && (run.fieldsTruncated?.includes("error") === true
3262
4651
  || run.fieldsTruncated?.includes("failureKind") === true);
3263
- if (!silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
4652
+ if (!notificationBacked && !silent && !preserveLoadedText && run.text !== undefined && run.text.length > 0) {
3264
4653
  reconcileFinalText(parts, run.text);
3265
4654
  }
3266
4655
  const hasText = parts.some((part) => part.type === "text" && part.text.trim().length > 0);
@@ -3304,7 +4693,7 @@ function cronRunParts(run, prior, conversationId) {
3304
4693
  sequence: run.sequence,
3305
4694
  trigger: run.trigger,
3306
4695
  status: run.status,
3307
- ...(silent ? { silent: true } : {}),
4696
+ ...(silent && !hasMeaningfulCronContent(parts) ? { silent: true } : {}),
3308
4697
  ...(run.startedAt === undefined ? {} : { startedAt: run.startedAt }),
3309
4698
  ...(run.completedAt === undefined ? {} : { completedAt: run.completedAt }),
3310
4699
  ...(run.artifactRunId === undefined ? {} : { artifactRunId: run.artifactRunId }),
@@ -3319,6 +4708,9 @@ function cronRunParts(run, prior, conversationId) {
3319
4708
  : {}),
3320
4709
  ...(activityStale ? { activityStale: true, loadedEventCount: priorLoadedEventCount } : {}),
3321
4710
  ...(eventsTruncated ? { eventsTruncated: true } : {}),
4711
+ ...(run.projection === "detail"
4712
+ ? { detailFieldsTruncated: run.fieldsTruncated ?? [] }
4713
+ : priorDetailFieldsTruncated === undefined ? {} : { detailFieldsTruncated: priorDetailFieldsTruncated }),
3322
4714
  ...(run.fieldsTruncated === undefined ? {} : { fieldsTruncated: run.fieldsTruncated }),
3323
4715
  },
3324
4716
  });
@@ -3390,7 +4782,9 @@ function parseStoredCronRun(serialized) {
3390
4782
  ...(legacyEvents === undefined ? {} : { eventsTruncated: true }),
3391
4783
  }
3392
4784
  : raw);
3393
- if (run.projection !== "summary"
4785
+ if ((run.text !== undefined && typeof run.text !== "string")
4786
+ || (run.fieldsTruncated !== undefined && (!Array.isArray(run.fieldsTruncated) || run.fieldsTruncated.some((field) => typeof field !== "string")))
4787
+ || run.projection !== "summary"
3394
4788
  || typeof run.runId !== "string"
3395
4789
  || typeof run.jobId !== "string"
3396
4790
  || typeof run.scheduledAt !== "string"
@@ -3460,6 +4854,8 @@ function mapAgent(row) {
3460
4854
  const models = parseStringArray(row.models_json);
3461
4855
  const efforts = parseStringArray(row.efforts_json);
3462
4856
  const modelOptions = parseRecord(row.model_options_json);
4857
+ const providers = parseProviderSummary(row.providers_json);
4858
+ const runSettings = agentRunSettings(row);
3463
4859
  return {
3464
4860
  sourceId: row.source_id,
3465
4861
  label: row.label,
@@ -3467,11 +4863,14 @@ function mapAgent(row) {
3467
4863
  pinned: row.pinned === 1,
3468
4864
  ...(row.health === null ? {} : { health: row.health }),
3469
4865
  supportsAttachments: row.supports_attachments === 1,
4866
+ ...(row.supports_provider_auth === 1 ? { supportsProviderAuth: true } : {}),
3470
4867
  ...(models === undefined ? {} : { models }),
3471
4868
  ...(row.default_model === null ? {} : { defaultModel: row.default_model }),
3472
4869
  ...(row.default_effort === null ? {} : { defaultEffort: row.default_effort }),
3473
4870
  ...(efforts === undefined ? {} : { efforts }),
3474
4871
  ...(modelOptions === undefined ? {} : { modelOptions }),
4872
+ runSettings,
4873
+ ...(providers === undefined ? {} : { providers }),
3475
4874
  ...(row.cron_read === 1
3476
4875
  ? { cron: { read: true, actions: row.cron_actions === 1 } }
3477
4876
  : {}),
@@ -3479,6 +4878,30 @@ function mapAgent(row) {
3479
4878
  updatedAt: row.updated_at,
3480
4879
  };
3481
4880
  }
4881
+ function agentRunSettings(row) {
4882
+ const effectiveModel = row.override_model ?? row.default_model;
4883
+ const effectiveEffort = row.override_effort ?? row.default_effort;
4884
+ const config = {
4885
+ ...(row.default_model === null ? {} : { model: row.default_model }),
4886
+ ...(row.default_effort === null ? {} : { effort: row.default_effort }),
4887
+ };
4888
+ const override = row.override_model === null && row.override_effort === null
4889
+ ? null
4890
+ : {
4891
+ ...(row.override_model === null ? {} : { model: row.override_model }),
4892
+ ...(row.override_effort === null ? {} : { effort: row.override_effort }),
4893
+ };
4894
+ return {
4895
+ config,
4896
+ override,
4897
+ effective: {
4898
+ ...(effectiveModel === null ? {} : { model: effectiveModel }),
4899
+ modelSource: row.override_model === null ? "config" : "override",
4900
+ ...(effectiveEffort === null ? {} : { effort: effectiveEffort }),
4901
+ effortSource: row.override_effort === null ? "config" : "override",
4902
+ },
4903
+ };
4904
+ }
3482
4905
  function mapStoredAttachment(row) {
3483
4906
  return {
3484
4907
  id: row.id,
@@ -3519,12 +4942,28 @@ export function toWebAttachment(attachment) {
3519
4942
  ...(attachment.uploaded ? { contentUrl: `/api/v1/uploads/${encodeURIComponent(attachment.id)}/content` } : {}),
3520
4943
  };
3521
4944
  }
3522
- function applyEvent(parts, event) {
4945
+ function appliedMonitorWake(event, resolveMonitorWake) {
4946
+ if (resolveMonitorWake === undefined
4947
+ || event.metadata?.liveInput !== true
4948
+ || event.metadata?.synthetic !== true
4949
+ || typeof event.metadata.inputId !== "string") {
4950
+ return undefined;
4951
+ }
4952
+ const projection = resolveMonitorWake(event.metadata.inputId);
4953
+ return projection === undefined
4954
+ ? undefined
4955
+ : { deliveryKey: event.metadata.inputId, projection };
4956
+ }
4957
+ function applyEvent(parts, event, resolveMonitorWake, resolveProcessJobWake) {
3523
4958
  if (event.type === "assistant_thought") {
3524
4959
  appendTextPart(parts, "reasoning", event.text);
3525
4960
  return;
3526
4961
  }
3527
4962
  if (event.type === "tool_call_started") {
4963
+ if (appliedMonitorWake(event, resolveMonitorWake) !== undefined)
4964
+ return;
4965
+ if (appliedProcessJobWake(event, resolveProcessJobWake) !== undefined)
4966
+ return;
3528
4967
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3529
4968
  const subagent = subagentOf(event);
3530
4969
  if (subagent !== undefined) {
@@ -3566,6 +5005,19 @@ function applyEvent(parts, event) {
3566
5005
  return;
3567
5006
  }
3568
5007
  if (event.type === "tool_call_completed") {
5008
+ const monitorWake = appliedMonitorWake(event, resolveMonitorWake);
5009
+ if (monitorWake !== undefined) {
5010
+ upsertMonitorActivity(parts, monitorWake.projection, monitorWake.deliveryKey);
5011
+ return;
5012
+ }
5013
+ const processJobWake = appliedProcessJobWake(event, resolveProcessJobWake);
5014
+ if (processJobWake !== undefined) {
5015
+ if (!parts.some((part) => part.type === "process-job-wake"
5016
+ && part.deliveryKey === processJobWake.deliveryKey)) {
5017
+ parts.push({ type: "process-job-wake", ...processJobWake, disposition: "steered" });
5018
+ }
5019
+ return;
5020
+ }
3569
5021
  const status = event.isError === true ? "failed" : "complete";
3570
5022
  const historyUpdate = canonicalEventHistoryUpdate(event.history);
3571
5023
  const executionMs = canonicalExecutionMs(event.executionMs);
@@ -3578,6 +5030,7 @@ function applyEvent(parts, event) {
3578
5030
  status,
3579
5031
  ...(executionMs === undefined ? {} : { executionMs }),
3580
5032
  ...(subagent.costUsd === undefined ? {} : { costUsd: subagent.costUsd }),
5033
+ ...(subagent.attribution === undefined ? {} : { attribution: subagent.attribution }),
3581
5034
  }, historyUpdate));
3582
5035
  return;
3583
5036
  }
@@ -3610,10 +5063,10 @@ function applyEvent(parts, event) {
3610
5063
  toolName: event.name ?? existingToolName(parts, event.id) ?? "Tool",
3611
5064
  ...(event.arguments === undefined ? {} : { args: event.arguments }),
3612
5065
  ...(event.content === undefined ? {} : { result: event.content }),
3613
- // `result` is the model-facing text and cannot answer "what did this tool
3614
- // actually decide". The AskUser card needs `interactionId`/`answered` to
3615
- // re-render an answered question after a reload, so keep the structured
3616
- // payload beside the prose rather than reparsing the sentence.
5066
+ // `result` is model-facing and cannot answer "what did this tool actually
5067
+ // decide". Keep bounded MCP and canonical host outcomes beside the prose:
5068
+ // AskUser needs its answer identity after reload, and process-job launches
5069
+ // need their exact causal receipt rather than reparsing a sentence.
3617
5070
  ...(event.structuredContent === undefined ? {} : { structuredResult: event.structuredContent }),
3618
5071
  ...(executionMs === undefined ? {} : { executionMs }),
3619
5072
  status,
@@ -3626,6 +5079,42 @@ function applyEvent(parts, event) {
3626
5079
  }
3627
5080
  parts.push({ type: "telemetry", event: event.type, data: event });
3628
5081
  }
5082
+ function appliedProcessJobWake(event, resolveProcessJobWake) {
5083
+ if (resolveProcessJobWake === undefined
5084
+ || event.metadata?.liveInput !== true
5085
+ || event.metadata?.synthetic !== true
5086
+ || typeof event.metadata.inputId !== "string") {
5087
+ return undefined;
5088
+ }
5089
+ return resolveProcessJobWake(event.metadata.inputId);
5090
+ }
5091
+ function upsertMonitorActivity(parts, projection, deliveryKey) {
5092
+ const index = parts.findIndex((part) => part.type === "monitor-activity");
5093
+ const previous = index < 0 ? undefined : parts[index];
5094
+ const monitors = previous?.monitors ?? [];
5095
+ const monitorIndex = monitors.findIndex((entry) => entry.projection.monitorId === projection.monitorId);
5096
+ const prior = monitorIndex < 0 ? undefined : monitors[monitorIndex];
5097
+ // The stream receipt and the durable delivery settlement can race. Once an
5098
+ // exact key is present, settlement is bookkeeping only: replacing its newer
5099
+ // projection with the reservation's older snapshot would make the activity
5100
+ // row move backwards and emit a duplicate invalidation.
5101
+ if (prior?.deliveryKeys.includes(deliveryKey) === true)
5102
+ return false;
5103
+ const deliveryKeys = prior === undefined ? [deliveryKey] : [...prior.deliveryKeys, deliveryKey];
5104
+ const entry = { projection, deliveryKeys };
5105
+ const nextMonitors = monitorIndex < 0
5106
+ ? [...monitors, entry]
5107
+ : monitors.map((value, at) => at === monitorIndex ? entry : value);
5108
+ const next = { type: "monitor-activity", monitors: nextMonitors };
5109
+ if (index < 0) {
5110
+ parts.push(next);
5111
+ return true;
5112
+ }
5113
+ if (isDeepStrictEqual(previous, next))
5114
+ return false;
5115
+ parts[index] = next;
5116
+ return true;
5117
+ }
3629
5118
  function contextCompactionOperationId(value) {
3630
5119
  if (value === null || typeof value !== "object" || Array.isArray(value))
3631
5120
  return undefined;
@@ -3697,11 +5186,13 @@ function subagentOf(event) {
3697
5186
  const costUsd = typeof record.costUsd === "number" && Number.isFinite(record.costUsd) && record.costUsd > 0
3698
5187
  ? record.costUsd
3699
5188
  : undefined;
5189
+ const attribution = canonicalRunAttribution(record.attribution);
3700
5190
  return {
3701
5191
  id: canonicalId,
3702
5192
  name: name.length === 0 ? "subagent" : name,
3703
5193
  ...(label.length === 0 ? {} : { label }),
3704
5194
  ...(costUsd === undefined ? {} : { costUsd }),
5195
+ ...(attribution === undefined ? {} : { attribution }),
3705
5196
  };
3706
5197
  }
3707
5198
  /** Drop the `<profile>▸` prefix: the group header already names the profile. */
@@ -3781,19 +5272,26 @@ function upsertToolCall(parts, next, historyUpdate) {
3781
5272
  parts[index] = updated;
3782
5273
  }
3783
5274
  /**
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.
5275
+ * Whether a stored part marks a semantic boundary between streamed text runs.
5276
+ * Most telemetry remains invisible; compaction and the content-free assistant
5277
+ * message marker intentionally keep adjacent provider responses separate.
3788
5278
  */
3789
- function isRenderedPart(part) {
5279
+ function separatesStreamedText(part) {
5280
+ // A Monitor acknowledgement may arrive while the provider is still flushing
5281
+ // the preceding message's final text delta. Its compact activity row must not
5282
+ // split that word; the explicit message boundary below separates responses.
5283
+ if (part.type === "monitor-activity")
5284
+ return false;
3790
5285
  if (part.type !== "telemetry")
3791
5286
  return true;
3792
5287
  const event = part.data;
3793
5288
  if (event === null || typeof event !== "object" || Array.isArray(event))
3794
5289
  return false;
3795
5290
  const record = event;
3796
- return record.type === "runtime_telemetry" && record.kind === "context_compaction";
5291
+ return record.type === "runtime_telemetry"
5292
+ && (record.kind === "context_compaction"
5293
+ || record.kind === "context_usage"
5294
+ || record.kind === "assistant_message_boundary");
3797
5295
  }
3798
5296
  /**
3799
5297
  * Append a streamed delta to the trailing run of `type`.
@@ -3812,7 +5310,7 @@ function appendTextPart(parts, type, delta) {
3812
5310
  const part = parts[index];
3813
5311
  if (part === undefined)
3814
5312
  break;
3815
- if (!isRenderedPart(part))
5313
+ if (!separatesStreamedText(part))
3816
5314
  continue;
3817
5315
  if (part.type !== type)
3818
5316
  break;
@@ -3922,6 +5420,81 @@ function replaceWholeText(parts, text) {
3922
5420
  parts.splice(index, 1);
3923
5421
  }
3924
5422
  }
5423
+ /**
5424
+ * The ops that turn `prev` into `next`.
5425
+ *
5426
+ * Parts are compared by REFERENCE, which is sound only because every helper
5427
+ * above replaces the slot it touches with a new object instead of editing the
5428
+ * one already there — `store.test.ts` freezes a write path's previous parts to
5429
+ * keep it that way. A truncate leads, so no later op can name an index the
5430
+ * shortened array no longer has; the rest ascend, so a replay that walks them
5431
+ * in order only ever extends the array by one slot at a time.
5432
+ */
5433
+ export function diffParts(prev, next) {
5434
+ const ops = [];
5435
+ if (next.length < prev.length)
5436
+ ops.push({ op: "truncate", length: next.length });
5437
+ for (let index = 0; index < next.length; index += 1) {
5438
+ const after = next[index];
5439
+ if (after === undefined)
5440
+ continue;
5441
+ const before = prev[index];
5442
+ if (before === after)
5443
+ continue;
5444
+ // A streaming answer grows by its tail, which is the whole point of the
5445
+ // exercise: sending the delta rather than the message again is what takes
5446
+ // a long answer's per-frame cost off the wire.
5447
+ if (before !== undefined
5448
+ && (after.type === "text" || after.type === "reasoning")
5449
+ && before.type === after.type
5450
+ && after.text.startsWith(before.text)) {
5451
+ const delta = after.text.slice(before.text.length);
5452
+ if (delta.length > 0)
5453
+ ops.push({ op: "append", index, delta });
5454
+ continue;
5455
+ }
5456
+ ops.push({ op: "set", index, part: after });
5457
+ }
5458
+ return ops;
5459
+ }
5460
+ /**
5461
+ * Replay `ops` onto `parts`, the exact inverse of {@link diffParts}.
5462
+ *
5463
+ * This is the shared definition of what an op MEANS, so the console can apply a
5464
+ * delta without inventing its own reading of one. Anything the ops cannot mean
5465
+ * against these parts throws rather than producing a plausible transcript: a
5466
+ * client that lands here has missed a write and must re-read the message.
5467
+ *
5468
+ * The throws are plain `RangeError`/`TypeError` rather than `WebConsoleError`:
5469
+ * this is a pure function that runs on both sides of the wire, so it has no
5470
+ * request to answer and no HTTP status to pick. Its caller re-reads the message
5471
+ * rather than mapping a code.
5472
+ */
5473
+ export function applyDeltaOps(parts, ops) {
5474
+ let next = [...parts];
5475
+ for (const op of ops) {
5476
+ if (op.op === "truncate") {
5477
+ if (!Number.isInteger(op.length) || op.length < 0 || op.length > next.length) {
5478
+ throw new RangeError(`A message delta truncates to ${String(op.length)} parts, which is out of range for ${String(next.length)}.`);
5479
+ }
5480
+ next = next.slice(0, op.length);
5481
+ continue;
5482
+ }
5483
+ if (!Number.isInteger(op.index) || op.index < 0 || op.index > next.length) {
5484
+ throw new RangeError(`A message delta names part ${String(op.index)}, which is out of range for ${String(next.length)}.`);
5485
+ }
5486
+ if (op.op === "set") {
5487
+ next[op.index] = op.part;
5488
+ continue;
5489
+ }
5490
+ const target = next[op.index];
5491
+ if (target === undefined || (target.type !== "text" && target.type !== "reasoning")) {
5492
+ throw new TypeError(`A message delta appends to part ${String(op.index)}, which cannot be appended to.`);
5493
+ }
5494
+ next[op.index] = { type: target.type, text: `${target.text}${op.delta}` };
5495
+ }
5496
+ return next;
5497
+ }
3925
5498
  function deriveAutomaticTitle(text, attachments) {
3926
5499
  const candidate = text.trim().length > 0 ? text : attachments[0]?.name ?? "New conversation";
3927
5500
  return normalizeTitle(candidate.replace(/\s+/gu, " ").slice(0, 80));
@@ -4230,6 +5803,13 @@ function parseParts(value) {
4230
5803
  if (!Array.isArray(parts) || !parts.every(isWebMessagePart)) {
4231
5804
  throw new WebConsoleError("storage_corrupt", "Persisted message parts have an invalid shape.", 500);
4232
5805
  }
5806
+ if (parts.filter((part) => part.type === "monitor-activity").length > 1) {
5807
+ throw new WebConsoleError("storage_corrupt", "Persisted Monitor activity is duplicated.", 500);
5808
+ }
5809
+ const wakeKeys = parts.flatMap((part) => part.type === "process-job-wake" ? [part.deliveryKey] : []);
5810
+ if (new Set(wakeKeys).size !== wakeKeys.length) {
5811
+ throw new WebConsoleError("storage_corrupt", "Persisted process-job wake activity is duplicated.", 500);
5812
+ }
4233
5813
  quoteFromParts(parts);
4234
5814
  return parts;
4235
5815
  }
@@ -4256,7 +5836,7 @@ function liveInputStatusFromParts(parts) {
4256
5836
  throw new WebConsoleError("storage_corrupt", "Persisted live-input metadata is invalid.", 500);
4257
5837
  }
4258
5838
  const status = data.status;
4259
- if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled") {
5839
+ if (status !== "pending" && status !== "applied" && status !== "queued" && status !== "cancelled" && status !== "uncertain") {
4260
5840
  throw new WebConsoleError("storage_corrupt", "Persisted live-input status is invalid.", 500);
4261
5841
  }
4262
5842
  return status;
@@ -4309,7 +5889,9 @@ function canonicalSessionToolHistoryMetadata(value) {
4309
5889
  if (typeof value !== "object" || value === null || Array.isArray(value))
4310
5890
  return undefined;
4311
5891
  const history = value;
4312
- const valid = (history.persistence === "persisted" || history.persistence === "failed")
5892
+ const valid = (history.persistence === "persisted"
5893
+ || history.persistence === "deferred"
5894
+ || history.persistence === "failed")
4313
5895
  && history.untrusted === true
4314
5896
  && (history.recordId === undefined || boundedHistoryString(history.recordId, 4_096))
4315
5897
  && (history.sequence === undefined || positiveSafeInteger(history.sequence))
@@ -4376,8 +5958,11 @@ function canonicalizePersistedPartHistory(value) {
4376
5958
  if (part.type !== "subagent")
4377
5959
  return value;
4378
5960
  const canonicalPart = canonicalizePersistedObjectHistory(part);
5961
+ const { attribution: _rawAttribution, ...withoutAttribution } = canonicalPart;
5962
+ const attribution = canonicalRunAttribution(part.attribution);
4379
5963
  return {
4380
- ...canonicalPart,
5964
+ ...withoutAttribution,
5965
+ ...(attribution === undefined ? {} : { attribution }),
4381
5966
  ...(Array.isArray(part.calls)
4382
5967
  ? { calls: part.calls.map((call) => canonicalizePersistedHistoryRecord(call)) }
4383
5968
  : {}),
@@ -4435,6 +6020,7 @@ function isWebMessagePart(value) {
4435
6020
  && (part.label === undefined || typeof part.label === "string")
4436
6021
  && (part.executionMs == null || typeof part.executionMs === "number")
4437
6022
  && (part.costUsd === undefined || typeof part.costUsd === "number")
6023
+ && (part.attribution === undefined || canonicalRunAttribution(part.attribution) !== undefined)
4438
6024
  && (part.history === undefined || isSessionToolHistoryMetadata(part.history))
4439
6025
  && isWebToolCallStatus(part.status)
4440
6026
  && Array.isArray(part.calls)
@@ -4453,6 +6039,59 @@ function isWebMessagePart(value) {
4453
6039
  return false;
4454
6040
  }
4455
6041
  }
6042
+ if (part.type === "process-job-wake") {
6043
+ return hasOnlyKeys(part, new Set(["type", "jobId", "deliveryKey", "disposition"]))
6044
+ && validRichId(part.jobId)
6045
+ && typeof part.deliveryKey === "string"
6046
+ && part.deliveryKey.length > 0
6047
+ && part.deliveryKey.length <= 1_024
6048
+ && !/[\u0000-\u001f\u007f]/u.test(part.deliveryKey)
6049
+ && (part.disposition === "steered" || part.disposition === "follow_up");
6050
+ }
6051
+ if (part.type === "monitor-activity") {
6052
+ if (!hasOnlyKeys(part, new Set(["type", "monitors"]))
6053
+ || !Array.isArray(part.monitors)
6054
+ || part.monitors.length === 0
6055
+ || part.monitors.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
6056
+ return false;
6057
+ }
6058
+ const monitorIds = new Set();
6059
+ let deliveryCount = 0;
6060
+ for (const raw of part.monitors) {
6061
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
6062
+ return false;
6063
+ const entry = raw;
6064
+ if (!hasOnlyKeys(entry, new Set(["projection", "deliveryKeys"])) || !Array.isArray(entry.deliveryKeys)) {
6065
+ return false;
6066
+ }
6067
+ let projection;
6068
+ try {
6069
+ projection = parseMonitorProjection(entry.projection);
6070
+ }
6071
+ catch {
6072
+ return false;
6073
+ }
6074
+ if (monitorIds.has(projection.monitorId)
6075
+ || entry.deliveryKeys.length === 0
6076
+ || entry.deliveryKeys.length > AGENT_LIVE_INPUT_MAX_MESSAGES) {
6077
+ return false;
6078
+ }
6079
+ monitorIds.add(projection.monitorId);
6080
+ const keys = new Set();
6081
+ for (const key of entry.deliveryKeys) {
6082
+ if (typeof key !== "string"
6083
+ || key.length === 0
6084
+ || key.length > 1_024
6085
+ || /[\u0000-\u001f\u007f]/u.test(key)
6086
+ || keys.has(key)) {
6087
+ return false;
6088
+ }
6089
+ keys.add(key);
6090
+ }
6091
+ deliveryCount += entry.deliveryKeys.length;
6092
+ }
6093
+ return deliveryCount <= AGENT_LIVE_INPUT_MAX_MESSAGES;
6094
+ }
4456
6095
  if (part.type === "telemetry")
4457
6096
  return typeof part.event === "string";
4458
6097
  if (part.type === "error")
@@ -4575,6 +6214,38 @@ function parseStringArray(value) {
4575
6214
  return undefined;
4576
6215
  }
4577
6216
  }
6217
+ /**
6218
+ * Malformed stored JSON must degrade to "this agent advertises no providers",
6219
+ * never throw: `mapAgent` runs on every bootstrap and discovery read, so a
6220
+ * throw here would take the whole console down over one bad row.
6221
+ */
6222
+ function parseProviderSummary(value) {
6223
+ if (value === null)
6224
+ return undefined;
6225
+ let parsed;
6226
+ try {
6227
+ parsed = JSON.parse(value);
6228
+ }
6229
+ catch {
6230
+ return undefined;
6231
+ }
6232
+ if (!Array.isArray(parsed))
6233
+ return undefined;
6234
+ const result = [];
6235
+ for (const raw of parsed) {
6236
+ if (typeof raw !== "object" || raw === null)
6237
+ continue;
6238
+ const entry = raw;
6239
+ if (typeof entry.id !== "string" || entry.id.length === 0)
6240
+ continue;
6241
+ result.push({
6242
+ id: entry.id,
6243
+ label: typeof entry.label === "string" && entry.label.length > 0 ? entry.label : entry.id,
6244
+ ...(entry.configured === true ? { configured: true } : {}),
6245
+ });
6246
+ }
6247
+ return result.length === 0 ? undefined : result;
6248
+ }
4578
6249
  function parseRecord(value) {
4579
6250
  if (value === null)
4580
6251
  return undefined;
@@ -4604,16 +6275,187 @@ function normalizeRunStatus(value) {
4604
6275
  ? value
4605
6276
  : "complete";
4606
6277
  }
6278
+ const WEB_ROUTE_HISTORY_MAX_ENTRIES = 32;
6279
+ function canonicalRouteString(value, maxCharacters = 256) {
6280
+ if (typeof value !== "string")
6281
+ return undefined;
6282
+ const normalized = value.trim().slice(0, maxCharacters);
6283
+ return normalized.length === 0 ? undefined : normalized;
6284
+ }
6285
+ function canonicalRouteIndex(value) {
6286
+ return Number.isSafeInteger(value) && Number(value) >= 0 ? Number(value) : undefined;
6287
+ }
6288
+ function canonicalRouteTransition(value) {
6289
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6290
+ return undefined;
6291
+ const record = value;
6292
+ const from = canonicalRouteString(record.from);
6293
+ const to = canonicalRouteString(record.to);
6294
+ if (from === undefined || to === undefined)
6295
+ return undefined;
6296
+ const attemptIndex = canonicalRouteIndex(record.attemptIndex);
6297
+ const reason = canonicalRouteString(record.reason, 128);
6298
+ return {
6299
+ from,
6300
+ to,
6301
+ ...(attemptIndex === undefined ? {} : { attemptIndex }),
6302
+ ...(reason === undefined ? {} : { reason }),
6303
+ };
6304
+ }
6305
+ function canonicalRouteRetry(value) {
6306
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6307
+ return undefined;
6308
+ const record = value;
6309
+ const model = canonicalRouteString(record.model);
6310
+ const retryIndex = canonicalRouteIndex(record.retryIndex);
6311
+ const attempts = canonicalRouteIndex(record.attempts);
6312
+ const reason = canonicalRouteString(record.reason, 128);
6313
+ return {
6314
+ ...(model === undefined ? {} : { model }),
6315
+ ...(retryIndex === undefined ? {} : { retryIndex }),
6316
+ ...(attempts === undefined ? {} : { attempts }),
6317
+ ...(reason === undefined ? {} : { reason }),
6318
+ };
6319
+ }
6320
+ function boundedRouteEntries(entries) {
6321
+ if (entries.length <= WEB_ROUTE_HISTORY_MAX_ENTRIES)
6322
+ return { entries, truncated: false };
6323
+ return {
6324
+ entries: [entries[0], ...entries.slice(-(WEB_ROUTE_HISTORY_MAX_ENTRIES - 1))],
6325
+ truncated: true,
6326
+ };
6327
+ }
6328
+ function canonicalRoutingState(value) {
6329
+ const record = typeof value === "object" && value !== null && !Array.isArray(value)
6330
+ ? value
6331
+ : {};
6332
+ const transitions = boundedRouteEntries((Array.isArray(record.transitions) ? record.transitions : [])
6333
+ .map(canonicalRouteTransition)
6334
+ .filter((entry) => entry !== undefined));
6335
+ const retries = boundedRouteEntries((Array.isArray(record.retries) ? record.retries : [])
6336
+ .map(canonicalRouteRetry)
6337
+ .filter((entry) => entry !== undefined));
6338
+ return {
6339
+ transitions: transitions.entries,
6340
+ retries: retries.entries,
6341
+ ...(record.truncated === true || transitions.truncated || retries.truncated ? { truncated: true } : {}),
6342
+ };
6343
+ }
6344
+ function parseRoutingState(value) {
6345
+ try {
6346
+ return canonicalRoutingState(JSON.parse(value));
6347
+ }
6348
+ catch {
6349
+ throw new WebConsoleError("storage_corrupt", "Persisted turn routing metadata is not valid JSON.", 500);
6350
+ }
6351
+ }
6352
+ function serializeRoutingState(value) {
6353
+ return JSON.stringify(canonicalRoutingState(value));
6354
+ }
6355
+ function appendRouteTransition(state, entry) {
6356
+ const bounded = boundedRouteEntries([...state.transitions, entry]);
6357
+ return {
6358
+ ...state,
6359
+ transitions: bounded.entries,
6360
+ ...(state.truncated === true || bounded.truncated ? { truncated: true } : {}),
6361
+ };
6362
+ }
6363
+ function appendRouteRetry(state, entry) {
6364
+ const bounded = boundedRouteEntries([...state.retries, entry]);
6365
+ return {
6366
+ ...state,
6367
+ retries: bounded.entries,
6368
+ ...(state.truncated === true || bounded.truncated ? { truncated: true } : {}),
6369
+ };
6370
+ }
6371
+ function canonicalRunSelection(value) {
6372
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6373
+ return undefined;
6374
+ const record = value;
6375
+ const model = canonicalRouteString(record.model);
6376
+ const effort = canonicalRouteString(record.effort, 64);
6377
+ return {
6378
+ ...(model === undefined ? {} : { model }),
6379
+ ...(effort === undefined ? {} : { effort }),
6380
+ };
6381
+ }
6382
+ function canonicalRunExecution(value) {
6383
+ const selection = canonicalRunSelection(value);
6384
+ if (selection === undefined)
6385
+ return undefined;
6386
+ const effectiveEffort = canonicalRouteString(value.effectiveEffort, 64);
6387
+ return {
6388
+ ...selection,
6389
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
6390
+ };
6391
+ }
6392
+ function canonicalRunAttribution(value) {
6393
+ if (typeof value !== "object" || value === null || Array.isArray(value))
6394
+ return undefined;
6395
+ const record = value;
6396
+ const requested = canonicalRunSelection(record.requested);
6397
+ if (requested === undefined
6398
+ || (record.disposition !== "requested" && record.disposition !== "fallback" && record.disposition !== "unknown")) {
6399
+ return undefined;
6400
+ }
6401
+ const attempted = canonicalRunExecution(record.attempted);
6402
+ const executed = canonicalRunExecution(record.executed);
6403
+ const routing = canonicalRoutingState(record);
6404
+ return {
6405
+ requested,
6406
+ ...(attempted === undefined ? {} : { attempted }),
6407
+ ...(executed === undefined ? {} : { executed }),
6408
+ disposition: record.disposition,
6409
+ transitions: routing.transitions,
6410
+ retries: routing.retries,
6411
+ ...(record.truncated === true || routing.truncated === true ? { truncated: true } : {}),
6412
+ };
6413
+ }
6414
+ function runAttribution(row) {
6415
+ const requestedModel = canonicalRouteString(row.requested_model);
6416
+ const requestedEffort = canonicalRouteString(row.requested_effort, 64);
6417
+ const requested = {
6418
+ ...(requestedModel === undefined ? {} : { model: requestedModel }),
6419
+ ...(requestedEffort === undefined ? {} : { effort: requestedEffort }),
6420
+ };
6421
+ const attemptedModel = canonicalRouteString(row.model);
6422
+ const attemptedEffort = canonicalRouteString(row.effort, 64);
6423
+ const effectiveEffort = canonicalRouteString(row.effective_effort, 64);
6424
+ const attempted = {
6425
+ ...(attemptedModel === undefined ? {} : { model: attemptedModel }),
6426
+ ...(attemptedEffort === undefined ? {} : { effort: attemptedEffort }),
6427
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
6428
+ };
6429
+ const routing = parseRoutingState(row.routing_json);
6430
+ const hasRequested = requested.model !== undefined || requested.effort !== undefined;
6431
+ const hasAttempted = attempted.model !== undefined || attempted.effort !== undefined || attempted.effectiveEffort !== undefined;
6432
+ if (!hasRequested && !hasAttempted && routing.transitions.length === 0 && routing.retries.length === 0)
6433
+ return undefined;
6434
+ const executed = row.status === "complete" && hasAttempted ? attempted : undefined;
6435
+ const fallback = routing.transitions.length > 0
6436
+ || (executed?.model !== undefined && requested.model !== undefined && executed.model !== requested.model);
6437
+ return {
6438
+ requested,
6439
+ ...(hasAttempted ? { attempted } : {}),
6440
+ ...(executed === undefined ? {} : { executed }),
6441
+ disposition: requested.model === undefined ? "unknown" : fallback ? "fallback" : "requested",
6442
+ transitions: routing.transitions,
6443
+ retries: routing.retries,
6444
+ ...(routing.truncated === true ? { truncated: true } : {}),
6445
+ };
6446
+ }
4607
6447
  function runtimeMetadata(metadata) {
4608
6448
  const runtime = metadata?.runtime;
4609
6449
  if (typeof runtime !== "object" || runtime === null || Array.isArray(runtime))
4610
6450
  return undefined;
4611
6451
  const record = runtime;
4612
- const model = typeof record.model === "string" ? record.model : undefined;
4613
- const effort = typeof record.effort === "string" ? record.effort : undefined;
4614
- return model === undefined && effort === undefined ? undefined : {
6452
+ const model = canonicalRouteString(record.model);
6453
+ const effort = canonicalRouteString(record.effort, 64);
6454
+ const effectiveEffort = canonicalRouteString(record.effectiveEffort, 64);
6455
+ return model === undefined && effort === undefined && effectiveEffort === undefined ? undefined : {
4615
6456
  ...(model === undefined ? {} : { model }),
4616
6457
  ...(effort === undefined ? {} : { effort }),
6458
+ ...(effectiveEffort === undefined ? {} : { effectiveEffort }),
4617
6459
  };
4618
6460
  }
4619
6461
  function ignoreMissing(error) {