viveworker 0.8.9 → 0.9.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.
package/web/i18n.js CHANGED
@@ -22,6 +22,7 @@ const translations = {
22
22
  "common.logOut": "Log out",
23
23
  "common.actionable": "Actionable",
24
24
  "common.actionNeeded": "Action needed",
25
+ "common.inProgress": "In progress",
25
26
  "common.completed": "Completed",
26
27
  "common.clear": "Clear",
27
28
  "common.cancel": "Cancel",
@@ -76,10 +77,10 @@ const translations = {
76
77
  "tab.pending.label": "Pending",
77
78
  "tab.pending.eyebrow": "Action queue",
78
79
  "tab.pending.description": "Items that need attention right now.",
79
- "tab.inbox.title": "Tasks",
80
- "tab.inbox.label": "Tasks",
81
- "tab.inbox.eyebrow": "Work items",
82
- "tab.inbox.description": "Switch between items that need action now and recent completed results.",
80
+ "tab.inbox.title": "Today",
81
+ "tab.inbox.label": "Today",
82
+ "tab.inbox.eyebrow": "Work today",
83
+ "tab.inbox.description": "See what needs you, what is running, and what finished today.",
83
84
  "tab.timeline.title": "Timeline",
84
85
  "tab.timeline.label": "Timeline",
85
86
  "tab.timeline.eyebrow": "Recent activity",
@@ -257,6 +258,7 @@ const translations = {
257
258
  "summary.fileEvent": "Review the files {provider} checked or updated in this step.",
258
259
  "summary.commandEvent": "Review the command {provider} ran in this step.",
259
260
  "summary.diffThread": "Review the file changes for this thread.",
261
+ "summary.inProgress": "{provider} is working on this now.",
260
262
  "summary.default": "Open the item to review its details.",
261
263
  "intent.approval": "Approve or reject this request.",
262
264
  "intent.plan": "Review this plan before {provider} continues.",
@@ -269,10 +271,25 @@ const translations = {
269
271
  "intent.userMessage": "Open this message again.",
270
272
  "intent.assistantCommentary": "Read the latest working update.",
271
273
  "intent.assistantFinal": "Read {provider}'s final reply for this turn.",
274
+ "intent.inProgress": "{provider} is working on this now.",
275
+ "intent.draftReview": "Review and approve this draft.",
276
+ "intent.delegationReview": "Review this delegated task before it runs.",
277
+ "intent.handoffReview": "Review this handoff before it is sent.",
272
278
  "timeline.allThreads": "All",
273
279
  "timeline.unknownThread": "Unknown thread",
274
- "inbox.subtab.pending": "Pending",
280
+ "inbox.subtab.today": "Today",
281
+ "inbox.subtab.pending": "Needs you",
275
282
  "inbox.subtab.completed": "Completed",
283
+ "work.section.needsAction": "Needs you",
284
+ "work.section.inProgress": "In progress",
285
+ "work.section.completedToday": "Completed today",
286
+ "work.today.caughtUp": "Nothing needs your attention right now",
287
+ "work.today.emptyTitle": "You're caught up",
288
+ "work.today.emptyCopy": "New decisions and active work will appear here.",
289
+ "work.today.viewAllCompleted": "View all completed ({count} more)",
290
+ "work.health.title": "Connection needs attention",
291
+ "work.health.connectionRequired": "{provider} is not connected. Pending actions may not be delivered.",
292
+ "work.health.degraded": "A work source is temporarily unavailable.",
276
293
  "timeline.filterLabel": "Thread filter",
277
294
  "timeline.status.approved": "Approved",
278
295
  "timeline.status.rejected": "Rejected",
@@ -1153,6 +1170,7 @@ const translations = {
1153
1170
  "common.logOut": "ログアウト",
1154
1171
  "common.actionable": "対応が必要",
1155
1172
  "common.actionNeeded": "対応が必要",
1173
+ "common.inProgress": "進行中",
1156
1174
  "common.completed": "完了",
1157
1175
  "common.clear": "クリア",
1158
1176
  "common.cancel": "キャンセル",
@@ -1201,10 +1219,10 @@ const translations = {
1201
1219
  "tab.pending.label": "未対応",
1202
1220
  "tab.pending.eyebrow": "対応待ち",
1203
1221
  "tab.pending.description": "いま対応が必要な項目です。",
1204
- "tab.inbox.title": "タスク",
1205
- "tab.inbox.label": "タスク",
1206
- "tab.inbox.eyebrow": "作業一覧",
1207
- "tab.inbox.description": "未対応の項目と、最近の完了結果を切り替えて確認できます。",
1222
+ "tab.inbox.title": "今日",
1223
+ "tab.inbox.label": "今日",
1224
+ "tab.inbox.eyebrow": "今日の仕事",
1225
+ "tab.inbox.description": "要対応、進行中、今日完了した仕事をまとめて確認できます。",
1208
1226
  "tab.timeline.title": "タイムライン",
1209
1227
  "tab.timeline.label": "タイムライン",
1210
1228
  "tab.timeline.eyebrow": "最近の動き",
@@ -1378,6 +1396,7 @@ const translations = {
1378
1396
  "summary.fileEvent": "このステップで触れたファイルを確認します。",
1379
1397
  "summary.commandEvent": "このステップで実行したコマンドを確認します。",
1380
1398
  "summary.diffThread": "このスレッドのファイル変更を確認します。",
1399
+ "summary.inProgress": "{provider} が現在この作業を進めています。",
1381
1400
  "summary.default": "項目を開いて詳細を確認します。",
1382
1401
  "intent.approval": "このリクエストを承認するか拒否します。",
1383
1402
  "intent.plan": "{provider} が続行する前にこのプランを確認します。",
@@ -1390,10 +1409,25 @@ const translations = {
1390
1409
  "intent.userMessage": "このメッセージを開き直します。",
1391
1410
  "intent.assistantCommentary": "最新の途中経過を確認します。",
1392
1411
  "intent.assistantFinal": "このターンの {provider} の最終回答を確認します。",
1412
+ "intent.inProgress": "{provider} が現在この作業を進めています。",
1413
+ "intent.draftReview": "この下書きを確認して承認します。",
1414
+ "intent.delegationReview": "委任されたタスクを実行前に確認します。",
1415
+ "intent.handoffReview": "送信前にこの引き継ぎ内容を確認します。",
1393
1416
  "timeline.allThreads": "すべて",
1394
1417
  "timeline.unknownThread": "不明なスレッド",
1395
- "inbox.subtab.pending": "未対応",
1418
+ "inbox.subtab.today": "今日",
1419
+ "inbox.subtab.pending": "要対応",
1396
1420
  "inbox.subtab.completed": "完了済み",
1421
+ "work.section.needsAction": "要対応",
1422
+ "work.section.inProgress": "進行中",
1423
+ "work.section.completedToday": "今日の完了",
1424
+ "work.today.caughtUp": "現在、対応が必要な項目はありません",
1425
+ "work.today.emptyTitle": "対応はありません",
1426
+ "work.today.emptyCopy": "新しい判断や進行中の仕事がここに表示されます。",
1427
+ "work.today.viewAllCompleted": "完了済みをすべて表示(ほか {count} 件)",
1428
+ "work.health.title": "接続の確認が必要です",
1429
+ "work.health.connectionRequired": "{provider} に接続されていません。保留中の操作を送信できない可能性があります。",
1430
+ "work.health.degraded": "一部の接続先を一時的に利用できません。",
1397
1431
  "timeline.filterLabel": "スレッドフィルター",
1398
1432
  "timeline.status.approved": "承認済み",
1399
1433
  "timeline.status.rejected": "拒否済み",
@@ -105,6 +105,11 @@ const AUTO_STICKY_LAN_PROBE_TIMEOUT_MS = 900;
105
105
  const LAN_REACHABILITY_PROBE_TIMEOUT_MS = 700;
106
106
  const LAN_REACHABILITY_PROBE_PATH = "/health";
107
107
  const RECENT_LAN_OK_GRACE_MS = 60_000;
108
+ const RECENT_LAN_RESTART_RECOVERY_MS = 5_000;
109
+ const RECENT_LAN_RESTART_PROBE_INTERVAL_MS = 500;
110
+ const COLD_LAN_RECOVERY_MS = 35_000;
111
+ const COLD_LAN_RECOVERY_PROBE_INTERVAL_MS = 250;
112
+ const COLD_LAN_RECOVERY_PROBE_TIMEOUT_MS = 1_500;
108
113
  const TRANSIENT_LAN_FAILURE_STICKY_MS = 8_000;
109
114
  const TRANSIENT_LAN_FAILURE_THRESHOLD = 2;
110
115
  // If LAN was healthy moments ago, a single timed-out local request is more
@@ -118,6 +123,7 @@ const RELAY_CIRCUIT_BREAKER_MS = 60_000;
118
123
  const PAIRING_STATE_STORAGE_KEY = "viveworker.remote-pairing.state";
119
124
  const PAIRING_STATE_SCHEMA_VERSION = 2;
120
125
  const PAIRING_STATE_LEGACY_SCHEMA_VERSION = 1;
126
+ const LAN_SUCCESS_STORAGE_KEY = "viveworker.remote-pairing.last-lan-ok-at-ms";
121
127
  const ROUTING_STATUS_EVENT = "viveworker:remote-routing-status";
122
128
 
123
129
  // ---------------------------------------------------------------------------
@@ -148,6 +154,12 @@ let _lastSuccessfulRoute = null;
148
154
  /** Last time a LAN request completed successfully. */
149
155
  let _lastLanOkAtMs = 0;
150
156
 
157
+ /** Whether a recent LAN success was restored after this PWA module loaded. */
158
+ let _lastLanOkRestoredFromStorage = false;
159
+
160
+ /** A bridge-restart recovery loop should run at most once per PWA module. */
161
+ let _recentLanRestartRecoveryAttempted = false;
162
+
151
163
  /** Last time we tried a LAN probe while sticky relay was active. */
152
164
  let _lastStickyLanProbeAtMs = 0;
153
165
 
@@ -174,6 +186,49 @@ function newTelemetry() {
174
186
  };
175
187
  }
176
188
 
189
+ function lanSuccessStorage(opts = {}) {
190
+ if (opts.lanSuccessStorage) {
191
+ return opts.lanSuccessStorage;
192
+ }
193
+ try {
194
+ return globalThis.localStorage ?? null;
195
+ } catch {
196
+ return null;
197
+ }
198
+ }
199
+
200
+ function restoreRecentLanSuccess(opts = {}, now = nowMs(opts)) {
201
+ if (_lastLanOkAtMs > 0 || _recentLanRestartRecoveryAttempted) {
202
+ return;
203
+ }
204
+ const storage = lanSuccessStorage(opts);
205
+ if (!storage) return;
206
+
207
+ try {
208
+ const atMs = Number(storage.getItem(LAN_SUCCESS_STORAGE_KEY));
209
+ if (!Number.isFinite(atMs) || atMs <= 0 || atMs > now || now - atMs > RECENT_LAN_OK_GRACE_MS) {
210
+ storage.removeItem?.(LAN_SUCCESS_STORAGE_KEY);
211
+ return;
212
+ }
213
+ _lastLanOkAtMs = atMs;
214
+ _lastLanOkRestoredFromStorage = true;
215
+ } catch {
216
+ // Storage is only a restart hint; routing remains correct without it.
217
+ }
218
+ }
219
+
220
+ function rememberLanSuccess(atMs, opts = {}) {
221
+ _lastLanOkAtMs = atMs;
222
+ _lastLanOkRestoredFromStorage = false;
223
+ const storage = lanSuccessStorage(opts);
224
+ if (!storage) return;
225
+ try {
226
+ storage.setItem(LAN_SUCCESS_STORAGE_KEY, String(atMs));
227
+ } catch {
228
+ // Private browsing or a full storage quota should not affect LAN routing.
229
+ }
230
+ }
231
+
177
232
  function emitRoutingStatus(phase, opts = {}, detail = {}) {
178
233
  if (opts.suppressRoutingStatus === true) {
179
234
  return;
@@ -601,13 +656,15 @@ async function attemptLanFetch(url, init, opts) {
601
656
  ? await Promise.race([fetchPromise, timeoutPromise])
602
657
  : await fetchPromise;
603
658
  _telemetry.lanOk++;
604
- _lastLanOkAtMs = nowMs(opts);
659
+ rememberLanSuccess(nowMs(opts), opts);
605
660
  _consecutiveLanFailures = 0;
606
661
  _lastSuccessfulRoute = "lan";
607
662
  _stickyRelayUntilMs = 0;
608
663
  _lastStickyLanProbeAtMs = 0;
609
664
  resetRelayFailureCircuit();
610
- closeRelayClient("lan connected");
665
+ if (opts.keepRelayClientOnLanSuccess !== true) {
666
+ closeRelayClient("lan connected");
667
+ }
611
668
  emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
612
669
  return { ok: true, response };
613
670
  } catch (err) {
@@ -670,16 +727,19 @@ function shouldConfirmLanReachability(now, opts = {}) {
670
727
  if (opts.confirmLanReachabilityBeforeRelay === false) {
671
728
  return false;
672
729
  }
730
+ if (opts.confirmLanReachabilityBeforeRelay === true) {
731
+ return true;
732
+ }
673
733
  return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
674
734
  }
675
735
 
676
- async function confirmLanUnavailableBeforeRelay(url, err, opts = {}) {
736
+ async function recoverLanBeforeRelay(url, init, err, opts = {}) {
677
737
  if (!shouldConfirmLanReachability(nowMs(opts), opts)) {
678
- return false;
738
+ return { recovered: false, suppressRelay: false };
679
739
  }
680
740
  const path = urlToRelayPath(url);
681
741
  if (path === LAN_REACHABILITY_PROBE_PATH) {
682
- return false;
742
+ return { recovered: false, suppressRelay: false };
683
743
  }
684
744
 
685
745
  const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
@@ -691,12 +751,251 @@ async function confirmLanUnavailableBeforeRelay(url, err, opts = {}) {
691
751
  lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
692
752
  });
693
753
  if (!probe.ok) {
694
- return false;
754
+ return { recovered: false, suppressRelay: false };
755
+ }
756
+
757
+ if (opts.retryLanAfterReachabilityProbe === true) {
758
+ const retry = await attemptLanFetch(url, init, {
759
+ ...opts,
760
+ lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
761
+ });
762
+ if (retry.ok) {
763
+ return { recovered: true, response: retry.response, suppressRelay: true };
764
+ }
695
765
  }
696
766
 
697
767
  _telemetry.lanReachableAfterFailure++;
698
768
  suppressRelayAfterRecentLanFailure(url, err, opts);
699
- return true;
769
+ return { recovered: false, suppressRelay: true };
770
+ }
771
+
772
+ function shouldRecoverRecentLanAfterRestart(now, opts = {}) {
773
+ if (opts.recoverRecentLanAfterRestart === false) return false;
774
+ if (!_lastLanOkRestoredFromStorage || _recentLanRestartRecoveryAttempted) return false;
775
+ return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
776
+ }
777
+
778
+ function waitForLanRecoveryDelay(ms, signal) {
779
+ const delayMs = Math.max(0, Number(ms) || 0);
780
+ if (signal?.aborted) {
781
+ return Promise.reject(abortError(signal));
782
+ }
783
+ return new Promise((resolve, reject) => {
784
+ let timer = null;
785
+ const onAbort = () => {
786
+ if (timer) clearTimeout(timer);
787
+ signal?.removeEventListener?.("abort", onAbort);
788
+ reject(abortError(signal));
789
+ };
790
+ timer = setTimeout(() => {
791
+ signal?.removeEventListener?.("abort", onAbort);
792
+ resolve();
793
+ }, delayMs);
794
+ signal?.addEventListener?.("abort", onAbort, { once: true });
795
+ });
796
+ }
797
+
798
+ async function recoverRecentLanAfterRestart(url, init, opts = {}) {
799
+ const startedAtMs = nowMs(opts);
800
+ if (!shouldRecoverRecentLanAfterRestart(startedAtMs, opts)) {
801
+ return { recovered: false };
802
+ }
803
+ _recentLanRestartRecoveryAttempted = true;
804
+
805
+ const recoveryWindowMs = Math.max(
806
+ 0,
807
+ Number(opts.recentLanRestartRecoveryMs ?? RECENT_LAN_RESTART_RECOVERY_MS) || 0,
808
+ );
809
+ const deadlineMs = startedAtMs + recoveryWindowMs;
810
+ const path = urlToRelayPath(url);
811
+
812
+ while (true) {
813
+ const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
814
+ cache: "no-store",
815
+ credentials: "same-origin",
816
+ headers: { Accept: "application/json" },
817
+ }, {
818
+ ...opts,
819
+ lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
820
+ });
821
+ if (probe.ok) {
822
+ if (path === LAN_REACHABILITY_PROBE_PATH) {
823
+ return { recovered: true, response: probe.response };
824
+ }
825
+ const retry = await attemptLanFetch(url, init, {
826
+ ...opts,
827
+ lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
828
+ });
829
+ if (retry.ok) {
830
+ return { recovered: true, response: retry.response };
831
+ }
832
+ }
833
+
834
+ const remainingMs = deadlineMs - nowMs(opts);
835
+ if (remainingMs <= 0) {
836
+ return { recovered: false };
837
+ }
838
+ await waitForLanRecoveryDelay(
839
+ Math.min(RECENT_LAN_RESTART_PROBE_INTERVAL_MS, remainingMs),
840
+ init.signal,
841
+ );
842
+ }
843
+ }
844
+
845
+ function createLinkedAbortScope(parentSignal) {
846
+ if (typeof AbortController === "undefined") {
847
+ return {
848
+ signal: parentSignal,
849
+ abort() {},
850
+ cleanup() {},
851
+ };
852
+ }
853
+
854
+ const controller = new AbortController();
855
+ const onParentAbort = () => controller.abort(parentSignal?.reason);
856
+ if (parentSignal?.aborted) {
857
+ onParentAbort();
858
+ } else {
859
+ parentSignal?.addEventListener?.("abort", onParentAbort, { once: true });
860
+ }
861
+ return {
862
+ signal: controller.signal,
863
+ abort(reason) {
864
+ if (!controller.signal.aborted) controller.abort(reason);
865
+ },
866
+ cleanup() {
867
+ parentSignal?.removeEventListener?.("abort", onParentAbort);
868
+ },
869
+ };
870
+ }
871
+
872
+ function routeWinnerAbortError() {
873
+ const err = new Error("alternate route connected");
874
+ err.name = "AbortError";
875
+ return err;
876
+ }
877
+
878
+ async function settleRouteAttempt(route, promise) {
879
+ try {
880
+ return { route, ...(await promise) };
881
+ } catch (err) {
882
+ return { route, ok: false, err };
883
+ }
884
+ }
885
+
886
+ async function recoverColdLanWhileRelayConnects(url, init, initialErr, opts = {}) {
887
+ const startedAtMs = nowMs(opts);
888
+ const recoveryWindowMs = Math.max(
889
+ 0,
890
+ Number(opts.coldLanRecoveryMs ?? COLD_LAN_RECOVERY_MS) || 0,
891
+ );
892
+ const deadlineMs = startedAtMs + recoveryWindowMs;
893
+ const path = urlToRelayPath(url);
894
+ let lastErr = initialErr;
895
+
896
+ while (true) {
897
+ const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
898
+ cache: "no-store",
899
+ credentials: "same-origin",
900
+ headers: { Accept: "application/json" },
901
+ signal: init.signal,
902
+ }, {
903
+ ...opts,
904
+ keepRelayClientOnLanSuccess: true,
905
+ lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? COLD_LAN_RECOVERY_PROBE_TIMEOUT_MS,
906
+ });
907
+ if (probe.ok) {
908
+ if (path === LAN_REACHABILITY_PROBE_PATH) {
909
+ return { ok: true, response: probe.response };
910
+ }
911
+ const retry = await attemptLanFetch(url, init, {
912
+ ...opts,
913
+ lanTimeoutMs: opts.lanRecoveryTimeoutMs ?? opts.lanTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
914
+ });
915
+ if (retry.ok) {
916
+ return { ok: true, response: retry.response };
917
+ }
918
+ lastErr = retry.err;
919
+ } else {
920
+ lastErr = probe.err;
921
+ }
922
+
923
+ const remainingMs = deadlineMs - nowMs(opts);
924
+ if (remainingMs <= 0) {
925
+ return { ok: false, err: lastErr };
926
+ }
927
+ await waitForLanRecoveryDelay(
928
+ Math.min(
929
+ Math.max(0, Number(opts.coldLanRecoveryProbeIntervalMs ?? COLD_LAN_RECOVERY_PROBE_INTERVAL_MS) || 0),
930
+ remainingMs,
931
+ ),
932
+ init.signal,
933
+ );
934
+ }
935
+ }
936
+
937
+ async function raceRelayWithColdLanRecovery(url, init, initialLanErr, opts = {}) {
938
+ const lanScope = createLinkedAbortScope(init.signal);
939
+ const relayScope = createLinkedAbortScope(init.signal);
940
+ const lanInit = { ...init, signal: lanScope.signal };
941
+ const relayInit = { ...init, signal: relayScope.signal };
942
+ let lanResult = null;
943
+ let relayResult = null;
944
+
945
+ try {
946
+ const lanPromise = settleRouteAttempt(
947
+ "lan",
948
+ recoverColdLanWhileRelayConnects(url, lanInit, initialLanErr, opts),
949
+ );
950
+ emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
951
+ const relayPromise = settleRouteAttempt("relay", attemptRelayFetch(url, relayInit, opts));
952
+ const first = await Promise.race([lanPromise, relayPromise]);
953
+
954
+ if (first.route === "lan") lanResult = first;
955
+ else relayResult = first;
956
+
957
+ if (first.ok) {
958
+ if (first.route === "lan") {
959
+ relayScope.abort(routeWinnerAbortError());
960
+ closeRelayClient("LAN recovery won route race");
961
+ } else {
962
+ lanScope.abort(routeWinnerAbortError());
963
+ }
964
+ return { ok: true, response: first.response, route: first.route };
965
+ }
966
+
967
+ const second = await (first.route === "lan" ? relayPromise : lanPromise);
968
+ if (second.route === "lan") lanResult = second;
969
+ else relayResult = second;
970
+ if (second.ok) {
971
+ if (second.route === "lan") {
972
+ relayScope.abort(routeWinnerAbortError());
973
+ closeRelayClient("LAN recovery won route race");
974
+ } else {
975
+ lanScope.abort(routeWinnerAbortError());
976
+ }
977
+ return { ok: true, response: second.response, route: second.route };
978
+ }
979
+
980
+ return {
981
+ ok: false,
982
+ lanErr: lanResult?.err || initialLanErr,
983
+ relayErr: relayResult?.err || null,
984
+ };
985
+ } finally {
986
+ lanScope.cleanup();
987
+ relayScope.cleanup();
988
+ }
989
+ }
990
+
991
+ function throwRouteRaceFailure(result, opts = {}) {
992
+ if (isRemotePairingEnrollmentRequiredError(result.relayErr)) {
993
+ throw result.relayErr;
994
+ }
995
+ if (opts.preferRelayError && result.relayErr) {
996
+ throw result.relayErr;
997
+ }
998
+ throw result.lanErr || result.relayErr || new TypeError("LAN and relay unavailable");
700
999
  }
701
1000
 
702
1001
  async function attemptStickyLanProbe(url, init, opts) {
@@ -980,8 +1279,17 @@ function nowMs(opts) {
980
1279
  * autoProbeLanWhileSticky?: boolean,
981
1280
  * delayRelayAfterRecentLanFailure?: boolean,
982
1281
  * confirmLanReachabilityBeforeRelay?: boolean,
1282
+ * retryLanAfterReachabilityProbe?: boolean,
1283
+ * lanRecoveryTimeoutMs?: number,
1284
+ * lanAfterRelayFailureTimeoutMs?: number,
983
1285
  * stickyLanProbeTimeoutMs?: number,
984
1286
  * lanReachabilityProbeTimeoutMs?: number,
1287
+ * recoverRecentLanAfterRestart?: boolean,
1288
+ * recentLanRestartRecoveryMs?: number,
1289
+ * raceLanRecoveryWithRelay?: boolean,
1290
+ * coldLanRecoveryMs?: number,
1291
+ * coldLanRecoveryProbeIntervalMs?: number,
1292
+ * lanSuccessStorage?: Storage,
985
1293
  * }} [opts]
986
1294
  * @returns {Promise<{
987
1295
  * ok: boolean,
@@ -994,17 +1302,31 @@ function nowMs(opts) {
994
1302
  */
995
1303
  export async function routedFetch(url, init = {}, opts = {}) {
996
1304
  const t = nowMs(opts);
1305
+ restoreRecentLanSuccess(opts, t);
997
1306
 
998
1307
  // Sticky-relay path: LAN just failed, prefer relay for a while.
999
1308
  if (_stickyRelayUntilMs > t) {
1309
+ if (opts.raceLanRecoveryWithRelay === true) {
1310
+ const lan = await attemptLanFetch(url, init, opts);
1311
+ if (lan.ok) return lan.response;
1312
+ const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
1313
+ if (recovery.recovered) return recovery.response;
1314
+ const raced = await raceRelayWithColdLanRecovery(url, init, lan.err, opts);
1315
+ if (raced.ok) return raced.response;
1316
+ return throwRouteRaceFailure(raced, opts);
1317
+ }
1000
1318
  if (shouldDelayRelayAfterRecentLanFailure(t, opts)) {
1001
1319
  const lan = await attemptLanFetch(url, init, opts);
1002
1320
  if (lan.ok) return lan.response;
1321
+ const restartRecovery = await recoverRecentLanAfterRestart(url, init, opts);
1322
+ if (restartRecovery.recovered) return restartRecovery.response;
1003
1323
  if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1004
1324
  suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1005
1325
  throw lan.err;
1006
1326
  }
1007
- if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1327
+ const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
1328
+ if (recovery.recovered) return recovery.response;
1329
+ if (recovery.suppressRelay) {
1008
1330
  throw lan.err;
1009
1331
  }
1010
1332
  }
@@ -1012,7 +1334,9 @@ export async function routedFetch(url, init = {}, opts = {}) {
1012
1334
  if (opts.probeLanWhileSticky === true || autoProbe) {
1013
1335
  const lan = await attemptStickyLanProbe(url, init, stickyLanProbeOpts(opts, autoProbe));
1014
1336
  if (lan.ok) return lan.response;
1015
- if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1337
+ const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
1338
+ if (recovery.recovered) return recovery.response;
1339
+ if (recovery.suppressRelay) {
1016
1340
  throw lan.err;
1017
1341
  }
1018
1342
  }
@@ -1034,11 +1358,22 @@ export async function routedFetch(url, init = {}, opts = {}) {
1034
1358
  // Happy path: LAN first.
1035
1359
  const lan = await attemptLanFetch(url, init, opts);
1036
1360
  if (lan.ok) return lan.response;
1361
+ if (opts.raceLanRecoveryWithRelay === true) {
1362
+ const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
1363
+ if (recovery.recovered) return recovery.response;
1364
+ const raced = await raceRelayWithColdLanRecovery(url, init, lan.err, opts);
1365
+ if (raced.ok) return raced.response;
1366
+ return throwRouteRaceFailure(raced, opts);
1367
+ }
1368
+ const restartRecovery = await recoverRecentLanAfterRestart(url, init, opts);
1369
+ if (restartRecovery.recovered) return restartRecovery.response;
1037
1370
  if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1038
1371
  suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1039
1372
  throw lan.err;
1040
1373
  }
1041
- if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1374
+ const recovery = await recoverLanBeforeRelay(url, init, lan.err, opts);
1375
+ if (recovery.recovered) return recovery.response;
1376
+ if (recovery.suppressRelay) {
1042
1377
  throw lan.err;
1043
1378
  }
1044
1379
 
@@ -1046,10 +1381,18 @@ export async function routedFetch(url, init = {}, opts = {}) {
1046
1381
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
1047
1382
  const r = await attemptRelayFetch(url, init, opts);
1048
1383
  if (r.ok) return r.response;
1384
+ // The bridge may have finished restarting while the relay handshake was
1385
+ // failing. Give the direct LAN route one final chance before surfacing an
1386
+ // off-LAN error, even when this request prefers relay diagnostics.
1387
+ const lanAfterRelay = await attemptLanFetch(url, init, {
1388
+ ...opts,
1389
+ lanTimeoutMs: opts.lanAfterRelayFailureTimeoutMs ?? opts.lanRecoveryTimeoutMs ?? DEFAULT_LAN_TIMEOUT_MS,
1390
+ });
1391
+ if (lanAfterRelay.ok) return lanAfterRelay.response;
1049
1392
  if (isRemotePairingEnrollmentRequiredError(r.err)) throw r.err;
1050
1393
  if (opts.preferRelayError && r.err) throw r.err;
1051
1394
 
1052
- throw lan.err;
1395
+ throw lanAfterRelay.err;
1053
1396
  }
1054
1397
 
1055
1398
  /**
@@ -1094,6 +1437,8 @@ export function __resetForTest() {
1094
1437
  _lastPairingStateStatus = null;
1095
1438
  _lastSuccessfulRoute = null;
1096
1439
  _lastLanOkAtMs = 0;
1440
+ _lastLanOkRestoredFromStorage = false;
1441
+ _recentLanRestartRecoveryAttempted = false;
1097
1442
  _lastStickyLanProbeAtMs = 0;
1098
1443
  _consecutiveLanFailures = 0;
1099
1444
  _relayFailureAtMs = [];
@@ -539,7 +539,10 @@ export class RemotePairingTransport {
539
539
  if (this._closed) return; // user-initiated; close() already settled
540
540
  if (this._state === STATE.FAILED) return; // already terminal
541
541
 
542
+ const stateBeforeClose = this._state;
542
543
  const isRelayReset = Number(evt?.code) === CLOSE_RELAY_RESET_SESSION;
544
+ const relayResetDuringNegotiation =
545
+ isRelayReset && (stateBeforeClose === STATE.HANDSHAKING || stateBeforeClose === STATE.RESUMING);
543
546
  if (isRelayReset) {
544
547
  this._log.debug?.(`relay requested fresh handshake reason=${evt?.reason || ""}`);
545
548
  this._dropNoiseState();
@@ -547,7 +550,10 @@ export class RemotePairingTransport {
547
550
 
548
551
  this._log.debug?.(`ws closed code=${evt?.code} reason=${evt?.reason}`);
549
552
  this._setState(STATE.DISCONNECTED, { code: evt?.code, reason: evt?.reason });
550
- if (Number(evt?.code) !== CLOSE_NORMAL) {
553
+ // A 4004 while handshaking/resuming is the relay coordinating a normal
554
+ // state reset after one peer restarted. Counting it towards the reconnect
555
+ // circuit turns two expected reset hops into minutes of needless outage.
556
+ if (Number(evt?.code) !== CLOSE_NORMAL && !relayResetDuringNegotiation) {
551
557
  this._recordReconnectFailure({
552
558
  code: Number(evt?.code) || 0,
553
559
  reason: String(evt?.reason || ""),