@mono-agent/web 0.20.11 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +397 -68
  2. package/dist/contracts.d.ts +437 -8
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/cron-reply-context.d.ts +27 -0
  7. package/dist/cron-reply-context.d.ts.map +1 -0
  8. package/dist/cron-reply-context.js +241 -0
  9. package/dist/cron-reply-context.js.map +1 -0
  10. package/dist/discovery.d.ts +1 -0
  11. package/dist/discovery.d.ts.map +1 -1
  12. package/dist/discovery.js +8 -6
  13. package/dist/discovery.js.map +1 -1
  14. package/dist/effort-ladder.d.ts +111 -0
  15. package/dist/effort-ladder.d.ts.map +1 -0
  16. package/dist/effort-ladder.js +155 -0
  17. package/dist/effort-ladder.js.map +1 -0
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/long-lived-fetch.d.ts +5 -0
  23. package/dist/long-lived-fetch.d.ts.map +1 -0
  24. package/dist/long-lived-fetch.js +22 -0
  25. package/dist/long-lived-fetch.js.map +1 -0
  26. package/dist/monitor-reply.d.ts +10 -0
  27. package/dist/monitor-reply.d.ts.map +1 -0
  28. package/dist/monitor-reply.js +67 -0
  29. package/dist/monitor-reply.js.map +1 -0
  30. package/dist/notification-client.d.ts +11 -2
  31. package/dist/notification-client.d.ts.map +1 -1
  32. package/dist/notification-client.js +5 -4
  33. package/dist/notification-client.js.map +1 -1
  34. package/dist/notification-ingress.d.ts +8 -1
  35. package/dist/notification-ingress.d.ts.map +1 -1
  36. package/dist/notification-ingress.js +28 -6
  37. package/dist/notification-ingress.js.map +1 -1
  38. package/dist/operator-client.d.ts +35 -2
  39. package/dist/operator-client.d.ts.map +1 -1
  40. package/dist/operator-client.js +276 -9
  41. package/dist/operator-client.js.map +1 -1
  42. package/dist/server.d.ts +42 -1
  43. package/dist/server.d.ts.map +1 -1
  44. package/dist/server.js +679 -52
  45. package/dist/server.js.map +1 -1
  46. package/dist/service.d.ts +299 -18
  47. package/dist/service.d.ts.map +1 -1
  48. package/dist/service.js +1915 -160
  49. package/dist/service.js.map +1 -1
  50. package/dist/store-migrations.d.ts +23 -0
  51. package/dist/store-migrations.d.ts.map +1 -0
  52. package/dist/store-migrations.js +212 -0
  53. package/dist/store-migrations.js.map +1 -0
  54. package/dist/store.d.ts +389 -21
  55. package/dist/store.d.ts.map +1 -1
  56. package/dist/store.js +2087 -245
  57. package/dist/store.js.map +1 -1
  58. package/package.json +9 -8
  59. package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
  60. package/webapp/dist/assets/index-6uQ7TVQe.css +1 -0
  61. package/webapp/dist/assets/index-CsSMjSgW.js +156 -0
  62. package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
  63. package/webapp/dist/badge-96.png +0 -0
  64. package/webapp/dist/index.html +26 -6
  65. package/webapp/dist/manifest.webmanifest +1 -1
  66. package/webapp/dist/notification-sw.js +3 -1
  67. package/webapp/dist/sw.js +1 -1
  68. package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
  69. package/webapp/dist/assets/index-BT463dRM.css +0 -1
  70. package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/service.js CHANGED
@@ -1,26 +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 { 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 { parseCronReplyContext } from "./cron-reply-context.js";
9
+ import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel, inheritedEffortForModel, } from "./effort-ladder.js";
8
10
  import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
9
11
  import { OperatorClient } from "./operator-client.js";
10
12
  import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
11
13
  import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
12
- import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
14
+ import { cronChannelReadOnlyError, toWebAttachment, WebStore, WEB_THREAD_PAGE_DEFAULT, notificationPushLogicalKey, } from "./store.js";
13
15
  const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
14
16
  const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
15
17
  const INFO_TIMEOUT_MS = 2_500;
16
18
  const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
19
+ /** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
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;
17
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;
18
38
  /**
19
39
  * Raster types the console keeps its own copy of. `image/svg+xml` is absent on
20
40
  * purpose: it is active content, and both the inline gate in the browser and
21
41
  * `setReplyDownloadHeaders` already refuse to treat it as an image.
22
42
  */
