@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/server.js CHANGED
@@ -4,15 +4,17 @@ import { chmod, open, readFile, rename, unlink } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { isIP } from "node:net";
6
6
  import { hostname as systemHostname } from "node:os";
7
- import { dirname, resolve } from "node:path";
7
+ import { dirname, join, resolve } from "node:path";
8
8
  import { Readable } from "node:stream";
9
9
  import { pipeline } from "node:stream/promises";
10
10
  import { fileURLToPath } from "node:url";
11
- import { AGENT_LIVE_INPUT_MAX_CHARACTERS, closeServerBounded, hostForUrl, listen, normalizeHostForBind, } from "@mono-agent/agent-contracts";
11
+ import { constants as zlibConstants } from "node:zlib";
12
+ import { AGENT_LIVE_INPUT_MAX_CHARACTERS, closeServerBounded, hostForUrl, listen, MAX_PROVIDER_AUTH_BODY_BYTES, normalizeHostForBind, parseProviderAuthSessionInput, parseProviderAuthSessionStartInput, parseProviderAuthCheckStartInput, } from "@mono-agent/agent-contracts";
13
+ import compression from "compression";
12
14
  import express, {} from "express";
13
- import { DEFAULT_WEB_THEME, WEB_API_VERSION, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_THEMES, } from "./contracts.js";
15
+ import { DEFAULT_WEB_THEME, WEB_API_VERSION, WEB_CONSOLE_NAME_MAX_CHARACTERS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_THEMES, } from "./contracts.js";
14
16
  import { errorMessage, WebConsoleError } from "./errors.js";
15
- import { WEB_THREAD_SEARCH_MAX } from "./store.js";
17
+ import { WEB_MESSAGE_PAGE_DEFAULT, WEB_MESSAGE_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT, WEB_THREAD_PAGE_MAX, WEB_THREAD_SEARCH_MAX, } from "./store.js";
16
18
  import { MCP_APP_PROXY_CONTENT_SECURITY_POLICY, MCP_APP_PROXY_DOCUMENT, MCP_APP_PROXY_PATH, } from "./mcp-app-proxy.js";
17
19
  import { startWebNotificationIngress, } from "./notification-ingress.js";
18
20
  import { WebService } from "./service.js";
@@ -20,20 +22,49 @@ export const DEFAULT_WEB_HOST = "0.0.0.0";
20
22
  export const DEFAULT_WEB_PORT = 5050;
21
23
  const HEARTBEAT_INTERVAL_MS = 15_000;
22
24
  const MAX_SSE_CLIENTS = 64;
25
+ /**
26
+ * How often one connection may be told that one conversation's message moved,
27
+ * when it is not subscribed to that conversation's content.
28
+ *
29
+ * A hint costs its reader a re-read, and a streaming turn produces one write
30
+ * every 50 ms. A console that is not looking at the conversation has no reason
31
+ * to follow it at that rate -- it needs the sidebar row to be right, which the
32
+ * conversation events already carry.
33
+ */
34
+ const DELTA_HINT_INTERVAL_MS = 1_000;
35
+ /** Bound on `?thread=`, matching the other bounded query strings. */
36
+ const MAX_SUBSCRIPTION_LENGTH = 512;
37
+ /**
38
+ * How many conversations one connection remembers having hinted about.
39
+ *
40
+ * A hard bound, evicting the least recently hinted: a burst wider than this
41
+ * inside one second would otherwise grow the map faster than the window can
42
+ * retire it. Evicting an entry that is still inside its window costs one extra
43
+ * hint for that conversation; an unbounded map on a server that runs for weeks
44
+ * costs the server.
45
+ */
46
+ const MAX_THROTTLED_CONVERSATIONS = 256;
23
47
  const MAX_MCP_APP_BRIDGE_REQUEST_BYTES = 64 * 1024;
24
- const WEB_THEME_CHROME = {
25
- evergreen: { light: "#eeefeb", dark: "#0f1110" },
26
- ocean: { light: "#edf1f4", dark: "#0d1115" },
27
- plum: { light: "#f2eef3", dark: "#120f14" },
28
- terracotta: { light: "#f4efec", dark: "#130f0d" },
48
+ /**
49
+ * Content-addressed build output and write-once upload bytes never change under
50
+ * their URL, so the browser may hold them for a year and skip the request.
51
+ */
52
+ const IMMUTABLE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
53
+ const WEB_THEME_MANIFEST_COLORS = {
54
+ evergreen: { themeColor: "#191c1a", backgroundColor: "#0f1110" },
55
+ ocean: { themeColor: "#191c1a", backgroundColor: "#0d1115" },
56
+ plum: { themeColor: "#191c1a", backgroundColor: "#120f14" },
57
+ terracotta: { themeColor: "#191c1a", backgroundColor: "#130f0d" },
29
58
  };
30
59
  export async function startWebServer(options = {}) {
31
60
  const host = normalizeHostForBind(options.host ?? DEFAULT_WEB_HOST);
32
61
  const port = normalizePort(options.port ?? DEFAULT_WEB_PORT);
33
62
  const staticDir = options.staticDir ?? defaultStaticDir();
34
63
  const theme = resolveWebTheme(options.theme);
64
+ const hostName = systemHostname().trim() || "localhost";
35
65
  const consoleIdentity = {
36
- hostName: systemHostname().trim() || "localhost",
66
+ hostName,
67
+ displayName: resolveWebConsoleName(options.name) ?? hostName,
37
68
  theme,
38
69
  };
39
70
  const webManifest = await loadWebManifest(staticDir, consoleIdentity);
@@ -52,11 +83,30 @@ export async function startWebServer(options = {}) {
52
83
  let notificationIngress;
53
84
  let stopPromise;
54
85
  app.disable("x-powered-by");
86
+ // Mounted first so every response body, error JSON included, is negotiated.
87
+ // Brotli quality 4 keeps a phone-sized payload under a few milliseconds of
88
+ // CPU; the default filter already declines anything the response marked
89
+ // `no-transform` (SSE, attachment bytes) or that is not compressible.
90
+ app.use(compression({
91
+ threshold: 1024,
92
+ level: 6,
93
+ brotli: { params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } },
94
+ }));
55
95
  app.use(securityHeaders);
56
96
  app.use(validateLocalRequest(host, allowedHosts));
97
+ // Console state is private to this operator but not secret from their own
98
+ // browser: `no-cache` still forces revalidation on every read, and lets
99
+ // Express answer an unchanged payload with a 304 instead of resending it.
57
100
  app.use("/api", (_req, res, next) => {
58
- res.setHeader("Cache-Control", "no-store");
59
- res.setHeader("Pragma", "no-cache");
101
+ res.setHeader("Cache-Control", "private, no-cache");
102
+ next();
103
+ });
104
+ app.use("/api/v1/threads/:id/submissions", (_req, res, next) => {
105
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
106
+ next();
107
+ });
108
+ app.use("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId/reply-threads", (_req, res, next) => {
109
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
60
110
  next();
61
111
  });
62
112
  app.use("/api/v1", express.json({ limit: "256kb", strict: true }));
@@ -67,10 +117,26 @@ export async function startWebServer(options = {}) {
67
117
  push: service.webPushDegraded() ? "degraded" : "ok",
68
118
  });
69
119
  });
