@hank-warren/pi-statusline 0.7.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.7.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 7f2143a: Make the subscription usage meters accurate as well as prompt. Three changes to how the poller treats each endpoint and its own cache, following the ten-second tick shipped in 0.7.1.
8
+
9
+ **Codex gets its own sixty-second interval.** Both endpoints shared one five-minute spacing, written for the stricter of the two. OpenAI's own Codex CLI polls its usage endpoint every sixty seconds and independent tools converge on the same figure, so the Codex meter had been five times more conservative than the vendor's own client for no reason beyond sharing a constant. Anthropic's endpoint, which rate-limits far harder, keeps the five minutes it needs.
10
+
11
+ **A `Retry-After` is now honoured.** Any 429 previously got a flat fifteen-minute backoff, ignoring the header saying when to come back; a provider asking for sixty seconds now gets sixty seconds, and one asking for an hour is no longer cut short by our guess. Both forms are accepted, delta-seconds and HTTP-date. A value that is not in the future is discarded rather than clamped, because Anthropic's usage endpoint is widely reported to answer `retry-after: 0` while still refusing requests — obeying it literally would retry straight back into the limit that produced it. The flat window remains the fallback for a 429 that says nothing.
12
+
13
+ **Cached values expire at their window boundary, not just by age.** Each cache entry now records the soonest moment any of its windows rolls over, from Anthropic's `resets_at` or Codex's `reset_at`/`reset_after_seconds`. Once that moment passes the stored percentages describe the window that just _ended_ — not merely stale but wrong, and invisible to any age-based expiry. Such a value is no longer adopted from the shared cache or kept as a last-known-good, and its account skips the poll interval so the post-reset numbers appear promptly. A boundary is answered exactly once rather than on every tick: a failed poll leaves the boundary in the past, so an unconditional override would re-poll continuously for as long as the failure lasted — a 429 sets a backoff that stops that, but a 500 or a timeout does not. An active rate-limit backoff still outranks a boundary, and a value carrying no boundary — which is what an entry written by an earlier statusline looks like — never expires this way.
14
+
15
+ The cache file stays compatible in both directions: the reset boundary is an additive field that older readers ignore, and an entry without one behaves exactly as before.
16
+
3
17
  ## 0.7.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -75,7 +75,7 @@ When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Clau
75
75
 
76
76
  Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
77
77
 
78
- Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches are driven by session start, each turn, and a ten-second tick while the footer is mounted, throttled to at most once every five minutes (the Anthropic usage endpoint rate-limits aggressively), and are strictly best-effort: on any failure the last-known value is kept, and providers without credentials (or before the first successful fetch) are simply omitted, leaving the statusline exactly as before. While a provider that *does* have credentials still has no value, the throttle drops to 30 seconds — Pi only refreshes an expired OAuth access token when that provider is first used, so a session starting with a stale Anthropic token would otherwise show no Claude meter for a full interval.
78
+ Usage is fetched from the providers' own usage endpoints with Pi's stored tokens — read-only; tokens are never refreshed or written. Fetches are driven by session start, each turn, and a ten-second tick while the footer is mounted, throttled per provider five minutes for Claude, sixty seconds for Codex (the Anthropic usage endpoint rate-limits aggressively), and are strictly best-effort: on any failure the last-known value is kept, and providers without credentials (or before the first successful fetch) are simply omitted, leaving the statusline exactly as before. While a provider that *does* have credentials still has no value, the throttle drops to 30 seconds — Pi only refreshes an expired OAuth access token when that provider is first used, so a session starting with a stale Anthropic token would otherwise show no Claude meter for a full interval.
79
79
 
80
80
  ### Which account each meter shows
81
81
 
@@ -87,8 +87,14 @@ When [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) has registered
87
87
 
88
88
  Polling is host-wide, not per-session. Usage percentages describe the account rather than the session, and a busy machine runs dozens of pi processes, so every process shares `~/.pi/agent/statusline-usage.json`, written atomically via a temp file and rename. It is keyed by **credential id**, one entry per account, each holding that account's last good values plus the time its last poll was *started*. A session adopts the cached values for its selected accounts on first refresh — so the meters are populated before it has issued a single request, and switching back to an account polled earlier repaints with no request at all — and only polls an account whose timestamp is older than the interval. Keying by account rather than by provider family is what lets two sessions on two different Anthropic logins coexist: keyed by family, each looked like an account switch to the other, so they evicted each other's values and re-polled every cycle.
