@hank-warren/pi-statusline 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/usage.ts +91 -9
package/README.md CHANGED
@@ -27,7 +27,7 @@ Numbers are colored by remaining headroom: green above 60, yellow 41–60, orang
27
27
 
28
28
  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.
29
29
 
30
- 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. Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
30
+ 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.
31
31
 
32
32
  ## Neon cache-wave celebration
33
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Compact Pi footer statusline with Git/worktree context, token usage, and neon celebrations for exceptional prompt-cache hits.",
5
5
  "type": "module",
6
6
  "keywords": [
package/usage.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFile, rename, writeFile } from "node:fs/promises";
2
3
  import { homedir } from "node:os";
3
4
  import { join } from "node:path";
@@ -121,6 +122,31 @@ export interface UsageTrackerOptions {
121
122
 
122
123
  type ProviderKey = "claude" | "codex";
123
124
 
125
+ const PROVIDER_KEYS = ["claude", "codex"] as const;
126
+
127
+ /** Non-reversible per-provider credential fingerprints; never raw tokens. */
128
+ type ProviderIdentities = Partial<Record<ProviderKey, string>>;
129
+
130
+ function fingerprint(material: string): string {
131
+ return createHash("sha256").update(material).digest("hex").slice(0, 16);
132
+ }
133
+
134
+ /**
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.
139
+ */
140
+ function credentialIdentities(auth: AuthEntries): ProviderIdentities {
141
+ 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}`);
146
+ }
147
+ return identities;
148
+ }
149
+
124
150
  /**
125
151
  * Host-wide poll state shared by every pi process. Usage percentages are a
126
152
  * property of the account, not of a session, so one poll per interval per host
@@ -133,23 +159,57 @@ interface UsageCache {
133
159
  /** Absolute times before which a rate-limited provider must not be polled. */
134
160
  backoff: Partial<Record<ProviderKey, number>>;
135
161
  snapshot: UsageSnapshot;
162
+ /** Credential fingerprints the snapshot was fetched with. */
163
+ identity: ProviderIdentities;
136
164
  }
137
165
 
138
- const EMPTY_CACHE: UsageCache = { attemptedAt: Number.NEGATIVE_INFINITY, backoff: {}, snapshot: {} };
166
+ const EMPTY_CACHE: UsageCache = {
167
+ attemptedAt: Number.NEGATIVE_INFINITY,
168
+ backoff: {},
169
+ snapshot: {},
170
+ identity: {},
171
+ };
172
+
173
+ /**
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.
178
+ */
179
+ function pruneCache(cache: UsageCache, identity: ProviderIdentities): UsageCache {
180
+ const snapshot = { ...cache.snapshot };
181
+ const backoff = { ...cache.backoff };
182
+ 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];
188
+ }
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 };
193
+ }
139
194
 
140
195
  function parseUsageCache(json: unknown): UsageCache | undefined {
141
196
  if (typeof json !== "object" || json === null) return undefined;
142
- const body = json as { attemptedAt?: unknown; backoff?: unknown; snapshot?: unknown };
197
+ const body = json as { attemptedAt?: unknown; backoff?: unknown; snapshot?: unknown; identity?: unknown };
143
198
  if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) return undefined;
144
199
  const snapshot = typeof body.snapshot === "object" && body.snapshot !== null ? (body.snapshot as UsageSnapshot) : {};
145
200
  const backoff: UsageCache["backoff"] = {};
146
- if (typeof body.backoff === "object" && body.backoff !== null) {
147
- for (const key of ["claude", "codex"] as const) {
201
+ const identity: ProviderIdentities = {};
202
+ for (const key of PROVIDER_KEYS) {
203
+ if (typeof body.backoff === "object" && body.backoff !== null) {
148
204
  const value = (body.backoff as Record<string, unknown>)[key];
149
205
  if (typeof value === "number" && Number.isFinite(value)) backoff[key] = value;
150
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
+ }
151
211
  }
152
- return { attemptedAt: body.attemptedAt, backoff, snapshot };
212
+ return { attemptedAt: body.attemptedAt, backoff, snapshot, identity };
153
213
  }
154
214
 
155
215
  interface AuthEntries {
@@ -184,6 +244,7 @@ export class UsageTracker {
184
244
  private readonly onChange?: () => void;
185
245
  private readonly now: () => number;
186
246
  private current: UsageSnapshot = {};
247
+ private identities: ProviderIdentities = {};
187
248
  private awaitingProvider = true;
188
249
  private readonly rateLimited = new Set<ProviderKey>();
189
250
  private lastAttempt = Number.NEGATIVE_INFINITY;
@@ -229,7 +290,9 @@ export class UsageTracker {
229
290
  */
230
291
  private async performRefresh(): Promise<void> {
231
292
  const auth = (await this.readAuth()) ?? {};
232
- const cache = (await this.readCache()) ?? EMPTY_CACHE;
293
+ const identity = credentialIdentities(auth);
294
+ const cache = pruneCache((await this.readCache()) ?? EMPTY_CACHE, identity);
295
+ this.pruneCurrent(identity);
233
296
  // Another process's values are as good as ours and cost no request, so a new
234
297
  // session shows real numbers on its very first render.
235
298
  this.publish(this.merge(cache.snapshot, auth), auth, cache);
@@ -243,8 +306,9 @@ export class UsageTracker {
243
306
 
244
307
  this.lastAttempt = now;
245
308
  // Claim the slot before fetching so sibling processes skip this window even
246
- // if our own request is slow or fails outright.
247
- await this.writeCache({ ...cache, attemptedAt: now });
309
+ // if our own request is slow or fails outright. The claim carries the fresh
310
+ // identity so siblings do not re-prune and stampede the same window.
311
+ await this.writeCache({ ...cache, attemptedAt: now, identity });
248
312
 
249
313
  const [claude, codex] = await Promise.all([
250
314
  claudeAllowed ? this.fetchClaude(auth) : Promise.resolve(undefined),
@@ -258,11 +322,29 @@ export class UsageTracker {
258
322
  this.rateLimited.clear();
259
323
 
260
324
  const next = this.merge({ claude, codex }, auth);
261
- const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next };
325
+ const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next, identity };
262
326
  this.publish(next, auth, updated);
263
327
  await this.writeCache(updated);
264
328
  }
265
329
 
330
+ /**
331
+ * Drop in-memory values fetched with previous credentials so merge() cannot
332
+ * resurrect another account's numbers, and let the changed identity reopen
333
+ * this process's own throttle gate.
334
+ */
335
+ private pruneCurrent(identity: ProviderIdentities): void {
336
+ const changedKeys = PROVIDER_KEYS.filter((key) => this.identities[key] !== identity[key]);
337
+ this.identities = identity;
338
+ if (changedKeys.length === 0) return;
339
+ this.lastAttempt = Number.NEGATIVE_INFINITY;
340
+ if (!changedKeys.some((key) => this.current[key] !== undefined)) return;
341
+ const next = { ...this.current };
342
+ for (const key of changedKeys) delete next[key];
343
+ this.current = next;
344
+ // snapshot() changed even if the upcoming publish() sees no further delta.
345
+ this.onChange?.();
346
+ }
347
+
266
348
  /**
267
349
  * Layer fresh values over the last-known ones and drop providers without
268
350
  * credentials: a logged-out provider must disappear immediately, while a