@mono-agent/web 0.20.10 → 0.20.14

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 (43) hide show
  1. package/README.md +19 -8
  2. package/dist/contracts.d.ts +82 -2
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js.map +1 -1
  5. package/dist/effort-ladder.d.ts +97 -0
  6. package/dist/effort-ladder.d.ts.map +1 -0
  7. package/dist/effort-ladder.js +114 -0
  8. package/dist/effort-ladder.js.map +1 -0
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/notification-client.d.ts +11 -2
  13. package/dist/notification-client.d.ts.map +1 -1
  14. package/dist/notification-client.js +1 -1
  15. package/dist/notification-client.js.map +1 -1
  16. package/dist/notification-ingress.d.ts +8 -1
  17. package/dist/notification-ingress.d.ts.map +1 -1
  18. package/dist/notification-ingress.js +28 -6
  19. package/dist/notification-ingress.js.map +1 -1
  20. package/dist/operator-client.d.ts +10 -1
  21. package/dist/operator-client.d.ts.map +1 -1
  22. package/dist/operator-client.js +110 -2
  23. package/dist/operator-client.js.map +1 -1
  24. package/dist/server.d.ts.map +1 -1
  25. package/dist/server.js +52 -3
  26. package/dist/server.js.map +1 -1
  27. package/dist/service.d.ts +117 -12
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +661 -69
  30. package/dist/service.js.map +1 -1
  31. package/dist/store.d.ts +124 -6
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +543 -34
  34. package/dist/store.js.map +1 -1
  35. package/package.json +5 -7
  36. package/webapp/dist/assets/{assistant-ui-CxqQJmH9.js → assistant-ui-BzN2E6n6.js} +1 -1
  37. package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
  38. package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
  39. package/webapp/dist/assets/{markdown-8pwMczg6.js → markdown-Vq23xgh7.js} +1 -1
  40. package/webapp/dist/index.html +4 -4
  41. package/webapp/dist/sw.js +1 -1
  42. package/webapp/dist/assets/index-DG1ELH22.css +0 -1
  43. package/webapp/dist/assets/index-j6VTEsJ5.js +0 -155
