@mono-agent/web 0.20.11 → 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 (41) hide show
  1. package/README.md +19 -8
  2. package/dist/contracts.d.ts +75 -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 +100 -12
  28. package/dist/service.d.ts.map +1 -1
  29. package/dist/service.js +531 -65
  30. package/dist/service.js.map +1 -1
  31. package/dist/store.d.ts +97 -6
  32. package/dist/store.d.ts.map +1 -1
  33. package/dist/store.js +501 -33
  34. package/dist/store.js.map +1 -1
  35. package/package.json +5 -7
  36. package/webapp/dist/assets/index-C4a2Dv1W.js +155 -0
  37. package/webapp/dist/assets/index-mhMBLGB0.css +1 -0
  38. package/webapp/dist/index.html +2 -2
  39. package/webapp/dist/sw.js +1 -1
  40. package/webapp/dist/assets/index-BT463dRM.css +0 -1
  41. package/webapp/dist/assets/index-Co-qDQPq.js +0 -155
package/dist/service.js CHANGED
@@ -1,10 +1,11 @@
1
1
  import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { readFile, rename, unlink, writeFile } from "node:fs/promises";
3
- import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, createChannelUserCancelReason, isChannelUserCancelReason, toolNameLeaf, } from "@mono-agent/agent-contracts";
3
+ import { AGENT_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,6 +15,8 @@ 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;
18
21
  /**
19
22
  * Raster types the console keeps its own copy of. `image/svg+xml` is absent on
@@ -29,6 +32,31 @@ function formatQuotedTurn(quote, text) {
29
32
  .join("\n");
30
33
  return `Quoted context:\n${blockquote}\n\n${text}`;
31
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
+ }
32
60
  export class WebService {
33
61
  store;
34
62
  options;
@@ -39,9 +67,10 @@ export class WebService {
39
67
  drainingLiveInputThreads = new Set();
40
68
  activeUploads = new Map();
41
69
  activeNotifications = new Map();
42
- processJobWakeTails = new Map();
43
- processJobWakeReservations = new Map();
44
- 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();
45
74
  allowlist = new Set(DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST);
46
75
  attachmentTurnBudget;
47
76
  pushIdentity;
@@ -50,6 +79,13 @@ export class WebService {
50
79
  replyAccessKey;
51
80
  askWatches = new Map();
52
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();
53
89
  /** Parts whose durable copy is being fetched, so concurrent reads fetch once. */
54
90
  persistingReplyImages = new Set();
55
91
  discoveryTimer;
@@ -317,6 +353,23 @@ export class WebService {
317
353
  return result;
318
354
  }
319
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
+ }
320
373
  const thread = this.store.patchThread(id, patch);
321
374
  this.emit("thread.changed", thread.id, { thread });
322
375
  this.emit("threads.changed", thread.id);
@@ -341,7 +394,7 @@ export class WebService {
341
394
  }
342
395
  patchAgent(sourceId, patch) {
343
396
  const agent = this.store.setAgentPinned(sourceId, patch.pinned);
344
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
397
+ this.emit("agents.changed");
345
398
  return agent;
346
399
  }
347
400
  agentSkills(sourceId) {
@@ -416,6 +469,47 @@ export class WebService {
416
469
  }
417
470
  return this.decorateMessage(message);
418
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
+ }
419
513
  async cronConfigView(sourceId) {
420
514
  const connection = this.requireCronConnection(sourceId, false);
421
515
  return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
@@ -457,6 +551,29 @@ export class WebService {
457
551
  }
458
552
  if (this.store.getAgent(input.sourceId) === undefined)
459
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
+ }
460
577
  if (input.triggerKind === "job") {
461
578
  const completed = this.store.upsertProcessJobCard({
462
579
  sourceId: input.sourceId,
@@ -476,6 +593,9 @@ export class WebService {
476
593
  return completed;
477
594
  if (this.connections.get(input.sourceId) === undefined)
478
595
  await this.refreshAgents();
596
+ if (this.stopped) {
597
+ throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
598
+ }
479
599
  const delivery = await this.deliverProcessJobWake(input);
480
600
  return { ...completed, delivery };
481
601
  }
@@ -530,8 +650,15 @@ export class WebService {
530
650
  if (agent === undefined || connection === undefined || !thread.canSend) {
531
651
  throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
532
652
  }
533
- validateModelAndEffort(agent, input.model, input.effort);
534
- const started = this.store.beginTurn({ threadId, text, attachmentIds, ...(input.quote === undefined ? {} : { quote: input.quote }), ...(input.model === undefined ? {} : { model: input.model }), ...(input.effort === undefined ? {} : { effort: input.effort }) });
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 }) });
535
662
  this.launchTurn(started, connection.client, operatorText);
