@hank-warren/pi-statusline 0.4.2 → 0.6.0

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,23 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 01994c7: Show the Codex 5-hour window alongside the weekly one. Codex has restored a 5-hour limit next to the weekly one on some plans, and the parser already received that window and deliberately discarded it — it kept only the largest window spanning at least a day — so a real limit went unshown. Both are now rendered like the Claude meter: 5-hour, dim separator, weekly.
8
+
9
+ Which windows exist is a property of the account, so the two slots are filled from each window's span rather than from the plan name: the shortest sub-day window fills the 5-hour slot and the longest multi-day window fills the other. A plan with no 5-hour limit keeps rendering its single weekly number, and a free plan's lone 30-day window keeps rendering in the same place. A payload carrying only a sub-day window now reports it as the 5-hour value instead of mislabelling it as weekly.
10
+
11
+ The host-wide usage cache changes additively: an entry written by an earlier version has no 5-hour value and renders weekly-only until the next poll, and an earlier version ignores the extra one.
12
+
13
+ ## 0.5.0
14
+
15
+ ### Minor Changes
16
+
17
+ - 0e9400e: Point each subscription usage meter at the account behind the main model, so additional provider logins report the headroom actually being spent. Families the main model does not belong to keep showing their base account, and a login used only for background work is never polled.
18
+
19
+ The host-wide usage cache is now keyed by credential id rather than by provider family. Keyed by family, two sessions on two different Anthropic accounts each looked like an account switch to the other, evicting each other's values and re-polling every cycle. Cache files written by earlier versions are discarded, costing one extra poll on upgrade.
20
+
3
21
  ## 0.4.2
4
22
 
5
23
  ### Patch Changes
package/README.md CHANGED
@@ -71,13 +71,27 @@ Up to 0.2.x this package hardcoded five alias pairs and stripped a `platform-` p
71
71
  When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Claude subscription) and/or OpenAI Codex, line 1 shows **percent remaining** for each rate-limit window after the context meter:
72
72
 
73
73
  - ` 97·54` — Claude 5-hour, then weekly remaining percent (Nerd Font `nf-cod-claude` icon). Subscriptions with a model-scoped weekly limit (e.g. Fable) show it as a third number — ` 97·54·24` — and it is omitted when the account has none.
74
- - ` 80` — Codex weekly remaining percent (`nf-cod-openai` icon)
74
+ - ` 92·99` — Codex 5-hour, then weekly remaining percent (`nf-cod-openai` icon). Which windows exist is a property of the plan, so the slots are filled from the payload's window spans rather than from its plan name: a plan with no 5-hour limit shows the weekly number alone — ` 45` — and a free plan, whose single window is monthly, shows that one number in the same place.
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
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.
79
79
 
80
- 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`: it holds the last good snapshot plus the time the last poll was *started*, written atomically via a temp file and rename. A session adopts the cached values on its first refresh — so the meters are populated before it has issued a single request — and only polls when that shared timestamp is older than the interval. A provider answering `429` is parked for fifteen minutes (tracked per provider, 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. Each provider's cache entry is keyed to a fingerprint (a sha256 prefix, never the token itself) of the credentials that fetched it: switching accounts — or rotating a token — discards that provider's cached numbers and backoff and polls immediately, so an exhausted old account's meters never masquerade as the new account's. Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
80
+ ### Which account each meter shows
81
+
82
+ With a single login per provider — the ordinary case, including logging out and back in as a different Anthropic account — nothing here applies: each meter shows that provider's account, exactly as before.
83
+
84
+ When [`@hank-warren/pi-multi-login`](../pi-multi-login/README.md) has registered additional logins (`anthropic-work`, `openai-codex-alt`), a provider family can hold several accounts at once. Each meter then shows **the account behind the main model**, falling back to the base account (`anthropic`, `openai-codex`) when the main model belongs to the other family. So switching the main model between two Claude logins swaps the Claude meter and leaves the Codex meter alone, and a login used only for background work — such as a `pi-auto-permissions` reviewer, which is never the main model — is never polled at all. There is deliberately no marker for *which* account is shown: the meter tracks whatever you are actually spending.
85
+
86
+ ### Polling and the shared cache
87
+
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
+
90
+ 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
+
92
+ 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.
93
+
94
+ Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
81
95
 
82
96
  ## Cache-hit celebration
83
97
 
package/index.ts CHANGED
@@ -71,15 +71,21 @@ export function renderUsageSegment(
71
71
  palette: StatuslinePalette = DEFAULT_PALETTE,
72
72
  ): string | undefined {
73
73
  const percent = (remaining: number) => styled(bandColor(remaining, palette), `${remaining}`);
74
+ const dot = styled(palette.dim, "\u00b7");
74
75
  const parts: string[] = [];
75
76
  if (usage.claude) {
76
- const dot = styled(palette.dim, "\u00b7");
77
77
  let claude = `${percent(usage.claude.fiveHour)}${dot}${percent(usage.claude.sevenDay)}`;
78
78
  if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${percent(usage.claude.scopedWeekly)}`;
