@hank-warren/pi-statusline 0.7.0 → 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,29 @@
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
+
17
+ ## 0.7.1
18
+
19
+ ### Patch Changes
20
+
21
+ - 950c926: Tick the subscription usage meters on a clock instead of on turn boundaries. The tracker only refreshed from session start, the end of a turn, and a model switch, while the poll gate that spaces requests is host-wide and five minutes wide. The moment a meter actually moved was therefore the first turn in this session ending more than five minutes after any pi process on the host last polled that account — which made updates look arbitrary, and froze an idle session's numbers indefinitely while the five-hour window was visibly recovering.
22
+
23
+ `UsageTracker` now owns an unref'd ten-second tick, started with the footer and stopped with the footer, with the usage setting, or at session shutdown. Request volume is unchanged, because a throttled refresh already issues no request: it reads `auth.json` and the shared cache, publishes whatever another process has already fetched, finds the gate closed and returns. The tick costs two small local reads and buys the two things the turn-driven cadence could not — an idle session's meters keep moving, and a sibling process's fresh values are adopted within one tick rather than waiting for this session's next turn to end. The poll itself now also fires when the five-minute window opens rather than at the first turn to end after it.
24
+
25
+ Nothing about the meters' appearance, the poll interval, the rate-limit backoff or the shared cache format changes.
26
+
3
27
  ## 0.7.0
4
28
 
5
29
  ### Minor 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 happen on session start and after each turn, 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,6 +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
+
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.
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
+
90
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.
91
99
 
92
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/index.ts CHANGED
@@ -304,7 +304,13 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
304
304
  } else if (!previous.showWorktrees || !tracker || previous.worktreeRoot !== next.worktreeRoot) {
305
305
  resetTracker(ctx);
306
306
  }
307
- if (next.showUsage && !previous.showUsage) runInBackground(usageTracker.refresh());
307
+ if (next.showUsage && !previous.showUsage) {
308
+ runInBackground(usageTracker.refresh());
309
+ usageTracker.start();
310
+ } else if (!next.showUsage && previous.showUsage) {
311
+ // A hidden meter must not keep polling, matching the worktree line above.
312
+ usageTracker.stop();
313
+ }
308
314
  if (!next.showCacheCelebration) cacheCelebration.dispose();
309
315
  // Dropping a row leaves a stale one behind in fullscreen mode.
