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