@hank-warren/pi-statusline 0.7.1 → 0.7.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.7.3
4
+
5
+ ### Patch Changes
6
+
7
+ - d0c46a5: Resolve `auth.json`, the usage cache and `statusline-settings.json` through Pi's agent directory (`PI_CODING_AGENT_DIR`) instead of a hardcoded `~/.pi/agent`. Behaviour is unchanged for a normal install; a session started with a scratch agent dir no longer reads the real credentials or writes the real usage cache. `engines.node` now states Pi's own floor, `>=22.19.0`.
8
+
9
+ ## 0.7.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 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.
14
+
15
+ **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.
16
+
17
+ **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.
18
+
19
+ **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.
20
+
21
+ 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.
22
+
3
23
  ## 0.7.1
4
24
 
5
25
  ### 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.
@@ -2,7 +2,7 @@ export const CACHE_HIT_THRESHOLD = 0.96;
2
2
  export const CACHE_CELEBRATION_FRAME_INTERVAL_MS = 60;
3
3
  export const CACHE_CELEBRATION_DURATION_MS = 2_000;
4
4
 
5
- export interface CacheUsage {
5
+ interface CacheUsage {
6
6
  input: number;
7
7
  cacheRead: number;
8
8
  cacheWrite: number;
@@ -13,11 +13,11 @@ export interface CacheCelebrationSnapshot {
13
13
  frame: number;
14
14
  }
15
15
 
16
- export interface CacheCelebrationTarget {
16
+ interface CacheCelebrationTarget {
17
17
  start(percent: number): void;
18
18
  }
19
19
 
20
- export interface CacheCelebrationControllerOptions {
20
+ interface CacheCelebrationControllerOptions {
21
21
  frameIntervalMs?: number;
22
22
  durationMs?: number;
23
23
  now?: () => number;
@@ -7,7 +7,7 @@ import type { SettingsListTheme } from "@earendil-works/pi-tui";
7
7
  /** Percentage shown by the settings-menu preview badge. */
8
8
  export const PREVIEW_PERCENT = 96;
9
9
 
10
- export interface CelebrationPreviewOptions {
10
+ interface CelebrationPreviewOptions {
11
11
  frameIntervalMs?: number;
12
12
  schedule?: (callback: () => void, intervalMs: number) => unknown;
13
13
  cancel?: (handle: unknown) => void;
@@ -11,13 +11,13 @@ import type { StatuslinePalette } from "./themes.ts";
11
11
  const RESET = "\x1b[0m";
12
12
  const BOLD = "\x1b[1m";
13
13
 
14
- export interface Rgb {
14
+ interface Rgb {
15
15
  r: number;
16
16
  g: number;
17
17
  b: number;
18
18
  }
19
19
 
20
- export const fgCode = ({ r, g, b }: Rgb): string =>
20
+ const fgCode = ({ r, g, b }: Rgb): string =>
21
21
  `\x1b[38;2;${Math.round(r)};${Math.round(g)};${Math.round(b)}m`;
22
22
 
23
23
  /** Parse an SGR true-colour prefix back into channels; anything else reads as white. */
@@ -68,7 +68,7 @@ function hue(degrees: number): Rgb {
68
68
  }
69
69
 
70
70
  /** Colour every character of `badge` for one frame. */
71
- export type CelebrationStyle = (badge: string, frame: number, palette: StatuslinePalette) => string;
71
+ type CelebrationStyle = (badge: string, frame: number, palette: StatuslinePalette) => string;
72
72
 
73
73
  const uniform = (badge: string, colour: Rgb): string => `${BOLD}${fgCode(colour)}${badge}${RESET}`;
74
74
 
package/index.ts CHANGED
@@ -124,7 +124,7 @@ function renderRepository(
124
124
  return part;
125
125
  }
126
126
 
127
- export function renderCacheCelebrationLine(
127
+ function renderCacheCelebrationLine(
128
128
  summary: string,
129
129
  celebration: CacheCelebrationSnapshot,
130
130
  palette: StatuslinePalette = DEFAULT_PALETTE,
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.3",
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": [
@@ -23,7 +23,7 @@
23
23
  },
24
24
  "homepage": "https://github.com/hank-warren/pi-extensions/tree/main/packages/pi-statusline#readme",
25
25
  "engines": {
26
- "node": ">=18.0.0"
26
+ "node": ">=22.19.0"
27
27
  },
28
28
  "pi": {
29
29
  "extensions": [
package/redraw.ts CHANGED
@@ -33,7 +33,7 @@ export interface RedrawTarget {
33
33
  requestRender(force?: boolean): void;
34
34
  }
35
35
 
36
- export interface FullRedrawSchedulerOptions {
36
+ interface FullRedrawSchedulerOptions {
37
37
  minGapMs?: number;
38
38
  rowRefreshIntervalMs?: number;
39
39
  idleIntervalMs?: number;
package/settings-menu.ts CHANGED
@@ -23,7 +23,7 @@ export const ON = "on";
23
23
  export const OFF = "off";
24
24
  const TOGGLE_VALUES = [ON, OFF];
25
25
 
26
- export interface BooleanRow {
26
+ interface BooleanRow {
27
27
  id: BooleanSettingKey;
28
28
  label: string;
29
29
  description: string;
package/settings.ts CHANGED
@@ -2,6 +2,7 @@ import { mkdir, readFile, rename, unlink, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, isAbsolute, join } from "node:path";
4
4
  import { pid } from "node:process";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
6
  import {
6
7
  type CelebrationStyleName,
7
8
  DEFAULT_CELEBRATION_STYLE,
@@ -34,7 +35,7 @@ export interface StatuslineSettings extends Record<BooleanSettingKey, boolean> {
34
35
  repoAliases: Record<string, string>;
35
36
  }
36
37
 
37
- export function defaultWorktreeRoot(home: string = homedir()): string {
38
+ function defaultWorktreeRoot(home: string = homedir()): string {
38
39
  return join(home || homedir(), "repos", "worktrees");
39
40
  }
40
41
 
@@ -56,8 +57,17 @@ export function defaultSettings(home: string = homedir()): StatuslineSettings {
56
57
  };
57
58
  }
58
59
 
59
- export function defaultSettingsPath(home: string = homedir()): string {
60
- return join(home || homedir(), ".pi", "agent", "statusline-settings.json");
60
+ /**
61
+ * Where the settings live when the caller names no path.
62
+ *
63
+ * The agent dir, not the home dir: pi honours `PI_CODING_AGENT_DIR`, so a
64
+ * session running against a scratch agent dir must save its statusline settings
65
+ * there rather than into the host's real `~/.pi/agent`. `home` still shapes the
66
+ * settings' *content* (the worktree root default, `~` collapsing) — that is a
67
+ * different thing and keeps its own parameter.
68
+ */
69
+ export function defaultSettingsPath(): string {
70
+ return join(getAgentDir(), "statusline-settings.json");
61
71
  }
62
72
 
63
73
  /** Expand a leading `~` (and `$HOME`) against `home`; other paths are returned as-is. */
@@ -78,7 +88,7 @@ export function collapseHome(path: string, home: string = homedir()): string {
78
88
  return path.startsWith(`${base}/`) ? `~/${path.slice(base.length + 1)}` : path;
79
89
  }
80
90
 
81
- export interface WorktreeRootResult {
91
+ interface WorktreeRootResult {
82
92
  path?: string;
83
93
  error?: string;
84
94
  }
@@ -109,7 +119,7 @@ function normalizeAliases(value: unknown): Record<string, string> | undefined {
109
119
  return aliases;
110
120
  }
111
121
 
112
- export interface NormalizedSettings {
122
+ interface NormalizedSettings {
113
123
  settings: StatuslineSettings;
114
124
  /** Top-level keys this version does not know about, preserved on write. */
115
125
  extra: Record<string, unknown>;
@@ -225,7 +235,7 @@ export function serializeSettings(
225
235
  return out;
226
236
  }
227
237
 
228
- export interface SettingsStoreOptions {
238
+ interface SettingsStoreOptions {
229
239
  path?: string;
230
240
  home?: string;
231
241
  }
@@ -242,7 +252,7 @@ export class SettingsStore {
242
252
 
243
253
  constructor(options: SettingsStoreOptions = {}) {
244
254
  this.home = options.home ?? homedir();
245
- this.path = options.path ?? defaultSettingsPath(this.home);
255
+ this.path = options.path ?? defaultSettingsPath();
246
256
  this.current = defaultSettings(this.home);
247
257
  }
248
258
 
package/usage.ts CHANGED
@@ -1,10 +1,18 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile, rename, writeFile } from "node:fs/promises";
3
- import { homedir } from "node:os";
4
3
  import { join } from "node:path";
5
4
  import { pid } from "node:process";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
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
  /**
@@ -45,7 +84,7 @@ export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
45
84
  * process's fresh values appear within one tick instead of at the next turn.
46
85
  * Request volume is unchanged: both throttles still gate every fetch.
47
86
  */
48
- export const USAGE_TICK_INTERVAL_MS = 10_000;
87
+ const USAGE_TICK_INTERVAL_MS = 10_000;
49
88
  const FETCH_TIMEOUT_MS = 10_000;
50
89
  const ONE_DAY_SECONDS = 86_400;
51
90
 
@@ -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;
@@ -182,13 +283,25 @@ type ProviderKey = "claude" | "codex";
182
283
  const PROVIDER_KEYS = ["claude", "codex"] as const;
183
284
 
184
285
  /** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
185
- export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
286
+ const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
186
287
  claude: "anthropic",
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
- export type UsageAccounts = Record<ProviderKey, string>;
298
+ type UsageAccounts = Record<ProviderKey, string>;
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
+ }
192
305
 
193
306
  function inFamily(providerId: string | undefined, base: string): boolean {
194
307
  return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
@@ -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;
@@ -354,8 +477,11 @@ export class UsageTracker {
354
477
  private tickHandle: unknown;
355
478
 
356
479
  constructor(options: UsageTrackerOptions = {}) {
357
- this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
358
- this.cachePath = options.cachePath ?? join(homedir(), ".pi", "agent", "statusline-usage.json");
480
+ // getAgentDir(), not ~/.pi/agent: pi honours PI_CODING_AGENT_DIR, and a
481
+ // hardcoded home path made a session pointed at another agent dir read the
482
+ // wrong credentials and write its usage cache into the host's real one.
483
+ this.authPath = options.authPath ?? join(getAgentDir(), "auth.json");
484
+ this.cachePath = options.cachePath ?? join(getAgentDir(), "statusline-usage.json");
359
485
  this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
360
486
  this.onChange = options.onChange;
361
487
  this.now = options.now ?? Date.now;
@@ -398,11 +524,12 @@ export class UsageTracker {
398
524
  }
399
525
 
400
526
  /**
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.
527
+ * Interval until the next allowed attempt for one family: the short retry
528
+ * window while a credentialed provider is still missing a value, that
529
+ * family's own steady-state interval otherwise.
403
530
  */
404
- private currentInterval(): number {
405
- return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : USAGE_REFRESH_INTERVAL_MS;
531
+ private currentInterval(key: ProviderKey): number {
532
+ return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : PROVIDER_REFRESH_INTERVAL_MS[key];
406
533
  }
407
534
 
408
535
  /** Throttled refresh; resolves when the current attempt (if any) settles. */
@@ -432,21 +559,32 @@ export class UsageTracker {
432
559
  // Another process's values are as good as ours and cost no request, so a new
433
560
  // session — or a switch back to an account polled earlier — shows real
434
561
  // numbers on its very first render.
435
- this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
436
-
437
562
  const now = this.now();
438
- const interval = this.currentInterval();
563
+ this.publish(this.merge(...cachedSnapshot(cache, accounts, now), auth, accounts, now), auth, accounts, cache);
564
+
439
565
  const pollable: Partial<Record<ProviderKey, string>> = {};
440
566
  for (const key of PROVIDER_KEYS) {
441
567
  const accountId = accounts[key];
442
568
  if (!hasAuth(auth, accountId)) continue;
443
569
  const entry = cache.accounts[accountId];
570
+ // A backoff outranks everything: a rate-limited endpoint does not want to
571
+ // hear from us at a window boundary either.
444
572
  if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
445
573
  const lastAttempt = Math.max(
446
574
  this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
447
575
  entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
448
576
  );
449
- if (now - lastAttempt < interval) continue;
577
+ // Past a window boundary the stored percentages are wrong rather than
578
+ // merely old, so the interval is skipped to republish promptly — but only
579
+ // for a boundary we have not already answered. A failed poll leaves the
580
+ // boundary in the past, so an unconditional override would poll on every
581
+ // tick for as long as the failure lasted. A 429 sets a backoff that stops
582
+ // that; a 500, a timeout or a dropped connection does not. Answering each
583
+ // boundary once hands the account back to the ordinary intervals, which
584
+ // for a provider now holding no value is the short retry window.
585
+ const boundary = entry?.resetsAt;
586
+ const boundaryUnanswered = hasReset(boundary, now) && (boundary as number) > lastAttempt;
587
+ if (!boundaryUnanswered && now - lastAttempt < this.currentInterval(key)) continue;
450
588
  pollable[key] = accountId;
451
589
  }
452
590
  if (pollable.claude === undefined && pollable.codex === undefined) return;
@@ -467,10 +605,15 @@ export class UsageTracker {
467
605
  }
468
606
  await this.writeCache({ accounts: claimed });
469
607
 
470
- const [claude, codex] = await Promise.all([
608
+ const [claudeResult, codexResult] = await Promise.all([
471
609
  pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
472
610
  pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
473
611
  ]);
612
+ const claude = claudeResult?.value;
613
+ const codex = codexResult?.value;
614
+ const incomingResets: Partial<Record<ProviderKey, number>> = {};
615
+ if (claudeResult?.resetsAt !== undefined) incomingResets.claude = claudeResult.resetsAt;
616
+ if (codexResult?.resetsAt !== undefined) incomingResets.codex = codexResult.resetsAt;
474
617
 
475
618
  const updatedAccounts = { ...claimed };
476
619
  for (const key of PROVIDER_KEYS) {
@@ -478,18 +621,33 @@ export class UsageTracker {
478
621
  if (accountId === undefined) continue;
479
622
  const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
480
623
  const value = key === "claude" ? claude : codex;
481
- if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
624
+ // A `Retry-After` we could use is authoritative; the flat window is only a
625
+ // guess for a 429 that told us nothing. Deliberately a plain assignment:
626
+ // the poll gate above skips any account whose backoff is still in the
627
+ // future, so a value we are about to overwrite is necessarily absent or
628
+ // already expired, and a monotonic max() here would never do anything.
629
+ if (this.rateLimited.has(key)) {
630
+ entry.backoff = this.rateLimited.get(key) ?? now + USAGE_RATE_LIMIT_BACKOFF_MS;
631
+ }
482
632
  else if (value !== undefined) delete entry.backoff;
483
633
  // A failed fetch leaves the last-known value in place rather than blanking
484
634
  // the meter for whatever caused one bad response.
485
635
  if (claude !== undefined && key === "claude") entry.claude = claude;
486
636
  if (codex !== undefined && key === "codex") entry.codex = codex;
637
+ // The boundary travels with the value it describes: a refreshed value gets
638
+ // the new window, and one that arrived without a usable reset must not
639
+ // inherit the previous window's.
640
+ if (value !== undefined) {
641
+ const resetsAt = incomingResets[key];
642
+ if (resetsAt === undefined) delete entry.resetsAt;
643
+ else entry.resetsAt = resetsAt;
644
+ }
487
645
  updatedAccounts[accountId] = entry;
488
646
  }
489
647
  this.rateLimited.clear();
490
648
 
491
649
  const updated: UsageCache = { accounts: updatedAccounts };
492
- this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
650
+ this.publish(this.merge({ claude, codex }, incomingResets, auth, accounts, now), auth, accounts, updated);
493
651
  await this.writeCache(updated);
494
652
  }
495
653
 
@@ -503,6 +661,7 @@ export class UsageTracker {
503
661
  this.identities = identity;
504
662
  if (changedKeys.length === 0) return;
505
663
  for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
664
+ for (const key of changedKeys) delete this.currentResets[key];
506
665
  if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
507
666
  const next = { ...this.current };
508
667
  for (const key of changedKeys) delete next[key];
@@ -516,21 +675,44 @@ export class UsageTracker {
516
675
  * account has no credentials: a logged-out account must disappear immediately,
517
676
  * while a failed fetch keeps whatever we last saw.
518
677
  */
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;
678
+ private merge(
679
+ incoming: UsageSnapshot,
680
+ incomingResets: Partial<Record<ProviderKey, number>>,
681
+ auth: AuthEntries,
682
+ accounts: UsageAccounts,
683
+ now: number,
684
+ ): { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> } {
685
+ const snapshot: UsageSnapshot = {};
686
+ const resets: Partial<Record<ProviderKey, number>> = {};
687
+ for (const key of PROVIDER_KEYS) {
688
+ if (!hasAuth(auth, accounts[key])) continue;
689
+ if (incoming[key] !== undefined) {
690
+ // biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
691
+ snapshot[key] = incoming[key] as any;
692
+ if (incomingResets[key] !== undefined) resets[key] = incomingResets[key];
693
+ continue;
694
+ }
695
+ // A last-known-good value survives a failed fetch, but not the rollover of
696
+ // the window it describes.
697
+ if (this.current[key] === undefined || hasReset(this.currentResets[key], now)) continue;
698
+ // biome-ignore lint/suspicious/noExplicitAny: one assignment, two value shapes.
699
+ snapshot[key] = this.current[key] as any;
700
+ if (this.currentResets[key] !== undefined) resets[key] = this.currentResets[key];
528
701
  }
529
- return next;
702
+ return { snapshot, resets };
530
703
  }
531
704
 
532
705
  /** 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 {
706
+ private publish(
707
+ merged: { snapshot: UsageSnapshot; resets: Partial<Record<ProviderKey, number>> },
708
+ auth: AuthEntries,
709
+ accounts: UsageAccounts,
710
+ cache: UsageCache,
711
+ ): void {
712
+ const next = merged.snapshot;
713
+ // Boundaries follow their values even when the percentages are unchanged, so
714
+ // a refreshed window cannot be judged against the previous one's deadline.
715
+ this.currentResets = merged.resets;
534
716
  const now = this.now();
535
717
  // An account serving 429s is not "pending": retrying it faster is exactly
536
718
  // what got us rate limited, so it must not hold the short window open.
@@ -579,7 +761,9 @@ export class UsageTracker {
579
761
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
580
762
  try {
581
763
  const response = await this.fetchFn(url, { headers, signal: controller.signal });
582
- if (response.status === 429) this.rateLimited.add(provider);
764
+ if (response.status === 429) {
765
+ this.rateLimited.set(provider, parseRetryAfter(response.headers?.get("retry-after"), this.now()));
766
+ }
583
767
  if (!response.ok) return undefined;
584
768
  return await response.json();
585
769
  } finally {
@@ -587,47 +771,64 @@ export class UsageTracker {
587
771
  }
588
772
  }
589
773
 
590
- private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
774
+ private async fetchClaude(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"claude"> | undefined> {
591
775
  const access = auth[accountId]?.access;
592
776
  if (typeof access !== "string" || access.length === 0) return undefined;
593
777
  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
- ),
778
+ const json = await this.fetchJson(
779
+ CLAUDE_USAGE_URL,
780
+ { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
781
+ "claude",
600
782
  );
783
+ const value = parseClaudeUsage(json);
784
+ return value === undefined ? undefined : { value, resetsAt: claudeResetAt(json) };
601
785
  } catch {
602
786
  return undefined;
603
787
  }
604
788
  }
605
789
 
606
- private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
790
+ private async fetchCodex(auth: AuthEntries, accountId: string): Promise<FetchedUsage<"codex"> | undefined> {
607
791
  const entry = auth[accountId];
608
792
  const access = entry?.access;
609
793
  const account = entry?.accountId;
610
794
  if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
611
795
  try {
612
- return parseCodexUsage(
613
- await this.fetchJson(
614
- CODEX_USAGE_URL,
615
- { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
616
- "codex",
617
- ),
796
+ const json = await this.fetchJson(
797
+ CODEX_USAGE_URL,
798
+ { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
799
+ "codex",
618
800
  );
801
+ const value = parseCodexUsage(json);
802
+ // Codex may report only a relative reset, which is meaningless without the
803
+ // clock the rest of the tracker runs on.
804
+ return value === undefined ? undefined : { value, resetsAt: codexResetAt(json, this.now()) };
619
805
  } catch {
620
806
  return undefined;
621
807
  }
622
808
  }
623
809
  }
624
810
 
625
- /** The cached values for the currently selected accounts, in display shape. */
626
- function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
811
+ /**
812
+ * The cached values for the currently selected accounts, in display shape.
813
+ * Values whose window has already rolled are dropped: adopting another
814
+ * process's stale reading is exactly as wrong as keeping our own.
815
+ */
816
+ function cachedSnapshot(
817
+ cache: UsageCache,
818
+ accounts: UsageAccounts,
819
+ now: number,
820
+ ): [UsageSnapshot, Partial<Record<ProviderKey, number>>] {
627
821
  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;
822
+ const resets: Partial<Record<ProviderKey, number>> = {};
823
+ const claudeEntry = cache.accounts[accounts.claude];
824
+ const codexEntry = cache.accounts[accounts.codex];
825
+ if (claudeEntry?.claude && !hasReset(claudeEntry.resetsAt, now)) {
826
+ snapshot.claude = claudeEntry.claude;
827
+ if (claudeEntry.resetsAt !== undefined) resets.claude = claudeEntry.resetsAt;
828
+ }
829
+ if (codexEntry?.codex && !hasReset(codexEntry.resetsAt, now)) {
830
+ snapshot.codex = codexEntry.codex;
831
+ if (codexEntry.resetsAt !== undefined) resets.codex = codexEntry.resetsAt;
832
+ }
833
+ return [snapshot, resets];
633
834
  }
package/worktrees.ts CHANGED
@@ -29,7 +29,7 @@ interface WorktreeMetadata {
29
29
  branch: string;
30
30
  }
31
31
 
32
- export interface WorktreeTrackerHost {
32
+ interface WorktreeTrackerHost {
33
33
  exec(
34
34
  command: string,
35
35
  args: string[],