70
- app.get("/api/v1/bootstrap", (_req, res, next) => {
71
- void service.bootstrap()
72
- .then((bootstrap) => res.status(200).json({ ...bootstrap, console: consoleIdentity }))
73
- .catch(next);
120
+ app.get("/api/v1/bootstrap", (req, res, next) => {
121
+ try {
122
+ // One bucket, not every agent's conversations. An unknown or absent
123
+ // `sourceId` falls back inside the service rather than failing: the
124
+ // first request a fresh console makes has no selection to name yet.
125
+ // An empty `sourceId` is a console that has not resolved an agent yet,
126
+ // which is the same thing as omitting it -- not a malformed request.
127
+ const requested = req.query.sourceId === "" ? undefined : req.query.sourceId;
128
+ const sourceId = optionalQueryString(requested, 512);
129
+ void service.bootstrap({
130
+ ...(sourceId === undefined ? {} : { sourceId }),
131
+ archived: optionalArchivedQuery(req.query.archived) ?? false,
132
+ limit: boundedQueryLimit(req.query.limit, WEB_THREAD_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT),
133
+ })
134
+ .then((bootstrap) => res.status(200).json({ ...bootstrap, console: consoleIdentity }))
135
+ .catch(next);
136
+ }
137
+ catch (error) {
138
+ next(error);
139
+ }
74
140
  });
75
141
  app.get(MCP_APP_PROXY_PATH, (_req, res) => {
76
142
  setMcpAppProxyHeaders(res);
@@ -91,6 +157,115 @@ export async function startWebServer(options = {}) {
91
157
  next(error);
92
158
  }
93
159
  });
160
+ app.put("/api/v1/agents/:id/run-defaults", (req, res, next) => {
161
+ try {
162
+ const input = parsePutAgentRunSettings(req.body);
163
+ res.status(200).json({ agent: service.setAgentRunDefaults(pathParam(req.params.id), input) });
164
+ }
165
+ catch (error) {
166
+ next(error);
167
+ }
168
+ });
169
+ app.delete("/api/v1/agents/:id/run-defaults", (req, res, next) => {
170
+ try {
171
+ res.status(200).json({ agent: service.clearAgentRunDefaults(pathParam(req.params.id)) });
172
+ }
173
+ catch (error) {
174
+ next(error);
175
+ }
176
+ });
177
+ const sendProviderAuth = (res, status, body) => {
178
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
179
+ if (status === 204)
180
+ res.status(status).end();
181
+ else
182
+ res.status(status).json(body);
183
+ };
184
+ app.use("/api/v1/agents/:id/provider-auth", (_req, res, next) => {
185
+ res.setHeader("Cache-Control", "private, no-store, max-age=0");
186
+ next();
187
+ });
188
+ app.get("/api/v1/agents/:id/provider-auth", (req, res, next) => {
189
+ try {
190
+ exactRequestOrigin(req);
191
+ void trackOperation(service.providerAuthStatus(pathParam(req.params.id)), activeOperations)
192
+ .then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
193
+ }
194
+ catch (error) {
195
+ next(error);
196
+ }
197
+ });
198
+ app.post("/api/v1/agents/:id/provider-auth/sessions", (req, res, next) => {
199
+ try {
200
+ exactRequestOrigin(req);
201
+ assertProviderAuthBody(req.body);
202
+ const input = parseProviderAuthSessionStartInput(req.body);
203
+ void trackOperation(service.startProviderAuth(pathParam(req.params.id), input), activeOperations)
204
+ .then((snapshot) => sendProviderAuth(res, 201, snapshot)).catch(next);
205
+ }
206
+ catch (error) {
207
+ next(error);
208
+ }
209
+ });
210
+ app.get("/api/v1/agents/:id/provider-auth/sessions/:sessionId", (req, res, next) => {
211
+ try {
212
+ exactRequestOrigin(req);
213
+ void trackOperation(service.providerAuthSession(pathParam(req.params.id), pathParam(req.params.sessionId)), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
214
+ }
215
+ catch (error) {
216
+ next(error);
217
+ }
218
+ });
219
+ app.post("/api/v1/agents/:id/provider-auth/sessions/:sessionId/input", (req, res, next) => {
220
+ try {
221
+ exactRequestOrigin(req);
222
+ assertProviderAuthBody(req.body);
223
+ const input = parseProviderAuthSessionInput(req.body);
224
+ void trackOperation(service.submitProviderAuth(pathParam(req.params.id), pathParam(req.params.sessionId), input), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
225
+ }
226
+ catch (error) {
227
+ next(error);
228
+ }
229
+ });
230
+ app.delete("/api/v1/agents/:id/provider-auth/sessions/:sessionId", (req, res, next) => {
231
+ try {
232
+ exactRequestOrigin(req);
233
+ void trackOperation(service.cancelProviderAuth(pathParam(req.params.id), pathParam(req.params.sessionId)), activeOperations).then(() => sendProviderAuth(res, 204)).catch(next);
234
+ }
235
+ catch (error) {
236
+ next(error);
237
+ }
238
+ });
239
+ app.post("/api/v1/agents/:id/provider-auth/checks", (req, res, next) => {
240
+ try {
241
+ exactRequestOrigin(req);
242
+ assertProviderAuthBody(req.body);
243
+ const input = parseProviderAuthCheckStartInput(req.body);
244
+ void trackOperation(service.startProviderAuthCheck(pathParam(req.params.id), input), activeOperations)
245
+ .then((snapshot) => sendProviderAuth(res, 201, snapshot)).catch(next);
246
+ }
247
+ catch (error) {
248
+ next(error);
249
+ }
250
+ });
251
+ app.get("/api/v1/agents/:id/provider-auth/checks/:checkId", (req, res, next) => {
252
+ try {
253
+ exactRequestOrigin(req);
254
+ void trackOperation(service.providerAuthCheck(pathParam(req.params.id), pathParam(req.params.checkId)), activeOperations).then((snapshot) => sendProviderAuth(res, 200, snapshot)).catch(next);
255
+ }
256
+ catch (error) {
257
+ next(error);
258
+ }
259
+ });
260
+ app.delete("/api/v1/agents/:id/provider-auth/checks/:checkId", (req, res, next) => {
261
+ try {
262
+ exactRequestOrigin(req);
263
+ void trackOperation(service.cancelProviderAuthCheck(pathParam(req.params.id), pathParam(req.params.checkId)), activeOperations).then(() => sendProviderAuth(res, 204)).catch(next);
264
+ }
265
+ catch (error) {
266
+ next(error);
267
+ }
268
+ });
94
269
  app.get("/api/v1/agents/:id/skills", (req, res, next) => {
95
270
  try {
96
271
  res.status(200).json(service.agentSkills(pathParam(req.params.id)));
@@ -119,9 +294,49 @@ export async function startWebServer(options = {}) {
119
294
  next(error);
120
295
  }
121
296
  });
297
+ app.get("/api/v1/agents/:id/models", (req, res, next) => {
298
+ try {
299
+ // The operator clamps to the same 200-model page ceiling; mirror it
300
+ // server-side rather than trusting the client's requested size.
301
+ const limit = boundedQueryLimit(req.query.limit, 200, 50);
302
+ const provider = optionalSearchQuery(req.query.provider, 256);
303
+ const q = optionalSearchQuery(req.query.q, 512);
304
+ // The agent's `/v1/models` contract treats the two modes as mutually
305
+ // exclusive: a supplier services `provider` and ignores the query, so a
306
+ // request carrying both comes back looking like an answered search. The
307
+ // agent rejects it, but the operator client re-reports any agent 4xx as
308
+ // a 502 `agent_http_error` -- which blames the agent for the caller's
309
+ // mistake. Keep this one local and legible.
310
+ if (provider.length > 0 && q.length > 0) {
311
+ throw new WebConsoleError("invalid_page", "provider and q are mutually exclusive for the model catalog.", 400);
312
+ }
313
+ const cursor = optionalQueryString(req.query.cursor, 4_096);
314
+ void trackOperation(service.agentModels(pathParam(req.params.id), {
315
+ limit,
316
+ ...(provider.length === 0 ? {} : { provider }),
317
+ ...(q.length === 0 ? {} : { q }),
318
+ ...(cursor === undefined ? {} : { cursor }),
319
+ }), activeOperations).then((page) => res.status(200).json(page)).catch(next);
320
+ }
321
+ catch (error) {
322
+ next(error);
323
+ }
324
+ });
122
325
  app.get("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId", (req, res, next) => {
123
326
  void trackOperation(service.cronRun(pathParam(req.params.id), pathParam(req.params.jobId), pathParam(req.params.runId)), activeOperations).then((message) => res.status(200).json({ message })).catch(next);
124
327
  });
328
+ app.post("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId/reply-threads", (req, res, next) => {
329
+ try {
330
+ exactRequestOrigin(req);
331
+ const input = parseCronReply(req.body);
332
+ void trackOperation(service.createCronReplyThread(pathParam(req.params.id), pathParam(req.params.jobId), pathParam(req.params.runId), input), activeOperations).then((receipt) => {
333
+ res.status(receipt.duplicate ? 200 : 201).json(receipt);
334
+ }).catch(next);
335
+ }
336
+ catch (error) {
337
+ next(error);
338
+ }
339
+ });
125
340
  app.post("/api/v1/agents/:id/cron/jobs/:jobId/run", (req, res, next) => {
126
341
  try {
127
342
  exactRequestOrigin(req);
@@ -149,7 +364,10 @@ export async function startWebServer(options = {}) {
149
364
  app.post("/api/v1/threads", (req, res, next) => {
150
365
  try {
151
366
  const input = parseCreateThread(req.body);
152
- res.status(201).json({ thread: service.createThread(input.sourceId) });
367
+ res.status(201).json({ thread: service.createThread(input.sourceId, {
368
+ ...(input.model === undefined ? {} : { model: input.model }),
369
+ ...(input.effort === undefined ? {} : { effort: input.effort }),
370
+ }) });
153
371
  }
154
372
  catch (error) {
155
373
  next(error);
@@ -158,16 +376,17 @@ export async function startWebServer(options = {}) {
158
376
  app.get("/api/v1/threads", (req, res, next) => {
159
377
  try {
160
378
  const sourceId = requiredQueryString(req.query.sourceId, "sourceId", 512);
161
- const archived = req.query.archived === "true"
162
- ? true
163
- : req.query.archived === "false"
164
- ? false
165
- : (() => { throw new WebConsoleError("invalid_page", "archived must be true or false.", 400); })();
379
+ const archived = optionalArchivedQuery(req.query.archived);
380
+ if (archived === undefined) {
381
+ throw new WebConsoleError("invalid_page", "archived must be true or false.", 400);
382
+ }
166
383
  const before = optionalQueryString(req.query.before, 4_096);
167
384
  res.status(200).json(service.threadsPage({
168
385
  sourceId,
169
386
  archived,
170
- limit: boundedQueryLimit(req.query.limit, 200, 200),
387
+ // A sidebar shows a handful of rows and pages from there. This used to
388
+ // answer with the whole per-bucket cap by default.
389
+ limit: boundedQueryLimit(req.query.limit, WEB_THREAD_PAGE_MAX, WEB_THREAD_PAGE_DEFAULT),
171
390
  ...(before === undefined ? {} : { before }),
172
391
  }));
173
392
  }
@@ -190,9 +409,35 @@ export async function startWebServer(options = {}) {
190
409
  next(error);
191
410
  }
192
411
  });
412
+ // Registered above `/threads/:id` so the conversation reads and the read that
413
+ // repairs one of their truncated tool calls stay together.
414
+ app.get("/api/v1/threads/:threadId/messages/:messageId/tool-calls/:toolCallId", (req, res, next) => {
415
+ try {
416
+ res.status(200).json({
417
+ part: service.toolCallPart(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.toolCallId)),
418
+ });
419
+ }
420
+ catch (error) {
421
+ next(error);
422
+ }
423
+ });
424
+ // Registered above `/threads/:id` and above the message page for the same
425
+ // reason the tool-call read is: this is how a console whose delta stream
426
+ // skipped a version repairs ONE message instead of re-reading the whole
427
+ // conversation around it.
428
+ app.get("/api/v1/threads/:threadId/messages/:messageId", (req, res, next) => {
429
+ try {
430
+ res.status(200).json({
431
+ message: service.message(pathParam(req.params.threadId), pathParam(req.params.messageId), fullTranscriptQuery(req.query.full)),
432
+ });
433
+ }
434
+ catch (error) {
435
+ next(error);
436
+ }
437
+ });
193
438
  app.get("/api/v1/threads/:id", (req, res, next) => {
194
439
  try {
195
- res.status(200).json(service.thread(pathParam(req.params.id)));
440
+ res.status(200).json(service.thread(pathParam(req.params.id), fullTranscriptQuery(req.query.full)));
196
441
  }
197
442
  catch (error) {
198
443
  next(error);
@@ -202,8 +447,11 @@ export async function startWebServer(options = {}) {
202
447
  try {
203
448
  const before = optionalQueryString(req.query.before, 4_096);
204
449
  res.status(200).json(service.messagePage(pathParam(req.params.id), {
205
- limit: boundedQueryLimit(req.query.limit, 100, 100),
450
+ // A screenful, not the ceiling: the console renders the tail and pages
451
+ // backwards from the cursor.
452
+ limit: boundedQueryLimit(req.query.limit, WEB_MESSAGE_PAGE_MAX, WEB_MESSAGE_PAGE_DEFAULT),
206
453
  ...(before === undefined ? {} : { before }),
454
+ ...fullTranscriptQuery(req.query.full),
207
455
  }));
208
456
  }
209
457
  catch (error) {
@@ -227,10 +475,10 @@ export async function startWebServer(options = {}) {
227
475
  if (!res.writableEnded)
228
476
  controller.abort(new Error("Reply attachment client disconnected."));
229
477
  });
230
- void trackOperation(service.replyAttachment(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.partId), access.expires, access.token, controller.signal).then(async ({ part, response }) => {
478
+ void trackOperation(service.replyAttachment(pathParam(req.params.threadId), pathParam(req.params.messageId), pathParam(req.params.partId), access.expires, access.token, controller.signal).then(async ({ part, response, remainingSeconds }) => {
231
479
  if (response.body === null)
232
480
  throw new WebConsoleError("reply_attachment_unavailable", "Attachment stream is unavailable.", 502);
233
- setReplyDownloadHeaders(res, part);
481
+ setReplyDownloadHeaders(res, part, remainingSeconds);
234
482
  await pipeline(Readable.fromWeb(response.body), res);
235
483
  }), activeOperations).catch((error) => {
236
484
  if (res.headersSent) {
@@ -304,9 +552,15 @@ export async function startWebServer(options = {}) {
304
552
  }
305
553
  });
306
554
  app.delete("/api/v1/threads/:id", (req, res, next) => {
307
- void trackOperation(service.deleteThread(pathParam(req.params.id)), activeOperations)
308
- .then(() => res.status(204).end())
309
- .catch(next);
555
+ try {
556
+ const emptyOnly = optionalEmptyOnlyQuery(req.query.emptyOnly);
557
+ void trackOperation(service.deleteThread(pathParam(req.params.id), { emptyOnly }), activeOperations)
558
+ .then(() => res.status(204).end())
559
+ .catch(next);
560
+ }
561
+ catch (error) {
562
+ next(error);
563
+ }
310
564
  });
311
565
  app.post("/api/v1/threads/:id/turns", (req, res, next) => {
312
566
  let input;
@@ -323,6 +577,25 @@ export async function startWebServer(options = {}) {
323
577
  .then((started) => res.status(202).json(started))
324
578
  .catch(next);
325
579
  });
580
+ app.post("/api/v1/threads/:id/submissions", (req, res, next) => {
581
+ try {
582
+ const input = parseSubmission(req.body);
583
+ const receipt = service.submit(pathParam(req.params.id), input);
584
+ res.status(receipt.outcome === "rejected" ? 409 : 202).json(receipt);
585
+ }
586
+ catch (error) {
587
+ next(error);
588
+ }
589
+ });
590
+ app.get("/api/v1/threads/:id/submissions/:submissionId", (req, res, next) => {
591
+ try {
592
+ const receipt = service.submission(pathParam(req.params.id), parseSubmissionId(pathParam(req.params.submissionId)));
593
+ res.status(200).json(receipt);
594
+ }
595
+ catch (error) {
596
+ next(error);
597
+ }
598
+ });
326
599
  app.post("/api/v1/threads/:id/live-input", (req, res, next) => {
327
600
  try {
328
601
  const input = parseLiveInput(req.body);
@@ -430,11 +703,40 @@ export async function startWebServer(options = {}) {
430
703
  app.get("/api/v1/uploads/:id/content", (req, res, next) => {
431
704
  void trackOperation(handleDownloadContent(pathParam(req.params.id), res, service), activeOperations).catch(next);
432
705
  });
433
- app.get("/api/v1/events", (_req, res) => {
706
+ /**
707
+ * The console's live channel. {@link createWebEventDispatch} decides what each
708
+ * connection is served; this owns the socket, the capacity cap and the
709
+ * heartbeat.
710
+ */
711
+ app.get("/api/v1/events", (req, res, next) => {
712
+ let named;
713
+ try {
714
+ named = optionalThreadSubscription(req.query.thread);
715
+ }
716
+ catch (error) {
717
+ next(error);
718
+ return;
719
+ }
434
720
  if (activeStreams.size >= MAX_SSE_CLIENTS) {
435
721
  res.status(503).json({ error: { code: "sse_capacity", message: "Too many event streams are connected." } });
436
722
  return;
437
723
  }
724
+ let subscribed;
725
+ try {
726
+ // The CANONICAL id, because that is what every event carries. A console
727
+ // restores a selection it stored before a cron-channel adoption merged
728
+ // that conversation away, or opens a push link minted against the old id;
729
+ // matched by string equality, both subscribed to nothing and were served
730
+ // throttled hints for the conversation they had open.
731
+ //
732
+ // AFTER the capacity check: this reads the database, and a console the
733
+ // server is about to refuse must not be able to make it work.
734
+ subscribed = named === undefined ? undefined : service.resolveThreadId(named);
735
+ }
736
+ catch (error) {
737
+ next(error);
738
+ return;
739
+ }
438
740
  let closed = false;
439
741
  res.status(200);
440
742
  res.setHeader("Content-Type", "text/event-stream");
@@ -451,18 +753,23 @@ export async function startWebServer(options = {}) {
451
753
  activeStreams.delete(closeStream);
452
754
  res.end();
453
755
  };
454
- const send = (event) => {
455
- if (closed || res.writableEnded)
456
- return false;
457
- const writable = res.write(formatSse(event));
458
- if (!writable) {
756
+ const send = createWebEventDispatch({
757
+ ...(subscribed === undefined ? {} : { subscribed }),
758
+ write: (event) => {
759
+ if (closed || res.writableEnded)
760
+ return false;
761
+ if (res.write(formatSse(event)))
762
+ return true;
459
763
  // Events are state-invalidation hints, not an unbounded replay log. A
460
764
  // client that cannot drain one frame must reconnect and bootstrap.
461
765
  closeStream();
462
766
  return false;
463
- }
464
- return true;
465
- };
767
+ },
768
+ close: closeStream,
769
+ onFailure: (error) => {
770
+ logger?.error?.("Web console event stream failed.", { error: errorMessage(error) });
771
+ },
772
+ });
466
773
  const unsubscribe = service.subscribe(send);
467
774
  const heartbeat = setInterval(() => {
468
775
  if (!res.write(`: heartbeat ${Date.now()}\n\n`))
@@ -476,12 +783,28 @@ export async function startWebServer(options = {}) {
476
783
  app.use("/api", (_req, res) => {
477
784
  res.status(404).json({ error: { code: "not_found", message: "Not found." } });
478
785
  });
479
- app.use(express.static(staticDir, { fallthrough: true, index: false, redirect: false }));
786
+ // Vite fingerprints everything under assets/, so those URLs are immutable and
787
+ // must be mounted ahead of the revalidating root. The root then covers the
788
+ // service workers, icons, and the shell, which keep their names across builds
789
+ // and so have to be revalidated (cheaply, against their ETag) every load.
790
+ app.use("/assets", express.static(join(staticDir, "assets"), {
791
+ fallthrough: true,
792
+ index: false,
793
+ redirect: false,
794
+ immutable: true,
795
+ maxAge: IMMUTABLE_MAX_AGE_SECONDS * 1000,
796
+ }));
797
+ app.use(express.static(staticDir, {
798
+ fallthrough: true,
799
+ index: false,
800
+ redirect: false,
801
+ setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
802
+ }));
480
803
  app.get("/{*splat}", (_req, res, next) => {
481
804
  // Keep the managed runtime's hidden ~/.mono-agent parent out of the
482
805
  // request-relative path. Express otherwise applies its dotfile policy to
483
806
  // the absolute path and rejects an existing index.html as Not Found.
484
- res.sendFile("index.html", { root: staticDir }, (error) => {
807
+ res.sendFile("index.html", { root: staticDir, headers: { "Cache-Control": "no-cache" } }, (error) => {
485
808
  if (error !== undefined && error !== null)
486
809
  next(error);
487
810
  });
@@ -497,6 +820,12 @@ export async function startWebServer(options = {}) {
497
820
  && (error.status === 413 || error.type === "entity.too.large");
498
821
  const status = known ? error.status : tooLarge ? 413 : syntax ? 400 : 500;
499
822
  const code = known ? error.code : tooLarge ? "request_too_large" : syntax ? "invalid_json" : "internal_error";
823
+ const retryAfterSeconds = known && typeof error.details?.retryAfterSeconds === "number"
824
+ ? error.details.retryAfterSeconds
825
+ : undefined;
826
+ if (status === 429 && Number.isSafeInteger(retryAfterSeconds) && retryAfterSeconds > 0) {
827
+ res.setHeader("Retry-After", String(retryAfterSeconds));
828
+ }
500
829
  if (status >= 500)
501
830
  logger?.error?.("Web console request failed.", { error: errorMessage(error) });
502
831
  res.status(status).json({
@@ -555,6 +884,28 @@ export async function startWebServer(options = {}) {
555
884
  throw error;
556
885
  }
557
886
  }
887
+ /**
888
+ * Normalize the operator-chosen console label. Control characters are rejected rather
889
+ * than stripped so a typo cannot silently install under a different name than requested.
890
+ */
891
+ function resolveWebConsoleName(value) {
892
+ if (value === undefined)
893
+ return undefined;
894
+ if (typeof value !== "string") {
895
+ throw new WebConsoleError("invalid_console_name", "Web console name must be a string.", 400);
896
+ }
897
+ const name = value.trim();
898
+ if (name.length === 0) {
899
+ throw new WebConsoleError("invalid_console_name", "Web console name must not be empty.", 400);
900
+ }
901
+ if (/[\u0000-\u001f\u007f-\u009f\u2028-\u202e]/u.test(name)) {
902
+ throw new WebConsoleError("invalid_console_name", "Web console name must not contain control characters, line separators, or bidirectional overrides.", 400);
903
+ }
904
+ if ([...name].length > WEB_CONSOLE_NAME_MAX_CHARACTERS) {
905
+ throw new WebConsoleError("invalid_console_name", `Web console name must be at most ${String(WEB_CONSOLE_NAME_MAX_CHARACTERS)} characters.`, 400);
906
+ }
907
+ return name;
908
+ }
558
909
  function resolveWebTheme(value) {
559
910
  if (value === undefined)
560
911
  return DEFAULT_WEB_THEME;
@@ -584,13 +935,13 @@ async function loadWebManifest(staticDir, identity) {
584
935
  if (template === null || typeof template !== "object" || Array.isArray(template)) {
585
936
  throw new WebConsoleError("invalid_static_manifest", "The web console manifest must be a JSON object.", 500);
586
937
  }
587
- const chrome = WEB_THEME_CHROME[identity.theme];
938
+ const colors = WEB_THEME_MANIFEST_COLORS[identity.theme];
588
939
  return {
589
940
  ...template,
590
- name: `${identity.hostName} · mono-agent Console`,
591
- short_name: identity.hostName,
592
- theme_color: chrome.dark,
593
- background_color: chrome.dark,
941
+ name: `${identity.displayName} · mono-agent Console`,
942
+ short_name: identity.displayName,
943
+ theme_color: colors.themeColor,
944
+ background_color: colors.backgroundColor,
594
945
  };
595
946
  }
596
947
  function trackOperation(operation, active) {
@@ -690,6 +1041,10 @@ async function handleDownloadContent(id, res, service) {
690
1041
  res.status(200);
691
1042
  res.setHeader("Content-Type", image ? attachment.contentType : "application/octet-stream");
692
1043
  res.setHeader("Content-Length", String(info.size));
1044
+ // An upload id is a fresh UUID whose bytes are written exactly once, so this
1045
+ // URL can never change meaning. `no-transform` keeps the declared length
1046
+ // honest for the client that streams these bytes back into a Blob.
1047
+ res.setHeader("Cache-Control", `private, max-age=${IMMUTABLE_MAX_AGE_SECONDS}, immutable, no-transform`);
693
1048
  res.setHeader("X-Content-Type-Options", "nosniff");
694
1049
  res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
695
1050
  res.setHeader("Content-Disposition", contentDisposition(attachment.name, image ? "inline" : "attachment"));
@@ -707,7 +1062,9 @@ function replyAccessQuery(req) {
707
1062
  }
708
1063
  return { expires, token };
709
1064
  }
710
- function setReplyDownloadHeaders(res, part) {
1065
+ function setReplyDownloadHeaders(res, part,
1066
+ /** Seconds this response's own capability is still good for. */
1067
+ maxAgeSeconds) {
711
1068
  const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
712
1069
  .test(part.mediaType);
713
1070
  res.status(200);
@@ -717,7 +1074,16 @@ function setReplyDownloadHeaders(res, part) {
717
1074
  res.setHeader("Content-Length", String(part.sizeBytes));
718
1075
  res.setHeader("Accept-Ranges", "none");
719
1076
  res.setHeader("Content-Disposition", contentDisposition(part.name, "attachment"));
720
- res.setHeader("Cache-Control", "private, no-store, max-age=0");
1077
+ // The client refuses a part whose Content-Length disagrees with the declared
1078
+ // size, so this response must reach it byte-for-byte -- hence `no-transform`.
1079
+ //
1080
+ // Cacheable for exactly as long as the signed key in the URL is: the mint
1081
+ // quantises its expiry to a five-minute bucket, so the same picture asked for
1082
+ // twice inside one bucket is the same URL and the browser answers the second
1083
+ // read itself. `private`, because the URL is a capability and no shared cache
1084
+ // may keep it; never past the key, because the response is only servable while
1085
+ // the key is.
1086
+ res.setHeader("Cache-Control", `private, max-age=${String(maxAgeSeconds)}, no-transform`);
721
1087
  res.setHeader("X-Content-Type-Options", "nosniff");
722
1088
  res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
723
1089
  res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
@@ -845,7 +1211,31 @@ function securityHeaders(_req, res, next) {
845
1211
  }
846
1212
  function parseCreateThread(value) {
847
1213
  const body = requireRecord(value);
848
- return { sourceId: requireString(body.sourceId, "sourceId", 256) };
1214
+ const model = optionalNullableString(body.model, "model", 120);
1215
+ const effort = optionalNullableString(body.effort, "effort", 120);
1216
+ return {
1217
+ sourceId: requireString(body.sourceId, "sourceId", 256),
1218
+ ...(model === undefined ? {} : { model }),
1219
+ ...(effort === undefined ? {} : { effort }),
1220
+ };
1221
+ }
1222
+ function parsePutAgentRunSettings(value) {
1223
+ const body = requireRecord(value);
1224
+ if (!("model" in body) || !("effort" in body)) {
1225
+ throw invalidBody("model and effort are required and must be strings or null.");
1226
+ }
1227
+ const unknown = Object.keys(body).filter((key) => key !== "model" && key !== "effort");
1228
+ if (unknown.length > 0)
1229
+ throw invalidBody(`Unknown run-defaults field: ${unknown[0]}.`);
1230
+ const model = optionalNullableString(body.model, "model", 120);
1231
+ const effort = optionalNullableString(body.effort, "effort", 120);
1232
+ if (model === undefined || effort === undefined) {
1233
+ throw invalidBody("model and effort are required and must be strings or null.");
1234
+ }
1235
+ if (model === null && effort === null) {
1236
+ throw invalidBody("Choose a model or effort override, or use Revert to config.");
1237
+ }
1238
+ return { model, effort };
849
1239
  }
850
1240
  function parsePatchAgent(value) {
851
1241
  const body = requireRecord(value);
@@ -856,12 +1246,25 @@ function parsePatchAgent(value) {
856
1246
  function parsePatchThread(value) {
857
1247
  const body = requireRecord(value);
858
1248
  const title = optionalString(body.title, "title", 120);
1249
+ const model = optionalNullableString(body.model, "model", 120);
1250
+ const effort = optionalNullableString(body.effort, "effort", 120);
859
1251
  const archived = body.archived;
860
1252
  if (archived !== undefined && typeof archived !== "boolean")
861
1253
  throw invalidBody("archived must be boolean.");
862
- if (title === undefined && archived === undefined)
863
- throw invalidBody("Provide title or archived.");
864
- return { ...(title === undefined ? {} : { title }), ...(archived === undefined ? {} : { archived }) };
1254
+ const ifRunConfigUnset = body.ifRunConfigUnset;
1255
+ if (ifRunConfigUnset !== undefined && typeof ifRunConfigUnset !== "boolean") {
1256
+ throw invalidBody("ifRunConfigUnset must be boolean.");
1257
+ }
1258
+ if (title === undefined && archived === undefined && model === undefined && effort === undefined) {
1259
+ throw invalidBody("Provide title, archived, model, or effort.");
1260
+ }
1261
+ return {
1262
+ ...(title === undefined ? {} : { title }),
1263
+ ...(archived === undefined ? {} : { archived }),
1264
+ ...(model === undefined ? {} : { model }),
1265
+ ...(effort === undefined ? {} : { effort }),
1266
+ ...(ifRunConfigUnset === undefined ? {} : { ifRunConfigUnset }),
1267
+ };
865
1268
  }
866
1269
  function parseTurn(value) {
867
1270
  const body = requireRecord(value);
@@ -889,6 +1292,29 @@ function parseTurn(value) {
889
1292
  ...(effort === undefined ? {} : { effort }),
890
1293
  };
891
1294
  }
1295
+ function parseSubmission(value) {
1296
+ const body = requireRecord(value);
1297
+ const submissionId = parseSubmissionId(body.submissionId);
1298
+ return { ...parseTurn(body), submissionId };
1299
+ }
1300
+ function parseSubmissionId(value) {
1301
+ const submissionId = requireString(value, "submissionId", 36);
1302
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(submissionId)) {
1303
+ throw invalidBody("submissionId must be a canonical UUID.");
1304
+ }
1305
+ return submissionId.toLowerCase();
1306
+ }
1307
+ function parseCronReply(value) {
1308
+ const body = requireRecord(value);
1309
+ if (Object.keys(body).sort().join("\0") !== ["operationId", "snapshotKind"].join("\0")) {
1310
+ throw invalidBody("Cron Reply requires only operationId and snapshotKind.");
1311
+ }
1312
+ const operationId = parseSubmissionId(body.operationId);
1313
+ if (body.snapshotKind !== "summary" && body.snapshotKind !== "detail") {
1314
+ throw invalidBody("snapshotKind must be summary or detail.");
1315
+ }
1316
+ return { operationId, snapshotKind: body.snapshotKind };
1317
+ }
892
1318
  function parseLiveInput(value) {
893
1319
  const body = requireRecord(value);
894
1320
  return {
@@ -977,6 +1403,142 @@ function requireRecord(value) {
977
1403
  throw invalidBody("JSON body must be an object.");
978
1404
  return value;
979
1405
  }
1406
+ function assertProviderAuthBody(value) {
1407
+ let bytes;
1408
+ try {
1409
+ bytes = Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8");
1410
+ }
1411
+ catch {
1412
+ throw invalidBody("Provider authentication body must be JSON.");
1413
+ }
1414
+ if (bytes > MAX_PROVIDER_AUTH_BODY_BYTES) {
1415
+ throw new WebConsoleError("provider_auth_too_large", "Provider authentication body is too large.", 413);
1416
+ }
1417
+ }
1418
+ /**
1419
+ * One connection's view of the event stream: which frames it is served, and in
1420
+ * what form.
1421
+ *
1422
+ * A console names the conversation it is looking at with `?thread=`. It is
1423
+ * served EVERYTHING about that conversation unthrottled -- `message.delta`
1424
+ * frames carrying what each write changed, and the `message.changed` hints the
1425
+ * delta path emits when it declines (a delta bigger than the message, a
1426
+ * reconciliation that describes nothing), because both are things it must act
1427
+ * on. Anything about any OTHER conversation is reduced to a
1428
+ * `message.changed { messageId, updatedAt }` and rate-limited to one per
1429
+ * conversation per {@link DELTA_HINT_INTERVAL_MS}, dropped rather than queued:
1430
+ * a console is not rendering it.
1431
+ *
1432
+ * A console that named NO conversation is not telling this stream which
1433
+ * conversation it is looking at, so it keeps every reconciliation hint and is
1434
+ * rate-limited only on what the delta path produces -- downgraded deltas and
1435
+ * the declines above -- which is the write-rate traffic the limit exists for. A
1436
+ * turn's `thread.changed` still follows every finish unthrottled, so a dropped
1437
+ * frame never leaves a settled conversation stale.
1438
+ *
1439
+ * Every other event type passes through untouched. `Last-Event-ID` is ignored
1440
+ * and nothing is replayed: this is state invalidation, not a log. A
1441
+ * reconnecting console bootstraps, and `ready` means resync the conversation it
1442
+ * has open.
1443
+ *
1444
+ * Lives outside the route because neither of the two things that matter is
1445
+ * observable through an HTTP fixture: the rate limit is a clock decision, and a
1446
+ * frame this connection cannot serialize or write must CLOSE it rather than
1447
+ * leave a socket that reads live and receives nothing.
1448
+ */
1449
+ export function createWebEventDispatch(options) {
1450
+ const { subscribed, write, close, onFailure } = options;
1451
+ const now = options.now ?? (() => Date.now());
1452
+ // Per connection, and cleared with it: what one console has already been told
1453
+ // about is no reason to keep another quiet.
1454
+ const hintedAt = new Map();
1455
+ /** How many frames this connection has minted an id for. */
1456
+ let downgrades = 0;
1457
+ const hint = (event, payload) => {
1458
+ const downgraded = event.type !== "message.changed" || payload.deltaDeclined === true;
1459
+ if (downgraded)
1460
+ downgrades += 1;
1461
+ return write({
1462
+ ...event,
1463
+ // A frame this layer rewrote is a DIFFERENT frame -- another type, another
1464
+ // payload -- and `id:` is what names one on the wire. Sending it under the
1465
+ // source event's id had one id naming two frames, which a console that
1466
+ // deduplicates by id would read as one. Derived from the source so it
1467
+ // stays traceable, and unique per connection because that is the scope the
1468
+ // id is read in.
1469
+ ...(downgraded ? { id: `${event.id}#${String(downgrades)}` } : {}),
1470
+ type: "message.changed",
1471
+ // `deltaDeclined` is this layer's own signal and stops here.
1472
+ payload: { messageId: payload.messageId, updatedAt: payload.updatedAt },
1473
+ });
1474
+ };
1475
+ const rateLimited = (event, payload) => {
1476
+ const key = event.threadId ?? "";
1477
+ const at = now();
1478
+ const last = hintedAt.get(key);
1479
+ // Dropped, not queued: a hint says only that the message moved, so the one
1480
+ // that was suppressed is answered by the one that follows it.
1481
+ if (last !== undefined && at - last < DELTA_HINT_INTERVAL_MS)
1482
+ return true;
1483
+ // Delete before set, so insertion order IS recency order and the eviction
1484
+ // below drops the conversation this connection heard about longest ago.
1485
+ hintedAt.delete(key);
1486
+ hintedAt.set(key, at);
1487
+ while (hintedAt.size > MAX_THROTTLED_CONVERSATIONS) {
1488
+ const oldest = hintedAt.keys().next();
1489
+ if (oldest.done === true)
1490
+ break;
1491
+ hintedAt.delete(oldest.value);
1492
+ }
1493
+ return hint(event, payload);
1494
+ };
1495
+ return (event) => {
1496
+ try {
1497
+ if (event.type !== "message.delta" && event.type !== "message.changed")
1498
+ return write(event);
1499
+ const own = event.threadId !== undefined && event.threadId === subscribed;
1500
+ if (event.type === "message.delta") {
1501
+ const delta = requireMessageDelta(event.payload);
1502
+ return own ? write(event) : rateLimited(event, delta);
1503
+ }
1504
+ const payload = requireMessageChanged(event.payload);
1505
+ if (own)
1506
+ return hint(event, payload);
1507
+ if (subscribed === undefined && payload.deltaDeclined !== true)
1508
+ return hint(event, payload);
1509
+ return rateLimited(event, payload);
1510
+ }
1511
+ catch (error) {
1512
+ // A frame this connection cannot make sense of is a server bug, and the
1513
+ // honest answer is to end the stream so the console reconnects and
1514
+ // bootstraps -- never to drop the frame and keep a live-looking socket.
1515
+ onFailure?.(error);
1516
+ close();
1517
+ return false;
1518
+ }
1519
+ };
1520
+ }
1521
+ function requireMessageDelta(payload) {
1522
+ const delta = payload;
1523
+ if (delta === null || delta === undefined || typeof delta !== "object"
1524
+ || typeof delta.messageId !== "string"
1525
+ || typeof delta.updatedAt !== "string"
1526
+ || typeof delta.baseSeq !== "number"
1527
+ || typeof delta.seq !== "number"
1528
+ || !Array.isArray(delta.ops)) {
1529
+ throw new TypeError("A message.delta event carried no delta.");
1530
+ }
1531
+ return delta;
1532
+ }
1533
+ function requireMessageChanged(payload) {
1534
+ const changed = payload;
1535
+ if (changed === null || changed === undefined || typeof changed !== "object"
1536
+ || typeof changed.messageId !== "string"
1537
+ || typeof changed.updatedAt !== "string") {
1538
+ throw new TypeError("A message.changed event named no message.");
1539
+ }
1540
+ return changed;
1541
+ }
980
1542
  function requiredQueryString(value, field, max) {
981
1543
  if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
982
1544
  throw new WebConsoleError("invalid_page", `${field} is required.`, 400);
@@ -996,6 +1558,34 @@ function optionalSearchQuery(value, max) {
996
1558
  }
997
1559
  return value;
998
1560
  }
1561
+ /**
1562
+ * The conversation a console subscribes its event stream to.
1563
+ *
1564
+ * Its own code, not the pagination one: an invalid `?thread=` is a bad
1565
+ * subscription, and answering it with "Pagination cursor is invalid." sent a
1566
+ * console looking for a cursor it never sent.
1567
+ */
1568
+ function optionalThreadSubscription(value) {
1569
+ if (value === undefined)
1570
+ return undefined;
1571
+ if (typeof value !== "string") {
1572
+ throw invalidSubscription();
1573
+ }
1574
+ // The TRIMMED id is the subject of every rule here: it is what the dispatch
1575
+ // compares against `event.threadId`, so it is what the length must bound and
1576
+ // what is returned. Returning the padded string matched nothing and served
1577
+ // hints for the conversation on screen.
1578
+ const named = value.trim();
1579
+ // Blank is refused rather than read as "no subscription": `?thread=` and
1580
+ // `?thread=%20` are a console asking for a conversation and getting one it
1581
+ // never named, which would look like the delta stream had simply stopped.
1582
+ if (named.length === 0 || named.length > MAX_SUBSCRIPTION_LENGTH)
1583
+ throw invalidSubscription();
1584
+ return named;
1585
+ }
1586
+ function invalidSubscription() {
1587
+ return new WebConsoleError("invalid_subscription", `thread must name one conversation, in at most ${String(MAX_SUBSCRIPTION_LENGTH)} characters.`, 400);
1588
+ }
999
1589
  function optionalQueryString(value, max) {
1000
1590
  if (value === undefined)
1001
1591
  return undefined;
@@ -1004,6 +1594,35 @@ function optionalQueryString(value, max) {
1004
1594
  }
1005
1595
  return value;
1006
1596
  }
1597
+ /** `true`/`false`, or `undefined` for a query that said neither. */
1598
+ function optionalArchivedQuery(value) {
1599
+ if (value === "true")
1600
+ return true;
1601
+ if (value === "false")
1602
+ return false;
1603
+ if (value === undefined)
1604
+ return undefined;
1605
+ throw new WebConsoleError("invalid_page", "archived must be true or false.", 400);
1606
+ }
1607
+ function optionalEmptyOnlyQuery(value) {
1608
+ if (value === undefined)
1609
+ return false;
1610
+ if (value === "true")
1611
+ return true;
1612
+ throw new WebConsoleError("invalid_delete", "emptyOnly must be true when provided.", 400);
1613
+ }
1614
+ /**
1615
+ * `?full=1` (or `full=true`) turns the transcript diet off for one read: no
1616
+ * truncated tool payloads, no stripped telemetry. Anything else, including an
1617
+ * absent parameter, keeps the shaped transcript.
1618
+ *
1619
+ * It changes the SHAPE of the messages a read answers with, never how many: a
1620
+ * full conversation read still answers with one page and the rest still comes
1621
+ * from `messagesNextCursor` (or an explicit `limit`).
1622
+ */
1623
+ function fullTranscriptQuery(value) {
1624
+ return value === "1" || value === "true" ? { full: true } : {};
1625
+ }
1007
1626
  function boundedQueryLimit(value, maximum, fallback) {
1008
1627
  if (value === undefined)
1009
1628
  return fallback;
@@ -1031,6 +1650,14 @@ function requireString(value, field, max, allowEmpty = false) {
1031
1650
  function optionalString(value, field, max) {
1032
1651
  return value === undefined ? undefined : requireString(value, field, max);
1033
1652
  }
1653
+ /** Optional override field: absent (`undefined`), cleared (`null`), or a bounded string. */
1654
+ function optionalNullableString(value, field, max) {
1655
+ if (value === undefined)
1656
+ return undefined;
1657
+ if (value === null)
1658
+ return null;
1659
+ return requireString(value, field, max);
1660
+ }
1034
1661
  function invalidBody(message) {
1035
1662
  return new WebConsoleError("invalid_request", message, 400);
1036
1663
  }