oysterun 1.5.7 → 1.6.0-beta.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 (91) hide show
  1. package/dev/client/connectivity/completed-message-visibility.d.mts +30 -0
  2. package/dev/client/connectivity/completed-message-visibility.mjs +67 -0
  3. package/dev/client/connectivity/internal-connectivity-status.d.mts +11 -0
  4. package/dev/client/connectivity/internal-connectivity-status.mjs +45 -2
  5. package/dev/client/connectivity/managed-host-browser.d.mts +1 -0
  6. package/dev/client/connectivity/managed-host-browser.mjs +130 -25
  7. package/dev/client/connectivity/session-switcher-state.d.mts +16 -0
  8. package/dev/client/connectivity/session-switcher-state.mjs +72 -0
  9. package/dev/client/connectivity/session-transition-state.mjs +116 -0
  10. package/dev/client/web/index.html +1715 -792
  11. package/dev/client/web-chat/dist/assets/{ReactPrism-C_qvQuWU.js → ReactPrism-jJu8nX0A.js} +1 -1
  12. package/dev/client/web-chat/dist/assets/ReactPrism-jJu8nX0A.js.map +1 -0
  13. package/dev/client/web-chat/dist/assets/index-B9fWrEk-.js.map +1 -1
  14. package/dev/client/web-chat/dist/assets/{index-BgxQAT-U.js → index-BFCvSvbT.js} +93 -93
  15. package/dev/client/web-chat/dist/assets/index-BFCvSvbT.js.map +1 -0
  16. package/dev/client/web-chat/dist/assets/{index-B44MyL7f.js → index-CIqQrfEb.js} +2 -2
  17. package/dev/client/web-chat/dist/assets/index-CIqQrfEb.js.map +1 -0
  18. package/dev/client/web-chat/dist/assets/{index-DdoJ_noi.js → index-CmRIDO-s.js} +1 -1
  19. package/dev/client/web-chat/dist/assets/index-CmRIDO-s.js.map +1 -0
  20. package/dev/client/web-chat/dist/assets/{index-1o8_vyRd.css → index-NYnaSGBb.css} +1 -1
  21. package/dev/client/web-chat/dist/assets/{pdf-DgSk769O.js → pdf-DKH_xqif.js} +3 -3
  22. package/dev/client/web-chat/dist/assets/pdf-DKH_xqif.js.map +1 -0
  23. package/dev/client/web-chat/dist/assets/{web-CIst0rFB.js → web-D7o80CWH.js} +1 -1
  24. package/dev/client/web-chat/dist/assets/web-D7o80CWH.js.map +1 -0
  25. package/dev/client/web-chat/dist/assets/{web-D3DR1arO.js → web-DoWTwn9c.js} +1 -1
  26. package/dev/client/web-chat/dist/assets/web-DoWTwn9c.js.map +1 -0
  27. package/dev/client/web-chat/dist/index.html +10 -4
  28. package/docs/routec/dev/npmjs_publish.md +7 -1
  29. package/docs/routec/form_UI_fields.md +17 -1
  30. package/docs/routec/oysterun_operation_interface_spec.md +8 -1
  31. package/docs/routec/provider_contract.md +25 -2
  32. package/host-service/README.md +9 -1
  33. package/host-service/adapters/debug-fixture-provider-adapter.mjs +98 -0
  34. package/host-service/adapters/grok-acp-adapter.mjs +1097 -0
  35. package/host-service/agent-config.mjs +147 -35
  36. package/host-service/assets/product-skills/Oysterun/modules/oysterun-sessions/SKILL.md +7 -0
  37. package/host-service/assets/product-skills/oysterun-sessions/SKILL.md +7 -0
  38. package/host-service/callback-durable-store.mjs +668 -0
  39. package/host-service/callback-foundation.mjs +459 -0
  40. package/host-service/callback-host-integration.mjs +364 -0
  41. package/host-service/cli/product-cli.mjs +98 -3
  42. package/host-service/completed-session-unread.mjs +286 -0
  43. package/host-service/completed-turn-navigation.mjs +479 -0
  44. package/host-service/config.mjs +77 -2
  45. package/host-service/connectivity/managed-host-origin-authority.mjs +70 -0
  46. package/host-service/connectivity/webrtc-host-dispatcher.mjs +69 -2
  47. package/host-service/connectivity/webrtc-signaling-service.mjs +28 -1
  48. package/host-service/fixtures/p204-grok-acp-1.0.25-fixture.mjs +262 -0
  49. package/host-service/grok-acp-model-discovery.mjs +301 -0
  50. package/host-service/grok-version.mjs +102 -0
  51. package/host-service/mail-store.mjs +54 -0
  52. package/host-service/matrix-event-writer.mjs +151 -0
  53. package/host-service/matrix-facade.mjs +53 -13
  54. package/host-service/p203-callback-restart-fixture.mjs +229 -0
  55. package/host-service/package.json +4 -2
  56. package/host-service/provider-auth.mjs +20 -1
  57. package/host-service/provider-command-detection.mjs +17 -4
  58. package/host-service/provider-model-params.mjs +5 -0
  59. package/host-service/provider-model-refresh-runner.mjs +64 -1
  60. package/host-service/provider-registry.mjs +124 -5
  61. package/host-service/provider-startup-preflight.mjs +6 -1
  62. package/host-service/routec-matrix-sqlite-store.mjs +336 -1
  63. package/host-service/routec-matrix-storage-adapter.mjs +73 -0
  64. package/host-service/runtime-capability-catalog.mjs +63 -0
  65. package/host-service/runtime-capability-lifecycle.mjs +47 -0
  66. package/host-service/runtime-capability-retirement.mjs +42 -0
  67. package/host-service/scheduler-mutation-audit.mjs +87 -0
  68. package/host-service/scheduler-run-product-contract.mjs +203 -0
  69. package/host-service/scheduler-service.mjs +92 -43
  70. package/host-service/scheduler-setup-snapshot-contract.mjs +5 -2
  71. package/host-service/server.mjs +813 -240
  72. package/host-service/session-history.mjs +1 -1
  73. package/host-service/session-manager.mjs +131 -12
  74. package/host-service/session-switcher-projection.mjs +36 -0
  75. package/host-service/setup.mjs +8 -1
  76. package/host-service/telegram-bridge.mjs +10 -1
  77. package/package.json +2 -1
  78. package/static/site/docs/generated_manifest.json +5 -5
  79. package/static/site/docs/understand-agent-activity/en/index.html +5 -0
  80. package/static/site/docs/understand-agent-activity/en/index.md +12 -0
  81. package/static/site/docs/understand-agent-activity/zh/index.html +5 -0
  82. package/static/site/docs/understand-agent-activity/zh/index.md +12 -0
  83. package/static/site_source/content/tutorials/published/understand-agent-activity/en/article.html +5 -0
  84. package/static/site_source/content/tutorials/published/understand-agent-activity/zh/article.html +5 -0
  85. package/dev/client/web-chat/dist/assets/ReactPrism-C_qvQuWU.js.map +0 -1
  86. package/dev/client/web-chat/dist/assets/index-B44MyL7f.js.map +0 -1
  87. package/dev/client/web-chat/dist/assets/index-BgxQAT-U.js.map +0 -1
  88. package/dev/client/web-chat/dist/assets/index-DdoJ_noi.js.map +0 -1
  89. package/dev/client/web-chat/dist/assets/pdf-DgSk769O.js.map +0 -1
  90. package/dev/client/web-chat/dist/assets/web-CIst0rFB.js.map +0 -1
  91. package/dev/client/web-chat/dist/assets/web-D3DR1arO.js.map +0 -1