536
663
  this.emit("turn.changed", threadId, { turn: started.thread.runState });
537
664
  this.emit("threads.changed", threadId);
@@ -709,7 +836,7 @@ export class WebService {
709
836
  const active = [...this.activeTurns.values()];
710
837
  const activeLiveInputs = [...this.activeLiveInputs.entries()];
711
838
  const activeNotifications = [...this.activeNotifications.values()];
712
- const activeProcessJobWakes = [...this.activeProcessJobWakes.values()];
839
+ const activeHostWakes = [...this.activeHostWakes.values()];
713
840
  const trackedIds = new Set(active.map((turn) => turn.turnId));
714
841
  for (const turnId of this.store.listActiveTurnIds()) {
715
842
  if (!trackedIds.has(turnId))
@@ -725,7 +852,7 @@ export class WebService {
725
852
  }
726
853
  await Promise.allSettled(active.map((turn) => turn.completion));
727
854
  await Promise.allSettled(activeLiveInputs.map(([, input]) => input.completion));
728
- await Promise.allSettled(activeProcessJobWakes);
855
+ await Promise.allSettled(activeHostWakes);
729
856
  await Promise.allSettled(activeNotifications);
730
857
  await Promise.allSettled(askWatches.map((watch) => watch.promise));
731
858
  await this.pushDispatcher.stopAndDrain(5_000);
@@ -737,7 +864,7 @@ export class WebService {
737
864
  this.store.close();
738
865
  await this.lease.release();
739
866
  }
740
- async runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey) {
867
+ async runTurn(started, client, controller, operatorText, hostWakeDeliveryKey) {
741
868
  const coalescer = new StreamFrameCoalescer(async (frames) => {
742
869
  const message = this.store.applyStreamFrames(started.turnId, frames);
743
870
  this.emit("message.changed", started.thread.id, { messageId: message.id, updatedAt: message.updatedAt });
@@ -761,13 +888,13 @@ export class WebService {
761
888
  threadId: started.thread.id,
762
889
  turnId: started.turnId,
763
890
  ...modelMetadata,
764
- ...(processJobWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
891
+ ...(hostWakeDeliveryKey === undefined && this.store.canApplyAgentTitle(started.thread.id)
765
892
  ? { conversationTitle: { schema: 1, writable: true } }
766
893
  : {}),
767
894
  },
768
895
  tui: modelMetadata,
769
896
  },
770
- ...(processJobWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey }),
897
+ ...(hostWakeDeliveryKey === undefined ? {} : { processJobWakeDeliveryKey: hostWakeDeliveryKey }),
771
898
  onFrame: (frame) => {
772
899
  this.observeAskUserFrame(started.thread.id, started.turnId, frame);
773
900
  this.observeConversationTitleFrame(started.thread.id, started.turnId, frame);
@@ -775,11 +902,15 @@ export class WebService {
775
902
  },
776
903
  });
777
904
  await coalescer.flush();
778
- 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 });
779
909
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
780
910
  this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
781
911
  this.emit("threads.changed", started.thread.id);
782
- this.announcePushEvent(`turn:${started.turnId}:terminal`);
912
+ if (!silentMonitorWake)
913
+ this.announcePushEvent(`turn:${started.turnId}:terminal`);
783
914
  // Detached: the turn is already finished and reported, and keeping a copy
784
915
  // must neither delay nor fail it. The agent is still connected here, which
785
916
  // is when a fetch is most likely to succeed.
@@ -812,14 +943,14 @@ export class WebService {
812
943
  coalescer.close();
813
944
  }
814
945
  }
