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
@@ -293,7 +293,7 @@ class MultiProviderQuotaService {
293
293
  await this.refreshPeakHours();
294
294
  const creds = await this.readClaudeCredentials();
295
295
  if (!creds) {
296
- this.handleUnavailableState(makeUnavailableClaudeState('Renew your Claude Max OAuth to view quota', 'auth'));
296
+ this.handleUnavailableState(makeUnavailableClaudeState('Renew your Claude login to view quota', 'auth'));
297
297
  return;
298
298
  }
299
299
  const state = await this.fetchClaudeQuota(creds.accessToken);
package/dist/quota.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { QuotaProviderId } from './providerIds';
2
2
  export { QUOTA_PROVIDER_IDS } from './providerIds';
3
3
  export type { QuotaProviderId } from './providerIds';
4
4
  /**
5
- * Stateless quota fetcher for Claude Max subscription usage.
5
+ * Stateless quota fetcher for Claude subscription usage (Pro, Max, Ultra).
6
6
  *
7
7
  * Calls the Anthropic OAuth usage endpoint and returns utilization data.
8
8
  * Polling / eventing is the caller's responsibility.
@@ -12,6 +12,67 @@ export interface QuotaWindow {
12
12
  utilization: number;
13
13
  /** ISO timestamp when the window resets */
14
14
  resetsAt: string;
15
+ /**
16
+ * True when the provider reported that this window does not exist for the
17
+ * account — Claude Ultra has no five-hour session limit (`five_hour: null`),
18
+ * and a Codex plan can report a primary window without a secondary one.
19
+ * `utilization`/`resetsAt` are then `0`/`''` placeholders, so readers that
20
+ * predate the flag keep their old behaviour. Absent or false: the window
21
+ * applies (it may still be idle, with no reset time yet).
22
+ */
23
+ absent?: boolean;
24
+ /** Window length in milliseconds when the provider reports it (e.g. Codex `window_minutes`). */
25
+ windowMs?: number;
26
+ }
27
+ /**
28
+ * One usage meter as reported by the Claude usage endpoint's `limits[]` array.
29
+ * The server decides which meters exist and in what order; classify on
30
+ * `kind`, never on `label`.
31
+ */
32
+ export interface QuotaLimitRow {
33
+ /** Server meter kind, e.g. `session`, `weekly_all`, `weekly_scoped`. */
34
+ kind: string;
35
+ /** Server row group, e.g. `session` or `weekly`. */
36
+ group: string;
37
+ /** Display label derived from kind, group, and scope, e.g. `7-Day (Fable)`. */
38
+ label: string;
39
+ /** Share of the window used, 0–100. */
40
+ utilization: number;
41
+ /** ISO timestamp when the window resets; empty when unknown. */
42
+ resetsAt: string;
43
+ /** Window length in milliseconds, derived from the group when known. */
44
+ windowMs?: number;
45
+ /** Projected utilization at reset (percentage, capped at 200). */
46
+ projected?: number;
47
+ /** Model the meter is scoped to (server display name), e.g. `Fable`. */
48
+ scopeModel?: string;
49
+ /** Surface the meter is scoped to (server display name). */
50
+ scopeSurface?: string;
51
+ /** Server severity for colouring: `normal`, `warning`, `critical`. */
52
+ severity?: string;
53
+ /** The server's headline pick — the row a single-value indicator shows. */
54
+ isActive?: boolean;
55
+ /** For scoped weekly meters: how much of the all-models weekly limit this meter may use. */
56
+ allowancePercentOfWeekly?: number;
57
+ /** For scoped weekly meters: how much of the all-models weekly limit it has used. */
58
+ usedPercentOfWeekly?: number;
59
+ }
60
+ /** Claude extra-usage (usage credits) spend for the billing period. */
61
+ export interface QuotaExtraUsage {
62
+ /** Whether extra usage can currently cover requests. */
63
+ enabled: boolean;
64
+ /** Monthly cap in minor units of `currency` (cents for USD); null when uncapped. */
65
+ monthlyLimit: number | null;
66
+ /** Spend so far this period, in minor units of `currency`. */
67
+ usedCredits: number | null;
68
+ /** Share of the monthly cap used, 0–100. */
69
+ utilization: number | null;
70
+ /** ISO 4217 currency code, e.g. `USD`. */
71
+ currency?: string;
72
+ /** Number of minor-unit decimals (2 for USD). */
73
+ decimalPlaces?: number;
74
+ /** Server reason extra usage is off, e.g. `out_of_credits`. */
75
+ disabledReason?: string;
15
76
  }
