sidekick-shared 0.28.2 → 0.28.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.
Files changed (54) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +29 -13
  3. package/dist/browser.d.ts +2 -0
  4. package/dist/browser.js +10 -1
  5. package/dist/codexQuota.js +17 -4
  6. package/dist/esm/browser.js +2 -0
  7. package/dist/esm/codexQuota.js +18 -5
  8. package/dist/esm/index.js +3 -3
  9. package/dist/esm/multiProviderQuotaService.js +1 -1
  10. package/dist/esm/quota.js +356 -24
  11. package/dist/esm/quotaHistory.js +33 -8
  12. package/dist/esm/quotaPoller.js +5 -2
  13. package/dist/esm/quotaResolver.js +31 -6
  14. package/dist/esm/quotaSnapshots.js +65 -10
  15. package/dist/esm/quotaThresholds.js +7 -2
  16. package/dist/esm/schemas/index.js +2 -2
  17. package/dist/esm/schemas/quota.js +30 -0
  18. package/dist/esm/schemas/quotaHistory.js +10 -2
  19. package/dist/esm/schemas/stateFile.js +9 -0
  20. package/dist/esm/stateFile.js +18 -3
  21. package/dist/esm/statusline/claudeStatuslinePayload.js +16 -4
  22. package/dist/esm/statusline/formatter.js +57 -18
  23. package/dist/esm/statusline/index.js +2 -2
  24. package/dist/esm/zaiQuotaApi.js +4 -2
  25. package/dist/index.d.ts +6 -6
  26. package/dist/index.js +18 -5
  27. package/dist/multiProviderQuotaService.js +1 -1
  28. package/dist/quota.d.ts +131 -1
  29. package/dist/quota.js +366 -24
  30. package/dist/quotaHistory.d.ts +10 -0
  31. package/dist/quotaHistory.js +34 -8
  32. package/dist/quotaPoller.d.ts +11 -3
  33. package/dist/quotaPoller.js +5 -2
  34. package/dist/quotaResolver.js +30 -5
  35. package/dist/quotaSnapshots.js +64 -9
  36. package/dist/quotaThresholds.js +7 -2
  37. package/dist/schemas/index.d.ts +3 -3
  38. package/dist/schemas/index.js +5 -2
  39. package/dist/schemas/quota.d.ts +258 -0
  40. package/dist/schemas/quota.js +31 -1
  41. package/dist/schemas/quotaHistory.d.ts +2 -0
  42. package/dist/schemas/quotaHistory.js +10 -2
  43. package/dist/schemas/stateFile.d.ts +34 -0
  44. package/dist/schemas/stateFile.js +10 -1
  45. package/dist/stateFile.d.ts +18 -0
  46. package/dist/stateFile.js +17 -2
  47. package/dist/statusline/claudeStatuslinePayload.d.ts +4 -3
  48. package/dist/statusline/claudeStatuslinePayload.js +16 -4
  49. package/dist/statusline/formatter.d.ts +5 -0
  50. package/dist/statusline/formatter.js +56 -17
  51. package/dist/statusline/index.d.ts +4 -4
  52. package/dist/statusline/index.js +5 -1
  53. package/dist/zaiQuotaApi.js +3 -1
  54. package/package.json +1 -1
package/dist/esm/quota.js CHANGED
@@ -18,6 +18,126 @@ export function classifyQuotaFreshness(ageMs) {
18
18
  return 'aging';
19
19
  return 'stale';
20
20
  }