79
79
  parts.push(`${styled(palette.text, CLAUDE_ICON)} ${claude}`);
80
80
  }
81
81
  if (usage.codex) {
82
- parts.push(`${styled(palette.text, OPENAI_ICON)} ${percent(usage.codex.weekly)}`);
82
+ // Shortest window first, mirroring Claude. Plans without a 5-hour limit
83
+ // leave that slot empty and keep rendering the single long-window number.
84
+ const codex = [usage.codex.fiveHour, usage.codex.weekly]
85
+ .filter((remaining): remaining is number => remaining !== undefined)
86
+ .map(percent)
87
+ .join(dot);
88
+ if (codex.length > 0) parts.push(`${styled(palette.text, OPENAI_ICON)} ${codex}`);
83
89
  }
84
90
  return parts.length > 0 ? parts.join(" ") : undefined;
85
91
  }
@@ -264,7 +270,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
264
270
  cwdStatusAbort = new AbortController();
265
271
  cwdStatusInFlight = undefined;
266
272
  runInBackground(refreshCwdStatus(ctx));
267
- if (settings.showUsage) runInBackground(usageTracker.refresh());
273
+ if (settings.showUsage) {
274
+ usageTracker.setActiveProvider(ctx.model?.provider);
275
+ runInBackground(usageTracker.refresh());
276
+ }
268
277
  // A hidden worktree line must not pay for git/gh polling.
269
278
  if (!settings.showWorktrees) return;
270
279
  const next = new SessionWorktreeTracker({
@@ -446,9 +455,20 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
446
455
  requestRender?.();
447
456
  runInBackground(refreshCwdStatus(ctx));
448
457
  if (tracker) runInBackground(tracker.refresh());
449
- if (settings.showUsage) runInBackground(usageTracker.refresh());
458
+ if (settings.showUsage) {
459
+ usageTracker.setActiveProvider(ctx.model?.provider);
460
+ runInBackground(usageTracker.refresh());
461
+ }
462
+ });
463
+ // The meters follow the main model's account, so a switch between two logins
464
+ // of the same provider family has to re-point the tracker before it repaints.
465
+ pi.on("model_select", (event) => {
466
+ if (settings.showUsage) {
467
+ usageTracker.setActiveProvider(event.model.provider);
468
+ runInBackground(usageTracker.refresh());
469
+ }
470
+ requestRender?.();
450
471
  });
