@hank-warren/pi-statusline 0.1.2 → 0.2.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/README.md CHANGED
@@ -3,19 +3,48 @@
3
3
  Replaces Pi's default footer with a compact statusline:
4
4
 
5
5
  ```text
6
- gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m
6
+ gpt-5.6-sol | pi-extensions:main* ⇣1 | 40k/1.0m |  97·54  80
7
7
  ⑂ pi-extensions:feature/statusline* ⇣2 #7 | infra:fix/alerts #168
8
8
  019fafa7-29c0-7e99-9f82-5794d5721848
9
9
  ```
10
10
 
11
11
  ## What it shows
12
12
 
13
- - **Line 1** — active model ID, current directory basename and Git branch, and current context usage/window. A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate.
13
+ - **Line 1** — active model ID, current directory basename and Git branch, current context usage/window, and subscription usage headroom (see below). A yellow `*` marks a dirty checkout and `⇣N` shows how many commits it is behind its locally known upstream ref. Unknown context usage is rendered as `?/<window>` until Pi can provide an estimate. Exceptional prompt-cache hits trigger the celebration described below.
14
14
  - **Worktree lines** — when the session works in or sends tool calls into linked worktrees, one line shows the same branch/dirty/behind state for each worktree plus its associated PR number.
15
15
  - **Final line** — the full Pi session ID.
16
16
 
17
17
  It uses a fixed true-color palette with context warning thresholds.
18
18
 
