@iasiv5/dsh-quota-watch 0.1.20 → 0.1.22

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
@@ -15,7 +15,7 @@
15
15
  ## ✨ 功能一览
16
16
 
17
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
- - **🖥️ 详情面板**:半透明毛玻璃底,宽 `min(320px, 100vw−24px)`,靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area;Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:
18
+ - **🖥️ 详情面板**:半透明毛玻璃底;总览(Token 额度)宽 `240px` 与侧边栏卡片同宽(窄屏 `min(240px, 100vw−24px)` 兜底),点进 GLM / Copilot 详情后恢复 0.0.18 内容自适应宽度(上限 `min(320px, 100vw−24px)`),靠边自动翻侧,滚动 / 缩放跟随;≤480px 窄屏自动改为底部弹层并避开 safe-area;Esc 或点击面板外关闭。概览列两个 provider 行,点击进入详情:
19
19
  - **GLM**:今日 Tokens / 调用次数双大数字 + 五列对齐窗口网格(MCP(月)→ 5 小时 → 每月 → 每周固定排序,周窗口未下发时显示「每周额度 · ♾️(无限)」占位)+ 分模型用量行;
20
20
  - **Copilot**:可用额度、本期已用(credits 消耗 + 弱化百分比)、重置时间三行。
21
21
  - 窗口备注的括号按语言成对统一(zh 全角 `(…)` / en 半角 `(…)`),备注列左缘恒对齐(0.1.16)。接口未下发有效 `remaining` / `percent_remaining` 时该行不渲染,`credits_used` 绝不用于推算余额。