16
77
  export interface CodexResetCredit {
17
78
  title?: string;
@@ -80,8 +141,23 @@ export interface QuotaState {
80
141
  resetCredits?: CodexResetCreditsSnapshot;
81
142
  /** Provider-specific plan type */
82
143
  planType?: string;
144
+ /** Display name of the subscription plan, e.g. `Pro`, `Max`, `Ultra`. */
145
+ planLabel?: string;
83
146
  /** Provider-specific rate-limit reached reason */
84
147
  rateLimitReachedType?: string;
148
+ /**
149
+ * Every usage meter the provider reported, in server order (Claude's
150
+ * `limits[]`). The `session` and `weekly_all` rows duplicate `fiveHour` and
151
+ * `sevenDay`; use `extraQuotaRows()` for the rest (e.g. a Fable weekly cap).
152
+ */
153
+ limits?: QuotaLimitRow[];
154
+ /** Claude extra-usage (usage credits) spend, when the plan reports it. */
155
+ extraUsage?: QuotaExtraUsage;
156
+ }
157
+ /** Plan facts read from the Claude OAuth credentials. */
158
+ export interface ClaudePlanInfo {
159
+ subscriptionType?: string | null;
160
+ rateLimitTier?: string | null;
85
161
  }
86
162
  export declare const FIVE_HOUR_WINDOW_MS: number;
87
163
  export declare const SEVEN_DAY_WINDOW_MS: number;
@@ -93,6 +169,39 @@ export declare const QUOTA_FRESH_MAX_AGE_MS: number;
93
169
  export declare const QUOTA_AGING_MAX_AGE_MS: number;
94
170
  /** Classify a cached sample's age. Non-finite or negative ages count as stale. */
95
171
  export declare function classifyQuotaFreshness(ageMs: number | undefined): QuotaFreshness;
172
+ /** Whether the provider reports this window for the account at all. */
173
+ export declare function quotaWindowApplies(window: QuotaWindow | null | undefined): boolean;
174
+ /** Whether the window applies and carries a reading worth showing (a reset time or any usage). */
175
+ export declare function quotaWindowHasData(window: QuotaWindow | null | undefined): boolean;
176
+ /**
177
+ * True when every window of a sample that has a known reset time has already
178
+ * reset by `nowMs`. Such a sample says nothing about current usage — showing
179
+ * it as live would print old percentages with "resets now". Samples without
180
+ * any reset time are never considered expired.
181
+ */
182
+ export declare function quotaSampleExpired(quota: QuotaState, nowMs?: number): boolean;
183
+ /**
184
+ * Human label for a window length: `5-Hour`, `Daily`, `Weekly`, `30-Day`, …
185
+ * Returns `fallback` when the length is unknown or not a whole number of hours.
186
+ */
187
+ export declare function quotaWindowLabel(windowMs: number | undefined, fallback: string): string;
188
+ /** Compact label for a window length: `5h`, `1d`, `7d`. Returns `fallback` when unknown. */
189
+ export declare function quotaWindowShortLabel(windowMs: number | undefined, fallback: string): string;
190
+ /**
191
+ * The reported meters that `fiveHour` and `sevenDay` do not already show —
192
+ * per-model or per-surface weekly caps and any future meter kind.
193
+ */
194
+ export declare function extraQuotaRows(quota: Pick<QuotaState, 'limits'>): QuotaLimitRow[];
195
+ /**
196
+ * Display name for a Claude subscription. The 20× tier was renamed Ultra and
197
+ * the 5× tier Max on 2026-10-30; credentials keep the old tier ids.
198
+ */
199
+ export declare function claudePlanLabel(plan: ClaudePlanInfo | null | undefined): string | undefined;
200
+ /**
201
+ * One-line extra-usage summary, e.g. `$12.40 of $200.00 used` or
202
+ * `$0.00 of $200.00 used · off (out of credits)`.
203
+ */
204
+ export declare function formatExtraUsage(extra: QuotaExtraUsage): string;
96
205
  /** Compact age label for status lines and table footers: `just now`, `3m ago`, `2h ago`, `3d ago`. */
97
206
  export declare function formatQuotaAge(ageMs: number | undefined): string;
98
207
  export interface QuotaProjectionInput {
@@ -112,6 +221,25 @@ export declare function withQuotaProjections<T extends QuotaState>(state: T, opt
112
221
  sevenDayWindowMs?: number;
113
222
  capturedAt?: string;
114
223
  }): T;
224
+ /**
225
+ * Normalize a Claude OAuth usage-endpoint body into a `QuotaState`.
226
+ *
227
+ * - `five_hour`/`seven_day` that are null or missing become `absent` windows
228
+ * (Ultra plans have no five-hour session limit), unless `limits[]` carries
229
+ * the matching `session`/`weekly_all` meter.
230
+ * - `limits[]` is kept verbatim as `limits` (with derived labels, window
231
+ * lengths, and projections); older servers' `seven_day_opus` /
232
+ * `seven_day_sonnet` / `seven_day_overage_included` fill it instead.
233
+ * - `extra_usage` becomes `extraUsage`.
234
+ *
235
+ * Bodies in the original two-window shape produce exactly the state they did
236
+ * before these fields existed.
237
+ */
238
+ export declare function quotaStateFromClaudeUsagePayload(payload: unknown, options?: {
239
+ capturedAt?: string;
240
+ }): QuotaState;
241
+ /** Stamp `planType` / `planLabel` from Claude credential plan facts (no-op without them). */
242
+ export declare function withClaudePlan<T extends QuotaState>(state: T, plan: ClaudePlanInfo | null | undefined): T;
115
243
  /**
116
244
  * Fetch current quota utilization from the Anthropic OAuth usage API.
117
245
  *
@@ -120,8 +248,10 @@ export declare function withQuotaProjections<T extends QuotaState>(state: T, opt
120
248
  *
121
249
  * @param accessToken - OAuth access token from `readClaudeMaxCredentials()`
122
250
  * @param options.fetchImpl - Fetch implementation (defaults to the global `fetch`; tests inject a stub)
251
+ * @param options.plan - Plan facts from the same credentials; stamps `planType` and `planLabel`
123
252
  * @returns QuotaState with utilization data and elapsed-time projections
124
253
  */
125
254
  export declare function fetchQuota(accessToken: string, options?: {
126
255
  fetchImpl?: typeof fetch;
256
+ plan?: ClaudePlanInfo | null;
127
257
  }): Promise<QuotaState>;
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[]>;