89
89
 
90
+ The two intervals differ because the endpoints do. OpenAI's own Codex CLI polls `wham/usage` every sixty seconds, and independent community tools converge on the same figure, so matching the first-party client is well inside what that endpoint expects. Anthropic's usage endpoint is the opposite — it rate-limits hard enough that a whole ecosystem of statusline tools has been stuck in permanent 429 loops — so Claude keeps the conservative five minutes. The two had shared one interval only because it was written for the stricter of them, leaving the Codex meter five times more conservative than the vendor's own client for no reason.
91
+
90
92
  The ten-second tick does not increase request volume; it only stops the meters from being pinned to turn boundaries. A throttled refresh issues no request at all — it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the poll gate closed and returns — so the tick costs two small local reads and buys the two things the previous turn-driven cadence could not: an **idle** session's meters keep moving (quota recovers on a wall clock, not on your turns), and a sibling process's fresh values are adopted within one tick instead of waiting for this session's next turn to end. It also means the poll itself happens when the five-minute window opens rather than at the first turn that ends after it. The tick is `unref`ed, runs only in TUI mode, and stops with the footer — so turning the usage segment off still stops all polling.
91
93
 
94
+ A provider answering **429** is left alone before being polled again. When the response carries a usable `Retry-After` — either form, delta-seconds or an HTTP-date — that instruction is authoritative and is used exactly. Otherwise a flat fifteen-minute window applies, because a 429 that says nothing about when to return is only telling us the endpoint wants a break. A `Retry-After` that is not in the future is discarded rather than clamped: Anthropic's usage endpoint is widely reported to answer `retry-after: 0` while still refusing requests, so obeying it literally would retry straight back into the limit that produced it. Any successful poll clears the block.
95
+
96
+ Cached values also expire at a **window boundary**, not just by age. Each entry records the soonest moment any of its windows rolls over — from Anthropic's `resets_at`, or Codex's `reset_at` (absolute, preferred) or `reset_after_seconds` (relative, resolved against our clock). Once that moment passes, the stored percentages describe the window that just *ended*: they are not merely old, they are wrong, and no age-based expiry can see it. Such a value is never adopted from the cache and never kept as a last-known-good, and the account becomes pollable immediately rather than waiting out its interval, so the post-reset numbers appear promptly. An active 429 backoff still outranks a boundary — a rate-limited endpoint is the last thing to argue with the moment its window turns over. A value carrying no boundary at all, which is what an entry written by an older statusline looks like, never expires this way.
97
+
92
98
  Cache entries stay compatible in both directions: a Codex entry written by a statusline older than 0.6.0 carries no 5-hour value, so a newer one renders it as a single weekly number until its own next poll fills the second slot, and an older reader ignores the extra value entirely.
93
99
 
94
100
  An account answering `429` is parked for fifteen minutes (tracked per account, so a rate-limited Anthropic never stops codex from updating) and stops counting as pending, since retrying harder is what earns the rate limit in the first place. Every cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credential that fetched it: switching accounts — or rotating a token — discards that entry's numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. A logged-out account fails the same check, so the file garbage-collects itself.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
5
5
  "type": "module",
6
6
  "keywords": [
package/usage.ts CHANGED
@@ -4,7 +4,15 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { pid } from "node:process";
6
6
 
7
- /** Remaining (not used) integer percents per provider window. */
7
+ /**
8
+ * Remaining (not used) integer percents per provider window.
9
+ *
10
+ * `resetsAt` is the soonest moment any of that provider's windows rolls over,
11
+ * as epoch milliseconds. It is what makes a cached value falsifiable: once that
12
+ * boundary passes, the percentages describe the *previous* window and are not
13
+ * merely old but wrong. Absent when the payload carried no usable reset time,
14
+ * which is also what an entry written by an older statusline looks like.
15
+ */
8
16
  export interface UsageSnapshot {
9
17
  claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
10
18
  /**
@@ -15,6 +23,27 @@ export interface UsageSnapshot {
15
23
  codex?: { fiveHour?: number; weekly?: number };
16
24
  }
17
25
 
26
+ /**
27
+ * Whether a reset boundary has passed. A missing boundary never expires, which
28
+ * is what an entry written by an older statusline looks like.
29
+ */
30
+ function hasReset(resetsAt: number | undefined, now: number): boolean {
31
+ return resetsAt !== undefined && now >= resetsAt;
32
+ }
33
+
34
+ /** The soonest of a set of candidate reset times, ignoring unusable ones. */
35
+ function earliestReset(candidates: (number | undefined)[]): number | undefined {
36
+ const usable = candidates.filter((value): value is number => value !== undefined && Number.isFinite(value));
37
+ return usable.length === 0 ? undefined : Math.min(...usable);
38
+ }
39
+
40
+ /** Anthropic reports reset times as an ISO 8601 string. */
41
+ function parseIsoReset(value: unknown): number | undefined {
42
+ if (typeof value !== "string") return undefined;
43
+ const parsed = Date.parse(value);
44
+ return Number.isFinite(parsed) ? parsed : undefined;
45
+ }
46
+
18
47
  const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
19
48
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
20
49
  /**
@@ -22,6 +51,15 @@ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
22
51
  * (429) aggressively, so keep this well above a per-turn cadence.
23
52
  */
24
53
  export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
54
+ /**
55
+ * Minimum spacing for the Codex endpoint, which is markedly more permissive
56
+ * than Anthropic's. OpenAI's own Codex CLI polls this exact endpoint on a
57
+ * 60-second interval (`ChatWidget::prefetch_rate_limits`), and independent
58
+ * community tools converge on the same figure, so matching the first-party
59
+ * client is well inside what the endpoint expects. The two providers had
60
+ * shared one interval purely because it was written for the stricter of them.
61
+ */
62
+ export const CODEX_USAGE_REFRESH_INTERVAL_MS = 60_000;
25
63
  /**
26
64
  * Shorter spacing used while a credentialed provider still has no value. Pi
27
65
  * refreshes an expired OAuth token only when that provider is first used, so a
@@ -30,9 +68,10 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
30
68
  */
31
69
  export const USAGE_RETRY_INTERVAL_MS = 30_000;
32
70
  /**
33
- * How long an account is left alone after it answers 429. Polls are host-wide
34
- * (see the shared cache below), so a rate limit means the provider itself wants
35
- * a break rather than that we are racing ourselves.
71
+ * How long an account is left alone after it answers 429 *without* telling us
72
+ * when to come back. Polls are host-wide (see the shared cache below), so a rate
73
+ * limit means the provider itself wants a break rather than that we are racing
74
+ * ourselves. A usable `Retry-After` always wins over this guess.
36
75
  */
37
76
  export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
38
77
  /**
@@ -68,8 +107,8 @@ interface ClaudeLimitEntry {
68
107
  export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
69
108
  if (typeof json !== "object" || json === null) return undefined;
70
109
  const body = json as {
71
- five_hour?: { utilization?: unknown };
72
- seven_day?: { utilization?: unknown };
110
+ five_hour?: { utilization?: unknown; resets_at?: unknown };
111
+ seven_day?: { utilization?: unknown; resets_at?: unknown };
73
112
  limits?: unknown;
74
113
  };
75
114
  const fiveHour = toRemaining(body.five_hour?.utilization);
@@ -93,6 +132,18 @@ export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undef
93
132
  interface CodexWindow {
94
133
  used_percent?: unknown;
95
134
  limit_window_seconds?: unknown;
135
+ /** Absolute reset, Unix seconds. Preferred over the relative form. */
136
+ reset_at?: unknown;
137
+ /** Relative reset, seconds from the moment the payload was produced. */
138
+ reset_after_seconds?: unknown;
139
+ }
140
+
141
+ /** A Codex window's reset time as epoch ms, absolute form preferred. */
142
+ function codexReset(window: CodexWindow | undefined, now: number): number | undefined {
143
+ if (window === undefined) return undefined;
144
+ if (typeof window.reset_at === "number" && Number.isFinite(window.reset_at)) return window.reset_at * 1_000;
145
+ const after = window.reset_after_seconds;
146
+ return typeof after === "number" && Number.isFinite(after) ? now + after * 1_000 : undefined;
96
147
  }
97
148
 
98
149
  /** A window whose span is known, so it can be sorted into a slot. */
@@ -109,9 +160,21 @@ interface SpannedCodexWindow extends CodexWindow {
109
160
  * and an absent window simply leaves its slot empty.
110
161
  */
111
162
  export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
112
- if (typeof json !== "object" || json === null) return undefined;
163
+ const { shortest, longest } = slotCodexWindows(json);
164
+ const fiveHour = toRemaining(shortest?.used_percent);
165
+ const weekly = toRemaining(longest?.used_percent);
166
+ if (fiveHour === undefined && weekly === undefined) return undefined;
167
+ const result: UsageSnapshot["codex"] = {};
168
+ if (fiveHour !== undefined) result.fiveHour = fiveHour;
169
+ if (weekly !== undefined) result.weekly = weekly;
170
+ return result;
171
+ }
172
+
173
+ /** Sort a Codex payload's windows into the short and long display slots. */
174
+ function slotCodexWindows(json: unknown): { shortest?: SpannedCodexWindow; longest?: SpannedCodexWindow } {
175
+ if (typeof json !== "object" || json === null) return {};
113
176
  const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
114
- if (typeof rateLimit !== "object" || rateLimit === null) return undefined;
177
+ if (typeof rateLimit !== "object" || rateLimit === null) return {};
115
178
  const { primary_window, secondary_window } = rateLimit as {
116
179
  primary_window?: CodexWindow | null;
117
180
  secondary_window?: CodexWindow | null;
@@ -124,19 +187,35 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
124
187
  typeof window.limit_window_seconds === "number" &&
125
188
  Number.isFinite(window.limit_window_seconds),
126
189
  );
127
- const shortest = windows
128
- .filter((window) => window.limit_window_seconds < ONE_DAY_SECONDS)
129
- .sort((a, b) => a.limit_window_seconds - b.limit_window_seconds)[0];
130
- const longest = windows
131
- .filter((window) => window.limit_window_seconds >= ONE_DAY_SECONDS)
132
- .sort((a, b) => b.limit_window_seconds - a.limit_window_seconds)[0];
133
- const fiveHour = toRemaining(shortest?.used_percent);
134
- const weekly = toRemaining(longest?.used_percent);
135
- if (fiveHour === undefined && weekly === undefined) return undefined;
136
- const result: UsageSnapshot["codex"] = {};
137
- if (fiveHour !== undefined) result.fiveHour = fiveHour;
138
- if (weekly !== undefined) result.weekly = weekly;
139
- return result;
190
+ return {
191
+ shortest: windows
192
+ .filter((window) => window.limit_window_seconds < ONE_DAY_SECONDS)
193
+ .sort((a, b) => a.limit_window_seconds - b.limit_window_seconds)[0],
194
+ longest: windows
195
+ .filter((window) => window.limit_window_seconds >= ONE_DAY_SECONDS)
196
+ .sort((a, b) => b.limit_window_seconds - a.limit_window_seconds)[0],
197
+ };
198
+ }
199
+
200
+ /**
201
+ * The soonest moment any window in a Claude payload rolls over, epoch ms.
202
+ * Kept out of the parsed value on purpose: this is cache-lifetime metadata,
203
+ * never rendered, and folding it into the display shape would make every
204
+ * snapshot assertion carry a timestamp.
205
+ */
206
+ export function claudeResetAt(json: unknown): number | undefined {
207
+ if (typeof json !== "object" || json === null) return undefined;
208
+ const body = json as { five_hour?: { resets_at?: unknown }; seven_day?: { resets_at?: unknown } };
209
+ return earliestReset([parseIsoReset(body.five_hour?.resets_at), parseIsoReset(body.seven_day?.resets_at)]);
210
+ }
211
+
212
+ /**
213
+ * The soonest moment any slotted Codex window rolls over, epoch ms. Needs the
214
+ * clock because Codex may report only a relative `reset_after_seconds`.
215
+ */
216
+ export function codexResetAt(json: unknown, now: number): number | undefined {
217
+ const { shortest, longest } = slotCodexWindows(json);
218
+ return earliestReset([codexReset(shortest, now), codexReset(longest, now)]);
140
219
  }
141
220
 
142
221
  /** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
@@ -150,9 +229,31 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
150
229
  type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
151
230
  ok: boolean;
152
231
  status?: number;
232
+ /** Optional so a caller (or a test double) may omit response headers. */
233
+ headers?: { get(name: string): string | null };
153
234
  json(): Promise<unknown>;
154
235
  }>;