package/dist/service.js CHANGED
@@ -1,10 +1,11 @@
1
- import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
- import { readFile } from "node:fs/promises";
3
- import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
1
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
+ import { AGENT_LIVE_INPUT_MAX_CHARACTERS, DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
4
4
  import { EFFORT_LEVELS } from "@mono-agent/config";
5
5
  import { WEB_API_VERSION, WEB_MAX_CONCURRENT_UPLOADS, WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, WEB_MAX_FILES_PER_TURN, WEB_MAX_STAGED_UPLOAD_BYTES, WEB_MAX_STAGED_UPLOADS, WEB_MAX_QUEUED_ATTACHMENT_TURNS, WEB_MAX_TURN_TEXT_CHARACTERS, WEB_MAX_TURN_ATTACHMENT_BYTES, WEB_STAGED_UPLOAD_TTL_MS, } from "./contracts.js";
6
6
  import { discoverOperatorAgents, } from "./discovery.js";
7
7
  import { conversationTitleFromFrame } from "./conversation-title.js";
8
+ import { advertisedEffortLevels, effectiveModelForAgent, effortLevelsForModel } from "./effort-ladder.js";
8
9
  import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
9
10
  import { OperatorClient } from "./operator-client.js";
10
11
  import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
@@ -14,7 +15,15 @@ const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
14
15
  const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
15
16
  const INFO_TIMEOUT_MS = 2_500;
16
17
  const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
18
+ /** Bounded per-agent catalog-admitted model refs; beyond it, oldest go first. */
19
+ const MODEL_CATALOG_CACHE_CAP = 2_048;
17
20
  const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
21
+ /**
22
+ * Raster types the console keeps its own copy of. `image/svg+xml` is absent on
23
+ * purpose: it is active content, and both the inline gate in the browser and
24
+ * `setReplyDownloadHeaders` already refuse to treat it as an image.
25
+ */
26
+ const REPLY_IMAGE_MEDIA_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
18
27
  function formatQuotedTurn(quote, text) {
19
28
  const blockquote = quote
20
29
  .trim()
@@ -23,6 +32,31 @@ function formatQuotedTurn(quote, text) {
23
32
  .join("\n");
24
33
  return `Quoted context:\n${blockquote}\n\n${text}`;
25
34
  }
35
+ function assertMonitorWakeAddress(input) {
36
+ const originConversation = input.monitor.origin.conversationId.split("#", 1)[0];
37
+ const expectedDeliveryKey = `monitor:${input.monitor.monitorId}:${String(input.monitor.counters.seq)}`;
38
+ if (input.monitor.origin.channel !== "web"
39
+ || originConversation !== `web:${input.threadId}`
40
+ || input.deliveryKey !== expectedDeliveryKey) {
41
+ throw new WebConsoleError("invalid_notification", "The Monitor wake origin or delivery key does not match its web destination.", 409);
42
+ }
43
+ }
44
+ function monitorWakePayloadSha256(monitor, wakePrompt) {
45
+ return createHash("sha256")
46
+ .update(canonicalJson(monitor))
47
+ .update("\0")
48
+ .update(wakePrompt)
49
+ .digest("hex");
50
+ }
51
+ function canonicalJson(value) {
52
+ if (Array.isArray(value))
53
+ return `[${value.map((entry) => canonicalJson(entry)).join(",")}]`;
54
+ if (typeof value === "object" && value !== null) {
55
+ const record = value;
56
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
57
+ }
58
+ return JSON.stringify(value) ?? "null";
59
+ }
26
60
  export class WebService {
27
61
  store;
28
62
  options;
@@ -33,9 +67,10 @@ export class WebService {
33
67
  drainingLiveInputThreads = new Set();
34
68
  activeUploads = new Map();
35
69
  activeNotifications = new Map();
36
- processJobWakeTails = new Map();
37
- processJobWakeReservations = new Map();
38
- activeProcessJobWakes = new Map();
70
+ /** One serialization lane shared by queued user input and every host wake kind. */
71
+ hostWakeTails = new Map();
72
+ hostWakeReservations = new Map();
73
+ activeHostWakes = new Map();
39
74
  allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
40
75
  attachmentTurnBudget;
41
76
  pushIdentity;
@@ -44,6 +79,15 @@ export class WebService {
44
79
  replyAccessKey;
45
80
  askWatches = new Map();
46
81
  connections = new Map();
82
+ /** Bounded catalog-admitted model refs per agent, seeded from `modelOptions`
83
+ * and appended to by every proxied `/v1/models` page. Admission is `has`,
84
+ * metadata is `get`. Map preserves insertion order, so evicting the oldest
85
+ * entry is deleting the head. Scoped to the agent GENERATION that filled it
86
+ * (see `reconcileModelCatalogCache`), because a source id outlives the
87
+ * process behind it. */
88
+ modelCatalogCache = new Map();
89
+ /** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
90
+ persistingReplyImages = new Set();
47
91
  discoveryTimer;
48
92
  purgeTimer;
49
93
  purgePromise;
@@ -309,6 +353,23 @@ export class WebService {
309
353
  return result;
310
354
  }
311
355
  patchThread(id, patch) {
356
+ if (patch.ifRunConfigUnset === true) {
357
+ // Compare-and-set for the console's one-time adoption of a browser-local
358
+ // override. Whoever set an override first keeps it; the loser adopts what
359
+ // it reads back, and no event is emitted because nothing changed.
360
+ //
361
+ // The check and the write are one `BEGIN IMMEDIATE` inside the store, not
362
+ // two calls from here: this request handler is synchronous, so no other
363
+ // HTTP request can interleave, but the process lease is held on a
364
+ // separate database file and does not stop a second connection to the
365
+ // state DB from writing between a bare read and a bare write.
366
+ const result = this.store.patchThreadIfRunConfigUnset(id, patch);
367
+ if (!result.applied)
368
+ return result.thread;
369
+ this.emit("thread.changed", result.thread.id, { thread: result.thread });
370
+ this.emit("threads.changed", result.thread.id);
371
+ return result.thread;
372
+ }
312
373
  const thread = this.store.patchThread(id, patch);
313
374
  this.emit("thread.changed", thread.id, { thread });
314
375
  this.emit("threads.changed", thread.id);
@@ -333,7 +394,7 @@ export class WebService {
333
394
  }
334
395
  patchAgent(sourceId, patch) {
335
396
  const agent = this.store.setAgentPinned(sourceId, patch.pinned);
336
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
397
+ this.emit("agents.changed");
337
398
  return agent;
338
399
  }
339
400
  agentSkills(sourceId) {
@@ -408,6 +469,47 @@ export class WebService {
408
469
  }
409
470
  return this.decorateMessage(message);
410
471
  }
472
+ async agentModels(sourceId, input) {
473
+ const agent = this.store.getAgent(sourceId);
474
+ if (agent === undefined)
475
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
476
+ const connection = this.connections.get(sourceId);
477
+ if (connection === undefined)
478
+ throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
479
+ const generation = this.modelCatalogCache.get(sourceId)?.generation;
480
+ const page = await connection.client.models({
481
+ ...(input.provider === undefined ? {} : { provider: input.provider }),
482
+ ...(input.q === undefined ? {} : { q: input.q }),
483
+ ...(input.cursor === undefined ? {} : { cursor: input.cursor }),
484
+ limit: input.limit,
485
+ signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
486
+ });
487
+ // Every proxied page widens the per-agent catalog cache, the only feed
488
+ // (besides `modelOptions` keys) that makes tier-2 model admission possible,
489
+ // and the only place the effort ladder a catalog model advertises is ever
490
+ // seen -- `modelOptions` never describes it, so dropping it here is what
491
+ // left effort validation with nothing to judge against.
492
+ //
493
+ // Admitted under the generation the request was ISSUED under, never under
494
+ // whatever is current when it answers. A discovery refresh can retire this
495
+ // agent's generation inside the await above, and keyed by source id alone
496
+ // the reply -- fetched from a process that is gone -- was written straight
497
+ // into the freshly reconciled map, where `source: "page"` overwrites
498
+ // unconditionally. Generation 1's ladder then judged generation 2's turns.
499
+ this.admitCatalogRefs(sourceId, generation, page.models.flatMap((model) => {
500
+ const record = { source: "page", efforts: advertisedEffortLevels(model) };
501
+ // The wire carries provider-local ids while every selection surface
502
+ // speaks the canonical `<provider>:<model>` reference. Admit both, or a
503
+ // turn is judged against metadata the page did advertise but under a
504
+ // name nothing ever asks for.
505
+ const reference = model.provider ? `${model.provider}:${model.id}` : model.id;
506
+ const entries = [[model.id, record]];
507
+ if (reference !== model.id)
508
+ entries.push([reference, record]);
509
+ return entries;
510
+ }));
511
+ return page;
512
+ }
411
513
  async cronConfigView(sourceId) {
412
514
  const connection = this.requireCronConnection(sourceId, false);
413
515
  return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
@@ -449,6 +551,29 @@ export class WebService {
449
551
  }
450
552
  if (this.store.getAgent(input.sourceId) === undefined)
451
553
  await this.refreshAgents();
554
+ if (this.stopped) {
555
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
556
+ }
557
+ if (input.triggerKind === "monitor") {
558
+ assertMonitorWakeAddress(input);
559
+ const thread = this.store.getThread(input.threadId);
560
+ if (thread === undefined || thread.sourceId !== input.sourceId) {
561
+ return {
562
+ duplicate: true,
563
+ tombstoned: true,
564
+ delivery: { delivered: false, code: "monitor_origin_mismatch", retryable: false },
565
+ };
566
+ }
567
+ if (thread.archivedAt !== null || thread.trigger !== undefined) {
568
+ return {
569
+ thread,
570
+ duplicate: false,
571
+ delivery: { delivered: false, code: "monitor_wake_failed", retryable: false },
572
+ };
573
+ }
574
+ const result = await this.deliverMonitorWake(input);
575
+ return { thread, duplicate: result.duplicate, delivery: result.receipt };
576
+ }
452
577
  if (input.triggerKind === "job") {
453
578
  const completed = this.store.upsertProcessJobCard({
454
579
  sourceId: input.sourceId,
@@ -468,6 +593,9 @@ export class WebService {
468
593
  return completed;
469
594
  if (this.connections.get(input.sourceId) === undefined)
470
595
  await this.refreshAgents();
596
+ if (this.stopped) {
597
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
598
+ }
471
599
  const delivery = await this.deliverProcessJobWake(input);
472
600
  return { ...completed, delivery };
473
601
  }
@@ -522,8 +650,15 @@ export class WebService {
522
650
  if (agent === undefined || connection === undefined || !thread.canSend) {
523
651
  throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
524
652
  }
525
- validateModelAndEffort(agent, input.model, input.effort);
526
- const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.model === undefined ? {} : { model: input.model }), ...(input.effort === undefined ? {} : { effort: input.effort }) });
653
+ // The per-thread override is server state now, so it governs turns this
654
+ // server starts too -- process-job follow-ups and other assistant-owned
655
+ // wakes omit model/effort and would otherwise silently run on the agent
656
+ // default, ignoring the selection made in that very conversation. An
657
+ // explicit request value still wins.
658
+ const model = input.model ?? thread.runModel ?? undefined;
659
+ const effort = input.effort ?? thread.runEffort ?? undefined;
660
+ this.validateModelAndEffort(thread.sourceId, agent, model, effort);
661
+ const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(model === undefined ? {} : { model }), ...(effort === undefined ? {} : { effort }) });
527
662
  this.launchTurn(started, connection.client, operatorText);
528
663
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
529
664
  this.emit("threads.changed", threadId);
@@ -701,7 +836,7 @@ export class WebService {
701
836
  const active = [...this.activeTurns.values()];
702
837
  const activeLiveInputs = [...this.activeLiveInputs.entries()];
703
838
  const activeNotifications = [...this.activeNotifications.values()];
704
- const activeProcessJobWakes = [...this.activeProcessJobWakes.values()];
839
+ const activeHostWakes = [...this.activeHostWakes.values()];
705
840
  const trackedIds = new Set(active.map((turn) => turn.turnId));
706
841
  for (const turnId of this.store.listActiveTurnIds()) {
707
842
  if (!trackedIds.has(turnId))
@@ -717,7 +852,7 @@ export class WebService {
717
852
  }
718
853
  await Promise.allSettled(active.map((turn) => turn.completion));
719
854
  await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
720
- await Promise.allSettled(activeProcessJobWakes);
855
+ await Promise.allSettled(activeHostWakes);
721
856
  await Promise.allSettled(activeNotifications);
722
857
  await Promise.allSettled(askWatches.map((watch) => watch.promise));
723
858
  await this.pushDispatcher.stopAndDrain(5_000);
@@ -729,7 +864,7 @@ export class WebService {
729
864
  this.store.close();
730
865
  await this.lease.release();
731
866
  }
732
- async runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey) {
867
+ async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
733
868
  const coalescer = new StreamFrameCoalescer(async (frames) => {
734
869
  const message = this.store.applyStreamFrames(started.turnId, frames);
735
870
  this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
@@ -753,13 +888,13 @@ export class WebService {
753
888
  threadId: started.thread.id,
754
889
  turnId: started.turnId,
755
890
  ...modelMetadata,
756
- ...(processJobWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
891
+ ...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
757
892
  ? { conversationTitle: { schema: 1, writable: true } }
758
893
  : {}),
759
894
  },
760
895
  tui: modelMetadata,
761
896
  },
762
- ...(processJobWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey }),
897
+ ...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
763
898
  onFrame: (frame) => {
764
899
  this.observeAskUserFrame(started.thread.id, started.turnId, frame);
765
900
  this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
@@ -767,11 +902,19 @@ export class WebService {
767
902
  },
768
903
  });
769
904
  await coalescer.flush();
770
- const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts);
905
+ const silentMonitorWake = hostWakeDeliveryKey?.startsWith("monitor:") === true
906
+ && (response.finalText === undefined || response.finalText.length === 0)
907
+ && (response.parts === undefined || response.parts.length === 0);
908
+ const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts, { suppressResponsePush: silentMonitorWake });
771
909
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
772
910
  this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
773
911
  this.emit("threads.changed", started.thread.id);
774
- this.announcePushEvent(`turn:${started.turnId}:terminal`);
912
+ if (!silentMonitorWake)
913
+ this.announcePushEvent(`turn:${started.turnId}:terminal`);
914
+ // Detached: the turn is already finished and reported, and keeping a copy
915
+ // must neither delay nor fail it. The agent is still connected here, which
916
+ // is when a fetch is most likely to succeed.
917
+ void this.persistReplyImages(started.thread.id, detail.messages);
775
918
  }
776
919
  catch (error) {
777
920
  let failure = error;
@@ -800,14 +943,14 @@ export class WebService {
800
943
  coalescer.close();
801
944
  }
802
945
  }
803
- launchTurn(started, client, operatorText, processJobWakeDeliveryKey) {
946
+ launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
804
947
  const threadId = started.thread.id;
805
948
  const controller = new AbortController();
806
- const completion = this.runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey).finally(() => {
949
+ const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey).finally(() => {
807
950
  const active = this.activeTurns.get(threadId);
808
951
  if (active?.turnId === started.turnId)
809
952
  this.activeTurns.delete(threadId);
810
- if (!this.stopped && !this.processJobWakeReservations.has(threadId)) {
953
+ if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
811
954
  void this.drainQueuedLiveInputs(threadId);
812
955
  }
813
956
  });
@@ -853,7 +996,7 @@ export class WebService {
853
996
  async drainQueuedLiveInputs(threadId) {
854
997
  if (this.stopped
855
998
  || this.activeTurns.has(threadId)
856
- || this.processJobWakeReservations.has(threadId)
999
+ || this.hostWakeReservations.has(threadId)
857
1000
  || this.drainingLiveInputThreads.has(threadId))
858
1001
  return;
859
1002
  this.drainingLiveInputThreads.add(threadId);
@@ -880,8 +1023,8 @@ export class WebService {
880
1023
  }
881
1024
  }
882
1025
  async deliverProcessJobWake(input) {
883
- const activeKey = `${input.sourceId}\0${input.processJob.jobId}`;
884
- const existing = this.activeProcessJobWakes.get(activeKey);
1026
+ const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
1027
+ const existing = this.activeHostWakes.get(activeKey);
885
1028
  if (existing !== undefined)
886
1029
  return await existing;
887
1030
  const reservation = this.store.reserveProcessJobWake({
@@ -901,8 +1044,8 @@ export class WebService {
901
1044
  ambiguous: true,
902
1045
  };
903
1046
  }
904
- this.retainProcessJobWakeReservation(input.threadId);
905
- const previous = this.processJobWakeTails.get(input.threadId) ?? Promise.resolve();
1047
+ this.retainHostWakeReservation(input.threadId);
1048
+ const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
906
1049
  const delivery = previous.catch(() => undefined).then(async () => {
907
1050
  const connection = this.connections.get(input.sourceId);
908
1051
  if (connection === undefined) {
@@ -1011,30 +1154,206 @@ export class WebService {
1011
1154
  return { delivered: true, disposition: "follow_up" };
1012
1155
  });
1013
1156
  const tail = delivery.then(() => undefined, () => undefined);
1014
- this.processJobWakeTails.set(input.threadId, tail);
1015
- this.activeProcessJobWakes.set(activeKey, delivery);
1157
+ this.hostWakeTails.set(input.threadId, tail);
1158
+ this.activeHostWakes.set(activeKey, delivery);
1016
1159
  try {
1017
1160
  return await delivery;
1018
1161
  }
1019
1162
  finally {
1020
- if (this.processJobWakeTails.get(input.threadId) === tail) {
1021
- this.processJobWakeTails.delete(input.threadId);
1163
+ if (this.hostWakeTails.get(input.threadId) === tail) {
1164
+ this.hostWakeTails.delete(input.threadId);
1165
+ }
1166
+ if (this.activeHostWakes.get(activeKey) === delivery) {
1167
+ this.activeHostWakes.delete(activeKey);
1168
+ }
1169
+ this.releaseHostWakeReservation(input.threadId);
1170
+ }
1171
+ }
1172
+ async deliverMonitorWake(input) {
1173
+ const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
1174
+ const reservation = this.store.reserveMonitorWake({
1175
+ sourceId: input.sourceId,
1176
+ threadId: input.threadId,
1177
+ monitorId: input.monitor.monitorId,
1178
+ deliveryKey: input.deliveryKey,
1179
+ payloadSha256: monitorWakePayloadSha256(input.monitor, input.wakePrompt),
1180
+ monitor: input.monitor,
1181
+ });
1182
+ if (reservation.kind === "completed") {
1183
+ return {
1184
+ receipt: { delivered: true, disposition: reservation.disposition },
1185
+ duplicate: true,
1186
+ };
1187
+ }
1188
+ if (reservation.kind === "uncertain") {
1189
+ const existing = this.activeHostWakes.get(activeKey);
1190
+ if (existing !== undefined)
1191
+ return { receipt: await existing, duplicate: true };
1192
+ return {
1193
+ receipt: {
1194
+ delivered: false,
1195
+ code: "monitor_wake_ambiguous",
1196
+ retryable: false,
1197
+ ambiguous: true,
1198
+ },
1199
+ duplicate: true,
1200
+ };
1201
+ }
1202
+ this.retainHostWakeReservation(input.threadId);
1203
+ const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
1204
+ const delivery = previous.catch(() => undefined).then(async () => {
1205
+ const abandon = () => this.store.abandonMonitorWake({
1206
+ sourceId: input.sourceId,
1207
+ monitorId: input.monitor.monitorId,
1208
+ deliveryKey: input.deliveryKey,
1209
+ });
1210
+ let connection = this.connections.get(input.sourceId);
1211
+ if (connection === undefined) {
1212
+ try {
1213
+ await this.refreshAgents();
1214
+ }
1215
+ catch (error) {
1216
+ abandon();
1217
+ this.options.logger?.debug?.("Web Monitor destination refresh failed before delivery.", {
1218
+ threadId: input.threadId,
1219
+ monitorId: input.monitor.monitorId,
1220
+ error: errorMessage(error),
1221
+ });
1222
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1223
+ }
1224
+ connection = this.connections.get(input.sourceId);
1225
+ }
1226
+ if (this.stopped || connection === undefined) {
1227
+ abandon();
1228
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1229
+ }
1230
+ const destination = this.store.getThread(input.threadId);
1231
+ if (destination === undefined
1232
+ || destination.sourceId !== input.sourceId
1233
+ || destination.archivedAt !== null
1234
+ || destination.trigger !== undefined) {
1235
+ abandon();
1236
+ return { delivered: false, code: "monitor_origin_mismatch", retryable: false };
1237
+ }
1238
+ const active = this.activeTurns.get(input.threadId);
1239
+ if (active !== undefined
1240
+ && connection.info.supportsLiveInput
1241
+ && input.wakePrompt.length <= AGENT_LIVE_INPUT_MAX_CHARACTERS) {
1242
+ try {
1243
+ const settlement = await active.client.liveInput({
1244
+ conversationId: `web:${input.threadId}`,
1245
+ id: input.deliveryKey,
1246
+ text: input.wakePrompt,
1247
+ receivedAt: new Date().toISOString(),
1248
+ deliveryKey: input.deliveryKey,
1249
+ signal: AbortSignal.timeout(10 * 60 * 1_000),
1250
+ });
1251
+ if (settlement.status === "applied") {
1252
+ const message = this.store.completeMonitorWake({
1253
+ sourceId: input.sourceId,
1254
+ monitorId: input.monitor.monitorId,
1255
+ deliveryKey: input.deliveryKey,
1256
+ disposition: "steered",
1257
+ turnId: active.turnId,
1258
+ });
1259
+ if (message !== undefined) {
1260
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
1261
+ }
1262
+ return { delivered: true, disposition: "steered" };
1263
+ }
1264
+ }
1265
+ catch (error) {
1266
+ this.options.logger?.warn?.("Web Monitor steering outcome is unknown; automatic fallback is suppressed.", {
1267
+ threadId: input.threadId,
1268
+ monitorId: input.monitor.monitorId,
1269
+ error: errorMessage(error),
1270
+ });
1271
+ return {
1272
+ delivered: false,
1273
+ code: "monitor_wake_ambiguous",
1274
+ retryable: false,
1275
+ ambiguous: true,
1276
+ };
1277
+ }
1278
+ }
1279
+ if (active !== undefined)
1280
+ await active.completion;
1281
+ if (this.stopped) {
1282
+ abandon();
1283
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1284
+ }
1285
+ const refreshedConnection = this.connections.get(input.sourceId);
1286
+ if (refreshedConnection === undefined) {
1287
+ abandon();
1288
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1289
+ }
1290
+ let started;
1291
+ try {
1292
+ started = this.store.beginAssistantTurn({
1293
+ threadId: input.threadId,
1294
+ prompt: input.wakePrompt,
1295
+ storedPrompt: "[Monitor wake]",
1296
+ });
1297
+ }
1298
+ catch (error) {
1299
+ abandon();
1300
+ return {
1301
+ delivered: false,
1302
+ code: errorCode(error) ?? "monitor_wake_failed",
1303
+ retryable: false,
1304
+ };
1305
+ }
1306
+ const completion = this.launchTurn(started, refreshedConnection.client, input.wakePrompt, input.deliveryKey);
1307
+ this.emit("message.changed", input.threadId, {
1308
+ messageId: started.assistantMessageId,
1309
+ updatedAt: started.thread.updatedAt,
1310
+ });
1311
+ this.emit("turn.changed", input.threadId, { turn: started.thread.runState });
1312
+ this.emit("threads.changed", input.threadId);
1313
+ await completion;
1314
+ if (this.store.turnStatus(started.turnId) !== "complete") {
1315
+ return {
1316
+ delivered: false,
1317
+ code: "monitor_wake_failed",
1318
+ retryable: false,
1319
+ ambiguous: true,
1320
+ };
1022
1321
  }
1023
- if (this.activeProcessJobWakes.get(activeKey) === delivery) {
1024
- this.activeProcessJobWakes.delete(activeKey);
1322
+ const message = this.store.completeMonitorWake({
1323
+ sourceId: input.sourceId,
1324
+ monitorId: input.monitor.monitorId,
1325
+ deliveryKey: input.deliveryKey,
1326
+ disposition: "follow_up",
1327
+ turnId: started.turnId,
1328
+ });
1329
+ if (message !== undefined) {
1330
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
1025
1331
  }
1026
- this.releaseProcessJobWakeReservation(input.threadId);
1332
+ return { delivered: true, disposition: "follow_up" };
1333
+ });
1334
+ const tail = delivery.then(() => undefined, () => undefined);
1335
+ this.hostWakeTails.set(input.threadId, tail);
1336
+ this.activeHostWakes.set(activeKey, delivery);
1337
+ try {
1338
+ return { receipt: await delivery, duplicate: false };
1339
+ }
1340
+ finally {
1341
+ if (this.hostWakeTails.get(input.threadId) === tail)
1342
+ this.hostWakeTails.delete(input.threadId);
1343
+ if (this.activeHostWakes.get(activeKey) === delivery)
1344
+ this.activeHostWakes.delete(activeKey);
1345
+ this.releaseHostWakeReservation(input.threadId);
1027
1346
  }
1028
1347
  }
1029
- retainProcessJobWakeReservation(threadId) {
1030
- this.processJobWakeReservations.set(threadId, (this.processJobWakeReservations.get(threadId) ?? 0) + 1);
1348
+ retainHostWakeReservation(threadId) {
1349
+ this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
1031
1350
  }
1032
- releaseProcessJobWakeReservation(threadId) {
1033
- const remaining = (this.processJobWakeReservations.get(threadId) ?? 1) - 1;
1351
+ releaseHostWakeReservation(threadId) {
1352
+ const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
1034
1353
  if (remaining > 0)
1035
- this.processJobWakeReservations.set(threadId, remaining);
1354
+ this.hostWakeReservations.set(threadId, remaining);
1036
1355
  else
1037
- this.processJobWakeReservations.delete(threadId);
1356
+ this.hostWakeReservations.delete(threadId);
1038
1357
  if (!this.stopped)
1039
1358
  void this.drainQueuedLiveInputs(threadId);
1040
1359
  }
@@ -1092,13 +1411,22 @@ export class WebService {
1092
1411
  const changed = this.store.replaceAgents([]);
1093
1412
  this.connections = new Map();
1094
1413
  if (changed)
1095
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
1414
+ this.emit("agents.changed");
1096
1415
  return;
1097
1416
  }
1098
1417
  const nextConnections = new Map();
1418
+ // What the cache is allowed to survive: the same process, at the same
1419
+ // endpoint, since the same start. Anything else is a new generation whose
1420
+ // catalog the previous one cannot speak for.
1421
+ const generations = new Map(discovered.map((agent) => [
1422
+ agent.source.sourceId,
1423
+ agentGeneration(agent),
1424
+ ]));
1425
+ this.reconcileModelCatalogCache(generations);
1099
1426
  const summaries = await Promise.all(discovered.map(async (agent) => {
1427
+ const generation = generations.get(agent.source.sourceId);
1100
1428
  if (agent.baseUrl === undefined)
1101
- return offlineSummary(agent);
1429
+ return offlineSummary(agent, generation);
1102
1430
  const client = new OperatorClient({
1103
1431
  baseUrl: agent.baseUrl,
1104
1432
  ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
@@ -1108,9 +1436,11 @@ export class WebService {
1108
1436
  try {
1109
1437
  const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1110
1438
  nextConnections.set(agent.source.sourceId, { client, info });
1439
+ this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
1111
1440
  const efforts = collectEfforts(info);
1112
1441
  return {
1113
1442
  sourceId: agent.source.sourceId,
1443
+ generation,
1114
1444
  label: info.label ?? agent.source.label,
1115
1445
  status: agent.source.health === "running" ? "online" : "degraded",
1116
1446
  pinned: false,
@@ -1121,6 +1451,7 @@ export class WebService {
1121
1451
  ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
1122
1452
  ...(efforts.length === 0 ? {} : { efforts }),
1123
1453
  ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
1454
+ ...(info.providers === undefined ? {} : { providers: info.providers }),
1124
1455
  ...(info.cron === undefined ? {} : { cron: info.cron }),
1125
1456
  ...(info.supportsAskById ? { supportsAskById: true } : {}),
1126
1457
  updatedAt: agent.source.updatedAt,
@@ -1131,7 +1462,7 @@ export class WebService {
1131
1462
  sourceId: agent.source.sourceId,
1132
1463
  error: errorMessage(error),
1133
1464
  });
1134
- return offlineSummary(agent);
1465
+ return offlineSummary(agent, generation);
1135
1466
  }
1136
1467
  }));
1137
1468
  this.connections = nextConnections;
@@ -1154,7 +1485,7 @@ export class WebService {
1154
1485
  }
1155
1486
  }));
1156
1487
  if (agentsChanged)
1157
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
1488
+ this.emit("agents.changed");
1158
1489
  for (const sourceId of cronChangedSources)
1159
1490
  this.emit("cron.changed", undefined, { sourceId });
1160
1491
  if (cronChangedSources.size > 0)
@@ -1353,6 +1684,116 @@ export class WebService {
1353
1684
  }
1354
1685
  return connection;
1355
1686
  }
1687
+ /**
1688
+ * Tier-2 admission: the catalog cache, seeded from `modelOptions` keys and
1689
+ * appended to by every proxied `/v1/models` page.
1690
+ *
1691
+ * Both feeds cross an await between deciding what to admit and admitting it,
1692
+ * so both name the generation they read. A write whose generation is no
1693
+ * longer the one on file is dropped outright rather than filed under the
1694
+ * successor: it describes a process that has been replaced, and admitting it
1695
+ * is exactly the cross-generation contamination `reconcileModelCatalogCache`
1696
+ * exists to prevent. Nothing is created here either -- an entry exists for
1697
+ * every discovered source from the moment a refresh reconciles it, and a
1698
+ * source with no entry is one discovery has dropped.
1699
+ *
1700
+ * @returns whether the refs were admitted.
1701
+ */
1702
+ admitCatalogRefs(sourceId, generation, refs) {
1703
+ if (generation === undefined)
1704
+ return false;
1705
+ const entry = this.modelCatalogCache.get(sourceId);
1706
+ if (entry === undefined || entry.generation !== generation)
1707
+ return false;
1708
+ for (const [ref, record] of refs)
1709
+ this.admitModelRef(entry.models, ref, record);
1710
+ return true;
1711
+ }
1712
+ /**
1713
+ * Bind the cache to the agent process that filled it, and to nothing else.
1714
+ * A source id outlives the process behind it: reconfigure an agent and
1715
+ * restart it and the next generation advertises a different catalog under
1716
+ * the same id. Keyed by id alone, generation 1's ladder judged generation
1717
+ * 2's turns and rejected grades the running agent accepts, with no way to
1718
+ * clear it short of restarting the console.
1719
+ *
1720
+ * The single place the cache is scoped, so there is one answer to "whose
1721
+ * catalog is this". Runs before the per-agent probes of a refresh, so a seed
1722
+ * or a proxied page during that refresh files under the current generation.
1723
+ * Sources discovery no longer reports are dropped outright, or a retired
1724
+ * agent's refs would accumulate for the life of the process.
1725
+ */
1726
+ reconcileModelCatalogCache(generations) {
1727
+ for (const sourceId of [...this.modelCatalogCache.keys()]) {
1728
+ if (!generations.has(sourceId))
1729
+ this.modelCatalogCache.delete(sourceId);
1730
+ }
1731
+ for (const [sourceId, generation] of generations) {
1732
+ if (this.modelCatalogCache.get(sourceId)?.generation === generation)
1733
+ continue;
1734
+ this.modelCatalogCache.set(sourceId, { generation, models: new Map() });
1735
+ }
1736
+ }
1737
+ seedModelCatalogFromOptions(sourceId, generation, modelOptions) {
1738
+ if (modelOptions === undefined)
1739
+ return;
1740
+ // `modelOptions` stays the authority for the refs it names, so the seed
1741
+ // only records admission; recording a ladder here would shadow it.
1742
+ this.admitCatalogRefs(sourceId, generation, Object.keys(modelOptions).map((key) => [key, { source: "shortlist", efforts: undefined }]));
1743
+ }
1744
+ admitModelRef(entries, ref, record) {
1745
+ const known = entries.get(ref);
1746
+ if (known !== undefined) {
1747
+ // A page is the live word on what it serves, so its metadata replaces
1748
+ // whatever is held -- including replacing a ladder with silence, which
1749
+ // is how a re-fetched catalog heals a model whose grades changed. The
1750
+ // shortlist seed only records admission and must never shadow a page.
1751
+ // Re-setting an existing key leaves its eviction position alone.
1752
+ if (record.source === "page")
1753
+ entries.set(ref, record);
1754
+ return;
1755
+ }
1756
+ entries.set(ref, record);
1757
+ if (entries.size > MODEL_CATALOG_CACHE_CAP) {
1758
+ const oldest = entries.keys().next().value;
1759
+ if (oldest !== undefined)
1760
+ entries.delete(oldest);
1761
+ }
1762
+ }
1763
+ validateModelAndEffort(sourceId, agent, model, effort) {
1764
+ if (model !== undefined && !this.modelAdmitted(sourceId, agent, model)) {
1765
+ throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
1766
+ }
1767
+ // Both ends resolve a blank selection to the same route -- the browser fell
1768
+ // back to the first shortlist entry while this stopped at `defaultModel`,
1769
+ // so any `/v1/info` omitting `model` had the picker offering one ladder and
1770
+ // this rejecting from another.
1771
+ const effectiveModel = effectiveModelForAgent(agent, model);
1772
+ // `modelOptions` only ever covers the configured shortlist, so a model
1773
+ // reached through the provider catalog has no entry there. `effort-ladder`
1774
+ // holds the tiering, and the browser runs the exact same function, so the
1775
+ // picker cannot offer a grade this rejects or hide one it accepts.
1776
+ const cached = effectiveModel === undefined
1777
+ ? undefined
1778
+ : this.modelCatalogCache.get(sourceId)?.models.get(effectiveModel);
1779
+ const allowedEfforts = effortLevelsForModel(agent, effectiveModel, cached?.efforts);
1780
+ if (effort !== undefined && !allowedEfforts.includes(effort)) {
1781
+ throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
1782
+ }
1783
+ }
1784
+ modelAdmitted(sourceId, agent, model) {
1785
+ // Tier 1: the configured-route shortlist — unchanged, always allowed.
1786
+ if (agent.models === undefined ? model === agent.defaultModel : agent.models.includes(model))
1787
+ return true;
1788
+ // Tier 2: a model reached only through the catalog cache.
1789
+ const cached = this.modelCatalogCache.get(sourceId);
1790
+ if (cached !== undefined && cached.models.has(model))
1791
+ return true;
1792
+ // Tier 3: syntactic `<provider>:<model>` floor. The web package has no pi-ai
1793
+ // access, so a well-formed ref passes here and the agent itself is the real
1794
+ // gate at turn time.
1795
+ return modelPassesSyntacticFloor(model);
1796
+ }
1356
1797
  authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
1357
1798
  const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
1358
1799
  if (access === "invalid") {
@@ -1385,6 +1826,10 @@ export class WebService {
1385
1826
  }
1386
1827
  }
1387
1828
  decorateThreadDetail(detail) {
1829
+ // Backfill: messages that predate this feature, and any turn whose own
1830
+ // attempt failed or was interrupted. Idempotent and guarded, so repeated
1831
+ // reads of the same thread fetch each image at most once.
1832
+ void this.persistReplyImages(detail.thread.id, detail.messages);
1388
1833
  return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
1389
1834
  }
1390
1835
  decorateMessage(message) {
@@ -1395,14 +1840,123 @@ export class WebService {
1395
1840
  });
1396
1841
  return { ...message, parts };
1397
1842
  }
1843
+ /**
1844
+ * Keeps the console's own copy of an image the agent published.
1845
+ *
1846
+ * Reply artifacts are proxied from the agent and never stored, so without this
1847
+ * a generated image dies at the agent's retention deadline and shows broken
1848
+ * whenever that agent is stopped. Raster types only: `image/svg+xml` is active
1849
+ * content, is refused inline by both the client and `setReplyDownloadHeaders`,
1850
+ * and is deliberately never persisted either.
1851
+ *
1852
+ * Entirely best-effort. Every failure leaves the part on its existing
1853
+ * capability path, because a stored copy is an optimisation and a turn must
1854
+ * never fail over one.
1855
+ */
1856
+ async persistReplyImages(threadId, messages) {
1857
+ for (const message of messages) {
1858
+ for (const part of message.parts) {
1859
+ if (part.type !== "attachment")
1860
+ continue;
1861
+ if (!REPLY_IMAGE_MEDIA_TYPES.has(part.mediaType.toLowerCase()))
1862
+ continue;
1863
+ if (part.sizeBytes > DEFAULT_AGENT_ATTACHMENT_MAX_BYTES)
1864
+ continue;
1865
+ if (this.store.storedReplyAttachment(message.id, part.id) !== undefined)
1866
+ continue;
1867
+ const key = WebStore.replyAttachmentId(message.id, part.id);
1868
+ if (this.persistingReplyImages.has(key))
1869
+ continue;
1870
+ this.persistingReplyImages.add(key);
1871
+ try {
1872
+ await this.persistReplyImage(threadId, message, part);
1873
+ }
1874
+ catch (error) {
1875
+ this.options.logger?.debug?.("Web reply image was not persisted.", {
1876
+ threadId,
1877
+ messageId: message.id,
1878
+ partId: part.id,
1879
+ error: errorMessage(error),
1880
+ });
1881
+ }
1882
+ finally {
1883
+ this.persistingReplyImages.delete(key);
1884
+ }
1885
+ }
1886
+ }
1887
+ }
1888
+ async persistReplyImage(threadId, message, part) {
1889
+ const thread = this.store.getThread(threadId);
1890
+ if (thread === undefined)
1891
+ return;
1892
+ const connection = this.connections.get(thread.sourceId);
1893
+ if (connection === undefined || connection.info.replyAttachments?.version !== 1)
1894
+ return;
1895
+ const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), {
1896
+ type: "attachment",
1897
+ id: part.id,
1898
+ reference: { scheme: "mono-agent-artifact", id: part.artifactId },
1899
+ name: part.name,
1900
+ mediaType: part.mediaType,
1901
+ sizeBytes: part.sizeBytes,
1902
+ integrityId: part.integrityId,
1903
+ ...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
1904
+ });
1905
+ if (!response.ok || response.body === null)
1906
+ return;
1907
+ const bytes = Buffer.from(await response.arrayBuffer());
1908
+ // The same pair the browser checks before handing a download to the user. A
1909
+ // copy that fails either is not written at all, so a corrupt artifact can
1910
+ // never be served from a stable URL that outlives its source.
1911
+ if (bytes.byteLength !== part.sizeBytes)
1912
+ return;
1913
+ const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
1914
+ if (digest !== part.integrityId.toLowerCase())
1915
+ return;
1916
+ const storageName = `${randomUUID()}.bin`;
1917
+ const destination = this.store.attachmentPath({ storageName });
1918
+ const staging = `${destination}.partial-${randomUUID()}`;
1919
+ await writeFile(staging, bytes, { mode: 0o600 });
1920
+ try {
1921
+ await rename(staging, destination);
1922
+ }
1923
+ catch (error) {
1924
+ await unlink(staging).catch(() => undefined);
1925
+ throw error;
1926
+ }
1927
+ try {
1928
+ this.store.recordReplyAttachment({
1929
+ threadId: thread.id,
1930
+ messageId: message.id,
1931
+ partId: part.id,
1932
+ name: part.name,
1933
+ contentType: part.mediaType,
1934
+ sizeBytes: bytes.byteLength,
1935
+ storageName,
1936
+ });
1937
+ }
1938
+ catch (error) {
1939
+ await unlink(destination).catch(() => undefined);
1940
+ throw error;
1941
+ }
1942
+ }
1398
1943
  decorateReplyPart(message, part) {
1944
+ // A durable copy is resolved before the retention gate below, because
1945
+ // outliving that deadline is the entire reason it was kept.
1946
+ const stored = part.type === "attachment"
1947
+ ? this.store.storedReplyAttachment(message.id, part.id)
1948
+ : undefined;
1949
+ const storedUrl = stored === undefined
1950
+ ? {}
1951
+ : { storedUrl: `/api/v1/uploads/${encodeURIComponent(stored.id)}/content` };
1399
1952
  const now = this.currentDate().getTime();
1400
1953
  const retentionDeadline = part.expiresAt === undefined
1401
1954
  ? Number.POSITIVE_INFINITY
1402
1955
  : Date.parse(part.expiresAt);
1403
1956
  const expiresAt = Math.min(now + REPLY_ACCESS_TTL_MS, retentionDeadline);
1404
- if (!Number.isFinite(expiresAt) || expiresAt <= now)
1405
- return part;
1957
+ if (!Number.isFinite(expiresAt) || expiresAt <= now) {
1958
+ return part.type === "attachment" ? { ...part, ...storedUrl } : part;
1959
+ }
1406
1960
  const expires = String(Math.floor(expiresAt / 1_000));
1407
1961
  const token = this.replyAccessToken(message.threadId, message.id, part.type, part.id, expires);
1408
1962
  const base = `/api/v1/threads/${encodeURIComponent(message.threadId)}`
@@ -1411,6 +1965,7 @@ export class WebService {
1411
1965
  return part.type === "attachment"
1412
1966
  ? {
1413
1967
  ...part,
1968
+ ...storedUrl,
1414
1969
  contentUrl: `${base}/reply-attachments/${encodeURIComponent(part.id)}/content?${query}`,
1415
1970
  }
1416
1971
  : {
@@ -1619,9 +2174,53 @@ export class WeightedTurnBudget {
1619
2174
  }
1620
2175
  }
1621
2176
  }
1622
- function offlineSummary(agent) {
2177
+ /**
2178
+ * The identity of the agent PROCESS behind a source id. `sourceId` is stable
2179
+ * across restarts by design, so it cannot scope anything the running process
2180
+ * told us: a reconfigured agent restarts at a new endpoint, with a new pid and
2181
+ * a new `startedAt`, and advertises a different catalog under the same id.
2182
+ * Deliberately excludes `updatedAt`, which every heartbeat moves.
2183
+ *
2184
+ * This is what the model catalog cache is scoped to -- and, since the browser
2185
+ * caches the same `/v1/models` pages and had nothing generation-shaped to
2186
+ * watch, what `WebAgentSummary.generation` carries to it.
2187
+ *
2188
+ * Hashed because it now goes on the wire: the raw form names the agent's
2189
+ * operator endpoint and pid, and the console has no reason to hand those to a
2190
+ * page. The token only has to be stable while one process lives and different
2191
+ * once it is replaced, which a digest of those three fields is.
2192
+ *
2193
+ * Length-prefixed rather than `|`-joined. A separator that can occur inside a
2194
+ * field is not a separator: two different accepted tuples whose parts happen to
2195
+ * contain the delimiter flatten to the same string and hash to the same token,
2196
+ * and two distinct processes sharing a generation is precisely the state the
2197
+ * token exists to make impossible. Nothing first-party produces such a tuple
2198
+ * today, which is why this is robustness rather than a live defect --- but a
2199
+ * digest whose only defence is what its inputs happen to look like is one
2200
+ * unrelated change away from being wrong.
2201
+ *
2202
+ * Hashed as UTF-16 code units for the same reason the prefix replaced the
2203
+ * delimiter. UTF-8 has no encoding for an unpaired surrogate, so a lone high
2204
+ * surrogate and a lone low surrogate both became the replacement character and
2205
+ * two different one-character fields -- identically length-prefixed -- hashed
2206
+ * alike. `utf16le` is a lossless transcription of exactly the code units the
2207
+ * length prefix counts, so what is hashed is what was measured.
2208
+ */
2209
+ export function agentGeneration(agent) {
2210
+ const parts = [
2211
+ agent.baseUrl ?? "",
2212
+ String(agent.source.pid ?? ""),
2213
+ agent.source.startedAt,
2214
+ ];
2215
+ return createHash("sha256")
2216
+ .update(parts.map((part) => `${String(part.length)}:${part}`).join(""), "utf16le")
2217
+ .digest("hex")
2218
+ .slice(0, 16);
2219
+ }
2220
+ function offlineSummary(agent, generation) {
1623
2221
  return {
1624
2222
  sourceId: agent.source.sourceId,
2223
+ generation,
1625
2224
  label: agent.source.label,
1626
2225
  status: "offline",
1627
2226
  pinned: false,
@@ -1637,32 +2236,25 @@ function collectEfforts(info) {
1637
2236
  if (info.modelOptions === undefined)
1638
2237
  return EFFORT_LEVELS;
1639
2238
  const models = info.models ?? (info.model === undefined ? [] : [info.model]);
1640
- return [...new Set(models.flatMap((model) => effortLevelsForOption(info.modelOptions?.[model])))];
1641
- }
1642
- function validateModelAndEffort(agent, model, effort) {
1643
- if (model !== undefined
1644
- && (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
1645
- throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
1646
- }
1647
- const effectiveModel = model ?? agent.defaultModel;
1648
- const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
1649
- const allowedEfforts = agent.modelOptions === undefined
1650
- ? agent.efforts
1651
- : effortLevelsForOption(option);
1652
- if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
1653
- throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
1654
- }
2239
+ // Same rule as every other effort decision, so the union an agent advertises
2240
+ // cannot disagree with what a turn on one of those models may carry.
2241
+ return [...new Set(models.flatMap((model) => effortLevelsForModel(info, model, undefined)))];
1655
2242
  }
1656
- function effortLevelsForOption(option) {
1657
- if (option === undefined
1658
- || option.reasoning === false
1659
- || option.reasoningMode === "none"
1660
- || option.effortLevels?.length === 0) {
1661
- return [];
1662
- }
1663
- if (option.reasoningMode === "toggle")
1664
- return ["high", "none"];
1665
- return option.effortLevels ?? [];
2243
+ /**
2244
+ * Tier 3: the syntactic floor. `@mono-agent/web` may not import pi-ai, so this
2245
+ * cannot be authoritative -- the agent is. It exists to reject obvious garbage,
2246
+ * and it must not be looser than the runtime parser, or a reference the console
2247
+ * accepts is silently ignored and the turn runs on the default model instead.
2248
+ */
2249
+ function modelPassesSyntacticFloor(model) {
2250
+ const separator = model.indexOf(":");
2251
+ if (separator <= 0 || separator >= model.length - 1)
2252
+ return false;
2253
+ const provider = model.slice(0, separator);
2254
+ const rest = model.slice(separator + 1);
2255
+ // Provider ids are lowercase kebab/alphanumeric; the model half may carry
2256
+ // further colons and slashes but must not be blank or padded.
2257
+ return /^[a-z0-9][a-z0-9-]*$/u.test(provider) && rest.trim() === rest && rest.trim().length > 0;
1666
2258
  }
1667
2259
  function normalizeFilename(value) {
1668
2260
  const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";