23
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
+ }
24
296
  function formatQuotedTurn(quote, text) {
25
297
  const blockquote = quote
26
298
  .trim()
@@ -29,6 +301,36 @@ function formatQuotedTurn(quote, text) {
29
301
  .join("\n");
30
302
  return `Quoted context:\n${blockquote}\n\n${text}`;
31
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
+ }
309
+ function assertMonitorWakeAddress(input) {
310
+ const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
311
+ const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
312
+ if (input.monitor.origin.channel !== "web"
313
+ || originConversation !== `web:${input.threadId}`
314
+ || input.deliveryKey !== expectedDeliveryKey) {
315
+ throw new WebConsoleError("invalid_notification", "The Monitor wake origin or delivery key does not match its web destination.", 409);
316
+ }
317
+ }
318
+ function monitorWakePayloadSha256(monitor, wakePrompt) {
319
+ return createHash("sha256")
320
+ .update(canonicalJson(monitor))
321
+ .update("\0")
322
+ .update(wakePrompt)
323
+ .digest("hex");
324
+ }
325
+ function canonicalJson(value) {
326
+ if (Array.isArray(value))
327
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
328
+ if (typeof value === "object" && value !== null) {
329
+ const record = value;
330
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
331
+ }
332
+ return JSON.stringify(value) ?? "null";
333
+ }
32
334
  export class WebService {
33
335
  store;
34
336
  options;
@@ -39,9 +341,11 @@ export class WebService {
39
341
  drainingLiveInputThreads = new Set();
40
342
  activeUploads = new Map();
41
343
  activeNotifications = new Map();
42
- processJobWakeTails = new Map();
43
- processJobWakeReservations = new Map();
44
- activeProcessJobWakes = new Map();
344
+ activeCronReplies = new Map();
345
+ /** One serialization lane shared by queued user input and every host wake kind. */
346
+ hostWakeTails = new Map();
347
+ hostWakeReservations = new Map();
348
+ activeHostWakes = new Map();
45
349
  allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
46
350
  attachmentTurnBudget;
47
351
  pushIdentity;
@@ -50,6 +354,20 @@ export class WebService {
50
354
  replyAccessKey;
51
355
  askWatches = new Map();
52
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();
364
+ /** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
365
+ * and appended to by every proxied `/v1/models` page. Admission is `has`,
366
+ * metadata is `get`. Map preserves insertion order, so evicting the oldest
367
+ * entry is deleting the head. Scoped to the agent GENERATION that filled it
368
+ * (see `reconcileModelCatalogCache`), because a source id outlives the
369
+ * process behind it. */
370
+ modelCatalogCache = new Map();
53
371
  /** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
54
372
  persistingReplyImages = new Set();
55
373
  discoveryTimer;
@@ -119,8 +437,23 @@ export class WebService {
119
437
  throw error;
120
438
  }
121
439
  }
122
- async bootstrap() {
440
+ async bootstrap(scope = {}) {
123
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
+ });
124
457
  return {
125
458
  version: WEB_API_VERSION,
126
459
  push: {
@@ -128,9 +461,11 @@ export class WebService {
128
461
  keyFingerprint: this.pushIdentity.fingerprint,
129
462
  serviceWorkerVersion: WEB_PUSH_SERVICE_WORKER_VERSION,
130
463
  },
131
- agents: this.store.listAgents(),
132
- threads: this.store.listThreads(),
133
- ...(currentThreadId === undefined ? {} : { currentThreadId }),
464
+ agents,
465
+ threads: page.threads,
466
+ threadsSourceId,
467
+ threadsNextCursor: page.nextCursor ?? null,
468
+ ...(discoveredCurrentThreadId === undefined ? {} : { currentThreadId: discoveredCurrentThreadId }),
134
469
  limits: {
135
470
  maxFileBytes: DEFAULT_AGENT_ATTACHMENT_MAX_BYTES,
136
471
  maxFilesPerTurn: WEB_MAX_FILES_PER_TURN,
@@ -139,16 +474,83 @@ export class WebService {
139
474
  },
140
475
  };
141
476
  }
142
- createThread(sourceId) {
143
- const thread = this.store.createThread(sourceId);
144
- 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 });
145
547
  return thread;
146
548
  }
147
- thread(id) {
549
+ thread(id, options = {}) {
148
550
  const detail = this.store.getThreadDetail(id);
149
551
  if (detail === undefined)
150
552
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
151
- return this.decorateThreadDetail(detail);
553
+ return this.decorateThreadDetail(detail, options);
152
554
  }
153
555
  threadsPage(input) {
154
556
  return this.store.listThreadsPage(input);
@@ -161,8 +563,56 @@ export class WebService {
161
563
  return this.store.searchThreads(input);
162
564
  }
163
565
  messagePage(threadId, input) {
164
- const page = this.store.listMessagesPage(threadId, input);
165
- 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);
166
616
  }
167
617
  /**
168
618
  * Re-mint a browser capability from the authoritative durable message. The
@@ -175,7 +625,7 @@ export class WebService {
175
625
  return this.decorateReplyPart(message, part);
176
626
  }
177
627
  async replyAttachment(threadId, messageId, partId, expires, token, signal) {
178
- const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
628
+ const { thread, part, remainingSeconds } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
179
629
  const connection = this.connections.get(thread.sourceId);
180
630
  if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
181
631
  throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
@@ -191,7 +641,7 @@ export class WebService {
191
641
  ...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
192
642
  };
193
643
  const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
194
- return { part, response };
644
+ return { part, response, remainingSeconds };
195
645
  }
196
646
  async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
197
647
  const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
@@ -317,32 +767,71 @@ export class WebService {
317
767
  return result;
318
768
  }
319
769
  patchThread(id, patch) {
770
+ if (patch.ifRunConfigUnset === true) {
771
+ // Compare-and-set for the console's one-time adoption of a browser-local
772
+ // override. Whoever set an override first keeps it; the loser adopts what
773
+ // it reads back, and no event is emitted because nothing changed.
774
+ //
775
+ // The check and the write are one `BEGIN IMMEDIATE` inside the store, not
776
+ // two calls from here: this request handler is synchronous, so no other
777
+ // HTTP request can interleave, but the process lease is held on a
778
+ // separate database file and does not stop a second connection to the
779
+ // state DB from writing between a bare read and a bare write.
780
+ const result = this.store.patchThreadIfRunConfigUnset(id, patch);
781
+ if (!result.applied)
782
+ return result.thread;
783
+ this.emitThread("thread.changed", { thread: result.thread });
784
+ this.emitThread("threads.changed", { thread: result.thread });
785
+ return result.thread;
786
+ }
320
787
  const thread = this.store.patchThread(id, patch);
321
- this.emit("thread.changed", thread.id, { thread });
322
- this.emit("threads.changed", thread.id);
788
+ this.emitThread("thread.changed", { thread });
789
+ this.emitThread("threads.changed", { thread });
323
790
  return thread;
324
791
  }
325
- async deleteThread(id) {
792
+ async deleteThread(id, options = {}) {
326
793
  const resolved = this.store.getThread(id)?.id;
327
794
  if (resolved === undefined)
328
795
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
329
796
  if (this.activeTurns.has(resolved)) {
330
797
  throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
331
798
  }
332
- const result = await this.store.deleteArchivedThread(resolved);
799
+ const result = await this.store.deleteArchivedThread(resolved, options);
333
800
  if (result.orphanedFiles > 0) {
334
801
  this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
335
802
  threadId: resolved,
336
803
  count: result.orphanedFiles,
337
804
  });
338
805
  }
339
- this.emit("thread.changed", resolved, { threadId: resolved, removed: true });
340
- this.emit("threads.changed", resolved);
806
+ this.emitThread("thread.changed", { threadId: resolved, removed: true });
807
+ this.emitThread("threads.changed", { threadId: resolved, removed: true });
341
808
  }
342
809
  patchAgent(sourceId, patch) {
343
810
  const agent = this.store.setAgentPinned(sourceId, patch.pinned);
344
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
345
- return agent;
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);
828
+ this.emit("agents.changed");
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);
346
835
  }
347
836
  agentSkills(sourceId) {
348
837
  const agent = this.store.getAgent(sourceId);
@@ -387,7 +876,7 @@ export class WebService {
387
876
  const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
388
877
  return stored.messages === undefined
389
878
  ? stored
390
- : { ...stored, messages: stored.messages.map((message) => this.decorateMessage(message)) };
879
+ : { ...stored, messages: stored.messages.map((message) => this.shapeMessage(message)) };
391
880
  }
392
881
  const page = await connection.client.cronRuns(jobId, {
393
882
  limit: input.limit,
@@ -396,25 +885,192 @@ export class WebService {
396
885
  });
397
886
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
398
887
  if (reconciled.changed) {
399
- const threadId = this.store.cronThread(sourceId, jobId)?.id;
400
- this.emit("thread.changed", threadId);
401
- this.emit("threads.changed", threadId);
888
+ this.announceReconciledMessages(reconciled);
889
+ this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
402
890
  }
403
- return { ...page, messages: reconciled.messages.map((message) => this.decorateMessage(message)) };
891
+ return { ...page, messages: reconciled.messages.map((message) => this.shapeMessage(message)) };
404
892
  }
405
893
  async cronRun(sourceId, jobId, runId) {
406
894
  const connection = this.requireCronConnection(sourceId, false);
407
895
  const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
408
896
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
409
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
+ }
410
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
+ }
411
906
  throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
412
907
  }
413
- if (reconciled.changed) {
414
- this.emit("thread.changed", message.threadId, { messageId: message.id });
415
- 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
+ }
416
957
  }
417
- return this.decorateMessage(message);
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 });
999
+ }
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;
1020
+ }
1021
+ async agentModels(sourceId, input) {
1022
+ const agent = this.store.getAgent(sourceId);
1023
+ if (agent === undefined)
1024
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
1025
+ const connection = this.connections.get(sourceId);
1026
+ if (connection === undefined)
1027
+ throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
1028
+ const generation = this.modelCatalogCache.get(sourceId)?.generation;
1029
+ const page = await connection.client.models({
1030
+ ...(input.provider === undefined ? {} : { provider: input.provider }),
1031
+ ...(input.q === undefined ? {} : { q: input.q }),
1032
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
1033
+ limit: input.limit,
1034
+ signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
1035
+ });
1036
+ // Every proxied page widens the per-agent catalog cache, the only feed
1037
+ // (besides `modelOptions` keys) that makes tier-2 model admission possible,
1038
+ // and the only place the effort ladder a catalog model advertises is ever
1039
+ // seen -- `modelOptions` never describes it, so dropping it here is what
1040
+ // left effort validation with nothing to judge against.
1041
+ //
1042
+ // Admitted under the generation the request was ISSUED under, never under
1043
+ // whatever is current when it answers. A discovery refresh can retire this
1044
+ // agent's generation inside the await above, and keyed by source id alone
1045
+ // the reply -- fetched from a process that is gone -- was written straight
1046
+ // into the freshly reconciled map, where `source: "page"` overwrites
1047
+ // unconditionally. Generation 1's ladder then judged generation 2's turns.
1048
+ this.admitModelPage(sourceId, generation, page);
1049
+ return page;
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)));
418
1074
  }
419
1075
  async cronConfigView(sourceId) {
420
1076
  const connection = this.requireCronConnection(sourceId, false);
@@ -426,9 +1082,8 @@ export class WebService {
426
1082
  if (result.kind === "completed") {
427
1083
  const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
428
1084
  if (reconciled.changed) {
429
- const threadId = this.store.cronThread(sourceId, jobId)?.id;
430
- this.emit("thread.changed", threadId);
431
- this.emit("threads.changed", threadId);
1085
+ this.announceReconciledMessages(reconciled);
1086
+ this.emitStoredThread(this.store.cronThread(sourceId, jobId)?.id, ["thread.changed", "threads.changed"]);
432
1087
  }
433
1088
  }
434
1089
  return result;
@@ -446,8 +1101,7 @@ export class WebService {
446
1101
  throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
447
1102
  if (synced.changed) {
448
1103
  this.emit("cron.changed", job.threadId, { sourceId, jobId });
449
- this.emit("thread.changed", job.threadId, { thread: this.store.getThread(job.threadId) });
450
- this.emit("threads.changed", job.threadId);
1104
+ this.emitStoredThread(job.threadId, ["thread.changed", "threads.changed"]);
451
1105
  }
452
1106
  return { ...result, value: { job } };
453
1107
  }
@@ -455,10 +1109,40 @@ export class WebService {
455
1109
  if (this.stopped) {
456
1110
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
457
1111
  }
458
- 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) {
459
1117
  await this.refreshAgents();
1118
+ }
1119
+ if (this.stopped) {
1120
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
1121
+ }
1122
+ if (input.triggerKind === "monitor") {
1123
+ assertMonitorWakeAddress(input);
1124
+ const thread = this.store.getThread(input.threadId);
1125
+ if (thread === undefined || thread.sourceId !== input.sourceId) {
1126
+ return {
1127
+ duplicate: true,
1128
+ tombstoned: true,
1129
+ delivery: { delivered: false, code: "monitor_origin_mismatch", retryable: false },
1130
+ };
1131
+ }
1132
+ if (thread.archivedAt !== null || thread.trigger !== undefined) {
1133
+ return {
1134
+ thread,
1135
+ duplicate: false,
1136
+ delivery: { delivered: false, code: "monitor_wake_failed", retryable: false },
1137
+ };
1138
+ }
1139
+ const result = await this.deliverMonitorWake(input);
1140
+ return { thread, duplicate: result.duplicate, delivery: result.receipt };
1141
+ }
460
1142
  if (input.triggerKind === "job") {
461
- 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({
462
1146
  sourceId: input.sourceId,
463
1147
  threadId: input.threadId,
464
1148
  deliveryKey: input.deliveryKey,
@@ -466,16 +1150,24 @@ export class WebService {
466
1150
  ...(input.text === undefined ? {} : { responseText: input.text }),
467
1151
  ...(input.parts === undefined ? {} : { replyParts: input.parts }),
468
1152
  });
469
- 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);
470
1157
  if (!completed.duplicate && message !== undefined) {
471
1158
  this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
472
1159
  }
473
- this.emit("threads.changed", input.threadId, { thread: completed.thread });
474
- 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"]);
475
1164
  if (input.wakePrompt === undefined)
476
1165
  return completed;
477
1166
  if (this.connections.get(input.sourceId) === undefined)
478
1167
  await this.refreshAgents();
1168
+ if (this.stopped) {
1169
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
1170
+ }
479
1171
  const delivery = await this.deliverProcessJobWake(input);
480
1172
  return { ...completed, delivery };
481
1173
  }
@@ -515,28 +1207,148 @@ export class WebService {
515
1207
  async startTurn(threadId, input) {
516
1208
  const text = input.text ?? "";
517
1209
  const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
518
- if (operatorText.length > WEB_MAX_TURN_TEXT_CHARACTERS) {
519
- throw new WebConsoleError("turn_text_too_large", `The message and quote may contain at most ${WEB_MAX_TURN_TEXT_CHARACTERS} characters after formatting.`, 413);
520
- }
1210
+ assertTurnTextWithinLimit(operatorText);
521
1211
  const attachmentIds = input.attachmentIds ?? [];
522
- const thread = this.store.getThread(threadId);
523
- if (thread === undefined)
524
- 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;
525
1214
  threadId = thread.id;
526
- const agent = this.store.getAgent(thread.sourceId);
527
1215
  const connection = this.connections.get(thread.sourceId);
528
1216
  if (thread.trigger?.kind === "cron")
529
1217
  throw cronChannelReadOnlyError();
530
- if (agent === undefined || connection === undefined || !thread.canSend) {
1218
+ if (connection === undefined || !thread.canSend) {
531
1219
  throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
532
1220
  }
533
- validateModelAndEffort(agent, input.model, input.effort);
534
- const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.model === undefined ? {} : { model: input.model }), ...(input.effort === undefined ? {} : { effort: input.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
+ });
535
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
+ });
536
1240
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
537
- this.emit("threads.changed", threadId);
1241
+ this.emitThread("threads.changed", { thread: started.thread });
538
1242
  return { thread: started.thread, turn: started.thread.runState };
539
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
+ }
540
1352
  submitLiveInput(threadId, text) {
541
1353
  if (this.stopped) {
542
1354
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
@@ -549,13 +1361,21 @@ export class WebService {
549
1361
  const active = this.activeTurns.get(threadId);
550
1362
  const reserved = this.store.reserveLiveInput(threadId, text);
551
1363
  this.emit("message.changed", threadId, { messageId: reserved.message.id, updatedAt: reserved.message.updatedAt });
552
- this.emit("threads.changed", threadId);
1364
+ this.emitThread("threads.changed", { thread: reserved.thread });
553
1365
  if (!reserved.offered || active === undefined || connection === undefined || !connection.info.supportsLiveInput) {
554
1366
  const queued = reserved.offered ? this.store.queueLiveInput(reserved.input.id) ?? reserved.message : reserved.message;
555
1367
  this.emit("message.changed", threadId, { messageId: queued.id, updatedAt: queued.updatedAt });
556
1368
  void this.drainQueuedLiveInputs(threadId);
557
1369
  return { message: queued, disposition: "queued" };
558
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
+ }
559
1379
  const controller = new AbortController();
560
1380
  const completion = this.deliverLiveInput(reserved.input.id, threadId, active.client, controller, {
561
1381
  conversationId: `web:${threadId}`,
@@ -678,6 +1498,17 @@ export class WebService {
678
1498
  readyEvent() {
679
1499
  return this.createEvent("ready", undefined, { version: WEB_API_VERSION });
680
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
+ }
681
1512
  refreshAgents() {
682
1513
  if (this.stopped)
683
1514
  return Promise.resolve();
@@ -709,7 +1540,7 @@ export class WebService {
709
1540
  const active = [...this.activeTurns.values()];
710
1541
  const activeLiveInputs = [...this.activeLiveInputs.entries()];
711
1542
  const activeNotifications = [...this.activeNotifications.values()];
712
- const activeProcessJobWakes = [...this.activeProcessJobWakes.values()];
1543
+ const activeHostWakes = [...this.activeHostWakes.values()];
713
1544
  const trackedIds = new Set(active.map((turn) => turn.turnId));
714
1545
  for (const turnId of this.store.listActiveTurnIds()) {
715
1546
  if (!trackedIds.has(turnId))
@@ -720,12 +1551,15 @@ export class WebService {
720
1551
  turn.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
721
1552
  }
722
1553
  for (const [id, input] of activeLiveInputs) {
723
- 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);
724
1558
  input.controller.abort(new WebTurnCancellation("shutdown", "Web service is stopping."));
725
1559
  }
726
1560
  await Promise.allSettled(active.map((turn) => turn.completion));
727
1561
  await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
728
- await Promise.allSettled(activeProcessJobWakes);
1562
+ await Promise.allSettled(activeHostWakes);
729
1563
  await Promise.allSettled(activeNotifications);
730
1564
  await Promise.allSettled(askWatches.map((watch) => watch.promise));
731
1565
  await this.pushDispatcher.stopAndDrain(5_000);
@@ -737,10 +1571,9 @@ export class WebService {
737
1571
  this.store.close();
738
1572
  await this.lease.release();
739
1573
  }
740
- async runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey) {
1574
+ async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey, onAdmitted) {
741
1575
  const coalescer = new StreamFrameCoalescer(async (frames) => {
742
- const message = this.store.applyStreamFrames(started.turnId, frames);
743
- 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));
744
1577
  }, (error) => controller.abort(error));
745
1578
  let releaseAttachmentBudget;
746
1579
  try {
@@ -761,24 +1594,32 @@ export class WebService {
761
1594
  threadId: started.thread.id,
762
1595
  turnId: started.turnId,
763
1596
  ...modelMetadata,
764
- ...(processJobWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
1597
+ ...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
765
1598
  ? { conversationTitle: { schema: 1, writable: true } }
766
1599
  : {}),
767
1600
  },
768
1601
  tui: modelMetadata,
769
1602
  },
770
- ...(processJobWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey }),
1603
+ ...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
771
1604
  onFrame: (frame) => {
772
1605
  this.observeAskUserFrame(started.thread.id, started.turnId, frame);
773
1606
  this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
774
1607
  coalescer.push(frame);
775
1608
  },
1609
+ ...(onAdmitted === undefined ? {} : { onAdmitted }),
776
1610
  });
777
1611
  await coalescer.flush();
778
- const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts);
1612
+ const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
1613
+ && (response.finalText === undefined || response.finalText.length === 0)
1614
+ && (response.parts === undefined || response.parts.length === 0);
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);
779
1620
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
780
- this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
781
- this.emit("threads.changed", started.thread.id);
1621
+ this.emitThread("thread.changed", { thread: detail.thread });
1622
+ this.emitThread("threads.changed", { thread: detail.thread });
782
1623
  this.announcePushEvent(`turn:${started.turnId}:terminal`);
783
1624
  // Detached: the turn is already finished and reported, and keeping a copy
784
1625
  // must neither delay nor fail it. The agent is still connected here, which
@@ -802,9 +1643,10 @@ export class WebService {
802
1643
  ...(code === undefined ? {} : { code }),
803
1644
  cancelled,
804
1645
  });
1646
+ this.emitMessageWrite(started.thread.id, detail.write);
805
1647
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
806
- this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
807
- this.emit("threads.changed", started.thread.id);
1648
+ this.emitThread("thread.changed", { thread: detail.thread });
1649
+ this.emitThread("threads.changed", { thread: detail.thread });
808
1650
  this.announcePushEvent(`turn:${started.turnId}:terminal`);
809
1651
  }
810
1652
  finally {
@@ -812,19 +1654,80 @@ export class WebService {
812
1654
  coalescer.close();
813
1655
  }
814
1656
  }
815
- launchTurn(started, client, operatorText, processJobWakeDeliveryKey) {
1657
+ launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
816
1658
  const threadId = started.thread.id;
817
1659
  const controller = new AbortController();
818
- const completion = this.runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey).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);
819
1666
  const active = this.activeTurns.get(threadId);
820
1667
  if (active?.turnId === started.turnId)
821
1668
  this.activeTurns.delete(threadId);
822
- if (!this.stopped && !this.processJobWakeReservations.has(threadId)) {
1669
+ if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
823
1670
  void this.drainQueuedLiveInputs(threadId);
824
1671
  }
825
1672
  });
826
- this.activeTurns.set(threadId, { turnId: started.turnId, controller, client, completion });
827
- 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 });
828
1731
  }
829
1732
  async deliverLiveInput(id, threadId, client, controller, input) {
830
1733
  let queued = false;
@@ -837,16 +1740,22 @@ export class WebService {
837
1740
  else if (result.status === "discarded") {
838
1741
  changedMessage = this.store.cancelLiveInput(id);
839
1742
  }
840
- else {
841
- changedMessage = this.store.queueLiveInput(id);
1743
+ else if (result.status === "requeue") {
1744
+ changedMessage = this.store.queueLiveInput(id, `mailbox_${result.reason}`);
842
1745
  queued = changedMessage !== undefined;
843
1746
  }
1747
+ else if (result.status === "unavailable") {
1748
+ changedMessage = this.store.queueLiveInput(id, `operator_${result.reason}`);
1749
+ queued = changedMessage !== undefined;
1750
+ }
1751
+ else {
1752
+ changedMessage = this.store.markLiveInputUncertain(id);
1753
+ }
844
1754
  }
845
1755
  catch (error) {
846
- changedMessage = this.store.queueLiveInput(id);
847
- queued = changedMessage !== undefined;
1756
+ changedMessage = this.store.markLiveInputUncertain(id);
848
1757
  if (!controller.signal.aborted) {
849
- 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.", {
850
1759
  threadId,
851
1760
  error: errorMessage(error),
852
1761
  });
@@ -858,14 +1767,14 @@ export class WebService {
858
1767
  updatedAt: changedMessage.updatedAt,
859
1768
  });
860
1769
  }
861
- this.emit("threads.changed", threadId);
1770
+ this.emitStoredThread(threadId, ["threads.changed"]);
862
1771
  if (queued && !this.stopped)
863
1772
  await this.drainQueuedLiveInputs(threadId);
864
1773
  }
865
1774
  async drainQueuedLiveInputs(threadId) {
866
1775
  if (this.stopped
867
1776
  || this.activeTurns.has(threadId)
868
- || this.processJobWakeReservations.has(threadId)
1777
+ || this.hostWakeReservations.has(threadId)
869
1778
  || this.drainingLiveInputThreads.has(threadId))
870
1779
  return;
871
1780
  this.drainingLiveInputThreads.add(threadId);
@@ -880,20 +1789,28 @@ export class WebService {
880
1789
  if (started === undefined)
881
1790
  return;
882
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
+ });
883
1800
  this.emit("message.changed", threadId, {
884
1801
  messageId: started.assistantMessageId,
885
1802
  updatedAt: started.thread.updatedAt,
886
1803
  });
887
1804
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
888
- this.emit("threads.changed", threadId);
1805
+ this.emitThread("threads.changed", { thread: started.thread });
889
1806
  }
890
1807
  finally {
891
1808
  this.drainingLiveInputThreads.delete(threadId);
892
1809
  }
893
1810
  }
894
1811
  async deliverProcessJobWake(input) {
895
- const activeKey = `${input.sourceId}\0${input.processJob.jobId}`;
896
- const existing = this.activeProcessJobWakes.get(activeKey);
1812
+ const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
1813
+ const existing = this.activeHostWakes.get(activeKey);
897
1814
  if (existing !== undefined)
898
1815
  return await existing;
899
1816
  const reservation = this.store.reserveProcessJobWake({
@@ -913,8 +1830,8 @@ export class WebService {
913
1830
  ambiguous: true,
914
1831
  };
915
1832
  }
916
- this.retainProcessJobWakeReservation(input.threadId);
917
- const previous = this.processJobWakeTails.get(input.threadId) ?? Promise.resolve();
1833
+ this.retainHostWakeReservation(input.threadId);
1834
+ const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
918
1835
  const delivery = previous.catch(() => undefined).then(async () => {
919
1836
  const connection = this.connections.get(input.sourceId);
920
1837
  if (connection === undefined) {
@@ -928,6 +1845,7 @@ export class WebService {
928
1845
  const active = this.activeTurns.get(input.threadId);
929
1846
  if (active !== undefined && connection.info.supportsLiveInput) {
930
1847
  try {
1848
+ this.store.associateProcessJobWakeTurn(input.deliveryKey, active.turnId);
931
1849
  const settlement = await active.client.liveInput({
932
1850
  conversationId: `web:${input.threadId}`,
933
1851
  id: input.deliveryKey,
@@ -937,16 +1855,33 @@ export class WebService {
937
1855
  signal: AbortSignal.timeout(10 * 60 * 1_000),
938
1856
  });
939
1857
  if (settlement.status === "applied") {
940
- this.store.completeProcessJobWake({
1858
+ const message = this.store.completeProcessJobWake({
941
1859
  sourceId: input.sourceId,
942
1860
  jobId: input.processJob.jobId,
943
1861
  deliveryKey: input.deliveryKey,
944
1862
  disposition: "steered",
1863
+ turnId: active.turnId,
945
1864
  });
1865
+ if (message !== undefined) {
1866
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
1867
+ }
946
1868
  return { delivered: true, disposition: "steered" };
947
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);
948
1880
  }
949
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);
950
1885
  this.options.logger?.warn?.("Web process-job steering outcome is unknown; automatic fallback is suppressed.", {
951
1886
  threadId: input.threadId,
952
1887
  error: errorMessage(error),
@@ -959,8 +1894,41 @@ export class WebService {
959
1894
  };
960
1895
  }
961
1896
  }
962
- if (active !== undefined)
963
- 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
+ }
964
1932
  if (this.stopped) {
965
1933
  this.store.abandonProcessJobWake({
966
1934
  sourceId: input.sourceId,
@@ -980,9 +1948,19 @@ export class WebService {
980
1948
  }
981
1949
  let started;
982
1950
  try {
1951
+ const selection = this.resolveTurnSelection(input.threadId);
983
1952
  started = this.store.beginAssistantTurn({
984
1953
  threadId: input.threadId,
985
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 }),
986
1964
  });
987
1965
  }
988
1966
  catch (error) {
@@ -997,56 +1975,277 @@ export class WebService {
997
1975
  retryable: false,
998
1976
  };
999
1977
  }
1000
- 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.
1001
1999
  this.emit("message.changed", input.threadId, {
1002
2000
  messageId: started.assistantMessageId,
1003
2001
  updatedAt: started.thread.updatedAt,
1004
2002
  });
1005
2003
  this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
1006
- this.emit("threads.changed", input.threadId);
1007
- await completion;
1008
- 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.
1009
2014
  return {
1010
2015
  delivered: false,
1011
- code: "process_job_wake_failed",
2016
+ code: "process_job_wake_ambiguous",
1012
2017
  retryable: false,
1013
2018
  ambiguous: true,
1014
2019
  };
1015
2020
  }
1016
- this.store.completeProcessJobWake({
2021
+ const message = this.store.completeProcessJobWake({
1017
2022
  sourceId: input.sourceId,
1018
2023
  jobId: input.processJob.jobId,
1019
2024
  deliveryKey: input.deliveryKey,
1020
2025
  disposition: "follow_up",
1021
2026
  turnId: started.turnId,
1022
2027
  });
2028
+ if (message !== undefined) {
2029
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
2030
+ }
1023
2031
  return { delivered: true, disposition: "follow_up" };
1024
2032
  });
1025
2033
  const tail = delivery.then(() => undefined, () => undefined);
1026
- this.processJobWakeTails.set(input.threadId, tail);
1027
- this.activeProcessJobWakes.set(activeKey, delivery);
2034
+ this.hostWakeTails.set(input.threadId, tail);
2035
+ this.activeHostWakes.set(activeKey, delivery);
1028
2036
  try {
1029
2037
  return await delivery;
1030
2038
  }
1031
2039
  finally {
1032
- if (this.processJobWakeTails.get(input.threadId) === tail) {
1033
- this.processJobWakeTails.delete(input.threadId);
2040
+ if (this.hostWakeTails.get(input.threadId) === tail) {
2041
+ this.hostWakeTails.delete(input.threadId);
2042
+ }
2043
+ if (this.activeHostWakes.get(activeKey) === delivery) {
2044
+ this.activeHostWakes.delete(activeKey);
2045
+ }
2046
+ this.releaseHostWakeReservation(input.threadId);
2047
+ }
2048
+ }
2049
+ async deliverMonitorWake(input) {
2050
+ const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
2051
+ const reservation = this.store.reserveMonitorWake({
2052
+ sourceId: input.sourceId,
2053
+ threadId: input.threadId,
2054
+ monitorId: input.monitor.monitorId,
2055
+ deliveryKey: input.deliveryKey,
2056
+ payloadSha256: monitorWakePayloadSha256(input.monitor, input.wakePrompt),
2057
+ monitor: input.monitor,
2058
+ });
2059
+ if (reservation.kind === "completed") {
2060
+ return {
2061
+ receipt: { delivered: true, disposition: reservation.disposition },
2062
+ duplicate: true,
2063
+ };
2064
+ }
2065
+ if (reservation.kind === "uncertain") {
2066
+ const existing = this.activeHostWakes.get(activeKey);
2067
+ if (existing !== undefined)
2068
+ return { receipt: await existing, duplicate: true };
2069
+ return {
2070
+ receipt: {
2071
+ delivered: false,
2072
+ code: "monitor_wake_ambiguous",
2073
+ retryable: false,
2074
+ ambiguous: true,
2075
+ },
2076
+ duplicate: true,
2077
+ };
2078
+ }
2079
+ this.retainHostWakeReservation(input.threadId);
2080
+ const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
2081
+ const delivery = previous.catch(() => undefined).then(async () => {
2082
+ const abandon = () => this.store.abandonMonitorWake({
2083
+ sourceId: input.sourceId,
2084
+ monitorId: input.monitor.monitorId,
2085
+ deliveryKey: input.deliveryKey,
2086
+ });
2087
+ let connection = this.connections.get(input.sourceId);
2088
+ if (connection === undefined) {
2089
+ try {
2090
+ await this.refreshAgents();
2091
+ }
2092
+ catch (error) {
2093
+ abandon();
2094
+ this.options.logger?.debug?.("Web Monitor destination refresh failed before delivery.", {
2095
+ threadId: input.threadId,
2096
+ monitorId: input.monitor.monitorId,
2097
+ error: errorMessage(error),
2098
+ });
2099
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
2100
+ }
2101
+ connection = this.connections.get(input.sourceId);
2102
+ }
2103
+ if (this.stopped || connection === undefined) {
2104
+ abandon();
2105
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
2106
+ }
2107
+ const destination = this.store.getThread(input.threadId);
2108
+ if (destination === undefined
2109
+ || destination.sourceId !== input.sourceId
2110
+ || destination.archivedAt !== null
2111
+ || destination.trigger !== undefined) {
2112
+ abandon();
2113
+ return { delivered: false, code: "monitor_origin_mismatch", retryable: false };
2114
+ }
2115
+ const active = this.activeTurns.get(input.threadId);
2116
+ if (active !== undefined
2117
+ && connection.info.supportsLiveInput
2118
+ && input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
2119
+ try {
2120
+ this.store.setMonitorWakeSteeringTurn(input.sourceId, input.deliveryKey, active.turnId, true);
2121
+ const settlement = await active.client.liveInput({
2122
+ conversationId: `web:${input.threadId}`,
2123
+ id: input.deliveryKey,
2124
+ text: input.wakePrompt,
2125
+ receivedAt: new Date().toISOString(),
2126
+ deliveryKey: input.deliveryKey,
2127
+ signal: AbortSignal.timeout(10 * 60 * 1_000),
2128
+ });
2129
+ if (settlement.status === "applied") {
2130
+ const message = this.store.completeMonitorWake({
2131
+ sourceId: input.sourceId,
2132
+ monitorId: input.monitor.monitorId,
2133
+ deliveryKey: input.deliveryKey,
2134
+ disposition: "steered",
2135
+ turnId: active.turnId,
2136
+ });
2137
+ if (message !== undefined) {
2138
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
2139
+ }
2140
+ return { delivered: true, disposition: "steered" };
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);
2151
+ }
2152
+ catch (error) {
2153
+ this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
2154
+ threadId: input.threadId,
2155
+ monitorId: input.monitor.monitorId,
2156
+ error: errorMessage(error),
2157
+ });
2158
+ return {
2159
+ delivered: false,
2160
+ code: "monitor_wake_ambiguous",
2161
+ retryable: false,
2162
+ ambiguous: true,
2163
+ };
2164
+ }
2165
+ }
2166
+ if (active !== undefined)
2167
+ await active.completion;
2168
+ if (this.stopped) {
2169
+ abandon();
2170
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
2171
+ }
2172
+ const refreshedConnection = this.connections.get(input.sourceId);
2173
+ if (refreshedConnection === undefined) {
2174
+ abandon();
2175
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1034
2176
  }
1035
- if (this.activeProcessJobWakes.get(activeKey) === delivery) {
1036
- this.activeProcessJobWakes.delete(activeKey);
2177
+ let started;
2178
+ try {
2179
+ const selection = this.resolveTurnSelection(input.threadId);
2180
+ started = this.store.beginAssistantTurn({
2181
+ threadId: input.threadId,
2182
+ prompt: input.wakePrompt,
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 }),
2188
+ });
1037
2189
  }
1038
- this.releaseProcessJobWakeReservation(input.threadId);
2190
+ catch (error) {
2191
+ abandon();
2192
+ return {
2193
+ delivered: false,
2194
+ code: errorCode(error) ?? "monitor_wake_failed",
2195
+ retryable: false,
2196
+ };
2197
+ }
2198
+ const { completion } = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
2199
+ this.emit("message.changed", input.threadId, {
2200
+ messageId: started.assistantMessageId,
2201
+ updatedAt: started.thread.updatedAt,
2202
+ });
2203
+ this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
2204
+ this.emitThread("threads.changed", { thread: started.thread });
2205
+ await completion;
2206
+ if (this.store.turnStatus(started.turnId) !== "complete") {
2207
+ return {
2208
+ delivered: false,
2209
+ code: "monitor_wake_failed",
2210
+ retryable: false,
2211
+ ambiguous: true,
2212
+ };
2213
+ }
2214
+ const message = this.store.completeMonitorWake({
2215
+ sourceId: input.sourceId,
2216
+ monitorId: input.monitor.monitorId,
2217
+ deliveryKey: input.deliveryKey,
2218
+ disposition: "follow_up",
2219
+ turnId: started.turnId,
2220
+ });
2221
+ if (message !== undefined) {
2222
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
2223
+ }
2224
+ return { delivered: true, disposition: "follow_up" };
2225
+ });
2226
+ const tail = delivery.then(() => undefined, () => undefined);
2227
+ this.hostWakeTails.set(input.threadId, tail);
2228
+ this.activeHostWakes.set(activeKey, delivery);
2229
+ try {
2230
+ return { receipt: await delivery, duplicate: false };
2231
+ }
2232
+ finally {
2233
+ if (this.hostWakeTails.get(input.threadId) === tail)
2234
+ this.hostWakeTails.delete(input.threadId);
2235
+ if (this.activeHostWakes.get(activeKey) === delivery)
2236
+ this.activeHostWakes.delete(activeKey);
2237
+ this.releaseHostWakeReservation(input.threadId);
1039
2238
  }
1040
2239
  }
1041
- retainProcessJobWakeReservation(threadId) {
1042
- this.processJobWakeReservations.set(threadId, (this.processJobWakeReservations.get(threadId) ?? 0) + 1);
2240
+ retainHostWakeReservation(threadId) {
2241
+ this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
1043
2242
  }
1044
- releaseProcessJobWakeReservation(threadId) {
1045
- const remaining = (this.processJobWakeReservations.get(threadId) ?? 1) - 1;
2243
+ releaseHostWakeReservation(threadId) {
2244
+ const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
1046
2245
  if (remaining > 0)
1047
- this.processJobWakeReservations.set(threadId, remaining);
2246
+ this.hostWakeReservations.set(threadId, remaining);
1048
2247
  else
1049
- this.processJobWakeReservations.delete(threadId);
2248
+ this.hostWakeReservations.delete(threadId);
1050
2249
  if (!this.stopped)
1051
2250
  void this.drainQueuedLiveInputs(threadId);
1052
2251
  }
@@ -1069,8 +2268,19 @@ export class WebService {
1069
2268
  return completed;
1070
2269
  throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
1071
2270
  }
1072
- this.emit("threads.changed", completed.thread.id, { thread: completed.thread });
1073
- 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 });
1074
2284
  if (!completed.duplicate) {
1075
2285
  this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
1076
2286
  }
@@ -1101,28 +2311,49 @@ export class WebService {
1101
2311
  }
1102
2312
  catch (error) {
1103
2313
  this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
1104
- const changed = this.store.replaceAgents([]);
2314
+ const changed = this.store.markDiscoveredAgentsOffline();
1105
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();
1106
2324
  if (changed)
1107
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
2325
+ this.emit("agents.changed");
1108
2326
  return;
1109
2327
  }
1110
2328
  const nextConnections = new Map();
2329
+ // What the cache is allowed to survive: the same process, at the same
2330
+ // endpoint, since the same start. Anything else is a new generation whose
2331
+ // catalog the previous one cannot speak for.
2332
+ const generations = new Map(discovered.map((agent) => [
2333
+ agent.source.sourceId,
2334
+ agentGeneration(agent),
2335
+ ]));
2336
+ this.reconcileModelCatalogCache(generations);
1111
2337
  const summaries = await Promise.all(discovered.map(async (agent) => {
2338
+ const generation = generations.get(agent.source.sourceId);
1112
2339
  if (agent.baseUrl === undefined)
1113
- return offlineSummary(agent);
2340
+ return offlineSummary(agent, generation);
1114
2341
  const client = new OperatorClient({
1115
2342
  baseUrl: agent.baseUrl,
1116
2343
  ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
1117
2344
  ...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
2345
+ ...(agent.monitorsBearer === undefined ? {} : { monitorsBearer: agent.monitorsBearer }),
1118
2346
  ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
1119
2347
  });
1120
2348
  try {
1121
2349
  const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1122
- nextConnections.set(agent.source.sourceId, { client, info });
2350
+ nextConnections.set(agent.source.sourceId, { client, info, generation });
2351
+ this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
2352
+ await this.restorePersistedModelAdmission(client, agent.source.sourceId, generation, info.providers, signal);
1123
2353
  const efforts = collectEfforts(info);
1124
2354
  return {
1125
2355
  sourceId: agent.source.sourceId,
2356
+ generation,
1126
2357
  label: info.label ?? agent.source.label,
1127
2358
  status: agent.source.health === "running" ? "online" : "degraded",
1128
2359
  pinned: false,
@@ -1133,6 +2364,8 @@ export class WebService {
1133
2364
  ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
1134
2365
  ...(efforts.length === 0 ? {} : { efforts }),
1135
2366
  ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
2367
+ runSettings: configRunSettings(info.model, info.effort),
2368
+ ...(info.providers === undefined ? {} : { providers: info.providers }),
1136
2369
  ...(info.cron === undefined ? {} : { cron: info.cron }),
1137
2370
  ...(info.supportsAskById ? { supportsAskById: true } : {}),
1138
2371
  updatedAt: agent.source.updatedAt,
@@ -1143,11 +2376,21 @@ export class WebService {
1143
2376
  sourceId: agent.source.sourceId,
1144
2377
  error: errorMessage(error),
1145
2378
  });
1146
- return offlineSummary(agent);
2379
+ return offlineSummary(agent, generation);
1147
2380
  }
1148
2381
  }));
1149
2382
  this.connections = nextConnections;
1150
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);
1151
2394
  const cronChangedSources = new Set();
1152
2395
  await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
1153
2396
  if (connection.info.cron?.read !== true)
@@ -1165,8 +2408,14 @@ export class WebService {
1165
2408
  });
1166
2409
  }
1167
2410
  }));
1168
- if (agentsChanged)
1169
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
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)
2418
+ this.emit("agents.changed");
1170
2419
  for (const sourceId of cronChangedSources)
1171
2420
  this.emit("cron.changed", undefined, { sourceId });
1172
2421
  if (cronChangedSources.size > 0)
@@ -1175,6 +2424,34 @@ export class WebService {
1175
2424
  void this.drainQueuedLiveInputs(threadId);
1176
2425
  }
1177
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
+ }
1178
2455
  startTimers() {
1179
2456
  const discoveryInterval = this.options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
1180
2457
  const purgeInterval = this.options.purgeIntervalMs ?? DEFAULT_PURGE_INTERVAL_MS;
@@ -1213,6 +2490,117 @@ export class WebService {
1213
2490
  this.options.logger?.info?.("Purged orphaned web uploads.", { count, partialCount, unreferencedCount });
1214
2491
  }
1215
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
+ }
1216
2604
  emit(type, threadId, payload) {
1217
2605
  if (this.stopped)
1218
2606
  return;
@@ -1240,7 +2628,7 @@ export class WebService {
1240
2628
  }
1241
2629
  observeAskUserFrame(threadId, turnId, frame) {
1242
2630
  if (this.stopped || frame.kind !== "event" || frame.event.type !== "tool_call_started"
1243
- || toolNameLeaf(frame.event.name).toLowerCase().replace(/[^a-z0-9]+/gu, "") !== "askuser")
2631
+ || !isAskUserToolName(frame.event.name))
1244
2632
  return;
1245
2633
  const key = `${threadId}\0${turnId}`;
1246
2634
  if (this.askWatches.has(key))
@@ -1263,8 +2651,8 @@ export class WebService {
1263
2651
  const thread = this.store.applyAgentTitle(threadId, title);
1264
2652
  if (thread === undefined)
1265
2653
  return;
1266
- this.emit("thread.changed", threadId, { revision: thread.revision });
1267
- this.emit("threads.changed", threadId, { thread });
2654
+ this.emitThread("thread.changed", { thread });
2655
+ this.emitThread("threads.changed", { thread });
1268
2656
  }
1269
2657
  catch (error) {
1270
2658
  this.options.logger?.warn?.("Agent conversation-title update failed; the turn is continuing.", {
@@ -1322,7 +2710,10 @@ export class WebService {
1322
2710
  sourceId: thread.sourceId,
1323
2711
  title: `${agent?.label ?? "mono-agent"} needs input`,
1324
2712
  body: `${question.header}: ${question.question}`,
1325
- 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(),
1326
2717
  });
1327
2718
  if (event !== undefined)
1328
2719
  this.announcePushEvent(event.logicalKey);
@@ -1344,7 +2735,8 @@ export class WebService {
1344
2735
  : await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
1345
2736
  return snapshot?.interactionId === interactionId
1346
2737
  && snapshot.status === "pending"
1347
- && new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime()
2738
+ && (snapshot.expiresAt === null
2739
+ || new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime())
1348
2740
  ? "current"
1349
2741
  : "stale";
1350
2742
  }
@@ -1365,6 +2757,245 @@ export class WebService {
1365
2757
  }
1366
2758
  return connection;
1367
2759
  }
2760
+ /**
2761
+ * Tier-2 admission: the catalog cache, seeded from `modelOptions` keys and
2762
+ * appended to by every proxied `/v1/models` page.
2763
+ *
2764
+ * Both feeds cross an await between deciding what to admit and admitting it,
2765
+ * so both name the generation they read. A write whose generation is no
2766
+ * longer the one on file is dropped outright rather than filed under the
2767
+ * successor: it describes a process that has been replaced, and admitting it
2768
+ * is exactly the cross-generation contamination `reconcileModelCatalogCache`
2769
+ * exists to prevent. Nothing is created here either -- an entry exists for
2770
+ * every discovered source from the moment a refresh reconciles it, and a
2771
+ * source with no entry is one discovery has dropped.
2772
+ *
2773
+ * @returns whether the refs were admitted.
2774
+ */
2775
+ admitCatalogRefs(sourceId, generation, refs) {
2776
+ if (generation === undefined)
2777
+ return false;
2778
+ const entry = this.modelCatalogCache.get(sourceId);
2779
+ if (entry === undefined || entry.generation !== generation)
2780
+ return false;
2781
+ for (const [ref, record] of refs)
2782
+ this.admitModelRef(entry.models, ref, record);
2783
+ return true;
2784
+ }
2785
+ /**
2786
+ * Bind the cache to the agent process that filled it, and to nothing else.
2787
+ * A source id outlives the process behind it: reconfigure an agent and
2788
+ * restart it and the next generation advertises a different catalog under
2789
+ * the same id. Keyed by id alone, generation 1's ladder judged generation
2790
+ * 2's turns and rejected grades the running agent accepts, with no way to
2791
+ * clear it short of restarting the console.
2792
+ *
2793
+ * The single place the cache is scoped, so there is one answer to "whose
2794
+ * catalog is this". Runs before the per-agent probes of a refresh, so a seed
2795
+ * or a proxied page during that refresh files under the current generation.
2796
+ * Sources discovery no longer reports are dropped outright, or a retired
2797
+ * agent's refs would accumulate for the life of the process.
2798
+ */
2799
+ reconcileModelCatalogCache(generations) {
2800
+ for (const sourceId of [...this.modelCatalogCache.keys()]) {
2801
+ if (!generations.has(sourceId))
2802
+ this.modelCatalogCache.delete(sourceId);
2803
+ }
2804
+ for (const [sourceId, generation] of generations) {
2805
+ if (this.modelCatalogCache.get(sourceId)?.generation === generation)
2806
+ continue;
2807
+ this.modelCatalogCache.set(sourceId, { generation, models: new Map() });
2808
+ }
2809
+ }
2810
+ seedModelCatalogFromOptions(sourceId, generation, modelOptions) {
2811
+ if (modelOptions === undefined)
2812
+ return;
2813
+ // `modelOptions` stays the authority for the refs it names, so the seed
2814
+ // only records admission; recording a ladder here would shadow it.
2815
+ this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
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
+ }
2916
+ admitModelRef(entries, ref, record) {
2917
+ const known = entries.get(ref);
2918
+ if (known !== undefined) {
2919
+ // A page is the live word on what it serves, so its metadata replaces
2920
+ // whatever is held -- including replacing a ladder with silence, which
2921
+ // is how a re-fetched catalog heals a model whose grades changed. The
2922
+ // shortlist seed only records admission and must never shadow a page.
2923
+ // Re-setting an existing key leaves its eviction position alone.
2924
+ if (record.source === "page")
2925
+ entries.set(ref, record);
2926
+ return;
2927
+ }
2928
+ entries.set(ref, record);
2929
+ if (entries.size > MODEL_CATALOG_CACHE_CAP) {
2930
+ const oldest = entries.keys().next().value;
2931
+ if (oldest !== undefined)
2932
+ entries.delete(oldest);
2933
+ }
2934
+ }
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))) {
2965
+ throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
2966
+ }
2967
+ // Both ends resolve a blank selection to the same route -- the browser fell
2968
+ // back to the first shortlist entry while this stopped at `defaultModel`,
2969
+ // so any `/v1/info` omitting `model` had the picker offering one ladder and
2970
+ // this rejecting from another.
2971
+ const effectiveModel = effectiveModelForAgent(agent, model);
2972
+ // `modelOptions` only ever covers the configured shortlist, so a model
2973
+ // reached through the provider catalog has no entry there. `effort-ladder`
2974
+ // holds the tiering, and the browser runs the exact same function, so the
2975
+ // picker cannot offer a grade this rejects or hide one it accepts.
2976
+ const cached = effectiveModel === undefined
2977
+ ? undefined
2978
+ : this.modelCatalogCache.get(sourceId)?.models.get(effectiveModel);
2979
+ const allowedEfforts = effortLevelsForModel(agent, effectiveModel, cached?.efforts);
2980
+ if (effort !== undefined && !allowedEfforts.includes(effort)) {
2981
+ throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
2982
+ }
2983
+ }
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) {
2992
+ // Tier 1: the configured-route shortlist — unchanged, always allowed.
2993
+ if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
2994
+ return true;
2995
+ // Tier 2: a model reached only through the catalog cache.
2996
+ const cached = this.modelCatalogCache.get(sourceId);
2997
+ return cached !== undefined && cached.models.has(model);
2998
+ }
1368
2999
  authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