815
- launchTurn(started, client, operatorText, processJobWakeDeliveryKey) {
946
+ launchTurn(started, client, operatorText, hostWakeDeliveryKey) {
816
947
  const threadId = started.thread.id;
817
948
  const controller = new AbortController();
818
- const completion = this.runTurn(started, client, controller, operatorText, processJobWakeDeliveryKey).finally(() => {
949
+ const completion = this.runTurn(started, client, controller, operatorText, hostWakeDeliveryKey).finally(() => {
819
950
  const active = this.activeTurns.get(threadId);
820
951
  if (active?.turnId === started.turnId)
821
952
  this.activeTurns.delete(threadId);
822
- if (!this.stopped && !this.processJobWakeReservations.has(threadId)) {
953
+ if (!this.stopped && !this.hostWakeReservations.has(threadId)) {
823
954
  void this.drainQueuedLiveInputs(threadId);
824
955
  }
825
956
  });
@@ -865,7 +996,7 @@ export class WebService {
865
996
  async drainQueuedLiveInputs(threadId) {
866
997
  if (this.stopped
867
998
  || this.activeTurns.has(threadId)
868
- || this.processJobWakeReservations.has(threadId)
999
+ || this.hostWakeReservations.has(threadId)
869
1000
  || this.drainingLiveInputThreads.has(threadId))
870
1001
  return;
871
1002
  this.drainingLiveInputThreads.add(threadId);
@@ -892,8 +1023,8 @@ export class WebService {
892
1023
  }
893
1024
  }
894
1025
  async deliverProcessJobWake(input) {
895
- const activeKey = `${input.sourceId}\0${input.processJob.jobId}`;
896
- const existing = this.activeProcessJobWakes.get(activeKey);
1026
+ const activeKey = `${input.sourceId}\0${input.deliveryKey}`;
1027
+ const existing = this.activeHostWakes.get(activeKey);
897
1028
  if (existing !== undefined)
898
1029
  return await existing;
899
1030
  const reservation = this.store.reserveProcessJobWake({
@@ -913,8 +1044,8 @@ export class WebService {
913
1044
  ambiguous: true,
914
1045
  };
915
1046
  }
916
- this.retainProcessJobWakeReservation(input.threadId);
917
- const previous = this.processJobWakeTails.get(input.threadId) ?? Promise.resolve();
1047
+ this.retainHostWakeReservation(input.threadId);
1048
+ const previous = this.hostWakeTails.get(input.threadId) ?? Promise.resolve();
918
1049
  const delivery = previous.catch(() => undefined).then(async () => {
919
1050
  const connection = this.connections.get(input.sourceId);
920
1051
  if (connection === undefined) {
@@ -1023,30 +1154,206 @@ export class WebService {
1023
1154
  return { delivered: true, disposition: "follow_up" };
1024
1155
  });
1025
1156
  const tail = delivery.then(() => undefined, () => undefined);
1026
- this.processJobWakeTails.set(input.threadId, tail);
1027
- this.activeProcessJobWakes.set(activeKey, delivery);
1157
+ this.hostWakeTails.set(input.threadId, tail);
1158
+ this.activeHostWakes.set(activeKey, delivery);
1028
1159
  try {
1029
1160
  return await delivery;
1030
1161
  }
1031
1162
  finally {
1032
- if (this.processJobWakeTails.get(input.threadId) === tail) {
1033
- 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
+ }
1034
1278
  }
1035
- if (this.activeProcessJobWakes.get(activeKey) === delivery) {
1036
- this.activeProcessJobWakes.delete(activeKey);
1279
+ if (active !== undefined)
1280
+ await active.completion;
1281
+ if (this.stopped) {
1282
+ abandon();
1283
+ return { delivered: false, code: "destination_channel_unavailable", retryable: true };
1037
1284
  }
1038
- this.releaseProcessJobWakeReservation(input.threadId);
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
+ };
1321
+ }
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 });
1331
+ }
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);
1039
1346
  }
1040
1347
  }
1041
- retainProcessJobWakeReservation(threadId) {
1042
- this.processJobWakeReservations.set(threadId, (this.processJobWakeReservations.get(threadId) ?? 0) + 1);
1348
+ retainHostWakeReservation(threadId) {
1349
+ this.hostWakeReservations.set(threadId, (this.hostWakeReservations.get(threadId) ?? 0) + 1);
1043
1350
  }
1044
- releaseProcessJobWakeReservation(threadId) {
1045
- const remaining = (this.processJobWakeReservations.get(threadId) ?? 1) - 1;
1351
+ releaseHostWakeReservation(threadId) {
1352
+ const remaining = (this.hostWakeReservations.get(threadId) ?? 1) - 1;
1046
1353
  if (remaining > 0)
1047
- this.processJobWakeReservations.set(threadId, remaining);
1354
+ this.hostWakeReservations.set(threadId, remaining);
1048
1355
  else
1049
- this.processJobWakeReservations.delete(threadId);
1356
+ this.hostWakeReservations.delete(threadId);
1050
1357
  if (!this.stopped)
1051
1358
  void this.drainQueuedLiveInputs(threadId);
1052
1359
  }
@@ -1104,13 +1411,22 @@ export class WebService {
1104
1411
  const changed = this.store.replaceAgents([]);
1105
1412
  this.connections = new Map();
1106
1413
  if (changed)
1107
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
1414
+ this.emit("agents.changed");
1108
1415
  return;
1109
1416
  }
1110
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);
1111
1426
  const summaries = await Promise.all(discovered.map(async (agent) => {
1427
+ const generation = generations.get(agent.source.sourceId);
1112
1428
  if (agent.baseUrl === undefined)
1113
- return offlineSummary(agent);
1429
+ return offlineSummary(agent, generation);
1114
1430
  const client = new OperatorClient({
1115
1431
  baseUrl: agent.baseUrl,
1116
1432
  ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
@@ -1120,9 +1436,11 @@ export class WebService {
1120
1436
  try {
1121
1437
  const info = await client.info(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1122
1438
  nextConnections.set(agent.source.sourceId, { client, info });
1439
+ this.seedModelCatalogFromOptions(agent.source.sourceId, generation, info.modelOptions);
1123
1440
  const efforts = collectEfforts(info);
1124
1441
  return {
1125
1442
  sourceId: agent.source.sourceId,
1443
+ generation,
1126
1444
  label: info.label ?? agent.source.label,
1127
1445
  status: agent.source.health === "running" ? "online" : "degraded",
1128
1446
  pinned: false,
@@ -1133,6 +1451,7 @@ export class WebService {
1133
1451
  ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
1134
1452
  ...(efforts.length === 0 ? {} : { efforts }),
1135
1453
  ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
1454
+ ...(info.providers === undefined ? {} : { providers: info.providers }),
1136
1455
  ...(info.cron === undefined ? {} : { cron: info.cron }),
1137
1456
  ...(info.supportsAskById ? { supportsAskById: true } : {}),
1138
1457
  updatedAt: agent.source.updatedAt,
@@ -1143,7 +1462,7 @@ export class WebService {
1143
1462
  sourceId: agent.source.sourceId,
1144
1463
  error: errorMessage(error),
1145
1464
  });
1146
- return offlineSummary(agent);
1465
+ return offlineSummary(agent, generation);
1147
1466
  }
1148
1467
  }));
1149
1468
  this.connections = nextConnections;
@@ -1166,7 +1485,7 @@ export class WebService {
1166
1485
  }
1167
1486
  }));
1168
1487
  if (agentsChanged)
1169
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
1488
+ this.emit("agents.changed");
1170
1489
  for (const sourceId of cronChangedSources)
1171
1490
  this.emit("cron.changed", undefined, { sourceId });
1172
1491
  if (cronChangedSources.size > 0)
@@ -1365,6 +1684,116 @@ export class WebService {
1365
1684
  }
1366
1685
  return connection;
1367
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
+ }
1368
1797
  authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
1369
1798
  const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
1370
1799
  if (access === "invalid") {
@@ -1745,9 +2174,53 @@ export class WeightedTurnBudget {
1745
2174
  }
1746
2175
  }
1747
2176
  }
1748
- 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) {
1749
2221
  return {
1750
2222
  sourceId: agent.source.sourceId,
2223
+ generation,
1751
2224
  label: agent.source.label,
1752
2225
  status: "offline",
1753
2226
  pinned: false,
@@ -1763,32 +2236,25 @@ function collectEfforts(info) {
1763
2236
  if (info.modelOptions === undefined)
1764
2237
  return EFFORT_LEVELS;
1765
2238
  const models = info.models ?? (info.model === undefined ? [] : [info.model]);
1766
- return [...new Set(models.flatMap((model) => effortLevelsForOption(info.modelOptions?.[model])))];
1767
- }
1768
- function validateModelAndEffort(agent, model, effort) {
1769
- if (model !== undefined
1770
- && (agent.models === undefined ? model !== agent.defaultModel : !agent.models.includes(model))) {
1771
- throw new WebConsoleError("invalid_model", "This agent did not advertise the selected model.", 400);
1772
- }
1773
- const effectiveModel = model ?? agent.defaultModel;
1774
- const option = effectiveModel === undefined ? undefined : agent.modelOptions?.[effectiveModel];
1775
- const allowedEfforts = agent.modelOptions === undefined
1776
- ? agent.efforts
1777
- : effortLevelsForOption(option);
1778
- if (effort !== undefined && (allowedEfforts === undefined || !allowedEfforts.includes(effort))) {
1779
- throw new WebConsoleError("invalid_effort", "This agent did not advertise the selected effort for this model.", 400);
1780
- }
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)))];
1781
2242
  }
1782
- function effortLevelsForOption(option) {
1783
- if (option === undefined
1784
- || option.reasoning === false
1785
- || option.reasoningMode === "none"
1786
- || option.effortLevels?.length === 0) {
1787
- return [];
1788
- }
1789
- if (option.reasoningMode === "toggle")
1790
- return ["high", "none"];
1791
- return option.effortLevels ?? [];
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;
1792
2258
  }
1793
2259
  function normalizeFilename(value) {
1794
2260
  const withoutPath = value.replace(/\\/gu, "/").split("/").at(-1)?.trim() ?? "";