@iasiv5/dsh-quota-watch 0.1.18 → 0.1.20

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/README.md CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  ## ✨ 功能一览
16
16
 
17
- - **🚀 悬浮胶囊(默认界面)**:一行「GLM 15% · Copilot 1.8%」常驻屏幕(默认右下角安全区),单击展开面板——胶囊上没有任何隐藏手势(右键 / 长按菜单已于 0.1.14 退役)。拖拽跟手(保持抓取点,transform 渲染)、任意位置停放、位置跨重启记忆、越界自动钳回视口(距边 8px 安全边距);触摸设备直接拖拽(更宽的防误拖阈值)、点击热区 44px、自动避让 iPhone 刘海 / 小黑条(safe-area)。已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时轻微脉动(尊重系统「减少动态效果」设置)。悬停提示与 aria-label 实时给出摘要。
17
+ - **🚀 悬浮胶囊(默认界面)**:一行「GLM 15% · Copilot 1.8%」常驻屏幕(默认右下角安全区),单击展开面板——胶囊上没有任何隐藏手势(右键 / 长按菜单已于 0.1.14 退役)。拖拽跟手(保持抓取点,transform 渲染)、任意位置停放、位置跨重启记忆、越界自动钳回视口(距边 8px 安全边距),并自动避开 Windows Desktop 壳顶部的标题栏拖拽带(0.1.20:该带按布局吞掉指针输入、无视 z-index,胶囊/面板落进去就再也点不到、拖不回——现与壳自家浮窗同款消费 `--dsh-windows-titlebar-height` 让位,历史卡死位置在启动时自动钳回带外;Web 无此变量,行为零漂移);触摸设备直接拖拽(更宽的防误拖阈值)、点击热区 44px、自动避让 iPhone 刘海 / 小黑条(safe-area)。已用超过 80% 转橙、超过 95% 转红,任一 provider 超 95% 时轻微脉动(尊重系统「减少动态效果」设置)。悬停提示与 aria-label 实时给出摘要。
18
18
  - **🖥️ 详情面板**:半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area;Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:
19
19
  - **GLM**:今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;
20
20
  - **Copilot**:可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。
@@ -23,7 +23,7 @@
23
23
  - **🧩 侧边栏卡片(可选,默认隐藏)**:胶囊是默认界面;需要侧栏卡片时从面板底部按钮开启(Web profile 侧栏底部区块存在时才出现该按钮)。卡片提供摘要行 + 行点击开面板 + 行首 provider 名称点击立即探测。侧栏收起(Web 窄栏)或容器过窄(<160px,跨 profile 通用判据)时自动隐藏,侧栏整体不存在(Desktop 收起态)同样自然缺席、无报错——入口始终由悬浮胶囊承载。
24
24
  - **🌏 语言跟随宿主**:界面文案跟随 DSH「设置 → 语言」——宿主 locale 特性把解析结果实时镜像到 `<html lang>`,插件监听该属性,切换即热重渲染(无需刷新页面);宿主未同步时回退浏览器语言。
25
25
  - **🛟 降级与容错**:凭据缺失的 provider 不渲染摘要行与胶囊分段(两者皆缺时悬浮胶囊与卡片一并隐藏);探测失败时保留最后一次成功数据并在行上加 ⚠ 过期标记(⚠ 只出现在面板与卡片行内,不在胶囊上显示)。
26
- - **🔁 刷新节奏**:宿主默认每 60 秒探测(窗口 + 用量 + Copilot),profile 可配 30–3600 秒;浏览器页签可见时每 30 秒刷新视图,隐藏时暂停。所有 UI 收在 Shadow DOM 里(ADR 0001),颜色走 DSH 主题语义变量(含硬编码兜底),自动适配浅色 / 深色主题。
26
+ - **🔁 刷新节奏**:宿主默认每 60 秒探测(窗口 + 用量 + Copilot),profile 可配 30–3600 秒;浏览器页签可见时每 30 秒刷新视图,隐藏时暂停。重启冷启动零等待:宿主 0 秒即发首轮探测(网络未就绪时以 5 秒冷重试直至拿到数据),客户端在无数据期间以 2 秒起步的指数退避快速重试、拿到数据后回到常规节奏——重启后数秒内胶囊即出现,不再有 ~30 秒空窗。所有 UI 收在 Shadow DOM 里(ADR 0001),颜色走 DSH 主题语义变量(含硬编码兜底),自动适配浅色 / 深色主题。
27
27
 