19
+ ## Subscription usage meters
20
+
21
+ 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:
22
+
23
+ - ` 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.
24
+ - ` 80` — Codex weekly remaining percent (`nf-cod-openai` icon)
25
+
26
+ Numbers are colored by remaining headroom: green above 60, yellow 41–60, orange 16–40, red at 15 and below.
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.
29
+
30
+ ## Neon cache-wave celebration
31
+
32
+ Whenever one assistant response reaches a prompt-cache hit rate of at least 90%, a temporary module is appended after context usage for about two seconds:
33
+
34
+ ```text
35
+ gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡96%·CACHE·HIT
36
+ ```
37
+
38
+ Only the `⚡96%·CACHE·HIT` badge animates: the whole badge flashes between neon magenta and neon cyan every 60 ms. The existing model, repository, context, separators, worktree, and session-ID rendering do not change.
39
+
40
+ The rate is evaluated per provider response as:
41
+
42
+ ```text
43
+ cacheRead / (input + cacheRead + cacheWrite)
44
+ ```
45
+
46
+ Output and reasoning tokens are excluded because they are not prompt-cache candidates. A zero-token denominator does not trigger the effect, exactly 90% does, and only the displayed percentage is rounded. Another qualifying response during the animation restarts it from frame zero with the new percentage. After expiry, the original statusline is restored exactly.
47
+
19
48
  ## Worktree/PR tracking behavior
20
49
 
21
50
  - Only worktrees touched on the active Pi session branch are included.
@@ -0,0 +1,132 @@
1
+ export const CACHE_HIT_THRESHOLD = 0.9;
2
+ export const CACHE_CELEBRATION_FRAME_INTERVAL_MS = 60;
3
+ export const CACHE_CELEBRATION_DURATION_MS = 2_000;
4
+
5
+ export interface CacheUsage {
6
+ input: number;
7
+ cacheRead: number;
8
+ cacheWrite: number;
9
+ }
10
+
11
+ export interface CacheCelebrationSnapshot {
12
+ percent: number;
13
+ frame: number;
14
+ }
15
+
16
+ export interface CacheCelebrationTarget {
17
+ start(percent: number): void;
18
+ }
19
+
20
+ export interface CacheCelebrationControllerOptions {
21
+ frameIntervalMs?: number;
22
+ durationMs?: number;
23
+ now?: () => number;
24
+ schedule?: (callback: () => void, intervalMs: number) => unknown;
25
+ cancel?: (handle: unknown) => void;
26
+ }
27
+
28
+ function defaultSchedule(callback: () => void, intervalMs: number): unknown {
29
+ const timer = setInterval(callback, intervalMs);
30
+ // A cosmetic celebration must never keep Pi alive.
31
+ (timer as { unref?: () => void }).unref?.();
32
+ return timer;
33
+ }
34
+
35
+ function defaultCancel(handle: unknown): void {
36
+ clearInterval(handle as ReturnType<typeof setInterval>);
37
+ }
38
+
39
+ /** Return the prompt-cache hit ratio for one response, or null without prompt tokens. */
40
+ export function cacheReadHitRate(usage: CacheUsage): number | null {
41
+ const promptTokens = usage.input + usage.cacheRead + usage.cacheWrite;
42
+ return promptTokens > 0 ? usage.cacheRead / promptTokens : null;
43
+ }
44
+
45
+ /** Return the rounded badge percentage only when a response qualifies for celebration. */
46
+ export function qualifyingCacheHitPercent(usage: CacheUsage): number | null {
47
+ const rate = cacheReadHitRate(usage);
48
+ return rate !== null && rate >= CACHE_HIT_THRESHOLD ? Math.round(rate * 100) : null;
49
+ }
50
+
51
+ /**
52
+ * Apply the same narrowing used by the turn_end hook and start a celebration
53
+ * for a qualifying assistant response.
54
+ */
55
+ export function triggerCacheCelebrationForMessage(
56
+ message: unknown,
57
+ target: CacheCelebrationTarget,
58
+ ): boolean {
59
+ if (!message || typeof message !== "object") return false;
60
+ const candidate = message as { role?: unknown; usage?: Partial<CacheUsage> };
61
+ if (candidate.role !== "assistant" || !candidate.usage) return false;
62
+ const { input, cacheRead, cacheWrite } = candidate.usage;
63
+ if (typeof input !== "number" || typeof cacheRead !== "number" || typeof cacheWrite !== "number") {
64
+ return false;
65
+ }
66
+ const percent = qualifyingCacheHitPercent({ input, cacheRead, cacheWrite });
67
+ if (percent === null) return false;
68
+ target.start(percent);
69
+ return true;
70
+ }
71
+
72
+ /** Owns the short-lived animation timer and exposes an immutable render snapshot. */
73
+ export class CacheCelebrationController implements CacheCelebrationTarget {
74
+ private current: CacheCelebrationSnapshot | undefined;
75
+ private startedAt = 0;
76
+ private timerHandle: unknown;
77
+ private readonly frameIntervalMs: number;
78
+ private readonly durationMs: number;
79
+ private readonly now: () => number;
80
+ private readonly schedule: (callback: () => void, intervalMs: number) => unknown;
81
+ private readonly cancel: (handle: unknown) => void;
82
+
83
+ constructor(
84
+ private readonly requestRender: () => void,
85
+ options: CacheCelebrationControllerOptions = {},
86
+ ) {
87
+ this.frameIntervalMs = options.frameIntervalMs ?? CACHE_CELEBRATION_FRAME_INTERVAL_MS;
88
+ this.durationMs = options.durationMs ?? CACHE_CELEBRATION_DURATION_MS;
89
+ this.now = options.now ?? Date.now;
90
+ this.schedule = options.schedule ?? defaultSchedule;
91
+ this.cancel = options.cancel ?? defaultCancel;
92
+ }
93
+
94
+ start(percent: number): void {
95
+ this.clearTimer();
96
+ this.startedAt = this.now();
97
+ this.current = { percent, frame: 0 };
98
+ this.requestRender();
99
+ this.timerHandle = this.schedule(() => this.tick(), this.frameIntervalMs);
100
+ }
101
+
102
+ snapshot(): CacheCelebrationSnapshot | undefined {
103
+ return this.current ? { ...this.current } : undefined;
104
+ }
105
+
106
+ /** Stop without requesting a render; intended for footer/session teardown. */
107
+ dispose(): void {
108
+ this.clearTimer();
109
+ this.current = undefined;
110
+ }
111
+
112
+ private tick(): void {
113
+ if (!this.current) return;
114
+ const elapsed = this.now() - this.startedAt;
115
+ if (elapsed >= this.durationMs) {
116
+ this.clearTimer();
117
+ this.current = undefined;
118
+ this.requestRender();
119
+ return;
120
+ }
121
+ this.current = {
122
+ ...this.current,
123
+ frame: Math.floor(elapsed / this.frameIntervalMs),
124
+ };
125
+ this.requestRender();
126
+ }
127
+
128
+ private clearTimer(): void {
129
+ if (this.timerHandle !== undefined) this.cancel(this.timerHandle);
130
+ this.timerHandle = undefined;
131
+ }
132
+ }
package/index.ts CHANGED
@@ -1,7 +1,13 @@
1
1
  import { basename } from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { truncateToWidth } from "@earendil-works/pi-tui";
4
+ import {
5
+ CacheCelebrationController,
6
+ type CacheCelebrationSnapshot,
7
+ triggerCacheCelebrationForMessage,
8
+ } from "./cache-celebration.ts";
4
9
  import { FullRedrawScheduler } from "./redraw.ts";
10
+ import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
5
11
  import {
6
12
  type GitRepositoryStatus,
7
13
  readGitStatus,
@@ -18,6 +24,8 @@ export interface StatuslineData {
18
24
  contextWindow: number;
19
25
  worktrees: SessionWorktree[];
20
26
  sessionId: string;
27
+ cacheCelebration?: CacheCelebrationSnapshot;
28
+ usage?: UsageSnapshot;
21
29
  }
22
30
 
23
31
  const BLUE = "\x1b[38;2;0;153;255m";
@@ -28,13 +36,38 @@ const RED = "\x1b[38;2;255;85;85m";
28
36
  const YELLOW = "\x1b[38;2;230;200;0m";
29
37
  const WHITE = "\x1b[38;2;220;220;220m";
30
38
  const MAGENTA = "\x1b[38;2;190;120;255m";
39
+ const NEON_CYAN = "\x1b[38;2;0;255;255m";
40
+ const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
31
41
  const DIM = "\x1b[2m";
42
+ const BOLD = "\x1b[1m";
32
43
  const RESET = "\x1b[0m";
33
44
 
34
45
  function styled(style: string, text: string): string {
35
46
  return `${style}${text}${RESET}`;
36
47
  }
37
48
 
49
+ const CLAUDE_ICON = "\uec82";
50
+ const OPENAI_ICON = "\uec81";
51
+ const USAGE_BAND_COLORS = { green: GREEN, yellow: YELLOW, orange: ORANGE, red: RED } as const;
52
+
53
+ function remainingPercent(remaining: number): string {
54
+ return styled(USAGE_BAND_COLORS[usageBand(remaining)], `${remaining}`);
55
+ }
56
+
57
+ export function renderUsageSegment(usage: UsageSnapshot): string | undefined {
58
+ const parts: string[] = [];
59
+ if (usage.claude) {
60
+ const dot = styled(DIM, "\u00b7");
61
+ let claude = `${remainingPercent(usage.claude.fiveHour)}${dot}${remainingPercent(usage.claude.sevenDay)}`;
62
+ if (usage.claude.scopedWeekly !== undefined) claude += `${dot}${remainingPercent(usage.claude.scopedWeekly)}`;
63
+ parts.push(`${styled(WHITE, CLAUDE_ICON)} ${claude}`);
64
+ }
65
+ if (usage.codex) {
66
+ parts.push(`${styled(WHITE, OPENAI_ICON)} ${remainingPercent(usage.codex.weekly)}`);
67
+ }
68
+ return parts.length > 0 ? parts.join(" ") : undefined;
69
+ }
70
+
38
71
  function contextColor(contextTokens: number, contextWindow: number): string {
39
72
  const percent = contextWindow > 0 ? Math.floor((contextTokens * 100) / contextWindow) : 0;
40
73
  if (percent >= 90) return RED;
@@ -62,6 +95,16 @@ function renderRepository(name: string, status: GitRepositoryStatus, nameColor =
62
95
  return part;
63
96
  }
64
97
 
98
+ export function renderCacheCelebrationLine(
99
+ summary: string,
100
+ celebration: CacheCelebrationSnapshot,
101
+ ): string {
102
+ const badge = `⚡${celebration.percent}%·CACHE·HIT`;
103
+ const color = celebration.frame % 2 === 0 ? NEON_MAGENTA : NEON_CYAN;
104
+ const animatedBadge = `${BOLD}${color}${badge}${RESET}`;
105
+ return `${summary}${styled(DIM, " | ")}${animatedBadge}`;
106
+ }
107
+
65
108
  function renderWorktreeLine(worktrees: SessionWorktree[]): string {
66
109
  const separator = styled(DIM, " | ");
67
110
  const parts = worktrees.map((worktree) => {
@@ -87,9 +130,19 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
87
130
  : styled(contextColor(data.contextTokens, data.contextWindow), formatTokenCount(data.contextTokens));
88
131
  const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
89
132
  const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
90
- const summary = styled(BLUE, data.model) + separator + cwd + separator + context;
133
+ const usageSegment = data.usage ? renderUsageSegment(data.usage) : undefined;
134
+ const summary =
135
+ styled(BLUE, data.model) +
136
+ separator +
137
+ cwd +
138
+ separator +
139
+ context +
140
+ (usageSegment ? separator + usageSegment : "");
141
+ const firstLine = data.cacheCelebration
142
+ ? renderCacheCelebrationLine(summary, data.cacheCelebration)
143
+ : summary;
91
144
 
92
- const lines = [truncateToWidth(summary, width)];
145
+ const lines = [truncateToWidth(firstLine, width)];
93
146
  if (data.worktrees.length > 0) lines.push(truncateToWidth(renderWorktreeLine(data.worktrees), width, "…"));
94
147
  lines.push(truncateToWidth(styled(DIM, data.sessionId), width));
95
148
  return lines;
@@ -98,7 +151,9 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
98
151
  export default function statuslineExtension(pi: ExtensionAPI): void {
99
152
  let requestRender: (() => void) | undefined;
100
153
  const fullRedraw = new FullRedrawScheduler();
154
+ const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
101
155
  let tracker: SessionWorktreeTracker | undefined;
156
+ const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
102
157
  let cwdGit: GitRepositoryStatus | null = null;
103
158
  let cwdStatusAbort: AbortController | undefined;
104
159
  let cwdStatusInFlight: Promise<void> | undefined;
@@ -151,9 +206,11 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
151
206
  runInBackground(refreshCwdStatus(ctx));
152
207
  runInBackground(next.seedFromEntries(ctx.sessionManager.getBranch()));
153
208
  runInBackground(next.includeCurrentWorktree(ctx.cwd));
209
+ runInBackground(usageTracker.refresh());
154
210
  };
155
211
 
156
212
  pi.on("session_start", (_event, ctx) => {
213
+ cacheCelebration.dispose();
157
214
  if (ctx.mode !== "tui") return;
158
215
 
159
216
  ctx.ui.setFooter((tui, _theme, footerData) => {
@@ -169,6 +226,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
169
226
  return {
170
227
  dispose(): void {
171
228
  stopBranchUpdates();
229
+ cacheCelebration.dispose();
172
230
  fullRedraw.detach();
173
231
  requestRender = undefined;
174
232
  },
@@ -188,6 +246,8 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
188
246
  contextWindow: usage?.contextWindow ?? ctx.model?.contextWindow ?? 0,
189
247
  worktrees: tracker?.getWorktrees() ?? [],
190
248
  sessionId: ctx.sessionManager.getSessionId(),
249
+ cacheCelebration: cacheCelebration.snapshot(),
250
+ usage: usageTracker.snapshot(),
191
251
  },
192
252
  width,
193
253
  ),
@@ -202,15 +262,18 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
202
262
  pi.on("tool_call", (event) => {
203
263
  if (tracker) runInBackground(tracker.observeToolInput(event.toolName, event.input));
204
264
  });
205
- pi.on("turn_end", (_event, ctx) => {
265
+ pi.on("turn_end", (event, ctx) => {
266
+ if (requestRender) triggerCacheCelebrationForMessage(event.message, cacheCelebration);
206
267
  fullRedraw.request();
207
268
  requestRender?.();
208
269
  runInBackground(refreshCwdStatus(ctx));
209
270
  if (tracker) runInBackground(tracker.refresh());
271
+ runInBackground(usageTracker.refresh());
210
272
  });
211
273
  pi.on("model_select", () => requestRender?.());
212
274
  pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
213
275
  pi.on("session_shutdown", () => {
276
+ cacheCelebration.dispose();
214
277
  fullRedraw.detach();
215
278
  tracker?.dispose();
216
279
  tracker = undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@hank-warren/pi-statusline",
3
- "version": "0.1.2",
4
- "description": "Compact Pi footer statusline: model ID, git branch/dirty/behind state, linked worktrees with PR numbers, context usage, and session ID.",
3
+ "version": "0.2.0",
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": [
7
7
  "pi-package",
@@ -32,7 +32,9 @@
32
32
  },
33
33
  "files": [
34
34
  "index.ts",
35
+ "cache-celebration.ts",
35
36
  "redraw.ts",
37
+ "usage.ts",
36
38
  "worktrees.ts",
37
39
  "README.md",
38
40
  "LICENSE"
package/usage.ts ADDED
@@ -0,0 +1,234 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Remaining (not used) integer percents per provider window. */
6
+ export interface UsageSnapshot {
7
+ claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
8
+ codex?: { weekly: number };
9
+ }
10
+
11
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
12
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
13
+ const REFRESH_INTERVAL_MS = 60_000;
14
+ const FETCH_TIMEOUT_MS = 10_000;
15
+ const ONE_DAY_SECONDS = 86_400;
16
+
17
+ function toRemaining(usedPercent: unknown): number | undefined {
18
+ if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) return undefined;
19
+ return Math.round(Math.min(100, Math.max(0, 100 - usedPercent)));
20
+ }
21
+
22
+ interface ClaudeLimitEntry {
23
+ kind?: unknown;
24
+ percent?: unknown;
25
+ scope?: { model?: unknown } | null;
26
+ }
27
+
28
+ /**
29
+ * Parse the Anthropic OAuth usage payload into remaining percents. Accounts
30
+ * with a model-scoped weekly limit (e.g. Fable) expose it in the `limits`
31
+ * array as `weekly_scoped`; accounts without one simply omit the entry.
32
+ */
33
+ export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
34
+ if (typeof json !== "object" || json === null) return undefined;
35
+ const body = json as {
36
+ five_hour?: { utilization?: unknown };
37
+ seven_day?: { utilization?: unknown };
38
+ limits?: unknown;
39
+ };
40
+ const fiveHour = toRemaining(body.five_hour?.utilization);
41
+ const sevenDay = toRemaining(body.seven_day?.utilization);
42
+ if (fiveHour === undefined || sevenDay === undefined) return undefined;
43
+ const result: UsageSnapshot["claude"] = { fiveHour, sevenDay };
44
+ if (Array.isArray(body.limits)) {
45
+ const scoped = body.limits.find(
46
+ (entry): entry is ClaudeLimitEntry =>
47
+ typeof entry === "object" &&
48
+ entry !== null &&
49
+ (entry as ClaudeLimitEntry).kind === "weekly_scoped" &&
50
+ typeof (entry as ClaudeLimitEntry).percent === "number",
51
+ );
52
+ const scopedWeekly = scoped === undefined ? undefined : toRemaining(scoped.percent);
53
+ if (scopedWeekly !== undefined) result.scopedWeekly = scopedWeekly;
54
+ }
55
+ return result;
56
+ }
57
+
58
+ interface CodexWindow {
59
+ used_percent?: unknown;
60
+ limit_window_seconds?: unknown;
61
+ }
62
+
63
+ /** Parse the Codex usage payload, selecting the weekly (largest ≥ 1 day) window. */
64
+ export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
65
+ if (typeof json !== "object" || json === null) return undefined;
66
+ const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
67
+ if (typeof rateLimit !== "object" || rateLimit === null) return undefined;
68
+ const { primary_window, secondary_window } = rateLimit as {
69
+ primary_window?: CodexWindow | null;
70
+ secondary_window?: CodexWindow | null;
71
+ };
72
+ const windows = [primary_window, secondary_window].filter(
73
+ (window): window is CodexWindow => typeof window === "object" && window !== null,
74
+ );
75
+ const weekly = windows
76
+ .filter((window) => typeof window.limit_window_seconds === "number" && window.limit_window_seconds >= ONE_DAY_SECONDS)
77
+ .sort((a, b) => (b.limit_window_seconds as number) - (a.limit_window_seconds as number))[0];
78
+ const remaining = toRemaining((weekly ?? windows[0])?.used_percent);
79
+ return remaining === undefined ? undefined : { weekly: remaining };
80
+ }
81
+
82
+ /** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
83
+ export function usageBand(remaining: number): "green" | "yellow" | "orange" | "red" {
84
+ if (remaining > 60) return "green";
85
+ if (remaining > 40) return "yellow";
86
+ if (remaining > 15) return "orange";
87
+ return "red";
88
+ }
89
+
90
+ type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
91
+ ok: boolean;
92
+ json(): Promise<unknown>;
93
+ }>;
94
+
95
+ export interface UsageTrackerOptions {
96
+ authPath?: string;
97
+ fetchFn?: FetchFn;
98
+ onChange?: () => void;
99
+ now?: () => number;
100
+ }
101
+
102
+ interface AuthEntries {
103
+ anthropic?: { access?: unknown };
104
+ "openai-codex"?: { access?: unknown; accountId?: unknown };
105
+ }
106
+
107
+ function hasClaudeAuth(auth: AuthEntries): boolean {
108
+ const access = auth.anthropic?.access;
109
+ return typeof access === "string" && access.length > 0;
110
+ }
111
+
112
+ function hasCodexAuth(auth: AuthEntries): boolean {
113
+ const entry = auth["openai-codex"];
114
+ return (
115
+ typeof entry?.access === "string" &&
116
+ entry.access.length > 0 &&
117
+ typeof entry.accountId === "string" &&
118
+ entry.accountId.length > 0
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
124
+ * refreshes them), fetches both usage endpoints, and keeps the last-known good
125
+ * value per provider. Refreshes are throttled and must never throw.
126
+ */
127
+ export class UsageTracker {
128
+ private readonly authPath: string;
129
+ private readonly fetchFn: FetchFn;
130
+ private readonly onChange?: () => void;
131
+ private readonly now: () => number;
132
+ private current: UsageSnapshot = {};
133
+ private lastAttempt = Number.NEGATIVE_INFINITY;
134
+ private inFlight: Promise<void> | undefined;
135
+
136
+ constructor(options: UsageTrackerOptions = {}) {
137
+ this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
138
+ this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
139
+ this.onChange = options.onChange;
140
+ this.now = options.now ?? Date.now;
141
+ }
142
+
143
+ snapshot(): UsageSnapshot {
144
+ return this.current;
145
+ }
146
+
147
+ /** Throttled refresh; resolves when the current attempt (if any) settles. */
148
+ refresh(): Promise<void> {
149
+ if (this.inFlight) return this.inFlight;
150
+ if (this.now() - this.lastAttempt < REFRESH_INTERVAL_MS) return Promise.resolve();
151
+ this.lastAttempt = this.now();
152
+ const attempt = this.performRefresh()
153
+ .catch(() => {
154
+ // Usage display is best-effort and must never interrupt the agent.
155
+ })
156
+ .finally(() => {
157
+ if (this.inFlight === attempt) this.inFlight = undefined;
158
+ });
159
+ this.inFlight = attempt;
160
+ return attempt;
161
+ }
162
+
163
+ private async performRefresh(): Promise<void> {
164
+ const auth = (await this.readAuth()) ?? {};
165
+ const [claude, codex] = await Promise.all([this.fetchClaude(auth), this.fetchCodex(auth)]);
166
+ 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
+ if (hasClaudeAuth(auth)) {
170
+ const nextClaude = claude ?? this.current.claude;
171
+ if (nextClaude) next.claude = nextClaude;
172
+ }
173
+ if (hasCodexAuth(auth)) {
174
+ const nextCodex = codex ?? this.current.codex;
175
+ if (nextCodex) next.codex = nextCodex;
176
+ }
177
+ if (JSON.stringify(next) === JSON.stringify(this.current)) return;
178
+ this.current = next;
179
+ this.onChange?.();
180
+ }
181
+
182
+ private async readAuth(): Promise<AuthEntries | undefined> {
183
+ try {
184
+ const parsed: unknown = JSON.parse(await readFile(this.authPath, "utf8"));
185
+ if (typeof parsed !== "object" || parsed === null) return undefined;
186
+ return parsed as AuthEntries;
187
+ } catch {
188
+ return undefined;
189
+ }
190
+ }
191
+
192
+ private async fetchJson(url: string, headers: Record<string, string>): Promise<unknown> {
193
+ const controller = new AbortController();
194
+ const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
195
+ try {
196
+ const response = await this.fetchFn(url, { headers, signal: controller.signal });
197
+ if (!response.ok) return undefined;
198
+ return await response.json();
199
+ } finally {
200
+ clearTimeout(timeout);
201
+ }
202
+ }
203
+
204
+ private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
205
+ if (!hasClaudeAuth(auth)) return undefined;
206
+ try {
207
+ 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
+ }),
212
+ );
213
+ } catch {
214
+ return undefined;
215
+ }
216
+ }
217
+
218
+ private async fetchCodex(auth: AuthEntries): Promise<UsageSnapshot["codex"] | undefined> {
219
+ if (!hasCodexAuth(auth)) return undefined;
220
+ const entry = auth["openai-codex"];
221
+ const access = entry?.access as string;
222
+ const accountId = entry?.accountId as string;
223
+ try {
224
+ return parseCodexUsage(
225
+ await this.fetchJson(CODEX_USAGE_URL, {
226
+ Authorization: `Bearer ${access}`,
227
+ "chatgpt-account-id": accountId,
228
+ }),
229
+ );
230
+ } catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ }