@mono-agent/web 0.20.14 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +388 -70
  2. package/dist/contracts.d.ts +364 -8
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +2 -0
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/cron-reply-context.d.ts +27 -0
  7. package/dist/cron-reply-context.d.ts.map +1 -0
  8. package/dist/cron-reply-context.js +241 -0
  9. package/dist/cron-reply-context.js.map +1 -0
  10. package/dist/discovery.d.ts +1 -0
  11. package/dist/discovery.d.ts.map +1 -1
  12. package/dist/discovery.js +8 -6
  13. package/dist/discovery.js.map +1 -1
  14. package/dist/effort-ladder.d.ts +14 -0
  15. package/dist/effort-ladder.d.ts.map +1 -1
  16. package/dist/effort-ladder.js +41 -0
  17. package/dist/effort-ladder.js.map +1 -1
  18. package/dist/index.d.ts +2 -2
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +1 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/long-lived-fetch.d.ts +5 -0
  23. package/dist/long-lived-fetch.d.ts.map +1 -0
  24. package/dist/long-lived-fetch.js +22 -0
  25. package/dist/long-lived-fetch.js.map +1 -0
  26. package/dist/monitor-reply.d.ts +10 -0
  27. package/dist/monitor-reply.d.ts.map +1 -0
  28. package/dist/monitor-reply.js +67 -0
  29. package/dist/monitor-reply.js.map +1 -0
  30. package/dist/notification-client.d.ts.map +1 -1
  31. package/dist/notification-client.js +5 -4
  32. package/dist/notification-client.js.map +1 -1
  33. package/dist/operator-client.d.ts +25 -1
  34. package/dist/operator-client.d.ts.map +1 -1
  35. package/dist/operator-client.js +167 -8
  36. package/dist/operator-client.js.map +1 -1
  37. package/dist/server.d.ts +42 -1
  38. package/dist/server.d.ts.map +1 -1
  39. package/dist/server.js +627 -49
  40. package/dist/server.js.map +1 -1
  41. package/dist/service.d.ts +201 -8
  42. package/dist/service.d.ts.map +1 -1
  43. package/dist/service.js +1425 -136
  44. package/dist/service.js.map +1 -1
  45. package/dist/store-migrations.d.ts +23 -0
  46. package/dist/store-migrations.d.ts.map +1 -0
  47. package/dist/store-migrations.js +212 -0
  48. package/dist/store-migrations.js.map +1 -0
  49. package/dist/store.d.ts +294 -17
  50. package/dist/store.d.ts.map +1 -1
  51. package/dist/store.js +1637 -263
  52. package/dist/store.js.map +1 -1
  53. package/package.json +8 -5
  54. package/webapp/dist/assets/{assistant-ui-BzN2E6n6.js → assistant-ui-pZmGxIp2.js} +16 -16
  55. package/webapp/dist/assets/index-0v07DD8D.css +1 -0
  56. package/webapp/dist/assets/index-DJXY5RPz.js +155 -0
  57. package/webapp/dist/assets/{markdown-Vq23xgh7.js → markdown-Du5t10ja.js} +1 -1
  58. package/webapp/dist/badge-96.png +0 -0
  59. package/webapp/dist/index.html +26 -6
  60. package/webapp/dist/manifest.webmanifest +1 -1
  61. package/webapp/dist/notification-sw.js +3 -1
  62. package/webapp/dist/sw.js +1 -1
  63. package/webapp/dist/{workbox-9c191d2f.js → workbox-2fbc6a65.js} +1 -1
  64. package/webapp/dist/assets/index-C4a2Dv1W.js +0 -155
  65. package/webapp/dist/assets/index-mhMBLGB0.css +0 -1
package/dist/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)));
@@ -150,6 +325,18 @@ export async function startWebServer(options = {}) {
150
325
  app.get("/api/v1/agents/:id/cron/jobs/:jobId/runs/:runId", (req, res, next) => {
151
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);
152
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
+ });
153
340
  app.post("/api/v1/agents/:id/cron/jobs/:jobId/run", (req, res, next) => {
154
341
  try {
155
342
  exactRequestOrigin(req);
@@ -177,7 +364,10 @@ export async function startWebServer(options = {}) {
177
364
  app.post("/api/v1/threads", (req, res, next) => {
178
365
  try {
179
366
  const input = parseCreateThread(req.body);
180
- 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
+ }) });
181
371
  }
182
372
  catch (error) {
183
373
  next(error);
@@ -186,16 +376,17 @@ export async function startWebServer(options = {}) {
186
376
  app.get("/api/v1/threads", (req, res, next) => {
187
377
  try {
188
378
  const sourceId = requiredQueryString(req.query.sourceId, "sourceId", 512);
189
- const archived = req.query.archived === "true"
190
- ? true
191
- : req.query.archived === "false"
192
- ? false
193
- : (() => { 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
+ }
194
383
  const before = optionalQueryString(req.query.before, 4_096);
195
384
  res.status(200).json(service.threadsPage({
196
385
  sourceId,
197
386
  archived,
198
- 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),
199
390
  ...(before === undefined ? {} : { before }),
200
391
  }));
201
392
  }
@@ -218,9 +409,35 @@ export async function startWebServer(options = {}) {
218
409
  next(error);
219
410
  }
220
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
+ });
221
438
  app.get("/api/v1/threads/:id", (req, res, next) => {
222
439
  try {
223
- 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)));
224
441
  }
225
442
  catch (error) {
226
443
  next(error);
@@ -230,8 +447,11 @@ export async function startWebServer(options = {}) {
230
447
  try {
231
448
  const before = optionalQueryString(req.query.before, 4_096);
232
449
  res.status(200).json(service.messagePage(pathParam(req.params.id), {
233
- 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),
234
453
  ...(before === undefined ? {} : { before }),
454
+ ...fullTranscriptQuery(req.query.full),
235
455
  }));
236
456
  }
237
457
  catch (error) {
@@ -255,10 +475,10 @@ export async function startWebServer(options = {}) {
255
475
  if (!res.writableEnded)
256
476
  controller.abort(new Error("Reply attachment client disconnected."));
257
477
  });
258
- 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 }) => {
259
479
  if (response.body === null)
260
480
  throw new WebConsoleError("reply_attachment_unavailable", "Attachment stream is unavailable.", 502);
261
- setReplyDownloadHeaders(res, part);
481
+ setReplyDownloadHeaders(res, part, remainingSeconds);
262
482
  await pipeline(Readable.fromWeb(response.body), res);
263
483
  }), activeOperations).catch((error) => {
264
484
  if (res.headersSent) {
@@ -332,9 +552,15 @@ export async function startWebServer(options = {}) {
332
552
  }
333
553
  });
334
554
  app.delete("/api/v1/threads/:id", (req, res, next) => {
335
- void trackOperation(service.deleteThread(pathParam(req.params.id)), activeOperations)
336
- .then(() => res.status(204).end())
337
- .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
+ }
338
564
  });
339
565
  app.post("/api/v1/threads/:id/turns", (req, res, next) => {
340
566
  let input;
@@ -351,6 +577,25 @@ export async function startWebServer(options = {}) {
351
577
  .then((started) => res.status(202).json(started))
352
578
  .catch(next);
353
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
+ });
354
599
  app.post("/api/v1/threads/:id/live-input", (req, res, next) => {
355
600
  try {
356
601
  const input = parseLiveInput(req.body);
@@ -458,11 +703,40 @@ export async function startWebServer(options = {}) {
458
703
  app.get("/api/v1/uploads/:id/content", (req, res, next) => {
459
704
  void trackOperation(handleDownloadContent(pathParam(req.params.id), res, service), activeOperations).catch(next);
460
705
  });
461
- 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
+ }
462
720
  if (activeStreams.size >= MAX_SSE_CLIENTS) {
463
721
  res.status(503).json({ error: { code: "sse_capacity", message: "Too many event streams are connected." } });
464
722
  return;
465
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
+ }
466
740
  let closed = false;
467
741
  res.status(200);
468
742
  res.setHeader("Content-Type", "text/event-stream");
@@ -479,18 +753,23 @@ export async function startWebServer(options = {}) {
479
753
  activeStreams.delete(closeStream);
480
754
  res.end();
481
755
  };
482
- const send = (event) => {
483
- if (closed || res.writableEnded)
484
- return false;
485
- const writable = res.write(formatSse(event));
486
- 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;
487
763
  // Events are state-invalidation hints, not an unbounded replay log. A
488
764
  // client that cannot drain one frame must reconnect and bootstrap.
489
765
  closeStream();
490
766
  return false;
491
- }
492
- return true;
493
- };
767
+ },
768
+ close: closeStream,
769
+ onFailure: (error) => {
770
+ logger?.error?.("Web console event stream failed.", { error: errorMessage(error) });
771
+ },
772
+ });
494
773
  const unsubscribe = service.subscribe(send);
