@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.
- package/README.md +1 -1
- package/package.json +1 -1
- 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
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 = {
|
|
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
|
-
|
|
147
|
-
|
|
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
|
|
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
|
-
|
|
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
|