@hank-warren/pi-statusline 0.4.1 → 0.5.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,19 @@
1
1
  # @hank-warren/pi-statusline
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 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.
8
+
9
+ 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.
10
+
11
+ ## 0.4.2
12
+
13
+ ### Patch Changes
14
+
15
+ - 1e25d6b: Guard against a setting being added without being registered for persistence. `SETTING_KEYS` feeds the per-key diff behind every save, so a key missing from it applied live and then silently failed to persist. Omitting one is now a `tsc` error naming the key, plus a test asserting the list matches `defaultSettings()`. Adds an "Adding a setting" checklist and the cross-version compatibility rules to the README.
16
+
3
17
  ## 0.4.1
4
18
 
5
19
  ### Patch Changes
package/README.md CHANGED
@@ -30,6 +30,26 @@ Settings live in a single global file, `~/.pi/agent/statusline-settings.json`, w
30
30
 
31
31
  Saves are per-key rather than whole-file: a change writes only the fields it actually touched over whatever is on disk at that moment. Sessions load settings once at startup, so a whole-file write would let a session that started hours ago revert edits it never saw — including hand edits and changes made in another session. Opening `/statusline` also re-reads the file first, so the menu always edits current state. Two sessions changing the *same* field are still last-writer-wins; everything else merges.
32
32
 
33
+ ### Adding a setting
34
+
35
+ A setting is persisted, validated, diffed, and rendered in separate places, so add it to all of them:
36
+
37
+ 1. `StatuslineSettings` in `settings.ts` — plus `BOOLEAN_SETTING_KEYS` if it is a toggle.
38
+ 2. `defaultSettings()`.
39
+ 3. `normalizeSettings()` — the `known` key set, and a parse branch that falls back to the default for an invalid value rather than discarding the whole file.
40
+ 4. `serializeSettings()` — write it only when it differs from its default, keeping the file sparse.
41
+ 5. `SETTING_KEYS`.
42
+ 6. A row in `buildSettingItems()` and a branch in `applySettingChange()` in `settings-menu.ts`, placed in the order the element renders.
43
+ 7. Live-apply handling in `applySettings()` in `index.ts`, if the change needs more than a repaint (disposing a poller, forcing a full redraw on a row-count change).
44
+
45
+ Steps 1 and 5 are enforced: omitting the key from `SETTING_KEYS` fails `npm run typecheck` by name, and a test asserts it matches the keys of `defaultSettings()`. Nothing enforces steps 3, 4, 6 or 7 — a setting missing from `serializeSettings` applies live and never persists.
46
+
47
+ Compatibility rules, because old and new versions share one file:
48
+
49
+ - **Never change a key's type or meaning — add a sibling key.** This is why `showCacheCelebration` stayed a boolean when it gained animation styles: an older version reading a repurposed key falls back to its default and can write that fallback back.
50
+ - **Unknown keys survive an older version; unknown *values* do not.** A theme name a reader does not recognise falls back to `default`, and a whole-file write from that reader drops the choice. Extending a cosmetic enum is fine; encoding behaviour in one is riskier than adding a key.
51
+ - **Keep settings independent.** Per-key saves mean two fields can be written by different sessions at different times, so resolve any relationship between settings at render time, not on disk.
52
+
33
53
  ## Themes
34
54
 
35
55
  | Name | Notes |
@@ -57,7 +77,19 @@ Numbers are colored by remaining headroom: green above 60, yellow 41–60, orang
57
77
 
58
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.
59
79
 
60
- 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
+ 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.
91
+
92
+ Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
61
93
 
62
94
  ## Cache-hit celebration
63
95
 
package/index.ts CHANGED
@@ -264,7 +264,10 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
264
264
  cwdStatusAbort = new AbortController();
265
265
  cwdStatusInFlight = undefined;
266
266
  runInBackground(refreshCwdStatus(ctx));
267
- if (settings.showUsage) runInBackground(usageTracker.refresh());
267
+ if (settings.showUsage) {
268
+ usageTracker.setActiveProvider(ctx.model?.provider);
269
+ runInBackground(usageTracker.refresh());
270
+ }
268
271
  // A hidden worktree line must not pay for git/gh polling.
269
272
  if (!settings.showWorktrees) return;
