@hank-warren/pi-statusline 0.2.0 → 0.2.1

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 +3 -1
  2. package/package.json +1 -1
  3. package/usage.ts +163 -20
package/README.md CHANGED
@@ -25,7 +25,9 @@ When Pi's `~/.pi/agent/auth.json` contains OAuth credentials for Anthropic (Clau
25
25
 
26
26
  Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
27
27
 
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 once per minute, 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. Requires a Nerd Font new enough to include the codicon brand glyphs (v3.5.0+); older fonts render them as replacement boxes.
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
+
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.
29
31
 
30
32
  ## Neon cache-wave celebration
31
33
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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,6 +1,7 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { readFile, rename, writeFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
+ import { pid } from "node:process";
4
5
 
5
6
  /** Remaining (not used) integer percents per provider window. */
6
7
  export interface UsageSnapshot {
@@ -10,7 +11,24 @@ export interface UsageSnapshot {
10
11
 
11
12
  const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
12
13
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
13
- const REFRESH_INTERVAL_MS = 60_000;
14
+ /**
15
+ * Minimum spacing between usage polls. The Anthropic usage endpoint rate-limits
16
+ * (429) aggressively, so keep this well above a per-turn cadence.
17
+ */
18
+ export const USAGE_REFRESH_INTERVAL_MS = 5 * 60_000;
19
+ /**
20
+ * Shorter spacing used while a credentialed provider still has no value. Pi
21
+ * refreshes an expired OAuth token only when that provider is first used, so a
22
+ * session that starts with a stale token would otherwise show nothing for a
23
+ * full refresh interval.
24
+ */
25
+ export const USAGE_RETRY_INTERVAL_MS = 30_000;
26
+ /**
27
+ * How long a provider is left alone after it answers 429. Polls are host-wide
28
+ * (see the shared cache below), so a rate limit means the provider itself wants
29
+ * a break rather than that we are racing ourselves.
30
+ */
31
+ export const USAGE_RATE_LIMIT_BACKOFF_MS = 15 * 60_000;
14
32
  const FETCH_TIMEOUT_MS = 10_000;
15
33
  const ONE_DAY_SECONDS = 86_400;
16
34
 
@@ -89,16 +107,51 @@ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "r
89
107
 
90
108
  type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
91
109
  ok: boolean;
110
+ status?: number;
92
111
  json(): Promise<unknown>;
93
112
  }>;
94
113
 
95
114
  export interface UsageTrackerOptions {
96
115
  authPath?: string;
116
+ cachePath?: string;
97
117
  fetchFn?: FetchFn;
98
118
  onChange?: () => void;
99
119
  now?: () => number;
100
120
  }
101
121
 
122
+ type ProviderKey = "claude" | "codex";
123
+
124
+ /**
125
+ * Host-wide poll state shared by every pi process. Usage percentages are a
126
+ * property of the account, not of a session, so one poll per interval per host
127
+ * is both sufficient and necessary: a busy host runs dozens of pi processes, and
128
+ * per-process polling stampedes the endpoints into rate limiting everyone.
129
+ */
130
+ interface UsageCache {
131
+ /** Last time any process started a poll; gates the shared throttle. */
132
+ attemptedAt: number;
133
+ /** Absolute times before which a rate-limited provider must not be polled. */
134
+ backoff: Partial<Record<ProviderKey, number>>;
135
+ snapshot: UsageSnapshot;
136
+ }
137
+
138
+ const EMPTY_CACHE: UsageCache = { attemptedAt: Number.NEGATIVE_INFINITY, backoff: {}, snapshot: {} };
139
+
140
+ function parseUsageCache(json: unknown): UsageCache | undefined {
141
+ if (typeof json !== "object" || json === null) return undefined;
142
+ const body = json as { attemptedAt?: unknown; backoff?: unknown; snapshot?: unknown };
143
+ if (typeof body.attemptedAt !== "number" || !Number.isFinite(body.attemptedAt)) return undefined;
144
+ const snapshot = typeof body.snapshot === "object" && body.snapshot !== null ? (body.snapshot as UsageSnapshot) : {};
145
+ const backoff: UsageCache["backoff"] = {};
146
+ if (typeof body.backoff === "object" && body.backoff !== null) {
147
+ for (const key of ["claude", "codex"] as const) {
148
+ const value = (body.backoff as Record<string, unknown>)[key];
149
+ if (typeof value === "number" && Number.isFinite(value)) backoff[key] = value;
150
+ }
151
+ }
152
+ return { attemptedAt: body.attemptedAt, backoff, snapshot };
153
+ }
154
+
102
155
  interface AuthEntries {
103
156
  anthropic?: { access?: unknown };
104
157
  "openai-codex"?: { access?: unknown; accountId?: unknown };
@@ -126,15 +179,19 @@ function hasCodexAuth(auth: AuthEntries): boolean {
126
179
  */
127
180
  export class UsageTracker {
128
181
  private readonly authPath: string;
182
+ private readonly cachePath: string;
129
183
  private readonly fetchFn: FetchFn;
130
184
  private readonly onChange?: () => void;
131
185
  private readonly now: () => number;
132
186
  private current: UsageSnapshot = {};
187
+ private awaitingProvider = true;
188
+ private readonly rateLimited = new Set<ProviderKey>();
133
189
  private lastAttempt = Number.NEGATIVE_INFINITY;
134
190
  private inFlight: Promise<void> | undefined;
135
191
 
136
192
  constructor(options: UsageTrackerOptions = {}) {
137
193
  this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
194
+ this.cachePath = options.cachePath ?? join(homedir(), ".pi", "agent", "statusline-usage.json");
138
195
  this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
139
196
  this.onChange = options.onChange;
140
197
  this.now = options.now ?? Date.now;
@@ -144,11 +201,17 @@ export class UsageTracker {
144
201
  return this.current;
145
202
  }
146
203
 
204
+ /**
205
+ * Interval until the next allowed attempt: the short retry window while a
206
+ * credentialed provider is still missing a value, the full interval otherwise.
207
+ */
208
+ private currentInterval(): number {
209
+ return this.awaitingProvider ? USAGE_RETRY_INTERVAL_MS : USAGE_REFRESH_INTERVAL_MS;
210
+ }
211
+
147
212
  /** Throttled refresh; resolves when the current attempt (if any) settles. */
148
213
  refresh(): Promise<void> {
149
214
  if (this.inFlight) return this.inFlight;
150
- if (this.now() - this.lastAttempt < REFRESH_INTERVAL_MS) return Promise.resolve();
151
- this.lastAttempt = this.now();
152
215
  const attempt = this.performRefresh()
153
216
  .catch(() => {
154
217
  // Usage display is best-effort and must never interrupt the agent.
@@ -160,25 +223,96 @@ export class UsageTracker {
160
223
  return attempt;
161
224
  }
162
225
 
226
+ /**
227
+ * One refresh cycle: adopt whatever another process has already published,
228
+ * then poll only if the shared throttle allows it.
229
+ */
163
230
  private async performRefresh(): Promise<void> {
164
231
  const auth = (await this.readAuth()) ?? {};
165
- const [claude, codex] = await Promise.all([this.fetchClaude(auth), this.fetchCodex(auth)]);
232
+ const cache = (await this.readCache()) ?? EMPTY_CACHE;
233
+ // Another process's values are as good as ours and cost no request, so a new
234
+ // session shows real numbers on its very first render.
235
+ this.publish(this.merge(cache.snapshot, auth), auth, cache);
236
+
237
+ const now = this.now();
238
+ const lastAttempt = Math.max(this.lastAttempt, cache.attemptedAt);
239
+ if (now - lastAttempt < this.currentInterval()) return;
240
+ const claudeAllowed = hasClaudeAuth(auth) && now >= (cache.backoff.claude ?? Number.NEGATIVE_INFINITY);
241
+ const codexAllowed = hasCodexAuth(auth) && now >= (cache.backoff.codex ?? Number.NEGATIVE_INFINITY);
242
+ if (!claudeAllowed && !codexAllowed) return;
243
+
244
+ this.lastAttempt = now;
245
+ // 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 });
248
+
249
+ const [claude, codex] = await Promise.all([
250
+ claudeAllowed ? this.fetchClaude(auth) : Promise.resolve(undefined),
251
+ codexAllowed ? this.fetchCodex(auth) : Promise.resolve(undefined),
252
+ ]);
253
+ const backoff = { ...cache.backoff };
254
+ for (const key of ["claude", "codex"] as const) {
255
+ if (this.rateLimited.has(key)) backoff[key] = now + USAGE_RATE_LIMIT_BACKOFF_MS;
256
+ else if ((key === "claude" ? claude : codex) !== undefined) delete backoff[key];
257
+ }
258
+ this.rateLimited.clear();
259
+
260
+ const next = this.merge({ claude, codex }, auth);
261
+ const updated: UsageCache = { attemptedAt: now, backoff, snapshot: next };
262
+ this.publish(next, auth, updated);
263
+ await this.writeCache(updated);
264
+ }
265
+
266
+ /**
267
+ * Layer fresh values over the last-known ones and drop providers without
268
+ * credentials: a logged-out provider must disappear immediately, while a
269
+ * failed fetch keeps whatever we last saw.
270
+ */
271
+ private merge(incoming: UsageSnapshot, auth: AuthEntries): UsageSnapshot {
166
272
  const next: UsageSnapshot = {};
167
- // A logged-out provider (missing/invalid auth entry) is dropped immediately;
168
- // a fetch failure with credentials present keeps the last-known value.
169
273
  if (hasClaudeAuth(auth)) {
170
- const nextClaude = claude ?? this.current.claude;
171
- if (nextClaude) next.claude = nextClaude;
274
+ const claude = incoming.claude ?? this.current.claude;
275
+ if (claude) next.claude = claude;
172
276
  }
173
277
  if (hasCodexAuth(auth)) {
174
- const nextCodex = codex ?? this.current.codex;
175
- if (nextCodex) next.codex = nextCodex;
278
+ const codex = incoming.codex ?? this.current.codex;
279
+ if (codex) next.codex = codex;
176
280
  }
281
+ return next;
282
+ }
283
+
284
+ /** Adopt a snapshot, recompute the retry gate, and repaint only on a change. */
285
+ private publish(next: UsageSnapshot, auth: AuthEntries, cache: UsageCache): void {
286
+ const now = this.now();
287
+ // A provider serving 429s is not "pending": retrying it faster is exactly
288
+ // what got us rate limited, so it must not hold the short window open.
289
+ this.awaitingProvider =
290
+ (hasClaudeAuth(auth) && next.claude === undefined && now >= (cache.backoff.claude ?? 0)) ||
291
+ (hasCodexAuth(auth) && next.codex === undefined && now >= (cache.backoff.codex ?? 0));
177
292
  if (JSON.stringify(next) === JSON.stringify(this.current)) return;
178
293
  this.current = next;
179
294
  this.onChange?.();
180
295
  }
181
296
 
297
+ private async readCache(): Promise<UsageCache | undefined> {
298
+ try {
299
+ return parseUsageCache(JSON.parse(await readFile(this.cachePath, "utf8")));
300
+ } catch {
301
+ return undefined;
302
+ }
303
+ }
304
+
305
+ /** Atomic write; a lost race just costs one extra poll, never a corrupt file. */
306
+ private async writeCache(cache: UsageCache): Promise<void> {
307
+ const temporary = `${this.cachePath}.${pid}.tmp`;
308
+ try {
309
+ await writeFile(temporary, JSON.stringify(cache), { mode: 0o600 });
310
+ await rename(temporary, this.cachePath);
311
+ } catch {
312
+ // The cache is an optimisation; failing to share it must never surface.
313
+ }
314
+ }
315
+
182
316
  private async readAuth(): Promise<AuthEntries | undefined> {
183
317
  try {
184
318
  const parsed: unknown = JSON.parse(await readFile(this.authPath, "utf8"));
@@ -189,11 +323,12 @@ export class UsageTracker {
189
323
  }
190
324
  }
191
325
 
192
- private async fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
326
+ private async fetchJson(url: string, headers: Record<string, string>, provider: ProviderKey): Promise<unknown> {
193
327
  const controller = new AbortController();
194
328
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
195
329
  try {
196
330
  const response = await this.fetchFn(url, { headers, signal: controller.signal });
331
+ if (response.status === 429) this.rateLimited.add(provider);
197
332
  if (!response.ok) return undefined;
198
333
  return await response.json();
199
334
  } finally {
@@ -205,10 +340,14 @@ export class UsageTracker {
205
340
  if (!hasClaudeAuth(auth)) return undefined;
206
341
  try {
207
342
  return parseClaudeUsage(
208
- await this.fetchJson(CLAUDE_USAGE_URL, {
209
- Authorization: `Bearer ${auth.anthropic?.access as string}`,
210
- "anthropic-beta": "oauth-2025-04-20",
211
- }),
343
+ await this.fetchJson(
344
+ CLAUDE_USAGE_URL,
345
+ {
346
+ Authorization: `Bearer ${auth.anthropic?.access as string}`,
347
+ "anthropic-beta": "oauth-2025-04-20",
348
+ },
349
+ "claude",
350
+ ),
212
351
  );
213
352
  } catch {
214
353
  return undefined;
@@ -222,10 +361,14 @@ export class UsageTracker {
222
361
  const accountId = entry?.accountId as string;
223
362
  try {
224
363
  return parseCodexUsage(
225
- await this.fetchJson(CODEX_USAGE_URL, {
226
- Authorization: `Bearer ${access}`,
227
- "chatgpt-account-id": accountId,
228
- }),
364
+ await this.fetchJson(
365
+ CODEX_USAGE_URL,
366
+ {
367
+ Authorization: `Bearer ${access}`,
368
+ "chatgpt-account-id": accountId,
369
+ },
370
+ "codex",
371
+ ),
229
372
  );
230
373
  } catch {
231
374
  return undefined;