1369
3000
  const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
1370
3001
  if (access === "invalid") {
@@ -1375,7 +3006,11 @@ export class WebService {
1375
3006
  if (access === "expired") {
1376
3007
  throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
1377
3008
  }
1378
- 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)) };
1379
3014
  }
1380
3015
  requireReplyPart(threadId, messageId, partId, type) {
1381
3016
  const thread = this.store.getThread(threadId);
@@ -1396,20 +3031,79 @@ export class WebService {
1396
3031
  throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
1397
3032
  }
1398
3033
  }
1399
- decorateThreadDetail(detail) {
3034
+ decorateThreadDetail(detail, options = {}) {
1400
3035
  // Backfill: messages that predate this feature, and any turn whose own
1401
3036
  // attempt failed or was interrupted. Idempotent and guarded, so repeated
1402
3037
  // reads of the same thread fetch each image at most once.
1403
3038
  void this.persistReplyImages(detail.thread.id, detail.messages);
1404
- return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
3039
+ return { ...detail, messages: this.shapeMessages(detail.messages, options) };
1405
3040
  }
1406
- decorateMessage(message) {
1407
- const parts = message.parts.map((part) => {
1408
- if (part.type !== "attachment" && part.type !== "mcp_app")
1409
- return part;
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)) };
3080
+ }
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")
1410
3091
  return this.decorateReplyPart(message, part);