21
+ const HOUR_MS = 3_600_000;
22
+ const DAY_MS = 86_400_000;
23
+ /** Whether the provider reports this window for the account at all. */
24
+ export function quotaWindowApplies(window) {
25
+ return Boolean(window) && window?.absent !== true;
26
+ }
27
+ /** Whether the window applies and carries a reading worth showing (a reset time or any usage). */
28
+ export function quotaWindowHasData(window) {
29
+ if (!window || window.absent === true)
30
+ return false;
31
+ return Boolean(window.resetsAt) || window.utilization > 0;
32
+ }
33
+ function usableWindowMs(windowMs) {
34
+ return typeof windowMs === 'number' && Number.isFinite(windowMs) && windowMs > 0
35
+ ? windowMs
36
+ : undefined;
37
+ }
38
+ /**
39
+ * True when every window of a sample that has a known reset time has already
40
+ * reset by `nowMs`. Such a sample says nothing about current usage — showing
41
+ * it as live would print old percentages with "resets now". Samples without
42
+ * any reset time are never considered expired.
43
+ */
44
+ export function quotaSampleExpired(quota, nowMs = Date.now()) {
45
+ const resets = [quota.fiveHour, quota.sevenDay]
46
+ .filter((window) => !window.absent && window.resetsAt)
47
+ .map((window) => Date.parse(window.resetsAt))
48
+ .filter((ms) => Number.isFinite(ms));
49
+ return resets.length > 0 && resets.every((ms) => ms <= nowMs);
50
+ }
51
+ /**
52
+ * Human label for a window length: `5-Hour`, `Daily`, `Weekly`, `30-Day`, …
53
+ * Returns `fallback` when the length is unknown or not a whole number of hours.
54
+ */
55
+ export function quotaWindowLabel(windowMs, fallback) {
56
+ const ms = usableWindowMs(windowMs);
57
+ if (ms === undefined)
58
+ return fallback;
59
+ if (ms === DAY_MS)
60
+ return 'Daily';
61
+ if (ms === 7 * DAY_MS)
62
+ return 'Weekly';
63
+ if (ms % DAY_MS === 0)
64
+ return `${ms / DAY_MS}-Day`;
65
+ if (ms % HOUR_MS === 0)
66
+ return `${ms / HOUR_MS}-Hour`;
67
+ return fallback;
68
+ }
69
+ /** Compact label for a window length: `5h`, `1d`, `7d`. Returns `fallback` when unknown. */
70
+ export function quotaWindowShortLabel(windowMs, fallback) {
71
+ const ms = usableWindowMs(windowMs);
72
+ if (ms === undefined)
73
+ return fallback;
74
+ if (ms % DAY_MS === 0)
75
+ return `${ms / DAY_MS}d`;
76
+ if (ms % HOUR_MS === 0)
77
+ return `${ms / HOUR_MS}h`;
78
+ if (ms % 60_000 === 0)
79
+ return `${ms / 60_000}m`;
80
+ return fallback;
81
+ }
82
+ /**
83
+ * The reported meters that `fiveHour` and `sevenDay` do not already show —
84
+ * per-model or per-surface weekly caps and any future meter kind.
85
+ */
86
+ export function extraQuotaRows(quota) {
87
+ return (quota.limits ?? []).filter((row) => row.kind !== 'session' && row.kind !== 'weekly_all');
88
+ }
89
+ /**
90
+ * Display name for a Claude subscription. The 20× tier was renamed Ultra and
91
+ * the 5× tier Max on 2026-10-30; credentials keep the old tier ids.
92
+ */
93
+ export function claudePlanLabel(plan) {
94
+ const tier = plan?.rateLimitTier?.trim().toLowerCase() ?? '';
95
+ if (tier.includes('ultra') || tier.includes('max_20x'))
96
+ return 'Ultra';
97
+ if (tier.includes('max_5x'))
98
+ return 'Max';
99
+ const subscription = plan?.subscriptionType?.trim().toLowerCase();
100
+ if (!subscription)
101
+ return undefined;
102
+ return subscription.charAt(0).toUpperCase() + subscription.slice(1);
103
+ }
104
+ function formatMinorUnits(amount, currency, decimals) {
105
+ const value = amount / 10 ** decimals;
106
+ if (currency) {
107
+ try {
108
+ return new Intl.NumberFormat('en-US', {
109
+ style: 'currency',
110
+ currency,
111
+ minimumFractionDigits: decimals,
112
+ maximumFractionDigits: decimals,
113
+ }).format(value);
114
+ }
115
+ catch {
116
+ return `${value.toFixed(decimals)} ${currency}`;
117
+ }
118
+ }
119
+ return value.toFixed(decimals);
120
+ }
121
+ /**
122
+ * One-line extra-usage summary, e.g. `$12.40 of $200.00 used` or
123
+ * `$0.00 of $200.00 used · off (out of credits)`.
124
+ */
125
+ export function formatExtraUsage(extra) {
126
+ const decimals = typeof extra.decimalPlaces === 'number' &&
127
+ Number.isInteger(extra.decimalPlaces) &&
128
+ extra.decimalPlaces >= 0 &&
129
+ extra.decimalPlaces <= 6
130
+ ? extra.decimalPlaces
131
+ : 2;
132
+ const used = formatMinorUnits(extra.usedCredits ?? 0, extra.currency, decimals);
133
+ const amount = extra.monthlyLimit !== null
134
+ ? `${used} of ${formatMinorUnits(extra.monthlyLimit, extra.currency, decimals)} used`
135
+ : `${used} used`;
136
+ if (extra.enabled)
137
+ return amount;
138
+ const reason = extra.disabledReason ? ` (${extra.disabledReason.replace(/_/g, ' ')})` : '';
139
+ return `${amount} · off${reason}`;
140
+ }
21
141
  /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