package/lib/client.js CHANGED
@@ -199,6 +199,7 @@ var COPY = {
199
199
  loading: "\u8BFB\u53D6\u4E2D",
200
200
  failed: "\u67E5\u8BE2\u5931\u8D25",
201
201
  stale: "\u6570\u636E\u53EF\u80FD\u5DF2\u8FC7\u671F",
202
+ authRejected: "\u51ED\u8BC1\u5DF2\u88AB\u670D\u52A1\u5546\u62D2\u7EDD\uFF0C\u8BF7\u91CD\u65B0\u6388\u6743",
202
203
  requestFailed: "\u5237\u65B0\u5931\u8D25\uFF0C\u663E\u793A\u4E0A\u6B21\u6210\u529F\u6570\u636E",
203
204
  available: "\u53EF\u7528\u989D\u5EA6",
204
205
  used: "\u672C\u671F\u5DF2\u7528",
@@ -236,6 +237,7 @@ var COPY = {
236
237
  loading: "Loading",
237
238
  failed: "Query failed",
238
239
  stale: "Data may be stale",
240
+ authRejected: "Credential rejected by provider \u2014 re-authorize",
239
241
  requestFailed: "Refresh failed; showing last successful data",
240
242
  available: "Available credits",
241
243
  used: "Used this cycle",
@@ -311,6 +313,11 @@ var FLOAT_STYLE_TEXT = `
311
313
  .dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
312
314
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
313
315
  .dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
316
+ /* Overview-only pin: the capsule pop matches the sidebar card (240px); detail
317
+ views (GLM/Copilot) keep the 0.0.18 content-driven width above. :not() keeps
318
+ the bottom sheet (\u2264480px) winning over the pin. Keyed off the data attr that
319
+ syncPop already maintains \u2014 no JS state change. */
320
+ .dqw-panel[data-dsh-quota-watch-panel-overview]:not(.dqw-panel--sheet) { width: min(240px, calc(100vw - 24px)); }
314
321
  .dqw-panel[hidden] { display: none; }
315
322
  .dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
316
323
  .dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
@@ -330,7 +337,7 @@ var FLOAT_STYLE_TEXT = `
330
337
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
331
338
  .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
332
339
  .dqw-label[data-action="refresh"]:hover { opacity: .75; }
333
- .dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
340
+ .dqw-bar { display: block; flex: 1 1 auto; width: auto; min-width: 36px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
334
341
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
335
342
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
336
343
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
@@ -351,7 +358,7 @@ var FLOAT_STYLE_TEXT = `
351
358
  .dqw-kv { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
352
359
  .dqw-kv-label { flex: none; opacity: .65; }
353
360
  .dqw-kv-value { min-width: 0; text-align: right; font-weight: 600; }
354
- .dqw-kv-sub { font-weight: 400; opacity: .55; }
361
+ .dqw-kv-sub { min-width: 0; overflow-wrap: anywhere; font-weight: 400; opacity: .55; }
355
362
  .dqw-wins { display: grid; grid-template-columns: max-content max-content max-content max-content 1fr; column-gap: 6px; row-gap: 3px; align-items: baseline; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
356
363
  .dqw-win-name { opacity: .65; }
357
364
  .dqw-win-mid { opacity: .65; white-space: nowrap; }
@@ -428,7 +435,7 @@ function kvRow(doc, label, value) {
428
435
  }
429
436
  function glmRowModel(provider) {
430
437
  if (provider?.status === "missing") return null;
431
- if (provider?.status === "error") return { kind: "error", label: "GLM", error: provider.error };
438
+ if (provider?.status === "error") return { kind: "error", label: "GLM", error: provider.error, errorKind: provider.errorKind };
432
439
  const windows = provider?.plan?.windows;
433
440
  if (!Array.isArray(windows) || windows.length === 0) return null;
434
441
  const five = windows.find((window) => window?.key === "5h");
@@ -439,12 +446,13 @@ function glmRowModel(provider) {
439
446
  percent: five?.percent,
440
447
  stale: provider.status === "stale",
441
448
  error: provider.error,
449
+ errorKind: provider.errorKind,
442
450
  extra: tokens
443
451
  };
444
452
  }
445
453
  function copilotRowModel(provider) {
446
454
  if (provider?.status === "missing") return null;
447
- if (provider?.status === "error") return { kind: "error", label: "Copilot", error: provider.error };
455
+ if (provider?.status === "error") return { kind: "error", label: "Copilot", error: provider.error, errorKind: provider.errorKind };
448
456
  const quota = provider?.quota;
449
457
  if (quota?.balanceAvailable !== true) return null;
450
458
  const used = remainingToUsed(quota.percentRemaining);
@@ -455,6 +463,7 @@ function copilotRowModel(provider) {
455
463
  percent: used,
456
464
  stale: provider.status === "stale",
457
465
  error: provider.error,
466
+ errorKind: provider.errorKind,
458
467
  extra: typeof quota.creditsUsed === "number" && quota.creditsUsed >= 0 ? quota.creditsUsed : void 0
459
468
  };
460
469
  }
@@ -735,7 +744,7 @@ function mountQuotaCard({
735
744
  if (model.stale) {
736
745
  const mark = text(doc, "span", "dqw-stale-mark", "\u26A0");
737
746
  mark.dataset.dshQuotaWatchStale = "";
738
- mark.title = model.error ?? copy.stale;
747
+ mark.title = model.errorKind === "auth" && model.error ? `${copy.authRejected}: ${model.error}` : model.error ?? copy.stale;
739
748
  row.append(mark);
740
749
  }
741
750
  row.append(text(doc, "span", "dqw-pct", typeof model.percent === "number" ? formatPercent(model.percent, locale) : "\u2014"));
@@ -819,7 +828,8 @@ function mountQuotaCard({
819
828
  detail.replaceChildren();
820
829
  detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav }));
821
830
  if (provider?.status === "stale") {
822
- detail.append(text(doc, "p", "dqw-error", `${copy.stale}${provider.error ? `: ${provider.error}` : ""}`));
831
+ const bannerPrefix = provider.errorKind === "auth" ? copy.authRejected : copy.stale;
832
+ detail.append(text(doc, "p", "dqw-error", `${bannerPrefix}${provider.error ? `: ${provider.error}` : ""}`));
823
833
  }
824
834
  if (key === "glm") {
825
835
  const usage = provider?.usage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iasiv5/dsh-quota-watch",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
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",
package/src/client.mjs CHANGED
@@ -34,6 +34,7 @@ const COPY = {
34
34
  loading: '读取中',
35
35
  failed: '查询失败',
36
36
  stale: '数据可能已过期',
37
+ authRejected: '凭证已被服务商拒绝,请重新授权',
37
38
  requestFailed: '刷新失败,显示上次成功数据',
38
39
  available: '可用额度',
39
40
  used: '本期已用',
@@ -71,6 +72,7 @@ const COPY = {
71
72
  loading: 'Loading',
72
73
  failed: 'Query failed',
73
74
  stale: 'Data may be stale',
75
+ authRejected: 'Credential rejected by provider — re-authorize',
74
76
  requestFailed: 'Refresh failed; showing last successful data',
75
77
  available: 'Available credits',
76
78
  used: 'Used this cycle',
@@ -153,6 +155,11 @@ const FLOAT_STYLE_TEXT = `
153
155
  .dqw-panel { position: fixed; z-index: 2147483000; min-width: 166px; max-width: min(320px, calc(100vw - 24px)); max-height: calc(100vh - 24px); overflow-y: auto; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-secondary, rgba(128,128,128,.35)); border-radius: 10px; background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); background: color-mix(in srgb, var(--dsw-alias-bg-base, #1f1f1f) 86%, transparent); -webkit-backdrop-filter: blur(14px) saturate(1.3); backdrop-filter: blur(14px) saturate(1.3); box-shadow: 0 8px 24px rgba(0,0,0,.25); color: var(--dsw-alias-label-primary, inherit); display: flex; flex-direction: column; gap: 8px; font: inherit; }
154
156
  @supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) { .dqw-panel { background: var(--dsw-alias-bg-elevated, var(--dsw-alias-bg-base, #1f1f1f)); } }
155
157
  .dqw-panel--sheet { left:8px; right:8px; top:auto; bottom:calc(8px + env(safe-area-inset-bottom, 0px)); width:auto; max-width:none; max-height:calc(100dvh - 24px); }
158
+ /* Overview-only pin: the capsule pop matches the sidebar card (240px); detail
159
+ views (GLM/Copilot) keep the 0.0.18 content-driven width above. :not() keeps
160
+ the bottom sheet (≤480px) winning over the pin. Keyed off the data attr that
161
+ syncPop already maintains — no JS state change. */
162
+ .dqw-panel[data-dsh-quota-watch-panel-overview]:not(.dqw-panel--sheet) { width: min(240px, calc(100vw - 24px)); }
156
163
  .dqw-panel[hidden] { display: none; }
157
164
  .dqw-panel *, .dqw-panel *::before, .dqw-panel *::after { box-sizing: border-box; }
158
165
  .dqw-panel-header { display: flex; align-items: center; gap: 6px; min-width: 0; }
@@ -172,7 +179,7 @@ const FLOAT_STYLE_TEXT = `
172
179
  .dqw-label { font-size: 11px; font-weight: 600; width: 52px; flex: none; }
173
180
  .dqw-label[data-action="refresh"] { cursor: pointer; border-radius: 4px; }
174
181
  .dqw-label[data-action="refresh"]:hover { opacity: .75; }
175
- .dqw-bar { display: block; flex: 0 0 auto; width: 100px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
182
+ .dqw-bar { display: block; flex: 1 1 auto; width: auto; min-width: 36px; height: 4px; overflow: hidden; border-radius: 4px; background: var(--dsw-alias-bg-tertiary, rgba(128,128,128,.2)); }
176
183
  .dqw-bar-fill { display: block; height: 100%; border-radius: inherit; background: var(--dsw-alias-button-primary-fill, #5b8def); }
177
184
  .dqw-bar-fill.warn { background: var(--dsw-alias-label-warning, #d29922); }
178
185
  .dqw-bar-fill.danger { background: var(--dsw-alias-label-danger, #c93c3c); }
@@ -193,7 +200,7 @@ const FLOAT_STYLE_TEXT = `
193
200
  .dqw-kv { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
194
201
  .dqw-kv-label { flex: none; opacity: .65; }
195
202
  .dqw-kv-value { min-width: 0; text-align: right; font-weight: 600; }
196
- .dqw-kv-sub { font-weight: 400; opacity: .55; }
203
+ .dqw-kv-sub { min-width: 0; overflow-wrap: anywhere; font-weight: 400; opacity: .55; }
197
204
  .dqw-wins { display: grid; grid-template-columns: max-content max-content max-content max-content 1fr; column-gap: 6px; row-gap: 3px; align-items: baseline; font-size: 10.5px; line-height: 17px; font-variant-numeric: tabular-nums; }
198
205
  .dqw-win-name { opacity: .65; }
199
206
  .dqw-win-mid { opacity: .65; white-space: nowrap; }
@@ -317,7 +324,7 @@ function kvRow(doc, label, value) {
317
324
  /** GLM summary row view model: fixed 5h-window percent plus today's compact tokens. */
318
325
  function glmRowModel(provider) {
319
326
  if (provider?.status === 'missing') return null
320
- if (provider?.status === 'error') return { kind: 'error', label: 'GLM', error: provider.error }
327
+ if (provider?.status === 'error') return { kind: 'error', label: 'GLM', error: provider.error, errorKind: provider.errorKind }
321
328
  const windows = provider?.plan?.windows
322
329
  if (!Array.isArray(windows) || windows.length === 0) return null
323
330
  const five = windows.find((window) => window?.key === '5h')
@@ -330,6 +337,7 @@ function glmRowModel(provider) {
330
337
  percent: five?.percent,
331
338
  stale: provider.status === 'stale',
332
339
  error: provider.error,
340
+ errorKind: provider.errorKind,
333
341
  extra: tokens,
334
342
  }
335
343
  }
@@ -337,7 +345,7 @@ function glmRowModel(provider) {
337
345
  /** Copilot summary row view model: used% inverted from the reported remaining percent. */
338
346
  function copilotRowModel(provider) {
339
347
  if (provider?.status === 'missing') return null
340
- if (provider?.status === 'error') return { kind: 'error', label: 'Copilot', error: provider.error }
348
+ if (provider?.status === 'error') return { kind: 'error', label: 'Copilot', error: provider.error, errorKind: provider.errorKind }
341
349
  const quota = provider?.quota
342
350
  if (quota?.balanceAvailable !== true) return null
343
351
  const used = remainingToUsed(quota.percentRemaining)
@@ -348,6 +356,7 @@ function copilotRowModel(provider) {
348
356
  percent: used,
349
357
  stale: provider.status === 'stale',
350
358
  error: provider.error,
359
+ errorKind: provider.errorKind,
351
360
  extra: typeof quota.creditsUsed === 'number' && quota.creditsUsed >= 0 ? quota.creditsUsed : undefined,
352
361
  }
353
362
  }
@@ -690,7 +699,9 @@ export function mountQuotaCard({
690
699
  if (model.stale) {
691
700
  const mark = text(doc, 'span', 'dqw-stale-mark', '⚠')
692
701
  mark.dataset.dshQuotaWatchStale = ''
693
- mark.title = model.error ?? copy.stale
702
+ mark.title = model.errorKind === 'auth' && model.error
703
+ ? `${copy.authRejected}: ${model.error}`
704
+ : (model.error ?? copy.stale)
694
705
  row.append(mark)
695
706
  }
696
707
  row.append(text(doc, 'span', 'dqw-pct', typeof model.percent === 'number' ? formatPercent(model.percent, locale) : '—'))
@@ -788,7 +799,8 @@ export function mountQuotaCard({
788
799
  detail.replaceChildren()
789
800
  detail.append(panelHeader({ title: copy.providerNames[key], showBack: fromPanelNav }))
790
801
  if (provider?.status === 'stale') {
791
- detail.append(text(doc, 'p', 'dqw-error', `${copy.stale}${provider.error ? `: ${provider.error}` : ''}`))
802
+ const bannerPrefix = provider.errorKind === 'auth' ? copy.authRejected : copy.stale
803
+ detail.append(text(doc, 'p', 'dqw-error', `${bannerPrefix}${provider.error ? `: ${provider.error}` : ''}`))
792
804
  }
793
805
  if (key === 'glm') {
794
806
  const usage = provider?.usage
package/src/host.mjs CHANGED
@@ -77,11 +77,17 @@ function initialProvider(key, displayName) {
77
77
  return { key, displayName, status: 'loading', credential: 'unknown' }
78
78
  }
79
79
 
80
- function safeProbeError(error) {
81
- if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return '请求超时'
82
- if (Number.isInteger(error?.status)) return `HTTP ${error.status}`
83
- if (error?.code === 'UNRECOGNIZED_RESPONSE') return '服务商响应格式无法识别'
84
- return '探测失败'
80
+ /** Classify a probe failure into a user-facing message and a stable kind for the client. */
81
+ export function classifyProbeError(error) {
82
+ if (error?.name === 'TimeoutError' || error?.name === 'AbortError') return { message: '请求超时', kind: 'network' }
83
+ if (Number.isInteger(error?.status)) {
84
+ if (error.status === 401 || error.status === 403) return { message: `HTTP ${error.status}`, kind: 'auth' }
85
+ return { message: `HTTP ${error.status}`, kind: 'server' }
86
+ }
87
+ if (error?.code === 'UNRECOGNIZED_RESPONSE') return { message: '服务商响应格式无法识别', kind: 'parse' }
88
+ const causeCode = String(error?.cause?.code ?? '')
89
+ if (/^[A-Z0-9_]{3,60}$/.test(causeCode)) return { message: `探测失败 (${causeCode})`, kind: 'network' }
90
+ return { message: '探测失败', kind: 'network' }
85
91
  }
86
92
 
87
93
  function responseJson(res, status, body) {
@@ -117,6 +123,7 @@ export function createQuotaService(ctx, options = {}) {
117
123
  let pollPromise
118
124
  let timer
119
125
  let disposed = false
126
+ let coldAttempts = 0
120
127
 
121
128
  function view() {
122
129
  return {
@@ -131,6 +138,7 @@ export function createQuotaService(ctx, options = {}) {
131
138
  ...(provider.updatedAt !== undefined ? { updatedAt: provider.updatedAt } : {}),
132
139
  ...(provider.checkedAt !== undefined ? { checkedAt: provider.checkedAt } : {}),
133
140
  ...(provider.error !== undefined ? { error: provider.error } : {}),
141
+ ...(provider.errorKind !== undefined ? { errorKind: provider.errorKind } : {}),
134
142
  ...(provider.plan !== undefined ? { plan: provider.plan } : {}),
135
143
  ...(provider.quota !== undefined ? { quota: provider.quota } : {}),
136
144
  ...(provider.usage !== undefined ? { usage: provider.usage } : {}),
@@ -139,7 +147,16 @@ export function createQuotaService(ctx, options = {}) {
139
147
  }
140
148
  }
141
149
 
150
+ /** Log one real state transition; degradations warn, recoveries and missing-credentials inform. */
151
+ function logTransition(key, from, to, detail) {
152
+ if (from === to) return
153
+ const line = `quota-watch: ${key} ${from}→${to}${detail ? ` (${detail})` : ''}`
154
+ if (to === 'error' || to === 'stale') ctx.logger?.warn?.(line)
155
+ else ctx.logger?.info?.(line)
156
+ }
157
+
142
158
  function markMissing(key, displayName) {
159
+ const from = providers.get(key)?.status
143
160
  providers.set(key, {
144
161
  key,
145
162
  displayName,
@@ -147,9 +164,11 @@ export function createQuotaService(ctx, options = {}) {
147
164
  credential: 'none',
148
165
  checkedAt: now(),
149
166
  })
167
+ logTransition(key, from, 'missing')
150
168
  }
151
169
 
152
170
  function markSuccess(key, displayName, payload) {
171
+ const from = providers.get(key)?.status
153
172
  const at = now()
154
173
  providers.set(key, {
155
174
  key,
@@ -160,6 +179,7 @@ export function createQuotaService(ctx, options = {}) {
160
179
  checkedAt: at,
161
180
  ...payload,
162
181
  })
182
+ logTransition(key, from, 'ready')
163
183
  }
164
184
 
165
185
  function markGlmPlanSuccess(plan) {
@@ -175,6 +195,7 @@ export function createQuotaService(ctx, options = {}) {
175
195
  plan,
176
196
  ...(previous?.usage !== undefined ? { usage: previous.usage } : {}),
177
197
  })
198
+ logTransition('glm', previous?.status, 'ready')
178
199
  }
179
200
 
180
201
  function markGlmUsageSuccess(usage) {
@@ -189,6 +210,7 @@ export function createQuotaService(ctx, options = {}) {
189
210
  checkedAt: at,
190
211
  usage: { status: 'ready', updatedAt: at, ...usage },
191
212
  })
213
+ logTransition('glm-usage', previous.usage?.status ?? 'loading', 'ready')
192
214
  }
193
215
 
194
216
  function markGlmUsageFailure(error) {
@@ -197,27 +219,35 @@ export function createQuotaService(ctx, options = {}) {
197
219
  const lastGood = previous.usage?.status === 'ready' || previous.usage?.status === 'stale'
198
220
  ? previous.usage
199
221
  : undefined
222
+ const { message, kind } = classifyProbeError(error)
223
+ const status = lastGood !== undefined ? 'stale' : 'error'
200
224
  providers.set('glm', {
201
225
  ...previous,
202
226
  checkedAt: now(),
203
227
  usage: {
204
228
  ...(lastGood !== undefined ? lastGood : {}),
205
- status: lastGood !== undefined ? 'stale' : 'error',
206
- error: safeProbeError(error),
229
+ status,
230
+ error: message,
231
+ errorKind: kind,
207
232
  },
208
233
  })
234
+ logTransition('glm-usage', previous.usage?.status ?? 'loading', status, message)
209
235
  }
210
236
 
211
237
  function markFailure(key, displayName, error) {
212
238
  const previous = providers.get(key)
213
239
  const checkedAt = now()
214
240
  const hasLastGood = previous?.status === 'ready' || previous?.status === 'stale'
241
+ const { message, kind } = classifyProbeError(error)
242
+ const status = hasLastGood ? 'stale' : 'error'
215
243
  providers.set(key, {
216
244
  ...(hasLastGood ? previous : { key, displayName, credential: 'configured' }),
217
- status: hasLastGood ? 'stale' : 'error',
245
+ status,
218
246
  checkedAt,
219
- error: safeProbeError(error),
247
+ error: message,
248
+ errorKind: kind,
220
249
  })
250
+ logTransition(key, previous?.status, status, message)
221
251
  }
222
252
 
223
253
  async function fetchBody(spec) {
@@ -311,18 +341,23 @@ export function createQuotaService(ctx, options = {}) {
311
341
  timer = setTimeout(async () => {
312
342
  timer = undefined
313
343
  await refresh()
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.
344
+ // Cold-start retry with capped exponential backoff: until at least one
345
+ // provider has reached ready/stale, a failed or still-loading round re-arms
346
+ // at coldRetryMs, doubling per consecutive retryable round and capping at
347
+ // the normal cadence — a desktop cold start with a slow network still gets
348
+ // fast early retries, but a persistently broken state (e.g. a rejected
349
+ // credential) can no longer hammer the upstream every few seconds forever.
350
+ // Any non-retryable round (success, or all-missing) resets the counter, so
351
+ // a later cold phase restarts from the short interval. (updatedAt alone
352
+ // cannot signal "cold": refresh() stamps it even when every probe failed.)
322
353
  const states = [...providers.values()].map((provider) => provider.status)
323
354
  const retryable = states.some((status) => status === 'loading' || status === 'error')
324
355
  && states.every((status) => status !== 'ready' && status !== 'stale')
325
- schedule(retryable ? (options.coldRetryMs ?? 5_000) : pollIntervalSec * 1000)
356
+ if (retryable) coldAttempts += 1
357
+ else coldAttempts = 0
358
+ schedule(retryable
359
+ ? Math.min((options.coldRetryMs ?? 5_000) * 2 ** (coldAttempts - 1), pollIntervalSec * 1000)
360
+ : pollIntervalSec * 1000)
326
361
  }, delayMs)
327
362
  timer.unref?.()
328
363
  }