@oxvo/ai-live-assist 7.5.0 → 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.0";
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;
@@ -78,6 +79,7 @@ const navigationTargetBinding = (target) => canonicalJson({
78
79
  inputType: target.inputType ?? null,
79
80
  autocomplete: target.autocomplete ?? null,
80
81
  semanticRegion: target.semanticRegion ?? null,
82
+ ...(target.navigationMenu === true ? { navigationMenu: true } : {}),
81
83
  destinationPath: target.destinationPath ?? null,
82
84
  sensitivity: target.sensitivity,
83
85
  protectedRegion: target.protectedRegion ?? null,
@@ -122,6 +124,7 @@ const actionTargetFingerprintBinding = (target) => canonicalJson({
122
124
  inputType: target.inputType ?? null,
123
125
  autocomplete: target.autocomplete ?? null,
124
126
  semanticRegion: target.semanticRegion ?? null,
127
+ ...(target.navigationMenu === true ? { navigationMenu: true } : {}),
125
128
  destinationPath: target.destinationPath ?? null,
126
129
  sensitivity: target.sensitivity,
127
130
  protectedRegion: target.protectedRegion ?? null,
@@ -145,47 +148,94 @@ const actionTargetFingerprint = async (target, subtle = globalThis.crypto?.subtl
145
148
  };
146
149
  exports.actionTargetFingerprint = actionTargetFingerprint;
147
150
  const isHTMLElement = (value) => value instanceof HTMLElement;
151
+ // Clipping is checked in document geometry, not against the viewport: a
152
+ // rendered footer link remains discoverable before it is scrolled into view.
153
+ // No hidden descendant text is inspected to discover a navigation menu.
154
+ const clippedRect = (element) => {
155
+ const rect = element.getBoundingClientRect();
156
+ let { left, top, right, bottom } = rect;
157
+ let depth = 0;
158
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
159
+ if (++depth > 64)
160
+ return null;
161
+ const style = window.getComputedStyle(parent);
162
+ if (Number.parseFloat(style.opacity) === 0 || style.visibility === 'hidden' || style.display === 'none' || style.contentVisibility === 'hidden')
163
+ return null;
164
+ if (parent === document.body || parent === document.documentElement)
165
+ continue;
166
+ const bounds = parent.getBoundingClientRect();
167
+ // Overflow scrolling can reveal an offscreen descendant. A collapsed or
168
+ // clipped disclosure cannot be revealed by scrolling the document to it.
169
+ if (['hidden', 'clip'].includes(style.overflowX)) {
170
+ left = Math.max(left, bounds.left);
171
+ right = Math.min(right, bounds.right);
172
+ }
173
+ if (['hidden', 'clip'].includes(style.overflowY)) {
174
+ top = Math.max(top, bounds.top);
175
+ bottom = Math.min(bottom, bounds.bottom);
176
+ }
177
+ if (right <= left || bottom <= top)
178
+ return null;
179
+ }
180
+ return right > left && bottom > top ? { left, top, right, bottom } : null;
181
+ };
148
182
  const isRendered = (element) => {
149
- if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR)) {
183
+ if (!isHTMLElement(element) || !element.isConnected || element.closest(PRIVATE_SELECTOR))
150
184
  return false;
151
- }
152
185
  const style = window.getComputedStyle(element);
153
- const opacity = Number.parseFloat(style.opacity);
154
- if (style.display === 'none' ||
155
- style.visibility === 'hidden' ||
156
- (Number.isFinite(opacity) && opacity === 0) ||
157
- style.pointerEvents === 'none') {
186
+ if (style.display === 'none' || style.visibility === 'hidden' ||
187
+ Number.parseFloat(style.opacity) === 0 || style.pointerEvents === 'none')
158
188
  return false;
159
- }
160
- const rect = element.getBoundingClientRect();
161
- return rect.width > 0 && rect.height > 0;
189
+ return clippedRect(element) !== null;
162
190
  };
163
- const isVisible = (element) => {
191
+ const visibleRect = (element) => {
164
192
  if (!isRendered(element))
165
- return false;
166
- const rect = element.getBoundingClientRect();
167
- return (rect.bottom >= 0 &&
168
- rect.right >= 0 &&
169
- rect.top <= window.innerHeight &&
170
- rect.left <= window.innerWidth);
193
+ return null;
194
+ const rect = clippedRect(element);
195
+ if (!rect)
196
+ return null;
197
+ const left = Math.max(0, rect.left), top = Math.max(0, rect.top);
198
+ const right = Math.min(window.innerWidth, rect.right), bottom = Math.min(window.innerHeight, rect.bottom);
199
+ return right > left && bottom > top ? { left, top, right, bottom } : null;
171
200
  };
172
- const isCovered = (element, trustedGuideRoot = null) => {
173
- const rect = element.getBoundingClientRect();
174
- const x = Math.max(0, Math.min(window.innerWidth - 1, rect.left + rect.width / 2));
175
- const y = Math.max(0, Math.min(window.innerHeight - 1, rect.top + rect.height / 2));
176
- let top;
177
- if (trustedGuideRoot?.isConnected &&
178
- typeof document.elementsFromPoint === 'function') {
179
- top =
180
- document
181
- .elementsFromPoint(x, y)
182
- .find((candidate) => candidate !== trustedGuideRoot) ??
183
- document.elementFromPoint(x, y);
184
- }
185
- else {
186
- top = document.elementFromPoint(x, y);
187
- }
188
- return Boolean(top && top !== element && !element.contains(top) && !top.contains(element));
201
+ const isVisible = (element) => visibleRect(element) !== null;
202
+ const interactionPoint = (element, trustedGuideRoot = null) => {
203
+ const rect = visibleRect(element);
204
+ if (!rect)
205
+ return null;
206
+ // Five bounded hit tests permit an exposed part of a link without treating
207
+ // an ancestor, empty hit, or a covering sibling as an interactable target.
208
+ const insetX = Math.min(1, (rect.right - rect.left) / 4);
209
+ const insetY = Math.min(1, (rect.bottom - rect.top) / 4);
210
+ const points = [
211
+ [(rect.left + rect.right) / 2, (rect.top + rect.bottom) / 2],
212
+ [rect.left + insetX, rect.top + insetY], [rect.right - insetX, rect.top + insetY],
213
+ [rect.left + insetX, rect.bottom - insetY], [rect.right - insetX, rect.bottom - insetY],
214
+ ];
215
+ for (const [x, y] of points) {
216
+ const top = trustedGuideRoot?.isConnected && typeof document.elementsFromPoint === 'function'
217
+ ? document.elementsFromPoint(x, y).find(candidate => candidate !== trustedGuideRoot) ?? null
218
+ : document.elementFromPoint(x, y);
219
+ if (top !== null && (top === element || element.contains(top)))
220
+ return { x: x, y: y, hit: top };
221
+ }
222
+ return null;
223
+ };
224
+ const isCovered = (element, trustedGuideRoot = null) => interactionPoint(element, trustedGuideRoot) === null;
225
+ const isNavigationMenu = (element, role) => {
226
+ if (role !== 'button' || literalBooleanAttribute(element, 'aria-expanded') === undefined ||
227
+ element.hasAttribute('aria-pressed') || element.hasAttribute('aria-checked') ||
228
+ element instanceof HTMLButtonElement && element.form !== null && element.type !== 'button')
229
+ return false;
230
+ if (element.closest('nav,[role="navigation"]'))
231
+ return true;
232
+ // aria-controls supplies identity only. Do not capture hidden menu labels,
233
+ // links, values or instructions before native expansion and recapture.
234
+ const controls = (element.getAttribute('aria-controls') ?? '').trim().split(/\s+/).slice(0, 4);
235
+ return controls.some(control => {
236
+ const menu = document.getElementById(control);
237
+ return menu !== null && menu.matches('nav,[role="navigation"],[role="menu"]');
238
+ });
189
239
  };
190
240
  const safePageUrl = () => `${window.location.origin}${window.location.pathname}`;
191
241
  const linkBrowsingTarget = (link) => (link.getAttribute('target') ?? document.querySelector('base[target]')?.getAttribute('target') ?? '').toLowerCase();
@@ -197,11 +247,13 @@ const sameNavigationDestination = (actual, expected) => {
197
247
  return left.origin === right.origin && left.pathname === right.pathname && left.search === right.search;
198
248
  };
199
249
  const safeDestinationPath = (element) => {
200
- if (!(element instanceof HTMLAnchorElement))
250
+ // An empty href is a control, not authority to navigate to the current URL.
251
+ if (!(element instanceof HTMLAnchorElement) || !element.getAttribute('href')?.trim())
201
252
  return null;
202
253
  try {
203
254
  const destination = new URL(element.href, window.location.href);
204
255
  if (destination.origin !== window.location.origin ||
256
+ destination.username !== '' || destination.password !== '' ||
205
257
  destination.search ||
206
258
  destination.hash ||
207
259
  !destination.pathname.startsWith('/') ||
@@ -444,15 +496,17 @@ const checkedStateFor = (element) => {
444
496
  }
445
497
  return framerToggleDescriptor(element)?.checked;
446
498
  };
447
- const dispatchFramerPrimaryPointerSequence = (element) => {
499
+ const dispatchFramerPrimaryPointerSequence = (element, trustedGuideRoot = null) => {
448
500
  if (typeof window.PointerEvent !== 'function') {
449
501
  throw new Error('Framer pointer interaction is unavailable.');
450
502
  }
451
- const rect = element.getBoundingClientRect();
452
- const clientX = rect.left + rect.width / 2;
453
- const clientY = rect.top + rect.height / 2;
454
- const hit = document.elementFromPoint(clientX, clientY);
455
- 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;
456
510
  const nestedLink = eventTarget.closest('a[href]');
457
511
  if (element instanceof HTMLAnchorElement && nestedLink instanceof HTMLAnchorElement && nestedLink !== element &&
458
512
  (!sameNavigationDestination(nestedLink.href, element.href) || nestedLink.hasAttribute('download') ||
@@ -524,11 +578,11 @@ const dispatchFramerPrimaryPointerSequence = (element) => {
524
578
  detail: 1,
525
579
  }));
526
580
  };
527
- const dispatchFramerTogglePrimaryPointer = (element) => {
581
+ const dispatchFramerTogglePrimaryPointer = (element, trustedGuideRoot = null) => {
528
582
  if (!framerToggleDescriptor(element)) {
529
583
  throw new Error('Framer toggle pointer interaction is unavailable.');
530
584
  }
531
- dispatchFramerPrimaryPointerSequence(element);
585
+ dispatchFramerPrimaryPointerSequence(element, trustedGuideRoot);
532
586
  };
533
587
  const semanticSourceSensitivity = (source) => {
534
588
  if (source.closest(PRIVATE_SELECTOR) || source.querySelector(PRIVATE_SELECTOR)) {
@@ -698,6 +752,7 @@ class PageContextCollector {
698
752
  this.mutationObserver = null;
699
753
  this.captureTimer = null;
700
754
  this.mutationCaptureTimer = null;
755
+ this.mutationCaptureStartedAt = null;
701
756
  this.targetIds = new WeakMap();
702
757
  this.pageScrollIds = new WeakMap();
703
758
  this.navigationTargetBindings = new WeakMap();
@@ -725,9 +780,7 @@ class PageContextCollector {
725
780
  this.pendingCapture = true;
726
781
  return;
727
782
  }
728
- if (this.mutationCaptureTimer)
729
- clearTimeout(this.mutationCaptureTimer);
730
- this.mutationCaptureTimer = null;
783
+ this.clearMutationCapture();
731
784
  if (this.captureTimer)
732
785
  return;
733
786
  this.captureTimer = setTimeout(() => {
@@ -744,12 +797,19 @@ class PageContextCollector {
744
797
  }
745
798
  if (this.captureTimer)
746
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)));
747
806
  if (this.mutationCaptureTimer)
748
807
  clearTimeout(this.mutationCaptureTimer);
749
808
  this.mutationCaptureTimer = setTimeout(() => {
750
809
  this.mutationCaptureTimer = null;
810
+ this.mutationCaptureStartedAt = null;
751
811
  void this.captureAndPublishIfChanged();
752
- }, MUTATION_CAPTURE_DEBOUNCE_MS);
812
+ }, delay);
753
813
  };
754
814
  this.scheduleViewportCapture = () => {
755
815
  this.scheduleMutationCapture();
@@ -822,9 +882,7 @@ class PageContextCollector {
822
882
  if (this.captureTimer)
823
883
  clearTimeout(this.captureTimer);
824
884
  this.captureTimer = null;
825
- if (this.mutationCaptureTimer)
826
- clearTimeout(this.mutationCaptureTimer);
827
- this.mutationCaptureTimer = null;
885
+ this.clearMutationCapture();
828
886
  if (this.releasedNavigationSettlement &&
829
887
  window.location.href !== this.releasedNavigationSettlement.fromUrl) {
830
888
  this.finishReleasedNavigationSettlement(sameNavigationDestination(window.location.href, this.releasedNavigationSettlement.toUrl)
@@ -955,9 +1013,7 @@ class PageContextCollector {
955
1013
  if (this.captureTimer)
956
1014
  clearTimeout(this.captureTimer);
957
1015
  this.captureTimer = null;
958
- if (this.mutationCaptureTimer)
959
- clearTimeout(this.mutationCaptureTimer);
960
- this.mutationCaptureTimer = null;
1016
+ this.clearMutationCapture();
961
1017
  window.removeEventListener('resize', this.scheduleViewportCapture);
962
1018
  document.removeEventListener('scroll', this.scheduleViewportCapture, true);
963
1019
  window.visualViewport?.removeEventListener('resize', this.scheduleViewportCapture);
@@ -1061,6 +1117,7 @@ class PageContextCollector {
1061
1117
  }
1062
1118
  : {}),
1063
1119
  ...(semanticRegion ? { semanticRegion } : {}),
1120
+ ...(this.statefulButtonActionProof && isNavigationMenu(element, role) ? { navigationMenu: true } : {}),
1064
1121
  ...(destinationPath ? { destinationPath } : {}),
1065
1122
  sensitivity,
1066
1123
  protectedRegion: sensitivity !== 'none' || regionAssessment.matchedRuleDigests.length > 0,
@@ -1172,6 +1229,7 @@ class PageContextCollector {
1172
1229
  pageId: this.pageId,
1173
1230
  revision,
1174
1231
  url: safePageUrl(),
1232
+ locationHasQuery: window.location.search !== '',
1175
1233
  title: normalize(document.title, 512),
1176
1234
  locale: normalize(document.documentElement.lang || navigator.language || 'en', 16),
1177
1235
  viewport: {
@@ -1792,7 +1850,7 @@ class PageContextCollector {
1792
1850
  };
1793
1851
  window.addEventListener('click', preventNativeNavigation);
1794
1852
  try {
1795
- dispatchFramerPrimaryPointerSequence(navigation.source);
1853
+ dispatchFramerPrimaryPointerSequence(navigation.source, this.trustedGuideRoot);
1796
1854
  }
1797
1855
  finally {
1798
1856
  window.removeEventListener('click', preventNativeNavigation);
@@ -1874,7 +1932,7 @@ class PageContextCollector {
1874
1932
  if (desiredStates.length > 0 && desiredStates.every(Boolean))
1875
1933
  return;
1876
1934
  if (desiredChecked !== undefined && framerToggleDescriptor(element)) {
1877
- dispatchFramerTogglePrimaryPointer(element);
1935
+ dispatchFramerTogglePrimaryPointer(element, this.trustedGuideRoot);
1878
1936
  return;
1879
1937
  }
1880
1938
  if (element instanceof HTMLAnchorElement) {
@@ -2182,9 +2240,19 @@ class PageContextCollector {
2182
2240
  height,
2183
2241
  };
2184
2242
  }
2243
+ clearMutationCapture() {
2244
+ if (this.mutationCaptureTimer)
2245
+ clearTimeout(this.mutationCaptureTimer);
2246
+ this.mutationCaptureTimer = null;
2247
+ this.mutationCaptureStartedAt = null;
2248
+ }
2185
2249
  async captureAndPublishIfChanged() {
2186
- if (this.navigationSettlement)
2250
+ if (!this.running || this.navigationSettlement)
2251
+ return;
2252
+ if (this.executingActionId) {
2253
+ this.pendingCapture = true;
2187
2254
  return;
2255
+ }
2188
2256
  const snapshot = this.captureSnapshot(false);
2189
2257
  if (snapshot && this.running)
2190
2258
  await this.callbacks.onSnapshot(snapshot);
@@ -2269,9 +2337,7 @@ class PageContextCollector {
2269
2337
  if (this.captureTimer)
2270
2338
  clearTimeout(this.captureTimer);
2271
2339
  this.captureTimer = null;
2272
- if (this.mutationCaptureTimer)
2273
- clearTimeout(this.mutationCaptureTimer);
2274
- this.mutationCaptureTimer = null;
2340
+ this.clearMutationCapture();
2275
2341
  return settlement;
2276
2342
  }
2277
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.0";
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 = {