451
- pi.on("model_select", () => requestRender?.());
452
472
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
453
473
  pi.on("session_shutdown", () => {
454
474
  cacheCelebration.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
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
@@ -7,7 +7,12 @@ import { pid } from "node:process";
7
7
  /** Remaining (not used) integer percents per provider window. */
8
8
  export interface UsageSnapshot {
9
9
  claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
10
- codex?: { weekly: number };
10
+ /**
11
+ * Codex reports one or two windows depending on the plan: a short (5-hour)
12
+ * window, a long one, or both. Every field is optional because which of them
13
+ * exists is a property of the account, not of the payload shape.
14
+ */
15
+ codex?: { fiveHour?: number; weekly?: number };
11
16
  }
12
17
 
13
18
  const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
@@ -25,7 +30,7 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
25
30
  */
26
31
  export const USAGE_RETRY_INTERVAL_MS = 30_000;
27
32
  /**
28
- * How long a provider is left alone after it answers 429. Polls are host-wide
33
+ * How long an account is left alone after it answers 429. Polls are host-wide
29
34
  * (see the shared cache below), so a rate limit means the provider itself wants
30
35
  * a break rather than that we are racing ourselves.
31
36
  */
@@ -79,7 +84,19 @@ interface CodexWindow {
79
84
  limit_window_seconds?: unknown;
80
85
  }
81
86
 
82
- /** Parse the Codex usage payload, selecting the weekly (largest 1 day) window. */
87
+ /** A window whose span is known, so it can be sorted into a slot. */
88
+ interface SpannedCodexWindow extends CodexWindow {
89
+ limit_window_seconds: number;
90
+ }
91
+
92
+ /**
93
+ * Parse the Codex usage payload into the short (sub-day) and long (≥ 1 day)
94
+ * windows. Which ones exist depends on the plan — $20 plans report a 5-hour
95
+ * window alongside the weekly one, larger plans have reported weekly only, and
96
+ * free plans report a single 30-day window that lands in the long slot. So the
97
+ * slots are filled from `limit_window_seconds` rather than from `plan_type`,
98
+ * and an absent window simply leaves its slot empty.
99
+ */
83
100
  export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
84
101
  if (typeof json !== "object" || json === null) return undefined;
85
102
  const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
@@ -88,14 +105,27 @@ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefin
88
105
  primary_window?: CodexWindow | null;
89
106
  secondary_window?: CodexWindow | null;
90
107
  };
108
+ // A window without a usable span cannot be placed in either slot.
91
109
  const windows = [primary_window, secondary_window].filter(
92
- (window): window is CodexWindow => typeof window === "object" && window !== null,
110
+ (window): window is SpannedCodexWindow =>
111
+ typeof window === "object" &&
112
+ window !== null &&
113
+ typeof window.limit_window_seconds === "number" &&
114
+ Number.isFinite(window.limit_window_seconds),
93
115
  );
94
- const weekly = windows
95
- .filter((window) => typeof window.limit_window_seconds === "number" && window.limit_window_seconds >= ONE_DAY_SECONDS)
96
- .sort((a, b) => (b.limit_window_seconds as number) - (a.limit_window_seconds as number))[0];
97
- const remaining = toRemaining((weekly ?? windows[0])?.used_percent);
98
- return remaining === undefined ? undefined : { weekly: remaining };
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;
99
129
  }
100
130
 
101
131
  /** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
@@ -118,124 +148,167 @@ export interface UsageTrackerOptions {
118
148
  fetchFn?: FetchFn;
119
149
  onChange?: () => void;
120
150
  now?: () => number;
151
+ /** Provider id of the session's main model; selects which account is polled. */
152
+ activeProvider?: string;
121
153
  }
122
154
 
123
155
  type ProviderKey = "claude" | "codex";
124
156
 
125
157
  const PROVIDER_KEYS = ["claude", "codex"] as const;
126
158
 
127
- /** Non-reversible per-provider credential fingerprints; never raw tokens. */
159
+ /** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
160
+ export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
161
+ claude: "anthropic",
162
+ codex: "openai-codex",
163
+ };
164
+
165
+ /** Credential id polled for each family. */
166
+ export type UsageAccounts = Record<ProviderKey, string>;
167
+
168
+ function inFamily(providerId: string | undefined, base: string): boolean {
169
+ return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
170
+ }
171
+
172
+ /** The family an account id belongs to, or undefined when it is neither. */
173
+ function familyOf(accountId: string): ProviderKey | undefined {
174
+ return PROVIDER_KEYS.find((key) => inFamily(accountId, USAGE_BASE_PROVIDERS[key]));
175
+ }
176
+
177
+ /**
178
+ * Choose the account whose meter each family shows.
179
+ *
180
+ * Additional logins (@hank-warren/pi-multi-login) mean a family can hold several
181
+ * accounts at once. The one worth showing is the one the session is actually
182
+ * spending: the provider of the main model. Every other family falls back to its
183
+ * base account, which is what makes a backup provider's meter stay visible while
184
+ * you are not using it — and what keeps a dedicated background-reviewer login
185
+ * (never the main model) from ever being polled.
186
+ */
187
+ export function resolveUsageAccounts(activeProvider: string | undefined): UsageAccounts {
188
+ const accounts = { ...USAGE_BASE_PROVIDERS };
189
+ for (const key of PROVIDER_KEYS) {
190
+ if (inFamily(activeProvider, USAGE_BASE_PROVIDERS[key])) accounts[key] = activeProvider as string;
191
+ }
192
+ return accounts;
193
+ }
194
+
195
+ /** Non-reversible per-family credential fingerprints; never raw tokens. */
128
196
  type ProviderIdentities = Partial<Record<ProviderKey, string>>;