495
774
  const heartbeat = setInterval(() => {
496
775
  if (!res.write(`: heartbeat ${Date.now()}\n\n`))
@@ -504,12 +783,28 @@ export async function startWebServer(options = {}) {
504
783
  app.use("/api", (_req, res) => {
505
784
  res.status(404).json({ error: { code: "not_found", message: "Not found." } });
506
785
  });
507
- 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
+ }));
508
803
  app.get("/{*splat}", (_req, res, next) => {
509
804
  // Keep the managed runtime's hidden ~/.mono-agent parent out of the
510
805
  // request-relative path. Express otherwise applies its dotfile policy to
511
806
  // the absolute path and rejects an existing index.html as Not Found.
512
- res.sendFile("index.html", { root: staticDir }, (error) => {
807
+ res.sendFile("index.html", { root: staticDir, headers: { "Cache-Control": "no-cache" } }, (error) => {
513
808
  if (error !== undefined && error !== null)
514
809
  next(error);
515
810
  });
@@ -525,6 +820,12 @@ export async function startWebServer(options = {}) {
525
820
  && (error.status === 413 || error.type === "entity.too.large");
526
821
  const status = known ? error.status : tooLarge ? 413 : syntax ? 400 : 500;
527
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
+ }
528
829
  if (status >= 500)
529
830
  logger?.error?.("Web console request failed.", { error: errorMessage(error) });
530
831
  res.status(status).json({
@@ -583,6 +884,28 @@ export async function startWebServer(options = {}) {
583
884
  throw error;
584
885
  }
585
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
+ }
586
909
  function resolveWebTheme(value) {
587
910
  if (value === undefined)
588
911
  return DEFAULT_WEB_THEME;
@@ -612,13 +935,13 @@ async function loadWebManifest(staticDir, identity) {
612
935
  if (template === null || typeof template !== "object" || Array.isArray(template)) {
613
936
  throw new WebConsoleError("invalid_static_manifest", "The web console manifest must be a JSON object.", 500);
614
937
  }
615
- const chrome = WEB_THEME_CHROME[identity.theme];
938
+ const colors = WEB_THEME_MANIFEST_COLORS[identity.theme];
616
939
  return {
617
940
  ...template,
618
- name: `${identity.hostName} · mono-agent Console`,
619
- short_name: identity.hostName,
620
- theme_color: chrome.dark,
621
- 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,
622
945
  };
623
946
  }
624
947
  function trackOperation(operation, active) {
@@ -718,6 +1041,10 @@ async function handleDownloadContent(id, res, service) {
718
1041
  res.status(200);
719
1042
  res.setHeader("Content-Type", image ? attachment.contentType : "application/octet-stream");
720
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`);
721
1048
  res.setHeader("X-Content-Type-Options", "nosniff");
722
1049
  res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
723
1050
  res.setHeader("Content-Disposition", contentDisposition(attachment.name, image ? "inline" : "attachment"));
@@ -735,7 +1062,9 @@ function replyAccessQuery(req) {
735
1062
  }
736
1063
  return { expires, token };
737
1064
  }
738
- function setReplyDownloadHeaders(res, part) {
1065
+ function setReplyDownloadHeaders(res, part,
1066
+ /** Seconds this response's own capability is still good for. */
1067
+ maxAgeSeconds) {
739
1068
  const risky = /^(?:text\/(?:html|javascript|xml)|application\/(?:javascript|xhtml\+xml|xml)|image\/svg\+xml)$/iu
740
1069
  .test(part.mediaType);
741
1070
  res.status(200);
@@ -745,7 +1074,16 @@ function setReplyDownloadHeaders(res, part) {
745
1074
  res.setHeader("Content-Length", String(part.sizeBytes));
746
1075
  res.setHeader("Accept-Ranges", "none");
747
1076
  res.setHeader("Content-Disposition", contentDisposition(part.name, "attachment"));
748
- 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`);
749
1087
  res.setHeader("X-Content-Type-Options", "nosniff");
750
1088
  res.setHeader("Content-Security-Policy", "sandbox; default-src 'none'; base-uri 'none'; form-action 'none'");
751
1089
  res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
@@ -873,7 +1211,31 @@ function securityHeaders(_req, res, next) {
873
1211
  }
874
1212
  function parseCreateThread(value) {
875
1213
  const body = requireRecord(value);
876
- 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 };
877
1239
  }
878
1240
  function parsePatchAgent(value) {
879
1241
  const body = requireRecord(value);
@@ -930,6 +1292,29 @@ function parseTurn(value) {
930
1292
  ...(effort === undefined ? {} : { effort }),
931
1293
  };
932
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
+ }
933
1318
  function parseLiveInput(value) {
934
1319
  const body = requireRecord(value);
935
1320
  return {
@@ -1018,6 +1403,142 @@ function requireRecord(value) {
1018
1403
  throw invalidBody("JSON body must be an object.");
1019
1404
  return value;
1020
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
+ }
1021
1542
  function requiredQueryString(value, field, max) {
1022
1543
  if (typeof value !== "string" || value.trim().length === 0 || value.length > max) {
1023
1544
  throw new WebConsoleError("invalid_page", `${field} is required.`, 400);
@@ -1037,6 +1558,34 @@ function optionalSearchQuery(value, max) {
1037
1558
  }
1038
1559
  return value;
1039
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
+ }
1040
1589
  function optionalQueryString(value, max) {
1041
1590
  if (value === undefined)
1042
1591
  return undefined;
@@ -1045,6 +1594,35 @@ function optionalQueryString(value, max) {
1045
1594
  }
1046
1595
  return value;
1047
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
+ }
1048
1626
  function boundedQueryLimit(value, maximum, fallback) {
1049
1627
  if (value === undefined)
1050
1628
  return fallback;