310
316
  if (previous.showSessionId !== next.showSessionId || previous.showWorktrees !== next.showWorktrees) {
@@ -406,6 +412,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
406
412
  // Fullscreen mode never repaints unchanged rows; the session id line is
407
413
  // static, so it needs periodic forced redraws to shed stale cells.
408
414
  fullRedraw.attach(tui);
415
+ // The meters are wall-clock quantities, not per-turn ones: an idle session
416
+ // still needs them to move, and a sibling process's poll is worth adopting
417
+ // before the next turn ends.
418
+ if (settings.showUsage) usageTracker.start();
409
419
  const stopBranchUpdates = footerData.onBranchChange(() => {
410
420
  runInBackground(refreshCwdStatus(ctx));
411
421
  tui.requestRender();
@@ -417,6 +427,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
417
427
  cacheCelebration.dispose();
418
428
  celebrationPreview.dispose();
419
429
  fullRedraw.detach();
430
+ usageTracker.stop();
420
431
  requestRender = undefined;
421
432
  },
422
433
  invalidate(): void {},
@@ -479,6 +490,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
479
490
  pi.on("session_shutdown", () => {
480
491
  cacheCelebration.dispose();
481
492
  fullRedraw.detach();
493
+ usageTracker.stop();
482
494
  tracker?.dispose();
483
495
  tracker = undefined;
484
496
  cwdStatusAbort?.abort();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.7.0",
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,11 +68,23 @@ 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;
77
+ /**
78
+ * How often a bound tracker calls itself. This is deliberately far shorter than
79
+ * the poll interval, because a throttled `refresh()` issues no request at all:
80
+ * it reads auth.json and the shared cache, publishes whatever another process
81
+ * has already fetched, finds the poll gate closed, and returns. So the tick
82
+ * costs two small local reads and buys two things the previous turn-driven
83
+ * cadence could not — an idle session's meters keep moving, and a sibling
84
+ * process's fresh values appear within one tick instead of at the next turn.
85
+ * Request volume is unchanged: both throttles still gate every fetch.
86
+ */
87
+ export const USAGE_TICK_INTERVAL_MS = 10_000;
38
88
  const FETCH_TIMEOUT_MS = 10_000;
39
89
  const ONE_DAY_SECONDS = 86_400;
40
90
 
@@ -57,8 +107,8 @@ interface ClaudeLimitEntry {
57
107
  export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
58
108
  if (typeof json !== "object" || json === null) return undefined;
59
109
  const body = json as {
60
- five_hour?: { utilization?: unknown };
61
- seven_day?: { utilization?: unknown };
110
+ five_hour?: { utilization?: unknown; resets_at?: unknown };
111
+ seven_day?: { utilization?: unknown; resets_at?: unknown };
62
112
  limits?: unknown;
63
113
  };
64
114
  const fiveHour = toRemaining(body.five_hour?.utilization);
@@ -82,6 +132,18 @@ export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undef
82
132
  interface CodexWindow {
83
133
  used_percent?: unknown;
84
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;
85
147
  }
86
148
 
87
149
  /** A window whose span is known, so it can be sorted into a slot. */
@@ -98,9 +160,21 @@ interface SpannedCodexWindow extends CodexWindow {
98
160
  * and an absent window simply leaves its slot empty.
99
161
  */
100
162
  export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
101
- 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 {};
102
176
  const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
103
- if (typeof rateLimit !== "object" || rateLimit === null) return undefined;
177
+ if (typeof rateLimit !== "object" || rateLimit === null) return {};
104
178
  const { primary_window, secondary_window } = rateLimit as {
105
179
  primary_window?: CodexWindow | null;
106
180
  secondary_window?: CodexWindow | null;
@@ -113,19 +187,35 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
113
187
  typeof window.limit_window_seconds === "number" &&
114
188
  Number.isFinite(window.limit_window_seconds),
115
189
  );
116
- const shortest = windows
117
- .filter((window) => window.limit_window_seconds < ONE_DAY_SECONDS)
118
- .sort((a, b) => a.limit_window_seconds - b.limit_window_seconds)[0];
119
- const longest = windows
120
- .filter((window) => window.limit_window_seconds >= ONE_DAY_SECONDS)
121
- .sort((a, b) => b.limit_window_seconds - a.limit_window_seconds)[0];
122
- const fiveHour = toRemaining(shortest?.used_percent);
123
- const weekly = toRemaining(longest?.used_percent);
124
- if (fiveHour === undefined && weekly === undefined) return undefined;
125
- const result: UsageSnapshot["codex"] = {};
126
- if (fiveHour !== undefined) result.fiveHour = fiveHour;
127
- if (weekly !== undefined) result.weekly = weekly;
128
- 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)]);
129
219
  }
130
220
 
131
221
  /** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
@@ -139,9 +229,31 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
139
229
  type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
140
230
  ok: boolean;
141
231
  status?: number;
232
+ /** Optional so a caller (or a test double) may omit response headers. */
233
+ headers?: { get(name: string): string | null };
142
234
  json(): Promise<unknown>;
143
235
  }>;
144
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
+
145
257
  export interface UsageTrackerOptions {
146
258
  authPath?: string;
147
259
  cachePath?: string;
@@ -150,6 +262,20 @@ export interface UsageTrackerOptions {
150
262
  now?: () => number;
151
263
  /** Provider id of the session's main model; selects which account is polled. */
152
264
  activeProvider?: string;
265
+ tickIntervalMs?: number;
266
+ schedule?: (callback: () => void, intervalMs: number) => unknown;
267
+ cancel?: (handle: unknown) => void;
268
+ }
269
+
270
+ function defaultSchedule(callback: () => void, intervalMs: number): unknown {
271
+ const timer = setInterval(callback, intervalMs);
272
+ // A usage meter is cosmetic; it must never hold the process open at exit.
273
+ (timer as { unref?: () => void }).unref?.();
274
+ return timer;
275
+ }
276
+
277
+ function defaultCancel(handle: unknown): void {
278
+ clearInterval(handle as ReturnType<typeof setInterval>);
153
279
  }
154
280
 
155
281
  type ProviderKey = "claude" | "codex";
@@ -162,9 +288,21 @@ export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
162
288
  codex: "openai-codex",
163
289
  };
164
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
+
165
297
  /** Credential id polled for each family. */
166
298
  export type UsageAccounts = Record<ProviderKey, string>;
167
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
+
168
306
  function inFamily(providerId: string | undefined, base: string): boolean {
169
307
  return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
170
308
  }
@@ -242,6 +380,12 @@ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): Provi
242
380
  interface UsageAccountEntry {
243
381
  /** Credential fingerprint the stored values were fetched with. */
244
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;
245
389
  /** Last time any process started a poll for this account. */
246
390
  attemptedAt: number;
247
391
  /** Absolute time before which a rate-limited account must not be polled. */
@@ -298,6 +442,7 @@ function parseUsageCache(json: unknown): UsageCache | undefined {
298
442
  if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
299
443
  const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
300
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;
301
446
  if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
302
447
  if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
303
448
  accounts[accountId] = entry;
@@ -317,12 +462,19 @@ export class UsageTracker {
317
462
  private readonly onChange?: () => void;
318
463
  private readonly now: () => number;
319
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>> = {};
320
467
  private identities: ProviderIdentities = {};
321
468
  private activeProvider: string | undefined;
322
469
  private awaitingProvider = true;
323
- 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>();
324
472
  private lastAttempt: Record<string, number> = {};
325
473
  private inFlight: Promise<void> | undefined;
474
+ private readonly tickIntervalMs: number;
475
+ private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
476
+ private readonly cancel: (handle: unknown) => void;
477
+ private tickHandle: unknown;
326
478
 
327
479
  constructor(options: UsageTrackerOptions = {}) {
328
480
  this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
@@ -331,12 +483,34 @@ export class UsageTracker {
331
483
  this.onChange = options.onChange;
332
484
  this.now = options.now ?? Date.now;
333
485
  this.activeProvider = options.activeProvider;
486
+ this.tickIntervalMs = options.tickIntervalMs ?? USAGE_TICK_INTERVAL_MS;
487
+ this.schedule = options.schedule ?? defaultSchedule;
488
+ this.cancel = options.cancel ?? defaultCancel;
334
489
  }
335
490
 
336
491
  snapshot(): UsageSnapshot {
337
492
  return this.current;
338
493
  }
339
494
 
495
+ /**
496
+ * Begin ticking. Idempotent, so the caller may start from whichever lifecycle
497
+ * event happens first without tracking whether it already did.
498
+ */
499
+ start(): void {
500
+ if (this.tickHandle !== undefined) return;
501
+ this.tickHandle = this.schedule(() => {
502
+ // refresh() swallows its own failures and no-ops while one is in flight.
503
+ void this.refresh();
504
+ }, this.tickIntervalMs);
505
+ }
506
+
507
+ /** Stop ticking. Safe to call when never started, and leaves values in place. */
508
+ stop(): void {
509
+ if (this.tickHandle === undefined) return;
510
+ this.cancel(this.tickHandle);
511
+ this.tickHandle = undefined;
512
+ }
513
+
340
514
  /**
341
515
  * Point the meters at the session's main model. A switch between two accounts
342
516
  * in the same family changes which one is displayed; the next refresh adopts
@@ -347,11 +521,12 @@ export class UsageTracker {
347
521
  }
348
522
 
349
523
  /**
350
- * Interval until the next allowed attempt: the short retry window while a
351
- * 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.
352
527
  */
353
- private currentInterval(): number {
354
- 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];
355
530
  }
356
531
 
357
532
  /** Throttled refresh; resolves when the current attempt (if any) settles. */
@@ -381,21 +556,32 @@ export class UsageTracker {
381
556
  // Another process's values are as good as ours and cost no request, so a new
382
557
  // session — or a switch back to an account polled earlier — shows real
383
558
  // numbers on its very first render.
384
- this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
385
-
386
559
  const now = this.now();
387
- const interval = this.currentInterval();
560
+ this.publish(this.merge(...cachedSnapshot(cache, accounts, now), auth, accounts, now), auth, accounts, cache);
561
+
388
562
  const pollable: Partial<Record<ProviderKey, string>> = {};
389
563
  for (const key of PROVIDER_KEYS) {
390
564
  const accountId = accounts[key];
391
565
  if (!hasAuth(auth, accountId)) continue;
392
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.
393
569
  if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
394
570
  const lastAttempt = Math.max(
395
571
  this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
396
572
  entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
397
573
  );
398
- 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;
399
585
  pollable[key] = accountId;
400
586
  }
401
587
  if (pollable.claude === undefined && pollable.codex === undefined) return;
@@ -416,10 +602,15 @@ export class UsageTracker {
416
602
  }
417
603
  await this.writeCache({ accounts: claimed });
418
604
 
419
- const [claude, codex] = await Promise.all([
605
+ const [claudeResult, codexResult] = await Promise.all([
420
606
  pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
421
607
  pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
422
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;
423
614
 
424
615
  const updatedAccounts = { ...claimed };
425
616
  for (const key of PROVIDER_KEYS) {
@@ -427,18 +618,33 @@ export class UsageTracker {
427
618
  if (accountId === undefined) continue;
428
619
  const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
429
620
  const value = key === "claude" ? claude : codex;
430
- 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
+ }
431
629
  else if (value !== undefined) delete entry.backoff;
432
630
  // A failed fetch leaves the last-known value in place rather than blanking
433
631
  // the meter for whatever caused one bad response.
434
632
  if (claude !== undefined && key === "claude") entry.claude = claude;
435
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
+ }
436
642
  updatedAccounts[accountId] = entry;
437
643
  }
438
644
  this.rateLimited.clear();
439
645
 
440
646
  const updated: UsageCache = { accounts: updatedAccounts };
441
- 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);
442
648
  await this.writeCache(updated);
443
649
  }
444
650
 
@@ -452,6 +658,7 @@ export class UsageTracker {
452
658
  this.identities = identity;
453
659
  if (changedKeys.length === 0) return;
454
660
  for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
661
+ for (const key of changedKeys) delete this.currentResets[key];
455
662
  if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
456
663
  const next = { ...this.current };
457
664
  for (const key of changedKeys) delete next[key];
@@ -465,21 +672,44 @@ export class UsageTracker {
465
672
  * account has no credentials: a logged-out account must disappear immediately,
466
673
  * while a failed fetch keeps whatever we last saw.
467
674
  */
468
- private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
469
- const next: UsageSnapshot = {};
470
- if (hasAuth(auth, accounts.claude)) {
471
- const claude = incoming.claude ?? this.current.claude;
472
- if (claude) next.claude = claude;
473
- }
474
- if (hasAuth(auth, accounts.codex)) {
475
- const codex = incoming.codex ?? this.current.codex;
476
- 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];
477
698
  }
478
- return next;
699
+ return { snapshot, resets };
479
700
  }
480
701
 
481
702
  /** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
482
- 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;
483
713
  const now = this.now();
484
714
  // An account serving 429s is not "pending": retrying it faster is exactly
485
715
  // what got us rate limited, so it must not hold the short window open.
@@ -528,7 +758,9 @@ export class UsageTracker {
528
758
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
529
759
  try {
530
760
  const response = await this.fetchFn(url, { headers, signal: controller.signal });
531
- 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
+ }
532
764
  if (!response.ok) return undefined;
533
765
  return await response.json();
534
766
  } finally {
@@ -536,47 +768,64 @@ export class UsageTracker {
536
768
  }
537
769
  }
538
770
 
539
- private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
771
+ private async fetchClaude(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"claude"> | undefined> {
540
772
  const access = auth[accountId]?.access;
541
773
  if (typeof access !== "string" || access.length === 0) return undefined;
542
774
  try {
543
- return parseClaudeUsage(
544
- await this.fetchJson(
545
- CLAUDE_USAGE_URL,
546
- { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
547
- "claude",
548
- ),
775
+ const json = await this.fetchJson(
776
+ CLAUDE_USAGE_URL,
777
+ { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
778
+ "claude",
549
779
  );
780
+ const value = parseClaudeUsage(json);
781
+ return value === undefined ? undefined : { value, resetsAt: claudeResetAt(json) };
550
782
  } catch {
551
783
  return undefined;
552
784
  }
553
785
  }
554
786
 
555
- private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
787
+ private async fetchCodex(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"codex"> | undefined> {
556
788
  const entry = auth[accountId];
557
789
  const access = entry?.access;
558
790
  const account = entry?.accountId;
559
791
  if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
560
792
  try {
561
- return parseCodexUsage(
562
- await this.fetchJson(
563
- CODEX_USAGE_URL,
564
- { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
565
- "codex",
566
- ),
793
+ const json = await this.fetchJson(
794
+ CODEX_USAGE_URL,
795
+ { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
796
+ "codex",
567
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()) };
568
802
  } catch {
569
803
  return undefined;
570
804
  }
571
805
  }
572
806
  }
573
807
 
574
- /** The cached values for the currently selected accounts, in display shape. */
575
- 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>>] {
576
818
  const snapshot: UsageSnapshot = {};
577
- const claude = cache.accounts[accounts.claude]?.claude;
578
- const codex = cache.accounts[accounts.codex]?.codex;
579
- if (claude) snapshot.claude = claude;
580
- if (codex) snapshot.codex = codex;
581
- 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];
582
831
  }