28
28
  ## 📸 界面巡礼
29
29
 
@@ -71,4 +71,4 @@ dsh plugin --profile desktop add @iasiv5/dsh-quota-watch # desktop profile
71
71
 
72
72
  ## English summary
73
73
 
74
- DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and free to place anywhere on the screen, with position remembered across restarts and viewport boundaries automatically clamped. Touch devices drag directly (wider slop against accidental drags), the hit area is padded to 44px and placement respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. The panel footer holds two equal-width buttons — sidebar-card visibility and an immediate provider re-probe — replacing the retired right-click/long-press menu, so the capsule itself carries no hidden gestures. A sidebar card remains an opportunistic extra entry on web profiles, opted in from that footer button, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. UI copy follows the DSH language preference (mirrored live onto `<html lang>`, re-rendering on switch without a reload) and falls back to the browser language; window notes share one paren glyph per locale so the grid's left edges stay aligned. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
74
+ DSH Quota Watch is a personal DSH quota monitor built around a persistent floating capsule: a one-line `GLM x% · Copilot y%` summary that turns amber past 80% and red past 95%. Dragging it is grab-accurate (transform-based, rAF-batched) and free to place anywhere on the screen, with position remembered across restarts and viewport boundaries automatically clamped. Since 0.1.20 placement also steers clear of the Windows Desktop shell titlebar drag band (a layout-level `-webkit-app-region:drag` strip that swallows pointer input regardless of z-index — the same trap dsh-m fixed in 0.9.4; the capsule and the panel header consume `--dsh-windows-titlebar-height` the way the shell's own floats do, and positions saved while stuck self-heal below the band on boot; Web, without the variable, is byte-identical). Touch devices drag directly (wider slop against accidental drags), the hit area is padded to 44px and placement respects `env(safe-area-inset-*)`. Clicking the capsule opens a floating glass panel whose overview reuses the classic 0.0.18 summary rows (label · bar · percent · extra · chevron) and whose detail pages replicate the 0.0.18 big numbers, window grid and per-model list; the panel closes via ✕, Esc or an outside click, and viewports ≤480px dock it to the bottom as a sheet. The panel footer holds two equal-width buttons — sidebar-card visibility and an immediate provider re-probe — replacing the retired right-click/long-press menu, so the capsule itself carries no hidden gestures. A sidebar card remains an opportunistic extra entry on web profiles, opted in from that footer button, and silently disappears whenever the sidebar does — collapsed web sidebars and the desktop profile are covered by the floating capsule alone. Credentials are resolved on every host-side probe, only normalized values reach the browser, no usage history is kept, and a Copilot balance is never inferred from credits used. UI copy follows the DSH language preference (mirrored live onto `<html lang>`, re-rendering on switch without a reload) and falls back to the browser language; window notes share one paren glyph per locale so the grid's left edges stay aligned. Colors ride DSH semantic theme tokens with hardcoded fallbacks; all UI lives inside a shadow root (ADR 0001).
package/lib/client.js CHANGED
@@ -107,6 +107,11 @@ function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
107
107
  y: Math.min(Math.max(point.y, minY), maxY)
108
108
  };
109
109
  }
