@mono-agent/web 0.20.14 → 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 (65) hide show
  1. package/README.md +388 -70
  2. package/dist/contracts.d.ts +364 -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 +14 -0
  15. package/dist/effort-ladder.d.ts.map +1 -1
  16. package/dist/effort-ladder.js +41 -0
  17. package/dist/effort-ladder.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  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.map +1 -1
  31. package/dist/notification-client.js +5 -4
  32. package/dist/notification-client.js.map +1 -1
  33. package/dist/operator-client.d.ts +25 -1
  34. package/dist/operator-client.d.ts.map +1 -1
  35. package/dist/operator-client.js +167 -8
  36. package/dist/operator-client.js.map +1 -1
  37. package/dist/server.d.ts +42 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +627 -49
  40. package/dist/server.js.map +1 -1
  41. package/dist/service.d.ts +201 -8
  42. package/dist/service.d.ts.map +1 -1
  43. package/dist/service.js +1425 -136
  44. package/dist/service.js.map +1 -1
  45. package/dist/store-migrations.d.ts +23 -0
  46. package/dist/store-migrations.d.ts.map +1 -0
  47. package/dist/store-migrations.js +212 -0
  48. package/dist/store-migrations.js.map +1 -0
  49. package/dist/store.d.ts +294 -17
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +1637 -263
  52. package/dist/store.js.map +1 -1
  53. package/package.json +8 -5
  54. package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
  55. package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
  56. package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
  57. package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
  58. package/webapp/dist/badge-96.png +0 -0
  59. package/webapp/dist/index.html +26 -6
  60. package/webapp/dist/manifest.webmanifest +1 -1
  61. package/webapp/dist/notification-sw.js +3 -1
  62. package/webapp/dist/sw.js +1 -1
  63. package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
  64. package/webapp/dist/assets/index-C4a2Dv1W.js +0 -155
  65. package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
package/dist/service.js CHANGED
@@ -1,29 +1,298 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
- import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
3
+ import { AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES, AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE, AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
4
4
  import { EFFORT_LEVELS } from "@mono-agent/config";
5
5
  import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
6
6
  import { discoverOperatorAgents, } from "./discovery.js";
7
7
  import { conversationTitleFromFrame } from "./conversation-title.js";
8
- import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel } from "./effort-ladder.js";
8
+ import { parseCronReplyContext } from "./cron-reply-context.js";
9
+ import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel, inheritedEffortForModel, } from "./effort-ladder.js";
9
10
  import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
10
11
  import { OperatorClient } from "./operator-client.js";
11
12
  import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
12
13
  import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
13
- import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
14
+ import { cronChannelReadOnlyError, toWebAttachment, WebStore, WEB_THREAD_PAGE_DEFAULT, notificationPushLogicalKey, } from "./store.js";
14
15
  const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
15
16
  const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
16
17
  const INFO_TIMEOUT_MS = 2_500;
17
18
  const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
18
19
  /** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
19
20
  const MODEL_CATALOG_CACHE_CAP = 2_048;
21
+ /** Matches the browser picker: enough for today's 200-row provider ceiling,
22
+ * with slack for an older or independently implemented operator. */
23
+ const MODEL_CATALOG_RESTORE_PAGE_SIZE = 100;
24
+ const MODEL_CATALOG_RESTORE_PAGE_LIMIT = 5;
20
25
  const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
26
+ /**
27
+ * How coarsely a reply capability's expiry is quantised.
28
+ *
29
+ * The expiry is part of the signed URL, so an expiry read off the wall clock
30
+ * made every projection of the same message a DIFFERENT transcript: the
31
+ * conversation's ETag moved once a second for the life of a running turn and no
32
+ * console could ever be answered with a 304, however little had changed. Rounded
33
+ * DOWN to the bucket the mint falls in, the URL is stable for the bucket and a
34
+ * key's life is between five and ten minutes -- shortened, never extended, so
35
+ * the validator's ceiling is untouched.
36
+ */
37
+ const REPLY_ACCESS_BUCKET_MS = 5 * 60 * 1_000;
21
38
  /**
22
39
  * Raster types the console keeps its own copy of. `image/svg+xml` is absent on
23
40
  * purpose: it is active content, and both the inline gate in the browser and
24
41
  * `setReplyDownloadHeaders` already refuse to treat it as an image.
25
42
  */
26
43
  const REPLY_IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