@@ -0,0 +1,30 @@
1
+ export type VisibilityRect = {
2
+ top: number;
3
+ right: number;
4
+ bottom: number;
5
+ left: number;
6
+ width: number;
7
+ height: number;
8
+ };
9
+
10
+ export function isCompletedMessageViewportVisible(input: {
11
+ elementRect: VisibilityRect | DOMRect;
12
+ viewportRect: VisibilityRect | DOMRect;
13
+ }): boolean;
14
+
15
+ export function evaluateCompletedMessageVisibilityAcknowledgement(input: {
16
+ routeCChatShell: boolean;
17
+ currentHostSessionId?: string;
18
+ targetHostSessionId?: string;
19
+ currentMatrixRoomId?: string;
20
+ targetMatrixRoomId?: string;
21
+ latestCompletedEventId?: string | null;
22
+ renderedEventId?: string | null;
23
+ hasUnreadCompletedMessage?: boolean;
24
+ documentVisibilityState?: string;
25
+ documentHasFocus?: boolean;
26
+ elementConnected?: boolean;
27
+ elementHidden?: boolean;
28
+ elementRect?: VisibilityRect | DOMRect;
29
+ viewportRect?: VisibilityRect | DOMRect;
30
+ }): { qualified: boolean; reason: string };
@@ -0,0 +1,67 @@
1
+ function finite(value) {
2
+ return Number.isFinite(value) ? Number(value) : 0;
3
+ }
4
+
5
+ export function isCompletedMessageViewportVisible({
6
+ elementRect,
7
+ viewportRect,
8
+ }) {
9
+ if (!elementRect || !viewportRect) return false;
10
+ const width = Math.max(
11
+ 0,
12
+ Math.min(finite(elementRect.right), finite(viewportRect.right)) -
13
+ Math.max(finite(elementRect.left), finite(viewportRect.left))
14
+ );
15
+ const height = Math.max(
16
+ 0,
17
+ Math.min(finite(elementRect.bottom), finite(viewportRect.bottom)) -
18
+ Math.max(finite(elementRect.top), finite(viewportRect.top))
19
+ );
20
+ return (
21
+ width >= Math.min(16, Math.max(1, finite(elementRect.width))) &&
22
+ height >= Math.min(16, Math.max(1, finite(elementRect.height)))
23
+ );
24
+ }
25
+
26
+ export function evaluateCompletedMessageVisibilityAcknowledgement({
27
+ routeCChatShell,
28
+ currentHostSessionId,
29
+ targetHostSessionId,
30
+ currentMatrixRoomId,
31
+ targetMatrixRoomId,
32
+ latestCompletedEventId,
33
+ renderedEventId,
34
+ hasUnreadCompletedMessage,
35
+ documentVisibilityState,
36
+ documentHasFocus,
37
+ elementConnected,
38
+ elementHidden,
39
+ elementRect,
40
+ viewportRect,
41
+ }) {
42
+ if (routeCChatShell !== true)
43
+ return { qualified: false, reason: "not_current_chat" };
44
+ if (!currentHostSessionId || currentHostSessionId !== targetHostSessionId) {
45
+ return { qualified: false, reason: "other_session" };
46
+ }
47
+ if (!currentMatrixRoomId || currentMatrixRoomId !== targetMatrixRoomId) {
48
+ return { qualified: false, reason: "other_room" };
49
+ }
50
+ if (hasUnreadCompletedMessage !== true)
51
+ return { qualified: false, reason: "not_unread" };
52
+ if (!latestCompletedEventId || latestCompletedEventId !== renderedEventId) {
53
+ return { qualified: false, reason: "stale_or_unrendered_event" };
54
+ }
55
+ if (documentVisibilityState !== "visible") {
56
+ return { qualified: false, reason: "hidden_document" };
57
+ }
58
+ if (documentHasFocus !== true)
59
+ return { qualified: false, reason: "unfocused_document" };
60
+ if (elementConnected !== true || elementHidden === true) {
61
+ return { qualified: false, reason: "event_not_materially_rendered" };
62
+ }
63
+ if (!isCompletedMessageViewportVisible({ elementRect, viewportRect })) {
64
+ return { qualified: false, reason: "event_outside_viewport" };
65
+ }
66
+ return { qualified: true, reason: "visible_latest_successful_final" };
67
+ }
@@ -1,3 +1,12 @@
1
+ export type ManagedWebRtcStageStatus =
2
+ | "Configuration"
3
+ | "Browser offer"
4
+ | "ICE gathering"
5
+ | "Browser ticket"
6
+ | "Signaling"
7
+ | "Answer application"
8
+ | "Data channel";
9
+
1
10
  export type InternalConnectivityStatus = {
2
11
  matrix: "Connected" | "Reconnecting" | "Error" | "Offline";
3
12
  activePath:
@@ -10,6 +19,8 @@ export type InternalConnectivityStatus = {
10
19
  | "Direct"
11
20
  | "None";
12
21
  promotion: "Idle" | `Attempt ${number}/4` | "Quiesced" | "Not applicable";
22
+ stage?: ManagedWebRtcStageStatus;
23
+ lastIssue?: `${ManagedWebRtcStageStatus} timeout`;
13
24
  };
14
25
 
15
26
  export const INTERNAL_CONNECTIVITY_PROMOTION_ATTEMPT_LIMIT: 4;
@@ -21,6 +21,26 @@ const WEBRTC_SELECTED_PATH_STATUS = Object.freeze({
21
21
  ipv6: "WebRTC (IPv6 Direct)",
22
22
  });
23
23
 
24
+ const MANAGED_WEBRTC_STAGE_STATUS = Object.freeze({
25
+ configuration_fetch: "Configuration",
26
+ browser_offer: "Browser offer",
27
+ browser_ice_gathering: "ICE gathering",
28
+ browser_ticket: "Browser ticket",
29
+ signaling_exchange: "Signaling",
30
+ answer_application: "Answer application",
31
+ datachannel_open: "Data channel",
32
+ });
33
+
34
+ const MANAGED_WEBRTC_STAGE_TIMEOUT_CODE = Object.freeze({
35
+ configuration_fetch: "managed_webrtc_configuration_fetch_timeout",
36
+ browser_offer: "managed_webrtc_browser_offer_timeout",
37
+ browser_ice_gathering: "managed_webrtc_browser_ice_gather_timeout",
38
+ browser_ticket: "managed_webrtc_browser_ticket_timeout",
39
+ signaling_exchange: "managed_webrtc_signaling_exchange_timeout",
40
+ answer_application: "managed_webrtc_answer_application_timeout",
41
+ datachannel_open: "managed_webrtc_datachannel_open_timeout",
42
+ });
43
+
24
44
  export const INTERNAL_CONNECTIVITY_PROMOTION_ATTEMPT_LIMIT = 4;
25
45
 
26
46
  function finiteNonNegativeInteger(value) {
@@ -62,9 +82,32 @@ export function deriveInternalConnectivityStatus({
62
82
  : "Idle";
63
83
  }
64
84
 
65
- return Object.freeze({ matrix, activePath, promotion });
85
+ const activeStageKey = transportStatus.managed_webrtc_active_stage;
86
+ const activeStage = MANAGED_WEBRTC_STAGE_STATUS[activeStageKey];
87
+ const lastIssueStageKey = transportStatus.managed_webrtc_last_issue_stage;
88
+ const lastIssueStage = MANAGED_WEBRTC_STAGE_STATUS[lastIssueStageKey];
89
+ const lastIssue =
90
+ !activeStage &&
91
+ lastIssueStage &&
92
+ transportStatus.managed_webrtc_last_issue_code ===
93
+ MANAGED_WEBRTC_STAGE_TIMEOUT_CODE[lastIssueStageKey]
94
+ ? `${lastIssueStage} timeout`
95
+ : null;
96
+
97
+ return Object.freeze({
98
+ matrix,
99
+ activePath,
100
+ promotion,
101
+ ...(activeStage ? { stage: activeStage } : {}),
102
+ ...(lastIssue ? { lastIssue } : {}),
103
+ });
66
104
  }
67
105
 
68
106
  export function internalConnectivityStatusText(status) {
69
- return `Debug Network Connection Status · Matrix: ${status.matrix} · Active path: ${status.activePath} · WebRTC promotion: ${status.promotion}`;
107
+ const detail = status.stage
108
+ ? ` · Stage: ${status.stage}`
109
+ : status.lastIssue
110
+ ? ` · Last issue: ${status.lastIssue}`
111
+ : "";
112
+ return `Debug Network Connection Status · Matrix: ${status.matrix} · Active path: ${status.activePath} · WebRTC promotion: ${status.promotion}${detail}`;
70
113
  }
@@ -25,6 +25,7 @@ export type ManagedHostTicketProvider = (input: {
25
25
  configuration: Record<string, unknown>;
26
26
  connectionAttemptId: string;
27
27
  dtlsFingerprintSha256: string;
28
+ signal?: AbortSignal | null;
28
29
  }) => Promise<{
29
30
  credential: string;
30
31
  client_authority_kind: "app_installation" | "host_owner_browser";
@@ -11,6 +11,7 @@ const REQUIRED_CHANNELS = Object.freeze(["control", "events", "bulk"]);
11
11
  const SUPPORTED_TRANSPORT_PROTOCOL_VERSIONS = Object.freeze([1]);
12
12
  const MANAGED_TRANSPORT_CONTRACT = "oysterun.managed_webrtc.v1";
13
13
  const SIGNALING_TIMEOUT_MS = 20_000;
14
+ const MANAGED_WEBRTC_STAGE_TIMEOUT_MS = 30_000;
14
15
  const MANAGED_ICE_CANDIDATE_SETTLE_MS = 100;
15
16
  const BOOTSTRAP_SAFE_REQUEST_TIMEOUT_MS = 12_000;
16
17
  const BOOTSTRAP_MATRIX_SYNC_TIMEOUT_MS = 45_000;
@@ -80,6 +81,15 @@ const MANAGED_WEBRTC_STAGE_FAILURE_CODES = Object.freeze({
80
81
  datachannel_open: "managed_webrtc_datachannel_open_timeout",
81
82
  logical_path_promotion: "managed_webrtc_logical_path_promotion_failed",
82
83
  });
84
+ const MANAGED_WEBRTC_STAGE_TIMEOUT_CODES = Object.freeze({
85
+ configuration_fetch: "managed_webrtc_configuration_fetch_timeout",
86
+ browser_offer: "managed_webrtc_browser_offer_timeout",
87
+ browser_ice_gathering: "managed_webrtc_browser_ice_gather_timeout",
88
+ browser_ticket: "managed_webrtc_browser_ticket_timeout",
89
+ signaling_exchange: "managed_webrtc_signaling_exchange_timeout",
90
+ answer_application: "managed_webrtc_answer_application_timeout",
91
+ datachannel_open: "managed_webrtc_datachannel_open_timeout",
92
+ });
83
93
  const PRESERVED_MANAGED_WEBRTC_ERROR_CODES = new Set([
84
94
  "managed_webrtc_not_enabled",
85
95
  "managed_webrtc_protocol_incompatible",
@@ -89,6 +99,7 @@ const PRESERVED_MANAGED_WEBRTC_ERROR_CODES = new Set([
89
99
  "managed_webrtc_host_answer_timeout",
90
100
  "managed_webrtc_host_ice_gather_timeout",
91
101
  "managed_webrtc_host_datachannel_open_timeout",
102
+ ...Object.values(MANAGED_WEBRTC_STAGE_TIMEOUT_CODES),
92
103
  ]);
93
104
  const MANAGED_MATRIX_DIAGNOSTIC_EVENTS = new Set([
94
105
  "matrix_sync_state_changed",
@@ -371,6 +382,10 @@ function browserConnectionStatusKey(status) {
371
382
  status.promotion_attempt_count,
372
383
  status.retry_quiesced,
373
384
  status.reconnect_pending,
385
+ status.managed_webrtc_active_stage,
386
+ status.managed_webrtc_active_stage_started_at,
387
+ status.managed_webrtc_last_issue_stage,
388
+ status.managed_webrtc_last_issue_code,
374
389
  status.managed_host_unavailable,
375
390
  status.closed,
376
391
  status.origin_kind,
@@ -393,9 +408,7 @@ export function readManagedWebRtcSelectedPath(hostStatus, connectionId) {
393
408
  }
394
409
  const matchingPeers = hostStatus.peers.filter(
395
410
  (peer) =>
396
- peer &&
397
- typeof peer === "object" &&
398
- peer.connection_id === connectionId
411
+ peer && typeof peer === "object" && peer.connection_id === connectionId
399
412
  );
400
413
  if (matchingPeers.length !== 1) return null;
401
414
  return normalizeManagedWebRtcSelectedPath(matchingPeers[0].selected_path);
@@ -683,9 +696,7 @@ function createManagedIceCandidateReadiness(
683
696
  } else if (!settled) {
684
697
  timeoutTimer = setTimeout(
685
698
  () =>
686
- reject(
687
- new Error("Browser ICE candidate gathering timed out")
688
- ),
699
+ reject(new Error("Browser ICE candidate gathering timed out")),
689
700
  timeoutMs
690
701
  );
691
702
  }
@@ -1063,6 +1074,7 @@ export class ManagedHostBrowserConnection {
1063
1074
  bootstrapSafeRequestTimeoutMs = BOOTSTRAP_SAFE_REQUEST_TIMEOUT_MS,
1064
1075
  bootstrapMatrixSyncTimeoutMs = BOOTSTRAP_MATRIX_SYNC_TIMEOUT_MS,
1065
1076
  presenceHealthRecoveryDelaysMs = PRESENCE_HEALTH_RECOVERY_DELAYS_MS,
1077
+ managedWebRtcStageTimeoutMs = MANAGED_WEBRTC_STAGE_TIMEOUT_MS,
1066
1078
  } = {}) {
1067
1079
  this.origin = origin.replace(/\/+$/, "");
1068
1080
  this.fetchImpl = fetchImpl;
@@ -1076,6 +1088,14 @@ export class ManagedHostBrowserConnection {
1076
1088
  ) {
1077
1089
  throw new Error("Managed Bootstrap request timeouts must be positive");
1078
1090
  }
1091
+ if (
1092
+ !Number.isInteger(managedWebRtcStageTimeoutMs) ||
1093
+ managedWebRtcStageTimeoutMs <= 0
1094
+ ) {
1095
+ throw new Error(
1096
+ "Managed WebRTC stage timeout must be a positive integer"
1097
+ );
1098
+ }
1079
1099
  if (
1080
1100
  !Array.isArray(presenceHealthRecoveryDelaysMs) ||
1081
1101
  presenceHealthRecoveryDelaysMs.length === 0 ||
@@ -1096,6 +1116,7 @@ export class ManagedHostBrowserConnection {
1096
1116
  this.presenceHealthRecoveryDelaysMs = Object.freeze([
1097
1117
  ...presenceHealthRecoveryDelaysMs,
1098
1118
  ]);
1119
+ this.managedWebRtcStageTimeoutMs = managedWebRtcStageTimeoutMs;
1099
1120
  this.clientInstanceId = getClientInstanceId();
1100
1121
  this.bootstrapTransport = new BootstrapFetchTransport({
1101
1122
  origin: this.origin,
@@ -1144,6 +1165,8 @@ export class ManagedHostBrowserConnection {
1144
1165
  this.selectedPathStatusReadConnectionId = null;
1145
1166
  this.selectedPathStatusReadPromise = null;
1146
1167
  this.lastRequestFailed = false;
1168
+ this.activeWebRtcStage = null;
1169
+ this.lastWebRtcStageIssue = null;
1147
1170
  this.accounting = createTransportAccounting();
1148
1171
  this.reconciliationOwner = new EpochBoundTransportReconciliationOwner({
1149
1172
  reconcile: (request) => this.runRestartWebRtc(request),
@@ -1190,9 +1213,7 @@ export class ManagedHostBrowserConnection {
1190
1213
  status.active_path_kind === "webrtc" &&
1191
1214
  this.activeConnection?.connectionId
1192
1215
  ) {
1193
- void this.refreshSelectedWebRtcPath(
1194
- this.activeConnection.connectionId
1195
- );
1216
+ void this.refreshSelectedWebRtcPath(this.activeConnection.connectionId);
1196
1217
  }
1197
1218
  return () => {
1198
1219
  this.statusListeners.delete(listener);
@@ -1243,6 +1264,17 @@ export class ManagedHostBrowserConnection {
1243
1264
 
1244
1265
  async runConnectionStage(stage, attempt, operation) {
1245
1266
  const startedAt = Date.now();
1267
+ const timeoutCode = MANAGED_WEBRTC_STAGE_TIMEOUT_CODES[stage] || null;
1268
+ const abortController = timeoutCode ? new AbortController() : null;
1269
+ const activeStage = timeoutCode
1270
+ ? {
1271
+ stage,
1272
+ startedAt,
1273
+ startedAtIso: new Date(startedAt).toISOString(),
1274
+ attempt,
1275
+ abortController,
1276
+ }
1277
+ : null;
1246
1278
  const fields = {
1247
1279
  connection_attempt_id: attempt.connectionAttemptId,
1248
1280
  connection_generation: attempt.connectionGeneration,
@@ -1255,8 +1287,31 @@ export class ManagedHostBrowserConnection {
1255
1287
  event_type: "managed_webrtc_stage_started",
1256
1288
  ...fields,
1257
1289
  });
1290
+ if (activeStage) {
1291
+ this.activeWebRtcStage = activeStage;
1292
+ this.notifyStatusListeners();
1293
+ }
1294
+ let timeoutTimer = null;
1258
1295
  try {
1259
- const result = await operation();
1296
+ const operationPromise = Promise.resolve().then(() =>
1297
+ operation({ signal: abortController?.signal || null })
1298
+ );
1299
+ const result = timeoutCode
1300
+ ? await Promise.race([
1301
+ operationPromise,
1302
+ new Promise((_, reject) => {
1303
+ timeoutTimer = setTimeout(() => {
1304
+ const timeoutError = managedWebRtcAttemptError(
1305
+ timeoutCode,
1306
+ `Managed WebRTC ${stage} stage timed out`
1307
+ );
1308
+ reject(timeoutError);
1309
+ abortController.abort(timeoutError);
1310
+ this.cancelTimedOutWebRtcAttempt(attempt, timeoutError);
1311
+ }, this.managedWebRtcStageTimeoutMs);
1312
+ }),
1313
+ ])
1314
+ : await operationPromise;
1260
1315
  this.emitDiagnostic({
1261
1316
  event_type: "managed_webrtc_stage_completed",
1262
1317
  ...fields,
@@ -1265,6 +1320,13 @@ export class ManagedHostBrowserConnection {
1265
1320
  return result;
1266
1321
  } catch (error) {
1267
1322
  const normalized = stageSpecificManagedWebRtcError(error, stage);
1323
+ if (normalized.code === timeoutCode) {
1324
+ this.lastWebRtcStageIssue = {
1325
+ stage,
1326
+ errorCode: normalized.code,
1327
+ at: new Date().toISOString(),
1328
+ };
1329
+ }
1268
1330
  this.emitDiagnostic({
1269
1331
  event_type: "managed_webrtc_stage_failed",
1270
1332
  ...fields,
@@ -1272,10 +1334,31 @@ export class ManagedHostBrowserConnection {
1272
1334
  stage_duration_ms: Math.max(0, Date.now() - startedAt),
1273
1335
  });
1274
1336
  throw normalized;
1337
+ } finally {
1338
+ if (timeoutTimer !== null) clearTimeout(timeoutTimer);
1339
+ if (this.activeWebRtcStage === activeStage) {
1340
+ this.activeWebRtcStage = null;
1341
+ this.notifyStatusListeners();
1342
+ }
1275
1343
  }
1276
1344
  }
1277
1345
 
1278
- async nativeJson(path, body) {
1346
+ cancelTimedOutWebRtcAttempt(attempt, timeoutError) {
1347
+ if (this.connectionGeneration === attempt.connectionGeneration) {
1348
+ this.connectionGeneration += 1;
1349
+ }
1350
+ const peer = attempt.peer || null;
1351
+ if (peer && this.pendingIceCandidateReadiness?.peer === peer) {
1352
+ this.pendingIceCandidateReadiness.cancel(timeoutError);
1353
+ this.pendingIceCandidateReadiness = null;
1354
+ }
1355
+ if (peer && this.connectingPeer === peer) {
1356
+ this.connectingPeer = null;
1357
+ peer.close();
1358
+ }
1359
+ }
1360
+
1361
+ async nativeJson(path, body, { signal = null } = {}) {
1279
1362
  const token = getStoredDashboardToken();
1280
1363
  if (!token) throw new Error("Dashboard authentication is unavailable");
1281
1364
  const response = await this.fetchImpl(`${this.origin}${path}`, {
@@ -1289,6 +1372,7 @@ export class ManagedHostBrowserConnection {
1289
1372
  ...body,
1290
1373
  }),
1291
1374
  credentials: "same-origin",
1375
+ ...(signal ? { signal } : {}),
1292
1376
  });
1293
1377
  const data = await response.json().catch(() => ({}));
1294
1378
  if (!response.ok) {
@@ -1492,7 +1576,14 @@ export class ManagedHostBrowserConnection {
1492
1576
  const configurationPayload = await this.runConnectionStage(
1493
1577
  "configuration_fetch",
1494
1578
  attempt,
1495
- () => this.nativeJson(SIGNALING_PATH_PREFIX + "configuration", {})
1579
+ ({ signal }) =>
1580
+ this.nativeJson(
1581
+ SIGNALING_PATH_PREFIX + "configuration",
1582
+ {},
1583
+ {
1584
+ signal,
1585
+ }
1586
+ )
1496
1587
  );
1497
1588
  const configuration = await this.runConnectionStage(
1498
1589
  "configuration_validation",
@@ -1515,6 +1606,7 @@ export class ManagedHostBrowserConnection {
1515
1606
  iceServers: configuration.ice_servers,
1516
1607
  iceTransportPolicy,
1517
1608
  });
1609
+ attempt.peer = peer;
1518
1610
  this.connectingPeer = peer;
1519
1611
  let iceCandidateReadiness = null;
1520
1612
  try {
@@ -1551,12 +1643,13 @@ export class ManagedHostBrowserConnection {
1551
1643
  const ticket = await this.runConnectionStage(
1552
1644
  "browser_ticket",
1553
1645
  attempt,
1554
- async () =>
1646
+ async ({ signal }) =>
1555
1647
  this.ticketProvider
1556
1648
  ? await this.ticketProvider({
1557
1649
  configuration,
1558
1650
  connectionAttemptId,
1559
1651
  dtlsFingerprintSha256,
1652
+ signal,
1560
1653
  })
1561
1654
  : {
1562
1655
  ...(await this.nativeJson(
@@ -1564,7 +1657,8 @@ export class ManagedHostBrowserConnection {
1564
1657
  {
1565
1658
  connection_attempt_id: connectionAttemptId,
1566
1659
  dtls_fingerprint_sha256: dtlsFingerprintSha256,
1567
- }
1660
+ },
1661
+ { signal }
1568
1662
  )),
1569
1663
  client_authority_kind: "host_owner_browser",
1570
1664
  }
@@ -1584,18 +1678,22 @@ export class ManagedHostBrowserConnection {
1584
1678
  const answer = await this.runConnectionStage(
1585
1679
  "signaling_exchange",
1586
1680
  attempt,
1587
- () =>
1588
- this.nativeJson(SIGNALING_PATH_PREFIX + "connect", {
1589
- ticket: ticket.credential,
1590
- client_authority_kind: ticket.client_authority_kind,
1591
- connection_attempt_id: connectionAttemptId,
1592
- dtls_fingerprint_sha256: dtlsFingerprintSha256,
1593
- offer: {
1594
- type: localDescription.type,
1595
- sdp: localDescription.sdp,
1681
+ ({ signal }) =>
1682
+ this.nativeJson(
1683
+ SIGNALING_PATH_PREFIX + "connect",
1684
+ {
1685
+ ticket: ticket.credential,
1686
+ client_authority_kind: ticket.client_authority_kind,
1687
+ connection_attempt_id: connectionAttemptId,
1688
+ dtls_fingerprint_sha256: dtlsFingerprintSha256,
1689
+ offer: {
1690
+ type: localDescription.type,
1691
+ sdp: localDescription.sdp,
1692
+ },
1693
+ candidates: iceCandidateReadiness.candidates,
1596
1694
  },
1597
- candidates: iceCandidateReadiness.candidates,
1598
- })
1695
+ { signal }
1696
+ )
1599
1697
  );
1600
1698
  assertCurrentAttempt(peer);
1601
1699
  await this.runConnectionStage("answer_application", attempt, async () => {
@@ -1654,6 +1752,7 @@ export class ManagedHostBrowserConnection {
1654
1752
  );
1655
1753
  assertCurrentAttempt(peer);
1656
1754
  this.lastRequestFailed = false;
1755
+ this.lastWebRtcStageIssue = null;
1657
1756
  this.webrtcSelectedPath = null;
1658
1757
  this.webrtcSelectedPathConnectionId = answer.connection_id;
1659
1758
  this.notifyStatusListeners();
@@ -2495,6 +2594,12 @@ export class ManagedHostBrowserConnection {
2495
2594
  promotion_attempt_count: this.promotionAttemptCount,
2496
2595
  retry_quiesced: this.retryQuiesced,
2497
2596
  reconnect_pending: Boolean(this.reconnectTimer || this.startPromise),
2597
+ managed_webrtc_active_stage: this.activeWebRtcStage?.stage || null,
2598
+ managed_webrtc_active_stage_started_at:
2599
+ this.activeWebRtcStage?.startedAtIso || null,
2600
+ managed_webrtc_last_issue_stage: this.lastWebRtcStageIssue?.stage || null,
2601
+ managed_webrtc_last_issue_code:
2602
+ this.lastWebRtcStageIssue?.errorCode || null,
2498
2603
  reconciliation_epoch: this.reconciliationOwner.status().epoch,
2499
2604
  reconciliation_pending:
2500
2605
  this.reconciliationOwner.status().in_flight ||
@@ -0,0 +1,16 @@
1
+ export function deriveSessionSwitcherViewportBounds(input: {
2
+ viewport?: { offsetTop?: number; height?: number } | null;
3
+ innerHeight: number;
4
+ headerHeight?: number;
5
+ bottomObstructionHeight?: number;
6
+ }): { top: number; height: number; bottomInset: number };
7
+
8
+ export function hasOtherUnreadSession(input: {
9
+ previews?: Array<{
10
+ session_id?: string;
11
+ has_unread_completed_message?: boolean;
12
+ }>;
13
+ currentSessionId: string;
14
+ }): boolean;
15
+
16
+ export function assertSessionSwitcherProjection<T>(value: T): T;
@@ -0,0 +1,72 @@
1
+ export function deriveSessionSwitcherViewportBounds({
2
+ viewport,
3
+ innerHeight,
4
+ headerHeight = 56,
5
+ bottomObstructionHeight = 0,
6
+ }) {
7
+ const visibleTop = Number.isFinite(viewport?.offsetTop)
8
+ ? Math.max(0, viewport.offsetTop)
9
+ : 0;
10
+ const visibleHeight = Number.isFinite(viewport?.height)
11
+ ? Math.max(0, viewport.height)
12
+ : Math.max(0, Number(innerHeight) || 0);
13
+ const top = Math.max(headerHeight, visibleTop);
14
+ return {
15
+ top,
16
+ height: Math.max(0, visibleTop + visibleHeight - top),
17
+ bottomInset: Math.max(0, Number(bottomObstructionHeight) || 0),
18
+ };
19
+ }
20
+
21
+ export function hasOtherUnreadSession({ previews, currentSessionId }) {
22
+ return (Array.isArray(previews) ? previews : []).some(
23
+ (preview) =>
24
+ preview?.session_id !== currentSessionId &&
25
+ preview?.has_unread_completed_message === true
26
+ );
27
+ }
28
+
29
+ export function assertSessionSwitcherProjection(value) {
30
+ const rows = Array.isArray(value?.session_rows) ? value.session_rows : [];
31
+ const rowIds = rows.map((row) => row?.session_id);
32
+ const nullableString = (entry) => typeof entry === "string" || entry === null;
33
+ const validRows = rows.every(
34
+ (row) =>
35
+ row &&
36
+ row.kind === "running" &&
37
+ typeof row.session_id === "string" &&
38
+ row.session_id.trim() === row.session_id &&
39
+ row.session_id.length > 0 &&
40
+ typeof row.agent_id === "string" &&
41
+ row.agent_id.trim() === row.agent_id &&
42
+ row.agent_id.length > 0 &&
43
+ typeof row.session_name === "string" &&
44
+ row.session_name.trim().length > 0 &&
45
+ nullableString(row.agent_folder) &&
46
+ nullableString(row.runtime) &&
47
+ nullableString(row.model) &&
48
+ nullableString(row.started_at) &&
49
+ nullableString(row.last_active_at) &&
50
+ nullableString(row.matrix_room_id) &&
51
+ nullableString(row.resume_unavailable_reason) &&
52
+ typeof row.running === "boolean" &&
53
+ row.running === true &&
54
+ typeof row.resume_supported === "boolean" &&
55
+ row.resume_supported === false
56
+ );
57
+ if (
58
+ value?.status !== "session_list_previews" ||
59
+ value.session_switcher_projection_contract !==
60
+ "routec.p217_live_session_switcher_projection.v1" ||
61
+ value.session_switcher_projection_batched !== true ||
62
+ value.session_switcher_per_row_http_requests !== 0 ||
63
+ value.session_switcher_matrix_query_count !== 0 ||
64
+ !Number.isSafeInteger(value.session_count) ||
65
+ value.session_count !== rows.length ||
66
+ new Set(rowIds).size !== rows.length ||
67
+ !validRows
68
+ ) {
69
+ throw new Error("Session switcher projection is unavailable.");
70
+ }
71
+ return value;
72
+ }