22
142
  export function formatQuotaAge(ageMs) {
23
143
  if (typeof ageMs !== 'number' || !Number.isFinite(ageMs) || ageMs < 0)
@@ -56,21 +176,21 @@ export function withQuotaProjections(state, options = {}) {
56
176
  return state;
57
177
  const next = { ...state };
58
178
  const capturedAt = options.capturedAt ?? state.capturedAt;
59
- if (next.projectedFiveHour == null) {
179
+ if (next.projectedFiveHour == null && !next.fiveHour.absent) {
60
180
  const projected = projectQuotaWindow({
61
181
  utilization: next.fiveHour.utilization,
62
182
  resetsAt: next.fiveHour.resetsAt,
63
- windowMs: options.fiveHourWindowMs ?? FIVE_HOUR_WINDOW_MS,
183
+ windowMs: options.fiveHourWindowMs ?? usableWindowMs(next.fiveHour.windowMs) ?? FIVE_HOUR_WINDOW_MS,
64
184
  capturedAt,
65
185
  });
66
186
  if (projected !== undefined)
67
187
  next.projectedFiveHour = projected;
68
188
  }
69
- if (next.projectedSevenDay == null) {
189
+ if (next.projectedSevenDay == null && !next.sevenDay.absent) {
70
190
  const projected = projectQuotaWindow({
71
191
  utilization: next.sevenDay.utilization,
72
192
  resetsAt: next.sevenDay.resetsAt,
73
- windowMs: options.sevenDayWindowMs ?? SEVEN_DAY_WINDOW_MS,
193
+ windowMs: options.sevenDayWindowMs ?? usableWindowMs(next.sevenDay.windowMs) ?? SEVEN_DAY_WINDOW_MS,
74
194
  capturedAt,
75
195
  });
76
196
  if (projected !== undefined)
@@ -78,6 +198,235 @@ export function withQuotaProjections(state, options = {}) {
78
198
  }
79
199
  return next;
80
200
  }
201
+ // ── Claude usage endpoint payload ──
202
+ /** Hard cap on parsed meter rows, matching Claude Code's own bound. */
203
+ const MAX_LIMIT_ROWS = 64;
204
+ const MAX_LABEL_LENGTH = 128;
205
+ function isRecord(value) {
206
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
207
+ }
208
+ function finiteNumber(value) {
209
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
210
+ }
211
+ function nullableFiniteNumber(value) {
212
+ return finiteNumber(value) ?? null;
213
+ }
214
+ function shortString(value) {
215
+ if (typeof value !== 'string')
216
+ return undefined;
217
+ const trimmed = value.trim();
218
+ return trimmed && trimmed.length <= MAX_LABEL_LENGTH ? trimmed : undefined;
219
+ }
220
+ function displayName(value) {
221
+ return isRecord(value) ? shortString(value.display_name) : undefined;
222
+ }
223
+ function usageWindow(value) {
224
+ if (!isRecord(value))
225
+ return null;
226
+ return {
227
+ utilization: finiteNumber(value.utilization) ?? 0,
228
+ resetsAt: typeof value.resets_at === 'string' ? value.resets_at : '',
229
+ };
230
+ }
231
+ function absentWindow() {
232
+ return { utilization: 0, resetsAt: '', absent: true };
233
+ }
234
+ function windowMsForMeter(kind, group) {
235
+ if (kind === 'session' || group === 'session')
236
+ return FIVE_HOUR_WINDOW_MS;
237
+ if (group === 'weekly' || kind.startsWith('weekly'))
238
+ return SEVEN_DAY_WINDOW_MS;
239
+ if (group === 'daily')
240
+ return DAY_MS;
241
+ return undefined;
242
+ }
243
+ function titleCase(value) {
244
+ return value
245
+ .split(/[_\s-]+/)
246
+ .filter(Boolean)
247
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
248
+ .join(' ');
249
+ }
250
+ function meterLabel(kind, group, scope) {
251
+ let base;
252
+ if (kind === 'session' || group === 'session')
253
+ base = '5-Hour';
254
+ else if (group === 'weekly' || kind.startsWith('weekly'))
255
+ base = '7-Day';
256
+ else if (group === 'daily')
257
+ base = 'Daily';
258
+ else
259
+ base = titleCase(group || kind) || kind;
260
+ return scope ? `${base} (${scope})` : base;
261
+ }
262
+ function weeklySharesByIndex(value) {
263
+ const shares = new Map();
264
+ if (!Array.isArray(value))
265
+ return shares;
266
+ for (const entry of value.slice(0, MAX_LIMIT_ROWS)) {
267
+ if (!isRecord(entry))
268
+ continue;
269
+ const index = finiteNumber(entry.limit_index);
270
+ if (index === undefined || !Number.isInteger(index) || index < 0)
271
+ continue;
272
+ shares.set(index, {
273
+ allowance: finiteNumber(entry.allowance_percent_of_weekly),
274
+ used: finiteNumber(entry.used_percent_of_weekly),
275
+ });
276
+ }
277
+ return shares;
278
+ }
279
+ function buildMeterRow(input, capturedAt) {
280
+ const windowMs = windowMsForMeter(input.kind, input.group);
281
+ const projected = windowMs !== undefined
282
+ ? projectQuotaWindow({
283
+ utilization: input.utilization,
284
+ resetsAt: input.resetsAt,
285
+ windowMs,
286
+ capturedAt,
287
+ })
288
+ : undefined;
289
+ return {
290
+ kind: input.kind,
291
+ group: input.group,
292
+ label: meterLabel(input.kind, input.group, input.scopeModel ?? input.scopeSurface),
293
+ utilization: input.utilization,
294
+ resetsAt: input.resetsAt,
295
+ ...(windowMs !== undefined ? { windowMs } : {}),
296
+ ...(projected !== undefined ? { projected } : {}),
297
+ ...(input.scopeModel ? { scopeModel: input.scopeModel } : {}),
298
+ ...(input.scopeSurface ? { scopeSurface: input.scopeSurface } : {}),
299
+ ...(input.severity ? { severity: input.severity } : {}),
300
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
301
+ ...(input.share?.allowance !== undefined
302
+ ? { allowancePercentOfWeekly: input.share.allowance }
303
+ : {}),
304
+ ...(input.share?.used !== undefined ? { usedPercentOfWeekly: input.share.used } : {}),
305
+ };
306
+ }
307
+ function parseLimitRows(value, sharesValue, capturedAt) {
308
+ if (!Array.isArray(value))
309
+ return undefined;
310
+ const shares = weeklySharesByIndex(sharesValue);
311
+ const rows = [];
312
+ value.slice(0, MAX_LIMIT_ROWS).forEach((raw, index) => {
313
+ if (!isRecord(raw))
314
+ return;
315
+ const kind = shortString(raw.kind);
316
+ const percent = finiteNumber(raw.percent);
317
+ if (!kind || percent === undefined)
318
+ return;
319
+ const scope = isRecord(raw.scope) ? raw.scope : undefined;
320
+ rows.push(buildMeterRow({
321
+ kind,
322
+ group: shortString(raw.group) ?? '',
323
+ utilization: Math.max(0, percent),
324
+ resetsAt: typeof raw.resets_at === 'string' ? raw.resets_at : '',
325
+ scopeModel: displayName(scope?.model),
326
+ scopeSurface: displayName(scope?.surface),
327
+ severity: shortString(raw.severity),
328
+ isActive: typeof raw.is_active === 'boolean' ? raw.is_active : undefined,
329
+ share: shares.get(index),
330
+ }, capturedAt));
331
+ });
332
+ return rows;
333
+ }
334
+ /** Pre-`limits[]` per-model weekly fields, read only when the server sends no `limits[]`. */
335
+ const LEGACY_SCOPED_WEEKLY_FIELDS = [
336
+ ['seven_day_opus', 'Opus'],
337
+ ['seven_day_sonnet', 'Sonnet'],
338
+ ['seven_day_overage_included', 'Fable'],
339
+ ];
340
+ function legacyScopedRows(data, capturedAt) {
341
+ const rows = [];
342
+ for (const [field, model] of LEGACY_SCOPED_WEEKLY_FIELDS) {
343
+ const window = usageWindow(data[field]);
344
+ if (!window)
345
+ continue;
346
+ rows.push(buildMeterRow({
347
+ kind: 'weekly_scoped',
348
+ group: 'weekly',
349
+ utilization: window.utilization,
350
+ resetsAt: window.resetsAt,
351
+ scopeModel: model,
352
+ }, capturedAt));
353
+ }
354
+ return rows;
355
+ }
356
+ function parseExtraUsage(value) {
357
+ if (!isRecord(value) || typeof value.is_enabled !== 'boolean')
358
+ return undefined;
359
+ const decimalPlaces = finiteNumber(value.decimal_places);
360
+ return {
361
+ enabled: value.is_enabled,
362
+ monthlyLimit: nullableFiniteNumber(value.monthly_limit),
363
+ usedCredits: nullableFiniteNumber(value.used_credits),
364
+ utilization: nullableFiniteNumber(value.utilization),
365
+ ...(shortString(value.currency) ? { currency: shortString(value.currency) } : {}),
366
+ ...(decimalPlaces !== undefined ? { decimalPlaces } : {}),
367
+ ...(shortString(value.disabled_reason)
368
+ ? { disabledReason: shortString(value.disabled_reason) }
369
+ : {}),
370
+ };
371
+ }
372
+ function windowFromMeter(row) {
373
+ return row ? { utilization: row.utilization, resetsAt: row.resetsAt } : null;
374
+ }
375
+ /**
376
+ * Normalize a Claude OAuth usage-endpoint body into a `QuotaState`.
377
+ *
378
+ * - `five_hour`/`seven_day` that are null or missing become `absent` windows
379
+ * (Ultra plans have no five-hour session limit), unless `limits[]` carries
380
+ * the matching `session`/`weekly_all` meter.
381
+ * - `limits[]` is kept verbatim as `limits` (with derived labels, window
382
+ * lengths, and projections); older servers' `seven_day_opus` /
383
+ * `seven_day_sonnet` / `seven_day_overage_included` fill it instead.
384
+ * - `extra_usage` becomes `extraUsage`.
385
+ *
386
+ * Bodies in the original two-window shape produce exactly the state they did
387
+ * before these fields existed.
388
+ */
389
+ export function quotaStateFromClaudeUsagePayload(payload, options = {}) {
390
+ const data = isRecord(payload) ? payload : {};
391
+ const capturedAt = options.capturedAt;
392
+ const parsedLimits = parseLimitRows(data.limits, data.weekly_scoped_shares, capturedAt);
393
+ const legacyRows = parsedLimits === undefined ? legacyScopedRows(data, capturedAt) : [];
394
+ const limits = parsedLimits ?? (legacyRows.length > 0 ? legacyRows : undefined);
395
+ const fiveHour = usageWindow(data.five_hour) ??
396
+ windowFromMeter(parsedLimits?.find((row) => row.kind === 'session')) ??
397
+ absentWindow();
398
+ const sevenDay = usageWindow(data.seven_day) ??
399
+ windowFromMeter(parsedLimits?.find((row) => row.kind === 'weekly_all')) ??
400
+ absentWindow();
401
+ const extraUsage = parseExtraUsage(data.extra_usage);
402
+ const projectedFiveHour = fiveHour.absent
403
+ ? undefined
404
+ : projectQuotaWindow({ ...fiveHour, windowMs: FIVE_HOUR_WINDOW_MS, capturedAt });
405
+ const projectedSevenDay = sevenDay.absent
406
+ ? undefined
407
+ : projectQuotaWindow({ ...sevenDay, windowMs: SEVEN_DAY_WINDOW_MS, capturedAt });
408
+ return {
409
+ fiveHour,
410
+ sevenDay,
411
+ available: true,
412
+ projectedFiveHour,
413
+ projectedSevenDay,
414
+ ...(limits ? { limits } : {}),
415
+ ...(extraUsage ? { extraUsage } : {}),
416
+ };
417
+ }
418
+ /** Stamp `planType` / `planLabel` from Claude credential plan facts (no-op without them). */
419
+ export function withClaudePlan(state, plan) {
420
+ const planLabel = claudePlanLabel(plan);
421
+ const planType = plan?.subscriptionType?.trim() || undefined;
422
+ if (!planLabel && !planType)
423
+ return state;
424
+ return {
425
+ ...state,
426
+ ...(planType ? { planType } : {}),
427
+ ...(planLabel ? { planLabel } : {}),
428
+ };
429
+ }
81
430
  /**
82
431
  * Helper to build an unavailable QuotaState.
83
432
  */
@@ -110,6 +459,7 @@ function parseRetryAfterMs(retryAfter) {
110
459
  *
111
460
  * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
112
461
  * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
462
+ * @param options.plan - Plan facts from the same credentials; stamps `planType` and `planLabel`
113
463
  * @returns QuotaState with utilization data and elapsed-time projections
114
464
  */
115
465
  export async function fetchQuota(accessToken, options = {}) {
@@ -152,26 +502,8 @@ export async function fetchQuota(accessToken, options = {}) {
152
502
  httpStatus: res.status,
153
503
  });
154
504
  }
155
- const data = await res.json();
156
- const fiveUtil = data.five_hour?.utilization ?? 0;
157
- const sevenUtil = data.seven_day?.utilization ?? 0;
158
- const fiveResetsAt = data.five_hour?.resets_at ?? '';
159
- const sevenResetsAt = data.seven_day?.resets_at ?? '';
160
- return {
161
- fiveHour: { utilization: fiveUtil, resetsAt: fiveResetsAt },
162
- sevenDay: { utilization: sevenUtil, resetsAt: sevenResetsAt },
163
- available: true,
164
- projectedFiveHour: projectQuotaWindow({
165
- utilization: fiveUtil,
166
- resetsAt: fiveResetsAt,
167
- windowMs: FIVE_HOUR_WINDOW_MS,
168
- }),
169
- projectedSevenDay: projectQuotaWindow({
170
- utilization: sevenUtil,
171
- resetsAt: sevenResetsAt,
172
- windowMs: SEVEN_DAY_WINDOW_MS,
173
- }),
174
- };
505
+ const state = quotaStateFromClaudeUsagePayload(await res.json());
506
+ return withClaudePlan(state, options.plan);
175
507
  }
176
508
  catch {
177
509
  return unavailableState('Network error', { failureKind: 'network' });
@@ -159,11 +159,26 @@ function pruneFileSync(filePath, retentionDays) {
159
159
  }
160
160
  return { kept: keptLines.length, pruned };
161
161
  }
162
+ function historyWindowToQuota(window) {
163
+ return {
164
+ utilization: window.utilization,
165
+ resetsAt: window.resetsAt,
166
+ ...(window.absent ? { absent: true } : {}),
167
+ };
168
+ }
169
+ /**
170
+ * Project a quota window onto the history-sample shape, keeping the `absent`
171
+ * marker so summaries and the derived snapshot do not mistake "no such
172
+ * window" for "0% used".
173
+ */
174
+ export function quotaHistoryWindow(window) {
175
+ return historyWindowToQuota(window);
176
+ }
162
177
  function sampleToQuotaState(sample) {
163
178
  const providerId = runtimeProviderToSnapshotProvider(sample.runtimeProvider);
164
179
  return {
165
- fiveHour: { utilization: sample.fiveHour.utilization, resetsAt: sample.fiveHour.resetsAt },
166
- sevenDay: { utilization: sample.sevenDay.utilization, resetsAt: sample.sevenDay.resetsAt },
180
+ fiveHour: historyWindowToQuota(sample.fiveHour),
181
+ sevenDay: historyWindowToQuota(sample.sevenDay),
167
182
  available: sample.available,
168
183
  error: sample.error,
169
184
  providerId,
@@ -329,12 +344,22 @@ export async function readQuotaHistoryDailyBuckets(options) {
329
344
  let maxSeven = 0;
330
345
  let sumFive = 0;
331
346
  let sumSeven = 0;
347
+ // Averages count only samples where the window exists, so a plan without
348
+ // a five-hour limit does not drag the five-hour average toward zero.
349
+ let fiveCount = 0;
350
+ let sevenCount = 0;
332
351
  let anyUnavailable = false;
333
352
  for (const s of daySamples) {
334
- maxFive = Math.max(maxFive, s.fiveHour.utilization);
335
- maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
336
- sumFive += s.fiveHour.utilization;
337
- sumSeven += s.sevenDay.utilization;
353
+ if (!s.fiveHour.absent) {
354
+ maxFive = Math.max(maxFive, s.fiveHour.utilization);
355
+ sumFive += s.fiveHour.utilization;
356
+ fiveCount += 1;
357
+ }
358
+ if (!s.sevenDay.absent) {
359
+ maxSeven = Math.max(maxSeven, s.sevenDay.utilization);
360
+ sumSeven += s.sevenDay.utilization;
361
+ sevenCount += 1;
362
+ }
338
363
  if (!s.available)
339
364
  anyUnavailable = true;
340
365
  }
@@ -344,8 +369,8 @@ export async function readQuotaHistoryDailyBuckets(options) {
344
369
  samples: n,
345
370
  maxUtilizationFiveHour: maxFive,
346
371
  maxUtilizationSevenDay: maxSeven,
347
- avgUtilizationFiveHour: Math.round((sumFive / n) * 100) / 100,
348
- avgUtilizationSevenDay: Math.round((sumSeven / n) * 100) / 100,
372
+ avgUtilizationFiveHour: fiveCount ? Math.round((sumFive / fiveCount) * 100) / 100 : 0,
373
+ avgUtilizationSevenDay: sevenCount ? Math.round((sumSeven / sevenCount) * 100) / 100 : 0,
349
374
  anyUnavailable,
350
375
  });
351
376
  }
@@ -125,7 +125,8 @@ export class QuotaPoller {
125
125
  this.dormant = true;
126
126
  return;
127
127
  }
128
- const token = await this.getAccessToken();
128
+ const credentials = await this.getAccessToken();
129
+ const token = typeof credentials === 'string' ? credentials : credentials?.accessToken;
129
130
  if (!token) {
130
131
  if (this.hasAccount) {
131
132
  // The account exists but its token was unreadable (locked keychain,
@@ -141,7 +142,9 @@ export class QuotaPoller {
141
142
  }
142
143
  return;
143
144
  }
144
- const state = await fetchQuota(token);
145
+ const state = await fetchQuota(token, {
146
+ plan: typeof credentials === 'object' && credentials !== null ? credentials : null,
147
+ });
145
148
  if (state.available) {
146
149
  this.latest = state;
147
150
  this.consecutiveFailures = 0;
@@ -23,7 +23,7 @@ import { resolveActiveClaudeAccount } from './accounts';
23
23
  import { getActiveCodexAccount, resolveActiveCodexAccount } from './codexProfiles';
24
24
  import { enrichCodexQuota, fetchCodexQuotaFromApi, resolveCodexQuotaFromLocalSources, } from './codexQuota';
25
25
  import { readClaudeMaxCredentials } from './credentials';
26
- import { classifyQuotaFreshness, fetchQuota, withQuotaProjections } from './quota';
26
+ import { classifyQuotaFreshness, fetchQuota, formatQuotaAge, quotaSampleExpired, withQuotaProjections, } from './quota';
27
27
  import { describeQuotaFailure } from './quotaPresentation';
28
28
  import { readQuotaSnapshot, writeQuotaSnapshot } from './quotaSnapshots';
29
29
  import { enrichZaiQuota, fetchZaiQuotaFromApi } from './zaiQuotaApi';
@@ -63,9 +63,18 @@ function resolveIdentity(options) {
63
63
  return { accountId: options.accountId ?? ZAI_DEFAULT_ACCOUNT_ID, codexAccount: null };
64
64
  }
65
65
  }
66
- async function defaultClaudeAccessToken() {
66
+ /** Token plus the plan facts stored beside it (the plan label needs no extra read). */
67
+ async function claudeTokenAndPlan(options) {
68
+ if (options.getClaudeAccessToken) {
69
+ return { token: await options.getClaudeAccessToken(), plan: null };
70
+ }
67
71
  const credentials = await readClaudeMaxCredentials();
68
- return credentials?.accessToken ?? null;
72
+ return {
73
+ token: credentials?.accessToken ?? null,
74
+ plan: credentials
75
+ ? { subscriptionType: credentials.subscriptionType, rateLimitTier: credentials.rateLimitTier }
76
+ : null,
77
+ };
69
78
  }
70
79
  function zeroWindows() {
71
80
  return { fiveHour: { utilization: 0, resetsAt: '' }, sevenDay: { utilization: 0, resetsAt: '' } };
@@ -84,7 +93,7 @@ function unavailableState(providerId, error, capturedAt) {
84
93
  async function fetchFromApi(options, identity, capturedAt) {
85
94
  switch (options.providerId) {
86
95
  case 'claude-code': {
87
- const token = await (options.getClaudeAccessToken ?? defaultClaudeAccessToken)();
96
+ const { token, plan } = await claudeTokenAndPlan(options);
88
97
  if (!token) {
89
98
  return {
90
99
  ...zeroWindows(),
@@ -96,7 +105,7 @@ async function fetchFromApi(options, identity, capturedAt) {
96
105
  capturedAt,
97
106
  };
98
107
  }
99
- const state = await fetchQuota(token, { fetchImpl: options.fetchImpl });
108
+ const state = await fetchQuota(token, { fetchImpl: options.fetchImpl, plan });
100
109
  return { ...state, providerId: 'claude-code', source: 'api', capturedAt };
101
110
  }
102
111
  case 'codex':
@@ -178,7 +187,10 @@ export async function resolveQuota(options) {
178
187
  const stored = identity.accountId
179
188
  ? readSnapshot(snapshotProviderId(providerId), identity.accountId, now)
180
189
  : null;
181
- const snapshot = stored?.available ? stored : null;
190
+ // A sample whose every window has reset since says nothing about current
191
+ // usage; it is reported as expired instead of being shown as live numbers.
192
+ const storedExpired = Boolean(stored?.available && quotaSampleExpired(stored, now.getTime()));
193
+ const snapshot = stored?.available && !storedExpired ? stored : null;
182
194
  if (preferFresh && snapshot && snapshot.freshness === 'fresh') {
183
195
  return finish(snapshot, 'snapshot-fresh');
184
196
  }
@@ -224,8 +236,21 @@ export async function resolveQuota(options) {
224
236
  const fallback = finish(snapshot, snapshotResolution(snapshot));
225
237
  return apiState ? { ...fallback, failure: describeQuotaFailure(apiState) } : fallback;
226
238
  }
239
+ if (storedExpired && stored) {
240
+ const expired = unavailableState(providerId, expiredSampleMessage(stored, now), capturedAt);
241
+ return apiState
242
+ ? { ...finish(expired, 'unavailable'), failure: describeQuotaFailure(apiState) }
243
+ : finish(expired, 'unavailable');
244
+ }
227
245
  return finish(apiState ??
228
246
  unavailableState(providerId, stored
229
247
  ? 'The last persisted quota sample was unavailable.'
230
248
  : 'No quota data is available yet; run the provider once so a sample can be recorded.', capturedAt), 'unavailable');
231
249
  }
250
+ function expiredSampleMessage(stored, now) {
251
+ const capturedMs = stored.capturedAt ? Date.parse(stored.capturedAt) : NaN;
252
+ const when = Number.isFinite(capturedMs)
253
+ ? ` from ${new Date(capturedMs).toISOString().slice(0, 10)} (${formatQuotaAge(now.getTime() - capturedMs)})`
254
+ : '';
255
+ return `The last quota sample${when} has expired: every window has reset since. No current data.`;
256
+ }