44
+ /**
45
+ * How much of a tool call's `result`/`args` a browser read carries.
46
+ *
47
+ * Measured on a real fleet transcript: a 4-message tool-heavy conversation is
48
+ * 231 KB, of which 211 KB is `tool-call` result bodies (single `Exec`/`ReadSkill`
49
+ * results run 14-21 KB). The rows are collapsed, so what the operator sees of
50
+ * one is its first screenful; the rest is a request away.
51
+ */
52
+ const TOOL_PAYLOAD_PREVIEW_CHARS = 4_096;
53
+ /**
54
+ * Telemetry whose `data` the console actually reads.
55
+ *
56
+ * `webapp/src/runtime.tsx` renders only `runtime_telemetry` of kind
57
+ * `context_compaction`/`assistant_message_boundary`/`context_usage` plus
58
+ * `cron_run`; `webapp/src/usage.ts` sums any part labelled `context_usage`,
59
+ * `context_compaction`, or containing `usage`/`cost`. Everything else --
60
+ * `provider_status`, `memory_recalled`, `status`, `run_config`, `cache_hit`,
61
+ * `capabilities_resolved`, ... -- is carried across the wire and dropped, which
62
+ * is ~10 KB a conversation.
63
+ */
64
+ const TELEMETRY_DATA_ALLOWLIST = new Set([
65
+ "usage_update",
66
+ "cron_run",
67
+ "context_usage",
68
+ "context_compaction",
69
+ "assistant_message_boundary",
70
+ ]);
71
+ /**
72
+ * Whether a tool name IS AskUser, however the agent qualified it.
73
+ *
74
+ * An MCP server serves it as `mcp__<server>__ask_user`, a forwarding runtime as
75
+ * `some.namespace:AskUser`, and separators vary. Two places have to agree on
76
+ * this -- the frame observer that arms the interaction poller, and the shaper
77
+ * that must leave the card's question and answer alone -- so they read the same
78
+ * rule rather than two spellings of it. An exact `=== "AskUser"` here silently
79
+ * shaped the card's payload for every run that routes the tool through a server.
80
+ */
81
+ function isAskUserToolName(toolName) {
82
+ return toolNameLeaf(toolName).toLowerCase().replace(/[^a-z0-9]+/gu, "") === "askuser";
83
+ }
84
+ /** A `runtime_telemetry` event's variant, which the store stores inside `data`. */
85
+ function telemetryKind(data) {
86
+ if (data === null || typeof data !== "object" || Array.isArray(data))
87
+ return undefined;
88
+ const kind = data.kind;
89
+ return typeof kind === "string" && kind.length > 0 ? kind : undefined;
90
+ }
91
+ /** The allowlist, or the `usage`/`cost` substring rule `usage.ts` applies. */
92
+ function readsTelemetryLabel(label) {
93
+ if (label === undefined)
94
+ return false;
95
+ const normalized = label.toLowerCase();
96
+ return TELEMETRY_DATA_ALLOWLIST.has(normalized)
97
+ || normalized.includes("usage")
98
+ || normalized.includes("cost");
99
+ }
100
+ function keepsTelemetryData(part) {
101
+ // `usage.ts` matches on the event name AND on a nested `kind`, so both are
102
+ // measured against the same rule -- a `runtime_telemetry{kind:"token_usage"}`
103
+ // counts towards the run's tokens exactly like a bare `usage_update`.
104
+ return readsTelemetryLabel(part.event) || readsTelemetryLabel(telemetryKind(part.data));
105
+ }
106
+ /**
107
+ * Keep the part, drop the payload the console never reads.
108
+ *
109
+ * Removing it outright would renumber every later part, and both the client's
110
+ * part conversion and the index-based transcript reads that follow this change
111
+ * depend on positions being stable. `kind` is surfaced whenever the event has
112
+ * one -- kept or stripped -- so its presence is never a back-channel for "the
113
+ * payload was dropped".
114
+ */
115
+ function shapeTelemetryPart(part) {
116
+ const kind = telemetryKind(part.data);
117
+ const keepsData = part.data === undefined || keepsTelemetryData(part);
118
+ if (keepsData && (kind === undefined || part.kind === kind))
119
+ return part;
120
+ return {
121
+ type: "telemetry",
122
+ event: part.event,
123
+ ...(kind === undefined ? {} : { kind }),
124
+ ...(keepsData ? { data: part.data } : {}),
125
+ };
126
+ }
127
+ /**
128
+ * What a truncated payload was cut from, so the console can prove a body it is
129
+ * holding is still that payload.
130
+ *
131
+ * The preview says how long the whole body is and what its first characters
132
+ * are, and a repaired body used to be restored on those two facts alone -- so a
133
+ * rewritten result of the same length with the same head put the OLD body back
134
+ * under the NEW preview. The digest closes that: a console restores only what
135
+ * the server names as the same content, and drops the repair otherwise.
136
+ *
137
+ * Over the SERIALIZED text, which is what both sides already have: a string
138
+ * payload as itself, anything else as its JSON. Nothing here is a secret and
139
+ * nothing is keyed -- it is an identity, not a signature.
140
+ */
141
+ function payloadDigest(text) {
142
+ return createHash("sha256").update(text, "utf8").digest("hex");
143
+ }
144
+ /**
145
+ * The head of an oversized payload, or `undefined` when it is small enough (or
146
+ * cannot be serialized, in which case it is left exactly as stored).
147
+ */
148
+ function payloadPreview(value) {
149
+ if (value === undefined)
150
+ return undefined;
151
+ const text = typeof value === "string" ? value : jsonTextOf(value);
152
+ if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
153
+ return undefined;
154
+ return {
155
+ preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS),
156
+ length: text.length,
157
+ digest: payloadDigest(text),
158
+ };
159
+ }
160
+ /** The same name the shaper gives a payload, for a whole one. See {@link payloadDigest}. */
161
+ function wholePayloadDigest(value) {
162
+ if (value === undefined)
163
+ return undefined;
164
+ const text = typeof value === "string" ? value : jsonTextOf(value);
165
+ return text === undefined ? undefined : payloadDigest(text);
166
+ }
167
+ /** `undefined` for anything JSON cannot express, which is then left as stored. */
168
+ function jsonTextOf(value) {
169
+ try {
170
+ return JSON.stringify(value);
171
+ }
172
+ catch {
173
+ return undefined;
174
+ }
175
+ }
176
+ function shapeToolCall(call) {
177
+ // AskUser's arguments and answer ARE the card the console renders, and they
178
+ // are bounded at the emitter. `structuredResult` is never shaped here: MCP
179
+ // and canonical host outcomes are already bounded and remain opaque.
180
+ if (isAskUserToolName(call.toolName))
181
+ return call;
182
+ const args = shapedArgs(call.args);
183
+ const result = payloadPreview(call.result);
184
+ if (args === undefined && result === undefined)
185
+ return call;
186
+ return {
187
+ ...call,
188
+ ...(args === undefined
189
+ ? {}
190
+ : { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
191
+ ...(result === undefined
192
+ ? {}
193
+ : { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
194
+ };
195
+ }
196
+ function shapeToolCallPart(part) {
197
+ return { ...shapeToolCall(part), type: "tool-call" };
198
+ }
199
+ /**
200
+ * A tool call's arguments, cut to fit -- serialized ONCE.
201
+ *
202
+ * The size question and the two answers to it all read the same JSON text: the
203
+ * object shaper used to serialize to decide whether it had work, and the
204
+ * whole-value fallback then serialized the same value again to answer the same
205
+ * question. Every under-budget object args paid for both, on every transcript
206
+ * read and now on every streamed delta.
207
+ */
208
+ function shapedArgs(args) {
209
+ if (args === undefined)
210
+ return undefined;
211
+ const text = typeof args === "string" ? args : jsonTextOf(args);
212
+ // Under budget, or nothing JSON can express: left exactly as stored.
213
+ if (text === undefined || text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
214
+ return undefined;
215
+ const shaped = shapedArgsObject(args, text)
216
+ ?? { preview: text.slice(0, TOOL_PAYLOAD_PREVIEW_CHARS), length: text.length };
217
+ return { ...shaped, digest: payloadDigest(text) };
218
+ }
219
+ /**
220
+ * An oversized ARGUMENTS OBJECT with its string leaves cut back to fit, rather
221
+ * than replaced by the head of its JSON text.
222
+ *
223
+ * Arguments are not opaque the way a result is: the console reads named keys out
224
+ * of them -- `command`, `file_path`, a delegation's `prompt` -- for the row
225
+ * summary and the Task note, and the head of a JSON text is none of those. It is
226
+ * also what an operator scans, and a mid-object slice reads as garbage. Nothing
227
+ * is added or removed, so the object keeps its shape and every key keeps its
228
+ * place, and the longest string pays first.
229
+ *
230
+ * Termination is the bounded pass count, NOT an assumption that every slice
231
+ * shrinks the serialization: cutting between a surrogate pair leaves a lone
232
+ * surrogate that `JSON.stringify` writes as `\udXXX`, which can make one pass
233
+ * longer than the last. Anything still over budget after eight passes falls
234
+ * back to the whole-value head.
235
+ */
236
+ function shapedArgsObject(args, original) {
237
+ if (args === null || typeof args !== "object" || Array.isArray(args))
238
+ return undefined;
239
+ let shaped = { ...args };
240
+ for (let pass = 0; pass < 8; pass += 1) {
241
+ const text = jsonTextOf(shaped);
242
+ if (text === undefined)
243
+ return undefined;
244
+ if (text.length <= TOOL_PAYLOAD_PREVIEW_CHARS)
245
+ return { preview: shaped, length: original.length };
246
+ const longest = Object.entries(shaped)
247
+ .filter((entry) => typeof entry[1] === "string" && entry[1].length > 1)
248
+ .sort(([, left], [, right]) => right.length - left.length)[0];
249
+ if (longest === undefined)
250
+ return undefined;
251
+ const [key, value] = longest;
252
+ const keep = Math.max(1, value.length - (text.length - TOOL_PAYLOAD_PREVIEW_CHARS));
253
+ shaped = { ...shaped, [key]: value.slice(0, keep) };
254
+ }
255
+ // Too many oversized leaves to fit: fall back to the whole-value head rather
256
+ // than serve an object that is still over budget.
257
+ return undefined;
258
+ }
259
+ function shapeSubagentPart(part) {
260
+ const args = shapedArgs(part.args);
261
+ const result = payloadPreview(part.result);
262
+ return {
263
+ ...part,
264
+ ...(args === undefined
265
+ ? {}
266
+ : { args: args.preview, argsTruncated: true, argsBytes: args.length, argsDigest: args.digest }),
267
+ ...(result === undefined
268
+ ? {}
269
+ : { result: result.preview, resultTruncated: true, resultBytes: result.length, resultDigest: result.digest }),
270
+ calls: part.calls.map(shapeToolCall),
271
+ };
272
+ }
273
+ /**
274
+ * ONE part's own payloads, named the way a preview of them would be.
275
+ *
276
+ * The repair read serves what is stored; without these the console has nothing
277
+ * to compare a later preview against and, failing closed, would drop every
278
+ * repair it made.
279
+ *
280
+ * Only the part handed to it -- a delegation's `calls` are NOT walked. They do
281
+ * not need to be: a repair is addressed by tool-call id, and a request naming a
282
+ * child is answered by synthesizing that child as the tool-call part it would
283
+ * have been, which comes back through here as a part in its own right.
284
+ */
285
+ function nameWholePayloads(part) {
286
+ const args = wholePayloadDigest(part.args);
287
+ const result = wholePayloadDigest(part.result);
288
+ if (args === undefined && result === undefined)
289
+ return part;
290
+ return {
291
+ ...part,
292
+ ...(args === undefined ? {} : { argsDigest: args }),
293
+ ...(result === undefined ? {} : { resultDigest: result }),
294
+ };
295
+ }
27
296
  function formatQuotedTurn(quote, text) {
28
297
  const blockquote = quote
29
298
  .trim()
@@ -32,6 +301,11 @@ function formatQuotedTurn(quote, text) {
32
301
  .join("\n");
33
302
  return `Quoted context:\n${blockquote}\n\n${text}`;
34
303
  }
304
+ function assertTurnTextWithinLimit(operatorText) {
305
+ if (operatorText.length <= WEB_MAX_TURN_TEXT_CHARACTERS)
306
+ return;
307
+ throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
308
+ }
35
309
  function assertMonitorWakeAddress(input) {
36
310
  const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
37
311
  const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
@@ -67,6 +341,7 @@ export class WebService {
67
341
  drainingLiveInputThreads = new Set();
68
342
  activeUploads = new Map();
69
343
  activeNotifications = new Map();
344
+ activeCronReplies = new Map();
70
345
  /** One serialization lane shared by queued user input and every host wake kind. */
71
346
  hostWakeTails = new Map();
72
347
  hostWakeReservations = new Map();
@@ -79,6 +354,13 @@ export class WebService {
79
354
  replyAccessKey;
80
355
  askWatches = new Map();
81
356
  connections = new Map();
357
+ /**
358
+ * Source id -> the capability signature projected for it on the last
359
+ * discovery pass. No projected capability is on the summary or in the store,
360
+ * so this is the only record of them that survives a poll -- and the only way
361
+ * `refreshAgentsOnce` can tell that provider authentication itself moved.
362
+ */
363
+ projectedCapabilities = new Map();
82
364
  /** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
83
365
  * and appended to by every proxied `/v1/models` page. Admission is `has`,
84
366
  * metadata is `get`. Map preserves insertion order, so evicting the oldest
@@ -155,8 +437,23 @@ export class WebService {
155
437
  throw error;
156
438
  }
157
439
  }
158
- async bootstrap() {
440
+ async bootstrap(scope = {}) {
159
441
  const currentThreadId = this.store.currentThreadId();
442
+ const currentThread = currentThreadId === undefined ? undefined : this.store.getThread(currentThreadId);
443
+ const discoveredCurrentThreadId = currentThread !== undefined
444
+ && this.store.getAgent(currentThread.sourceId) !== undefined
445
+ ? currentThreadId
446
+ : undefined;
447
+ const agents = this.store.listAgents().map((agent) => this.decorateProjectedCapabilities(agent));
448
+ const threadsSourceId = this.bootstrapSourceId(scope.sourceId, currentThread, agents);
449
+ const archived = scope.archived ?? false;
450
+ const page = threadsSourceId === null
451
+ ? { threads: [] }
452
+ : this.store.listThreadsPage({
453
+ sourceId: threadsSourceId,
454
+ archived,
455
+ limit: scope.limit ?? WEB_THREAD_PAGE_DEFAULT,
456
+ });
160
457
  return {
161
458
  version: WEB_API_VERSION,
162
459
  push: {
@@ -164,9 +461,11 @@ export class WebService {
164
461
  keyFingerprint: this.pushIdentity.fingerprint,
165
462
  serviceWorkerVersion: WEB_PUSH_SERVICE_WORKER_VERSION,
166
463
  },
167
- agents: this.store.listAgents(),
168
- threads: this.store.listThreads(),
169
- ...(currentThreadId === undefined ? {} : { currentThreadId }),
464
+ agents,
465
+ threads: page.threads,
466
+ threadsSourceId,
467
+ threadsNextCursor: page.nextCursor ?? null,
468
+ ...(discoveredCurrentThreadId === undefined ? {} : { currentThreadId: discoveredCurrentThreadId }),
170
469
  limits: {
171
470
  maxFileBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
172
471
  maxFilesPerTurn: WEB_MAX_FILES_PER_TURN,
@@ -175,16 +474,83 @@ export class WebService {
175
474
  },
176
475
  };
177
476
  }
178
- createThread(sourceId) {
179
- const thread = this.store.createThread(sourceId);
180
- this.emit("threads.changed", thread.id, { thread });
477
+ /**
478
+ * The one bucket a bootstrap answers with.
479
+ *
480
+ * An absent or unknown `sourceId` is the ordinary case, not an error: the
481
+ * first request a fresh console makes has no selection to name, and a
482
+ * console whose stored agent has since gone away must still get a console.
483
+ * The chain is the one the browser resolves its own selection with -- the
484
+ * agent of the conversation the console was last in, else the first agent
485
+ * that can answer, else the first agent at all.
486
+ */
487
+ bootstrapSourceId(requested, currentThread, agents) {
488
+ if (requested !== undefined && agents.some((agent) => agent.sourceId === requested))
489
+ return requested;
490
+ const current = currentThread === undefined
491
+ ? undefined
492
+ : agents.find((agent) => agent.sourceId === currentThread.sourceId);
493
+ return current?.sourceId
494
+ ?? agents.find((agent) => agent.status !== "offline")?.sourceId
495
+ ?? agents[0]?.sourceId
496
+ ?? null;
497
+ }
498
+ /**
499
+ * The only place provider authentication is projected onto an agent summary.
500
+ *
501
+ * It is derived from the live operator connection's `/v1/info` response.
502
+ * `supportsProviderAuth` has an `agents` column, but the stored presentation
503
+ * bit is never authorization to call an agent. Keeping the field off discovery
504
+ * summaries prevents every heartbeat from looking like a fleet change.
505
+ *
506
+ * Provider authentication also requires a live connection deliberately:
507
+ * `requireProviderAuthConnection` refuses without one, so advertising it to a
508
+ * browser that cannot use it would expose a button whose route only 409s.
509
+ *
510
+ * `refreshAgentsOnce` tracks this projection explicitly so real transitions
511
+ * still emit `agents.changed` without treating heartbeats as changes.
512
+ */
513
+ decorateProjectedCapabilities(agent) {
514
+ const connection = this.connections.get(agent.sourceId);
515
+ const providerAuth = connection?.info.supportsProviderAuth === true;
516
+ const providerAuthChecks = connection?.info.supportsProviderAuthChecks === true;
517
+ if (!providerAuth)
518
+ return agent;
519
+ return {
520
+ ...agent,
521
+ supportsProviderAuth: true,
522
+ ...(providerAuthChecks ? { supportsProviderAuthChecks: true } : {}),
523
+ };
524
+ }
525
+ /**
526
+ * Compared pass to pass, this says provider authentication moved when
527
+ * nothing on the stored summary did.
528
+ */
529
+ projectedCapabilitySignature(agent) {
530
+ const projected = this.decorateProjectedCapabilities(agent);
531
+ return [
532
+ projected.supportsProviderAuth === true ? "providerAuth" : "",
533
+ projected.supportsProviderAuthChecks === true ? "providerAuthChecks" : "",
534
+ ].join("|");
535
+ }
536
+ createThread(sourceId, input = {}) {
537
+ const agent = this.store.getAgent(sourceId);
538
+ if (agent === undefined) {
539
+ throw new WebConsoleError("agent_not_found", "The selected agent is no longer available.", 404);
540
+ }
541
+ const inherited = agent.runSettings.override;
542
+ const model = input.model === undefined ? inherited?.model : input.model ?? undefined;
543
+ const effort = input.effort === undefined ? inherited?.effort : input.effort ?? undefined;
544
+ this.validateModelAndEffort(sourceId, agent, model, effort, input.model === undefined && inherited?.model !== undefined);
545
+ const thread = this.store.createThread(sourceId, input);
546
+ this.emitThread("threads.changed", { thread });
181
547
  return thread;
182
548
  }
183
- thread(id) {
549
+ thread(id, options = {}) {
184
550
  const detail = this.store.getThreadDetail(id);
185
551
  if (detail === undefined)
186
552
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
187
- return this.decorateThreadDetail(detail);
553
+ return this.decorateThreadDetail(detail, options);
188
554
  }
189
555
  threadsPage(input) {
190
556
  return this.store.listThreadsPage(input);
@@ -197,8 +563,56 @@ export class WebService {
197
563
  return this.store.searchThreads(input);
198
564
  }
199
565
  messagePage(threadId, input) {
200
- const page = this.store.listMessagesPage(threadId, input);
201
- return { ...page, messages: page.messages.map((message) => this.decorateMessage(message)) };
566
+ // The store pages; the shape is a view concern and has no business reaching it.
567
+ const { full, ...query } = input;
568
+ const page = this.store.listMessagesPage(threadId, query);
569
+ const shape = full === undefined ? {} : { full };
570
+ return { ...page, messages: this.shapeMessages(page.messages, shape) };
571
+ }
572
+ /**
573
+ * ONE message, at the version it currently holds.
574
+ *
575
+ * The recovery a streamed transcript needs: a console whose delta no longer
576
+ * chains onto the `seq` it is holding re-reads the one message rather than
577
+ * the conversation around it. Addressed by (conversation, message) for the
578
+ * same reason the tool-call read is -- a message id is not a capability, and
579
+ * a lookup that took it alone would serve any caller any conversation.
580
+ */
581
+ message(threadId, messageId, options = {}) {
582
+ const thread = this.store.getThread(threadId);
583
+ const message = this.store.getMessage(messageId);
584
+ if (thread === undefined || message === undefined || message.threadId !== thread.id) {
585
+ throw new WebConsoleError("message_not_found", "The message is unavailable.", 404);
586
+ }
587
+ return this.shapeMessage(message, options);
588
+ }
589
+ /**
590
+ * The untruncated payloads of ONE tool call, for a transcript that was served
591
+ * a preview of it.
592
+ *
593
+ * Addressed by (conversation, message, tool call) rather than by tool-call id
594
+ * alone: the id is not a capability, and a lookup that took it on its own
595
+ * would hand any caller any conversation's transcript.
596
+ */
597
+ toolCallPart(threadId, messageId, toolCallId) {
598
+ const thread = this.store.getThread(threadId);
599
+ const message = this.store.getMessage(messageId);
600
+ if (thread === undefined || message === undefined || message.threadId !== thread.id) {
601
+ throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
602
+ }
603
+ const owned = message.parts.find((part) => (part.type === "tool-call" || part.type === "subagent") && part.toolCallId === toolCallId);
604
+ if (owned !== undefined)
605
+ return nameWholePayloads(owned);
606
+ for (const part of message.parts) {
607
+ if (part.type !== "subagent")
608
+ continue;
609
+ const call = part.calls.find((candidate) => candidate.toolCallId === toolCallId);
610
+ // A subagent's child owns no part of its own, so it answers as the
611
+ // tool-call part it would have been outside the delegation.
612
+ if (call !== undefined)
613
+ return nameWholePayloads({ type: "tool-call", ...call });
614
+ }
615
+ throw new WebConsoleError("tool_call_not_found", "The tool call is unavailable.", 404);
202
616
  }
203
617
  /**
204
618
  * Re-mint a browser capability from the authoritative durable message. The
@@ -211,7 +625,7 @@ export class WebService {
211
625
  return this.decorateReplyPart(message, part);
212
626
  }
213
627
  async replyAttachment(threadId, messageId, partId, expires, token, signal) {
214
- const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
628
+ const { thread, part, remainingSeconds } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
215
629
  const connection = this.connections.get(thread.sourceId);
216
630
  if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
217
631
  throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
@@ -227,7 +641,7 @@ export class WebService {
227
641
  ...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
228
642
  };
229
643
  const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
230
- return { part, response };
644
+ return { part, response, remainingSeconds };
231
645
  }
232
646
  async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
233
647
  const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
@@ -366,36 +780,58 @@ export class WebService {
366
780
  const result = this.store.patchThreadIfRunConfigUnset(id, patch);
367
781
  if (!result.applied)
368
782
  return result.thread;
369
- this.emit("thread.changed", result.thread.id, { thread: result.thread });
370
- this.emit("threads.changed", result.thread.id);
783
+ this.emitThread("thread.changed", { thread: result.thread });
784
+ this.emitThread("threads.changed", { thread: result.thread });
371
785
  return result.thread;
372
786
  }
373
787
  const thread = this.store.patchThread(id, patch);
374
- this.emit("thread.changed", thread.id, { thread });
375
- this.emit("threads.changed", thread.id);
788
+ this.emitThread("thread.changed", { thread });
789
+ this.emitThread("threads.changed", { thread });
376
790
  return thread;
377
791
  }
378
- async deleteThread(id) {
792
+ async deleteThread(id, options = {}) {
379
793
  const resolved = this.store.getThread(id)?.id;
380
794
  if (resolved === undefined)
381
795
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
382
796
  if (this.activeTurns.has(resolved)) {
383
797
  throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
384
798
  }
385
- const result = await this.store.deleteArchivedThread(resolved);
799
+ const result = await this.store.deleteArchivedThread(resolved, options);
386
800
  if (result.orphanedFiles > 0) {
387
801
  this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
388
802
  threadId: resolved,
389
803
  count: result.orphanedFiles,
390
804
  });
391
805
  }
392
- this.emit("thread.changed", resolved, { threadId: resolved, removed: true });
393
- this.emit("threads.changed", resolved);
806
+ this.emitThread("thread.changed", { threadId: resolved, removed: true });
807
+ this.emitThread("threads.changed", { threadId: resolved, removed: true });
394
808
  }
395
809
  patchAgent(sourceId, patch) {
396
810
  const agent = this.store.setAgentPinned(sourceId, patch.pinned);
811
+ // A pin says exactly what it changed. The payload-less form means "discovery
812
+ // saw something move", which costs every open console a bootstrap plus its
813
+ // skills and cron -- for one boolean the pinning tab already applied from
814
+ // this call's own response.
815
+ const payload = { sourceId: agent.sourceId, pinned: agent.pinned === true };
816
+ this.emit("agents.changed", undefined, payload);
817
+ return this.decorateProjectedCapabilities(agent);
818
+ }
819
+ setAgentRunDefaults(sourceId, input) {
820
+ const agent = this.store.getAgent(sourceId);
821
+ if (agent === undefined)
822
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
823
+ if (this.connections.get(sourceId) === undefined || agent.status === "offline") {
824
+ throw new WebConsoleError("agent_offline", "This agent is offline. Reconnect it before saving new defaults.", 409);
825
+ }
826
+ this.validateModelAndEffort(sourceId, agent, input.model ?? undefined, input.effort ?? undefined, true);
827
+ const updated = this.store.setAgentRunOverride(sourceId, input);
397
828
  this.emit("agents.changed");
398
- return agent;
829
+ return this.decorateProjectedCapabilities(updated);
830
+ }
831
+ clearAgentRunDefaults(sourceId) {
832
+ const agent = this.store.clearAgentRunOverride(sourceId);
833
+ this.emit("agents.changed");
834
+ return this.decorateProjectedCapabilities(agent);
399
835
  }
400
836
  agentSkills(sourceId) {
401
837
  const agent = this.store.getAgent(sourceId);
@@ -440,7 +876,7 @@ export class WebService {
440
876
  const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
441
877
  return stored.messages === undefined
442
878
  ? stored
443
- : { ...stored, messages: stored.messages.map((message) => this.decorateMessage(message)) };
879
+ : { ...stored, messages: stored.messages.map((message) => this.shapeMessage(message)) };
444
880
  }
445
881
  const page = await connection.client.cronRuns(jobId, {
446
882
  limit: input.limit,
@@ -449,25 +885,138 @@ export class WebService {
449
885
  });
450
886
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
451
887
  if (reconciled.changed) {
452
- const threadId = this.store.cronThread(sourceId, jobId)?.id;
453
- this.emit("thread.changed", threadId);
454
- this.emit("threads.changed", threadId);
888
+ this.announceReconciledMessages(reconciled);
889
+ this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
455
890
  }
456
- return { ...page, messages: reconciled.messages.map((message) => this.decorateMessage(message)) };
891
+ return { ...page, messages: reconciled.messages.map((message) => this.shapeMessage(message)) };
457
892
  }
458
893
  async cronRun(sourceId, jobId, runId) {
459
894
  const connection = this.requireCronConnection(sourceId, false);
460
895
  const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
461
896
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
462
897
  const message = reconciled.messages[0];
898
+ if (reconciled.changed) {
899
+ this.announceReconciledMessages(reconciled);
900
+ this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
901
+ }
463
902
  if (message === undefined) {
903
+ if (reconciled.suppressedRunIds?.includes(runId)) {
904
+ throw new WebConsoleError("cron_run_not_visible", "This cron run has no visible message.", 404);
905
+ }
464
906
  throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
465
907
  }
466
- if (reconciled.changed) {
467
- this.emit("thread.changed", message.threadId, { messageId: message.id });
468
- this.emit("threads.changed", message.threadId);
908
+ return this.shapeMessage(message);
909
+ }
910
+ createCronReplyThread(sourceId, jobId, runId, input) {
911
+ const running = this.activeCronReplies.get(input.operationId);
912
+ if (running !== undefined) {
913
+ if (running.sourceId !== sourceId || running.jobId !== jobId || running.runId !== runId) {
914
+ return Promise.reject(new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409));
915
+ }
916
+ return running.promise;
917
+ }
918
+ const operation = this.createCronReplyThreadOnce(sourceId, jobId, runId, input)
919
+ .then((receipt) => ({ ...receipt, messages: this.shapeMessages(receipt.messages) }));
920
+ this.activeCronReplies.set(input.operationId, { sourceId, jobId, runId, promise: operation });
921
+ const release = () => {
922
+ if (this.activeCronReplies.get(input.operationId)?.promise === operation) {
923
+ this.activeCronReplies.delete(input.operationId);
924
+ }
925
+ };
926
+ void operation.then(release, release);
927
+ return operation;
928
+ }
929
+ async createCronReplyThreadOnce(sourceId, jobId, runId, input) {
930
+ let state = this.store.cronReplyOperation(input.operationId);
931
+ if (state !== undefined) {
932
+ this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
933
+ const terminal = this.cronReplyTerminalResult(state);
934
+ if (terminal !== undefined)
935
+ return terminal;
936
+ }
937
+ const candidate = state === undefined
938
+ ? this.store.captureCronReplySnapshot(sourceId, jobId, runId, input.snapshotKind)
939
+ : undefined;
940
+ const connection = this.connections.get(sourceId);
941
+ if (connection === undefined) {
942
+ throw new WebConsoleError("cron_reply_agent_offline", "This agent is offline; Reply was not started.", 503);
943
+ }
944
+ if (connection.info.contextImport?.version !== 1
945
+ || connection.info.contextImport.maxTextBytes < AGENT_CONTEXT_IMPORT_MAX_TEXT_BYTES) {
946
+ throw new WebConsoleError("cron_reply_unsupported", "This agent does not support canonical cron-result Reply.", 503);
947
+ }
948
+ if (state === undefined) {
949
+ state = this.store.reserveCronReplyOperation(input.operationId, candidate);
950
+ this.assertCronReplyStateIdentity(state, sourceId, jobId, runId);
951
+ const terminal = this.cronReplyTerminalResult(state);
952
+ if (terminal !== undefined)
953
+ return terminal;
954
+ if (state.kind === "pending" && state.operation.operationId !== input.operationId) {
955
+ throw new WebConsoleError("cron_reply_pending", "A Reply for this cron result is already pending. Retry it explicitly.", 409, { operationId: state.operation.operationId });
956
+ }
957
+ }
958
+ if (state.kind !== "reserved" && state.kind !== "pending") {
959
+ throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation cannot continue.", 409);
960
+ }
961
+ const reservation = state.operation;
962
+ if (reservation.snapshotText === undefined) {
963
+ throw new WebConsoleError("storage_corrupt", "Pending cron reply lost its immutable snapshot.", 500);
964
+ }
965
+ let canonicalStatus;
966
+ try {
967
+ canonicalStatus = await connection.client.recordContextImport(reservation.conversationId, reservation.snapshotText, reservation.idempotencyKey);
968
+ }
969
+ catch (error) {
970
+ if (error instanceof WebConsoleError
971
+ && (error.code === "context_import_conflict"
972
+ || error.code === "context_import_failed"
973
+ || error.code === "context_import_unsupported")) {
974
+ const failed = this.store.failCronReplyOperation(input.operationId, typeof error.details?.reason === "string" ? error.details.reason : error.code);
975
+ const wonRace = this.cronReplyTerminalResult(failed);
976
+ if (wonRace !== undefined)
977
+ return wonRace;
978
+ throw new WebConsoleError(error.code === "context_import_conflict"
979
+ ? "cron_reply_conflict"
980
+ : error.code === "context_import_unsupported"
981
+ ? "cron_reply_unsupported"
982
+ : "cron_reply_failed", error.message, error.code === "context_import_conflict" ? 409 : error.code === "context_import_unsupported" ? 503 : 502);
983
+ }
984
+ throw new WebConsoleError("cron_reply_outcome_unknown", "Cron Reply may have reached the agent. Retry explicitly to resolve it.", 504, { operationId: input.operationId });
985
+ }
986
+ const completed = this.store.completeCronReplyOperation(input.operationId, canonicalStatus);
987
+ const receipt = this.cronReplyTerminalResult(completed);
988
+ if (receipt === undefined) {
989
+ throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation did not complete.", 409);
990
+ }
991
+ if (!receipt.duplicate) {
992
+ for (const message of receipt.messages) {
993
+ if (this.isCronReplyProvenanceMessage(message))
994
+ continue;
995
+ this.emit("message.changed", receipt.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
996
+ }
997
+ this.emitThread("threads.changed", { thread: receipt.thread });
998
+ this.emitThread("thread.changed", { thread: receipt.thread });
469
999
  }
470
- return this.decorateMessage(message);
1000
+ return receipt;
1001
+ }
1002
+ assertCronReplyStateIdentity(state, sourceId, jobId, runId) {
1003
+ const identity = state.kind === "completed" ? state.receipt : state.operation;
1004
+ if (identity.sourceId !== sourceId || identity.jobId !== jobId || identity.runId !== runId) {
1005
+ throw new WebConsoleError("cron_reply_operation_conflict", "Cron reply operation id was used for another run.", 409);
1006
+ }
1007
+ }
1008
+ cronReplyTerminalResult(state) {
1009
+ if (state.kind === "completed")
1010
+ return state.receipt;
1011
+ if (state.kind === "tombstoned") {
1012
+ throw new WebConsoleError("cron_reply_gone", "This imported conversation was deleted.", 410);
1013
+ }
1014
+ if (state.kind === "failed") {
1015
+ throw new WebConsoleError("cron_reply_failed", "This cron Reply failed definitively; start a new Reply to try again.", 409, {
1016
+ ...(state.operation.failureReason === undefined ? {} : { reason: state.operation.failureReason }),
1017
+ });
1018
+ }
1019
+ return undefined;
471
1020
  }
472
1021
  async agentModels(sourceId, input) {
473
1022
  const agent = this.store.getAgent(sourceId);
@@ -496,20 +1045,33 @@ export class WebService {
496
1045
  // the reply -- fetched from a process that is gone -- was written straight
497
1046
  // into the freshly reconciled map, where `source: "page"` overwrites
498
1047
  // unconditionally. Generation 1's ladder then judged generation 2's turns.
499
- this.admitCatalogRefs(sourceId, generation, page.models.flatMap((model) => {
500
- const record = { source: "page", efforts: advertisedEffortLevels(model) };
501
- // The wire carries provider-local ids while every selection surface
502
- // speaks the canonical `<provider>:<model>` reference. Admit both, or a
503
- // turn is judged against metadata the page did advertise but under a
504
- // name nothing ever asks for.
505
- const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
506
- const entries = [[model.id, record]];
507
- if (reference !== model.id)
508
- entries.push([reference, record]);
509
- return entries;
510
- }));
1048
+ this.admitModelPage(sourceId, generation, page);
511
1049
  return page;
512
1050
  }
1051
+ async providerAuthStatus(sourceId) {
1052
+ return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthStatus(AbortSignal.timeout(INFO_TIMEOUT_MS)));
1053
+ }
1054
+ async startProviderAuth(sourceId, input) {
1055
+ return await this.providerAuthCall(sourceId, async (connection) => await connection.client.startProviderAuth(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1056
+ }
1057
+ async providerAuthSession(sourceId, sessionId) {
1058
+ return await this.providerAuthCall(sourceId, async (connection) => await connection.client.providerAuthSession(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1059
+ }
1060
+ async submitProviderAuth(sourceId, sessionId, input) {
1061
+ return await this.providerAuthCall(sourceId, async (connection) => await connection.client.submitProviderAuth(sessionId, input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1062
+ }
1063
+ async cancelProviderAuth(sourceId, sessionId) {
1064
+ await this.providerAuthCall(sourceId, async (connection) => await connection.client.cancelProviderAuth(sessionId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1065
+ }
1066
+ async startProviderAuthCheck(sourceId, input) {
1067
+ return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.startProviderAuthCheck(input, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1068
+ }
1069
+ async providerAuthCheck(sourceId, checkId) {
1070
+ return await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.providerAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1071
+ }
1072
+ async cancelProviderAuthCheck(sourceId, checkId) {
1073
+ await this.providerAuthCheckCall(sourceId, async (connection) => await connection.client.cancelProviderAuthCheck(checkId, AbortSignal.timeout(INFO_TIMEOUT_MS)));
1074
+ }
513
1075
  async cronConfigView(sourceId) {
514
1076
  const connection = this.requireCronConnection(sourceId, false);
515
1077
  return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
@@ -520,9 +1082,8 @@ export class WebService {
520
1082
  if (result.kind === "completed") {
521
1083
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
522
1084
  if (reconciled.changed) {
523
- const threadId = this.store.cronThread(sourceId, jobId)?.id;
524
- this.emit("thread.changed", threadId);
525
- this.emit("threads.changed", threadId);
1085
+ this.announceReconciledMessages(reconciled);
1086
+ this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
526
1087
  }
527
1088
  }
528
1089
  return result;
@@ -540,8 +1101,7 @@ export class WebService {
540
1101
  throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
541
1102
  if (synced.changed) {
542
1103
  this.emit("cron.changed", job.threadId, { sourceId, jobId });
543
- this.emit("thread.changed", job.threadId, { thread: this.store.getThread(job.threadId) });
544
- this.emit("threads.changed", job.threadId);
1104
+ this.emitStoredThread(job.threadId, ["thread.changed", "threads.changed"]);
545
1105
  }
546
1106
  return { ...result, value: { job } };
547
1107
  }
@@ -549,8 +1109,13 @@ export class WebService {
549
1109
  if (this.stopped) {
550
1110
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
551
1111
  }
552
- if (this.store.getAgent(input.sourceId) === undefined)
1112
+ // Monitor wakes are addressed to a retained thread, so they must reach the
1113
+ // wake-specific retry/abandon path even after discovery has removed the
1114
+ // source from the picker. New source-scoped deliveries still refresh before
1115
+ // the store decides whether the agent exists.
1116
+ if (input.triggerKind !== "monitor" && this.store.getAgent(input.sourceId) === undefined) {
553
1117
  await this.refreshAgents();
1118
+ }
554
1119
  if (this.stopped) {
555
1120
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
556
1121
  }
@@ -575,7 +1140,9 @@ export class WebService {
575
1140
  return { thread, duplicate: result.duplicate, delivery: result.receipt };
576
1141
  }
577
1142
  if (input.triggerKind === "job") {
578
- const completed = this.store.upsertProcessJobCard({
1143
+ // Destructured off: the card's message id is how this service addresses
1144
+ // the invalidation, and it is not part of the delivery result on the wire.
1145
+ const { messageId, ...completed } = this.store.upsertProcessJobCard({
579
1146
  sourceId: input.sourceId,
580
1147
  threadId: input.threadId,
581
1148
  deliveryKey: input.deliveryKey,
@@ -583,12 +1150,17 @@ export class WebService {
583
1150
  ...(input.text === undefined ? {} : { responseText: input.text }),
584
1151
  ...(input.parts === undefined ? {} : { replyParts: input.parts }),
585
1152
  });
586
- const message = this.store.getThreadDetail(input.threadId)?.messages.find((candidate) => candidate.parts.some((part) => part.type === "process-job" && part.job.jobId === input.processJob.jobId));
1153
+ // Addressed, not searched. Scanning a page of the conversation meant a job
1154
+ // that finished behind thirty later messages emitted no invalidation at
1155
+ // all, and its card sat at "running" until something else forced a read.
1156
+ const message = this.store.getMessage(messageId);
587
1157
  if (!completed.duplicate && message !== undefined) {
588
1158
  this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
589
1159
  }
590
- this.emit("threads.changed", input.threadId, { thread: completed.thread });
591
- this.emit("thread.changed", input.threadId, { thread: completed.thread });
1160
+ // Read back rather than trusted: the shared notification result leaves the
1161
+ // conversation optional, and an event that carries `undefined` is exactly
1162
+ // the bare event this normalisation exists to remove.
1163
+ this.emitStoredThread(input.threadId, ["threads.changed", "thread.changed"]);
592
1164
  if (input.wakePrompt === undefined)
593
1165
  return completed;
594
1166
  if (this.connections.get(input.sourceId) === undefined)
@@ -635,35 +1207,148 @@ export class WebService {
635
1207
  async startTurn(threadId, input) {
636
1208
  const text = input.text ?? "";
637
1209
  const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
638
- if (operatorText.length > WEB_MAX_TURN_TEXT_CHARACTERS) {
639
- throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
640
- }
1210
+ assertTurnTextWithinLimit(operatorText);
641
1211
  const attachmentIds = input.attachmentIds ?? [];
642
- const thread = this.store.getThread(threadId);
643
- if (thread === undefined)
644
- throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
1212
+ const selection = this.resolveTurnSelection(threadId, input.model, input.effort);
1213
+ const { thread, agent, model, effort, requestedModel, requestedEffort } = selection;
645
1214
  threadId = thread.id;
646
- const agent = this.store.getAgent(thread.sourceId);
647
1215
  const connection = this.connections.get(thread.sourceId);
648
1216
  if (thread.trigger?.kind === "cron")
649
1217
  throw cronChannelReadOnlyError();
650
- if (agent === undefined || connection === undefined || !thread.canSend) {
1218
+ if (connection === undefined || !thread.canSend) {
651
1219
  throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
652
1220
  }
653
- // The per-thread override is server state now, so it governs turns this
654
- // server starts too -- process-job follow-ups and other assistant-owned
655
- // wakes omit model/effort and would otherwise silently run on the agent
656
- // default, ignoring the selection made in that very conversation. An
657
- // explicit request value still wins.
658
- const model = input.model ?? thread.runModel ?? undefined;
659
- const effort = input.effort ?? thread.runEffort ?? undefined;
660
- this.validateModelAndEffort(thread.sourceId, agent, model, effort);
661
- const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(model === undefined ? {} : { model }), ...(effort === undefined ? {} : { effort }) });
1221
+ const started = this.store.beginTurn({
1222
+ threadId,
1223
+ text,
1224
+ attachmentIds,
1225
+ ...(input.quote === undefined ? {} : { quote: input.quote }),
1226
+ ...(model === undefined ? {} : { model }),
1227
+ ...(effort === undefined ? {} : { effort }),
1228
+ ...(requestedModel === undefined ? {} : { requestedModel }),
1229
+ ...(requestedEffort === undefined ? {} : { requestedEffort }),
1230
+ });
662
1231
  this.launchTurn(started, connection.client, operatorText);
1232
+ // The operator's own row, inserted by `beginTurn` and announced by nothing
1233
+ // else. A console that did not issue this turn holds neither it nor the
1234
+ // assistant row the deltas are about to describe, and it no longer answers
1235
+ // a conversation summary by re-reading the transcript.
1236
+ this.emit("message.changed", threadId, {
1237
+ messageId: started.userMessageId,
1238
+ updatedAt: started.thread.updatedAt,
1239
+ });
663
1240
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
664
- this.emit("threads.changed", threadId);
1241
+ this.emitThread("threads.changed", { thread: started.thread });
665
1242
  return { thread: started.thread, turn: started.thread.runState };
666
1243
  }
1244
+ submit(threadId, input) {
1245
+ if (this.stopped)
1246
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
1247
+ const thread = this.store.getThread(threadId);
1248
+ if (thread === undefined)
1249
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
1250
+ threadId = thread.id;
1251
+ const text = input.text ?? "";
1252
+ const attachmentIds = input.attachmentIds ?? [];
1253
+ const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
1254
+ const payloadSha256 = createHash("sha256").update(JSON.stringify({
1255
+ text,
1256
+ quote: input.quote ?? null,
1257
+ attachmentIds,
1258
+ model: input.model ?? null,
1259
+ effort: input.effort ?? null,
1260
+ })).digest("hex");
1261
+ const existing = this.store.webSubmission(threadId, input.submissionId);
1262
+ if (existing !== undefined) {
1263
+ if (existing.payloadSha256 !== payloadSha256) {
1264
+ throw new WebConsoleError("submission_conflict", "Submission id was already used for different content.", 409);
1265
+ }
1266
+ return this.submissionReceipt(existing);
1267
+ }
1268
+ assertTurnTextWithinLimit(operatorText);
1269
+ if (thread.trigger?.kind === "cron")
1270
+ throw cronChannelReadOnlyError();
1271
+ const connection = this.connections.get(thread.sourceId);
1272
+ if (connection === undefined || !thread.canSend) {
1273
+ throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
1274
+ }
1275
+ let started;
1276
+ let reserved;
1277
+ const activeTurnId = this.store.activeTurn(threadId)?.id;
1278
+ const activeTarget = activeTurnId === undefined ? undefined : this.activeTurns.get(threadId);
1279
+ const ownsActiveTarget = activeTarget?.turnId === activeTurnId;
1280
+ const claimed = this.store.claimWebSubmission({
1281
+ threadId,
1282
+ submissionId: input.submissionId,
1283
+ payloadSha256,
1284
+ create: () => {
1285
+ if (activeTurnId !== undefined && attachmentIds.length > 0) {
1286
+ return { outcome: "rejected", reason: "active_attachments_unsupported" };
1287
+ }
1288
+ if (activeTurnId !== undefined) {
1289
+ reserved = this.store.reserveLiveInput(threadId, text, input.quote, operatorText);
1290
+ if (!connection.info.supportsLiveInputTargeting || !ownsActiveTarget) {
1291
+ const reason = connection.info.supportsLiveInputTargeting
1292
+ ? "closed_before_dispatch"
1293
+ : "unsupported_targeting";
1294
+ this.store.queueLiveInput(reserved.input.id, reason);
1295
+ return {
1296
+ outcome: "live-input",
1297
+ reason,
1298
+ messageId: reserved.message.id,
1299
+ inputId: reserved.input.id,
1300
+ turnId: activeTurnId,
1301
+ };
1302
+ }
1303
+ return {
1304
+ outcome: "live-input",
1305
+ messageId: reserved.message.id,
1306
+ inputId: reserved.input.id,
1307
+ turnId: activeTurnId,
1308
+ };
1309
+ }
1310
+ const { model, effort, requestedModel, requestedEffort } = this.resolveTurnSelection(threadId, input.model, input.effort);
1311
+ started = this.store.beginTurn({
1312
+ threadId,
1313
+ text,
1314
+ attachmentIds,
1315
+ ...(input.quote === undefined ? {} : { quote: input.quote }),
1316
+ ...(model === undefined ? {} : { model }),
1317
+ ...(effort === undefined ? {} : { effort }),
1318
+ ...(requestedModel === undefined ? {} : { requestedModel }),
1319
+ ...(requestedEffort === undefined ? {} : { requestedEffort }),
1320
+ });
1321
+ return {
1322
+ outcome: "turn",
1323
+ messageId: started.userMessageId,
1324
+ turnId: started.turnId,
1325
+ };
1326
+ },
1327
+ });
1328
+ if (claimed.created && started !== undefined) {
1329
+ this.launchTurn(started, connection.client, operatorText);
1330
+ this.emit("message.changed", threadId, { messageId: started.userMessageId, updatedAt: started.thread.updatedAt });
1331
+ this.emit("turn.changed", threadId, { turn: started.thread.runState });
1332
+ this.emitThread("threads.changed", { thread: started.thread });
1333
+ }
1334
+ else if (claimed.created && reserved !== undefined) {
1335
+ this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
1336
+ this.emitThread("threads.changed", { thread: reserved.thread });
1337
+ if (claimed.submission.reason !== undefined || activeTarget === undefined) {
1338
+ void this.drainQueuedLiveInputs(threadId);
1339
+ }
1340
+ else {
1341
+ void this.dispatchTargetedSubmission(claimed.submission, activeTarget);
1342
+ }
1343
+ }
1344
+ return this.submissionReceipt(claimed.submission);
1345
+ }
1346
+ submission(threadId, submissionId) {
1347
+ const stored = this.store.webSubmission(threadId, submissionId);
1348
+ if (stored === undefined)
1349
+ throw new WebConsoleError("submission_not_found", "Submission not found.", 404);
1350
+ return this.submissionReceipt(stored);
1351
+ }
667
1352
  submitLiveInput(threadId, text) {
668
1353
  if (this.stopped) {
669
1354
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
@@ -676,13 +1361,21 @@ export class WebService {
676
1361
  const active = this.activeTurns.get(threadId);
677
1362
  const reserved = this.store.reserveLiveInput(threadId, text);
678
1363
  this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
679
- this.emit("threads.changed", threadId);
1364
+ this.emitThread("threads.changed", { thread: reserved.thread });
680
1365
  if (!reserved.offered || active === undefined || connection === undefined || !connection.info.supportsLiveInput) {
681
1366
  const queued = reserved.offered ? this.store.queueLiveInput(reserved.input.id) ?? reserved.message : reserved.message;
682
1367
  this.emit("message.changed", threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
683
1368
  void this.drainQueuedLiveInputs(threadId);
684
1369
  return { message: queued, disposition: "queued" };
685
1370
  }
1371
+ if (!this.store.markLiveInputDispatchStarted(reserved.input.id, active.turnId)) {
1372
+ // A concurrent terminal transition won before the request boundary. Do
1373
+ // not dispatch and do not recreate a fallback from stale in-memory state.
1374
+ return {
1375
+ message: this.store.getMessage(reserved.message.id) ?? reserved.message,
1376
+ disposition: "pending",
1377
+ };
1378
+ }
686
1379
  const controller = new AbortController();
687
1380
  const completion = this.deliverLiveInput(reserved.input.id, threadId, active.client, controller, {
688
1381
  conversationId: `web:${threadId}`,
@@ -805,6 +1498,17 @@ export class WebService {
805
1498
  readyEvent() {
806
1499
  return this.createEvent("ready", undefined, { version: WEB_API_VERSION });
807
1500
  }
1501
+ /**
1502
+ * The id a conversation is known by NOW, following whatever superseded it.
1503
+ *
1504
+ * Every read resolves redirects on its way through the store; the event
1505
+ * stream matches its subscription by string equality against the id events
1506
+ * carry, so it has to ask for the same resolution explicitly. An id nothing
1507
+ * superseded resolves to itself, so this is safe for any string.
1508
+ */
1509
+ resolveThreadId(id) {
1510
+ return this.store.resolveThreadId(id);
1511
+ }
808
1512
  refreshAgents() {
809
1513
  if (this.stopped)
810
1514
  return Promise.resolve();
@@ -847,7 +1551,10 @@ export class WebService {
847
1551
  turn.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
848
1552
  }
849
1553
  for (const [id, input] of activeLiveInputs) {
850
- this.store.queueLiveInput(id);
1554
+ // Dispatch has already crossed the durable marker boundary. Seal it as
1555
+ // uncertain before abort so a crash at any later shutdown cut-point can
1556
+ // never recover it into the automatic next-turn queue.
1557
+ this.store.markLiveInputUncertain(id);
851
1558
  input.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
852
1559
  }
853
1560
  await Promise.allSettled(active.map((turn) => turn.completion));
@@ -864,10 +1571,9 @@ export class WebService {
864
1571
  this.store.close();
865
1572
  await this.lease.release();
866
1573
  }
867
- async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
1574
+ async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, onAdmitted) {
868
1575
  const coalescer = new StreamFrameCoalescer(async (frames) => {
869
- const message = this.store.applyStreamFrames(started.turnId, frames);
870
- this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
1576
+ this.emitMessageWrite(started.thread.id, this.store.applyStreamFrames(started.turnId, frames));
871
1577
  }, (error) => controller.abort(error));
872
1578
  let releaseAttachmentBudget;
873
1579
  try {
@@ -900,17 +1606,21 @@ export class WebService {
900
1606
  this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
901
1607
  coalescer.push(frame);
902
1608
  },
1609
+ ...(onAdmitted === undefined ? {} : { onAdmitted }),
903
1610
  });
904
1611
  await coalescer.flush();
905
1612
  const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
906
1613
  && (response.finalText === undefined || response.finalText.length === 0)
907
1614
  && (response.parts === undefined || response.parts.length === 0);
908
- const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, { suppressResponsePush: silentMonitorWake });
1615
+ const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, {
1616
+ suppressResponsePush: silentMonitorWake,
1617
+ ...(hostWakeDeliveryKey === undefined ? {} : { monitorWakeDeliveryKey: hostWakeDeliveryKey }),
1618
+ });
1619
+ this.emitMessageWrite(started.thread.id, detail.write);
909
1620
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
910
- this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
911
- this.emit("threads.changed", started.thread.id);
912
- if (!silentMonitorWake)
913
- this.announcePushEvent(`turn:${started.turnId}:terminal`);
1621
+ this.emitThread("thread.changed", { thread: detail.thread });
1622
+ this.emitThread("threads.changed", { thread: detail.thread });
1623
+ this.announcePushEvent(`turn:${started.turnId}:terminal`);
914
1624
  // Detached: the turn is already finished and reported, and keeping a copy
915
1625
  // must neither delay nor fail it. The agent is still connected here, which
916
1626
  // is when a fetch is most likely to succeed.
@@ -933,9 +1643,10 @@ export class WebService {
933
1643
  ...(code === undefined ? {} : { code }),
934
1644
  cancelled,
935
1645
  });
1646
+ this.emitMessageWrite(started.thread.id, detail.write);
936
1647
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
937
- this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
938
- this.emit("threads.changed", started.thread.id);
1648
+ this.emitThread("thread.changed", { thread: detail.thread });
1649
+ this.emitThread("threads.changed", { thread: detail.thread });
939
1650
  this.announcePushEvent(`turn:${started.turnId}:terminal`);
940
1651
  }
941
1652
  finally {
@@ -946,7 +1657,12 @@ export class WebService {
946
1657
  launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
947
1658
  const threadId = started.thread.id;
948
1659
  const controller = new AbortController();
949
- const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey).finally(() => {
1660
+ let resolveAdmitted;
1661
+ const admitted = new Promise((resolve) => { resolveAdmitted = resolve; });
1662
+ const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, () => { resolveAdmitted(true); }).finally(() => {
1663
+ // Inert once admission already resolved; the turn settled without the
1664
+ // operator ever returning a stream when it did not.
1665
+ resolveAdmitted(false);
950
1666
  const active = this.activeTurns.get(threadId);
951
1667
  if (active?.turnId === started.turnId)
952
1668
  this.activeTurns.delete(threadId);
@@ -954,8 +1670,64 @@ export class WebService {
954
1670
  void this.drainQueuedLiveInputs(threadId);
955
1671
  }
956
1672
  });
957
- this.activeTurns.set(threadId, { turnId: started.turnId, controller, client, completion });
958
- return completion;
1673
+ this.activeTurns.set(threadId, {
1674
+ turnId: started.turnId,
1675
+ controller,
1676
+ client,
1677
+ completion,
1678
+ admitted,
1679
+ resolveAdmitted,
1680
+ });
1681
+ return { completion, admitted };
1682
+ }
1683
+ submissionReceipt(submission) {
1684
+ const message = submission.messageId === undefined ? undefined : this.store.getMessage(submission.messageId);
1685
+ const thread = this.store.getThread(submission.threadId);
1686
+ return {
1687
+ submissionId: submission.submissionId,
1688
+ threadId: submission.threadId,
1689
+ outcome: submission.outcome,
1690
+ ...(submission.reason === undefined ? {} : { reason: submission.reason }),
1691
+ ...(submission.messageId === undefined ? {} : { messageId: submission.messageId }),
1692
+ ...(submission.turnId === undefined ? {} : { turnId: submission.turnId }),
1693
+ ...(message === undefined ? {} : { message }),
1694
+ ...(submission.turnId === undefined || thread?.runState.id !== submission.turnId
1695
+ ? {}
1696
+ : { turn: thread.runState }),
1697
+ ...(submission.outcome !== "live-input"
1698
+ ? {}
1699
+ : message?.liveInputStatus === "queued"
1700
+ ? { disposition: "queued" }
1701
+ : message?.liveInputStatus === "pending"
1702
+ ? { disposition: "pending" }
1703
+ : {}),
1704
+ };
1705
+ }
1706
+ async dispatchTargetedSubmission(submission, active) {
1707
+ if (submission.inputId === undefined || submission.messageId === undefined || submission.turnId === undefined)
1708
+ return;
1709
+ await active.admitted;
1710
+ if (this.activeTurns.get(submission.threadId) !== active
1711
+ || this.store.activeTurn(submission.threadId)?.id !== submission.turnId) {
1712
+ const queued = this.store.queueLiveInput(submission.inputId, "closed_before_dispatch");
1713
+ if (queued !== undefined) {
1714
+ this.emit("message.changed", submission.threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
1715
+ void this.drainQueuedLiveInputs(submission.threadId);
1716
+ }
1717
+ return;
1718
+ }
1719
+ const input = this.store.storedLiveInput(submission.inputId);
1720
+ if (input === undefined || !this.store.markLiveInputDispatchStarted(submission.inputId, submission.turnId))
1721
+ return;
1722
+ const controller = new AbortController();
1723
+ const completion = this.deliverLiveInput(submission.inputId, submission.threadId, active.client, controller, {
1724
+ conversationId: `web:${submission.threadId}`,
1725
+ id: submission.inputId,
1726
+ text: input.text,
1727
+ receivedAt: input.createdAt,
1728
+ targetTurnId: submission.turnId,
1729
+ }).finally(() => this.activeLiveInputs.delete(submission.inputId));
1730
+ this.activeLiveInputs.set(submission.inputId, { threadId: submission.threadId, controller, completion });
959
1731
  }
960
1732
  async deliverLiveInput(id, threadId, client, controller, input) {
961
1733
  let queued = false;
@@ -968,16 +1740,22 @@ export class WebService {
968
1740
  else if (result.status === "discarded") {
969
1741
  changedMessage = this.store.cancelLiveInput(id);
970
1742
  }
971
- else {
972
- changedMessage = this.store.queueLiveInput(id);
1743
+ else if (result.status === "requeue") {
1744
+ changedMessage = this.store.queueLiveInput(id, `mailbox_${result.reason}`);
1745
+ queued = changedMessage !== undefined;
1746
+ }
1747
+ else if (result.status === "unavailable") {
1748
+ changedMessage = this.store.queueLiveInput(id, `operator_${result.reason}`);
973
1749
  queued = changedMessage !== undefined;
974
1750
  }
1751
+ else {
1752
+ changedMessage = this.store.markLiveInputUncertain(id);
1753
+ }
975
1754
  }
976
1755
  catch (error) {
977
- changedMessage = this.store.queueLiveInput(id);
978
- queued = changedMessage !== undefined;
1756
+ changedMessage = this.store.markLiveInputUncertain(id);
979
1757
  if (!controller.signal.aborted) {
980
- this.options.logger?.debug?.("Web live-input delivery failed; queued as a turn.", {
1758
+ this.options.logger?.debug?.("Web live-input delivery outcome is uncertain; automatic fallback is suppressed.", {
981
1759
  threadId,
982
1760
  error: errorMessage(error),
983
1761
  });
@@ -989,7 +1767,7 @@ export class WebService {
989
1767
  updatedAt: changedMessage.updatedAt,
990
1768
  });
991
1769
  }
992
- this.emit("threads.changed", threadId);
1770
+ this.emitStoredThread(threadId, ["threads.changed"]);
993
1771
  if (queued && !this.stopped)
994
1772
  await this.drainQueuedLiveInputs(threadId);
995
1773
  }
@@ -1011,12 +1789,20 @@ export class WebService {
1011
1789
  if (started === undefined)
1012
1790
  return;
1013
1791
  this.launchTurn(started, connection.client, started.text);
1792
+ // BOTH rows. `promoteNextQueuedLiveInput` rewrites the queued operator
1793
+ // message (its live-input status becomes "applied") as well as opening
1794
+ // the assistant row, and a console that heard only about the second was
1795
+ // left showing a steer that still reads "queued".
1796
+ this.emit("message.changed", threadId, {
1797
+ messageId: started.userMessageId,
1798
+ updatedAt: started.thread.updatedAt,
1799
+ });
1014
1800
  this.emit("message.changed", threadId, {
1015
1801
  messageId: started.assistantMessageId,
1016
1802
  updatedAt: started.thread.updatedAt,
1017
1803
  });
1018
1804
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
1019
- this.emit("threads.changed", threadId);
1805
+ this.emitThread("threads.changed", { thread: started.thread });
1020
1806
  }
1021
1807
  finally {
1022
1808
  this.drainingLiveInputThreads.delete(threadId);
@@ -1059,6 +1845,7 @@ export class WebService {
1059
1845
  const active = this.activeTurns.get(input.threadId);
1060
1846
  if (active !== undefined && connection.info.supportsLiveInput) {
1061
1847
  try {
1848
+ this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId);
1062
1849
  const settlement = await active.client.liveInput({
1063
1850
  conversationId: `web:${input.threadId}`,
1064
1851
  id: input.deliveryKey,
@@ -1068,16 +1855,33 @@ export class WebService {
1068
1855
  signal: AbortSignal.timeout(10 * 60 * 1_000),
1069
1856
  });
1070
1857
  if (settlement.status === "applied") {
1071
- this.store.completeProcessJobWake({
1858
+ const message = this.store.completeProcessJobWake({
1072
1859
  sourceId: input.sourceId,
1073
1860
  jobId: input.processJob.jobId,
1074
1861
  deliveryKey: input.deliveryKey,
1075
1862
  disposition: "steered",
1863
+ turnId: active.turnId,
1076
1864
  });
1865
+ if (message !== undefined) {
1866
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
1867
+ }
1077
1868
  return { delivered: true, disposition: "steered" };
1078
1869
  }
1870
+ if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
1871
+ this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
1872
+ return {
1873
+ delivered: false,
1874
+ code: "process_job_wake_ambiguous",
1875
+ retryable: false,
1876
+ ambiguous: true,
1877
+ };
1878
+ }
1879
+ this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId, false);
1079
1880
  }
1080
1881
  catch (error) {
1882
+ // Receipt uncertainty forbids replay, but is not authority to silence
1883
+ // the ordinary answer from the active turn indefinitely.
1884
+ this.store.releaseProcessJobWakeTurn(input.deliveryKey, active.turnId);
1081
1885
  this.options.logger?.warn?.("Web process-job steering outcome is unknown; automatic fallback is suppressed.", {
1082
1886
  threadId: input.threadId,
1083
1887
  error: errorMessage(error),
@@ -1090,8 +1894,41 @@ export class WebService {
1090
1894
  };
1091
1895
  }
1092
1896
  }
1093
- if (active !== undefined)
1094
- await active.completion;
1897
+ if (active !== undefined) {
1898
+ try {
1899
+ await active.completion;
1900
+ }
1901
+ catch {
1902
+ // No live-input request was sent, or the active run explicitly said
1903
+ // requeue/unavailable before this wait. This wake therefore has not
1904
+ // crossed an operator boundary and is safe to release only when the
1905
+ // store proves that it deleted the exact accepted reservation.
1906
+ let abandoned = false;
1907
+ try {
1908
+ abandoned = this.store.abandonProcessJobWake({
1909
+ sourceId: input.sourceId,
1910
+ jobId: input.processJob.jobId,
1911
+ deliveryKey: input.deliveryKey,
1912
+ });
1913
+ }
1914
+ catch {
1915
+ // The accepted claim may remain. Preserve ambiguity and no-replay.
1916
+ }
1917
+ if (!abandoned) {
1918
+ return {
1919
+ delivered: false,
1920
+ code: "process_job_wake_ambiguous",
1921
+ retryable: false,
1922
+ ambiguous: true,
1923
+ };
1924
+ }
1925
+ return {
1926
+ delivered: false,
1927
+ code: "process_job_wake_failed",
1928
+ retryable: false,
1929
+ };
1930
+ }
1931
+ }
1095
1932
  if (this.stopped) {
1096
1933
  this.store.abandonProcessJobWake({
1097
1934
  sourceId: input.sourceId,
@@ -1111,9 +1948,19 @@ export class WebService {
1111
1948
  }
1112
1949
  let started;
1113
1950
  try {
1951
+ const selection = this.resolveTurnSelection(input.threadId);
1114
1952
  started = this.store.beginAssistantTurn({
1115
1953
  threadId: input.threadId,
1116
1954
  prompt: input.wakePrompt,
1955
+ processJobWake: {
1956
+ jobId: input.processJob.jobId,
1957
+ deliveryKey: input.deliveryKey,
1958
+ disposition: "follow_up",
1959
+ },
1960
+ ...(selection.model === undefined ? {} : { model: selection.model }),
1961
+ ...(selection.effort === undefined ? {} : { effort: selection.effort }),
1962
+ ...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
1963
+ ...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
1117
1964
  });
1118
1965
  }
1119
1966
  catch (error) {
@@ -1128,29 +1975,59 @@ export class WebService {
1128
1975
  retryable: false,
1129
1976
  };
1130
1977
  }
1131
- const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
1978
+ const { completion, admitted } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
1979
+ // Receipt ownership moves to the durable turn below. The turn remains
1980
+ // owned by `activeTurns`, but its completion is no longer part of the
1981
+ // notification request and an unexpected terminal-store failure must not
1982
+ // become an unhandled rejection after the receipt has returned. Attached
1983
+ // before the admission wait below, so a rejection in that window cannot
1984
+ // escape either.
1985
+ void completion.catch((error) => {
1986
+ try {
1987
+ this.options.logger?.error?.("Web process-job follow-up turn settlement failed after admission.", {
1988
+ threadId: input.threadId,
1989
+ turnId: started.turnId,
1990
+ errorCode: errorCode(error) ?? "unknown",
1991
+ });
1992
+ }
1993
+ catch {
1994
+ // A caller-provided logger cannot be allowed to re-detach the failure.
1995
+ }
1996
+ });
1997
+ // Announced before the wait: this row exists either way, and a turn that
1998
+ // never reaches the operator still has to be visible as the failure it is.
1132
1999
  this.emit("message.changed", input.threadId, {
1133
2000
  messageId: started.assistantMessageId,
1134
2001
  updatedAt: started.thread.updatedAt,
1135
2002
  });
1136
2003
  this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
1137
- this.emit("threads.changed", input.threadId);
1138
- await completion;
1139
- if (this.store.turnStatus(started.turnId) !== "complete") {
2004
+ this.emitThread("threads.changed", { thread: started.thread });
2005
+ // The wake's host-owned capability — chain depth and remaining background
2006
+ // starts — is bound to this exact request by the agent when it accepts the
2007
+ // turn. Receipting earlier ends the wake route on the agent side and
2008
+ // strips that capability from the very turn the wake raised, so the
2009
+ // follow-up can no longer start the next job. This waits for admission
2010
+ // only; the model turn itself stays detached above.
2011
+ if (!await admitted) {
2012
+ // The request may still have reached the agent, so the accepted claim
2013
+ // stays put: no abandon, no replay, and any retry fails closed.
1140
2014
  return {
1141
2015
  delivered: false,
1142
- code: "process_job_wake_failed",
2016
+ code: "process_job_wake_ambiguous",
1143
2017
  retryable: false,
1144
2018
  ambiguous: true,
1145
2019
  };
1146
2020
  }
1147
- this.store.completeProcessJobWake({
2021
+ const message = this.store.completeProcessJobWake({
1148
2022
  sourceId: input.sourceId,
1149
2023
  jobId: input.processJob.jobId,
1150
2024
  deliveryKey: input.deliveryKey,
1151
2025
  disposition: "follow_up",
1152
2026
  turnId: started.turnId,
1153
2027
  });
2028
+ if (message !== undefined) {
2029
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
2030
+ }
1154
2031
  return { delivered: true, disposition: "follow_up" };
1155
2032
  });
1156
2033
  const tail = delivery.then(() => undefined, () => undefined);
@@ -1240,6 +2117,7 @@ export class WebService {
1240
2117
  && connection.info.supportsLiveInput
1241
2118
  && input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
1242
2119
  try {
2120
+ this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, true);
1243
2121
  const settlement = await active.client.liveInput({
1244
2122
  conversationId: `web:${input.threadId}`,
1245
2123
  id: input.deliveryKey,
@@ -1261,6 +2139,15 @@ export class WebService {
1261
2139
  }
1262
2140
  return { delivered: true, disposition: "steered" };
1263
2141
  }
2142
+ if (settlement.status !== "requeue" && settlement.status !== "unavailable") {
2143
+ return {
2144
+ delivered: false,
2145
+ code: "monitor_wake_ambiguous",
2146
+ retryable: false,
2147
+ ambiguous: true,
2148
+ };
2149
+ }
2150
+ this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, false);
1264
2151
  }
1265
2152
  catch (error) {
1266
2153
  this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
@@ -1289,10 +2176,15 @@ export class WebService {
1289
2176
  }
1290
2177
  let started;
1291
2178
  try {
2179
+ const selection = this.resolveTurnSelection(input.threadId);
1292
2180
  started = this.store.beginAssistantTurn({
1293
2181
  threadId: input.threadId,
1294
2182
  prompt: input.wakePrompt,
1295
2183
  storedPrompt: "[Monitor wake]",
2184
+ ...(selection.model === undefined ? {} : { model: selection.model }),
2185
+ ...(selection.effort === undefined ? {} : { effort: selection.effort }),
2186
+ ...(selection.requestedModel === undefined ? {} : { requestedModel: selection.requestedModel }),
2187
+ ...(selection.requestedEffort === undefined ? {} : { requestedEffort: selection.requestedEffort }),
1296
2188
  });
1297
2189
  }
1298
2190
  catch (error) {
@@ -1303,13 +2195,13 @@ export class WebService {
1303
2195
  retryable: false,
1304
2196
  };
1305
2197
  }
1306
- const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
2198
+ const { completion } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
1307
2199
  this.emit("message.changed", input.threadId, {
1308
2200
  messageId: started.assistantMessageId,
1309
2201
  updatedAt: started.thread.updatedAt,
1310
2202
  });
1311
2203
  this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
1312
- this.emit("threads.changed", input.threadId);
2204
+ this.emitThread("threads.changed", { thread: started.thread });
1313
2205
  await completion;
1314
2206
  if (this.store.turnStatus(started.turnId) !== "complete") {
1315
2207
  return {
@@ -1376,8 +2268,19 @@ export class WebService {
1376
2268
  return completed;
1377
2269
  throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
1378
2270
  }
1379
- this.emit("threads.changed", completed.thread.id, { thread: completed.thread });
1380
- this.emit("thread.changed", completed.thread.id, { thread: completed.thread });
2271
+ // Before the summaries, so a console applies the transcript move it can act
2272
+ // on rather than being told only that the conversation's revision moved.
2273
+ if (!completed.duplicate && completed.messageId !== undefined) {
2274
+ const written = this.store.getMessage(completed.messageId);
2275
+ if (written !== undefined) {
2276
+ this.emit("message.changed", completed.thread.id, {
2277
+ messageId: written.id,
2278
+ updatedAt: written.updatedAt,
2279
+ });
2280
+ }
2281
+ }
2282
+ this.emitThread("threads.changed", { thread: completed.thread });
2283
+ this.emitThread("thread.changed", { thread: completed.thread });
1381
2284
  if (!completed.duplicate) {
1382
2285
  this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
1383
2286
  }
@@ -1408,8 +2311,16 @@ export class WebService {
1408
2311
  }
1409
2312
  catch (error) {
1410
2313
  this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
1411
- const changed = this.store.replaceAgents([]);
2314
+ const changed = this.store.markDiscoveredAgentsOffline();
1412
2315
  this.connections = new Map();
2316
+ // The live connection that backs the projection is gone, so provider
2317
+ // authentication is unavailable now.
2318
+ // Clearing it here is what makes the recovery a transition worth
2319
+ // announcing rather than a no-op against a stale map. It is belt and
2320
+ // braces today: this projected capability implies a live connection, which
2321
+ // implies a row that was not offline, so `markDiscoveredAgentsOffline`
2322
+ // returns true and the failure is announced anyway.
2323
+ this.projectedCapabilities = new Map();
1413
2324
  if (changed)
1414
2325
  this.emit("agents.changed");
1415
2326
  return;
@@ -1431,12 +2342,14 @@ export class WebService {
1431
2342
  baseUrl: agent.baseUrl,
1432
2343
  ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
1433
2344
  ...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
2345
+ ...(agent.monitorsBearer === undefined ? {} : { monitorsBearer: agent.monitorsBearer }),
1434
2346
  ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
1435
2347
  });
1436
2348
  try {
1437
2349
  const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1438
- nextConnections.set(agent.source.sourceId, { client, info });
2350
+ nextConnections.set(agent.source.sourceId, { client, info, generation });
1439
2351
  this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
2352
+ await this.restorePersistedModelAdmission(client, agent.source.sourceId, generation, info.providers, signal);
1440
2353
  const efforts = collectEfforts(info);
1441
2354
  return {
1442
2355
  sourceId: agent.source.sourceId,
@@ -1451,6 +2364,7 @@ export class WebService {
1451
2364
  ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
1452
2365
  ...(efforts.length === 0 ? {} : { efforts }),
1453
2366
  ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
2367
+ runSettings: configRunSettings(info.model, info.effort),
1454
2368
  ...(info.providers === undefined ? {} : { providers: info.providers }),
1455
2369
  ...(info.cron === undefined ? {} : { cron: info.cron }),
1456
2370
  ...(info.supportsAskById ? { supportsAskById: true } : {}),
@@ -1467,6 +2381,16 @@ export class WebService {
1467
2381
  }));
1468
2382
  this.connections = nextConnections;
1469
2383
  const agentsChanged = this.store.replaceAgents(summaries);
2384
+ // Usable provider authentication comes from the live connection, so when it
2385
+ // turns on or off nothing on the discovery summary moves and `replaceAgents`
2386
+ // is right to say so. An operator can start or stop advertising
2387
+ // `capabilities.providerAuth` across a restart; an open console would
2388
+ // otherwise keep the action hidden until an unrelated change, or keep
2389
+ // offering one whose route now 409s. Read here after assigning the live
2390
+ // connections and before the cron refresh awaits on the network.
2391
+ const projected = new Map(summaries.map((summary) => [summary.sourceId, this.projectedCapabilitySignature(summary)]));
2392
+ const capabilityChanged = projected.size !== this.projectedCapabilities.size
2393
+ || [...projected].some(([sourceId, signature]) => this.projectedCapabilities.get(sourceId) !== signature);
1470
2394
  const cronChangedSources = new Set();
1471
2395
  await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
1472
2396
  if (connection.info.cron?.read !== true)
@@ -1484,7 +2408,13 @@ export class WebService {
1484
2408
  });
1485
2409
  }
1486
2410
  }));
1487
- if (agentsChanged)
2411
+ // Adopted only once the announcement is about to go out. Assigning it
2412
+ // before the cron refresh would let a throw in that window consume the
2413
+ // transition: the next pass would compare against a map that already
2414
+ // carried the new signatures, find nothing changed, and the flip would be
2415
+ // lost rather than deferred.
2416
+ this.projectedCapabilities = projected;
2417
+ if (agentsChanged || capabilityChanged)
1488
2418
  this.emit("agents.changed");
1489
2419
  for (const sourceId of cronChangedSources)
1490
2420
  this.emit("cron.changed", undefined, { sourceId });
@@ -1494,6 +2424,34 @@ export class WebService {
1494
2424
  void this.drainQueuedLiveInputs(threadId);
1495
2425
  }
1496
2426
  }
2427
+ requireProviderAuthConnection(sourceId) {
2428
+ const agent = this.store.getAgent(sourceId);
2429
+ if (agent === undefined)
2430
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
2431
+ const connection = this.connections.get(sourceId);
2432
+ if (connection === undefined)
2433
+ throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
2434
+ if (connection.info.supportsProviderAuth !== true) {
2435
+ throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider authentication.", 409);
2436
+ }
2437
+ return connection;
2438
+ }
2439
+ async providerAuthCall(sourceId, operation) {
2440
+ const connection = this.requireProviderAuthConnection(sourceId);
2441
+ const result = await operation(connection);
2442
+ if (this.connections.get(sourceId)?.generation !== connection.generation) {
2443
+ throw new WebConsoleError("agent_generation_changed", "The agent restarted during provider authentication. Refresh status and retry.", 409);
2444
+ }
2445
+ return result;
2446
+ }
2447
+ async providerAuthCheckCall(sourceId, operation) {
2448
+ return await this.providerAuthCall(sourceId, async (connection) => {
2449
+ if (connection.info.supportsProviderAuthChecks !== true) {
2450
+ throw new WebConsoleError("provider_auth_unavailable", "This agent does not expose provider checks.", 409);
2451
+ }
2452
+ return await operation(connection);
2453
+ });
2454
+ }
1497
2455
  startTimers() {
1498
2456
  const discoveryInterval = this.options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
1499
2457
  const purgeInterval = this.options.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
@@ -1532,6 +2490,117 @@ export class WebService {
1532
2490
  this.options.logger?.info?.("Purged orphaned web uploads.", { count, partialCount, unreferencedCount });
1533
2491
  }
1534
2492
  }
2493
+ /**
2494
+ * A listing event that names a conversation AND describes it.
2495
+ *
2496
+ * The id comes off the payload, so the two can never disagree: an event that
2497
+ * named one conversation while carrying another would have every console
2498
+ * apply the wrong row.
2499
+ */
2500
+ emitThread(type, payload) {
2501
+ this.emit(type, "thread" in payload ? payload.thread.id : payload.threadId, payload);
2502
+ }
2503
+ /**
2504
+ * Name every row a cron reconciliation actually wrote.
2505
+ *
2506
+ * A reconciliation moves a transcript and has no delta to describe it, so
2507
+ * each written row reaches a console as the invalidation it answers with one
2508
+ * message read. Rows the poll left alone are not announced: an unchanged page
2509
+ * must cost a console nothing.
2510
+ */
2511
+ announceReconciledMessages(reconciled) {
2512
+ const written = new Set(reconciled.writtenMessageIds);
2513
+ for (const message of reconciled.messages) {
2514
+ if (!written.has(message.id))
2515
+ continue;
2516
+ this.emit("message.changed", message.threadId, {
2517
+ messageId: message.id,
2518
+ updatedAt: message.updatedAt,
2519
+ });
2520
+ }
2521
+ }
2522
+ /**
2523
+ * The same as {@link emitThread}, for a write whose caller kept no snapshot of
2524
+ * what it produced -- a cron reconcile, a live-input hand-off -- so the store
2525
+ * is the only place the fresh summary can come from.
2526
+ *
2527
+ * A conversation that is no longer there leaves nothing to describe, and the
2528
+ * bulk form is the only honest scope left for a listing that did change.
2529
+ */
2530
+ emitStoredThread(threadId, types) {
2531
+ const thread = threadId === undefined ? undefined : this.store.getThread(threadId);
2532
+ if (thread === undefined) {
2533
+ this.emit("threads.changed");
2534
+ return;
2535
+ }
2536
+ for (const type of types)
2537
+ this.emitThread(type, { thread });
2538
+ }
2539
+ /**
2540
+ * Put one persisted assistant-message write on the wire as content.
2541
+ *
2542
+ * A streaming answer is rewritten every {@link STREAM_FLUSH_INTERVAL_MS}
2543
+ * milliseconds. Announcing each one as an invalidation made every connected
2544
+ * console re-read the whole conversation to find the few characters that had
2545
+ * arrived, so these two paths -- the stream coalescer and the write that
2546
+ * settles a turn -- say what changed instead.
2547
+ *
2548
+ * Three rules keep that honest:
2549
+ * - A write that did not happen says nothing. `applyStreamFrames` and the
2550
+ * finish both answer a settled turn with no delta at all, and a version
2551
+ * nobody wrote is not a version to announce.
2552
+ * - An empty `ops` list is still announced. A status-only finish moves the
2553
+ * sequence number without changing a part, and a console that never heard
2554
+ * about it would reject the next delta as a gap.
2555
+ * - The parts inside a `set` are shaped exactly as a read would serve them,
2556
+ * and a delta that would cost more than re-reading the message declines to
2557
+ * the invalidation hint every other writer emits. A splice-driven finish
2558
+ * re-sets every part it shifted, which is bigger than the message itself.
2559
+ * That hint carries no sequence number, so the NEXT delta will not chain
2560
+ * onto the last one a console applied -- which is exactly the mismatch that
2561
+ * sends it to the message read.
2562
+ */
2563
+ emitMessageWrite(threadId, write) {
2564
+ if (write?.attributionChanged === true) {
2565
+ const thread = this.store.getThread(threadId);
2566
+ if (thread !== undefined)
2567
+ this.emit("turn.changed", threadId, { turn: thread.runState });
2568
+ }
2569
+ if (write === undefined || write.delta === undefined)
2570
+ return;
2571
+ const { message, delta } = write;
2572
+ // An invariant, not a case: all three callers pass the assistant row of a
2573
+ // turn. `mapMessage` filters quote and live-input telemetry off user rows,
2574
+ // so a delta diffed against one would describe parts no reader ever holds,
2575
+ // and quietly downgrading here would hide the day that stops being true.
2576
+ if (message.role !== "assistant") {
2577
+ throw new TypeError(`A ${message.role} message reached the delta path.`);
2578
+ }
2579
+ const shaped = {
2580
+ ...delta,
2581
+ // Set only once a turn, by the write that ends it. Without it a
2582
+ // subscribed console still bought one whole-conversation read per turn
2583
+ // finish, purely to draw the Activity header's window.
2584
+ ...(message.finishedAt === undefined ? {} : { finishedAt: message.finishedAt }),
2585
+ ops: delta.ops.map((op) => (op.op === "set" ? { ...op, part: this.shapePart(message, op.part, {}) } : op)),
2586
+ };
2587
+ // Only a write that REWRITES parts can outweigh the message it describes: an
2588
+ // `append` carries strictly less than the part it grew, and the message
2589
+ // carries that whole part plus its own envelope. Worth the check, because
2590
+ // the comparison shapes the entire message -- reply capabilities re-minted
2591
+ // and all -- and the streaming path runs this every 50 ms.
2592
+ if (shaped.ops.some((op) => op.op !== "append")
2593
+ && JSON.stringify(shaped.ops).length > JSON.stringify(this.shapeMessage(message)).length) {
2594
+ const declined = {
2595
+ messageId: message.id,
2596
+ updatedAt: message.updatedAt,
2597
+ deltaDeclined: true,
2598
+ };
2599
+ this.emit("message.changed", threadId, declined);
2600
+ return;
2601
+ }
2602
+ this.emit("message.delta", threadId, shaped);
2603
+ }
1535
2604
  emit(type, threadId, payload) {
1536
2605
  if (this.stopped)
1537
2606
  return;
@@ -1559,7 +2628,7 @@ export class WebService {
1559
2628
  }
1560
2629
  observeAskUserFrame(threadId, turnId, frame) {
1561
2630
  if (this.stopped || frame.kind !== "event" || frame.event.type !== "tool_call_started"
1562
- || toolNameLeaf(frame.event.name).toLowerCase().replace(/[^a-z0-9]+/gu, "") !== "askuser")
2631
+ || !isAskUserToolName(frame.event.name))
1563
2632
  return;
1564
2633
  const key = `${threadId}\0${turnId}`;
1565
2634
  if (this.askWatches.has(key))
@@ -1582,8 +2651,8 @@ export class WebService {
1582
2651
  const thread = this.store.applyAgentTitle(threadId, title);
1583
2652
  if (thread === undefined)
1584
2653
  return;
1585
- this.emit("thread.changed", threadId, { revision: thread.revision });
1586
- this.emit("threads.changed", threadId, { thread });
2654
+ this.emitThread("thread.changed", { thread });
2655
+ this.emitThread("threads.changed", { thread });
1587
2656
  }
1588
2657
  catch (error) {
1589
2658
  this.options.logger?.warn?.("Agent conversation-title update failed; the turn is continuing.", {
@@ -1641,7 +2710,10 @@ export class WebService {
1641
2710
  sourceId: thread.sourceId,
1642
2711
  title: `${agent?.label ?? "mono-agent"} needs input`,
1643
2712
  body: `${question.header}: ${question.question}`,
1644
- expiresAt: snapshot.expiresAt,
2713
+ // Push delivery remains bounded even when the underlying AskUser wait is
2714
+ // unbounded. Expiring this notification does not expire the interaction.
2715
+ expiresAt: snapshot.expiresAt
2716
+ ?? new Date(this.currentDate().getTime() + ASK_PUSH_DELIVERY_TTL_MS).toISOString(),
1645
2717
  });
1646
2718
  if (event !== undefined)
1647
2719
  this.announcePushEvent(event.logicalKey);
@@ -1663,7 +2735,8 @@ export class WebService {
1663
2735
  : await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
1664
2736
  return snapshot?.interactionId === interactionId
1665
2737
  && snapshot.status === "pending"
1666
- && new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime()
2738
+ && (snapshot.expiresAt === null
2739
+ || new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime())
1667
2740
  ? "current"
1668
2741
  : "stale";
1669
2742
  }
@@ -1741,6 +2814,105 @@ export class WebService {
1741
2814
  // only records admission; recording a ladder here would shadow it.
1742
2815
  this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
1743
2816
  }
2817
+ /**
2818
+ * A catalog-only web default outlives this process, while the admission cache
2819
+ * deliberately does not. Revalidate that one persisted ref against the live
2820
+ * generation during discovery so first-use thread creation never depends on
2821
+ * a browser having opened the model picker. An exact match is mandatory: a
2822
+ * retired ref remains unadmitted even if the search returns close names.
2823
+ */
2824
+ async restorePersistedModelAdmission(client, sourceId, generation, providers, signal) {
2825
+ const model = this.store.getAgent(sourceId)?.runSettings.override?.model;
2826
+ if (model === undefined || this.modelCatalogCache.get(sourceId)?.models.has(model) === true)
2827
+ return;
2828
+ const separator = model.indexOf(":");
2829
+ // The runtime wire makes only the first colon structural: provider ids may
2830
+ // not contain one, while opaque model ids commonly do. Keeping that exact
2831
+ // split also prevents a malformed colon-bearing provider from colliding
2832
+ // with a legitimate provider plus colon-bearing model id.
2833
+ const target = separator > 0 && separator < model.length - 1
2834
+ ? {
2835
+ kind: "canonical",
2836
+ provider: model.slice(0, separator),
2837
+ modelId: model.slice(separator + 1),
2838
+ }
2839
+ : { kind: "bare", modelId: model };
2840
+ const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]);
2841
+ try {
2842
+ if (target.kind === "canonical") {
2843
+ await this.restorePersistedModelFromProvider(client, sourceId, generation, target.provider, target, boundedSignal);
2844
+ return;
2845
+ }
2846
+ // Bare ids have no provider address. Keep the cheap global search, but
2847
+ // accept only an exact id; if its 100-row fuzzy cap hides the target,
2848
+ // traverse the bounded provider window advertised in `/v1/info`.
2849
+ const search = await client.models({
2850
+ q: target.modelId,
2851
+ limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
2852
+ signal: boundedSignal,
2853
+ });
2854
+ if (this.admitModelPage(sourceId, generation, search, target))
2855
+ return;
2856
+ for (const provider of new Set((providers ?? []).map((entry) => entry.id))) {
2857
+ if (await this.restorePersistedModelFromProvider(client, sourceId, generation, provider, target, boundedSignal))
2858
+ return;
2859
+ }
2860
+ }
2861
+ catch (error) {
2862
+ this.options.logger?.debug?.("Persisted web model default could not be revalidated.", {
2863
+ sourceId,
2864
+ error: errorMessage(error),
2865
+ });
2866
+ }
2867
+ }
2868
+ async restorePersistedModelFromProvider(client, sourceId, generation, provider, target, signal) {
2869
+ let cursor;
2870
+ const seenCursors = new Set();
2871
+ for (let pageIndex = 0; pageIndex < MODEL_CATALOG_RESTORE_PAGE_LIMIT; pageIndex += 1) {
2872
+ if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
2873
+ return false;
2874
+ const page = await client.models({
2875
+ provider,
2876
+ ...(cursor === undefined ? {} : { cursor }),
2877
+ limit: MODEL_CATALOG_RESTORE_PAGE_SIZE,
2878
+ signal,
2879
+ });
2880
+ if (this.modelCatalogCache.get(sourceId)?.generation !== generation)
2881
+ return false;
2882
+ if (this.admitModelPage(sourceId, generation, page, target))
2883
+ return true;
2884
+ const nextCursor = page.truncated ? page.nextCursor : undefined;
2885
+ if (nextCursor === undefined || nextCursor.length === 0 || seenCursors.has(nextCursor))
2886
+ return false;
2887
+ seenCursors.add(nextCursor);
2888
+ cursor = nextCursor;
2889
+ }
2890
+ return false;
2891
+ }
2892
+ admitModelPage(sourceId, generation, page, exactTarget) {
2893
+ const refs = page.models.flatMap((model) => {
2894
+ const record = {
2895
+ source: "page",
2896
+ efforts: advertisedEffortLevels(model),
2897
+ advertisement: model,
2898
+ };
2899
+ // The wire carries provider-local ids while every selection surface
2900
+ // speaks the canonical `<provider>:<model>` reference. Admit both, or a
2901
+ // turn is judged against metadata the page did advertise but under a
2902
+ // name nothing ever asks for.
2903
+ const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
2904
+ if (exactTarget?.kind === "canonical"
2905
+ && (model.provider !== exactTarget.provider || model.id !== exactTarget.modelId))
2906
+ return [];
2907
+ if (exactTarget?.kind === "bare" && model.id !== exactTarget.modelId)
2908
+ return [];
2909
+ const entries = [[model.id, record]];
2910
+ if (reference !== model.id)
2911
+ entries.push([reference, record]);
2912
+ return entries;
2913
+ });
2914
+ return refs.length > 0 && this.admitCatalogRefs(sourceId, generation, refs);
2915
+ }
1744
2916
  admitModelRef(entries, ref, record) {
1745
2917
  const known = entries.get(ref);
1746
2918
  if (known !== undefined) {
@@ -1760,8 +2932,36 @@ export class WebService {
1760
2932
  entries.delete(oldest);
1761
2933
  }
1762
2934
  }
1763
- validateModelAndEffort(sourceId, agent, model, effort) {
1764
- if (model !== undefined && !this.modelAdmitted(sourceId, agent, model)) {
2935
+ /** Resolve dispatch and durable attribution from the same fresh thread snapshot. */
2936
+ resolveTurnSelection(threadId, explicitModel, explicitEffort) {
2937
+ const thread = this.store.getThread(threadId);
2938
+ if (thread === undefined)
2939
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
2940
+ const agent = this.store.getAgent(thread.sourceId);
2941
+ if (agent === undefined) {
2942
+ throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
2943
+ }
2944
+ const model = explicitModel ?? thread.runModel ?? undefined;
2945
+ const effort = explicitEffort ?? thread.runEffort ?? undefined;
2946
+ this.validateModelAndEffort(thread.sourceId, agent, model, effort);
2947
+ const requestedModel = effectiveModelForAgent(agent, model);
2948
+ const cached = requestedModel === undefined
2949
+ ? undefined
2950
+ : this.modelCatalogCache.get(thread.sourceId)?.models.get(requestedModel);
2951
+ const requestedEffort = effort ?? inheritedEffortForModel(agent, requestedModel, cached?.advertisement, agent.defaultEffort);
2952
+ return {
2953
+ thread,
2954
+ agent,
2955
+ ...(model === undefined ? {} : { model }),
2956
+ ...(effort === undefined ? {} : { effort }),
2957
+ ...(requestedModel === undefined ? {} : { requestedModel }),
2958
+ ...(requestedEffort === undefined ? {} : { requestedEffort }),
2959
+ };
2960
+ }
2961
+ validateModelAndEffort(sourceId, agent, model, effort, strictModel = false) {
2962
+ if (model !== undefined && !(strictModel
2963
+ ? this.modelStrictlyAdmitted(sourceId, agent, model)
2964
+ : this.modelAdmitted(sourceId, agent, model))) {
1765
2965
  throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
1766
2966
  }
1767
2967
  // Both ends resolve a blank selection to the same route -- the browser fell
@@ -1782,17 +2982,19 @@ export class WebService {
1782
2982
  }
1783
2983
  }
1784
2984
  modelAdmitted(sourceId, agent, model) {
2985
+ if (this.modelStrictlyAdmitted(sourceId, agent, model))
2986
+ return true;
2987
+ // Tier 3: syntactic `<provider>:<model>` floor. Existing per-conversation
2988
+ // selections retain this compatibility path; durable web defaults do not.
2989
+ return modelPassesSyntacticFloor(model);
2990
+ }
2991
+ modelStrictlyAdmitted(sourceId, agent, model) {
1785
2992
  // Tier 1: the configured-route shortlist — unchanged, always allowed.
1786
2993
  if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
1787
2994
  return true;
1788
2995
  // Tier 2: a model reached only through the catalog cache.
1789
2996
  const cached = this.modelCatalogCache.get(sourceId);
1790
- if (cached !== undefined && cached.models.has(model))
1791
- return true;
1792
- // Tier 3: syntactic `<provider>:<model>` floor. The web package has no pi-ai
1793
- // access, so a well-formed ref passes here and the agent itself is the real
1794
- // gate at turn time.
1795
- return modelPassesSyntacticFloor(model);
2997
+ return cached !== undefined && cached.models.has(model);
1796
2998
  }
1797
2999
  authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
1798
3000
  const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
@@ -1804,7 +3006,11 @@ export class WebService {
1804
3006
  if (access === "expired") {
1805
3007
  throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
1806
3008
  }
1807
- return { thread, part };
3009
+ // Answered here rather than at the route, because the SERVICE owns the
3010
+ // clock this expiry was signed against -- a route reading `Date.now()`
3011
+ // against an injected clock reports a body that may already be cold.
3012
+ const remainingMs = Number(expires) * 1_000 - this.currentDate().getTime();
3013
+ return { thread, part, remainingSeconds: Math.max(0, Math.floor(remainingMs / 1_000)) };
1808
3014
  }
1809
3015
  requireReplyPart(threadId, messageId, partId, type) {
1810
3016
  const thread = this.store.getThread(threadId);
@@ -1825,20 +3031,79 @@ export class WebService {
1825
3031
  throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
1826
3032
  }
1827
3033
  }
1828
- decorateThreadDetail(detail) {
3034
+ decorateThreadDetail(detail, options = {}) {
1829
3035
  // Backfill: messages that predate this feature, and any turn whose own
1830
3036
  // attempt failed or was interrupted. Idempotent and guarded, so repeated
1831
3037
  // reads of the same thread fetch each image at most once.
1832
3038
  void this.persistReplyImages(detail.thread.id, detail.messages);
1833
- return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
3039
+ return { ...detail, messages: this.shapeMessages(detail.messages, options) };
3040
+ }
3041
+ /**
3042
+ * Fold the host's provenance row into the imported-context card.
3043
+ *
3044
+ * The exact host-owned string is enough to identify this row: no model turn
3045
+ * can own it, and the store writes it only for canonical context imports.
3046
+ * Filtering independently of its neighbour is intentional, because the two
3047
+ * stored rows can straddle a message-page boundary. `?full=1` remains the raw
3048
+ * transcript escape hatch and therefore returns both stored rows unchanged.
3049
+ */
3050
+ shapeMessages(messages, options = {}) {
3051
+ const visible = options.full === true
3052
+ ? messages
3053
+ : messages.filter((message) => !this.isCronReplyProvenanceMessage(message));
3054
+ return visible.map((message) => this.shapeMessage(message, options));
3055
+ }
3056
+ isCronReplyProvenanceMessage(message) {
3057
+ return message.role === "system"
3058
+ && message.turnId === undefined
3059
+ && message.parts.length === 1
3060
+ && message.parts[0]?.type === "text"
3061
+ && message.parts[0].text === AGENT_CONTEXT_IMPORT_SYSTEM_PROVENANCE;
3062
+ }
3063
+ /**
3064
+ * The one boundary every browser-facing message crosses: it mints the
3065
+ * short-lived reply capabilities, and it puts the transcript on a diet.
3066
+ *
3067
+ * Shaping lives HERE and never in the store. The store's parts feed the
3068
+ * sidebar preview, the streamed-text split, and the transcript deltas that
3069
+ * follow this change, all of which need the payloads whole and the indexes
3070
+ * exactly as recorded.
3071
+ */
3072
+ shapeMessage(message, options = {}) {
3073
+ if (options.full !== true && this.isCronReplyProvenanceMessage(message)) {
3074
+ // Individual-message recovery cannot omit its addressed row. An empty
3075
+ // projection keeps the raw provenance out of the UI; list reads remove
3076
+ // the row altogether through `shapeMessages`.
3077
+ return { ...message, parts: [] };
3078
+ }
3079
+ return { ...message, parts: message.parts.map((part) => this.shapePart(message, part, options)) };
1834
3080
  }
1835
- decorateMessage(message) {
1836
- const parts = message.parts.map((part) => {
1837
- if (part.type !== "attachment" && part.type !== "mcp_app")
1838
- return part;
3081
+ /**
3082
+ * One part, shaped exactly as a read of its message would serve it.
3083
+ *
3084
+ * A streamed delta puts individual parts on the wire, and they cross the same
3085
+ * boundary the transcript does. Reading the rules from one place is what keeps
3086
+ * a `set` op and a re-read of the same message from disagreeing about what a
3087
+ * tool result contains.
3088
+ */
3089
+ shapePart(message, part, options) {
3090
+ if (part.type === "attachment" || part.type === "mcp_app")
1839
3091
  return this.decorateReplyPart(message, part);
1840
- });
1841
- return { ...message, parts };
3092
+ if (options.full === true)
3093
+ return part;
3094
+ if (message.role === "assistant"
3095
+ && message.turnId === undefined
3096
+ && message.parts.length === 1
3097
+ && part.type === "text") {
3098
+ return parseCronReplyContext(part.text) ?? part;
3099
+ }
3100
+ if (part.type === "telemetry")
3101
+ return shapeTelemetryPart(part);
3102
+ if (part.type === "tool-call")
3103
+ return shapeToolCallPart(part);
3104
+ if (part.type === "subagent")
3105
+ return shapeSubagentPart(part);
3106
+ return part;
1842
3107
  }
1843
3108
  /**
1844
3109
  * Keeps the console's own copy of an image the agent published.
@@ -1953,7 +3218,10 @@ export class WebService {
1953
3218
  const retentionDeadline = part.expiresAt === undefined
1954
3219
  ? Number.POSITIVE_INFINITY
1955
3220
  : Date.parse(part.expiresAt);
1956
- const expiresAt = Math.min(now + REPLY_ACCESS_TTL_MS, retentionDeadline);
3221
+ // From the bucket, not from this instant. A retention deadline that binds is
3222
+ // already a fixed value and is left exactly as it is.
3223
+ const bucketStart = Math.floor(now / REPLY_ACCESS_BUCKET_MS) * REPLY_ACCESS_BUCKET_MS;
3224
+ const expiresAt = Math.min(bucketStart + REPLY_ACCESS_TTL_MS, retentionDeadline);
1957
3225
  if (!Number.isFinite(expiresAt) || expiresAt <= now) {
1958
3226
  return part.type === "attachment" ? { ...part, ...storedUrl } : part;
1959
3227
  }
@@ -2045,10 +3313,15 @@ function abortableDelay(delayMs, signal) {
2045
3313
  });
2046
3314
  }
2047
3315
  function isFuturePendingAsk(snapshot, now) {
3316
+ if (snapshot.status !== "pending")
3317
+ return false;
3318
+ if (snapshot.expiresAt === null)
3319
+ return true;
2048
3320
  const expiresAt = new Date(snapshot.expiresAt).getTime();
2049
- return snapshot.status === "pending" && Number.isFinite(expiresAt) && expiresAt > now.getTime();
3321
+ return Number.isFinite(expiresAt) && expiresAt > now.getTime();
2050
3322
  }
2051
3323
  const STREAM_FLUSH_INTERVAL_MS = 50;
3324
+ const ASK_PUSH_DELIVERY_TTL_MS = 24 * 60 * 60 * 1_000;
2052
3325
  class WebTurnCancellation extends Error {
2053
3326
  kind;
2054
3327
  constructor(kind, message) {
@@ -2226,9 +3499,25 @@ function offlineSummary(agent, generation) {
2226
3499
  pinned: false,
2227
3500
  health: agent.source.health,
2228
3501
  supportsAttachments: false,
3502
+ runSettings: configRunSettings(),
2229
3503
  updatedAt: agent.source.updatedAt,
2230
3504
  };
2231
3505
  }
3506
+ function configRunSettings(model, effort) {
3507
+ return {
3508
+ config: {
3509
+ ...(model === undefined ? {} : { model }),
3510
+ ...(effort === undefined ? {} : { effort }),
3511
+ },
3512
+ override: null,
3513
+ effective: {
3514
+ ...(model === undefined ? {} : { model }),
3515
+ modelSource: "config",
3516
+ ...(effort === undefined ? {} : { effort }),
3517
+ effortSource: "config",
3518
+ },
3519
+ };
3520
+ }
2232
3521
  function collectEfforts(info) {
2233
3522
  // Older operator schemas do not advertise per-model metadata. Match the TUI
2234
3523
  // picker in that case: cloud/unknown models use the canonical global effort