155
236
 
237
+ /**
238
+ * Absolute time a `Retry-After` header points at, or undefined when it is
239
+ * absent, unparseable, or already in the past. Both forms in RFC 9110 are
240
+ * accepted: delta-seconds (`Retry-After: 120`) and an HTTP-date.
241
+ *
242
+ * A value that is not in the future is deliberately discarded rather than
243
+ * clamped to now. Anthropic's usage endpoint is documented by its users to
244
+ * answer `retry-after: 0` while continuing to refuse requests, so obeying it
245
+ * literally would retry straight into the limit that produced it.
246
+ */
247
+ export function parseRetryAfter(value: string | null | undefined, now: number): number | undefined {
248
+ if (typeof value !== "string") return undefined;
249
+ const trimmed = value.trim();
250
+ if (trimmed.length === 0) return undefined;
251
+ const seconds = Number(trimmed);
252
+ const at = Number.isFinite(seconds) ? now + seconds * 1_000 : Date.parse(trimmed);
253
+ if (!Number.isFinite(at) || at <= now) return undefined;
254
+ return at;
255
+ }
256
+
156
257
  export interface UsageTrackerOptions {
157
258
  authPath?: string;
158
259
  cachePath?: string;
@@ -187,9 +288,21 @@ export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
187
288
  codex: "openai-codex",
188
289
  };