110
+ function titlebarTopInset({ titlebar = false, fullscreen = false, height } = {}) {
111
+ if (!titlebar || fullscreen) return 0;
112
+ const value = Number.parseFloat(height);
113
+ return Number.isFinite(value) && value > 0 ? value : 0;
114
+ }
110
115
  function loadFloatPosition(storage) {
111
116
  try {
112
117
  const raw = storage.getItem(FLOAT_POSITION_KEY);
@@ -470,7 +475,9 @@ function mountQuotaCard({
470
475
  doc = globalThis.document,
471
476
  win = globalThis.window,
472
477
  fetchImpl = globalThis.fetch,
473
- pollIntervalMs = CLIENT_POLL_INTERVAL_MS
478
+ pollIntervalMs = CLIENT_POLL_INTERVAL_MS,
479
+ retryNoDataMs = 2e3,
480
+ retryBackoff = 2
474
481
  } = {}) {
475
482
  if (!doc?.body || typeof fetchImpl !== "function") return () => {
476
483
  };
@@ -549,11 +556,23 @@ function mountQuotaCard({
549
556
  const safeAreaProbe = text(doc, "div", "", "");
550
557
  safeAreaProbe.style.cssText = "position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);";
551
558
  floatRoot.append(safeAreaProbe);
559
+ const readTitlebarInset = () => {
560
+ try {
561
+ const html = doc.documentElement;
562
+ return titlebarTopInset({
563
+ titlebar: html.hasAttribute("data-windows-titlebar"),
564
+ fullscreen: html.hasAttribute("data-fullscreen"),
565
+ height: parseInset(win.getComputedStyle(html)?.getPropertyValue("--dsh-windows-titlebar-height"))
566
+ });
567
+ } catch {
568
+ return 0;
569
+ }
570
+ };
552
571
  const readInsets = () => {
553
572
  try {
554
573
  const style2 = win.getComputedStyle(safeAreaProbe);
555
574
  return {
556
- top: parseInset(style2?.paddingTop),
575
+ top: Math.max(parseInset(style2?.paddingTop), readTitlebarInset()),
557
576
  right: parseInset(style2?.paddingRight),
558
577
  bottom: parseInset(style2?.paddingBottom),
559
578
  left: parseInset(style2?.paddingLeft)
@@ -670,11 +689,12 @@ function mountQuotaCard({
670
689
  let snapshot;
671
690
  let requestSequence = 0;
672
691
  let timer;
692
+ let noDataRetries = 0;
673
693
  let disposed = false;
674
694
  let queuedPlace = false;
675
695
  let openKey;
676
696
  let panelNav = false;
677
- let dataHidden = false;
697
+ let dataHidden = true;
678
698
  let surfaceFlags = loadSurfaceFlags(win.localStorage);
679
699
  const setCardHidden = (hidden) => {
680
700
  surfaceFlags = { cardHidden: hidden };
@@ -915,7 +935,8 @@ function mountQuotaCard({
915
935
  if (left + width > viewWidth - 8) left = rect.left - width - 8;
916
936
  if (left + width > viewWidth - 8) left = viewWidth - width - 8;
917
937
  left = Math.max(8, left);
918
- const top = Math.min(Math.max(rect.top, 8), Math.max(8, viewHeight - height - 8));
938
+ const minTop = MARGIN + readInsets().top;
939
+ const top = Math.min(Math.max(rect.top, minTop), Math.max(minTop, viewHeight - height - 8));
919
940
  panel.style.left = `${Math.round(left)}px`;
920
941
  panel.style.top = `${Math.round(top)}px`;
921
942
  };
@@ -1059,6 +1080,13 @@ function mountQuotaCard({
1059
1080
  if (sequence === requestSequence && !disposed) render(next);
1060
1081
  } catch {
1061
1082
  if (sequence === requestSequence && !disposed) showTransportError();
1083
+ } finally {
1084
+ if (timer === void 0 && sequence === requestSequence && !disposed && doc.visibilityState !== "hidden") {
1085
+ timer = win.setTimeout(() => {
1086
+ timer = void 0;
1087
+ if (!disposed && doc.visibilityState !== "hidden") void poll();
1088
+ }, nextPollDelay());
1089
+ }
1062
1090
  }
1063
1091
  };
1064
1092
  const place = () => {
@@ -1089,16 +1117,23 @@ function mountQuotaCard({
1089
1117
  if (!disposed) renderAll();
1090
1118
  }) : null;
1091
1119
  langObserver?.observe(doc.documentElement, { attributeFilter: ["lang"] });
1120
+ syncFloatVisibility();
1092
1121
  place();
1122
+ const nextPollDelay = () => {
1123
+ if (!dataHidden) {
1124
+ noDataRetries = 0;
1125
+ return pollIntervalMs;
1126
+ }
1127
+ const delay = retryNoDataMs * retryBackoff ** noDataRetries;
1128
+ noDataRetries += 1;
1129
+ return Math.min(delay, pollIntervalMs);
1130
+ };
1093
1131
  const startPolling = () => {
1094
1132
  if (timer !== void 0 || doc.visibilityState === "hidden" || disposed) return;
1095
1133
  void poll();
1096
- timer = win.setInterval(() => {
1097
- void poll();
1098
- }, pollIntervalMs);
1099
1134
  };
1100
1135
  const stopPolling = () => {
1101
- if (timer !== void 0) win.clearInterval(timer);
1136
+ if (timer !== void 0) win.clearTimeout(timer);
1102
1137
  timer = void 0;
1103
1138
  };
1104
1139
  const onVisibilityChange = () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "DSH Web sidebar monitor for GLM Coding Plan quotas and GitHub Copilot credits",
5
5
  "type": "module",
6
6
  "main": "./src/host.mjs",
@@ -31,6 +31,20 @@ export function clampPoint(point, viewport, size = 0, insets = ZERO_INSETS) {
31
31
  }
32
32
  }
33
33
 
34
+ // Windows Desktop shell titlebar contract (0.1.20): the shell runs
35
+ // `titleBarStyle:hidden + titleBarOverlay`, so the top strip of the window is a
36
+ // full-width `-webkit-app-region:drag` band that swallows pointer input by
37
+ // LAYOUT — z-index and paint order are irrelevant — and a capsule parked inside
38
+ // it can never be grabbed or clicked again. The shell flags the band on
39
+ // <html> with `data-windows-titlebar` + `--dsh-windows-titlebar-height` and
40
+ // clears it under `data-fullscreen`; its own floats clamp to exactly this
41
+ // value. Everything else (DSH Web, browsers, jsdom) yields 0 — zero drift.
42
+ export function titlebarTopInset({ titlebar = false, fullscreen = false, height } = {}) {
43
+ if (!titlebar || fullscreen) return 0
44
+ const value = Number.parseFloat(height)
45
+ return Number.isFinite(value) && value > 0 ? value : 0
46
+ }
47
+
34
48
  export function loadFloatPosition(storage) {
35
49
  try {
36
50
  const raw = storage.getItem(FLOAT_POSITION_KEY)
package/src/client.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CLIENT_POLL_INTERVAL_MS, CLIENT_ROUTES } from './shared.mjs'
2
2
  import { remainingToUsed } from './core/adapters.mjs'
3
- import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosition, saveSurfaceFlags } from './client/prefs.mjs'
3
+ import { MARGIN, clampPoint, loadFloatPosition, loadSurfaceFlags, saveFloatPosition, saveSurfaceFlags, titlebarTopInset } from './client/prefs.mjs'
4
4
  import { DRAG_SLOP_MOUSE, DRAG_SLOP_TOUCH, parseInset, clampFrame, dragSlop, grabOffset } from './client/drag.mjs'
5
5
 
6
6
  export const name = 'quota-watch-client'
@@ -373,6 +373,8 @@ export function mountQuotaCard({
373
373
  win = globalThis.window,
374
374
  fetchImpl = globalThis.fetch,
375
375
  pollIntervalMs = CLIENT_POLL_INTERVAL_MS,
376
+ retryNoDataMs = 2_000,
377
+ retryBackoff = 2,
376
378
  } = {}) {
377
379
  if (!doc?.body || typeof fetchImpl !== 'function') return () => {}
378
380
  if (doc.querySelector(CARD_SELECTOR) || doc.querySelector(FLOAT_SELECTOR)) return () => {}
@@ -448,7 +450,8 @@ export function mountQuotaCard({
448
450
  const rect = surface.getBoundingClientRect()
449
451
  return rect.width > 0 ? { width: rect.width, height: rect.height } : FALLBACK_SIZE
450
452
  }
451
- /** Position = user preferred {x, y} clamped to viewport & safe-area insets. */
453
+ /** Position = user preferred {x, y} clamped to viewport, safe-area insets
454
+ * and the Windows shell titlebar band. */
452
455
  const applyPosition = () => {
453
456
  const viewWidth = win.innerWidth ?? 1024
454
457
  const viewHeight = win.innerHeight ?? 768
@@ -469,13 +472,37 @@ export function mountQuotaCard({
469
472
  const safeAreaProbe = text(doc, 'div', '', '')
470
473
  safeAreaProbe.style.cssText = 'position:fixed;visibility:hidden;pointer-events:none;width:0;height:0;overflow:hidden;padding:env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);'
471
474
  floatRoot.append(safeAreaProbe)
475
+ // Windows Desktop titlebar band (0.1.20): with `titleBarStyle:hidden +
476
+ // titleBarOverlay` the top strip of the window is the shell's full-width
477
+ // `-webkit-app-region:drag` band — it wins pointer input by layout, ignoring
478
+ // z-index, so a capsule dropped there is stuck for good (un-grabbable,
479
+ // un-clickable, and the persisted position kept re-parking it). Folding the
480
+ // band into the top inset routes it through every clamp path at once: live
481
+ // drag frames, release docking, persisted-position restore and the default
482
+ // dock — and mount-time clamping also self-heals positions saved by 0.1.19.
483
+ // Same contract the shell's own floats consume: `data-windows-titlebar` +
484
+ // `--dsh-windows-titlebar-height` on <html>, dropped under `data-fullscreen`.
485
+ // DSH Web / browsers have neither attribute nor variable → 0, zero drift.
486
+ const readTitlebarInset = () => {
487
+ try {
488
+ const html = doc.documentElement
489
+ return titlebarTopInset({
490
+ titlebar: html.hasAttribute('data-windows-titlebar'),
491
+ fullscreen: html.hasAttribute('data-fullscreen'),
492
+ height: parseInset(win.getComputedStyle(html)?.getPropertyValue('--dsh-windows-titlebar-height')),
493
+ })
494
+ } catch {
495
+ return 0
496
+ }
497
+ }
472
498
  /** Live env(safe-area-inset-*) reading — re-probed on every use, so window
473
- * chrome changes (rotation, URL-bar collapse) need no explicit invalidation. */
499
+ * chrome changes (rotation, URL-bar collapse) need no explicit invalidation.
500
+ * `top` is the larger of the safe-area inset and the shell titlebar band. */
474
501
  const readInsets = () => {
475
502
  try {
476
503
  const style = win.getComputedStyle(safeAreaProbe)
477
504
  return {
478
- top: parseInset(style?.paddingTop),
505
+ top: Math.max(parseInset(style?.paddingTop), readTitlebarInset()),
479
506
  right: parseInset(style?.paddingRight),
480
507
  bottom: parseInset(style?.paddingBottom),
481
508
  left: parseInset(style?.paddingLeft),
@@ -610,11 +637,15 @@ export function mountQuotaCard({
610
637
  let snapshot
611
638
  let requestSequence = 0
612
639
  let timer
640
+ let noDataRetries = 0
613
641
  let disposed = false
614
642
  let queuedPlace = false
615
643
  let openKey
616
644
  let panelNav = false
617
- let dataHidden = false
645
+ // Cold until the first successful render: a mount whose first poll fails or
646
+ // stalls must behave like the documented no-data state (hidden shell, fast
647
+ // retries), not like a warm client showing an empty capsule.
648
+ let dataHidden = true
618
649
  let surfaceFlags = loadSurfaceFlags(win.localStorage)
619
650
  const setCardHidden = (hidden) => {
620
651
  surfaceFlags = { cardHidden: hidden }
@@ -886,7 +917,11 @@ export function mountQuotaCard({
886
917
  if (left + width > viewWidth - 8) left = rect.left - width - 8
887
918
  if (left + width > viewWidth - 8) left = viewWidth - width - 8
888
919
  left = Math.max(8, left)
889
- const top = Math.min(Math.max(rect.top, 8), Math.max(8, viewHeight - height - 8))
920
+ // The panel header carries the close button: keep its top edge below the
921
+ // shell titlebar band (and safe-area top) for the same reason as the
922
+ // capsule — layout-level drag hits ignore z-index. Web fallback: 8px, as before.
923
+ const minTop = MARGIN + readInsets().top
924
+ const top = Math.min(Math.max(rect.top, minTop), Math.max(minTop, viewHeight - height - 8))
890
925
  panel.style.left = `${Math.round(left)}px`
891
926
  panel.style.top = `${Math.round(top)}px`
892
927
  }
@@ -1052,6 +1087,18 @@ export function mountQuotaCard({
1052
1087
  if (sequence === requestSequence && !disposed) render(next)
1053
1088
  } catch {
1054
1089
  if (sequence === requestSequence && !disposed) showTransportError()
1090
+ } finally {
1091
+ // Self-scheduling chain (replaces the fixed interval): every completed
1092
+ // poll — success OR transport error — arms the next one, so the no-data
1093
+ // backoff also covers a cold start whose first request fails outright.
1094
+ // timer === undefined guards against a concurrent force poll forking a
1095
+ // second chain; the already-armed timer keeps the cadence.
1096
+ if (timer === undefined && sequence === requestSequence && !disposed && doc.visibilityState !== 'hidden') {
1097
+ timer = win.setTimeout(() => {
1098
+ timer = undefined
1099
+ if (!disposed && doc.visibilityState !== 'hidden') void poll()
1100
+ }, nextPollDelay())
1101
+ }
1055
1102
  }
1056
1103
  }
1057
1104
 
@@ -1091,15 +1138,33 @@ export function mountQuotaCard({
1091
1138
  ? new win.MutationObserver(() => { if (!disposed) renderAll() })
1092
1139
  : null
1093
1140
  langObserver?.observe(doc.documentElement, { attributeFilter: ['lang'] })
1141
+ // Cold until data lands: keep the shell hidden so a slow or failed first
1142
+ // probe never flashes an empty capsule (startPolling arms the retry chain).
1143
+ syncFloatVisibility()
1094
1144
  place()
1095
1145
 
1146
+ /** Cold-start cadence: while no provider has rendered any data, re-poll on
1147
+ * a shortening backoff (retryNoDataMs × retryBackoffⁿ, capped at
1148
+ * pollIntervalMs) so a desktop cold start surfaces the capsule in seconds
1149
+ * instead of waiting out the fixed interval. A successful render resets the
1150
+ * chain to the normal cadence; a transport error keeps the previous
1151
+ * dataHidden state, so a cold client keeps retrying fast and a warm one
1152
+ * stays calm. */
1153
+ const nextPollDelay = () => {
1154
+ if (!dataHidden) {
1155
+ noDataRetries = 0
1156
+ return pollIntervalMs
1157
+ }
1158
+ const delay = retryNoDataMs * (retryBackoff ** noDataRetries)
1159
+ noDataRetries += 1
1160
+ return Math.min(delay, pollIntervalMs)
1161
+ }
1096
1162
  const startPolling = () => {
1097
1163
  if (timer !== undefined || doc.visibilityState === 'hidden' || disposed) return
1098
1164
  void poll()
1099
- timer = win.setInterval(() => { void poll() }, pollIntervalMs)
1100
1165
  }
1101
1166
  const stopPolling = () => {
1102
- if (timer !== undefined) win.clearInterval(timer)
1167
+ if (timer !== undefined) win.clearTimeout(timer)
1103
1168
  timer = undefined
1104
1169
  }
1105
1170
  const onVisibilityChange = () => {
package/src/host.mjs CHANGED
@@ -311,7 +311,18 @@ export function createQuotaService(ctx, options = {}) {
311
311
  timer = setTimeout(async () => {
312
312
  timer = undefined
313
313
  await refresh()
314
- schedule(pollIntervalSec * 1000)
314
+ // Cold-start retry: until at least one provider has reached ready/stale,
315
+ // a failed or still-loading round re-arms shortly instead of waiting a
316
+ // full interval — a desktop cold start with a slow network would
317
+ // otherwise stare at no capsule for up to a minute. (updatedAt alone
318
+ // cannot signal "cold": refresh() stamps it even when every probe
319
+ // failed.) All-'missing' (no credentials configured) and warm states
320
+ // keep the normal cadence, so nobody without credentials gets
321
+ // high-frequency probing.
322
+ const states = [...providers.values()].map((provider) => provider.status)
323
+ const retryable = states.some((status) => status === 'loading' || status === 'error')
324
+ && states.every((status) => status !== 'ready' && status !== 'stale')
325
+ schedule(retryable ? (options.coldRetryMs ?? 5_000) : pollIntervalSec * 1000)
315
326
  }, delayMs)
316
327
  timer.unref?.()
317
328
  }
@@ -321,7 +332,9 @@ export function createQuotaService(ctx, options = {}) {
321
332
  refresh,
322
333
  start() {
323
334
  if (disposed || timer !== undefined) return
324
- schedule(2_000)
335
+ // Routes register synchronously in apply() before start(), so probing
336
+ // immediately is safe; the legacy 2s delay only padded the cold start.
337
+ schedule(0)
325
338
  },
326
339
  stop() {
327
340
  disposed = true