@mono-agent/web 0.19.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/README.md +123 -21
  2. package/dist/contracts.d.ts +115 -4
  3. package/dist/contracts.d.ts.map +1 -1
  4. package/dist/contracts.js +1 -1
  5. package/dist/contracts.js.map +1 -1
  6. package/dist/discovery.d.ts +1 -0
  7. package/dist/discovery.d.ts.map +1 -1
  8. package/dist/discovery.js +43 -1
  9. package/dist/discovery.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js.map +1 -1
  13. package/dist/mcp-app-document.d.ts +15 -0
  14. package/dist/mcp-app-document.d.ts.map +1 -0
  15. package/dist/mcp-app-document.js +42 -0
  16. package/dist/mcp-app-document.js.map +1 -0
  17. package/dist/mcp-app-proxy.d.ts +20 -0
  18. package/dist/mcp-app-proxy.d.ts.map +1 -0
  19. package/dist/mcp-app-proxy.js +321 -0
  20. package/dist/mcp-app-proxy.js.map +1 -0
  21. package/dist/notification-client.d.ts +18 -4
  22. package/dist/notification-client.d.ts.map +1 -1
  23. package/dist/notification-client.js +13 -4
  24. package/dist/notification-client.js.map +1 -1
  25. package/dist/notification-ingress.d.ts +19 -0
  26. package/dist/notification-ingress.d.ts.map +1 -1
  27. package/dist/notification-ingress.js +68 -6
  28. package/dist/notification-ingress.js.map +1 -1
  29. package/dist/operator-client.d.ts +51 -2
  30. package/dist/operator-client.d.ts.map +1 -1
  31. package/dist/operator-client.js +349 -2
  32. package/dist/operator-client.js.map +1 -1
  33. package/dist/server.d.ts.map +1 -1
  34. package/dist/server.js +244 -0
  35. package/dist/server.js.map +1 -1
  36. package/dist/service.d.ts +81 -5
  37. package/dist/service.d.ts.map +1 -1
  38. package/dist/service.js +401 -30
  39. package/dist/service.js.map +1 -1
  40. package/dist/store.d.ts +79 -7
  41. package/dist/store.d.ts.map +1 -1
  42. package/dist/store.js +2046 -162
  43. package/dist/store.js.map +1 -1
  44. package/package.json +4 -4
  45. package/webapp/dist/assets/index-CNY5_en5.css +1 -0
  46. package/webapp/dist/assets/index-Dl-iLQzF.js +152 -0
  47. package/webapp/dist/index.html +2 -2
  48. package/webapp/dist/sw.js +1 -1
  49. package/webapp/dist/assets/index-DBk-VjNE.css +0 -1
  50. package/webapp/dist/assets/index-DntvgDjy.js +0 -51