189
290
 
291
+ /** Steady-state poll spacing per family; each endpoint has its own tolerance. */
292
+ const PROVIDER_REFRESH_INTERVAL_MS: Record<ProviderKey, number> = {
293
+ claude: USAGE_REFRESH_INTERVAL_MS,
294
+ codex: CODEX_USAGE_REFRESH_INTERVAL_MS,
295
+ };
296
+
190
297
  /** Credential id polled for each family. */
191
298
  export type UsageAccounts = Record<ProviderKey, string>;
192
299
 
300
+ /** A freshly fetched value together with the boundary it is valid until. */
301
+ interface FetchedUsage<K extends ProviderKey> {
302
+ value: NonNullable<UsageSnapshot[K]>;
303
+ resetsAt?: number;
304
+ }
305
+
193
306
  function inFamily(providerId: string | undefined, base: string): boolean {
194
307
  return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
195
308
  }
@@ -267,6 +380,12 @@ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): Provi
267
380
  interface UsageAccountEntry {
268
381
  /** Credential fingerprint the stored values were fetched with. */
269
382
  identity: string;
383
+ /**
384
+ * Epoch ms at which the soonest window in the stored value rolls over. Past
385
+ * it the percentages describe the previous window, so they are wrong rather
386
+ * than merely old. Absent on entries written before this existed.
387
+ */
388
+ resetsAt?: number;
270
389
  /** Last time any process started a poll for this account. */
271
390
  attemptedAt: number;
272
391
  /** Absolute time before which a rate-limited account must not be polled. */
@@ -323,6 +442,7 @@ function parseUsageCache(json: unknown): UsageCache | undefined {
323
442
  if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
324
443
  const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
325
444
  if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
445
+ if (typeof body.resetsAt === "number" && Number.isFinite(body.resetsAt)) entry.resetsAt = body.resetsAt;
326
446
  if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
327
447
  if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
328
448
  accounts[accountId] = entry;
@@ -342,10 +462,13 @@ export class UsageTracker {
342
462
  private readonly onChange?: () => void;
343
463
  private readonly now: () => number;
344
464
  private current: UsageSnapshot = {};
465
+ /** Reset boundary of each value in `current`, so it can expire in place. */
466
+ private currentResets: Partial<Record<ProviderKey, number>> = {};
345
467
  private identities: ProviderIdentities = {};
346
468
  private activeProvider: string | undefined;
347
469
  private awaitingProvider = true;
348
- private readonly rateLimited = new Set<ProviderKey>();
470
+ /** Families that answered 429 this cycle, mapped to their `Retry-After`. */
471
+ private readonly rateLimited = new Map<ProviderKey, number | undefined>();
349
472
  private lastAttempt: Record<string, number> = {};
350
473
  private inFlight: Promise<void> | undefined;
351
474
  private readonly tickIntervalMs: number;
@@ -398,11 +521,12 @@ export class UsageTracker {
398
521
  }
399
522
 
400
523
  /**
401
- * Interval until the next allowed attempt: the short retry window while a
402
- * credentialed provider is still missing a value, the full interval otherwise.
524
+ * Interval until the next allowed attempt for one family: the short retry
525
+ * window while a credentialed provider is still missing a value, that
526
+ * family's own steady-state interval otherwise.
403
527
  */
404
- private currentInterval(): number {
405
- return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : USAGE_REFRESH_INTERVAL_MS;
528
+ private currentInterval(key: ProviderKey): number {
529
+ return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : PROVIDER_REFRESH_INTERVAL_MS[key];
406
530
  }
407
531
 
408
532
  /** Throttled refresh; resolves when the current attempt (if any) settles. */
@@ -432,21 +556,32 @@ export class UsageTracker {
432
556
  // Another process's values are as good as ours and cost no request, so a new
433
557
  // session — or a switch back to an account polled earlier — shows real
434
558
  // numbers on its very first render.
435
- this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
436
-
437
559
  const now = this.now();
438
- const interval = this.currentInterval();
560
+ this.publish(this.merge(...cachedSnapshot(cache, accounts, now), auth, accounts, now), auth, accounts, cache);
561
+
439
562
  const pollable: Partial<Record<ProviderKey, string>> = {};
440
563
  for (const key of PROVIDER_KEYS) {
441
564
  const accountId = accounts[key];
442
565
  if (!hasAuth(auth, accountId)) continue;
443
566
  const entry = cache.accounts[accountId];
567
+ // A backoff outranks everything: a rate-limited endpoint does not want to
568
+ // hear from us at a window boundary either.
444
569
  if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
445
570
  const lastAttempt = Math.max(
446
571
  this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
447
572
  entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
448
573
  );
449
- if (now - lastAttempt < interval) continue;
574
+ // Past a window boundary the stored percentages are wrong rather than
575
+ // merely old, so the interval is skipped to republish promptly — but only
576
+ // for a boundary we have not already answered. A failed poll leaves the
577
+ // boundary in the past, so an unconditional override would poll on every
578
+ // tick for as long as the failure lasted. A 429 sets a backoff that stops
579
+ // that; a 500, a timeout or a dropped connection does not. Answering each
580
+ // boundary once hands the account back to the ordinary intervals, which
581
+ // for a provider now holding no value is the short retry window.
582
+ const boundary = entry?.resetsAt;
583
+ const boundaryUnanswered = hasReset(boundary, now) && (boundary as number) > lastAttempt;
584
+ if (!boundaryUnanswered && now - lastAttempt < this.currentInterval(key)) continue;
450
585
  pollable[key] = accountId;
451
586
  }
452
587
  if (pollable.claude === undefined && pollable.codex === undefined) return;
@@ -467,10 +602,15 @@ export class UsageTracker {
467
602
  }
468
603
  await this.writeCache({ accounts: claimed });
469
604
 
470
- const [claude, codex] = await Promise.all([
605
+ const [claudeResult, codexResult] = await Promise.all([
471
606
  pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
472
607
  pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
473
608
  ]);
609
+ const claude = claudeResult?.value;
610
+ const codex = codexResult?.value;
611
+ const incomingResets: Partial<Record<ProviderKey, number>> = {};
612
+ if (claudeResult?.resetsAt !== undefined) incomingResets.claude = claudeResult.resetsAt;
613
+ if (codexResult?.resetsAt !== undefined) incomingResets.codex = codexResult.resetsAt;
474
614
 
475
615
  const updatedAccounts = { ...claimed };
476
616
  for (const key of PROVIDER_KEYS) {
@@ -478,18 +618,33 @@ export class UsageTracker {
478
618
  if (accountId === undefined) continue;
479
619
  const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
480
620
  const value = key === "claude" ? claude : codex;
481
- if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
621
+ // A `Retry-After` we could use is authoritative; the flat window is only a
622
+ // guess for a 429 that told us nothing. Deliberately a plain assignment:
623
+ // the poll gate above skips any account whose backoff is still in the
624
+ // future, so a value we are about to overwrite is necessarily absent or
625
+ // already expired, and a monotonic max() here would never do anything.
626
+ if (this.rateLimited.has(key)) {
627
+ entry.backoff = this.rateLimited.get(key) ?? now + USAGE_RATE_LIMIT_BACKOFF_MS;
628
+ }
482
629
  else if (value !== undefined) delete entry.backoff;
483
630
  // A failed fetch leaves the last-known value in place rather than blanking
484
631
  // the meter for whatever caused one bad response.
485
632
  if (claude !== undefined && key === "claude") entry.claude = claude;
486
633
  if (codex !== undefined && key === "codex") entry.codex = codex;
634
+ // The boundary travels with the value it describes: a refreshed value gets
635
+ // the new window, and one that arrived without a usable reset must not
636
+ // inherit the previous window's.
637
+ if (value !== undefined) {
638
+ const resetsAt = incomingResets[key];
639
+ if (resetsAt === undefined) delete entry.resetsAt;
640
+ else entry.resetsAt = resetsAt;
641
+ }
487
642
  updatedAccounts[accountId] = entry;
488
643
  }
489
644
  this.rateLimited.clear();
490
645
 
491
646
  const updated: UsageCache = { accounts: updatedAccounts };
492
- this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
647
+ this.publish(this.merge({ claude, codex }, incomingResets, auth, accounts, now), auth, accounts, updated);
493
648
  await this.writeCache(updated);
494
649
  }
495
650
 
@@ -503,6 +658,7 @@ export class UsageTracker {
503
658
  this.identities = identity;
504
659
  if (changedKeys.length === 0) return;
505
660
  for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
661
+ for (const key of changedKeys) delete this.currentResets[key];
506
662
  if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
507
663
  const next = { ...this.current };
508
664
  for (const key of changedKeys) delete next[key];
@@ -516,21 +672,44 @@ export class UsageTracker {
516
672
  * account has no credentials: a logged-out account must disappear immediately,
517
673
  * while a failed fetch keeps whatever we last saw.
518
674
  */
519
- private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
520
- const next: UsageSnapshot = {};
521
- if (hasAuth(auth, accounts.claude)) {
522
- const claude = incoming.claude ?? this.current.claude;
523
- if (claude) next.claude = claude;
524
- }
525
- if (hasAuth(auth, accounts.codex)) {
526
- const codex = incoming.codex ?? this.current.codex;
527
- if (codex) next.codex = codex;
675
+ private merge(
676
+ incoming: UsageSnapshot,
677
+ incomingResets: Partial<Record<ProviderKey, number>>,
678
+ auth: AuthEntries,
679
+ accounts: UsageAccounts,
680
+ now: number,
681
+ ): { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> } {
682
+ const snapshot: UsageSnapshot = {};
683
+ const resets: Partial<Record<ProviderKey, number>> = {};
684
+ for (const key of PROVIDER_KEYS) {
685
+ if (!hasAuth(auth, accounts[key])) continue;
686
+ if (incoming[key] !== undefined) {
687
+ // biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
688
+ snapshot[key] = incoming[key] as any;
689
+ if (incomingResets[key] !== undefined) resets[key] = incomingResets[key];
690
+ continue;
691
+ }
692
+ // A last-known-good value survives a failed fetch, but not the rollover of
693
+ // the window it describes.
694
+ if (this.current[key] === undefined || hasReset(this.currentResets[key], now)) continue;
695
+ // biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
696
+ snapshot[key] = this.current[key] as any;
697
+ if (this.currentResets[key] !== undefined) resets[key] = this.currentResets[key];
528
698
  }
529
- return next;
699
+ return { snapshot, resets };
530
700
  }
531
701
 
532
702
  /** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
533
- private publish(next: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts, cache: UsageCache): void {
703
+ private publish(
704
+ merged: { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> },
705
+ auth: AuthEntries,
706
+ accounts: UsageAccounts,
707
+ cache: UsageCache,
708
+ ): void {
709
+ const next = merged.snapshot;
710
+ // Boundaries follow their values even when the percentages are unchanged, so
711
+ // a refreshed window cannot be judged against the previous one's deadline.
712
+ this.currentResets = merged.resets;
534
713
  const now = this.now();
535
714
  // An account serving 429s is not "pending": retrying it faster is exactly
536
715
  // what got us rate limited, so it must not hold the short window open.
@@ -579,7 +758,9 @@ export class UsageTracker {
579
758
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
580
759
  try {
581
760
  const response = await this.fetchFn(url, { headers, signal: controller.signal });
582
- if (response.status === 429) this.rateLimited.add(provider);
761
+ if (response.status === 429) {
762
+ this.rateLimited.set(provider, parseRetryAfter(response.headers?.get("retry-after"), this.now()));
763
+ }
583
764
  if (!response.ok) return undefined;
584
765
  return await response.json();
585
766
  } finally {
@@ -587,47 +768,64 @@ export class UsageTracker {
587
768
  }
588
769
  }
589
770
 
590
- private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
771
+ private async fetchClaude(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"claude"> | undefined> {
591
772
  const access = auth[accountId]?.access;
592
773
  if (typeof access !== "string" || access.length === 0) return undefined;
593
774
  try {
594
- return parseClaudeUsage(
595
- await this.fetchJson(
596
- CLAUDE_USAGE_URL,
597
- { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
598
- "claude",
599
- ),
775
+ const json = await this.fetchJson(
776
+ CLAUDE_USAGE_URL,
777
+ { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
778
+ "claude",
600
779
  );
780
+ const value = parseClaudeUsage(json);
781
+ return value === undefined ? undefined : { value, resetsAt: claudeResetAt(json) };
601
782
  } catch {
602
783
  return undefined;
603
784
  }
604
785
  }
605
786
 
606
- private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
787
+ private async fetchCodex(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"codex"> | undefined> {
607
788
  const entry = auth[accountId];
608
789
  const access = entry?.access;
609
790
  const account = entry?.accountId;
610
791
  if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
611
792
  try {
612
- return parseCodexUsage(
613
- await this.fetchJson(
614
- CODEX_USAGE_URL,
615
- { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
616
- "codex",
617
- ),
793
+ const json = await this.fetchJson(
794
+ CODEX_USAGE_URL,
795
+ { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
796
+ "codex",
618
797
  );
798
+ const value = parseCodexUsage(json);
799
+ // Codex may report only a relative reset, which is meaningless without the
800
+ // clock the rest of the tracker runs on.
801
+ return value === undefined ? undefined : { value, resetsAt: codexResetAt(json, this.now()) };
619
802
  } catch {
620
803
  return undefined;
621
804
  }
622
805
  }
623
806
  }
624
807
 
625
- /** The cached values for the currently selected accounts, in display shape. */
626
- function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
808
+ /**
809
+ * The cached values for the currently selected accounts, in display shape.
810
+ * Values whose window has already rolled are dropped: adopting another
811
+ * process's stale reading is exactly as wrong as keeping our own.
812
+ */
813
+ function cachedSnapshot(
814
+ cache: UsageCache,
815
+ accounts: UsageAccounts,
816
+ now: number,
817
+ ): [UsageSnapshot, Partial<Record<ProviderKey, number>>] {
627
818
  const snapshot: UsageSnapshot = {};
628
- const claude = cache.accounts[accounts.claude]?.claude;
629
- const codex = cache.accounts[accounts.codex]?.codex;
630
- if (claude) snapshot.claude = claude;
631
- if (codex) snapshot.codex = codex;
632
- return snapshot;
819
+ const resets: Partial<Record<ProviderKey, number>> = {};
820
+ const claudeEntry = cache.accounts[accounts.claude];
821
+ const codexEntry = cache.accounts[accounts.codex];
822
+ if (claudeEntry?.claude && !hasReset(claudeEntry.resetsAt, now)) {
823
+ snapshot.claude = claudeEntry.claude;
824
+ if (claudeEntry.resetsAt !== undefined) resets.claude = claudeEntry.resetsAt;
825
+ }
826
+ if (codexEntry?.codex && !hasReset(codexEntry.resetsAt, now)) {
827
+ snapshot.codex = codexEntry.codex;
828
+ if (codexEntry.resetsAt !== undefined) resets.codex = codexEntry.resetsAt;
829
+ }
830
+ return [snapshot, resets];
633
831
  }