270
273
  const next = new SessionWorktreeTracker({
@@ -446,9 +449,20 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
446
449
  requestRender?.();
447
450
  runInBackground(refreshCwdStatus(ctx));
448
451
  if (tracker) runInBackground(tracker.refresh());
449
- if (settings.showUsage) runInBackground(usageTracker.refresh());
452
+ if (settings.showUsage) {
453
+ usageTracker.setActiveProvider(ctx.model?.provider);
454
+ runInBackground(usageTracker.refresh());
455
+ }
456
+ });
457
+ // The meters follow the main model's account, so a switch between two logins
458
+ // of the same provider family has to re-point the tracker before it repaints.
459
+ pi.on("model_select", (event) => {
460
+ if (settings.showUsage) {
461
+ usageTracker.setActiveProvider(event.model.provider);
462
+ runInBackground(usageTracker.refresh());
463
+ }
464
+ requestRender?.();
450
465
  });
451
- pi.on("model_select", () => requestRender?.());
452
466
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
453
467
  pi.on("session_shutdown", () => {
454
468
  cacheCelebration.dispose();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.4.1",
3
+ "version": "0.5.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/settings.ts CHANGED
@@ -176,6 +176,17 @@ export const SETTING_KEYS = [
176
176
 
177
177
  export type SettingKey = (typeof SETTING_KEYS)[number];
178
178
 
179
+ /**
180
+ * Compile-time completeness guard.
181
+ *
182
+ * `satisfies readonly (keyof StatuslineSettings)[]` above only proves the listed
183
+ * keys are real; it does not prove every key is listed. That gap is silent and
184
+ * expensive: SETTING_KEYS feeds changedSettingKeys, which feeds every save, so a
185
+ * setting missing from it works all session and then vanishes on restart.
186
+ */
187
+ type Unlisted<Key extends never> = Key;
188
+ type _EverySettingKeyIsListed = Unlisted<Exclude<keyof StatuslineSettings, SettingKey>>;
189
+
179
190
  /** The keys one edit actually touched; the unit of a merging save. */
180
191
  export function changedSettingKeys(
181
192
  previous: StatuslineSettings,
package/usage.ts CHANGED
@@ -25,7 +25,7 @@ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
25
25
  */
26
26
  export const USAGE_RETRY_INTERVAL_MS = 30_000;
27
27
  /**
28
- * How long a provider is left alone after it answers 429. Polls are host-wide
28
+ * How long an account is left alone after it answers 429. Polls are host-wide
29
29
  * (see the shared cache below), so a rate limit means the provider itself wants
30
30
  * a break rather than that we are racing ourselves.
31
31
  */
@@ -118,124 +118,167 @@ export interface UsageTrackerOptions {
118
118
  fetchFn?: FetchFn;
119
119
  onChange?: () => void;
120
120
  now?: () => number;
121
+ /** Provider id of the session's main model; selects which account is polled. */
122
+ activeProvider?: string;
121
123
  }
122
124
 
123
125
  type ProviderKey = "claude" | "codex";
124
126
 
125
127
  const PROVIDER_KEYS = ["claude", "codex"] as const;
126
128
 
127
- /** Non-reversible per-provider credential fingerprints; never raw tokens. */
129
+ /** Provider ids whose additional logins (`${base}-${suffix}`) share a meter. */
130
+ export const USAGE_BASE_PROVIDERS: Record<ProviderKey, string> = {
131
+ claude: "anthropic",
132
+ codex: "openai-codex",
133
+ };
134
+
135
+ /** Credential id polled for each family. */
136
+ export type UsageAccounts = Record<ProviderKey, string>;
137
+
138
+ function inFamily(providerId: string | undefined, base: string): boolean {
139
+ return providerId === base || (providerId !== undefined && providerId.startsWith(`${base}-`));
140
+ }
141
+
142
+ /** The family an account id belongs to, or undefined when it is neither. */
143
+ function familyOf(accountId: string): ProviderKey | undefined {
144
+ return PROVIDER_KEYS.find((key) => inFamily(accountId, USAGE_BASE_PROVIDERS[key]));
145
+ }
146
+
147
+ /**
148
+ * Choose the account whose meter each family shows.
149
+ *
150
+ * Additional logins (@hank-warren/pi-multi-login) mean a family can hold several
151
+ * accounts at once. The one worth showing is the one the session is actually
152
+ * spending: the provider of the main model. Every other family falls back to its
153
+ * base account, which is what makes a backup provider's meter stay visible while
154
+ * you are not using it — and what keeps a dedicated background-reviewer login
155
+ * (never the main model) from ever being polled.
156
+ */
157
+ export function resolveUsageAccounts(activeProvider: string | undefined): UsageAccounts {
158
+ const accounts = { ...USAGE_BASE_PROVIDERS };
159
+ for (const key of PROVIDER_KEYS) {
160
+ if (inFamily(activeProvider, USAGE_BASE_PROVIDERS[key])) accounts[key] = activeProvider as string;
161
+ }
162
+ return accounts;
163
+ }
164
+
165
+ /** Non-reversible per-family credential fingerprints; never raw tokens. */
128
166
  type ProviderIdentities = Partial<Record<ProviderKey, string>>;
129
167
 
130
168
  function fingerprint(material: string): string {
131
169
  return createHash("sha256").update(material).digest("hex").slice(0, 16);
132
170
  }
133
171
 
172
+ interface AuthEntry {
173
+ access?: unknown;
174
+ accountId?: unknown;
175
+ }
176
+
177
+ type AuthEntries = Record<string, AuthEntry | undefined>;
178
+
134
179
  /**
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.
180
+ * Fingerprint the credential an account would be polled with, or undefined when
181
+ * it cannot be polled at all. Usage percentages belong to an account, so a
182
+ * cached value is only trustworthy while the credential that produced it is
183
+ * still the one in auth.json — after an account switch, yesterday's "1% left"
184
+ * is somebody else's meter.
139
185
  */
140
- function credentialIdentities(auth: AuthEntries): ProviderIdentities {
186
+ function accountFingerprint(auth: AuthEntries, accountId: string): string | undefined {
187
+ const entry = auth[accountId];
188
+ const access = entry?.access;
189
+ if (typeof access !== "string" || access.length === 0) return undefined;
190
+ if (familyOf(accountId) === "codex") {
191
+ const account = entry?.accountId;
192
+ if (typeof account !== "string" || account.length === 0) return undefined;
193
+ return fingerprint(`${access}\n${account}`);
194
+ }
195
+ return fingerprint(access);
196
+ }
197
+
198
+ function hasAuth(auth: AuthEntries, accountId: string): boolean {
199
+ return accountFingerprint(auth, accountId) !== undefined;
200
+ }
201
+
202
+ function credentialIdentities(auth: AuthEntries, accounts: UsageAccounts): ProviderIdentities {
141
203
  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}`);
204
+ for (const key of PROVIDER_KEYS) {
205
+ const value = accountFingerprint(auth, accounts[key]);
206
+ if (value !== undefined) identities[key] = value;
146
207
  }
147
208
  return identities;
148
209
  }
149
210
 
211
+ /** One account's shared poll state. Only the family's own field is ever set. */
212
+ interface UsageAccountEntry {
213
+ /** Credential fingerprint the stored values were fetched with. */
214
+ identity: string;
215
+ /** Last time any process started a poll for this account. */
216
+ attemptedAt: number;
217
+ /** Absolute time before which a rate-limited account must not be polled. */
218
+ backoff?: number;
219
+ claude?: UsageSnapshot["claude"];
220
+ codex?: UsageSnapshot["codex"];
221
+ }
222
+
150
223
  /**
151
224
  * 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.
225
+ * property of the account, not of a session, so one poll per interval per
226
+ * account is both sufficient and necessary: a busy host runs dozens of pi
227
+ * processes, and per-process polling stampedes the endpoints into rate limiting
228
+ * everyone.
229
+ *
230
+ * Keyed by credential id rather than by family, because two sessions on the
231
+ * same host may legitimately be using two different Anthropic accounts. Keying
232
+ * by family made each of them look like an account switch to the other, so they
233
+ * evicted each other's values and re-polled on every cycle — the exact
234
+ * stampede this cache exists to prevent.
155
235
  */
156
236
  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;
237
+ accounts: Record<string, UsageAccountEntry>;
164
238
  }
165
239
 
166
- const EMPTY_CACHE: UsageCache = {
167
- attemptedAt: Number.NEGATIVE_INFINITY,
168
- backoff: {},
169
- snapshot: {},
170
- identity: {},
171
- };
240
+ const EMPTY_CACHE: UsageCache = { accounts: {} };
172
241
 
173
242
  /**
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.
243
+ * Drop entries whose credential no longer matches auth.json. A token rotation
244
+ * looks the same as an account switch here; the cost of treating it as one is a
245
+ * single immediate re-poll, while trusting a stale identity means showing
246
+ * another account's numbers as if they were current. Logged-out accounts fail
247
+ * the same check, so the file garbage-collects itself.
178
248
  */
179
- function pruneCache(cache: UsageCache, identity: ProviderIdentities): UsageCache {
180
- const snapshot = { ...cache.snapshot };
181
- const backoff = { ...cache.backoff };
249
+ function pruneCache(cache: UsageCache, auth: AuthEntries): UsageCache {
250
+ const accounts: Record<string, UsageAccountEntry> = {};
182
251
  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];
252
+ for (const [accountId, entry] of Object.entries(cache.accounts)) {
253
+ if (accountFingerprint(auth, accountId) === entry.identity) accounts[accountId] = entry;
254
+ else changed = true;
188
255
  }
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 };
256
+ return changed ? { accounts } : cache;
193
257
  }
194
258
 
195
259
  function parseUsageCache(json: unknown): UsageCache | undefined {
196
260
  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
- }
261
+ const rawAccounts = (json as { accounts?: unknown }).accounts;
262
+ if (typeof rawAccounts !== "object" || rawAccounts === null) return undefined;
263
+ const accounts: Record<string, UsageAccountEntry> = {};
264
+ for (const [accountId, value] of Object.entries(rawAccounts as Record<string, unknown>)) {
265
+ if (typeof value !== "object" || value === null) continue;
266
+ const body = value as Record<string, unknown>;
267
+ if (typeof body.identity !== "string") continue;
268
+ if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) continue;
269
+ const entry: UsageAccountEntry = { identity: body.identity, attemptedAt: body.attemptedAt };
270
+ if (typeof body.backoff === "number" && Number.isFinite(body.backoff)) entry.backoff = body.backoff;
271
+ if (typeof body.claude === "object" && body.claude !== null) entry.claude = body.claude as UsageSnapshot["claude"];
272
+ if (typeof body.codex === "object" && body.codex !== null) entry.codex = body.codex as UsageSnapshot["codex"];
273
+ accounts[accountId] = entry;
211
274
  }
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
- );
275
+ return { accounts };
233
276
  }
234
277
 
235
278
  /**
236
279
  * Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
237
280
  * refreshes them), fetches both usage endpoints, and keeps the last-known good
238
- * value per provider. Refreshes are throttled and must never throw.
281
+ * value per account. Refreshes are throttled and must never throw.
239
282
  */
240
283
  export class UsageTracker {
241
284
  private readonly authPath: string;
@@ -245,9 +288,10 @@ export class UsageTracker {
245
288
  private readonly now: () => number;
246
289
  private current: UsageSnapshot = {};
247
290
  private identities: ProviderIdentities = {};
291
+ private activeProvider: string | undefined;
248
292
  private awaitingProvider = true;
249
293
  private readonly rateLimited = new Set<ProviderKey>();
250
- private lastAttempt = Number.NEGATIVE_INFINITY;
294
+ private lastAttempt: Record<string, number> = {};
251
295
  private inFlight: Promise<void> | undefined;
252
296
 
253
297
  constructor(options: UsageTrackerOptions = {}) {
@@ -256,12 +300,22 @@ export class UsageTracker {
256
300
  this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
257
301
  this.onChange = options.onChange;
258
302
  this.now = options.now ?? Date.now;
303
+ this.activeProvider = options.activeProvider;
259
304
  }
260
305
 
261
306
  snapshot(): UsageSnapshot {
262
307
  return this.current;
263
308
  }
264
309
 
310
+ /**
311
+ * Point the meters at the session's main model. A switch between two accounts
312
+ * in the same family changes which one is displayed; the next refresh adopts
313
+ * the new account's cached value if there is one, and polls otherwise.
314
+ */
315
+ setActiveProvider(providerId: string | undefined): void {
316
+ this.activeProvider = providerId;
317
+ }
318
+
265
319
  /**
266
320
  * Interval until the next allowed attempt: the short retry window while a
267
321
  * credentialed provider is still missing a value, the full interval otherwise.
@@ -285,58 +339,89 @@ export class UsageTracker {
285
339
  }
286
340
 
287
341
  /**
288
- * One refresh cycle: adopt whatever another process has already published,
289
- * then poll only if the shared throttle allows it.
342
+ * One refresh cycle: adopt whatever another process has already published for
343
+ * the selected accounts, then poll only those the shared throttle allows.
290
344
  */
291
345
  private async performRefresh(): Promise<void> {
292
346
  const auth = (await this.readAuth()) ?? {};
293
- const identity = credentialIdentities(auth);
294
- const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, identity);
295
- this.pruneCurrent(identity);
347
+ const accounts = resolveUsageAccounts(this.activeProvider);
348
+ const identity = credentialIdentities(auth, accounts);
349
+ const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, auth);
350
+ this.pruneCurrent(identity, accounts);
296
351
  // 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);
352
+ // session or a switch back to an account polled earlier — shows real
353
+ // numbers on its very first render.
354
+ this.publish(this.merge(cachedSnapshot(cache, accounts), auth, accounts), auth, accounts, cache);
299
355
 
300
356
  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
357
+ const interval = this.currentInterval();
358
+ const pollable: Partial<Record<ProviderKey, string>> = {};
359
+ for (const key of PROVIDER_KEYS) {
360
+ const accountId = accounts[key];
361
+ if (!hasAuth(auth, accountId)) continue;
362
+ const entry = cache.accounts[accountId];
363
+ if (now < (entry?.backoff ?? Number.NEGATIVE_INFINITY)) continue;
364
+ const lastAttempt = Math.max(
365
+ this.lastAttempt[accountId] ?? Number.NEGATIVE_INFINITY,
366
+ entry?.attemptedAt ?? Number.NEGATIVE_INFINITY,
367
+ );
368
+ if (now - lastAttempt < interval) continue;
369
+ pollable[key] = accountId;
370
+ }
371
+ if (pollable.claude === undefined && pollable.codex === undefined) return;
372
+
373
+ // Claim each slot before fetching so sibling processes skip this window even
309
374
  // if our own request is slow or fails outright. The claim carries the fresh
310
375
  // identity so siblings do not re-prune and stampede the same window.
311
- await this.writeCache({ ...cache, attemptedAt: now, identity });
376
+ const claimed = { ...cache.accounts };
377
+ for (const key of PROVIDER_KEYS) {
378
+ const accountId = pollable[key];
379
+ if (accountId === undefined) continue;
380
+ this.lastAttempt[accountId] = now;
381
+ claimed[accountId] = {
382
+ ...claimed[accountId],
383
+ identity: identity[key] as string,
384
+ attemptedAt: now,
385
+ };
386
+ }
387
+ await this.writeCache({ accounts: claimed });
312
388
 
313
389
  const [claude, codex] = await Promise.all([
314
- claudeAllowed ? this.fetchClaude(auth) : Promise.resolve(undefined),
315
- codexAllowed ? this.fetchCodex(auth) : Promise.resolve(undefined),
390
+ pollable.claude === undefined ? Promise.resolve(undefined) : this.fetchClaude(auth, pollable.claude),
391
+ pollable.codex === undefined ? Promise.resolve(undefined) : this.fetchCodex(auth, pollable.codex),
316
392
  ]);
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];
393
+
394
+ const updatedAccounts = { ...claimed };
395
+ for (const key of PROVIDER_KEYS) {
396
+ const accountId = pollable[key];
397
+ if (accountId === undefined) continue;
398
+ const entry: UsageAccountEntry = { ...(updatedAccounts[accountId] as UsageAccountEntry) };
399
+ const value = key === "claude" ? claude : codex;
400
+ if (this.rateLimited.has(key)) entry.backoff = now + USAGE_RATE_LIMIT_BACKOFF_MS;
401
+ else if (value !== undefined) delete entry.backoff;
402
+ // A failed fetch leaves the last-known value in place rather than blanking
403
+ // the meter for whatever caused one bad response.
404
+ if (claude !== undefined && key === "claude") entry.claude = claude;
405
+ if (codex !== undefined && key === "codex") entry.codex = codex;
406
+ updatedAccounts[accountId] = entry;
321
407
  }
322
408
  this.rateLimited.clear();
323
409
 
324
- const next = this.merge({ claude, codex }, auth);
325
- const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next, identity };
326
- this.publish(next, auth, updated);
410
+ const updated: UsageCache = { accounts: updatedAccounts };
411
+ this.publish(this.merge({ claude, codex }, auth, accounts), auth, accounts, updated);
327
412
  await this.writeCache(updated);
328
413
  }
329
414
 
330
415
  /**
331
416
  * Drop in-memory values fetched with previous credentials so merge() cannot
332
417
  * resurrect another account's numbers, and let the changed identity reopen
333
- * this process's own throttle gate.
418
+ * this process's own throttle gate for the newly selected account.
334
419
  */
335
- private pruneCurrent(identity: ProviderIdentities): void {
420
+ private pruneCurrent(identity: ProviderIdentities, accounts: UsageAccounts): void {
336
421
  const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
337
422
  this.identities = identity;
338
423
  if (changedKeys.length === 0) return;
339
- this.lastAttempt = Number.NEGATIVE_INFINITY;
424
+ for (const key of changedKeys) delete this.lastAttempt[accounts[key]];
340
425
  if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
341
426
  const next = { ...this.current };
342
427
  for (const key of changedKeys) delete next[key];
@@ -346,17 +431,17 @@ export class UsageTracker {
346
431
  }
347
432
 
348
433
  /**
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.
434
+ * Layer fresh values over the last-known ones and drop families whose selected
435
+ * account has no credentials: a logged-out account must disappear immediately,
436
+ * while a failed fetch keeps whatever we last saw.
352
437
  */
353
- private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
438
+ private merge(incoming: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts): UsageSnapshot {
354
439
  const next: UsageSnapshot = {};
355
- if (hasClaudeAuth(auth)) {
440
+ if (hasAuth(auth, accounts.claude)) {
356
441
  const claude = incoming.claude ?? this.current.claude;
357
442
  if (claude) next.claude = claude;
358
443
  }
359
- if (hasCodexAuth(auth)) {
444
+ if (hasAuth(auth, accounts.codex)) {
360
445
  const codex = incoming.codex ?? this.current.codex;
361
446
  if (codex) next.codex = codex;
362
447
  }
@@ -364,13 +449,16 @@ export class UsageTracker {
364
449
  }
365
450
 
366
451
  /** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
367
- private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
452
+ private publish(next: UsageSnapshot, auth: AuthEntries, accounts: UsageAccounts, cache: UsageCache): void {
368
453
  const now = this.now();
369
- // A provider serving 429s is not "pending": retrying it faster is exactly
454
+ // An account serving 429s is not "pending": retrying it faster is exactly
370
455
  // 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));
456
+ this.awaitingProvider = PROVIDER_KEYS.some(
457
+ (key) =>
458
+ hasAuth(auth, accounts[key]) &&
459
+ next[key] === undefined &&
460
+ now >= (cache.accounts[accounts[key]]?.backoff ?? 0),
461
+ );
374
462
  if (JSON.stringify(next) === JSON.stringify(this.current)) return;
375
463
  this.current = next;
376
464
  this.onChange?.();
@@ -418,16 +506,14 @@ export class UsageTracker {
418
506
  }
419
507
  }
420
508
 
421
- private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
422
- if (!hasClaudeAuth(auth)) return undefined;
509
+ private async fetchClaude(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["claude"] | undefined> {
510
+ const access = auth[accountId]?.access;
511
+ if (typeof access !== "string" || access.length === 0) return undefined;
423
512
  try {
424
513
  return parseClaudeUsage(
425
514
  await this.fetchJson(
426
515
  CLAUDE_USAGE_URL,
427
- {
428
- Authorization: `Bearer ${auth.anthropic?.access as string}`,
429
- "anthropic-beta": "oauth-2025-04-20",
430
- },
516
+ { Authorization: `Bearer ${access}`, "anthropic-beta": "oauth-2025-04-20" },
431
517
  "claude",
432
518
  ),
433
519
  );
@@ -436,19 +522,16 @@ export class UsageTracker {
436
522
  }
437
523
  }
438
524
 
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;
525
+ private async fetchCodex(auth: AuthEntries, accountId: string): Promise<UsageSnapshot["codex"] | undefined> {
526
+ const entry = auth[accountId];
527
+ const access = entry?.access;
528
+ const account = entry?.accountId;
529
+ if (typeof access !== "string" || typeof account !== "string" || !access || !account) return undefined;
444
530
  try {
445
531
  return parseCodexUsage(
446
532
  await this.fetchJson(
447
533
  CODEX_USAGE_URL,
448
- {
449
- Authorization: `Bearer ${access}`,
450
- "chatgpt-account-id": accountId,
451
- },
534
+ { Authorization: `Bearer ${access}`, "chatgpt-account-id": account },
452
535
  "codex",
453
536
  ),
454
537
  );
@@ -457,3 +540,13 @@ export class UsageTracker {
457
540
  }
458
541
  }
459
542
  }
543
+
544
+ /** The cached values for the currently selected accounts, in display shape. */
545
+ function cachedSnapshot(cache: UsageCache, accounts: UsageAccounts): UsageSnapshot {
546
+ const snapshot: UsageSnapshot = {};
547
+ const claude = cache.accounts[accounts.claude]?.claude;
548
+ const codex = cache.accounts[accounts.codex]?.codex;
549
+ if (claude) snapshot.claude = claude;
550
+ if (codex) snapshot.codex = codex;
551
+ return snapshot;
552
+ }