1411
- });
1412
- 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;
1413
3107
  }
1414
3108
  /**
1415
3109
  * Keeps the console's own copy of an image the agent published.
@@ -1524,7 +3218,10 @@ export class WebService {
1524
3218
  const retentionDeadline = part.expiresAt === undefined
1525
3219
  ? Number.POSITIVE_INFINITY
1526
3220
  : Date.parse(part.expiresAt);
1527
- 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);
1528
3225
  if (!Number.isFinite(expiresAt) || expiresAt <= now) {
1529
3226
  return part.type === "attachment" ? { ...part, ...storedUrl } : part;
1530
3227
  }
@@ -1616,10 +3313,15 @@ function abortableDelay(delayMs, signal) {
1616
3313
  });
1617
3314
  }
1618
3315
  function isFuturePendingAsk(snapshot, now) {
3316
+ if (snapshot.status !== "pending")
3317
+ return false;
3318
+ if (snapshot.expiresAt === null)
3319
+ return true;
1619
3320
  const expiresAt = new Date(snapshot.expiresAt).getTime();
1620
- return snapshot.status === "pending" && Number.isFinite(expiresAt) && expiresAt > now.getTime();
3321
+ return Number.isFinite(expiresAt) && expiresAt > now.getTime();
1621
3322
  }
1622
3323
  const STREAM_FLUSH_INTERVAL_MS = 50;
3324
+ const ASK_PUSH_DELIVERY_TTL_MS = 24 * 60 * 60 * 1_000;
1623
3325
  class WebTurnCancellation extends Error {
1624
3326
  kind;
1625
3327
  constructor(kind, message) {
@@ -1745,17 +3447,77 @@ export class WeightedTurnBudget {
1745
3447
  }
1746
3448
  }
1747
3449
  }
1748
- function offlineSummary(agent) {
3450
+ /**
3451
+ * The identity of the agent PROCESS behind a source id. `sourceId` is stable
3452
+ * across restarts by design, so it cannot scope anything the running process
3453
+ * told us: a reconfigured agent restarts at a new endpoint, with a new pid and
3454
+ * a new `startedAt`, and advertises a different catalog under the same id.
3455
+ * Deliberately excludes `updatedAt`, which every heartbeat moves.
3456
+ *
3457
+ * This is what the model catalog cache is scoped to -- and, since the browser
3458
+ * caches the same `/v1/models` pages and had nothing generation-shaped to
3459
+ * watch, what `WebAgentSummary.generation` carries to it.
3460
+ *
3461
+ * Hashed because it now goes on the wire: the raw form names the agent's
3462
+ * operator endpoint and pid, and the console has no reason to hand those to a
3463
+ * page. The token only has to be stable while one process lives and different
3464
+ * once it is replaced, which a digest of those three fields is.
3465
+ *
3466
+ * Length-prefixed rather than `|`-joined. A separator that can occur inside a
3467
+ * field is not a separator: two different accepted tuples whose parts happen to
3468
+ * contain the delimiter flatten to the same string and hash to the same token,
3469
+ * and two distinct processes sharing a generation is precisely the state the
3470
+ * token exists to make impossible. Nothing first-party produces such a tuple
3471
+ * today, which is why this is robustness rather than a live defect --- but a
3472
+ * digest whose only defence is what its inputs happen to look like is one
3473
+ * unrelated change away from being wrong.
3474
+ *
3475
+ * Hashed as UTF-16 code units for the same reason the prefix replaced the
3476
+ * delimiter. UTF-8 has no encoding for an unpaired surrogate, so a lone high
3477
+ * surrogate and a lone low surrogate both became the replacement character and
3478
+ * two different one-character fields -- identically length-prefixed -- hashed
3479
+ * alike. `utf16le` is a lossless transcription of exactly the code units the
3480
+ * length prefix counts, so what is hashed is what was measured.
3481
+ */
3482
+ export function agentGeneration(agent) {
3483
+ const parts = [
3484
+ agent.baseUrl ?? "",
3485
+ String(agent.source.pid ?? ""),
3486
+ agent.source.startedAt,
3487
+ ];
3488
+ return createHash("sha256")
3489
+ .update(parts.map((part) => `${String(part.length)}:${part}`).join(""), "utf16le")
3490
+ .digest("hex")
3491
+ .slice(0, 16);
3492
+ }
3493
+ function offlineSummary(agent, generation) {
1749
3494
  return {
1750
3495
  sourceId: agent.source.sourceId,
3496
+ generation,
1751
3497
  label: agent.source.label,
1752
3498
  status: "offline",
1753
3499
  pinned: false,
1754
3500
  health: agent.source.health,
1755
3501
  supportsAttachments: false,
3502
+ runSettings: configRunSettings(),
1756
3503
  updatedAt: agent.source.updatedAt,
1757
3504
  };
1758
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
+ }
1759
3521
  function collectEfforts(info) {
1760
3522
  // Older operator schemas do not advertise per-model metadata. Match the TUI
1761
3523
  // picker in that case: cloud/unknown models use the canonical global effort
@@ -1763,32 +3525,25 @@ function collectEfforts(info) {
1763
3525
  if (info.modelOptions === undefined)
1764
3526
  return EFFORT_LEVELS;
1765
3527
  const models = info.models ?? (info.model === undefined ? [] : [info.model]);
1766
- return [...new Set(models.flatMap((model) => effortLevelsForOption(info.modelOptions?.[model])))];
3528
+ // Same rule as every other effort decision, so the union an agent advertises
3529
+ // cannot disagree with what a turn on one of those models may carry.
3530
+ return [...new Set(models.flatMap((model) => effortLevelsForModel(info, model, undefined)))];
1767
3531
  }
1768
- function validateModelAndEffort(agent, model, effort) {
1769
- if (model !== undefined
1770
- && (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
1771
- throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
1772
- }
1773
- const effectiveModel = model ?? agent.defaultModel;
1774
- const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
1775
- const allowedEfforts = agent.modelOptions === undefined
1776
- ? agent.efforts
1777
- : effortLevelsForOption(option);
1778
- if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
1779
- throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
1780
- }
1781
- }
1782
- function effortLevelsForOption(option) {
1783
- if (option === undefined
1784
- || option.reasoning === false
1785
- || option.reasoningMode === "none"
1786
- || option.effortLevels?.length === 0) {
1787
- return [];
1788
- }
1789
- if (option.reasoningMode === "toggle")
1790
- return ["high", "none"];
1791
- return option.effortLevels ?? [];
3532
+ /**
3533
+ * Tier 3: the syntactic floor. `@mono-agent/web` may not import pi-ai, so this
3534
+ * cannot be authoritative -- the agent is. It exists to reject obvious garbage,
3535
+ * and it must not be looser than the runtime parser, or a reference the console
3536
+ * accepts is silently ignored and the turn runs on the default model instead.
3537
+ */
3538
+ function modelPassesSyntacticFloor(model) {
3539
+ const separator = model.indexOf(":");
3540
+ if (separator <= 0 || separator >= model.length - 1)
3541
+ return false;
3542
+ const provider = model.slice(0, separator);
3543
+ const rest = model.slice(separator + 1);
3544
+ // Provider ids are lowercase kebab/alphanumeric; the model half may carry
3545
+ // further colons and slashes but must not be blank or padded.
3546
+ return /^[a-z0-9][a-z0-9-]*$/u.test(provider) && rest.trim() === rest && rest.trim().length > 0;
1792
3547
  }
1793
3548
  function normalizeFilename(value) {
1794
3549
  const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";