129
197
 
130
198
  function fingerprint(material: string): string {
131
199
  return createHash("sha256").update(material).digest("hex").slice(0, 16);
132
200
  }
133
201
 
202
+ interface AuthEntry {
203
+ access?: unknown;
204
+ accountId?: unknown;
205
+ }
206
+
207
+ type AuthEntries = Record<string, AuthEntry | undefined>;
208
+
134
209
  /**
135
- * Fingerprint the credentials each provider would be polled with. Usage
136
- * percentages belong to an account, so a cached value is only trustworthy
137
- * while the credentials that produced it are still the ones in auth.json —
138
- * after an account switch, yesterday's "1% left" is somebody else's meter.
210
+ * Fingerprint the credential an account would be polled with, or undefined when
211
+ * it cannot be polled at all. Usage percentages belong to an account, so a
212
+ * cached value is only trustworthy while the credential that produced it is
213
+ * still the one in auth.json — after an account switch, yesterday's "1% left"
214
+ * is somebody else's meter.
139
215
  */
140
- function credentialIdentities(auth: AuthEntries): ProviderIdentities {
216
+ function accountFingerprint(auth: AuthEntries, accountId: string): string | undefined {
217
+ const entry = auth[accountId];
218
+ const access = entry?.access;
219
+ if (typeof access !== "string" || access.length === 0) return undefined;
220
+ if (familyOf(accountId) === "codex") {
221
+ const account = entry?.accountId;
222
+ if (typeof account !== "string" || account.length === 0) return undefined;
223
+ return fingerprint(`${access}\n${account}`);
224
+ }
225
+ return fingerprint(access);
226
+ }
227
+
228
+ function hasAuth(auth: AuthEntries, accountId: string): boolean {
229
+ return accountFingerprint(auth, accountId) !== undefined;
230
+ }
231
+
232
+ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): ProviderIdentities {
141
233
  const identities: ProviderIdentities = {};
142
- if (hasClaudeAuth(auth)) identities.claude = fingerprint(String(auth.anthropic?.access));
143
- if (hasCodexAuth(auth)) {
144
- const entry = auth["openai-codex"];
145
- identities.codex = fingerprint(`${entry?.access as string}\n${entry?.accountId as string}`);
234
+ for (const key of PROVIDER_KEYS) {
235
+ const value = accountFingerprint(auth, accounts[key]);
236
+ if (value !== undefined) identities[key] = value;
146
237
  }
147
238
  return identities;
148
239
  }
149
240
 
241
+ /** One account's shared poll state. Only the family's own field is ever set. */
242
+ interface UsageAccountEntry {
243
+ /** Credential fingerprint the stored values were fetched with. */
244
+ identity: string;
245
+ /** Last time any process started a poll for this account. */
246
+ attemptedAt: number;
247
+ /** Absolute time before which a rate-limited account must not be polled. */
248
+ backoff?: number;
249
+ claude?: UsageSnapshot["claude"];
250
+ codex?: UsageSnapshot["codex"];
251
+ }
252
+
150
253
  /**
151
254
  * Host-wide poll state shared by every pi process. Usage percentages are a
152
- * property of the account, not of a session, so one poll per interval per host
153
- * is both sufficient and necessary: a busy host runs dozens of pi processes, and
154
- * per-process polling stampedes the endpoints into rate limiting everyone.
255
+ * property of the account, not of a session, so one poll per interval per
256
+ * account is both sufficient and necessary: a busy host runs dozens of pi
257
+ * processes, and per-process polling stampedes the endpoints into rate limiting
258
+ * everyone.
259
+ *
260
+ * Keyed by credential id rather than by family, because two sessions on the
261
+ * same host may legitimately be using two different Anthropic accounts. Keying
262
+ * by family made each of them look like an account switch to the other, so they
263
+ * evicted each other's values and re-polled on every cycle — the exact
264
+ * stampede this cache exists to prevent.
155
265
  */
156
266
  interface UsageCache {
157
- /** Last time any process started a poll; gates the shared throttle. */
158
- attemptedAt: number;
159
- /** Absolute times before which a rate-limited provider must not be polled. */
160
- backoff: Partial<Record<ProviderKey, number>>;
161
- snapshot: UsageSnapshot;
162
- /** Credential fingerprints the snapshot was fetched with. */
163
- identity: ProviderIdentities;
267
+ accounts: Record<string, UsageAccountEntry>;
164
268
  }
165
269
 
166
- const EMPTY_CACHE: UsageCache = {
167
- attemptedAt: Number.NEGATIVE_INFINITY,
168
- backoff: {},
169
- snapshot: {},
170
- identity: {},
171
- };
270
+ const EMPTY_CACHE: UsageCache = { accounts: {} };
172
271
 
173
272
  /**
174
- * Drop cached values that were fetched with different credentials. A token
175
- * rotation looks the same as an account switch here; the cost of treating it
176
- * as one is a single immediate re-poll, while trusting a stale identity means
177
- * showing another account's numbers as if they were current.
273
+ * Drop entries whose credential no longer matches auth.json. A token rotation
274
+ * looks the same as an account switch here; the cost of treating it as one is a
275
+ * single immediate re-poll, while trusting a stale identity means showing
276
+ * another account's numbers as if they were current. Logged-out accounts fail
277
+ * the same check, so the file garbage-collects itself.
178
278
  */
179
- function pruneCache(cache: UsageCache, identity: ProviderIdentities): UsageCache {
180
- const snapshot = { ...cache.snapshot };
181
- const backoff = { ...cache.backoff };
279
+ function pruneCache(cache: UsageCache, auth: AuthEntries): UsageCache {
280
+ const accounts: Record<string, UsageAccountEntry> = {};
182
281
  let changed = false;
183
- for (const key of PROVIDER_KEYS) {
184
- if (cache.identity[key] === identity[key]) continue;
185
- if (snapshot[key] !== undefined || backoff[key] !== undefined) changed = true;
186
- delete snapshot[key];
187
- delete backoff[key];
282
+ for (const [accountId, entry] of Object.entries(cache.accounts)) {
283
+ if (accountFingerprint(auth, accountId) === entry.identity) accounts[accountId] = entry;
284
+ else changed = true;
188
285
  }
189
- if (!changed) return cache;
190
- // The pruned values also claimed the shared throttle slot; the new account's
191
- // first poll must not wait out a window started for somebody else.
192
- return { attemptedAt: Number.NEGATIVE_INFINITY, backoff, snapshot, identity };
286
+ return changed ? { accounts } : cache;
193
287
  }
194
288
 
195
289
  function parseUsageCache(json: unknown): UsageCache | undefined {
196
290
  if (typeof json !== "object" || json === null) return undefined;
197
- const body = json as { attemptedAt?: unknown; backoff?: unknown; snapshot?: unknown; identity?: unknown };
198
- if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) return undefined;
199
- const snapshot = typeof body.snapshot === "object" && body.snapshot !== null ? (body.snapshot as UsageSnapshot) : {};
200
- const backoff: UsageCache["backoff"] = {};
201
- const identity: ProviderIdentities = {};
202
- for (const key of PROVIDER_KEYS) {
203
- if (typeof body.backoff === "object" && body.backoff !== null) {
204
- const value = (body.backoff as Record<string, unknown>)[key];
205
- if (typeof value === "number" && Number.isFinite(value)) backoff[key] = value;
206
- }
207
- if (typeof body.identity === "object" && body.identity !== null) {
208
- const value = (body.identity as Record<string, unknown>)[key];
209
- if (typeof value === "string") identity[key] = value;
210
- }
291
+ const rawAccounts = (json as { accounts?: unknown }).accounts;
292
+ if (typeof rawAccounts !== "object" || rawAccounts === null) return undefined;
293
+ const accounts: Record<string, UsageAccountEntry> = {};
294
+ for (const [accountId, value] of Object.entries(rawAccounts as Record<string, unknown>)) {
295
+ if (typeof value !== "object" || value === null) continue;
296
+ const body = value as Record<string, unknown>;
297
+ if (typeof body.identity !== "string") continue;
298
+ if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
299
+ const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
300
+ if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
301
+ if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
302
+ if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
303
+ accounts[accountId] = entry;
211
304
  }
212
- return { attemptedAt: body.attemptedAt, backoff, snapshot, identity };
213
- }
214
-
215
- interface AuthEntries {
216
- anthropic?: { access?: unknown };
217
- "openai-codex"?: { access?: unknown; accountId?: unknown };
218
- }
219
-
220
- function hasClaudeAuth(auth: AuthEntries): boolean {
221
- const access = auth.anthropic?.access;
222
- return typeof access === "string" && access.length > 0;
223
- }
224
-
225
- function hasCodexAuth(auth: AuthEntries): boolean {
226
- const entry = auth["openai-codex"];
227
- return (
228
- typeof entry?.access === "string" &&
229
- entry.access.length > 0 &&
230
- typeof entry.accountId === "string" &&
231
- entry.accountId.length > 0
232
- );
305
+ return { accounts };
233
306
  }
234
307
 
235
308
  /**
236
309
  * Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
237
310
  * refreshes them), fetches both usage endpoints, and keeps the last-known good
238
- * value per provider. Refreshes are throttled and must never throw.
311
+ * value per account. Refreshes are throttled and must never throw.
239
312
  */
240
313
  export class UsageTracker {
241
314
  private readonly authPath: string;
@@ -245,9 +318,10 @@ export class UsageTracker {
245
318
  private readonly now: () => number;
246
319
  private current: UsageSnapshot = {};
247
320
  private identities: ProviderIdentities = {};
321
+ private activeProvider: string | undefined;
248
322
  private awaitingProvider = true;
249
323
  private readonly rateLimited = new Set<ProviderKey>();
250
- private lastAttempt = Number.NEGATIVE_INFINITY;
324
+ private lastAttempt: Record<string, number> = {};
251
325
  private inFlight: Promise<void> | undefined;
252
326
 
253
327
  constructor(options: UsageTrackerOptions = {}) {
@@ -256,12 +330,22 @@ export class UsageTracker {
256
330
  this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
257
331
  this.onChange = options.onChange;
258
332
  this.now = options.now ?? Date.now;
333
+ this.activeProvider = options.activeProvider;
259
334
  }
260
335
 
261
336
  snapshot(): UsageSnapshot {
262
337
  return this.current;
263
338
  }
264
339
 
340
+ /**
341
+ * Point the meters at the session's main model. A switch between two accounts
342
+ * in the same family changes which one is displayed; the next refresh adopts
343
+ * the new account's cached value if there is one, and polls otherwise.
344
+ */
345
+ setActiveProvider(providerId: string | undefined): void {
346
+ this.activeProvider = providerId;
347
+ }
348
+
265
349
  /**
266
350
  * Interval until the next allowed attempt: the short retry window while a
267
351
  * credentialed provider is still missing a value, the full interval otherwise.
@@ -285,58 +369,89 @@ export class UsageTracker {
285
369
  }
286
370
 
287
371
  /**
288
- * One refresh cycle: adopt whatever another process has already published,
289
- * then poll only if the shared throttle allows it.
372
+ * One refresh cycle: adopt whatever another process has already published for
373
+ * the selected accounts, then poll only those the shared throttle allows.
290
374
  */
291
375
  private async performRefresh(): Promise<void> {
292
376
  const auth = (await this.readAuth()) ?? {};
293
- const identity = credentialIdentities(auth);
294
- const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, identity);
295
- this.pruneCurrent(identity);
377
+ const accounts = resolveUsageAccounts(this.activeProvider);
378
+ const identity = credentialIdentities(auth, accounts);
379
+ const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, auth);
380
+ this.pruneCurrent(identity, accounts);
296
381
  // Another process's values are as good as ours and cost no request, so a new
297
- // session shows real numbers on its very first render.
298
- this.publish(this.merge(cache.snapshot, auth), auth, cache);
382
+ // session or a switch back to an account polled earlier — shows real
383
+ // numbers on its very first render.
384
+ this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
299
385
 
300
386
  const now = this.now();
301
- const lastAttempt = Math.max(this.lastAttempt, cache.attemptedAt);
302
- if (now - lastAttempt < this.currentInterval()) return;
303
- const claudeAllowed = hasClaudeAuth(auth) && now >= (cache.backoff.claude ?? Number.NEGATIVE_INFINITY);
304
- const codexAllowed = hasCodexAuth(auth) && now >= (cache.backoff.codex ?? Number.NEGATIVE_INFINITY);
305
- if (!claudeAllowed && !codexAllowed) return;
306
-
307
- this.lastAttempt = now;
308
- // Claim the slot before fetching so sibling processes skip this window even
387
+ const interval = this.currentInterval();
388
+ const pollable: Partial<Record<ProviderKey, string>> = {};
389
+ for (const key of PROVIDER_KEYS) {
390
+ const accountId = accounts[key];
391
+ if (!hasAuth(auth, accountId)) continue;
392
+ const entry = cache.accounts[accountId];
393
+ if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
394
+ const lastAttempt = Math.max(
395
+ this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
396
+ entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
397
+ );
398
+ if (now - lastAttempt < interval) continue;
399
+ pollable[key] = accountId;
400
+ }
401
+ if (pollable.claude === undefined && pollable.codex === undefined) return;
402
+
403
+ // Claim each slot before fetching so sibling processes skip this window even
309
404
  // if our own request is slow or fails outright. The claim carries the fresh
310
405
  // identity so siblings do not re-prune and stampede the same window.
311
- await this.writeCache({ ...cache, attemptedAt: now, identity });
406
+ const claimed = { ...cache.accounts };
407
+ for (const key of PROVIDER_KEYS) {
408
+ const accountId = pollable[key];
409
+ if (accountId === undefined) continue;
410
+ this.lastAttempt[accountId] = now;
411
+ claimed[accountId] = {
412
+ ...claimed[accountId],
413
+ identity: identity[key] as string,
414
+ attemptedAt: now,
415
+ };
416
+ }
417
+ await this.writeCache({ accounts: claimed });
312
418
 
313
419
  const [claude, codex] = await Promise.all([
314
- claudeAllowed ? this.fetchClaude(auth) : Promise.resolve(undefined),
315
- codexAllowed ? this.fetchCodex(auth) : Promise.resolve(undefined),
420
+ pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
421
+ pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
316
422
  ]);
317
- const backoff = { ...cache.backoff };
318
- for (const key of ["claude", "codex"] as const) {
319
- if (this.rateLimited.has(key)) backoff[key] = now + USAGE_RATE_LIMIT_BACKOFF_MS;
320
- else if ((key === "claude" ? claude : codex) !== undefined) delete backoff[key];
423
+
424
+ const updatedAccounts = { ...claimed };
425
+ for (const key of PROVIDER_KEYS) {
426
+ const accountId = pollable[key];
427
+ if (accountId === undefined) continue;
428
+ const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
429
+ const value = key === "claude" ? claude : codex;
430
+ if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
431
+ else if (value !== undefined) delete entry.backoff;
432
+ // A failed fetch leaves the last-known value in place rather than blanking
433
+ // the meter for whatever caused one bad response.
434
+ if (claude !== undefined && key === "claude") entry.claude = claude;
435
+ if (codex !== undefined && key === "codex") entry.codex = codex;
436
+ updatedAccounts[accountId] = entry;
321
437
  }
322
438
  this.rateLimited.clear();
323
439
 
324
- const next = this.merge({ claude, codex }, auth);
325
- const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next, identity };
326
- this.publish(next, auth, updated);
440
+ const updated: UsageCache = { accounts: updatedAccounts };
441
+ this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
327
442
  await this.writeCache(updated);
328
443
  }
329
444
 
330
445
  /**
331
446
  * Drop in-memory values fetched with previous credentials so merge() cannot
332
447
  * resurrect another account's numbers, and let the changed identity reopen
333
- * this process's own throttle gate.
448
+ * this process's own throttle gate for the newly selected account.
334
449
  */
335
- private pruneCurrent(identity: ProviderIdentities): void {
450
+ private pruneCurrent(identity: ProviderIdentities, accounts: UsageAccounts): void {
336
451
  const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
337
452
  this.identities = identity;
338
453
  if (changedKeys.length === 0) return;
339
- this.lastAttempt = Number.NEGATIVE_INFINITY;
454
+ for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
340
455
  if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
341
456
  const next = { ...this.current };
342
457
  for (const key of changedKeys) delete next[key];
@@ -346,17 +461,17 @@ export class UsageTracker {
346
461
  }
347
462
 
348
463
  /**
349
- * Layer fresh values over the last-known ones and drop providers without
350
- * credentials: a logged-out provider must disappear immediately, while a
351
- * failed fetch keeps whatever we last saw.
464
+ * Layer fresh values over the last-known ones and drop families whose selected
465
+ * account has no credentials: a logged-out account must disappear immediately,
466
+ * while a failed fetch keeps whatever we last saw.
352
467
  */
353
- private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
468
+ private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
354
469
  const next: UsageSnapshot = {};
355
- if (hasClaudeAuth(auth)) {
470
+ if (hasAuth(auth, accounts.claude)) {
356
471
  const claude = incoming.claude ?? this.current.claude;
357
472
  if (claude) next.claude = claude;
358
473
  }
359
- if (hasCodexAuth(auth)) {
474
+ if (hasAuth(auth, accounts.codex)) {
360
475
  const codex = incoming.codex ?? this.current.codex;
361
476
  if (codex) next.codex = codex;
362
477
  }
@@ -364,13 +479,16 @@ export class UsageTracker {
364
479
  }
365
480
 
366
481
  /** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
367
- private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
482
+ private publish(next: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts, cache: UsageCache): void {
368
483
  const now = this.now();
369
- // A provider serving 429s is not "pending": retrying it faster is exactly
484
+ // An account serving 429s is not "pending": retrying it faster is exactly
370
485
  // what got us rate limited, so it must not hold the short window open.
371
- this.awaitingProvider =
372
- (hasClaudeAuth(auth) && next.claude === undefined && now >= (cache.backoff.claude ?? 0)) ||
373
- (hasCodexAuth(auth) && next.codex === undefined && now >= (cache.backoff.codex ?? 0));
486
+ this.awaitingProvider = PROVIDER_KEYS.some(
487
+ (key) =>
488
+ hasAuth(auth, accounts[key]) &&
489
+ next[key] === undefined &&
490
+ now >= (cache.accounts[accounts[key]]?.backoff ?? 0),
491
+ );
374
492
  if (JSON.stringify(next) === JSON.stringify(this.current)) return;
375
493
  this.current = next;
376
494
  this.onChange?.();
@@ -418,16 +536,14 @@ export class UsageTracker {
418
536
  }
419
537
  }
420
538
 
421
- private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
422
- if (!hasClaudeAuth(auth)) return undefined;
539
+ private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
540
+ const access = auth[accountId]?.access;
541
+ if (typeof access !== "string" || access.length === 0) return undefined;
423
542
  try {
424
543
  return parseClaudeUsage(
425
544
  await this.fetchJson(
426
545
  CLAUDE_USAGE_URL,
427
- {
428
- Authorization: `Bearer ${auth.anthropic?.access as string}`,
429
- "anthropic-beta": "oauth-2025-04-20",
430
- },
546
+ { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
431
547
  "claude",
432
548
  ),
433
549
  );
@@ -436,19 +552,16 @@ export class UsageTracker {
436
552
  }
437
553
  }
438
554
 
439
- private async fetchCodex(auth: AuthEntries): Promise<UsageSnapshot["codex"] | undefined> {
440
- if (!hasCodexAuth(auth)) return undefined;
441
- const entry = auth["openai-codex"];
442
- const access = entry?.access as string;
443
- const accountId = entry?.accountId as string;
555
+ private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
556
+ const entry = auth[accountId];
557
+ const access = entry?.access;
558
+ const account = entry?.accountId;
559
+ if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
444
560
  try {
445
561
  return parseCodexUsage(
446
562
  await this.fetchJson(
447
563
  CODEX_USAGE_URL,
448
- {
449
- Authorization: `Bearer ${access}`,
450
- "chatgpt-account-id": accountId,
451
- },
564
+ { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
452
565
  "codex",
453
566
  ),
454
567
  );
@@ -457,3 +570,13 @@ export class UsageTracker {
457
570
  }
458
571
  }
459
572
  }
573
+
574
+ /** The cached values for the currently selected accounts, in display shape. */
575
+ function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
576
+ 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;
582
+ }