@oxvo/ai-live-assist 7.5.1 → 7.5.6

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.
@@ -34,7 +34,7 @@ export declare const normalizeAiGuideOpenInput: (input?: AiGuideOpenInput) => Re
34
34
  export declare const resolveAiGuideProtocolVersion: (bootstrap: Pick<BootstrapConfig, "experienceMode" | "minimumProtocolVersion" | "protocol">) => 1 | 2;
35
35
  export default class AiLiveAssist {
36
36
  private readonly options;
37
- readonly version = "7.5.1";
37
+ readonly version = "7.5.6";
38
38
  private readonly host;
39
39
  private readonly guideAdmission;
40
40
  private readonly standaloneAdmissionPending;
@@ -627,6 +627,7 @@ export default class AiLiveAssist {
627
627
  private submitFeedback;
628
628
  private retry;
629
629
  private scheduleReconnect;
630
+ private canRecoverControlTransport;
630
631
  private reconnectPendingHandoffControlInMemory;
631
632
  private reconnectMessengerTerminalControl;
632
633
  private clearReconnectTimer;
@@ -7945,7 +7945,28 @@ class AiLiveAssist {
7945
7945
  ? this.navigationConsentResetReady
7946
7946
  : null;
7947
7947
  const resumeRecordAtConnect = this.readResume();
7948
- const channel = new control_js_1.ControlChannel(result, this.tabId, version_js_1.VERSION, () => this.scheduleReconnect(true), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence), () => this.handleControlSequenceGap());
7948
+ let recoveryPath = null;
7949
+ const channel = new control_js_1.ControlChannel(result, this.tabId, version_js_1.VERSION, () => this.scheduleReconnect(true), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence), () => this.handleControlSequenceGap(), {
7950
+ canRecover: () => {
7951
+ const currentPath = path();
7952
+ if (!this.canRecoverControlTransport(channel, result, media) ||
7953
+ (recoveryPath !== null && recoveryPath !== currentPath))
7954
+ return false;
7955
+ recoveryPath = currentPath;
7956
+ return true;
7957
+ },
7958
+ onRecovered: async () => {
7959
+ assertCurrent();
7960
+ const fencingToken = channel.leaseToken;
7961
+ channel.startHeartbeat(this.heartbeatSeconds);
7962
+ await this.replayPreFenceAuthorityEnvelopes(channel, fencingToken, assertCurrent);
7963
+ assertCurrent();
7964
+ await this.context?.captureAndPublish();
7965
+ assertCurrent();
7966
+ this.flushPlaybackState();
7967
+ this.reapplyConservativePreFencePresence(channel, fencingToken);
7968
+ },
7969
+ });
7949
7970
  const pauseHelloStateSignal = deferredSignal();
7950
7971
  (this.pauseHelloStateSignals ?? (this.pauseHelloStateSignals = new WeakMap())).set(channel, pauseHelloStateSignal);
7951
7972
  this.handoffContextPromptReceiptChannel = null;
@@ -8315,7 +8336,7 @@ class AiLiveAssist {
8315
8336
  this.context === context;
8316
8337
  context = new context_js_1.PageContextCollector({
8317
8338
  onSnapshot: (snapshot) => {
8318
- if (isCurrent() && this.channel?.leaseToken) {
8339
+ if (isCurrent() && this.channel?.hasFencedAuthority) {
8319
8340
  this.send("context.snapshot", snapshot);
8320
8341
  }
8321
8342
  },
@@ -9674,7 +9695,15 @@ class AiLiveAssist {
9674
9695
  this.activePageAssistanceAuthorityEpoch === null &&
9675
9696
  this.activeActionAuthorityEpoch === null;
9676
9697
  if (!exactApplied || !localAuthorityInactive) {
9677
- this.invalidatePageAssistanceAuthority(false);
9698
+ // A resumed document still owes the original action's terminal result.
9699
+ // Preserve only its grantless outbox, never browser-control authority.
9700
+ // Delivery remains fenced by the server's exact already_started receipt.
9701
+ const preserveNavigationResult = source === "navigation_resume" &&
9702
+ this.awaitingNavigationConsentReset &&
9703
+ structurallyValid &&
9704
+ localAuthorityInactive &&
9705
+ this.pendingNavigationActionResult?.grant === null;
9706
+ this.invalidatePageAssistanceAuthority(false, preserveNavigationResult);
9678
9707
  }
9679
9708
  if (!structurallyValid ||
9680
9709
  identity.authorityEpoch < this.minimumPageAssistanceAuthorityEpoch) {
@@ -11997,15 +12026,19 @@ class AiLiveAssist {
11997
12026
  // intentionally result-less until navigation settles, preserving the
11998
12027
  // cross-document recovery contract if this click unloads the page.
11999
12028
  pendingExecution.result = deliveryResult;
12029
+ let navigationReleased = false;
12000
12030
  try {
12001
- if (await this.navigateTo(deferredNavigation)) {
12002
- return deliveryResult;
12003
- }
12004
- deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12031
+ navigationReleased = await this.navigateTo(deferredNavigation);
12005
12032
  }
12006
12033
  catch {
12007
- deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12034
+ // A rejected router and an outgoing document both settle here.
12008
12035
  }
12036
+ // Pagehide stops the old collector and settles its pending router
12037
+ // callback. It must not replace the destination document's grantless
12038
+ // recovery record with an unconfirmed result from the outgoing page.
12039
+ if (navigationReleased || !authorityIsCurrent())
12040
+ return deliveryResult;
12041
+ deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12009
12042
  }
12010
12043
  else if (deferredNavigation) {
12011
12044
  deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
@@ -14669,6 +14702,21 @@ class AiLiveAssist {
14669
14702
  }, delay + Math.floor(Math.random() * 250));
14670
14703
  this.reconnectTimer = timer;
14671
14704
  }
14705
+ canRecoverControlTransport(channel, result, media) {
14706
+ return (this.channel === channel &&
14707
+ this.result === result &&
14708
+ this.media === media && media.isConnected && navigator.onLine &&
14709
+ !this.ended && !this.destroyed && !this.terminalRequestPending &&
14710
+ !this.reconnecting && !this.launchInFlight && !this.isVoicePresence() &&
14711
+ (this.lifecyclePhase === "active_text" || this.lifecyclePhase === "active_voice") &&
14712
+ !this.pendingAction && !this.pendingConfirmation && !this.pendingPageAssistance &&
14713
+ !this.pendingNavigationActionResult && !this.legacyActionExecution &&
14714
+ this.actionGrantInFlight.size === 0 &&
14715
+ !this.pendingModeUpgrade && !this.pendingTextModeTransition &&
14716
+ !this.messengerTransferPending && !this.messengerTransferred &&
14717
+ !this.handoffRequestLifecycleOwner && !this.readResume()?.pauseCommand &&
14718
+ this.modeUpgradePathIsCurrent(path()));
14719
+ }
14672
14720
  async reconnectPendingHandoffControlInMemory(result, record, clientSequence, fencingToken, reconnectGeneration) {
14673
14721
  if (reconnectGeneration !== this.reconnectGeneration ||
14674
14722
  this.reconnecting ||
package/cjs/context.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class PageContextCollector {
23
23
  private mutationObserver;
24
24
  private captureTimer;
25
25
  private mutationCaptureTimer;
26
+ private mutationCaptureStartedAt;
26
27
  private targetIds;
27
28
  private pageScrollIds;
28
29
  private navigationTargetBindings;
@@ -74,6 +75,7 @@ export declare class PageContextCollector {
74
75
  private waitForScrollSettlement;
75
76
  private actionApplied;
76
77
  private clippedGeometry;
78
+ private clearMutationCapture;
77
79
  private scheduleCapture;
78
80
  private scheduleMutationCapture;
79
81
  private scheduleViewportCapture;
package/cjs/context.js CHANGED
@@ -10,6 +10,7 @@ const MAX_SELECTED_TEXT = 2000;
10
10
  const SELECTION_RETENTION_MS = 120000;
11
11
  const CAPTURE_SETTLE_MS = 250;
12
12
  const MUTATION_CAPTURE_DEBOUNCE_MS = 350;
13
+ const MUTATION_CAPTURE_MAX_WAIT_MS = 1000;
13
14
  const SCROLL_SETTLEMENT_TIMEOUT_MS = 2000;
14
15
  const SCROLL_SETTLEMENT_FRAME_FALLBACK_MS = 50;
15
16
  const SCROLL_SETTLEMENT_POSITION_TOLERANCE_PX = 1;
@@ -198,10 +199,10 @@ const visibleRect = (element) => {
198
199
  return right > left && bottom > top ? { left, top, right, bottom } : null;
199
200
  };
200
201
  const isVisible = (element) => visibleRect(element) !== null;
201
- const isCovered = (element, trustedGuideRoot = null) => {
202
+ const interactionPoint = (element, trustedGuideRoot = null) => {
202
203
  const rect = visibleRect(element);
203
204
  if (!rect)
204
- return true;
205
+ return null;
205
206
  // Five bounded hit tests permit an exposed part of a link without treating
206
207
  // an ancestor, empty hit, or a covering sibling as an interactable target.
207
208
  const insetX = Math.min(1, (rect.right - rect.left) / 4);
@@ -211,13 +212,16 @@ const isCovered = (element, trustedGuideRoot = null) => {
211
212
  [rect.left + insetX, rect.top + insetY], [rect.right - insetX, rect.top + insetY],
212
213
  [rect.left + insetX, rect.bottom - insetY], [rect.right - insetX, rect.bottom - insetY],
213
214
  ];
214
- return !points.some(([x, y]) => {
215
+ for (const [x, y] of points) {
215
216
  const top = trustedGuideRoot?.isConnected && typeof document.elementsFromPoint === 'function'
216
217
  ? document.elementsFromPoint(x, y).find(candidate => candidate !== trustedGuideRoot) ?? null
217
218
  : document.elementFromPoint(x, y);
218
- return top !== null && (top === element || element.contains(top));
219
- });
219
+ if (top !== null && (top === element || element.contains(top)))
220
+ return { x: x, y: y, hit: top };
221
+ }
222
+ return null;
220
223
  };
224
+ const isCovered = (element, trustedGuideRoot = null) => interactionPoint(element, trustedGuideRoot) === null;
221
225
  const isNavigationMenu = (element, role) => {
222
226
  if (role !== 'button' || literalBooleanAttribute(element, 'aria-expanded') === undefined ||
223
227
  element.hasAttribute('aria-pressed') || element.hasAttribute('aria-checked') ||
@@ -492,15 +496,17 @@ const checkedStateFor = (element) => {
492
496
  }
493
497
  return framerToggleDescriptor(element)?.checked;
494
498
  };
495
- const dispatchFramerPrimaryPointerSequence = (element) => {
499
+ const dispatchFramerPrimaryPointerSequence = (element, trustedGuideRoot = null) => {
496
500
  if (typeof window.PointerEvent !== 'function') {
497
501
  throw new Error('Framer pointer interaction is unavailable.');
498
502
  }
499
- const rect = element.getBoundingClientRect();
500
- const clientX = rect.left + rect.width / 2;
501
- const clientY = rect.top + rect.height / 2;
502
- const hit = document.elementFromPoint(clientX, clientY);
503
- const eventTarget = element instanceof HTMLAnchorElement && hit && element.contains(hit) ? hit : element;
503
+ // Capture and dispatch must agree on the exposed point and exact descendant,
504
+ // including when our own mobile surface covers a link's router-owned logo.
505
+ const point = interactionPoint(element, trustedGuideRoot);
506
+ if (!point)
507
+ throw new Error('The approved pointer target is covered.');
508
+ const { x: clientX, y: clientY } = point;
509
+ const eventTarget = element instanceof HTMLAnchorElement ? point.hit : element;
504
510
  const nestedLink = eventTarget.closest('a[href]');
505
511
  if (element instanceof HTMLAnchorElement && nestedLink instanceof HTMLAnchorElement && nestedLink !== element &&
506
512
  (!sameNavigationDestination(nestedLink.href, element.href) || nestedLink.hasAttribute('download') ||
@@ -572,11 +578,11 @@ const dispatchFramerPrimaryPointerSequence = (element) => {
572
578
  detail: 1,
573
579
  }));
574
580
  };
575
- const dispatchFramerTogglePrimaryPointer = (element) => {
581
+ const dispatchFramerTogglePrimaryPointer = (element, trustedGuideRoot = null) => {
576
582
  if (!framerToggleDescriptor(element)) {
577
583
  throw new Error('Framer toggle pointer interaction is unavailable.');
578
584
  }
579
- dispatchFramerPrimaryPointerSequence(element);
585
+ dispatchFramerPrimaryPointerSequence(element, trustedGuideRoot);
580
586
  };
581
587
  const semanticSourceSensitivity = (source) => {
582
588
  if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
@@ -746,6 +752,7 @@ class PageContextCollector {
746
752
  this.mutationObserver = null;
747
753
  this.captureTimer = null;
748
754
  this.mutationCaptureTimer = null;
755
+ this.mutationCaptureStartedAt = null;
749
756
  this.targetIds = new WeakMap();
750
757
  this.pageScrollIds = new WeakMap();
751
758
  this.navigationTargetBindings = new WeakMap();
@@ -773,9 +780,7 @@ class PageContextCollector {
773
780
  this.pendingCapture = true;
774
781
  return;
775
782
  }
776
- if (this.mutationCaptureTimer)
777
- clearTimeout(this.mutationCaptureTimer);
778
- this.mutationCaptureTimer = null;
783
+ this.clearMutationCapture();
779
784
  if (this.captureTimer)
780
785
  return;
781
786
  this.captureTimer = setTimeout(() => {
@@ -792,12 +797,19 @@ class PageContextCollector {
792
797
  }
793
798
  if (this.captureTimer)
794
799
  return;
800
+ // Decorative animation must not indefinitely hide newly available links.
801
+ // Retain trailing-edge batching, with at most one forced scan per second
802
+ // during continuous churn. Unchanged semantic snapshots are still deduped.
803
+ const now = performance.now();
804
+ this.mutationCaptureStartedAt ?? (this.mutationCaptureStartedAt = now);
805
+ const delay = Math.max(0, Math.min(MUTATION_CAPTURE_DEBOUNCE_MS, MUTATION_CAPTURE_MAX_WAIT_MS - (now - this.mutationCaptureStartedAt)));
795
806
  if (this.mutationCaptureTimer)
796
807
  clearTimeout(this.mutationCaptureTimer);
797
808
  this.mutationCaptureTimer = setTimeout(() => {
798
809
  this.mutationCaptureTimer = null;
810
+ this.mutationCaptureStartedAt = null;
799
811
  void this.captureAndPublishIfChanged();
800
- }, MUTATION_CAPTURE_DEBOUNCE_MS);
812
+ }, delay);
801
813
  };
802
814
  this.scheduleViewportCapture = () => {
803
815
  this.scheduleMutationCapture();
@@ -870,9 +882,7 @@ class PageContextCollector {
870
882
  if (this.captureTimer)
871
883
  clearTimeout(this.captureTimer);
872
884
  this.captureTimer = null;
873
- if (this.mutationCaptureTimer)
874
- clearTimeout(this.mutationCaptureTimer);
875
- this.mutationCaptureTimer = null;
885
+ this.clearMutationCapture();
876
886
  if (this.releasedNavigationSettlement &&
877
887
  window.location.href !== this.releasedNavigationSettlement.fromUrl) {
878
888
  this.finishReleasedNavigationSettlement(sameNavigationDestination(window.location.href, this.releasedNavigationSettlement.toUrl)
@@ -1003,9 +1013,7 @@ class PageContextCollector {
1003
1013
  if (this.captureTimer)
1004
1014
  clearTimeout(this.captureTimer);
1005
1015
  this.captureTimer = null;
1006
- if (this.mutationCaptureTimer)
1007
- clearTimeout(this.mutationCaptureTimer);
1008
- this.mutationCaptureTimer = null;
1016
+ this.clearMutationCapture();
1009
1017
  window.removeEventListener('resize', this.scheduleViewportCapture);
1010
1018
  document.removeEventListener('scroll', this.scheduleViewportCapture, true);
1011
1019
  window.visualViewport?.removeEventListener('resize', this.scheduleViewportCapture);
@@ -1842,7 +1850,7 @@ class PageContextCollector {
1842
1850
  };
1843
1851
  window.addEventListener('click', preventNativeNavigation);
1844
1852
  try {
1845
- dispatchFramerPrimaryPointerSequence(navigation.source);
1853
+ dispatchFramerPrimaryPointerSequence(navigation.source, this.trustedGuideRoot);
1846
1854
  }
1847
1855
  finally {
1848
1856
  window.removeEventListener('click', preventNativeNavigation);
@@ -1924,7 +1932,7 @@ class PageContextCollector {
1924
1932
  if (desiredStates.length > 0 && desiredStates.every(Boolean))
1925
1933
  return;
1926
1934
  if (desiredChecked !== undefined && framerToggleDescriptor(element)) {
1927
- dispatchFramerTogglePrimaryPointer(element);
1935
+ dispatchFramerTogglePrimaryPointer(element, this.trustedGuideRoot);
1928
1936
  return;
1929
1937
  }
1930
1938
  if (element instanceof HTMLAnchorElement) {
@@ -2232,9 +2240,19 @@ class PageContextCollector {
2232
2240
  height,
2233
2241
  };
2234
2242
  }
2243
+ clearMutationCapture() {
2244
+ if (this.mutationCaptureTimer)
2245
+ clearTimeout(this.mutationCaptureTimer);
2246
+ this.mutationCaptureTimer = null;
2247
+ this.mutationCaptureStartedAt = null;
2248
+ }
2235
2249
  async captureAndPublishIfChanged() {
2236
- if (this.navigationSettlement)
2250
+ if (!this.running || this.navigationSettlement)
2237
2251
  return;
2252
+ if (this.executingActionId) {
2253
+ this.pendingCapture = true;
2254
+ return;
2255
+ }
2238
2256
  const snapshot = this.captureSnapshot(false);
2239
2257
  if (snapshot && this.running)
2240
2258
  await this.callbacks.onSnapshot(snapshot);
@@ -2319,9 +2337,7 @@ class PageContextCollector {
2319
2337
  if (this.captureTimer)
2320
2338
  clearTimeout(this.captureTimer);
2321
2339
  this.captureTimer = null;
2322
- if (this.mutationCaptureTimer)
2323
- clearTimeout(this.mutationCaptureTimer);
2324
- this.mutationCaptureTimer = null;
2340
+ this.clearMutationCapture();
2325
2341
  return settlement;
2326
2342
  }
2327
2343
  observeCausalNavigationAfterMutation(actionId, destinationPath, beforeActionUrl) {
package/cjs/control.d.ts CHANGED
@@ -24,6 +24,10 @@ export type ControlOutboundOperation = {
24
24
  type: string;
25
25
  };
26
26
  export type ReservedControlOperation = Readonly<ControlOutboundOperation>;
27
+ type TransportRecovery = {
28
+ canRecover: () => boolean;
29
+ onRecovered: () => Promise<void>;
30
+ };
27
31
  export declare class ControlChannel {
28
32
  private readonly result;
29
33
  private readonly tabId;
@@ -31,6 +35,7 @@ export declare class ControlChannel {
31
35
  private readonly onDisconnect;
32
36
  private readonly onClientSequence;
33
37
  private readonly onUnsafeDisconnect;
38
+ private readonly transportRecovery?;
34
39
  private socket;
35
40
  private clientSequence;
36
41
  private serverSequence;
@@ -54,7 +59,9 @@ export declare class ControlChannel {
54
59
  private pendingConnection;
55
60
  private helloOperation;
56
61
  private fencedAuthorityConfirmed;
57
- constructor(result: RuntimeAuthorization, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void, onUnsafeDisconnect?: () => void);
62
+ private transportRecoveryAttempted;
63
+ private recoveringTransport;
64
+ constructor(result: RuntimeAuthorization, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void, onUnsafeDisconnect?: () => void, transportRecovery?: TransportRecovery | undefined);
58
65
  get leaseToken(): number;
59
66
  get lastClientSequence(): number;
60
67
  get isOpen(): boolean;
@@ -78,6 +85,7 @@ export declare class ControlChannel {
78
85
  private handleMessage;
79
86
  private recoverFromSequenceGap;
80
87
  private notifyUnsafeDisconnect;
88
+ private recoverTransport;
81
89
  private enqueueHandlers;
82
90
  private dispatchHandlers;
83
91
  private handleHandlerFailure;
package/cjs/control.js CHANGED
@@ -6,6 +6,7 @@ const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
6
6
  const SERVER_SEQUENCE_GAP_CLOSE_CODE = 4009;
7
7
  const HANDLER_FAILURE_CLOSE_CODE = 4010;
8
8
  const HANDLER_TIMEOUT_MS = 15000;
9
+ const TRANSPORT_RECOVERY_TIMEOUT_MS = 5000;
9
10
  const MAX_PENDING_HANDLER_MESSAGES = 64;
10
11
  const MAX_PENDING_STATEFUL_OPERATIONS = 64;
11
12
  const MAX_SETTLED_OPERATION_HISTORY = 100;
@@ -38,13 +39,14 @@ const STATEFUL_CONTROL_TYPES = new Set([
38
39
  ]);
39
40
  const outcomeKey = (messageId, sequence) => `${messageId}\u0000${sequence}`;
40
41
  class ControlChannel {
41
- constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined) {
42
+ constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined, transportRecovery) {
42
43
  this.result = result;
43
44
  this.tabId = tabId;
44
45
  this.clientVersion = clientVersion;
45
46
  this.onDisconnect = onDisconnect;
46
47
  this.onClientSequence = onClientSequence;
47
48
  this.onUnsafeDisconnect = onUnsafeDisconnect;
49
+ this.transportRecovery = transportRecovery;
48
50
  this.socket = null;
49
51
  this.serverSequence = 0;
50
52
  this.serverBaselineEstablished = false;
@@ -67,6 +69,8 @@ class ControlChannel {
67
69
  this.pendingConnection = null;
68
70
  this.helloOperation = null;
69
71
  this.fencedAuthorityConfirmed = false;
72
+ this.transportRecoveryAttempted = false;
73
+ this.recoveringTransport = false;
70
74
  this.clientSequence =
71
75
  Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
72
76
  ? initialClientSequence
@@ -79,7 +83,7 @@ class ControlChannel {
79
83
  return this.clientSequence;
80
84
  }
81
85
  get isOpen() {
82
- return this.socket?.readyState === WebSocket.OPEN;
86
+ return (!this.recoveringTransport && this.socket?.readyState === WebSocket.OPEN);
83
87
  }
84
88
  get connectionHelloOperation() {
85
89
  return this.helloOperation ? { ...this.helloOperation } : null;
@@ -180,10 +184,19 @@ class ControlChannel {
180
184
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
181
185
  }
182
186
  }, { once: true });
183
- socket.addEventListener("close", () => {
187
+ socket.addEventListener("close", (event) => {
184
188
  if (!this.isCurrentSocket(socket, generation))
185
189
  return;
186
190
  const wasIntentionallyClosed = this.intentionallyClosed;
191
+ // A settled transport loss is not a new session. Keep its provider
192
+ // connection alive for one bounded retry, but never carry ambiguous
193
+ // browser writes, an active handler, or a protocol failure across it.
194
+ const canRecover = event.code === 1006 &&
195
+ this.fencedAuthorityConfirmed &&
196
+ !this.transportRecoveryAttempted &&
197
+ !this.sequenceGapDetected &&
198
+ this.pendingHandlerMessages === 0 &&
199
+ this.unsettledStatefulOperations.size === 0;
187
200
  this.socket = null;
188
201
  this.socketGeneration += 1;
189
202
  this.stopHeartbeat();
@@ -197,12 +210,24 @@ class ControlChannel {
197
210
  this.pendingHandlerMessages = 0;
198
211
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel closed before connecting."));
199
212
  if (!wasIntentionallyClosed && !signal.aborted) {
213
+ try {
214
+ if (canRecover && this.transportRecovery?.canRecover()) {
215
+ void this.recoverTransport(signal);
216
+ return;
217
+ }
218
+ }
219
+ catch {
220
+ /* A failed eligibility check uses full recovery. */
221
+ }
200
222
  this.notifyUnsafeDisconnect();
201
223
  }
202
224
  });
203
225
  });
204
226
  }
205
227
  send(type, payload, stableMessageId) {
228
+ if (this.recoveringTransport && type !== "hello" && type !== "heartbeat") {
229
+ throw new Error("The AI Guide control channel is reconnecting.");
230
+ }
206
231
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
207
232
  throw new Error("The AI Guide control channel is not connected.");
208
233
  }
@@ -513,6 +538,59 @@ class ControlChannel {
513
538
  this.onDisconnect();
514
539
  }
515
540
  }
541
+ async recoverTransport(signal) {
542
+ this.transportRecoveryAttempted = true;
543
+ this.recoveringTransport = true;
544
+ const controller = new AbortController();
545
+ const abort = () => controller.abort();
546
+ signal.addEventListener("abort", abort, { once: true });
547
+ let timeout;
548
+ const deadline = new Promise((_resolve, reject) => {
549
+ timeout = setTimeout(() => {
550
+ controller.abort();
551
+ reject(new Error("The control reconnect timed out."));
552
+ }, TRANSPORT_RECOVERY_TIMEOUT_MS);
553
+ });
554
+ const fencingToken = this.fencingToken;
555
+ let recovered = false;
556
+ try {
557
+ await Promise.race([
558
+ deadline,
559
+ (async () => {
560
+ if (signal.aborted)
561
+ throw new Error("The control connection was retired.");
562
+ await this.connect(controller.signal, true);
563
+ const hello = await this.waitForHelloOutcome(2000);
564
+ if (hello.status !== "ack" || controller.signal.aborted) {
565
+ throw new Error("The control reconnect was not acknowledged.");
566
+ }
567
+ const authority = await this.proveFencedAuthority(2000);
568
+ if (authority.status !== "ack" ||
569
+ controller.signal.aborted ||
570
+ this.fencingToken !== fencingToken ||
571
+ !this.transportRecovery?.canRecover()) {
572
+ throw new Error("The control reconnect no longer owns this session.");
573
+ }
574
+ this.recoveringTransport = false;
575
+ await this.transportRecovery.onRecovered();
576
+ recovered = !controller.signal.aborted && this.hasFencedAuthority;
577
+ })(),
578
+ ]);
579
+ }
580
+ catch {
581
+ // The existing full-resume path revokes local executable authority and
582
+ // handles expired credentials, changed ownership, and longer outages.
583
+ }
584
+ finally {
585
+ clearTimeout(timeout);
586
+ signal.removeEventListener("abort", abort);
587
+ this.recoveringTransport = false;
588
+ if (!recovered && !signal.aborted && !this.intentionallyClosed) {
589
+ this.retireSocket("control reconnect failed");
590
+ this.notifyUnsafeDisconnect();
591
+ }
592
+ }
593
+ }
516
594
  enqueueHandlers(message, socket, generation) {
517
595
  const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
518
596
  if (!isCurrent())
@@ -1,5 +1,5 @@
1
1
  import { type LiveDuplexMediaOptions, type LiveDuplexMediaDependencies } from './liveDuplexMedia.js';
2
- export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.1";
2
+ export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.6";
3
3
  export declare const LIVE_CONTROL_CLIENT_CAPABILITIES: readonly ["live_duplex_v1", "live_local_wake_v1", "live_fragment_captions_v1"];
4
4
  declare const phases: readonly ["voice_off", "local_listening", "wake_buffering", "starting", "active", "tentative_idle", "closing", "uncertain", "muted", "suspended", "budget_blocked", "ended"];
5
5
  export type LiveControlState = {
package/cjs/media.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare class RealtimeMedia {
36
36
  connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal, offerIdempotencyKey?: string, expectedSessionState?: "active_text" | "active_voice"): Promise<number>;
37
37
  setMuted(muted: boolean): void;
38
38
  get isMuted(): boolean;
39
+ get isConnected(): boolean;
39
40
  setAssistantPlaybackAudible(audible: boolean): void;
40
41
  setManualTurn(manual: boolean): void;
41
42
  beginPushToTalk(): void;
package/cjs/media.js CHANGED
@@ -137,6 +137,9 @@ class RealtimeMedia {
137
137
  get isMuted() {
138
138
  return this.muted;
139
139
  }
140
+ get isConnected() {
141
+ return !this.closed && this.connection?.connectionState === "connected";
142
+ }
140
143
  setAssistantPlaybackAudible(audible) {
141
144
  if (this.closed)
142
145
  return;
package/cjs/ui.js CHANGED
@@ -6793,7 +6793,9 @@ class WidgetView {
6793
6793
  this.setActionStateRunning(status === "running");
6794
6794
  if (this.actionStateRunning) {
6795
6795
  this.clearAssistantReadyVisualTimer();
6796
+ const previousState = this.assistantState;
6796
6797
  this.assistantState = "thinking";
6798
+ this.updateRemoteAudioEvidenceExpectation(previousState, "thinking");
6797
6799
  const assistantCaption = this.captions.querySelector('[data-caption="assistant-partial"]');
6798
6800
  if (!assistantCaption || assistantCaption.classList.contains("pending")) {
6799
6801
  this.ensurePendingCaption("assistant", this.messages.thinking);
package/cjs/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "7.5.1";
1
+ export declare const VERSION = "7.5.6";
package/cjs/version.js CHANGED
@@ -1,4 +1,4 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.VERSION = void 0;
4
- exports.VERSION = '7.5.1';
4
+ exports.VERSION = '7.5.6';
@@ -34,7 +34,7 @@ export declare const normalizeAiGuideOpenInput: (input?: AiGuideOpenInput) => Re
34
34
  export declare const resolveAiGuideProtocolVersion: (bootstrap: Pick<BootstrapConfig, "experienceMode" | "minimumProtocolVersion" | "protocol">) => 1 | 2;
35
35
  export default class AiLiveAssist {
36
36
  private readonly options;
37
- readonly version = "7.5.1";
37
+ readonly version = "7.5.6";
38
38
  private readonly host;
39
39
  private readonly guideAdmission;
40
40
  private readonly standaloneAdmissionPending;
@@ -627,6 +627,7 @@ export default class AiLiveAssist {
627
627
  private submitFeedback;
628
628
  private retry;
629
629
  private scheduleReconnect;
630
+ private canRecoverControlTransport;
630
631
  private reconnectPendingHandoffControlInMemory;
631
632
  private reconnectMessengerTerminalControl;
632
633
  private clearReconnectTimer;
@@ -7937,7 +7937,28 @@ export default class AiLiveAssist {
7937
7937
  ? this.navigationConsentResetReady
7938
7938
  : null;
7939
7939
  const resumeRecordAtConnect = this.readResume();
7940
- const channel = new ControlChannel(result, this.tabId, VERSION, () => this.scheduleReconnect(true), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence), () => this.handleControlSequenceGap());
7940
+ let recoveryPath = null;
7941
+ const channel = new ControlChannel(result, this.tabId, VERSION, () => this.scheduleReconnect(true), this.readResume()?.clientSequence ?? 0, (sequence) => this.updateResumeSequence(sequence), () => this.handleControlSequenceGap(), {
7942
+ canRecover: () => {
7943
+ const currentPath = path();
7944
+ if (!this.canRecoverControlTransport(channel, result, media) ||
7945
+ (recoveryPath !== null && recoveryPath !== currentPath))
7946
+ return false;
7947
+ recoveryPath = currentPath;
7948
+ return true;
7949
+ },
7950
+ onRecovered: async () => {
7951
+ assertCurrent();
7952
+ const fencingToken = channel.leaseToken;
7953
+ channel.startHeartbeat(this.heartbeatSeconds);
7954
+ await this.replayPreFenceAuthorityEnvelopes(channel, fencingToken, assertCurrent);
7955
+ assertCurrent();
7956
+ await this.context?.captureAndPublish();
7957
+ assertCurrent();
7958
+ this.flushPlaybackState();
7959
+ this.reapplyConservativePreFencePresence(channel, fencingToken);
7960
+ },
7961
+ });
7941
7962
  const pauseHelloStateSignal = deferredSignal();
7942
7963
  (this.pauseHelloStateSignals ?? (this.pauseHelloStateSignals = new WeakMap())).set(channel, pauseHelloStateSignal);
7943
7964
  this.handoffContextPromptReceiptChannel = null;
@@ -8307,7 +8328,7 @@ export default class AiLiveAssist {
8307
8328
  this.context === context;
8308
8329
  context = new PageContextCollector({
8309
8330
  onSnapshot: (snapshot) => {
8310
- if (isCurrent() && this.channel?.leaseToken) {
8331
+ if (isCurrent() && this.channel?.hasFencedAuthority) {
8311
8332
  this.send("context.snapshot", snapshot);
8312
8333
  }
8313
8334
  },
@@ -9666,7 +9687,15 @@ export default class AiLiveAssist {
9666
9687
  this.activePageAssistanceAuthorityEpoch === null &&
9667
9688
  this.activeActionAuthorityEpoch === null;
9668
9689
  if (!exactApplied || !localAuthorityInactive) {
9669
- this.invalidatePageAssistanceAuthority(false);
9690
+ // A resumed document still owes the original action's terminal result.
9691
+ // Preserve only its grantless outbox, never browser-control authority.
9692
+ // Delivery remains fenced by the server's exact already_started receipt.
9693
+ const preserveNavigationResult = source === "navigation_resume" &&
9694
+ this.awaitingNavigationConsentReset &&
9695
+ structurallyValid &&
9696
+ localAuthorityInactive &&
9697
+ this.pendingNavigationActionResult?.grant === null;
9698
+ this.invalidatePageAssistanceAuthority(false, preserveNavigationResult);
9670
9699
  }
9671
9700
  if (!structurallyValid ||
9672
9701
  identity.authorityEpoch < this.minimumPageAssistanceAuthorityEpoch) {
@@ -11989,15 +12018,19 @@ export default class AiLiveAssist {
11989
12018
  // intentionally result-less until navigation settles, preserving the
11990
12019
  // cross-document recovery contract if this click unloads the page.
11991
12020
  pendingExecution.result = deliveryResult;
12021
+ let navigationReleased = false;
11992
12022
  try {
11993
- if (await this.navigateTo(deferredNavigation)) {
11994
- return deliveryResult;
11995
- }
11996
- deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12023
+ navigationReleased = await this.navigateTo(deferredNavigation);
11997
12024
  }
11998
12025
  catch {
11999
- deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12026
+ // A rejected router and an outgoing document both settle here.
12000
12027
  }
12028
+ // Pagehide stops the old collector and settles its pending router
12029
+ // callback. It must not replace the destination document's grantless
12030
+ // recovery record with an unconfirmed result from the outgoing page.
12031
+ if (navigationReleased || !authorityIsCurrent())
12032
+ return deliveryResult;
12033
+ deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
12001
12034
  }
12002
12035
  else if (deferredNavigation) {
12003
12036
  deliveryResult = this.markDeferredNavigationUnconfirmed(pendingExecution, context.currentRevision);
@@ -14661,6 +14694,21 @@ export default class AiLiveAssist {
14661
14694
  }, delay + Math.floor(Math.random() * 250));
14662
14695
  this.reconnectTimer = timer;
14663
14696
  }
14697
+ canRecoverControlTransport(channel, result, media) {
14698
+ return (this.channel === channel &&
14699
+ this.result === result &&
14700
+ this.media === media && media.isConnected && navigator.onLine &&
14701
+ !this.ended && !this.destroyed && !this.terminalRequestPending &&
14702
+ !this.reconnecting && !this.launchInFlight && !this.isVoicePresence() &&
14703
+ (this.lifecyclePhase === "active_text" || this.lifecyclePhase === "active_voice") &&
14704
+ !this.pendingAction && !this.pendingConfirmation && !this.pendingPageAssistance &&
14705
+ !this.pendingNavigationActionResult && !this.legacyActionExecution &&
14706
+ this.actionGrantInFlight.size === 0 &&
14707
+ !this.pendingModeUpgrade && !this.pendingTextModeTransition &&
14708
+ !this.messengerTransferPending && !this.messengerTransferred &&
14709
+ !this.handoffRequestLifecycleOwner && !this.readResume()?.pauseCommand &&
14710
+ this.modeUpgradePathIsCurrent(path()));
14711
+ }
14664
14712
  async reconnectPendingHandoffControlInMemory(result, record, clientSequence, fencingToken, reconnectGeneration) {
14665
14713
  if (reconnectGeneration !== this.reconnectGeneration ||
14666
14714
  this.reconnecting ||
package/lib/context.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class PageContextCollector {
23
23
  private mutationObserver;
24
24
  private captureTimer;
25
25
  private mutationCaptureTimer;
26
+ private mutationCaptureStartedAt;
26
27
  private targetIds;
27
28
  private pageScrollIds;
28
29
  private navigationTargetBindings;
@@ -74,6 +75,7 @@ export declare class PageContextCollector {
74
75
  private waitForScrollSettlement;
75
76
  private actionApplied;
76
77
  private clippedGeometry;
78
+ private clearMutationCapture;
77
79
  private scheduleCapture;
78
80
  private scheduleMutationCapture;
79
81
  private scheduleViewportCapture;
package/lib/context.js CHANGED
@@ -7,6 +7,7 @@ const MAX_SELECTED_TEXT = 2000;
7
7
  const SELECTION_RETENTION_MS = 120000;
8
8
  const CAPTURE_SETTLE_MS = 250;
9
9
  const MUTATION_CAPTURE_DEBOUNCE_MS = 350;
10
+ const MUTATION_CAPTURE_MAX_WAIT_MS = 1000;
10
11
  const SCROLL_SETTLEMENT_TIMEOUT_MS = 2000;
11
12
  const SCROLL_SETTLEMENT_FRAME_FALLBACK_MS = 50;
12
13
  const SCROLL_SETTLEMENT_POSITION_TOLERANCE_PX = 1;
@@ -194,10 +195,10 @@ const visibleRect = (element) => {
194
195
  return right > left && bottom > top ? { left, top, right, bottom } : null;
195
196
  };
196
197
  const isVisible = (element) => visibleRect(element) !== null;
197
- const isCovered = (element, trustedGuideRoot = null) => {
198
+ const interactionPoint = (element, trustedGuideRoot = null) => {
198
199
  const rect = visibleRect(element);
199
200
  if (!rect)
200
- return true;
201
+ return null;
201
202
  // Five bounded hit tests permit an exposed part of a link without treating
202
203
  // an ancestor, empty hit, or a covering sibling as an interactable target.
203
204
  const insetX = Math.min(1, (rect.right - rect.left) / 4);
@@ -207,13 +208,16 @@ const isCovered = (element, trustedGuideRoot = null) => {
207
208
  [rect.left + insetX, rect.top + insetY], [rect.right - insetX, rect.top + insetY],
208
209
  [rect.left + insetX, rect.bottom - insetY], [rect.right - insetX, rect.bottom - insetY],
209
210
  ];
210
- return !points.some(([x, y]) => {
211
+ for (const [x, y] of points) {
211
212
  const top = trustedGuideRoot?.isConnected && typeof document.elementsFromPoint === 'function'
212
213
  ? document.elementsFromPoint(x, y).find(candidate => candidate !== trustedGuideRoot) ?? null
213
214
  : document.elementFromPoint(x, y);
214
- return top !== null && (top === element || element.contains(top));
215
- });
215
+ if (top !== null && (top === element || element.contains(top)))
216
+ return { x: x, y: y, hit: top };
217
+ }
218
+ return null;
216
219
  };
220
+ const isCovered = (element, trustedGuideRoot = null) => interactionPoint(element, trustedGuideRoot) === null;
217
221
  const isNavigationMenu = (element, role) => {
218
222
  if (role !== 'button' || literalBooleanAttribute(element, 'aria-expanded') === undefined ||
219
223
  element.hasAttribute('aria-pressed') || element.hasAttribute('aria-checked') ||
@@ -488,15 +492,17 @@ const checkedStateFor = (element) => {
488
492
  }
489
493
  return framerToggleDescriptor(element)?.checked;
490
494
  };
491
- const dispatchFramerPrimaryPointerSequence = (element) => {
495
+ const dispatchFramerPrimaryPointerSequence = (element, trustedGuideRoot = null) => {
492
496
  if (typeof window.PointerEvent !== 'function') {
493
497
  throw new Error('Framer pointer interaction is unavailable.');
494
498
  }
495
- const rect = element.getBoundingClientRect();
496
- const clientX = rect.left + rect.width / 2;
497
- const clientY = rect.top + rect.height / 2;
498
- const hit = document.elementFromPoint(clientX, clientY);
499
- const eventTarget = element instanceof HTMLAnchorElement && hit && element.contains(hit) ? hit : element;
499
+ // Capture and dispatch must agree on the exposed point and exact descendant,
500
+ // including when our own mobile surface covers a link's router-owned logo.
501
+ const point = interactionPoint(element, trustedGuideRoot);
502
+ if (!point)
503
+ throw new Error('The approved pointer target is covered.');
504
+ const { x: clientX, y: clientY } = point;
505
+ const eventTarget = element instanceof HTMLAnchorElement ? point.hit : element;
500
506
  const nestedLink = eventTarget.closest('a[href]');
501
507
  if (element instanceof HTMLAnchorElement && nestedLink instanceof HTMLAnchorElement && nestedLink !== element &&
502
508
  (!sameNavigationDestination(nestedLink.href, element.href) || nestedLink.hasAttribute('download') ||
@@ -568,11 +574,11 @@ const dispatchFramerPrimaryPointerSequence = (element) => {
568
574
  detail: 1,
569
575
  }));
570
576
  };
571
- const dispatchFramerTogglePrimaryPointer = (element) => {
577
+ const dispatchFramerTogglePrimaryPointer = (element, trustedGuideRoot = null) => {
572
578
  if (!framerToggleDescriptor(element)) {
573
579
  throw new Error('Framer toggle pointer interaction is unavailable.');
574
580
  }
575
- dispatchFramerPrimaryPointerSequence(element);
581
+ dispatchFramerPrimaryPointerSequence(element, trustedGuideRoot);
576
582
  };
577
583
  const semanticSourceSensitivity = (source) => {
578
584
  if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
@@ -742,6 +748,7 @@ export class PageContextCollector {
742
748
  this.mutationObserver = null;
743
749
  this.captureTimer = null;
744
750
  this.mutationCaptureTimer = null;
751
+ this.mutationCaptureStartedAt = null;
745
752
  this.targetIds = new WeakMap();
746
753
  this.pageScrollIds = new WeakMap();
747
754
  this.navigationTargetBindings = new WeakMap();
@@ -769,9 +776,7 @@ export class PageContextCollector {
769
776
  this.pendingCapture = true;
770
777
  return;
771
778
  }
772
- if (this.mutationCaptureTimer)
773
- clearTimeout(this.mutationCaptureTimer);
774
- this.mutationCaptureTimer = null;
779
+ this.clearMutationCapture();
775
780
  if (this.captureTimer)
776
781
  return;
777
782
  this.captureTimer = setTimeout(() => {
@@ -788,12 +793,19 @@ export class PageContextCollector {
788
793
  }
789
794
  if (this.captureTimer)
790
795
  return;
796
+ // Decorative animation must not indefinitely hide newly available links.
797
+ // Retain trailing-edge batching, with at most one forced scan per second
798
+ // during continuous churn. Unchanged semantic snapshots are still deduped.
799
+ const now = performance.now();
800
+ this.mutationCaptureStartedAt ?? (this.mutationCaptureStartedAt = now);
801
+ const delay = Math.max(0, Math.min(MUTATION_CAPTURE_DEBOUNCE_MS, MUTATION_CAPTURE_MAX_WAIT_MS - (now - this.mutationCaptureStartedAt)));
791
802
  if (this.mutationCaptureTimer)
792
803
  clearTimeout(this.mutationCaptureTimer);
793
804
  this.mutationCaptureTimer = setTimeout(() => {
794
805
  this.mutationCaptureTimer = null;
806
+ this.mutationCaptureStartedAt = null;
795
807
  void this.captureAndPublishIfChanged();
796
- }, MUTATION_CAPTURE_DEBOUNCE_MS);
808
+ }, delay);
797
809
  };
798
810
  this.scheduleViewportCapture = () => {
799
811
  this.scheduleMutationCapture();
@@ -866,9 +878,7 @@ export class PageContextCollector {
866
878
  if (this.captureTimer)
867
879
  clearTimeout(this.captureTimer);
868
880
  this.captureTimer = null;
869
- if (this.mutationCaptureTimer)
870
- clearTimeout(this.mutationCaptureTimer);
871
- this.mutationCaptureTimer = null;
881
+ this.clearMutationCapture();
872
882
  if (this.releasedNavigationSettlement &&
873
883
  window.location.href !== this.releasedNavigationSettlement.fromUrl) {
874
884
  this.finishReleasedNavigationSettlement(sameNavigationDestination(window.location.href, this.releasedNavigationSettlement.toUrl)
@@ -999,9 +1009,7 @@ export class PageContextCollector {
999
1009
  if (this.captureTimer)
1000
1010
  clearTimeout(this.captureTimer);
1001
1011
  this.captureTimer = null;
1002
- if (this.mutationCaptureTimer)
1003
- clearTimeout(this.mutationCaptureTimer);
1004
- this.mutationCaptureTimer = null;
1012
+ this.clearMutationCapture();
1005
1013
  window.removeEventListener('resize', this.scheduleViewportCapture);
1006
1014
  document.removeEventListener('scroll', this.scheduleViewportCapture, true);
1007
1015
  window.visualViewport?.removeEventListener('resize', this.scheduleViewportCapture);
@@ -1838,7 +1846,7 @@ export class PageContextCollector {
1838
1846
  };
1839
1847
  window.addEventListener('click', preventNativeNavigation);
1840
1848
  try {
1841
- dispatchFramerPrimaryPointerSequence(navigation.source);
1849
+ dispatchFramerPrimaryPointerSequence(navigation.source, this.trustedGuideRoot);
1842
1850
  }
1843
1851
  finally {
1844
1852
  window.removeEventListener('click', preventNativeNavigation);
@@ -1920,7 +1928,7 @@ export class PageContextCollector {
1920
1928
  if (desiredStates.length > 0 && desiredStates.every(Boolean))
1921
1929
  return;
1922
1930
  if (desiredChecked !== undefined && framerToggleDescriptor(element)) {
1923
- dispatchFramerTogglePrimaryPointer(element);
1931
+ dispatchFramerTogglePrimaryPointer(element, this.trustedGuideRoot);
1924
1932
  return;
1925
1933
  }
1926
1934
  if (element instanceof HTMLAnchorElement) {
@@ -2228,9 +2236,19 @@ export class PageContextCollector {
2228
2236
  height,
2229
2237
  };
2230
2238
  }
2239
+ clearMutationCapture() {
2240
+ if (this.mutationCaptureTimer)
2241
+ clearTimeout(this.mutationCaptureTimer);
2242
+ this.mutationCaptureTimer = null;
2243
+ this.mutationCaptureStartedAt = null;
2244
+ }
2231
2245
  async captureAndPublishIfChanged() {
2232
- if (this.navigationSettlement)
2246
+ if (!this.running || this.navigationSettlement)
2233
2247
  return;
2248
+ if (this.executingActionId) {
2249
+ this.pendingCapture = true;
2250
+ return;
2251
+ }
2234
2252
  const snapshot = this.captureSnapshot(false);
2235
2253
  if (snapshot && this.running)
2236
2254
  await this.callbacks.onSnapshot(snapshot);
@@ -2315,9 +2333,7 @@ export class PageContextCollector {
2315
2333
  if (this.captureTimer)
2316
2334
  clearTimeout(this.captureTimer);
2317
2335
  this.captureTimer = null;
2318
- if (this.mutationCaptureTimer)
2319
- clearTimeout(this.mutationCaptureTimer);
2320
- this.mutationCaptureTimer = null;
2336
+ this.clearMutationCapture();
2321
2337
  return settlement;
2322
2338
  }
2323
2339
  observeCausalNavigationAfterMutation(actionId, destinationPath, beforeActionUrl) {
package/lib/control.d.ts CHANGED
@@ -24,6 +24,10 @@ export type ControlOutboundOperation = {
24
24
  type: string;
25
25
  };
26
26
  export type ReservedControlOperation = Readonly<ControlOutboundOperation>;
27
+ type TransportRecovery = {
28
+ canRecover: () => boolean;
29
+ onRecovered: () => Promise<void>;
30
+ };
27
31
  export declare class ControlChannel {
28
32
  private readonly result;
29
33
  private readonly tabId;
@@ -31,6 +35,7 @@ export declare class ControlChannel {
31
35
  private readonly onDisconnect;
32
36
  private readonly onClientSequence;
33
37
  private readonly onUnsafeDisconnect;
38
+ private readonly transportRecovery?;
34
39
  private socket;
35
40
  private clientSequence;
36
41
  private serverSequence;
@@ -54,7 +59,9 @@ export declare class ControlChannel {
54
59
  private pendingConnection;
55
60
  private helloOperation;
56
61
  private fencedAuthorityConfirmed;
57
- constructor(result: RuntimeAuthorization, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void, onUnsafeDisconnect?: () => void);
62
+ private transportRecoveryAttempted;
63
+ private recoveringTransport;
64
+ constructor(result: RuntimeAuthorization, tabId: string, clientVersion: string, onDisconnect: () => void, initialClientSequence?: number, onClientSequence?: (sequence: number) => void, onUnsafeDisconnect?: () => void, transportRecovery?: TransportRecovery | undefined);
58
65
  get leaseToken(): number;
59
66
  get lastClientSequence(): number;
60
67
  get isOpen(): boolean;
@@ -78,6 +85,7 @@ export declare class ControlChannel {
78
85
  private handleMessage;
79
86
  private recoverFromSequenceGap;
80
87
  private notifyUnsafeDisconnect;
88
+ private recoverTransport;
81
89
  private enqueueHandlers;
82
90
  private dispatchHandlers;
83
91
  private handleHandlerFailure;
package/lib/control.js CHANGED
@@ -3,6 +3,7 @@ const PROTOCOL_VIOLATION_CLOSE_CODE = 4008;
3
3
  const SERVER_SEQUENCE_GAP_CLOSE_CODE = 4009;
4
4
  const HANDLER_FAILURE_CLOSE_CODE = 4010;
5
5
  const HANDLER_TIMEOUT_MS = 15000;
6
+ const TRANSPORT_RECOVERY_TIMEOUT_MS = 5000;
6
7
  const MAX_PENDING_HANDLER_MESSAGES = 64;
7
8
  const MAX_PENDING_STATEFUL_OPERATIONS = 64;
8
9
  const MAX_SETTLED_OPERATION_HISTORY = 100;
@@ -35,13 +36,14 @@ const STATEFUL_CONTROL_TYPES = new Set([
35
36
  ]);
36
37
  const outcomeKey = (messageId, sequence) => `${messageId}\u0000${sequence}`;
37
38
  export class ControlChannel {
38
- constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined) {
39
+ constructor(result, tabId, clientVersion, onDisconnect, initialClientSequence = 0, onClientSequence = () => undefined, onUnsafeDisconnect = () => undefined, transportRecovery) {
39
40
  this.result = result;
40
41
  this.tabId = tabId;
41
42
  this.clientVersion = clientVersion;
42
43
  this.onDisconnect = onDisconnect;
43
44
  this.onClientSequence = onClientSequence;
44
45
  this.onUnsafeDisconnect = onUnsafeDisconnect;
46
+ this.transportRecovery = transportRecovery;
45
47
  this.socket = null;
46
48
  this.serverSequence = 0;
47
49
  this.serverBaselineEstablished = false;
@@ -64,6 +66,8 @@ export class ControlChannel {
64
66
  this.pendingConnection = null;
65
67
  this.helloOperation = null;
66
68
  this.fencedAuthorityConfirmed = false;
69
+ this.transportRecoveryAttempted = false;
70
+ this.recoveringTransport = false;
67
71
  this.clientSequence =
68
72
  Number.isSafeInteger(initialClientSequence) && initialClientSequence >= 0
69
73
  ? initialClientSequence
@@ -76,7 +80,7 @@ export class ControlChannel {
76
80
  return this.clientSequence;
77
81
  }
78
82
  get isOpen() {
79
- return this.socket?.readyState === WebSocket.OPEN;
83
+ return (!this.recoveringTransport && this.socket?.readyState === WebSocket.OPEN);
80
84
  }
81
85
  get connectionHelloOperation() {
82
86
  return this.helloOperation ? { ...this.helloOperation } : null;
@@ -177,10 +181,19 @@ export class ControlChannel {
177
181
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel could not connect."));
178
182
  }
179
183
  }, { once: true });
180
- socket.addEventListener("close", () => {
184
+ socket.addEventListener("close", (event) => {
181
185
  if (!this.isCurrentSocket(socket, generation))
182
186
  return;
183
187
  const wasIntentionallyClosed = this.intentionallyClosed;
188
+ // A settled transport loss is not a new session. Keep its provider
189
+ // connection alive for one bounded retry, but never carry ambiguous
190
+ // browser writes, an active handler, or a protocol failure across it.
191
+ const canRecover = event.code === 1006 &&
192
+ this.fencedAuthorityConfirmed &&
193
+ !this.transportRecoveryAttempted &&
194
+ !this.sequenceGapDetected &&
195
+ this.pendingHandlerMessages === 0 &&
196
+ this.unsettledStatefulOperations.size === 0;
184
197
  this.socket = null;
185
198
  this.socketGeneration += 1;
186
199
  this.stopHeartbeat();
@@ -194,12 +207,24 @@ export class ControlChannel {
194
207
  this.pendingHandlerMessages = 0;
195
208
  this.rejectPendingConnection(socket, generation, new Error("The AI Guide control channel closed before connecting."));
196
209
  if (!wasIntentionallyClosed && !signal.aborted) {
210
+ try {
211
+ if (canRecover && this.transportRecovery?.canRecover()) {
212
+ void this.recoverTransport(signal);
213
+ return;
214
+ }
215
+ }
216
+ catch {
217
+ /* A failed eligibility check uses full recovery. */
218
+ }
197
219
  this.notifyUnsafeDisconnect();
198
220
  }
199
221
  });
200
222
  });
201
223
  }
202
224
  send(type, payload, stableMessageId) {
225
+ if (this.recoveringTransport && type !== "hello" && type !== "heartbeat") {
226
+ throw new Error("The AI Guide control channel is reconnecting.");
227
+ }
203
228
  if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
204
229
  throw new Error("The AI Guide control channel is not connected.");
205
230
  }
@@ -510,6 +535,59 @@ export class ControlChannel {
510
535
  this.onDisconnect();
511
536
  }
512
537
  }
538
+ async recoverTransport(signal) {
539
+ this.transportRecoveryAttempted = true;
540
+ this.recoveringTransport = true;
541
+ const controller = new AbortController();
542
+ const abort = () => controller.abort();
543
+ signal.addEventListener("abort", abort, { once: true });
544
+ let timeout;
545
+ const deadline = new Promise((_resolve, reject) => {
546
+ timeout = setTimeout(() => {
547
+ controller.abort();
548
+ reject(new Error("The control reconnect timed out."));
549
+ }, TRANSPORT_RECOVERY_TIMEOUT_MS);
550
+ });
551
+ const fencingToken = this.fencingToken;
552
+ let recovered = false;
553
+ try {
554
+ await Promise.race([
555
+ deadline,
556
+ (async () => {
557
+ if (signal.aborted)
558
+ throw new Error("The control connection was retired.");
559
+ await this.connect(controller.signal, true);
560
+ const hello = await this.waitForHelloOutcome(2000);
561
+ if (hello.status !== "ack" || controller.signal.aborted) {
562
+ throw new Error("The control reconnect was not acknowledged.");
563
+ }
564
+ const authority = await this.proveFencedAuthority(2000);
565
+ if (authority.status !== "ack" ||
566
+ controller.signal.aborted ||
567
+ this.fencingToken !== fencingToken ||
568
+ !this.transportRecovery?.canRecover()) {
569
+ throw new Error("The control reconnect no longer owns this session.");
570
+ }
571
+ this.recoveringTransport = false;
572
+ await this.transportRecovery.onRecovered();
573
+ recovered = !controller.signal.aborted && this.hasFencedAuthority;
574
+ })(),
575
+ ]);
576
+ }
577
+ catch {
578
+ // The existing full-resume path revokes local executable authority and
579
+ // handles expired credentials, changed ownership, and longer outages.
580
+ }
581
+ finally {
582
+ clearTimeout(timeout);
583
+ signal.removeEventListener("abort", abort);
584
+ this.recoveringTransport = false;
585
+ if (!recovered && !signal.aborted && !this.intentionallyClosed) {
586
+ this.retireSocket("control reconnect failed");
587
+ this.notifyUnsafeDisconnect();
588
+ }
589
+ }
590
+ }
513
591
  enqueueHandlers(message, socket, generation) {
514
592
  const isCurrent = () => this.isMessageSourceCurrent(socket, generation);
515
593
  if (!isCurrent())
@@ -1,5 +1,5 @@
1
1
  import { type LiveDuplexMediaOptions, type LiveDuplexMediaDependencies } from './liveDuplexMedia.js';
2
- export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.1";
2
+ export declare const LIVE_CONTROL_CLIENT_VERSION = "7.5.6";
3
3
  export declare const LIVE_CONTROL_CLIENT_CAPABILITIES: readonly ["live_duplex_v1", "live_local_wake_v1", "live_fragment_captions_v1"];
4
4
  declare const phases: readonly ["voice_off", "local_listening", "wake_buffering", "starting", "active", "tentative_idle", "closing", "uncertain", "muted", "suspended", "budget_blocked", "ended"];
5
5
  export type LiveControlState = {
package/lib/media.d.ts CHANGED
@@ -36,6 +36,7 @@ export declare class RealtimeMedia {
36
36
  connect(result: LaunchResult, mode: AssistMode, signal: AbortSignal, offerIdempotencyKey?: string, expectedSessionState?: "active_text" | "active_voice"): Promise<number>;
37
37
  setMuted(muted: boolean): void;
38
38
  get isMuted(): boolean;
39
+ get isConnected(): boolean;
39
40
  setAssistantPlaybackAudible(audible: boolean): void;
40
41
  setManualTurn(manual: boolean): void;
41
42
  beginPushToTalk(): void;
package/lib/media.js CHANGED
@@ -134,6 +134,9 @@ export class RealtimeMedia {
134
134
  get isMuted() {
135
135
  return this.muted;
136
136
  }
137
+ get isConnected() {
138
+ return !this.closed && this.connection?.connectionState === "connected";
139
+ }
137
140
  setAssistantPlaybackAudible(audible) {
138
141
  if (this.closed)
139
142
  return;
package/lib/ui.js CHANGED
@@ -6790,7 +6790,9 @@ export class WidgetView {
6790
6790
  this.setActionStateRunning(status === "running");
6791
6791
  if (this.actionStateRunning) {
6792
6792
  this.clearAssistantReadyVisualTimer();
6793
+ const previousState = this.assistantState;
6793
6794
  this.assistantState = "thinking";
6795
+ this.updateRemoteAudioEvidenceExpectation(previousState, "thinking");
6794
6796
  const assistantCaption = this.captions.querySelector('[data-caption="assistant-partial"]');
6795
6797
  if (!assistantCaption || assistantCaption.classList.contains("pending")) {
6796
6798
  this.ensurePendingCaption("assistant", this.messages.thinking);
package/lib/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "7.5.1";
1
+ export declare const VERSION = "7.5.6";
package/lib/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '7.5.1';
1
+ export const VERSION = '7.5.6';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxvo/ai-live-assist",
3
3
  "description": "Secure AI Guide browser plugin for OXVO Sessions.",
4
- "version": "7.5.1",
4
+ "version": "7.5.6",
5
5
  "keywords": [
6
6
  "ai-live-assist",
7
7
  "webrtc",