@hank-warren/pi-statusline 0.1.3 → 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.
- package/README.md +17 -4
- package/cache-celebration.ts +1 -1
- package/index.ts +39 -10
- package/package.json +2 -1
- package/usage.ts +377 -0
package/README.md
CHANGED
|
@@ -3,28 +3,41 @@
|
|
|
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,
|
|
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 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.
|
|
31
|
+
|
|
19
32
|
## Neon cache-wave celebration
|
|
20
33
|
|
|
21
34
|
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:
|
|
22
35
|
|
|
23
36
|
```text
|
|
24
|
-
gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡
|
|
37
|
+
gpt-5.6-sol | pi-extensions:main | 135k/272k | ⚡96%·CACHE·HIT
|
|
25
38
|
```
|
|
26
39
|
|
|
27
|
-
Only the `⚡
|
|
40
|
+
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.
|
|
28
41
|
|
|
29
42
|
The rate is evaluated per provider response as:
|
|
30
43
|
|
package/cache-celebration.ts
CHANGED
package/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
triggerCacheCelebrationForMessage,
|
|
8
8
|
} from "./cache-celebration.ts";
|
|
9
9
|
import { FullRedrawScheduler } from "./redraw.ts";
|
|
10
|
+
import { type UsageSnapshot, usageBand, UsageTracker } from "./usage.ts";
|
|
10
11
|
import {
|
|
11
12
|
type GitRepositoryStatus,
|
|
12
13
|
readGitStatus,
|
|
@@ -24,6 +25,7 @@ export interface StatuslineData {
|
|
|
24
25
|
worktrees: SessionWorktree[];
|
|
25
26
|
sessionId: string;
|
|
26
27
|
cacheCelebration?: CacheCelebrationSnapshot;
|
|
28
|
+
usage?: UsageSnapshot;
|
|
27
29
|
}
|
|
28
30
|
|
|
29
31
|
const BLUE = "\x1b[38;2;0;153;255m";
|
|
@@ -35,17 +37,37 @@ const YELLOW = "\x1b[38;2;230;200;0m";
|
|
|
35
37
|
const WHITE = "\x1b[38;2;220;220;220m";
|
|
36
38
|
const MAGENTA = "\x1b[38;2;190;120;255m";
|
|
37
39
|
const NEON_CYAN = "\x1b[38;2;0;255;255m";
|
|
38
|
-
const NEON_BLUE = "\x1b[38;2;0;125;255m";
|
|
39
40
|
const NEON_MAGENTA = "\x1b[38;2;255;0;255m";
|
|
40
41
|
const DIM = "\x1b[2m";
|
|
41
42
|
const BOLD = "\x1b[1m";
|
|
42
43
|
const RESET = "\x1b[0m";
|
|
43
|
-
const NEON_WAVE = [NEON_CYAN, NEON_CYAN, NEON_BLUE, NEON_BLUE, NEON_MAGENTA, NEON_MAGENTA] as const;
|
|
44
44
|
|
|
45
45
|
function styled(style: string, text: string): string {
|
|
46
46
|
return `${style}${text}${RESET}`;
|
|
47
47
|
}
|
|
48
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
|
+
|
|
49
71
|
function contextColor(contextTokens: number, contextWindow: number): string {
|
|
50
72
|
const percent = contextWindow > 0 ? Math.floor((contextTokens * 100) / contextWindow) : 0;
|
|
51
73
|
if (percent >= 90) return RED;
|
|
@@ -77,13 +99,9 @@ export function renderCacheCelebrationLine(
|
|
|
77
99
|
summary: string,
|
|
78
100
|
celebration: CacheCelebrationSnapshot,
|
|
79
101
|
): string {
|
|
80
|
-
const badge =
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
const color = NEON_WAVE[(index + celebration.frame) % NEON_WAVE.length];
|
|
84
|
-
return `${BOLD}${color}${character}${RESET}`;
|
|
85
|
-
})
|
|
86
|
-
.join("");
|
|
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}`;
|
|
87
105
|
return `${summary}${styled(DIM, " | ")}${animatedBadge}`;
|
|
88
106
|
}
|
|
89
107
|
|
|
@@ -112,7 +130,14 @@ export function renderStatusline(data: StatuslineData, width: number): string[]
|
|
|
112
130
|
: styled(contextColor(data.contextTokens, data.contextWindow), formatTokenCount(data.contextTokens));
|
|
113
131
|
const context = `${used}${styled(DIM, "/")}${styled(WHITE, formatTokenCount(data.contextWindow))}`;
|
|
114
132
|
const cwd = data.cwdGit ? renderRepository(data.cwd, data.cwdGit) : styled(CYAN, data.cwd);
|
|
115
|
-
const
|
|
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 : "");
|
|
116
141
|
const firstLine = data.cacheCelebration
|
|
117
142
|
? renderCacheCelebrationLine(summary, data.cacheCelebration)
|
|
118
143
|
: summary;
|
|
@@ -128,6 +153,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
128
153
|
const fullRedraw = new FullRedrawScheduler();
|
|
129
154
|
const cacheCelebration = new CacheCelebrationController(() => requestRender?.());
|
|
130
155
|
let tracker: SessionWorktreeTracker | undefined;
|
|
156
|
+
const usageTracker = new UsageTracker({ onChange: () => requestRender?.() });
|
|
131
157
|
let cwdGit: GitRepositoryStatus | null = null;
|
|
132
158
|
let cwdStatusAbort: AbortController | undefined;
|
|
133
159
|
let cwdStatusInFlight: Promise<void> | undefined;
|
|
@@ -180,6 +206,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
180
206
|
runInBackground(refreshCwdStatus(ctx));
|
|
181
207
|
runInBackground(next.seedFromEntries(ctx.sessionManager.getBranch()));
|
|
182
208
|
runInBackground(next.includeCurrentWorktree(ctx.cwd));
|
|
209
|
+
runInBackground(usageTracker.refresh());
|
|
183
210
|
};
|
|
184
211
|
|
|
185
212
|
pi.on("session_start", (_event, ctx) => {
|
|
@@ -220,6 +247,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
220
247
|
worktrees: tracker?.getWorktrees() ?? [],
|
|
221
248
|
sessionId: ctx.sessionManager.getSessionId(),
|
|
222
249
|
cacheCelebration: cacheCelebration.snapshot(),
|
|
250
|
+
usage: usageTracker.snapshot(),
|
|
223
251
|
},
|
|
224
252
|
width,
|
|
225
253
|
),
|
|
@@ -240,6 +268,7 @@ export default function statuslineExtension(pi: ExtensionAPI): void {
|
|
|
240
268
|
requestRender?.();
|
|
241
269
|
runInBackground(refreshCwdStatus(ctx));
|
|
242
270
|
if (tracker) runInBackground(tracker.refresh());
|
|
271
|
+
runInBackground(usageTracker.refresh());
|
|
243
272
|
});
|
|
244
273
|
pi.on("model_select", () => requestRender?.());
|
|
245
274
|
pi.on("session_tree", (_event, ctx) => resetTracker(ctx));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hank-warren/pi-statusline",
|
|
3
|
-
"version": "0.1
|
|
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": [
|
|
@@ -34,6 +34,7 @@
|
|
|
34
34
|
"index.ts",
|
|
35
35
|
"cache-celebration.ts",
|
|
36
36
|
"redraw.ts",
|
|
37
|
+
"usage.ts",
|
|
37
38
|
"worktrees.ts",
|
|
38
39
|
"README.md",
|
|
39
40
|
"LICENSE"
|
package/usage.ts
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pid } from "node:process";
|
|
5
|
+
|
|
6
|
+
/** Remaining (not used) integer percents per provider window. */
|
|
7
|
+
export interface UsageSnapshot {
|
|
8
|
+
claude?: { fiveHour: number; sevenDay: number; scopedWeekly?: number };
|
|
9
|
+
codex?: { weekly: number };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
|
|
13
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
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;
|
|
32
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
33
|
+
const ONE_DAY_SECONDS = 86_400;
|
|
34
|
+
|
|
35
|
+
function toRemaining(usedPercent: unknown): number | undefined {
|
|
36
|
+
if (typeof usedPercent !== "number" || !Number.isFinite(usedPercent)) return undefined;
|
|
37
|
+
return Math.round(Math.min(100, Math.max(0, 100 - usedPercent)));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface ClaudeLimitEntry {
|
|
41
|
+
kind?: unknown;
|
|
42
|
+
percent?: unknown;
|
|
43
|
+
scope?: { model?: unknown } | null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Parse the Anthropic OAuth usage payload into remaining percents. Accounts
|
|
48
|
+
* with a model-scoped weekly limit (e.g. Fable) expose it in the `limits`
|
|
49
|
+
* array as `weekly_scoped`; accounts without one simply omit the entry.
|
|
50
|
+
*/
|
|
51
|
+
export function parseClaudeUsage(json: unknown): UsageSnapshot["claude"] | undefined {
|
|
52
|
+
if (typeof json !== "object" || json === null) return undefined;
|
|
53
|
+
const body = json as {
|
|
54
|
+
five_hour?: { utilization?: unknown };
|
|
55
|
+
seven_day?: { utilization?: unknown };
|
|
56
|
+
limits?: unknown;
|
|
57
|
+
};
|
|
58
|
+
const fiveHour = toRemaining(body.five_hour?.utilization);
|
|
59
|
+
const sevenDay = toRemaining(body.seven_day?.utilization);
|
|
60
|
+
if (fiveHour === undefined || sevenDay === undefined) return undefined;
|
|
61
|
+
const result: UsageSnapshot["claude"] = { fiveHour, sevenDay };
|
|
62
|
+
if (Array.isArray(body.limits)) {
|
|
63
|
+
const scoped = body.limits.find(
|
|
64
|
+
(entry): entry is ClaudeLimitEntry =>
|
|
65
|
+
typeof entry === "object" &&
|
|
66
|
+
entry !== null &&
|
|
67
|
+
(entry as ClaudeLimitEntry).kind === "weekly_scoped" &&
|
|
68
|
+
typeof (entry as ClaudeLimitEntry).percent === "number",
|
|
69
|
+
);
|
|
70
|
+
const scopedWeekly = scoped === undefined ? undefined : toRemaining(scoped.percent);
|
|
71
|
+
if (scopedWeekly !== undefined) result.scopedWeekly = scopedWeekly;
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface CodexWindow {
|
|
77
|
+
used_percent?: unknown;
|
|
78
|
+
limit_window_seconds?: unknown;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Parse the Codex usage payload, selecting the weekly (largest ≥ 1 day) window. */
|
|
82
|
+
export function parseCodexUsage(json: unknown): UsageSnapshot["codex"] | undefined {
|
|
83
|
+
if (typeof json !== "object" || json === null) return undefined;
|
|
84
|
+
const rateLimit = (json as { rate_limit?: unknown }).rate_limit;
|
|
85
|
+
if (typeof rateLimit !== "object" || rateLimit === null) return undefined;
|
|
86
|
+
const { primary_window, secondary_window } = rateLimit as {
|
|
87
|
+
primary_window?: CodexWindow | null;
|
|
88
|
+
secondary_window?: CodexWindow | null;
|
|
89
|
+
};
|
|
90
|
+
const windows = [primary_window, secondary_window].filter(
|
|
91
|
+
(window): window is CodexWindow => typeof window === "object" && window !== null,
|
|
92
|
+
);
|
|
93
|
+
const weekly = windows
|
|
94
|
+
.filter((window) => typeof window.limit_window_seconds === "number" && window.limit_window_seconds >= ONE_DAY_SECONDS)
|
|
95
|
+
.sort((a, b) => (b.limit_window_seconds as number) - (a.limit_window_seconds as number))[0];
|
|
96
|
+
const remaining = toRemaining((weekly ?? windows[0])?.used_percent);
|
|
97
|
+
return remaining === undefined ? undefined : { weekly: remaining };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Color band for a remaining percent: >60 green, >40 yellow, >15 orange, else red. */
|
|
101
|
+
export function usageBand(remaining: number): "green" | "yellow" | "orange" | "red" {
|
|
102
|
+
if (remaining > 60) return "green";
|
|
103
|
+
if (remaining > 40) return "yellow";
|
|
104
|
+
if (remaining > 15) return "orange";
|
|
105
|
+
return "red";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type FetchFn = (url: string, init: { headers: Record<string, string>; signal: AbortSignal }) => Promise<{
|
|
109
|
+
ok: boolean;
|
|
110
|
+
status?: number;
|
|
111
|
+
json(): Promise<unknown>;
|
|
112
|
+
}>;
|
|
113
|
+
|
|
114
|
+
export interface UsageTrackerOptions {
|
|
115
|
+
authPath?: string;
|
|
116
|
+
cachePath?: string;
|
|
117
|
+
fetchFn?: FetchFn;
|
|
118
|
+
onChange?: () => void;
|
|
119
|
+
now?: () => number;
|
|
120
|
+
}
|
|
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
|
+
|
|
155
|
+
interface AuthEntries {
|
|
156
|
+
anthropic?: { access?: unknown };
|
|
157
|
+
"openai-codex"?: { access?: unknown; accountId?: unknown };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function hasClaudeAuth(auth: AuthEntries): boolean {
|
|
161
|
+
const access = auth.anthropic?.access;
|
|
162
|
+
return typeof access === "string" && access.length > 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function hasCodexAuth(auth: AuthEntries): boolean {
|
|
166
|
+
const entry = auth["openai-codex"];
|
|
167
|
+
return (
|
|
168
|
+
typeof entry?.access === "string" &&
|
|
169
|
+
entry.access.length > 0 &&
|
|
170
|
+
typeof entry.accountId === "string" &&
|
|
171
|
+
entry.accountId.length > 0
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Best-effort subscription usage poller. Reads Pi's auth.json for tokens (never
|
|
177
|
+
* refreshes them), fetches both usage endpoints, and keeps the last-known good
|
|
178
|
+
* value per provider. Refreshes are throttled and must never throw.
|
|
179
|
+
*/
|
|
180
|
+
export class UsageTracker {
|
|
181
|
+
private readonly authPath: string;
|
|
182
|
+
private readonly cachePath: string;
|
|
183
|
+
private readonly fetchFn: FetchFn;
|
|
184
|
+
private readonly onChange?: () => void;
|
|
185
|
+
private readonly now: () => number;
|
|
186
|
+
private current: UsageSnapshot = {};
|
|
187
|
+
private awaitingProvider = true;
|
|
188
|
+
private readonly rateLimited = new Set<ProviderKey>();
|
|
189
|
+
private lastAttempt = Number.NEGATIVE_INFINITY;
|
|
190
|
+
private inFlight: Promise<void> | undefined;
|
|
191
|
+
|
|
192
|
+
constructor(options: UsageTrackerOptions = {}) {
|
|
193
|
+
this.authPath = options.authPath ?? join(homedir(), ".pi", "agent", "auth.json");
|
|
194
|
+
this.cachePath = options.cachePath ?? join(homedir(), ".pi", "agent", "statusline-usage.json");
|
|
195
|
+
this.fetchFn = options.fetchFn ?? ((url, init) => fetch(url, init));
|
|
196
|
+
this.onChange = options.onChange;
|
|
197
|
+
this.now = options.now ?? Date.now;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
snapshot(): UsageSnapshot {
|
|
201
|
+
return this.current;
|
|
202
|
+
}
|
|
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
|
+
|
|
212
|
+
/** Throttled refresh; resolves when the current attempt (if any) settles. */
|
|
213
|
+
refresh(): Promise<void> {
|
|
214
|
+
if (this.inFlight) return this.inFlight;
|
|
215
|
+
const attempt = this.performRefresh()
|
|
216
|
+
.catch(() => {
|
|
217
|
+
// Usage display is best-effort and must never interrupt the agent.
|
|
218
|
+
})
|
|
219
|
+
.finally(() => {
|
|
220
|
+
if (this.inFlight === attempt) this.inFlight = undefined;
|
|
221
|
+
});
|
|
222
|
+
this.inFlight = attempt;
|
|
223
|
+
return attempt;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* One refresh cycle: adopt whatever another process has already published,
|
|
228
|
+
* then poll only if the shared throttle allows it.
|
|
229
|
+
*/
|
|
230
|
+
private async performRefresh(): Promise<void> {
|
|
231
|
+
const auth = (await this.readAuth()) ?? {};
|
|
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 {
|
|
272
|
+
const next: UsageSnapshot = {};
|
|
273
|
+
if (hasClaudeAuth(auth)) {
|
|
274
|
+
const claude = incoming.claude ?? this.current.claude;
|
|
275
|
+
if (claude) next.claude = claude;
|
|
276
|
+
}
|
|
277
|
+
if (hasCodexAuth(auth)) {
|
|
278
|
+
const codex = incoming.codex ?? this.current.codex;
|
|
279
|
+
if (codex) next.codex = codex;
|
|
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));
|
|
292
|
+
if (JSON.stringify(next) === JSON.stringify(this.current)) return;
|
|
293
|
+
this.current = next;
|
|
294
|
+
this.onChange?.();
|
|
295
|
+
}
|
|
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
|
+
|
|
316
|
+
private async readAuth(): Promise<AuthEntries | undefined> {
|
|
317
|
+
try {
|
|
318
|
+
const parsed: unknown = JSON.parse(await readFile(this.authPath, "utf8"));
|
|
319
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
320
|
+
return parsed as AuthEntries;
|
|
321
|
+
} catch {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private async fetchJson(url: string, headers: Record<string, string>, provider: ProviderKey): Promise<unknown> {
|
|
327
|
+
const controller = new AbortController();
|
|
328
|
+
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
329
|
+
try {
|
|
330
|
+
const response = await this.fetchFn(url, { headers, signal: controller.signal });
|
|
331
|
+
if (response.status === 429) this.rateLimited.add(provider);
|
|
332
|
+
if (!response.ok) return undefined;
|
|
333
|
+
return await response.json();
|
|
334
|
+
} finally {
|
|
335
|
+
clearTimeout(timeout);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private async fetchClaude(auth: AuthEntries): Promise<UsageSnapshot["claude"] | undefined> {
|
|
340
|
+
if (!hasClaudeAuth(auth)) return undefined;
|
|
341
|
+
try {
|
|
342
|
+
return parseClaudeUsage(
|
|
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
|
+
),
|
|
351
|
+
);
|
|
352
|
+
} catch {
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
private async fetchCodex(auth: AuthEntries): Promise<UsageSnapshot["codex"] | undefined> {
|
|
358
|
+
if (!hasCodexAuth(auth)) return undefined;
|
|
359
|
+
const entry = auth["openai-codex"];
|
|
360
|
+
const access = entry?.access as string;
|
|
361
|
+
const accountId = entry?.accountId as string;
|
|
362
|
+
try {
|
|
363
|
+
return parseCodexUsage(
|
|
364
|
+
await this.fetchJson(
|
|
365
|
+
CODEX_USAGE_URL,
|
|
366
|
+
{
|
|
367
|
+
Authorization: `Bearer ${access}`,
|
|
368
|
+
"chatgpt-account-id": accountId,
|
|
369
|
+
},
|
|
370
|
+
"codex",
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
} catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|