package/dist/service.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
- import { DEFAULT_AGENT_ATTACHMENT_MAX_BYTES, DEFAULT_AGENT_ATTACHMENT_MIME_ALLOWLIST, toolNameLeaf, } from "@mono-agent/agent-contracts";
3
+ import { 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";
@@ -8,11 +8,12 @@ import { errorCode, errorMessage, WebConsoleError } from "./errors.js";
8
8
  import { OperatorClient } from "./operator-client.js";
9
9
  import { generateWebPushIdentity, normalizeWebPushEndpoint, resolveWebPushSubject, validateWebPushEndpoint, validateWebPushKeys, WebPushDispatcher, WEB_PUSH_SERVICE_WORKER_VERSION, } from "./push.js";
10
10
  import { acquireWebStateLease, prepareWebStatePaths } from "./state-paths.js";
11
- import { toWebAttachment, WebStore, } from "./store.js";
11
+ import { cronChannelReadOnlyError, toWebAttachment, WebStore, notificationPushLogicalKey, } from "./store.js";
12
12
  const DEFAULT_DISCOVERY_INTERVAL_MS = 5_000;
13
13
  const DEFAULT_PURGE_INTERVAL_MS = 60 * 60 * 1_000;
14
14
  const INFO_TIMEOUT_MS = 2_500;
15
15
  const ASK_DISCOVERY_TIMEOUT_MS = 120_000;
16
+ const REPLY_ACCESS_TTL_MS = 10 * 60 * 1_000;
16
17
  function formatQuotedTurn(quote, text) {
17
18
  const blockquote = quote
18
19
  .trim()
@@ -36,6 +37,7 @@ export class WebService {
36
37
  pushIdentity;
37
38
  pushDispatcher;
38
39
  pushAckKey = randomBytes(32);
40
+ replyAccessKey;
39
41
  askWatches = new Map();
40
42
  connections = new Map();
41
43
  discoveryTimer;
@@ -45,11 +47,12 @@ export class WebService {
45
47
  refreshController;
46
48
  eventSequence = 0;
47
49
  stopped = false;
48
- constructor(store, lease, options, pushIdentity, pushSubject) {
50
+ constructor(store, lease, options, pushIdentity, pushSubject, replyAccessKey) {
49
51
  this.store = store;
50
52
  this.lease = lease;
51
53
  this.options = options;
52
54
  this.pushIdentity = pushIdentity;
55
+ this.replyAccessKey = replyAccessKey;
53
56
  this.attachmentTurnBudget = new WeightedTurnBudget(options.maxActiveAttachmentTurnBytes ?? WEB_MAX_ACTIVE_ATTACHMENT_TURN_BYTES, options.maxQueuedAttachmentTurns ?? WEB_MAX_QUEUED_ATTACHMENT_TURNS);
54
57
  this.pushDispatcher = new WebPushDispatcher(store, pushIdentity, pushSubject, {
55
58
  ...(options.pushSendImpl === undefined ? {} : { send: options.pushSendImpl }),
@@ -83,8 +86,9 @@ export class WebService {
83
86
  let service;
84
87
  try {
85
88
  const pushIdentity = store.ensureWebPushIdentity(generateWebPushIdentity);
89
+ const replyAccessKey = Buffer.from(store.ensureReplyAccessKey(() => randomBytes(32).toString("base64url")), "base64url");
86
90
  const pushSubject = resolveWebPushSubject(options.env?.MONO_AGENT_WEB_PUSH_SUBJECT);
87
- service = new WebService(store, lease, options, pushIdentity, pushSubject);
91
+ service = new WebService(store, lease, options, pushIdentity, pushSubject, replyAccessKey);
88
92
  await store.purgePartialUploadFiles();
89
93
  await service.purgeOrphans();
90
94
  await service.refreshAgents();
@@ -132,7 +136,78 @@ export class WebService {
132
136
  const detail = this.store.getThreadDetail(id);
133
137
  if (detail === undefined)
134
138
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
135
- return detail;
139
+ return this.decorateThreadDetail(detail);
140
+ }
141
+ threadsPage(input) {
142
+ return this.store.listThreadsPage(input);
143
+ }
144
+ messagePage(threadId, input) {
145
+ const page = this.store.listMessagesPage(threadId, input);
146
+ return { ...page, messages: page.messages.map((message) => this.decorateMessage(message)) };
147
+ }
148
+ /**
149
+ * Re-mint a browser capability from the authoritative durable message. The
150
+ * HTTP route supplies exact-origin console authority; stale access tokens are
151
+ * deliberately not accepted as renewable credentials.
152
+ */
153
+ replyPartAccess(threadId, messageId, partId, type) {
154
+ const { message, part } = this.requireReplyPart(threadId, messageId, partId, type);
155
+ this.assertReplyPartRetained(part);
156
+ return this.decorateReplyPart(message, part);
157
+ }
158
+ async replyAttachment(threadId, messageId, partId, expires, token, signal) {
159
+ const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "attachment", expires, token);
160
+ const connection = this.connections.get(thread.sourceId);
161
+ if (connection === undefined || connection.info.replyAttachments?.version !== 1) {
162
+ throw new WebConsoleError("reply_attachment_unavailable", "The attachment source is offline or incompatible.", 409);
163
+ }
164
+ const attachment = {
165
+ type: "attachment",
166
+ id: part.id,
167
+ reference: { scheme: "mono-agent-artifact", id: part.artifactId },
168
+ name: part.name,
169
+ mediaType: part.mediaType,
170
+ sizeBytes: part.sizeBytes,
171
+ integrityId: part.integrityId,
172
+ ...(part.expiresAt === undefined ? {} : { expiresAt: part.expiresAt }),
173
+ };
174
+ const response = await connection.client.replyArtifact(this.conversationIdForThread(thread.id), attachment, signal);
175
+ return { part, response };
176
+ }
177
+ async mcpAppResource(threadId, messageId, partId, expires, token, signal) {
178
+ const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
179
+ const connection = this.connections.get(thread.sourceId);
180
+ if (connection === undefined || connection.info.mcpApps?.bridgeVersion !== 1) {
181
+ throw new WebConsoleError("mcp_app_unavailable", "The MCP App source is offline or incompatible.", 409);
182
+ }
183
+ const resource = await connection.client.mcpAppResource(this.conversationIdForThread(thread.id), part.invocationId, part.connectionId, signal);
184
+ if (resource.app.invocationId !== part.invocationId
185
+ || resource.app.connectionId !== part.connectionId
186
+ || resource.app.resourceUri !== part.resourceUri
187
+ || resource.app.protocolVersion !== part.protocolVersion) {
188
+ throw new WebConsoleError("mcp_app_identity_mismatch", "The MCP App identity changed after publication.", 409);
189
+ }
190
+ return resource;
191
+ }
192
+ async mcpAppRequest(threadId, messageId, partId, expires, token, input, signal) {
193
+ const { thread, part } = this.authorizeReplyPart(threadId, messageId, partId, "mcp_app", expires, token);
194
+ const confirmationRequired = input.method === "tools/call"
195
+ || input.method === "ui/open-link"
196
+ || input.method === "ui/update-model-context";
197
+ if (confirmationRequired && input.confirmed !== true) {
198
+ throw new WebConsoleError("mcp_app_confirmation_required", "Confirm this MCP App action before continuing.", 409);
199
+ }
200
+ const connection = this.connections.get(thread.sourceId);
201
+ if (connection === undefined || connection.info.mcpApps?.bridgeVersion !== 1) {
202
+ throw new WebConsoleError("mcp_app_unavailable", "The MCP App source is offline or incompatible.", 409);
203
+ }
204
+ return await connection.client.mcpAppRequest(this.conversationIdForThread(thread.id), {
205
+ invocationId: part.invocationId,
206
+ connectionId: part.connectionId,
207
+ method: input.method,
208
+ ...(input.params === undefined ? {} : { params: input.params }),
209
+ ...(input.confirmed === undefined ? {} : { confirmed: input.confirmed }),
210
+ }, signal);
136
211
  }
137
212
  async registerWebPushSubscription(input) {
138
213
  if (input.previousSubscriptionId !== undefined && input.previousEndpoint !== undefined) {
@@ -185,17 +260,29 @@ export class WebService {
185
260
  const thread = this.store.getThread(threadId);
186
261
  if (thread === undefined)
187
262
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
263
+ threadId = thread.id;
188
264
  const connection = this.connections.get(thread.sourceId);
189
265
  if (connection === undefined)
190
266
  throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
191
267
  if (!connection.info.supportsAskUser)
192
268
  return undefined;
193
- const snapshot = await connection.client.pendingAsk(`web:${threadId}`, AbortSignal.timeout(INFO_TIMEOUT_MS));
269
+ const snapshot = await connection.client.pendingAsk(this.store.cronConversationIdForThread(thread.id) ?? `web:${thread.id}`, AbortSignal.timeout(INFO_TIMEOUT_MS));
194
270
  if (!this.stopped && snapshot !== undefined && isFuturePendingAsk(snapshot, this.currentDate())) {
195
- this.enqueueAskPush(threadId, snapshot);
271
+ this.enqueueAskPush(thread.id, snapshot);
196
272
  }
197
273
  return snapshot;
198
274
  }
275
+ async ask(threadId, interactionId) {
276
+ const thread = this.store.getThread(threadId);
277
+ if (thread === undefined)
278
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
279
+ const connection = this.connections.get(thread.sourceId);
280
+ if (connection === undefined)
281
+ throw new WebConsoleError("agent_offline", "This agent is offline.", 409);
282
+ if (!connection.info.supportsAskById)
283
+ return undefined;
284
+ return await connection.client.ask(interactionId, AbortSignal.timeout(INFO_TIMEOUT_MS));
285
+ }
199
286
  async submitAsk(threadId, interactionId, answers) {
200
287
  const thread = this.store.getThread(threadId);
201
288
  if (thread === undefined)
@@ -204,30 +291,34 @@ export class WebService {
204
291
  if (connection === undefined || !connection.info.supportsAskUser) {
205
292
  throw new WebConsoleError("ask_user_unavailable", "This agent does not support interactive questions.", 409);
206
293
  }
207
- const result = await connection.client.submitAsk(`web:${threadId}`, interactionId, answers);
294
+ const conversationId = this.store.cronConversationIdForThread(thread.id) ?? `web:${thread.id}`;
295
+ const result = await connection.client.submitAsk(conversationId, interactionId, answers);
208
296
  if (result.accepted)
209
297
  this.store.staleWebPushEvent(`ask:${interactionId}`, "answered");
210
298
  return result;
211
299
  }
212
300
  patchThread(id, patch) {
213
301
  const thread = this.store.patchThread(id, patch);
214
- this.emit("thread.changed", id, { thread });
215
- this.emit("threads.changed", id);
302
+ this.emit("thread.changed", thread.id, { thread });
303
+ this.emit("threads.changed", thread.id);
216
304
  return thread;
217
305
  }
218
306
  async deleteThread(id) {
219
- if (this.activeTurns.has(id)) {
307
+ const resolved = this.store.getThread(id)?.id;
308
+ if (resolved === undefined)
309
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
310
+ if (this.activeTurns.has(resolved)) {
220
311
  throw new WebConsoleError("turn_active", "Cancel the active turn before deleting this conversation.", 409);
221
312
  }
222
- const result = await this.store.deleteArchivedThread(id);
313
+ const result = await this.store.deleteArchivedThread(resolved);
223
314
  if (result.orphanedFiles > 0) {
224
315
  this.options.logger?.warn?.("Deleted a web conversation with attachment files deferred to orphan cleanup.", {
225
- threadId: id,
316
+ threadId: resolved,
226
317
  count: result.orphanedFiles,
227
318
  });
228
319
  }
229
- this.emit("thread.changed", id, { threadId: id, removed: true });
230
- this.emit("threads.changed", id);
320
+ this.emit("thread.changed", resolved, { threadId: resolved, removed: true });
321
+ this.emit("threads.changed", resolved);
231
322
  }
232
323
  patchAgent(sourceId, patch) {
233
324
  const agent = this.store.setAgentPinned(sourceId, patch.pinned);
@@ -244,12 +335,126 @@ export class WebService {
244
335
  }
245
336
  return connection.info.skills ?? { status: "unsupported", items: [] };
246
337
  }
338
+ async cronOverview(sourceId) {
339
+ const agent = this.store.getAgent(sourceId);
340
+ if (agent === undefined)
341
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
342
+ const connection = this.connections.get(sourceId);
343
+ if (connection?.info.cron?.read === true) {
344
+ const overview = await connection.client.cronOverview(AbortSignal.timeout(INFO_TIMEOUT_MS));
345
+ const synced = this.store.syncCronOverviewResult({ sourceId, ...overview });
346
+ if (synced.changed) {
347
+ this.emit("cron.changed", undefined, { sourceId });
348
+ this.emit("threads.changed");
349
+ }
350
+ return synced.overview;
351
+ }
352
+ const stored = this.store.storedCronOverview(sourceId);
353
+ if (stored !== undefined)
354
+ return { ...stored, actionsEnabled: false };
355
+ throw new WebConsoleError("cron_unavailable", "This agent does not expose first-class cron operator state.", 404);
356
+ }
357
+ async cronRuns(sourceId, jobId, input) {
358
+ const agent = this.store.getAgent(sourceId);
359
+ if (agent === undefined)
360
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
361
+ const connection = this.connections.get(sourceId);
362
+ if (connection?.info.cron?.read !== true) {
363
+ if (this.store.cronThread(sourceId, jobId) === undefined) {
364
+ throw new WebConsoleError("cron_job_not_found", "Cron job not found for this agent.", 404);
365
+ }
366
+ if (input.before !== undefined)
367
+ return { runs: [] };
368
+ const stored = this.store.storedCronRuns(sourceId, jobId, input.limit);
369
+ return stored.messages === undefined
370
+ ? stored
371
+ : { ...stored, messages: stored.messages.map((message) => this.decorateMessage(message)) };
372
+ }
373
+ const page = await connection.client.cronRuns(jobId, {
374
+ limit: input.limit,
375
+ ...(input.before === undefined ? {} : { before: input.before }),
376
+ signal: AbortSignal.timeout(INFO_TIMEOUT_MS),
377
+ });
378
+ const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, page.runs);
379
+ if (reconciled.changed) {
380
+ const threadId = this.store.cronThread(sourceId, jobId)?.id;
381
+ this.emit("thread.changed", threadId);
382
+ this.emit("threads.changed", threadId);
383
+ }
384
+ return { ...page, messages: reconciled.messages.map((message) => this.decorateMessage(message)) };
385
+ }
386
+ async cronRun(sourceId, jobId, runId) {
387
+ const connection = this.requireCronConnection(sourceId, false);
388
+ const run = await connection.client.cronRun(jobId, runId, AbortSignal.timeout(INFO_TIMEOUT_MS));
389
+ const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [run]);
390
+ const message = reconciled.messages[0];
391
+ if (message === undefined) {
392
+ throw new WebConsoleError("invalid_operator_cron", "Cron detail did not reconcile a message.", 502);
393
+ }
394
+ if (reconciled.changed) {
395
+ this.emit("thread.changed", message.threadId, { messageId: message.id });
396
+ this.emit("threads.changed", message.threadId);
397
+ }
398
+ return this.decorateMessage(message);
399
+ }
400
+ async cronConfigView(sourceId) {
401
+ const connection = this.requireCronConnection(sourceId, false);
402
+ return await connection.client.cronConfigView(AbortSignal.timeout(INFO_TIMEOUT_MS));
403
+ }
404
+ async cronRunNow(sourceId, jobId, input) {
405
+ const connection = this.requireCronConnection(sourceId, true);
406
+ const result = await connection.client.cronRunNow(jobId, input, AbortSignal.timeout(INFO_TIMEOUT_MS));
407
+ if (result.kind === "completed") {
408
+ const reconciled = this.store.reconcileCronRunsResult(sourceId, jobId, [result.value.run]);
409
+ if (reconciled.changed) {
410
+ const threadId = this.store.cronThread(sourceId, jobId)?.id;
411
+ this.emit("thread.changed", threadId);
412
+ this.emit("threads.changed", threadId);
413
+ }
414
+ }
415
+ return result;
416
+ }
417
+ async cronSetEffectiveEnabled(sourceId, jobId, enabled, input) {
418
+ const connection = this.requireCronConnection(sourceId, true);
419
+ const result = await connection.client.cronSetEffectiveEnabled(jobId, enabled, input, AbortSignal.timeout(INFO_TIMEOUT_MS));
420
+ if (result.kind === "confirmation_required")
421
+ return result;
422
+ const refreshed = await connection.client.cronOverview(AbortSignal.timeout(INFO_TIMEOUT_MS));
423
+ const synced = this.store.syncCronOverviewResult({ sourceId, ...refreshed });
424
+ const overview = synced.overview;
425
+ const job = overview.jobs.find((candidate) => candidate.jobId === jobId);
426
+ if (job === undefined)
427
+ throw new WebConsoleError("invalid_operator_cron", "Updated cron job disappeared.", 502);
428
+ if (synced.changed) {
429
+ this.emit("cron.changed", job.threadId, { sourceId, jobId });
430
+ this.emit("thread.changed", job.threadId, { thread: this.store.getThread(job.threadId) });
431
+ this.emit("threads.changed", job.threadId);
432
+ }
433
+ return { ...result, value: { job } };
434
+ }
247
435
  async deliverNotification(input) {
248
436
  if (this.stopped) {
249
437
  throw new WebConsoleError("web_service_stopping", "The web service is stopping.", 409);
250
438
  }
251
439
  if (this.store.getAgent(input.sourceId) === undefined)
252
440
  await this.refreshAgents();
441
+ if (input.triggerKind === "job") {
442
+ const completed = this.store.upsertProcessJobCard({
443
+ sourceId: input.sourceId,
444
+ threadId: input.threadId,
445
+ deliveryKey: input.deliveryKey,
446
+ processJob: input.processJob,
447
+ ...(input.text === undefined ? {} : { responseText: input.text }),
448
+ ...(input.parts === undefined ? {} : { replyParts: input.parts }),
449
+ });
450
+ const message = this.store.getThreadDetail(input.threadId)?.messages.find((candidate) => candidate.parts.some((part) => part.type === "process-job" && part.job.jobId === input.processJob.jobId));
451
+ if (!completed.duplicate && message !== undefined) {
452
+ this.emit("message.changed", input.threadId, { messageId: message.id, updatedAt: message.updatedAt });
453
+ }
454
+ this.emit("threads.changed", input.threadId, { thread: completed.thread });
455
+ this.emit("thread.changed", input.threadId, { thread: completed.thread });
456
+ return completed;
457
+ }
253
458
  const reservation = this.store.reserveNotification(input);
254
459
  if (reservation.duplicate)
255
460
  return this.store.completeNotification(reservation);
@@ -263,6 +468,26 @@ export class WebService {
263
468
  this.activeNotifications.set(activeKey, delivery);
264
469
  return delivery;
265
470
  }
471
+ /** Proxy one authenticated retained card to its exact agent/thread owner. */
472
+ async threadJob(threadId, jobId) {
473
+ const thread = this.store.getThread(threadId);
474
+ if (thread === undefined)
475
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
476
+ if (!this.store.processJobCardBelongsToThread(thread.sourceId, threadId, jobId)) {
477
+ throw new WebConsoleError("process_job_not_found", "Process job was not found for this conversation.", 404);
478
+ }
479
+ const connection = this.connections.get(thread.sourceId);
480
+ if (connection === undefined || connection.info.supportsJobs !== true) {
481
+ throw new WebConsoleError("process_jobs_unavailable", "Process jobs are unavailable for this agent.", 409);
482
+ }
483
+ const job = await connection.client.getJob(jobId, AbortSignal.timeout(INFO_TIMEOUT_MS));
484
+ if (job.jobId !== jobId
485
+ || job.origin.channel !== "web"
486
+ || job.origin.conversationId.split("#", 1)[0] !== `web:${threadId}`) {
487
+ throw new WebConsoleError("process_job_not_found", "Process job was not found for this conversation.", 404);
488
+ }
489
+ return job;
490
+ }
266
491
  async startTurn(threadId, input) {
267
492
  const text = input.text ?? "";
268
493
  const operatorText = input.quote === undefined ? text : formatQuotedTurn(input.quote.text, text);
@@ -273,8 +498,11 @@ export class WebService {
273
498
  const thread = this.store.getThread(threadId);
274
499
  if (thread === undefined)
275
500
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
501
+ threadId = thread.id;
276
502
  const agent = this.store.getAgent(thread.sourceId);
277
503
  const connection = this.connections.get(thread.sourceId);
504
+ if (thread.trigger?.kind === "cron")
505
+ throw cronChannelReadOnlyError();
278
506
  if (agent === undefined || connection === undefined || !thread.canSend) {
279
507
  throw new WebConsoleError("agent_offline", "This agent is offline. The conversation remains available read-only.", 409);
280
508
  }
@@ -292,6 +520,7 @@ export class WebService {
292
520
  const thread = this.store.getThread(threadId);
293
521
  if (thread === undefined)
294
522
  throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
523
+ threadId = thread.id;
295
524
  const connection = this.connections.get(thread.sourceId);
296
525
  const active = this.activeTurns.get(threadId);
297
526
  const reserved = this.store.reserveLiveInput(threadId, text);
@@ -316,10 +545,15 @@ export class WebService {
316
545
  return { message: reserved.message, disposition: "pending" };
317
546
  }
318
547
  async cancelTurn(threadId) {
548
+ const resolved = this.store.getThread(threadId)?.id;
549
+ if (resolved === undefined)
550
+ throw new WebConsoleError("thread_not_found", "Conversation not found.", 404);
551
+ threadId = resolved;
319
552
  const active = this.activeTurns.get(threadId);
320
553
  const stored = this.store.activeTurn(threadId);
321
554
  if (stored === undefined)
322
555
  throw new WebConsoleError("no_active_turn", "This conversation has no active turn.", 409);
556
+ const reason = createChannelUserCancelReason("Web");
323
557
  const liveInputs = [...this.activeLiveInputs.entries()]
324
558
  .filter(([, input]) => input.threadId === threadId);
325
559
  const cancelledInputs = this.store.cancelLiveInputs(threadId);
@@ -327,13 +561,13 @@ export class WebService {
327
561
  this.emit("message.changed", threadId, { messageId: message.id, updatedAt: message.updatedAt });
328
562
  }
329
563
  for (const [, input] of liveInputs) {
330
- input.controller.abort(new WebTurnCancellation("user", "Live input cancelled from the web console."));
564
+ input.controller.abort(reason);
331
565
  }
332
566
  if (active !== undefined) {
333
- void active.client.cancel(stored.conversationId).catch((error) => {
567
+ await active.client.cancel(stored.conversationId).catch((error) => {
334
568
  this.options.logger?.debug?.("Web turn cancel request failed.", { error: errorMessage(error) });
335
569
  });
336
- active.controller.abort(new WebTurnCancellation("user", "Cancelled from the web console."));
570
+ active.controller.abort(reason);
337
571
  }
338
572
  const thread = this.store.getThread(threadId);
339
573
  if (thread === undefined)
@@ -506,7 +740,7 @@ export class WebService {
506
740
  },
507
741
  });
508
742
  await coalescer.flush();
509
- const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata);
743
+ const detail = this.store.completeTurn(started.turnId, response.finalText, response.metadata, response.parts);
510
744
  this.emit("turn.changed", started.thread.id, { turn: detail.thread.runState });
511
745
  this.emit("thread.changed", started.thread.id, { revision: detail.thread.revision });
512
746
  this.emit("threads.changed", started.thread.id);
@@ -520,7 +754,8 @@ export class WebService {
520
754
  catch (flushError) {
521
755
  failure = flushError;
522
756
  }
523
- const cancelled = controller.signal.reason instanceof WebTurnCancellation
757
+ const cancelled = isChannelUserCancelReason(controller.signal.reason)
758
+ || controller.signal.reason instanceof WebTurnCancellation
524
759
  || error.cancelled === true;
525
760
  const code = errorCode(failure);
526
761
  const detail = this.store.failTurn(started.turnId, {
@@ -624,12 +859,18 @@ export class WebService {
624
859
  if (!connection.info.supportsHistoryAppend) {
625
860
  throw new WebConsoleError("history_record_unavailable", "The notification agent does not support durable history append.", 409);
626
861
  }
627
- await connection.client.recordVerbatim(`web:${reservation.threadId}`, reservation.text, reservation.deliveryKey);
862
+ await connection.client.recordVerbatim(this.store.notificationConversationId(reservation), reservation.text, reservation.deliveryKey);
628
863
  const completed = this.store.completeNotification(reservation);
864
+ if (completed.thread === undefined) {
865
+ if (completed.tombstoned === true)
866
+ return completed;
867
+ throw new WebConsoleError("storage_corrupt", "A new notification completed without a conversation.", 500);
868
+ }
629
869
  this.emit("threads.changed", completed.thread.id, { thread: completed.thread });
630
870
  this.emit("thread.changed", completed.thread.id, { thread: completed.thread });
631
- if (!completed.duplicate)
632
- this.announcePushEvent(`web-new:${reservation.threadId}`);
871
+ if (!completed.duplicate) {
872
+ this.announcePushEvent(notificationPushLogicalKey(reservation.sourceId, reservation.deliveryKey));
873
+ }
633
874
  return completed;
634
875
  }
635
876
  async toAgentAttachment(attachment) {
@@ -657,9 +898,10 @@ export class WebService {
657
898
  }
658
899
  catch (error) {
659
900
  this.options.logger?.warn?.("Web agent discovery failed.", { error: errorMessage(error) });
660
- this.store.replaceAgents([]);
901
+ const changed = this.store.replaceAgents([]);
661
902
  this.connections = new Map();
662
- this.emit("agents.changed");
903
+ if (changed)
904
+ this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
663
905
  return;
664
906
  }
665
907
  const nextConnections = new Map();
@@ -669,6 +911,7 @@ export class WebService {
669
911
  const client = new OperatorClient({
670
912
  baseUrl: agent.baseUrl,
671
913
  ...(agent.apiKey === undefined ? {} : { apiKey: agent.apiKey }),
914
+ ...(agent.processJobsBearer === undefined ? {} : { processJobsBearer: agent.processJobsBearer }),
672
915
  ...(this.options.fetchImpl === undefined ? {} : { fetchImpl: this.options.fetchImpl }),
673
916
  });
674
917
  try {
@@ -687,6 +930,8 @@ export class WebService {
687
930
  ...(info.effort === undefined ? {} : { defaultEffort: info.effort }),
688
931
  ...(efforts.length === 0 ? {} : { efforts }),
689
932
  ...(info.modelOptions === undefined ? {} : { modelOptions: info.modelOptions }),
933
+ ...(info.cron === undefined ? {} : { cron: info.cron }),
934
+ ...(info.supportsAskById ? { supportsAskById: true } : {}),
690
935
  updatedAt: agent.source.updatedAt,
691
936
  };
692
937
  }
@@ -699,9 +944,30 @@ export class WebService {
699
944
  }
700
945
  }));
701
946
  this.connections = nextConnections;
702
- this.store.replaceAgents(summaries);
703
- this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
704
- this.emit("threads.changed");
947
+ const agentsChanged = this.store.replaceAgents(summaries);
948
+ const cronChangedSources = new Set();
949
+ await Promise.all([...nextConnections.entries()].map(async ([sourceId, connection]) => {
950
+ if (connection.info.cron?.read !== true)
951
+ return;
952
+ try {
953
+ const overview = await connection.client.cronOverview(AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
954
+ const synced = this.store.syncCronOverviewResult({ sourceId, ...overview });
955
+ if (synced.changed)
956
+ cronChangedSources.add(sourceId);
957
+ }
958
+ catch (error) {
959
+ this.options.logger?.debug?.("Cron operator refresh failed; retaining the last authoritative snapshot.", {
960
+ sourceId,
961
+ error: errorMessage(error),
962
+ });
963
+ }
964
+ }));
965
+ if (agentsChanged)
966
+ this.emit("agents.changed", undefined, { agents: this.store.listAgents() });
967
+ for (const sourceId of cronChangedSources)
968
+ this.emit("cron.changed", undefined, { sourceId });
969
+ if (cronChangedSources.size > 0)
970
+ this.emit("threads.changed");
705
971
  for (const threadId of this.store.queuedLiveInputThreadIds()) {
706
972
  void this.drainQueuedLiveInputs(threadId);
707
973
  }
@@ -794,7 +1060,7 @@ export class WebService {
794
1060
  const connection = thread === undefined ? undefined : this.connections.get(thread.sourceId);
795
1061
  if (connection !== undefined && connection.info.supportsAskUser) {
796
1062
  try {
797
- const snapshot = await connection.client.pendingAsk(`web:${threadId}`, AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1063
+ const snapshot = await connection.client.pendingAsk(this.store.cronConversationIdForThread(threadId) ?? `web:${threadId}`, AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
798
1064
  if (snapshot !== undefined) {
799
1065
  if (isFuturePendingAsk(snapshot, this.currentDate())) {
800
1066
  if (this.stopped || signal.aborted)
@@ -848,7 +1114,10 @@ export class WebService {
848
1114
  if (connection === undefined || !connection.info.supportsAskUser)
849
1115
  return "unknown";
850
1116
  try {
851
- const snapshot = await connection.client.pendingAsk(`web:${event.threadId}`, AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]));
1117
+ const boundedSignal = AbortSignal.any([signal, AbortSignal.timeout(INFO_TIMEOUT_MS)]);
1118
+ const snapshot = connection.info.supportsAskById
1119
+ ? await connection.client.ask(interactionId, boundedSignal)
1120
+ : await connection.client.pendingAsk(this.store.cronConversationIdForThread(event.threadId) ?? `web:${event.threadId}`, boundedSignal);
852
1121
  return snapshot?.interactionId === interactionId
853
1122
  && snapshot.status === "pending"
854
1123
  && new Date(snapshot.expiresAt).getTime() > this.currentDate().getTime()
@@ -859,6 +1128,108 @@ export class WebService {
859
1128
  return "unknown";
860
1129
  }
861
1130
  }
1131
+ requireCronConnection(sourceId, actions) {
1132
+ if (this.store.getAgent(sourceId) === undefined) {
1133
+ throw new WebConsoleError("agent_not_found", "Agent not found.", 404);
1134
+ }
1135
+ const connection = this.connections.get(sourceId);
1136
+ if (connection === undefined || connection.info.cron?.read !== true) {
1137
+ throw new WebConsoleError("cron_unavailable", "Cron operator state is unavailable for this agent.", 409);
1138
+ }
1139
+ if (actions && connection.info.cron.actions !== true) {
1140
+ throw new WebConsoleError("cron_actions_disabled", "Cron actions require this agent's authenticated operator capability.", 403);
1141
+ }
1142
+ return connection;
1143
+ }
1144
+ authorizeReplyPart(threadId, messageId, partId, type, expires, token) {
1145
+ const access = this.replyAccessTokenStatus(threadId, messageId, type, partId, expires, token);
1146
+ if (access === "invalid") {
1147
+ throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
1148
+ }
1149
+ const { thread, part } = this.requireReplyPart(threadId, messageId, partId, type);
1150
+ this.assertReplyPartRetained(part);
1151
+ if (access === "expired") {
1152
+ throw new WebConsoleError("reply_access_expired", "Reply access expired. Refresh this reply part and try again.", 410);
1153
+ }
1154
+ return { thread, part };
1155
+ }
1156
+ requireReplyPart(threadId, messageId, partId, type) {
1157
+ const thread = this.store.getThread(threadId);
1158
+ const message = this.store.getMessage(messageId);
1159
+ if (thread === undefined || message === undefined || message.threadId !== thread.id) {
1160
+ throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
1161
+ }
1162
+ const matches = message.parts.filter((part) => part.type === type && part.id === partId);
1163
+ if (matches.length !== 1) {
1164
+ throw new WebConsoleError("reply_part_not_found", "The reply part is unavailable.", 404);
1165
+ }
1166
+ const part = matches[0];
1167
+ return { thread, message, part };
1168
+ }
1169
+ assertReplyPartRetained(part) {
1170
+ const expiresAt = part.expiresAt;
1171
+ if (expiresAt !== undefined && Date.parse(expiresAt) <= this.currentDate().getTime()) {
1172
+ throw new WebConsoleError("reply_part_expired", "The reply part has expired.", 410);
1173
+ }
1174
+ }
1175
+ decorateThreadDetail(detail) {
1176
+ return { ...detail, messages: detail.messages.map((message) => this.decorateMessage(message)) };
1177
+ }
1178
+ decorateMessage(message) {
1179
+ const parts = message.parts.map((part) => {
1180
+ if (part.type !== "attachment" && part.type !== "mcp_app")
1181
+ return part;
1182
+ return this.decorateReplyPart(message, part);
1183
+ });
1184
+ return { ...message, parts };
1185
+ }
1186
+ decorateReplyPart(message, part) {
1187
+ const now = this.currentDate().getTime();
1188
+ const retentionDeadline = part.expiresAt === undefined
1189
+ ? Number.POSITIVE_INFINITY
1190
+ : Date.parse(part.expiresAt);
1191
+ const expiresAt = Math.min(now + REPLY_ACCESS_TTL_MS, retentionDeadline);
1192
+ if (!Number.isFinite(expiresAt) || expiresAt <= now)
1193
+ return part;
1194
+ const expires = String(Math.floor(expiresAt / 1_000));
1195
+ const token = this.replyAccessToken(message.threadId, message.id, part.type, part.id, expires);
1196
+ const base = `/api/v1/threads/${encodeURIComponent(message.threadId)}`
1197
+ + `/messages/${encodeURIComponent(message.id)}`;
1198
+ const query = new URLSearchParams({ expires, token }).toString();
1199
+ return part.type === "attachment"
1200
+ ? {
1201
+ ...part,
1202
+ contentUrl: `${base}/reply-attachments/${encodeURIComponent(part.id)}/content?${query}`,
1203
+ }
1204
+ : {
1205
+ ...part,
1206
+ resourceUrl: `${base}/mcp-apps/${encodeURIComponent(part.id)}?${query}`,
1207
+ bridgeUrl: `${base}/mcp-apps/${encodeURIComponent(part.id)}/requests?${query}`,
1208
+ };
1209
+ }
1210
+ replyAccessToken(threadId, messageId, type, partId, expires) {
1211
+ return createHmac("sha256", this.replyAccessKey)
1212
+ .update(["v1", threadId, messageId, type, partId, expires].join("\0"))
1213
+ .digest("base64url");
1214
+ }
1215
+ replyAccessTokenStatus(threadId, messageId, type, partId, expires, supplied) {
1216
+ if (!/^\d{10,13}$/u.test(expires) || !/^[A-Za-z0-9_-]{43}$/u.test(supplied))
1217
+ return "invalid";
1218
+ const expiresMs = Number(expires) * 1_000;
1219
+ if (!Number.isSafeInteger(expiresMs))
1220
+ return "invalid";
1221
+ const expected = Buffer.from(this.replyAccessToken(threadId, messageId, type, partId, expires), "utf8");
1222
+ const candidate = Buffer.from(supplied, "utf8");
1223
+ if (candidate.byteLength !== expected.byteLength || !timingSafeEqual(candidate, expected))
1224
+ return "invalid";
1225
+ const now = this.currentDate().getTime();
1226
+ if (expiresMs > now + REPLY_ACCESS_TTL_MS + 1_000)
1227
+ return "invalid";
1228
+ return expiresMs <= now ? "expired" : "valid";
1229
+ }
1230
+ conversationIdForThread(threadId) {
1231
+ return this.store.cronConversationIdForThread(threadId) ?? `web:${threadId}`;
1232
+ }
862
1233
  currentDate() {
863
1234
  return this.options.clock?.() ?? new Date();
864
1235
  }