viveworker 0.8.2 → 0.8.3

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/app.css CHANGED
@@ -2755,6 +2755,39 @@ pre code {
2755
2755
  color: rgba(236, 248, 255, 0.66);
2756
2756
  }
2757
2757
 
2758
+ .timeline-entry--activity {
2759
+ cursor: default;
2760
+ border-color: rgba(121, 196, 255, 0.18);
2761
+ background:
2762
+ linear-gradient(180deg, rgba(121, 196, 255, 0.11), rgba(20, 31, 40, 0.94)),
2763
+ var(--card);
2764
+ }
2765
+
2766
+ .timeline-entry__activity-pulse {
2767
+ width: 0.58rem;
2768
+ height: 0.58rem;
2769
+ flex: 0 0 auto;
2770
+ border-radius: 999px;
2771
+ background: rgba(121, 196, 255, 0.9);
2772
+ box-shadow: 0 0 0 0 rgba(121, 196, 255, 0.26);
2773
+ animation: timeline-activity-pulse 1.6s ease-out infinite;
2774
+ }
2775
+
2776
+ @keyframes timeline-activity-pulse {
2777
+ 0% {
2778
+ box-shadow: 0 0 0 0 rgba(121, 196, 255, 0.26);
2779
+ opacity: 0.95;
2780
+ }
2781
+ 70% {
2782
+ box-shadow: 0 0 0 0.56rem rgba(121, 196, 255, 0);
2783
+ opacity: 0.72;
2784
+ }
2785
+ 100% {
2786
+ box-shadow: 0 0 0 0 rgba(121, 196, 255, 0);
2787
+ opacity: 0.95;
2788
+ }
2789
+ }
2790
+
2758
2791
  .timeline-entry--message .timeline-entry__title {
2759
2792
  font-size: 1.03rem;
2760
2793
  line-height: 1.55;
package/web/app.js CHANGED
@@ -897,7 +897,7 @@ async function forceAppRefreshFromLan() {
897
897
  const keys = await caches.keys();
898
898
  await Promise.all(
899
899
  keys
900
- .filter((key) => /^viveworker-v/.test(key))
900
+ .filter((key) => /^viveworker-/u.test(key))
901
901
  .map((key) => caches.delete(key))
902
902
  );
903
903
  }
@@ -1930,17 +1930,26 @@ function currentTimelineKindFilterOption() {
1930
1930
 
1931
1931
  function timelineEntryMatchesKindFilter(entry, filterId) {
1932
1932
  const kind = normalizeClientText(entry?.kind || entry?.item?.kind || "");
1933
+ const fileEventType = normalizeClientText(entry?.fileEventType || entry?.item?.fileEventType || "");
1934
+ const activityPhase = normalizeClientText(entry?.activityPhase || entry?.item?.activityPhase || "");
1933
1935
  switch (filterId) {
1934
1936
  case "messages":
1935
- return TIMELINE_MESSAGE_KINDS.has(kind);
1937
+ return TIMELINE_MESSAGE_KINDS.has(kind) || (kind === "activity_status" && activityPhase === "thinking");
1936
1938
  case "suggestions":
1937
1939
  return kind === "ambient_suggestions";
1938
1940
  case "files":
1939
- return kind === "file_event";
1941
+ return (
1942
+ (kind === "file_event" && fileEventType !== "git") ||
1943
+ (kind === "activity_status" && ["reading", "searching", "editing"].includes(activityPhase))
1944
+ );
1940
1945
  case "commands":
1941
- return kind === "command_event";
1946
+ return (
1947
+ kind === "command_event" ||
1948
+ (kind === "file_event" && fileEventType === "git") ||
1949
+ (kind === "activity_status" && activityPhase === "running_command")
1950
+ );
1942
1951
  case "approvals":
1943
- return kind === "approval";
1952
+ return kind === "approval" || (kind === "activity_status" && activityPhase === "awaiting_approval");
1944
1953
  case "plans":
1945
1954
  return kind === "plan" || kind === "plan_ready";
1946
1955
  case "choices":
@@ -3773,6 +3782,7 @@ function renderTimelineEntry(entry, { desktop }) {
3773
3782
  const isMessageLike = TIMELINE_MESSAGE_KINDS.has(item.kind) || isMoltbookOrA2A;
3774
3783
  const isFileEvent = item.kind === "file_event";
3775
3784
  const isCommandEvent = item.kind === "command_event";
3785
+ const isActivityStatus = item.kind === "activity_status";
3776
3786
  const imageUrls = Array.isArray(item.imageUrls) ? item.imageUrls.filter(Boolean) : [];
3777
3787
  const fileRefs = normalizeClientFileRefs(item.fileRefs);
3778
3788
  const primaryText = timelineEntryPrimaryText(item, entry.status, { isMessageLike, isFileEvent, isCommandEvent });
@@ -3783,13 +3793,15 @@ function renderTimelineEntry(entry, { desktop }) {
3783
3793
  const fileEventFileSummary = isFileEvent ? timelineFileEventFileSummary(item) : "";
3784
3794
  const fileEventDiffStatsHtml = isFileEvent ? renderDiffEntryStatsHtml(item) : "";
3785
3795
  const toolEventCommand = timelineToolEventCommand(item);
3796
+ const tagName = isActivityStatus ? "div" : "button";
3797
+ const openAttrs = isActivityStatus
3798
+ ? `role="status" aria-live="polite"`
3799
+ : `data-open-item-kind="${escapeHtml(item.kind)}" data-open-item-token="${escapeHtml(item.token)}" data-source-tab="timeline"`;
3786
3800
 
3787
3801
  return `
3788
- <button
3789
- class="timeline-entry timeline-entry--${kindClassName} timeline-entry--kind-${kindNameClass} ${isMessageLike ? "timeline-entry--message" : "timeline-entry--operational"}"
3790
- data-open-item-kind="${escapeHtml(item.kind)}"
3791
- data-open-item-token="${escapeHtml(item.token)}"
3792
- data-source-tab="timeline"
3802
+ <${tagName}
3803
+ class="timeline-entry timeline-entry--${kindClassName} timeline-entry--kind-${kindNameClass} ${isMessageLike ? "timeline-entry--message" : "timeline-entry--operational"} ${isActivityStatus ? "timeline-entry--activity" : ""}"
3804
+ ${openAttrs}
3793
3805
  >
3794
3806
  <div class="timeline-entry__meta">
3795
3807
  <span class="timeline-entry__kind">
@@ -3799,7 +3811,7 @@ function renderTimelineEntry(entry, { desktop }) {
3799
3811
  </span>
3800
3812
  <span class="timeline-entry__meta-right">
3801
3813
  <span class="timeline-entry__time">${escapeHtml(timestampLabel)}</span>
3802
- <span class="timeline-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>
3814
+ ${isActivityStatus ? `<span class="timeline-entry__activity-pulse" aria-hidden="true"></span>` : `<span class="timeline-entry__chevron" aria-hidden="true">${renderIcon("chevron-right")}</span>`}
3803
3815
  </span>
3804
3816
  </div>
3805
3817
  ${threadLabel ? `<p class="timeline-entry__thread">${escapeHtml(threadLabel)}</p>` : ""}
@@ -3821,7 +3833,7 @@ function renderTimelineEntry(entry, { desktop }) {
3821
3833
  ${isFileEvent ? "" : renderTimelineEntryFileStrip(fileRefs)}
3822
3834
  </div>
3823
3835
  ${statusLabel ? `<div class="timeline-entry__footer"><span class="timeline-entry__status">${escapeHtml(statusLabel)}</span></div>` : ""}
3824
- </button>
3836
+ </${tagName}>
3825
3837
  `;
3826
3838
  }
3827
3839
 
@@ -4059,10 +4071,13 @@ function shouldRenderFileEventCommand(item) {
4059
4071
  if (item?.kind !== "file_event") {
4060
4072
  return false;
4061
4073
  }
4062
- return ["read", "search"].includes(normalizeClientText(item?.fileEventType || ""));
4074
+ return ["read", "search", "git"].includes(normalizeClientText(item?.fileEventType || ""));
4063
4075
  }
4064
4076
 
4065
4077
  function timelineToolEventCommand(item) {
4078
+ if (item?.kind === "activity_status") {
4079
+ return truncateUiText(item?.commandText || "", 220);
4080
+ }
4066
4081
  if (item?.kind === "command_event") {
4067
4082
  return timelineCommandEventCommand(item);
4068
4083
  }
@@ -4078,6 +4093,8 @@ function fileEventDisplayLabel(fileEventType) {
4078
4093
  return L("fileEvent.read");
4079
4094
  case "search":
4080
4095
  return L("fileEvent.search");
4096
+ case "git":
4097
+ return L("fileEvent.command");
4081
4098
  case "command":
4082
4099
  return L("fileEvent.command");
4083
4100
  case "write":
@@ -4095,15 +4112,24 @@ function fileEventDisplayLabel(fileEventType) {
4095
4112
 
4096
4113
  function fileEventTimelineCountLabel(item) {
4097
4114
  const fileEventType = normalizeClientText(item?.fileEventType || "");
4098
- const count = normalizeClientFileRefs(item?.fileRefs).length;
4115
+ const fileRefs = normalizeClientFileRefs(item?.fileRefs);
4116
+ const count = fileRefs.length;
4099
4117
  if (count <= 0) {
4100
4118
  return fileEventDisplayLabel(fileEventType) || L("common.fileEvent");
4101
4119
  }
4102
4120
  switch (fileEventType) {
4103
4121
  case "read":
4122
+ if (count === 1) {
4123
+ return L("fileEvent.read");
4124
+ }
4104
4125
  return L("fileEvent.timeline.read", { count });
4105
4126
  case "search":
4127
+ if (count === 1) {
4128
+ return L("fileEvent.search");
4129
+ }
4106
4130
  return L("fileEvent.timeline.search", { count });
4131
+ case "git":
4132
+ return L("fileEvent.timeline.command", { count });
4107
4133
  case "command":
4108
4134
  return L("fileEvent.timeline.command", { count });
4109
4135
  case "write":
@@ -5448,20 +5474,29 @@ function recentAutoPilotEntries(limit = 5) {
5448
5474
  }
5449
5475
 
5450
5476
  function isAutoPilotApprovalEntry(entry) {
5477
+ const mode = normalizeClientText(entry?.autoPilotMode || "");
5451
5478
  const stableId = normalizeClientText(entry?.stableId || "");
5452
5479
  return (
5453
5480
  normalizeClientText(entry?.kind || "") === "approval" &&
5454
5481
  normalizeClientText(entry?.outcome || "") === "approved" &&
5455
- (stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
5482
+ (mode === "read" || mode === "write" || stableId.endsWith(":autopilot") || stableId.includes(":autopilot-write"))
5456
5483
  );
5457
5484
  }
5458
5485
 
5459
5486
  function autoPilotEntryMode(item) {
5487
+ const mode = normalizeClientText(item?.autoPilotMode || "");
5488
+ if (mode === "read" || mode === "write") {
5489
+ return mode;
5490
+ }
5460
5491
  const stableId = normalizeClientText(item?.stableId || "");
5461
5492
  return stableId.includes(":autopilot-write") ? "write" : "read";
5462
5493
  }
5463
5494
 
5464
5495
  function autoPilotEntryWriteLane(item) {
5496
+ const projected = normalizeClientText(item?.autoPilotWriteLane || "");
5497
+ if (projected) {
5498
+ return projected;
5499
+ }
5465
5500
  const stableId = normalizeClientText(item?.stableId || "");
5466
5501
  const match = stableId.match(/:autopilot-write:([a-z_-]+)$/u);
5467
5502
  return normalizeClientText(match?.[1] || "");
@@ -7627,7 +7662,7 @@ function renderMoltbookDraftComposer(detail, options = {}) {
7627
7662
  ? `
7628
7663
  <div class="actions actions--stack${options.mobile ? " actions--sticky" : ""}">
7629
7664
  <button type="submit" data-action="approve" class="primary primary--wide">${escapeHtml(approveLabel)}</button>
7630
- <button type="submit" data-action="deny" class="danger danger--wide">Deny</button>
7665
+ <button type="submit" data-action="deny" class="danger danger--wide">${escapeHtml(L("moltbook.draft.deny"))}</button>
7631
7666
  </div>
7632
7667
  `
7633
7668
  : `<p class="muted reply-composer__description">${escapeHtml(L("moltbook.draft.resolved"))}</p>`;
@@ -8800,7 +8835,7 @@ function bindShellInteractions() {
8800
8835
  buildActionOutcomeDetail({
8801
8836
  kind: "approval",
8802
8837
  title: state.currentDetail?.title,
8803
- message: approvalOutcomeMessage(actionUrl, activeItem?.provider),
8838
+ message: approvalOutcomeMessage(actionUrl, state.currentDetail?.provider || activeItem?.provider),
8804
8839
  })
8805
8840
  );
8806
8841
  await renderShell();
@@ -9712,7 +9747,9 @@ for (const button of document.querySelectorAll("[data-wallet-address-copy]")) {
9712
9747
  labelCache.set(btn, btn.innerHTML);
9713
9748
  btn.disabled = true;
9714
9749
  if (btn.dataset.action === submittedAction) {
9715
- btn.innerHTML = submittedAction === "approve" ? "送信中…" : "処理中…";
9750
+ btn.innerHTML = submittedAction === "approve"
9751
+ ? escapeHtml(L("moltbook.draft.submittingApprove"))
9752
+ : escapeHtml(L("moltbook.draft.submittingDeny"));
9716
9753
  btn.classList.add("is-loading");
9717
9754
  } else {
9718
9755
  btn.classList.add("is-dimmed");
@@ -10582,6 +10619,8 @@ function kindMeta(kind, item) {
10582
10619
  return { label: L("common.assistantCommentary"), tone: "plan", icon: "assistant-commentary" };
10583
10620
  case "assistant_final":
10584
10621
  return { label: L("common.assistantFinal"), tone: "completion", icon: "assistant-final" };
10622
+ case "activity_status":
10623
+ return { label: L("server.title.activityStatus"), tone: "plan", icon: activityStatusIcon(item?.activityPhase) };
10585
10624
  case "ambient_suggestions":
10586
10625
  return { label: L("common.ambientSuggestions"), tone: "neutral", icon: "suggestions" };
10587
10626
  case "approval":
@@ -10596,6 +10635,9 @@ function kindMeta(kind, item) {
10596
10635
  case "diff_thread":
10597
10636
  return { label: L("common.diff"), tone: "neutral", icon: "diff" };
10598
10637
  case "file_event":
10638
+ if (normalizeClientText(item?.fileEventType || "") === "git") {
10639
+ return { label: L("common.commandEvent"), tone: "neutral", icon: "command" };
10640
+ }
10599
10641
  return { label: L("common.fileEvent"), tone: "neutral", icon: "file-event" };
10600
10642
  case "command_event":
10601
10643
  return { label: L("common.commandEvent"), tone: "neutral", icon: "command" };
@@ -10616,6 +10658,24 @@ function kindMeta(kind, item) {
10616
10658
  }
10617
10659
  }
10618
10660
 
10661
+ function activityStatusIcon(phase) {
10662
+ switch (normalizeClientText(phase || "")) {
10663
+ case "reading":
10664
+ return "file-event";
10665
+ case "searching":
10666
+ return "filter";
10667
+ case "editing":
10668
+ return "diff";
10669
+ case "running_command":
10670
+ return "command";
10671
+ case "awaiting_approval":
10672
+ return "approval";
10673
+ case "thinking":
10674
+ default:
10675
+ return "pending";
10676
+ }
10677
+ }
10678
+
10619
10679
  function renderTypePillContent(kindInfo) {
10620
10680
  return `
10621
10681
  <span class="type-pill__icon" aria-hidden="true">${renderIcon(kindInfo.icon)}</span>
package/web/build-id.js CHANGED
@@ -1 +1 @@
1
- export const APP_BUILD_ID = "20260505-command-event-fast-approval-v1";
1
+ export const APP_BUILD_ID = "20260509-remote-lan-relay-guard";
package/web/i18n.js CHANGED
@@ -463,7 +463,7 @@ const translations = {
463
463
  "settings.row.defaultLanguage": "Setup default",
464
464
  "settings.moltbook.title": "Moltbook",
465
465
  "settings.moltbook.subtitle": "Auto-scout & notifications",
466
- "settings.moltbook.copy": "View your daily auto-scout posting status and quota.",
466
+ "settings.moltbook.copy": "View incoming reply drafts, feed scouting, and today's posting quota.",
467
467
  "settings.moltbook.unavailable": "Moltbook is not configured.",
468
468
  "settings.row.moltbookAccount": "Profile",
469
469
  "settings.row.moltbookQuota": "Replies today",
@@ -484,6 +484,9 @@ const translations = {
484
484
  "moltbook.draft.titleLabel": "Title",
485
485
  "moltbook.draft.approvePost": "Approve & publish",
486
486
  "moltbook.draft.approveReply": "Approve & post",
487
+ "moltbook.draft.deny": "Deny",
488
+ "moltbook.draft.submittingApprove": "Publishing...",
489
+ "moltbook.draft.submittingDeny": "Denying...",
487
490
  "moltbook.draft.editHint": "Edit if needed, then approve to publish or deny to skip.",
488
491
  "moltbook.draft.resolved": "This draft has already been resolved.",
489
492
  "moltbook.draft.greetMorning": "Good morning! Share yesterday's highlights?",
@@ -780,6 +783,7 @@ const translations = {
780
783
  "server.title.userMessage": "User",
781
784
  "server.title.assistantCommentary": "Update",
782
785
  "server.title.assistantFinal": "Final reply",
786
+ "server.title.activityStatus": "Working",
783
787
  "server.title.ambientSuggestions": "Suggested next steps",
784
788
  "server.title.threadShare": "Thread Share",
785
789
  "server.action.review": "Review",
@@ -814,6 +818,13 @@ const translations = {
814
818
  "server.message.choiceSummarySubmitted": "Selected answers sent.",
815
819
  "server.message.choiceSummaryReceivedTest": "Test answers received.",
816
820
  "server.message.questionAnswered": "Your answer was sent to {provider}.",
821
+ "server.activity.thinking": "Thinking...",
822
+ "server.activity.reading": "Reading files...",
823
+ "server.activity.searching": "Searching...",
824
+ "server.activity.editing": "Editing...",
825
+ "server.activity.runningCommand": "Running command...",
826
+ "server.activity.awaitingApproval": "Waiting for approval...",
827
+ "server.activity.working": "Working...",
817
828
  "claudePlan.title": "Plan from {provider}",
818
829
  "claudePlan.approve": "Approve plan",
819
830
  "claudePlan.reject": "Reject plan",
@@ -1529,7 +1540,7 @@ const translations = {
1529
1540
  "settings.row.defaultLanguage": "setup 時の既定値",
1530
1541
  "settings.moltbook.title": "Moltbook",
1531
1542
  "settings.moltbook.subtitle": "自動スカウト・通知連携",
1532
- "settings.moltbook.copy": "本日の自動スカウト投稿状況と上限を確認できます。",
1543
+ "settings.moltbook.copy": "自分宛コメントの返信案、フィード巡回、本日の投稿上限を確認できます。",
1533
1544
  "settings.moltbook.unavailable": "Moltbook が設定されていません。",
1534
1545
  "settings.row.moltbookAccount": "プロフィール",
1535
1546
  "settings.row.moltbookQuota": "本日の返信数",
@@ -1550,6 +1561,9 @@ const translations = {
1550
1561
  "moltbook.draft.titleLabel": "タイトル",
1551
1562
  "moltbook.draft.approvePost": "承認して投稿",
1552
1563
  "moltbook.draft.approveReply": "承認して返信",
1564
+ "moltbook.draft.deny": "拒否",
1565
+ "moltbook.draft.submittingApprove": "投稿中…",
1566
+ "moltbook.draft.submittingDeny": "拒否中…",
1553
1567
  "moltbook.draft.editHint": "必要に応じて編集し、承認して投稿するか、拒否してスキップします。",
1554
1568
  "moltbook.draft.resolved": "このドラフトは既に処理済みです。",
1555
1569
  "moltbook.draft.greetMorning": "おはようございます!昨日の成果をシェアしませんか?",
@@ -1846,6 +1860,7 @@ const translations = {
1846
1860
  "server.title.userMessage": "ユーザー",
1847
1861
  "server.title.assistantCommentary": "途中経過",
1848
1862
  "server.title.assistantFinal": "最終回答",
1863
+ "server.title.activityStatus": "作業中",
1849
1864
  "server.title.ambientSuggestions": "次の候補",
1850
1865
  "server.title.threadShare": "スレッド共有",
1851
1866
  "server.action.review": "判断する",
@@ -1880,6 +1895,13 @@ const translations = {
1880
1895
  "server.message.choiceSummarySubmitted": "選択内容を送信しました。",
1881
1896
  "server.message.choiceSummaryReceivedTest": "テスト回答を受信しました。",
1882
1897
  "server.message.questionAnswered": "回答を {provider} に送信しました。",
1898
+ "server.activity.thinking": "考え中...",
1899
+ "server.activity.reading": "ファイルを読んでいます...",
1900
+ "server.activity.searching": "検索しています...",
1901
+ "server.activity.editing": "編集中...",
1902
+ "server.activity.runningCommand": "コマンドを実行中...",
1903
+ "server.activity.awaitingApproval": "承認待ち...",
1904
+ "server.activity.working": "作業中...",
1883
1905
  "claudePlan.title": "{provider} からのプラン",
1884
1906
  "claudePlan.approve": "プランを承認",
1885
1907
  "claudePlan.reject": "プランを却下",
@@ -13,12 +13,13 @@
13
13
  * 2. If LAN throws a `TypeError` (network failure, DNS, connect refused —
14
14
  * i.e. the bridge isn't reachable), fall back to the relay's
15
15
  * `RemotePairingRpcClient.fetch()`.
16
- * 3. After a LAN failure, enter a STICKY_RELAY_MS window where subsequent
17
- * requests skip LAN and go straight to relay. Avoids paying the LAN
18
- * timeout cost on every call after we've already learned LAN is dead.
19
- * 4. When the window expires, the next request re-probes LAN. If LAN is
20
- * back, the sticky window is left dormant and we return to the happy
21
- * path. If LAN is still dead, the window resets.
16
+ * 3. After a LAN failure, enter a sticky relay window where subsequent
17
+ * requests skip LAN and go straight to relay. If LAN was healthy very
18
+ * recently, keep that window short so one transient WiFi wobble doesn't
19
+ * make an in-room phone behave like it is off-LAN for minutes.
20
+ * 4. While sticky relay is active, periodically run a tiny LAN probe. If
21
+ * LAN is back, clear sticky state and close the relay client. If LAN is
22
+ * still dead, the sticky window resets and relay stays the fast path.
22
23
  * 5. AbortError (caller cancelled) is never treated as a LAN failure —
23
24
  * we re-throw immediately rather than waste a relay attempt.
24
25
  *
@@ -76,11 +77,9 @@ import { loadPairingState } from "./pairing-state.js";
76
77
  // ---------------------------------------------------------------------------
77
78
 
78
79
  /**
79
- * After a LAN failure we prefer relay for this long before re-probing LAN.
80
- * 5 minutes is a coarse pick: long enough that a phone that fell off LAN
81
- * (cellular handoff, WiFi drop) doesn't pay LAN connect timeouts on every
82
- * outbound call, short enough that "I just walked back into wifi range"
83
- * recovers without the user noticing.
80
+ * Hard upper bound after a LAN failure. We keep this for real off-LAN use so
81
+ * cellular/remote sessions do not pay LAN connect timeouts on every request.
82
+ * Recent successful LAN sessions use a much shorter transient window below.
84
83
  */
85
84
  const STICKY_RELAY_MS = 5 * 60 * 1000;
86
85
 
@@ -99,6 +98,20 @@ const DEFAULT_RELAY_TIMEOUT_MS = 60_000;
99
98
  */
100
99
  const DEFAULT_LAN_TIMEOUT_MS = 2_500;
101
100
  const DEFAULT_STICKY_LAN_PROBE_TIMEOUT_MS = 350;
101
+ // Recover quickly from accidentally sticky relay while still avoiding LAN
102
+ // timeout spam during real off-LAN sessions.
103
+ const AUTO_STICKY_LAN_PROBE_INTERVAL_MS = 8_000;
104
+ const AUTO_STICKY_LAN_PROBE_TIMEOUT_MS = 900;
105
+ const LAN_REACHABILITY_PROBE_TIMEOUT_MS = 700;
106
+ const LAN_REACHABILITY_PROBE_PATH = "/health";
107
+ const RECENT_LAN_OK_GRACE_MS = 60_000;
108
+ const TRANSIENT_LAN_FAILURE_STICKY_MS = 8_000;
109
+ const TRANSIENT_LAN_FAILURE_THRESHOLD = 2;
110
+ // If LAN was healthy moments ago, a single timed-out local request is more
111
+ // likely a busy bridge/Safari wobble than a true off-LAN transition. Delay
112
+ // relay fallback for the first few consecutive failures to avoid burning
113
+ // Cloudflare Durable Object quota while the phone is still on trusted LAN.
114
+ const RECENT_LAN_RELAY_SUPPRESSION_FAILURES = 2;
102
115
  const RELAY_FAILURE_WINDOW_MS = 60_000;
103
116
  const RELAY_FAILURE_THRESHOLD = 6;
104
117
  const RELAY_CIRCUIT_BREAKER_MS = 60_000;
@@ -132,6 +145,15 @@ let _lastPairingStateStatus = null;
132
145
  /** Last transport route that completed successfully. */
133
146
  let _lastSuccessfulRoute = null;
134
147
 
148
+ /** Last time a LAN request completed successfully. */
149
+ let _lastLanOkAtMs = 0;
150
+
151
+ /** Last time we tried a LAN probe while sticky relay was active. */
152
+ let _lastStickyLanProbeAtMs = 0;
153
+
154
+ /** Consecutive LAN transport failures since the last LAN success. */
155
+ let _consecutiveLanFailures = 0;
156
+
135
157
  /** Recent relay-level failures used to avoid burning relay/DO quota in loops. */
136
158
  let _relayFailureAtMs = [];
137
159
 
@@ -142,8 +164,10 @@ function newTelemetry() {
142
164
  return {
143
165
  lanOk: 0,
144
166
  lanFail: 0,
167
+ lanReachableAfterFailure: 0,
145
168
  relayOk: 0,
146
169
  relayFail: 0,
170
+ relaySuppressed: 0,
147
171
  lastLanFailAt: 0,
148
172
  lastRelayFailAt: 0,
149
173
  clientResets: 0,
@@ -440,6 +464,29 @@ function relayCircuitError(opts = {}) {
440
464
  return err;
441
465
  }
442
466
 
467
+ function shouldAutoProbeLanWhileSticky(now, opts = {}) {
468
+ if (opts.autoProbeLanWhileSticky === false) {
469
+ return false;
470
+ }
471
+ if (relayCircuitDelayMs(opts) > 0) {
472
+ return false;
473
+ }
474
+ return now - _lastStickyLanProbeAtMs >= AUTO_STICKY_LAN_PROBE_INTERVAL_MS;
475
+ }
476
+
477
+ function stickyLanProbeOpts(opts, autoProbe) {
478
+ if (!autoProbe) {
479
+ return opts;
480
+ }
481
+ if (opts.stickyLanProbeTimeoutMs != null) {
482
+ return opts;
483
+ }
484
+ return {
485
+ ...opts,
486
+ stickyLanProbeTimeoutMs: AUTO_STICKY_LAN_PROBE_TIMEOUT_MS,
487
+ };
488
+ }
489
+
443
490
  function recordRelayFailure(err, opts = {}) {
444
491
  const now = nowMs(opts);
445
492
  _relayFailureAtMs = _relayFailureAtMs.filter((at) => now - at <= RELAY_FAILURE_WINDOW_MS);
@@ -554,8 +601,11 @@ async function attemptLanFetch(url, init, opts) {
554
601
  ? await Promise.race([fetchPromise, timeoutPromise])
555
602
  : await fetchPromise;
556
603
  _telemetry.lanOk++;
604
+ _lastLanOkAtMs = nowMs(opts);
605
+ _consecutiveLanFailures = 0;
557
606
  _lastSuccessfulRoute = "lan";
558
607
  _stickyRelayUntilMs = 0;
608
+ _lastStickyLanProbeAtMs = 0;
559
609
  resetRelayFailureCircuit();
560
610
  closeRelayClient("lan connected");
561
611
  emitRoutingStatus("lan-connected", opts, { url: String(url || "") });
@@ -566,12 +616,17 @@ async function attemptLanFetch(url, init, opts) {
566
616
  throw err;
567
617
  }
568
618
  const lanErr = didTimeout ? new TypeError("LAN fetch timed out") : err;
619
+ const failedAt = nowMs(opts);
569
620
  _telemetry.lanFail++;
570
- _telemetry.lastLanFailAt = nowMs(opts);
571
- _stickyRelayUntilMs = _telemetry.lastLanFailAt + STICKY_RELAY_MS;
621
+ _consecutiveLanFailures++;
622
+ _telemetry.lastLanFailAt = failedAt;
623
+ _lastStickyLanProbeAtMs = failedAt;
624
+ _stickyRelayUntilMs = failedAt + stickyRelayWindowAfterLanFailure(failedAt);
572
625
  emitRoutingStatus("lan-failed", opts, {
573
626
  url: String(url || ""),
574
627
  reason: lanErr?.message || String(lanErr),
628
+ consecutiveLanFailures: _consecutiveLanFailures,
629
+ stickyRelayMs: Math.max(0, _stickyRelayUntilMs - failedAt),
575
630
  });
576
631
  return { ok: false, err: lanErr };
577
632
  } finally {
@@ -582,8 +637,71 @@ async function attemptLanFetch(url, init, opts) {
582
637
  }
583
638
  }
584
639
 
640
+ function stickyRelayWindowAfterLanFailure(now) {
641
+ const recentLanOk = _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
642
+ if (recentLanOk && _consecutiveLanFailures <= TRANSIENT_LAN_FAILURE_THRESHOLD) {
643
+ return TRANSIENT_LAN_FAILURE_STICKY_MS;
644
+ }
645
+ return STICKY_RELAY_MS;
646
+ }
647
+
648
+ function shouldDelayRelayAfterRecentLanFailure(now, opts = {}) {
649
+ if (opts.delayRelayAfterRecentLanFailure === false) {
650
+ return false;
651
+ }
652
+ if (_lastLanOkAtMs <= 0 || now - _lastLanOkAtMs > RECENT_LAN_OK_GRACE_MS) {
653
+ return false;
654
+ }
655
+ return _consecutiveLanFailures > 0 &&
656
+ _consecutiveLanFailures <= RECENT_LAN_RELAY_SUPPRESSION_FAILURES;
657
+ }
658
+
659
+ function suppressRelayAfterRecentLanFailure(url, err, opts = {}) {
660
+ _telemetry.relaySuppressed++;
661
+ emitRoutingStatus("remote-delayed", opts, {
662
+ url: String(url || ""),
663
+ reason: err?.message || String(err || "LAN failure"),
664
+ consecutiveLanFailures: _consecutiveLanFailures,
665
+ recentLanOkAtMs: _lastLanOkAtMs,
666
+ });
667
+ }
668
+
669
+ function shouldConfirmLanReachability(now, opts = {}) {
670
+ if (opts.confirmLanReachabilityBeforeRelay === false) {
671
+ return false;
672
+ }
673
+ return _lastLanOkAtMs > 0 && now - _lastLanOkAtMs <= RECENT_LAN_OK_GRACE_MS;
674
+ }
675
+
676
+ async function confirmLanUnavailableBeforeRelay(url, err, opts = {}) {
677
+ if (!shouldConfirmLanReachability(nowMs(opts), opts)) {
678
+ return false;
679
+ }
680
+ const path = urlToRelayPath(url);
681
+ if (path === LAN_REACHABILITY_PROBE_PATH) {
682
+ return false;
683
+ }
684
+
685
+ const probe = await attemptLanFetch(LAN_REACHABILITY_PROBE_PATH, {
686
+ cache: "no-store",
687
+ credentials: "same-origin",
688
+ headers: { Accept: "application/json" },
689
+ }, {
690
+ ...opts,
691
+ lanTimeoutMs: opts.lanReachabilityProbeTimeoutMs ?? LAN_REACHABILITY_PROBE_TIMEOUT_MS,
692
+ });
693
+ if (!probe.ok) {
694
+ return false;
695
+ }
696
+
697
+ _telemetry.lanReachableAfterFailure++;
698
+ suppressRelayAfterRecentLanFailure(url, err, opts);
699
+ return true;
700
+ }
701
+
585
702
  async function attemptStickyLanProbe(url, init, opts) {
586
703
  const probeTimeoutMs = opts.stickyLanProbeTimeoutMs ?? DEFAULT_STICKY_LAN_PROBE_TIMEOUT_MS;
704
+ _lastStickyLanProbeAtMs = nowMs(opts);
587
705
  const lan = await attemptLanFetch(url, init, {
588
706
  ...opts,
589
707
  lanTimeoutMs: probeTimeoutMs,
@@ -859,7 +977,11 @@ function nowMs(opts) {
859
977
  * suppressRoutingStatus?: boolean,
860
978
  * preferRelayError?: boolean,
861
979
  * probeLanWhileSticky?: boolean,
980
+ * autoProbeLanWhileSticky?: boolean,
981
+ * delayRelayAfterRecentLanFailure?: boolean,
982
+ * confirmLanReachabilityBeforeRelay?: boolean,
862
983
  * stickyLanProbeTimeoutMs?: number,
984
+ * lanReachabilityProbeTimeoutMs?: number,
863
985
  * }} [opts]
864
986
  * @returns {Promise<{
865
987
  * ok: boolean,
@@ -875,9 +997,24 @@ export async function routedFetch(url, init = {}, opts = {}) {
875
997
 
876
998
  // Sticky-relay path: LAN just failed, prefer relay for a while.
877
999
  if (_stickyRelayUntilMs > t) {
878
- if (opts.probeLanWhileSticky === true) {
879
- const lan = await attemptStickyLanProbe(url, init, opts);
1000
+ if (shouldDelayRelayAfterRecentLanFailure(t, opts)) {
1001
+ const lan = await attemptLanFetch(url, init, opts);
1002
+ if (lan.ok) return lan.response;
1003
+ if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1004
+ suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1005
+ throw lan.err;
1006
+ }
1007
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1008
+ throw lan.err;
1009
+ }
1010
+ }
1011
+ const autoProbe = shouldAutoProbeLanWhileSticky(t, opts);
1012
+ if (opts.probeLanWhileSticky === true || autoProbe) {
1013
+ const lan = await attemptStickyLanProbe(url, init, stickyLanProbeOpts(opts, autoProbe));
880
1014
  if (lan.ok) return lan.response;
1015
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1016
+ throw lan.err;
1017
+ }
881
1018
  }
882
1019
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: true });
883
1020
  const r = await attemptRelayFetch(url, init, opts);
@@ -897,6 +1034,13 @@ export async function routedFetch(url, init = {}, opts = {}) {
897
1034
  // Happy path: LAN first.
898
1035
  const lan = await attemptLanFetch(url, init, opts);
899
1036
  if (lan.ok) return lan.response;
1037
+ if (shouldDelayRelayAfterRecentLanFailure(nowMs(opts), opts)) {
1038
+ suppressRelayAfterRecentLanFailure(url, lan.err, opts);
1039
+ throw lan.err;
1040
+ }
1041
+ if (await confirmLanUnavailableBeforeRelay(url, lan.err, opts)) {
1042
+ throw lan.err;
1043
+ }
900
1044
 
901
1045
  // Try relay once before giving up.
902
1046
  emitRoutingStatus("remote-switching", opts, { url: String(url || ""), sticky: false });
@@ -918,6 +1062,9 @@ export function __getTelemetry() {
918
1062
  ..._telemetry,
919
1063
  stickyRelayUntilMs: _stickyRelayUntilMs,
920
1064
  lastRoute: _lastSuccessfulRoute,
1065
+ lastLanOkAtMs: _lastLanOkAtMs,
1066
+ lastStickyLanProbeAtMs: _lastStickyLanProbeAtMs,
1067
+ consecutiveLanFailures: _consecutiveLanFailures,
921
1068
  hasClient: Boolean(_client),
922
1069
  clientPairingKey: _clientPairingKey,
923
1070
  relayCircuitOpenUntilMs: _relayCircuitOpenUntilMs,
@@ -946,6 +1093,9 @@ export function __resetForTest() {
946
1093
  _telemetry = newTelemetry();
947
1094
  _lastPairingStateStatus = null;
948
1095
  _lastSuccessfulRoute = null;
1096
+ _lastLanOkAtMs = 0;
1097
+ _lastStickyLanProbeAtMs = 0;
1098
+ _consecutiveLanFailures = 0;
949
1099
  _relayFailureAtMs = [];
950
1100
  _relayCircuitOpenUntilMs = 0;
951
1101
  }
@@ -953,5 +1103,8 @@ export function __resetForTest() {
953
1103
  // Test-visible constants for tests that want to assert behavior at the
954
1104
  // sticky-window boundary without hard-coding 5 minutes in two places.
955
1105
  export const __STICKY_RELAY_MS = STICKY_RELAY_MS;
1106
+ export const __AUTO_STICKY_LAN_PROBE_INTERVAL_MS = AUTO_STICKY_LAN_PROBE_INTERVAL_MS;
1107
+ export const __TRANSIENT_LAN_FAILURE_STICKY_MS = TRANSIENT_LAN_FAILURE_STICKY_MS;
956
1108
  export const __DEFAULT_RELAY_TIMEOUT_MS = DEFAULT_RELAY_TIMEOUT_MS;
957
1109
  export const __DEFAULT_LAN_TIMEOUT_MS = DEFAULT_LAN_TIMEOUT_MS;
1110
+ export const __RECENT_LAN_RELAY_SUPPRESSION_FAILURES = RECENT_LAN_RELAY_SUPPRESSION_FAILURES;