@nothumanwork/pi-usage 0.0.0-stage → 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
@@ -1,3 +1,165 @@
1
- # Temporary Holding Version
1
+ # Pi Usage
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **OpenAI ChatGPT and Claude subscription usage limits in Pi.**
4
+
5
+ Run `/usage` for a scrollable view of each account's allowance and reset times—not tokens consumed in the current Pi session. OpenAI bars show **percent left**, like Codex. Claude bars show **percent used**, like Claude Code, including the five-hour session, all-models weekly, and Fable weekly limits.
6
+
7
+ ## Install
8
+
9
+ From this checkout, after installing dependencies at the repository root:
10
+
11
+ ```sh
12
+ pi install /path/to/pi-extensions/pi-usage
13
+ ```
14
+
15
+ Then restart Pi or run `/reload`. For a one-session preview:
16
+
17
+ ```sh
18
+ pi -e /path/to/pi-extensions/pi-usage/index.ts
19
+ ```
20
+
21
+ Published releases:
22
+
23
+ ```sh
24
+ pi install npm:@nothumanwork/pi-usage
25
+ ```
26
+
27
+ Requires Pi 1.4.0's extension/auth APIs and Node.js 22.19+. No background polling, configuration file, or model tool is installed.
28
+
29
+ ## Sign in
30
+
31
+ ### OpenAI
32
+
33
+ Keep using **`/login openai` → Sign in with ChatGPT** for inference. Add the separate credential used by Codex to read usage:
34
+
35
+ ```text
36
+ /login openai-codex
37
+ /usage
38
+ ```
39
+
40
+ Choose the **same ChatGPT account/workspace** for both logins. The usage view displays the account belonging to the Codex credential; it does not automatically verify that it matches your inference account. Signing into the Codex provider does not require changing your selected model.
41
+
42
+ Pi owns credential storage, refresh, and refresh-token locking. This extension does not create another OAuth client, copy tokens, read `~/.codex/auth.json`, or use browser cookies. An API key is not a fallback.
43
+
44
+ #### Why the second OpenAI login?
45
+
46
+ The newer Sign in with ChatGPT token-sharing login does not currently expose Codex-style account usage. Its token is not accepted by Codex's usage endpoint. See [OpenAI issue #54219](https://github.com/openai/codex/issues/54219) and [OpenAI's errors and recovery guide](https://developers.openai.com/siwc/token-sharing-open-source/errors-and-recovery).
47
+
48
+ This extension reads **account allowance through Codex**. It deliberately **does not include Pi-specific app caps** on the newer login. An app-specific cap can therefore block Pi even when the account view shows allowance remaining. No allowance is inferred from session token counts or token expiry.
49
+
50
+ ### Claude: native Pi or Claude Code / pi-claude-bridge
51
+
52
+ If already signed into Claude Code (including through `pi-claude-bridge`), just run:
53
+
54
+ ```text
55
+ /usage claude
56
+ ```
57
+
58
+ **No separate `/login anthropic` is needed for bridge users.** If Claude Code needs a login, run `claude auth login` in your terminal. Inference-only `claude setup-token` tokens may lack the profile permission needed for usage.
59
+
60
+ Alternatively, use Pi's native subscription login:
61
+
62
+ ```text
63
+ /login anthropic
64
+ /usage claude
65
+ ```
66
+
67
+ Select the Claude subscription/OAuth option, not an API key. An Anthropic API key is not a subscription-usage credential.
68
+
69
+ #### Where authentication lives
70
+
71
+ | Login | Credential store | Who resolves and refreshes it |
72
+ | --- | --- | --- |
73
+ | Pi native Anthropic | `anthropic` entry in `~/.pi/agent/auth.json`, or `$PI_CODING_AGENT_DIR/auth.json` | Pi's `modelRegistry.getProviderAuth("anthropic")` |
74
+ | Claude Code / bridge on macOS | macOS Keychain, normally service `Claude Code-credentials`; fallback `~/.claude/.credentials.json` if Keychain storage is unavailable | Claude Code, through the Agent SDK |
75
+ | Claude Code / bridge on Linux | `~/.claude/.credentials.json` | Claude Code, through the Agent SDK |
76
+ | Claude Code / bridge on Windows | `%USERPROFILE%\.claude\.credentials.json` | Claude Code, through the Agent SDK |
77
+
78
+ `CLAUDE_CONFIG_DIR` changes Claude Code's credentials-file directory **and** the macOS Keychain entry it selects. The SDK handles this, including config-specific Keychain naming; the extension does not duplicate that lookup or rotate Claude Code's tokens itself. The bridge passes the process environment to Claude Code and does not maintain a separate subscription login in Pi. See [Claude Code authentication](https://code.claude.com/docs/en/authentication) and the [bridge source](https://github.com/elidickinson/pi-claude-bridge).
79
+
80
+ **Account selection:** if the selected Pi model uses `claude-bridge`, use Claude Code's login. Otherwise prefer native Pi Anthropic OAuth when present, falling back to Claude Code only when no native OAuth credential is configured (an API key also falls through). A failed selected login does **not** silently switch to another account. The view labels the source and shows identity when available.
81
+
82
+ The Claude Code path inspects the stored **subscription**, not API billing: API-key, API-endpoint, profile, and third-party backend environment overrides are removed from the usage-only child. `CLAUDE_CONFIG_DIR` and an explicit `CLAUDE_CODE_OAUTH_TOKEN` remain respected; an inference-only token results in an unavailable state rather than a hidden account switch.
83
+
84
+ The Agent SDK opens a short-lived Claude Code process in an empty temporary directory. It sends **no prompt**, enables no tools or MCP servers, disables ordinary hooks/settings, does not persist a conversation, and requests `skipBehaviors: true` to avoid scanning local transcripts. Claude Code's managed policies still apply. The process is closed and the temporary directory removed on success, failure, cancellation, or timeout. Credential refresh remains owned by Claude Code.
85
+
86
+ Keep npm optional dependencies enabled: the Agent SDK supplies a platform-specific Claude Code binary. No separately installed CLI executable is required to read an existing login. The SDK is loaded lazily, only for this auth path.
87
+
88
+ ## Commands and controls
89
+
90
+ | Command / key | Action |
91
+ | --- | --- |
92
+ | `/usage` | Fetch all supported providers |
93
+ | `/usage openai` | Fetch only OpenAI |
94
+ | `/usage claude` | Fetch only Claude subscription usage |
95
+ | `/usage anthropic` | Alias for `/usage claude` |
96
+ | `r` | Fetch a fresh snapshot |
97
+ | Up / Down | Scroll |
98
+ | Page Up / Page Down | Scroll a page |
99
+ | Enter / Escape / `q` | Close; cancel pending work |
100
+
101
+ The terminal view is an overlay. RPC clients receive the same information as a notification; run the command again to refresh. Print/JSON mode has no account-usage UI. Nothing is sent to the model or appended to session history.
102
+
103
+ The view includes:
104
+
105
+ - Account identity when available, plan when reported, credential source, and fetch time.
106
+ - OpenAI usage windows returned by the server, including five-hour, weekly, and additional model limits when present.
107
+ - Claude's **Current session (5h)**, **Current week (all models)**, and **Current week (Fable)**, plus other model-specific weekly limits when reported.
108
+ - Local reset date/time and a relative duration.
109
+ - Credit balance or unlimited-credit status when reported. Balances are not assumed to be dollars.
110
+ - Links to [ChatGPT Settings → Usage](https://chatgpt.com/settings/usage) and [Claude Settings → Usage](https://claude.ai/settings/usage).
111
+
112
+ Primary does **not** necessarily mean five-hour: some plans only report a weekly window. Labels follow the server's window duration. Missing/null values are shown as unknown, not zero usage or unlimited allowance. Expired reset timestamps prompt a refresh rather than inventing a replenished quota. Failed refreshes clear the previous snapshot.
113
+
114
+ Requests happen only when opening or refreshing the view, with no extension background polling or retry loop. Claude Code manages its own authentication/network behavior. A 15-second overall timeout bounds the wait, including auth resolution and SDK startup; closing the view cancels the usage request or SDK process. Pi may finish its own already-started credential refresh independently. One failed provider does not suppress other providers.
115
+
116
+ Claude's model-specific weekly limits are read from the SDK's `model_scoped` rows or the endpoint's `limits[]` rows—not an assumed `seven_day_fable` field. Missing Fable data remains **Not reported**, never 0% used. App/surface-specific caps and local behavior estimates are deliberately excluded.
117
+
118
+ ## Implementation and extension points
119
+
120
+ ```text
121
+ index.ts Command registration and mode handling
122
+ src/types.ts Provider-neutral snapshots, windows, groups and errors
123
+ src/service.ts Independent provider collection, cancellation and timeouts
124
+ src/providers/index.ts Adapter registration
125
+ src/providers/openai.ts Pi auth + Codex usage adapter and normalization
126
+ src/providers/claude.ts Claude auth selection + native Pi OAuth HTTP adapter
127
+ src/providers/claude-code.ts Claude Agent SDK usage-only connection
128
+ src/providers/claude-normalize.ts Native/SDK quota normalization
129
+ src/format.ts Shared text presentation
130
+ src/view.ts Pi TUI overlay, refresh and scrolling
131
+ ```
132
+
133
+ To add a provider:
134
+
135
+ 1. Implement `UsageProvider` from `src/types.ts`.
136
+ 2. Resolve credentials through Pi or the provider's own SDK and return a normalized `UsageSnapshot`. Set a window's `display: "used"` for Claude-style bars; the default is percent remaining.
137
+ 3. Use `UsageError` for safe, actionable error messages. Never include tokens, raw auth errors, or response bodies.
138
+ 4. Add the adapter to `createUsageProviders()` in `src/providers/index.ts` and add fixture-based tests.
139
+
140
+ No changes to the command or view should be needed. Other extensions are not dynamically discovered as usage plugins in this first version.
141
+
142
+ ### SDKs and endpoint stability
143
+
144
+ - **Pi SDK:** provider authentication and refresh; commands and UI.
145
+ - **OpenAI and Anthropic JavaScript SDKs:** HTTP transport, error classification, timeouts and cancellation.
146
+ - **Claude Agent SDK:** Claude Code credential management and the structured usage control request. Version **0.3.310 is pinned** because `usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET()` is explicitly experimental; the integration is isolated in one file.
147
+ - **jose:** decode account claims for routing/display (the server validates the bearer token).
148
+ - **Zod:** validate and normalize the usage response.
149
+
150
+ The direct adapters use the API SDKs' generic `get()` methods: OpenAI reads `GET https://chatgpt.com/backend-api/wham/usage`, as used by [Codex's backend client](https://github.com/openai/codex/tree/main/codex-rs/backend-client); native Anthropic OAuth reads `GET https://api.anthropic.com/api/oauth/usage` with `anthropic-beta: oauth-2025-04-20`. An optional `/api/oauth/profile` request supplies native Claude account identity. These are **not stable public billing APIs** and may change. The Claude Code path uses the Agent SDK control API instead of duplicating its auth/storage integration.
151
+
152
+ The direct adapters send credentials only to fixed provider endpoints; redirects and SDK logging are disabled. Account metadata is escaped for terminal display. The extension writes no token or usage cache. Pi and Claude Code may update their own original credential stores during refresh.
153
+
154
+ ## Develop
155
+
156
+ Run from the repository root:
157
+
158
+ ```sh
159
+ npm install --ignore-scripts
160
+ npm run check --workspace @nothumanwork/pi-usage
161
+ npm test --workspace @nothumanwork/pi-usage
162
+ npm pack --workspace @nothumanwork/pi-usage --dry-run
163
+ ```
164
+
165
+ Automated tests mock the network, credentials, and Claude Code process. They exercise the real API SDK transports and Pi extension loader, and cover both Claude auth paths, native/SDK quota schemas, account selection, used/remaining bars, errors, cancellation and process cleanup, multiple providers, narrow terminals, refresh, and scrolling. They do not make model requests or access a real account. Claude Code's usage-only SDK path has also been smoke-tested locally against a real subscription without any model requests.
package/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { formatUsage } from "./src/format.ts";
3
+ import { createUsageProviders } from "./src/providers/index.ts";
4
+ import { collectUsage } from "./src/service.ts";
5
+ import type { UsageProvider } from "./src/types.ts";
6
+ import { UsageView } from "./src/view.ts";
7
+
8
+ export function registerUsage(pi: ExtensionAPI, providers: readonly UsageProvider[]): void {
9
+ let active: AbortController | undefined;
10
+ pi.on("session_shutdown", () => active?.abort());
11
+
12
+ pi.registerCommand("usage", {
13
+ description: "Show account usage limits and reset times (/usage [provider])",
14
+ getArgumentCompletions: (prefix) => providers
15
+ .filter((provider) => provider.id.startsWith(prefix.toLowerCase()))
16
+ .map((provider) => ({ value: provider.id, label: provider.label })),
17
+ handler: async (args, ctx) => {
18
+ if (!ctx.hasUI) {
19
+ ctx.ui.notify("/usage requires interactive Pi or an RPC client with notifications.", "warning");
20
+ return;
21
+ }
22
+ const id = args.trim().toLowerCase();
23
+ const selected = id ? providers.filter((provider) => provider.id === id || provider.aliases?.includes(id)) : providers;
24
+ if (!selected.length) {
25
+ ctx.ui.notify(`Usage: /usage [${providers.map((provider) => provider.id).join("|")}]`, "warning");
26
+ return;
27
+ }
28
+ if (active) {
29
+ ctx.ui.notify("An account usage view is already open.", "info");
30
+ return;
31
+ }
32
+ const controller = new AbortController();
33
+ active = controller;
34
+ try {
35
+ if (ctx.mode === "tui") {
36
+ await ctx.ui.custom<void>((tui, theme, keys, done) => {
37
+ const view = new UsageView(tui, theme, keys, () => done(), (signal) =>
38
+ collectUsage(selected, ctx.modelRegistry, AbortSignal.any([signal, controller.signal]), { activeProvider: ctx.model?.provider }));
39
+ controller.signal.addEventListener("abort", () => view.close(), { once: true });
40
+ return view;
41
+ }, { overlay: true, overlayOptions: { width: "90%", maxHeight: "85%" } });
42
+ } else {
43
+ const results = await collectUsage(selected, ctx.modelRegistry, controller.signal, { activeProvider: ctx.model?.provider });
44
+ if (!controller.signal.aborted) ctx.ui.notify(formatUsage(results), "info");
45
+ }
46
+ } catch {
47
+ if (!controller.signal.aborted) ctx.ui.notify("Could not display account usage. Try /usage again.", "error");
48
+ } finally {
49
+ controller.abort();
50
+ if (active === controller) active = undefined;
51
+ }
52
+ },
53
+ });
54
+ }
55
+
56
+ export default function usageExtension(pi: ExtensionAPI): void {
57
+ registerUsage(pi, createUsageProviders());
58
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@nothumanwork/pi-usage",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "description": "OpenAI ChatGPT and Claude subscription usage limits and reset times in Pi",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "engines": {
10
+ "node": ">=22.19.0"
11
+ },
12
+ "publishConfig": {
13
+ "access": "public"
14
+ },
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/thehumanworks/pi-extensions.git",
18
+ "directory": "pi-usage"
19
+ },
20
+ "files": [
21
+ "index.ts",
22
+ "src",
23
+ "README.md"
24
+ ],
25
+ "pi": {
26
+ "extensions": [
27
+ "./index.ts"
28
+ ]
29
+ },
30
+ "scripts": {
31
+ "check": "tsc --noEmit",
32
+ "test": "tsx --test test/*.test.ts"
33
+ },
34
+ "dependencies": {
35
+ "@anthropic-ai/claude-agent-sdk": "0.3.310",
36
+ "@anthropic-ai/sdk": "^1.3.0",
37
+ "jose": "^6.2.14",
38
+ "openai": "^7.19.0",
39
+ "zod": "^4.6.7"
40
+ },
41
+ "peerDependencies": {
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "1.4.0",
47
+ "@earendil-works/pi-tui": "1.4.0",
48
+ "@types/node": "^22.19.0",
49
+ "tsx": "^4.20.0",
50
+ "typescript": "^5.9.3"
51
+ }
52
+ }
package/src/format.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { UsageResult, UsageWindow } from "./types.ts";
3
+
4
+ /** Provider labels and account metadata are data, not terminal control sequences. */
5
+ export function safeText(value: string): string {
6
+ return stripVTControlCharacters(value)
7
+ .replace(/[\x00-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, " ")
8
+ .slice(0, 240);
9
+ }
10
+
11
+ export function formatReset(resetsAt: number | undefined, now: number): string {
12
+ if (resetsAt === undefined) return "Reset time not reported";
13
+ if (resetsAt <= now) return "Reset time passed — refresh to update";
14
+ const minutes = Math.max(1, Math.ceil((resetsAt - now) / 60_000));
15
+ const days = Math.floor(minutes / 1440);
16
+ const hours = Math.floor((minutes % 1440) / 60);
17
+ const mins = minutes % 60;
18
+ const relative = [days ? `${days}d` : "", hours ? `${hours}h` : "", mins ? `${mins}m` : ""].filter(Boolean).join(" ");
19
+ const date = new Date(resetsAt).toLocaleString(undefined, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", timeZoneName: "short" });
20
+ return `Resets in ${relative} · ${date}`;
21
+ }
22
+
23
+ export function formatWindow(window: UsageWindow, now: number): string[] {
24
+ const label = safeText(window.label);
25
+ if (window.remainingPercent === undefined) return [`${label}: Not reported`, ` ${formatReset(window.resetsAt, now)}`];
26
+ const left = Math.max(0, Math.min(100, window.remainingPercent));
27
+ const percent = window.display === "used" ? 100 - left : left;
28
+ const filled = Math.round(percent / 5);
29
+ const bar = "█".repeat(filled) + "░".repeat(20 - filled);
30
+ return [
31
+ `${label}: [${bar}] ${Math.round(percent * 10) / 10}% ${window.display === "used" ? "used" : "left"}`,
32
+ ` ${formatReset(window.resetsAt, now)}`,
33
+ ];
34
+ }
35
+
36
+ export function formatUsage(results: readonly UsageResult[], now = Date.now()): string {
37
+ return results.map((result) => {
38
+ const lines = [safeText(result.provider.label)];
39
+ if (result.status !== "ok") {
40
+ lines.push(`${result.status === "unavailable" ? "Unavailable" : "Error"}: ${safeText(result.message)}`);
41
+ } else {
42
+ const data = result.snapshot;
43
+ lines.push(`Account: ${safeText(data.account)}`);
44
+ if (data.plan) lines.push(`Plan: ${safeText(data.plan)}`);
45
+ lines.push(`Source: ${safeText(data.source)}`);
46
+ lines.push(`Checked: ${new Date(data.fetchedAt).toLocaleString()}`);
47
+ if (!data.groups.length) lines.push("", "No allowance windows reported (not necessarily unlimited).");
48
+ for (const group of data.groups) {
49
+ lines.push("", safeText(group.label));
50
+ if (group.limitReached) lines.push("Limit reached / usage currently restricted");
51
+ if (!group.windows.length) lines.push("No allowance windows reported (not necessarily unlimited).");
52
+ for (const window of group.windows) lines.push(...formatWindow(window, now));
53
+ }
54
+ if (data.credits) {
55
+ const credits = data.credits;
56
+ const balance = credits.unlimited ? "Unlimited"
57
+ : credits.balance != null ? safeText(credits.balance)
58
+ : credits.hasCredits === false ? "None"
59
+ : credits.hasCredits === true ? "Available (balance not reported)" : "Not reported";
60
+ lines.push("", `Credits: ${balance}`);
61
+ }
62
+ lines.push("", ...data.notes.map(safeText));
63
+ }
64
+ lines.push(`Manage usage: ${safeText(result.provider.dashboardUrl)}`);
65
+ return lines.join("\n");
66
+ }).join("\n\n────────────────────────────────────────\n\n");
67
+ }
@@ -0,0 +1,96 @@
1
+ import type { AccountInfo, Options, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { UsageError, type UsageSnapshot } from "../types.ts";
6
+ import { normalizeClaudeUsage } from "./claude-normalize.ts";
7
+
8
+ const USAGE_METHOD = "usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET";
9
+ export const CLAUDE_CODE_LOGIN_HINT = "Sign in with Claude Code (claude auth login), then retry /usage claude.";
10
+ type UsageSession = Pick<Query, "accountInfo" | typeof USAGE_METHOD | "close">;
11
+ export type StartUsageSession = (params: { prompt: AsyncIterable<SDKUserMessage>; options: Options }) => UsageSession;
12
+
13
+ /** Inspect the subscription, not API billing or a gateway. Preserve Code's
14
+ * CLAUDE_CONFIG_DIR and explicit OAuth token; never substitute another account. */
15
+ export function claudeUsageEnvironment(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
16
+ const child = { ...env };
17
+ for (const key of Object.keys(child)) {
18
+ if (key.startsWith("ANTHROPIC_") || key.startsWith("CLAUDE_CODE_USE_")) delete child[key];
19
+ }
20
+ return { ...child, ENABLE_CLAUDEAI_MCP_SERVERS: "0", DISABLE_TELEMETRY: "1", DISABLE_ERROR_REPORTING: "1" };
21
+ }
22
+
23
+ /** Keep stdin open for SDK control requests without ever submitting a prompt. */
24
+ async function* noPrompts(signal: AbortSignal): AsyncGenerator<SDKUserMessage> {
25
+ if (!signal.aborted) await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
26
+ }
27
+
28
+ export async function fetchClaudeCodeUsage(signal: AbortSignal, options: {
29
+ start?: StartUsageSession;
30
+ env?: NodeJS.ProcessEnv;
31
+ now?: () => number;
32
+ } = {}): Promise<UsageSnapshot> {
33
+ signal.throwIfAborted();
34
+ const controller = new AbortController();
35
+ let session: UsageSession | undefined;
36
+ let cwd: string | undefined;
37
+ let closed = false;
38
+ const stop = () => {
39
+ controller.abort();
40
+ if (session && !closed) {
41
+ closed = true;
42
+ try { session.close(); } catch { /* No raw SDK errors in logs/UI. */ }
43
+ }
44
+ };
45
+ signal.addEventListener("abort", stop, { once: true });
46
+ try {
47
+ // Root entry inlines its runtime dependencies. /core needs extra MCP peers.
48
+ // Pin this SDK version because the structured usage method is experimental.
49
+ const start: StartUsageSession = options.start ?? (await import("@anthropic-ai/claude-agent-sdk")).query;
50
+ signal.throwIfAborted();
51
+ cwd = await mkdtemp(join(tmpdir(), "pi-usage-claude-"));
52
+ signal.throwIfAborted();
53
+ session = start({
54
+ prompt: noPrompts(controller.signal),
55
+ options: {
56
+ cwd,
57
+ env: claudeUsageEnvironment(options.env ?? process.env),
58
+ abortController: controller,
59
+ persistSession: false,
60
+ settingSources: [],
61
+ settings: { disableAllHooks: true, autoMemoryEnabled: false },
62
+ tools: [],
63
+ mcpServers: {},
64
+ strictMcpConfig: true,
65
+ permissionMode: "dontAsk",
66
+ stderr: () => {},
67
+ },
68
+ });
69
+ const account: AccountInfo = await session.accountInfo();
70
+ signal.throwIfAborted();
71
+ if (account.apiProvider && account.apiProvider !== "firstParty") {
72
+ throw new UsageError(`Claude Code is not using a Claude subscription. ${CLAUDE_CODE_LOGIN_HINT}`, "unavailable");
73
+ }
74
+ const report = await session[USAGE_METHOD]({ skipBehaviors: true });
75
+ signal.throwIfAborted();
76
+ if (!report.rate_limits_available) {
77
+ throw new UsageError(`Claude subscription usage needs a login with profile access, not an API key or inference-only setup token. ${CLAUDE_CODE_LOGIN_HINT}`, "unavailable");
78
+ }
79
+ if (!report.rate_limits) {
80
+ throw new UsageError("Claude Code could not retrieve subscription limits. Check Claude Code /usage and try again later.");
81
+ }
82
+ return normalizeClaudeUsage(report.rate_limits, {
83
+ account: account.email || "Claude Code subscription (identity not reported)",
84
+ plan: report.subscription_type ?? account.subscriptionType,
85
+ source: "Claude Code login (Agent SDK; also used by pi-claude-bridge)",
86
+ }, options.now?.() ?? Date.now());
87
+ } catch (error) {
88
+ if (signal.aborted) throw signal.reason;
89
+ if (error instanceof UsageError) throw error;
90
+ throw new UsageError(`Could not read usage through the Claude Agent SDK. Ensure its optional platform package is installed. ${CLAUDE_CODE_LOGIN_HINT}`);
91
+ } finally {
92
+ signal.removeEventListener("abort", stop);
93
+ stop();
94
+ if (cwd) await rm(cwd, { recursive: true, force: true }).catch(() => {});
95
+ }
96
+ }
@@ -0,0 +1,74 @@
1
+ import { z } from "zod";
2
+ import { UsageError, type UsageSnapshot, type UsageWindow } from "../types.ts";
3
+
4
+ const percent = z.number().finite().nonnegative().nullish();
5
+ const windowSchema = z.object({ utilization: percent, resets_at: z.string().nullish() });
6
+ const modelSchema = z.object({ display_name: z.string().min(1) });
7
+ const payloadSchema = z.object({
8
+ five_hour: windowSchema.nullish(),
9
+ seven_day: windowSchema.nullish(),
10
+ seven_day_opus: windowSchema.nullish(),
11
+ seven_day_sonnet: windowSchema.nullish(),
12
+ // Claude Agent SDK get_usage normalizes the model rows into this field.
13
+ model_scoped: z.array(windowSchema.extend({ display_name: z.string().min(1) })).nullish(),
14
+ // Native Pi OAuth uses the endpoint directly. Fable lives here, NOT in an
15
+ // assumed seven_day_fable field. is_active is a headline pick, not a filter.
16
+ limits: z.array(z.object({
17
+ kind: z.string(),
18
+ percent,
19
+ resets_at: z.string().nullish(),
20
+ scope: z.object({ model: modelSchema.nullish(), surface: modelSchema.nullish() }).nullish(),
21
+ })).nullish(),
22
+ }).refine((data) => Object.values(data).some((value) => value !== undefined));
23
+
24
+ export interface ClaudeIdentity { account: string; plan?: string; source: string }
25
+
26
+ function toWindow(label: string, value?: z.infer<typeof windowSchema> | null): UsageWindow {
27
+ // Invalid/absent reset times stay unknown. Never use OAuth expiry as a reset.
28
+ const validTime = z.iso.datetime({ offset: true }).safeParse(value?.resets_at);
29
+ const reset = validTime.success ? Date.parse(validTime.data) : NaN;
30
+ return {
31
+ label,
32
+ display: "used",
33
+ remainingPercent: value?.utilization == null ? undefined : Math.max(0, 100 - value.utilization),
34
+ resetsAt: Number.isFinite(reset) ? reset : undefined,
35
+ };
36
+ }
37
+
38
+ export function normalizeClaudeUsage(payload: unknown, identity: ClaudeIdentity, now = Date.now()): UsageSnapshot {
39
+ const parsed = payloadSchema.safeParse(payload);
40
+ if (!parsed.success) throw new UsageError("Claude returned an unrecognized usage response. Check Claude Code /usage or the usage dashboard.");
41
+ const data = parsed.data;
42
+ let session = data.five_hour;
43
+ let weekly = data.seven_day;
44
+ const models = new Map<string, { name: string; window: z.infer<typeof windowSchema> }>();
45
+ const addModel = (name: string, window: z.infer<typeof windowSchema>) => {
46
+ const key = name.trim().toLowerCase();
47
+ if (key && !models.has(key)) models.set(key, { name: name.trim(), window });
48
+ };
49
+ if (data.seven_day_opus) addModel("Opus", data.seven_day_opus);
50
+ if (data.seven_day_sonnet) addModel("Sonnet", data.seven_day_sonnet);
51
+ for (const model of data.model_scoped ?? []) addModel(model.display_name, model);
52
+ for (const limit of data.limits ?? []) {
53
+ const value = { utilization: limit.percent, resets_at: limit.resets_at };
54
+ if (limit.kind === "session" && !limit.scope?.model && !limit.scope?.surface) session ??= value;
55
+ if (limit.kind === "weekly_all" && !limit.scope?.model && !limit.scope?.surface) weekly ??= value;
56
+ if (limit.kind === "weekly_scoped" && limit.scope?.model && !limit.scope.surface) {
57
+ addModel(limit.scope.model.display_name, value);
58
+ }
59
+ }
60
+ const windows = [toWindow("Current session (5h)", session), toWindow("Current week (all models)", weekly)];
61
+ // Always expose the three requested buckets. A missing Fable row is unknown,
62
+ // not 0% used; extra plan-dependent model buckets follow it.
63
+ const fable = models.get("fable");
64
+ windows.push(toWindow("Current week (Fable)", fable?.window));
65
+ for (const [key, model] of models) {
66
+ if (key !== "fable") windows.push(toWindow(`Current week (${model.name})`, model.window));
67
+ }
68
+ return {
69
+ ...identity,
70
+ fetchedAt: now,
71
+ groups: [{ label: "Subscription allowance", windows, limitReached: windows.some((window) => window.remainingPercent === 0) }],
72
+ notes: ["Claude subscription usage, not this Pi session's token totals. App-specific caps are not included."],
73
+ };
74
+ }
@@ -0,0 +1,82 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { z } from "zod";
3
+ import { UsageError, type AuthRegistry, type UsageProvider, type UsageSnapshot } from "../types.ts";
4
+ import { fetchClaudeCodeUsage } from "./claude-code.ts";
5
+ import { normalizeClaudeUsage } from "./claude-normalize.ts";
6
+
7
+ const BASE_URL = "https://api.anthropic.com";
8
+ const PI_LOGIN_HINT = "Run /login anthropic and choose the Claude subscription login, then retry /usage claude.";
9
+
10
+ async function piToken(registry: AuthRegistry): Promise<string | undefined> {
11
+ if (!registry.getProvider("anthropic")) return undefined;
12
+ let auth;
13
+ try { auth = await registry.getProviderAuth("anthropic"); }
14
+ catch { throw new UsageError(`Could not refresh Pi's Anthropic login. ${PI_LOGIN_HINT}`); }
15
+ // API keys are not subscription credentials. Never resolve bridge's dummy key.
16
+ if (auth?.source !== "OAuth") return undefined;
17
+ if (!auth.auth.apiKey) throw new UsageError(`Pi's Anthropic OAuth login is incomplete. ${PI_LOGIN_HINT}`);
18
+ return auth.auth.apiKey;
19
+ }
20
+
21
+ async function fetchPiUsage(token: string, signal: AbortSignal, options: { fetch?: typeof globalThis.fetch; now?: () => number }): Promise<UsageSnapshot> {
22
+ const client = new Anthropic({
23
+ apiKey: null,
24
+ authToken: token,
25
+ baseURL: BASE_URL,
26
+ logLevel: "off",
27
+ maxRetries: 0,
28
+ timeout: 12_000,
29
+ fetch: options.fetch,
30
+ fetchOptions: { redirect: "error" },
31
+ defaultHeaders: { "anthropic-beta": "oauth-2025-04-20" },
32
+ });
33
+ let payload: unknown;
34
+ try {
35
+ // There is no generated subscription-quota method in the API SDK. Pi owns
36
+ // OAuth storage and refresh; the official SDK owns HTTP transport.
37
+ payload = await client.get<unknown>("/api/oauth/usage", { signal });
38
+ } catch (error) {
39
+ if (signal.aborted) throw signal.reason;
40
+ if (error instanceof Anthropic.APIError) {
41
+ if (error.status === 401) throw new UsageError(`Pi's Anthropic login was rejected. ${PI_LOGIN_HINT}`);
42
+ if (error.status === 403) throw new UsageError(`Claude usage needs subscription/profile permission. ${PI_LOGIN_HINT}`);
43
+ if (error.status === 429) throw new UsageError("Claude throttled the usage check. Wait before refreshing; this does not mean your subscription allowance is empty.");
44
+ if (error.status && error.status >= 500) throw new UsageError("Claude usage is temporarily unavailable. Try again later.");
45
+ }
46
+ if (error instanceof Anthropic.APIConnectionTimeoutError) throw new UsageError("Claude usage request timed out. Try again.");
47
+ throw new UsageError("Could not retrieve Claude usage. Check your connection or the usage dashboard.");
48
+ }
49
+ // Identity is supplementary: a profile failure must not hide known quotas.
50
+ // This uses the very same token, never account data from another login store.
51
+ const profile = await client.get<unknown>("/api/oauth/profile", { signal, timeout: 2_000 }).catch(() => undefined);
52
+ signal.throwIfAborted();
53
+ const parsed = z.object({ account: z.object({ email: z.string().min(1) }) }).safeParse(profile);
54
+ return normalizeClaudeUsage(payload, {
55
+ account: parsed.success ? parsed.data.account.email : "Pi Anthropic subscription (identity not reported)",
56
+ source: "Pi Anthropic OAuth login",
57
+ }, options.now?.() ?? Date.now());
58
+ }
59
+
60
+ export function createClaudeProvider(options: {
61
+ fetch?: typeof globalThis.fetch;
62
+ now?: () => number;
63
+ readCodeUsage?: (signal: AbortSignal) => Promise<UsageSnapshot>;
64
+ } = {}): UsageProvider {
65
+ return {
66
+ id: "claude",
67
+ aliases: ["anthropic"],
68
+ label: "Anthropic · Claude",
69
+ dashboardUrl: "https://claude.ai/settings/usage",
70
+ async fetch({ registry, signal, activeProvider }) {
71
+ signal.throwIfAborted();
72
+ const readCode = options.readCodeUsage ?? ((signal) => fetchClaudeCodeUsage(signal, { now: options.now }));
73
+ // If both accounts exist, follow the selected bridge rather than silently
74
+ // showing a potentially different native Anthropic account. A chosen
75
+ // source failing is an error, not permission to switch accounts.
76
+ if (activeProvider === "claude-bridge") return readCode(signal);
77
+ const token = await piToken(registry);
78
+ signal.throwIfAborted();
79
+ return token ? fetchPiUsage(token, signal, options) : readCode(signal);
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,8 @@
1
+ import type { UsageProvider } from "../types.ts";
2
+ import { createOpenAIProvider } from "./openai.ts";
3
+ import { createClaudeProvider } from "./claude.ts";
4
+
5
+ // Add an adapter here; commands, collection, and rendering remain provider-neutral.
6
+ export function createUsageProviders(): UsageProvider[] {
7
+ return [createOpenAIProvider(), createClaudeProvider()];
8
+ }
@@ -0,0 +1,181 @@
1
+ import { decodeJwt } from "jose";
2
+ import OpenAI from "openai";
3
+ import { z } from "zod";
4
+ import { UsageError, type AuthRegistry, type UsageGroup, type UsageProvider, type UsageSnapshot, type UsageWindow } from "../types.ts";
5
+
6
+ export const CODEX_PROVIDER = "openai-codex";
7
+ export const USAGE_BASE_URL = "https://chatgpt.com/backend-api";
8
+ export const LOGIN_HINT = "Run /login openai-codex and choose the same ChatGPT account as your OpenAI login. Your inference login is unchanged.";
9
+
10
+ const nonnegative = z.number().finite().nonnegative();
11
+ const timestamp = nonnegative.max(8_640_000_000_000);
12
+ const windowSchema = z.object({
13
+ used_percent: nonnegative.nullish(),
14
+ limit_window_seconds: nonnegative.nullish(),
15
+ reset_at: timestamp.nullish(),
16
+ reset_after_seconds: nonnegative.nullish(),
17
+ });
18
+ const limitSchema = z.object({
19
+ allowed: z.boolean().nullish(),
20
+ limit_reached: z.boolean().nullish(),
21
+ primary_window: windowSchema.nullish(),
22
+ secondary_window: windowSchema.nullish(),
23
+ });
24
+
25
+ // Codex's backend-client/OpenAPI models are the wire-contract reference. This is
26
+ // an internal endpoint, not the public OpenAI organization/billing usage API.
27
+ // Unknown fields are ignored; missing/null quotas stay unknown, never 0% used.
28
+ const payloadSchema = z.object({
29
+ plan_type: z.string().min(1),
30
+ account_id: z.string().nullish(),
31
+ email: z.string().nullish(),
32
+ rate_limit: limitSchema.nullish(),
33
+ code_review_rate_limit: limitSchema.nullish(),
34
+ additional_rate_limits: z.array(z.object({
35
+ limit_name: z.string(),
36
+ metered_feature: z.string(),
37
+ rate_limit: limitSchema.nullish(),
38
+ })).nullish(),
39
+ credits: z.object({
40
+ has_credits: z.boolean().nullish(),
41
+ unlimited: z.boolean().nullish(),
42
+ balance: z.string().nullish(),
43
+ overage_limit_reached: z.boolean().nullish(),
44
+ }).nullish(),
45
+ spend_control: z.object({ reached: z.boolean().nullish() }).nullish(),
46
+ });
47
+
48
+ export interface CodexAccount { accessToken: string; accountId: string; email?: string }
49
+
50
+ /** Decode only for account routing/display; OpenAI validates the token itself. */
51
+ export function accountFromToken(accessToken: string): CodexAccount | undefined {
52
+ try {
53
+ const claims = decodeJwt(accessToken);
54
+ const auth = z.object({ chatgpt_account_id: z.string().regex(/^[\w-]+$/) })
55
+ .safeParse(claims["https://api.openai.com/auth"]);
56
+ if (!auth.success) return undefined;
57
+ const profile = z.object({ email: z.string().optional() })
58
+ .safeParse(claims["https://api.openai.com/profile"]);
59
+ return {
60
+ accessToken,
61
+ accountId: auth.data.chatgpt_account_id,
62
+ email: profile.success ? profile.data.email : undefined,
63
+ };
64
+ } catch { return undefined; }
65
+ }
66
+
67
+ export async function getCodexAccount(registry: AuthRegistry): Promise<CodexAccount> {
68
+ if (!registry.getProvider(CODEX_PROVIDER)) {
69
+ throw new UsageError(`This Pi build has no Codex login provider. ${LOGIN_HINT}`, "unavailable");
70
+ }
71
+ let result;
72
+ try { result = await registry.getProviderAuth(CODEX_PROVIDER); }
73
+ catch { throw new UsageError(`Could not refresh the Codex login. ${LOGIN_HINT}`); }
74
+ // Do not use API keys, the new SIWC token, ~/.codex/auth.json, or browser cookies.
75
+ if (result?.source !== "OAuth" || !result.auth.apiKey) {
76
+ throw new UsageError(`A Codex ChatGPT login is needed to read account limits. ${LOGIN_HINT}`, "unavailable");
77
+ }
78
+ const account = accountFromToken(result.auth.apiKey);
79
+ if (!account) throw new UsageError(`The Codex login has no usable ChatGPT account. ${LOGIN_HINT}`);
80
+ return account;
81
+ }
82
+
83
+ export function windowLabel(seconds: number | null | undefined, fallback: string): string {
84
+ if (!seconds) return fallback;
85
+ if (seconds === 604800) return "Weekly limit";
86
+ if (seconds % 86400 === 0) return `${seconds / 86400} day limit`;
87
+ if (seconds % 3600 === 0) return `${seconds / 3600} hour limit`;
88
+ if (seconds % 60 === 0) return `${seconds / 60} minute limit`;
89
+ return `${seconds} second limit`;
90
+ }
91
+
92
+ function normalizeWindow(window: z.infer<typeof windowSchema>, fallback: string, now: number): UsageWindow {
93
+ const reset = window.reset_at != null ? window.reset_at * 1000
94
+ : window.reset_after_seconds != null ? now + window.reset_after_seconds * 1000 : undefined;
95
+ return {
96
+ label: windowLabel(window.limit_window_seconds, fallback),
97
+ remainingPercent: window.used_percent == null ? undefined : Math.max(0, 100 - window.used_percent),
98
+ resetsAt: reset !== undefined && Number.isFinite(reset) && reset <= 8_640_000_000_000_000 ? reset : undefined,
99
+ };
100
+ }
101
+
102
+ function normalizeGroup(label: string, limit: z.infer<typeof limitSchema>, now: number): UsageGroup {
103
+ const windows: UsageWindow[] = [];
104
+ if (limit.primary_window) windows.push(normalizeWindow(limit.primary_window, "Primary limit", now));
105
+ if (limit.secondary_window) windows.push(normalizeWindow(limit.secondary_window, "Secondary limit", now));
106
+ return { label, windows, limitReached: limit.limit_reached === true || limit.allowed === false };
107
+ }
108
+
109
+ export function normalizeUsage(payload: unknown, account: CodexAccount, now = Date.now()): UsageSnapshot {
110
+ const parsed = payloadSchema.safeParse(payload);
111
+ if (!parsed.success) throw new UsageError("OpenAI returned an unrecognized usage response. Check the usage dashboard; the Codex endpoint may have changed.");
112
+ const data = parsed.data;
113
+ if (data.account_id && data.account_id !== account.accountId) {
114
+ throw new UsageError("OpenAI returned usage for a different account. No usage is displayed; check your Codex login.");
115
+ }
116
+ const groups: UsageGroup[] = [];
117
+ if (data.rate_limit) groups.push(normalizeGroup("Account allowance", data.rate_limit, now));
118
+ for (const extra of data.additional_rate_limits ?? []) {
119
+ if (extra.rate_limit) groups.push(normalizeGroup(extra.limit_name || extra.metered_feature, extra.rate_limit, now));
120
+ }
121
+ if (data.code_review_rate_limit) groups.push(normalizeGroup("Code review", data.code_review_rate_limit, now));
122
+ const email = data.email || account.email;
123
+ const notes = ["Account allowance via Codex; Pi-specific app caps are not included."];
124
+ if (data.spend_control?.reached) notes.push("An account/workspace spending limit has been reached.");
125
+ if (data.credits?.overage_limit_reached) notes.push("The credit overage limit has been reached.");
126
+ return {
127
+ account: email ? `${email} (${account.accountId})` : account.accountId,
128
+ plan: data.plan_type,
129
+ source: "Codex login (openai-codex)",
130
+ fetchedAt: now,
131
+ groups,
132
+ credits: data.credits ? {
133
+ unlimited: data.credits.unlimited ?? undefined,
134
+ hasCredits: data.credits.has_credits ?? undefined,
135
+ balance: data.credits.balance ?? undefined,
136
+ } : undefined,
137
+ notes,
138
+ };
139
+ }
140
+
141
+ export function createOpenAIProvider(options: { fetch?: typeof globalThis.fetch; now?: () => number } = {}): UsageProvider {
142
+ return {
143
+ id: "openai",
144
+ label: "OpenAI · ChatGPT",
145
+ dashboardUrl: "https://chatgpt.com/settings/usage",
146
+ async fetch({ registry, signal }) {
147
+ const account = await getCodexAccount(registry);
148
+ signal.throwIfAborted();
149
+ const client = new OpenAI({
150
+ apiKey: account.accessToken,
151
+ baseURL: USAGE_BASE_URL,
152
+ // Never pick up OPENAI_ORG_ID/PROJECT_ID or log credentials/response bodies.
153
+ organization: null,
154
+ project: null,
155
+ logLevel: "off",
156
+ maxRetries: 0,
157
+ timeout: 12_000,
158
+ fetch: options.fetch,
159
+ fetchOptions: { redirect: "error" },
160
+ defaultHeaders: { "ChatGPT-Account-Id": account.accountId },
161
+ });
162
+ let payload: unknown;
163
+ try {
164
+ // The official SDK's generic request API handles transport and cancellation.
165
+ // There is no generated quota method in the JavaScript SDK.
166
+ payload = await client.get<unknown>("/wham/usage", { signal });
167
+ } catch (error) {
168
+ if (signal.aborted) throw signal.reason;
169
+ if (error instanceof OpenAI.APIError) {
170
+ if (error.status === 401) throw new UsageError(`The Codex login was rejected. ${LOGIN_HINT}`);
171
+ if (error.status === 403) throw new UsageError("OpenAI denied access to Codex usage. Check the account and usage dashboard.");
172
+ if (error.status === 429) throw new UsageError("OpenAI throttled the usage check. Try again later; this does not mean your plan allowance is empty.");
173
+ if (error.status && error.status >= 500) throw new UsageError("OpenAI usage is temporarily unavailable. Try again later.");
174
+ }
175
+ if (error instanceof OpenAI.APIConnectionTimeoutError) throw new UsageError("OpenAI usage request timed out. Try again.");
176
+ throw new UsageError("Could not retrieve OpenAI usage. Check your connection or the usage dashboard.");
177
+ }
178
+ return normalizeUsage(payload, account, options.now?.() ?? Date.now());
179
+ },
180
+ };
181
+ }
package/src/service.ts ADDED
@@ -0,0 +1,38 @@
1
+ import { UsageError, type AuthRegistry, type UsageProvider, type UsageResult } from "./types.ts";
2
+
3
+ /** Pi owns auth refresh, which has no command AbortSignal in 1.4.0. Stop waiting
4
+ * on dismissal/timeout without interfering with its locked token rotation. */
5
+ function abortable<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
6
+ return new Promise((resolve, reject) => {
7
+ const abort = () => reject(signal.reason);
8
+ signal.addEventListener("abort", abort, { once: true });
9
+ promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort));
10
+ if (signal.aborted) abort();
11
+ });
12
+ }
13
+
14
+ export async function collectUsage(
15
+ providers: readonly UsageProvider[],
16
+ registry: AuthRegistry,
17
+ signal: AbortSignal,
18
+ options: { timeoutMs?: number; activeProvider?: string } = {},
19
+ ): Promise<UsageResult[]> {
20
+ signal.throwIfAborted();
21
+ const results = await Promise.all(providers.map(async (adapter): Promise<UsageResult> => {
22
+ const provider = { id: adapter.id, label: adapter.label, dashboardUrl: adapter.dashboardUrl };
23
+ const timeout = AbortSignal.timeout(options.timeoutMs ?? 15_000);
24
+ const combined = AbortSignal.any([signal, timeout]);
25
+ try {
26
+ const snapshot = await abortable(Promise.resolve().then(() => adapter.fetch({ registry, signal: combined, activeProvider: options.activeProvider })), combined);
27
+ return { provider, status: "ok", snapshot };
28
+ } catch (error) {
29
+ if (signal.aborted) throw signal.reason;
30
+ if (timeout.aborted) return { provider, status: "error", message: "Usage check timed out. Try refreshing." };
31
+ if (error instanceof UsageError) return { provider, status: error.status, message: error.message };
32
+ // A provider must opt into a safe message. Do not render raw request errors.
33
+ return { provider, status: "error", message: "Could not retrieve usage for this provider. Try refreshing or check its dashboard." };
34
+ }
35
+ }));
36
+ signal.throwIfAborted();
37
+ return results;
38
+ }
package/src/types.ts ADDED
@@ -0,0 +1,51 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ export type AuthRegistry = Pick<ExtensionContext["modelRegistry"], "getProviderAuth" | "getProvider">;
4
+
5
+ /** Provider adapters own auth and wire formats; the view only knows these types. */
6
+ export interface UsageWindow {
7
+ label: string;
8
+ remainingPercent?: number;
9
+ /** Default is remaining (Codex); Claude displays used like Claude Code. */
10
+ display?: "remaining" | "used";
11
+ resetsAt?: number; // Unix milliseconds, not credential expiry.
12
+ }
13
+
14
+ export interface UsageGroup {
15
+ label: string;
16
+ windows: UsageWindow[];
17
+ limitReached?: boolean;
18
+ }
19
+
20
+ export interface UsageSnapshot {
21
+ account: string;
22
+ plan?: string;
23
+ source: string;
24
+ fetchedAt: number;
25
+ groups: UsageGroup[];
26
+ credits?: { unlimited?: boolean; hasCredits?: boolean; balance?: string };
27
+ notes: string[];
28
+ }
29
+
30
+ export interface UsageProvider {
31
+ id: string;
32
+ label: string;
33
+ aliases?: readonly string[];
34
+ dashboardUrl: string;
35
+ fetch(context: { registry: AuthRegistry; signal: AbortSignal; activeProvider?: string }): Promise<UsageSnapshot>;
36
+ }
37
+
38
+ export type UsageResult = {
39
+ provider: Pick<UsageProvider, "id" | "label" | "dashboardUrl">;
40
+ } & (
41
+ | { status: "ok"; snapshot: UsageSnapshot }
42
+ | { status: "unavailable" | "error"; message: string }
43
+ );
44
+
45
+ /** Only static, user-safe messages belong here. Never wrap raw SDK/auth errors. */
46
+ export class UsageError extends Error {
47
+ constructor(message: string, public readonly status: "unavailable" | "error" = "error") {
48
+ super(message);
49
+ this.name = "UsageError";
50
+ }
51
+ }
package/src/view.ts ADDED
@@ -0,0 +1,121 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, ScrollView, Text, truncateToWidth, type Component, type KeybindingsManager, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { formatUsage } from "./format.ts";
4
+ import type { UsageResult } from "./types.ts";
5
+
6
+ /** Built from Pi's layout, wrapping, scroll and input components, not another TUI. */
7
+ export class UsageView implements Component {
8
+ private readonly heading: Text;
9
+ private readonly body: Text;
10
+ private readonly scroll: ScrollView;
11
+ private request?: AbortController;
12
+ private disposed = false;
13
+ private results?: UsageResult[];
14
+ private loading = true;
15
+ private failed = false;
16
+
17
+ constructor(
18
+ private readonly tui: Pick<TUI, "requestRender"> & { terminal?: { rows: number } },
19
+ private readonly theme: Pick<Theme, "fg" | "bold">,
20
+ private readonly keys: Pick<KeybindingsManager, "matches">,
21
+ private readonly done: () => void,
22
+ private readonly load: (signal: AbortSignal) => Promise<UsageResult[]>,
23
+ ) {
24
+ const heading = new Text("", 0, 0);
25
+ const body = new Text("", 0, 0);
26
+ const scroll = new ScrollView(body, { scrollbar: "hidden", overscroll: "contain" });
27
+ this.heading = heading;
28
+ this.body = body;
29
+ this.scroll = scroll;
30
+ this.refresh();
31
+ }
32
+
33
+ refresh(): void {
34
+ if (this.disposed || this.request) return;
35
+ const request = new AbortController();
36
+ this.request = request;
37
+ this.loading = true;
38
+ this.failed = false;
39
+ this.results = undefined; // Never leave an old account snapshot looking current.
40
+ this.scroll.scrollToStart();
41
+ this.update();
42
+ void Promise.resolve().then(() => this.load(request.signal)).then(
43
+ (results) => { if (!this.disposed && !request.signal.aborted) this.results = results; },
44
+ () => { if (!this.disposed && !request.signal.aborted) this.failed = true; },
45
+ ).finally(() => {
46
+ if (this.request === request) this.request = undefined;
47
+ if (!this.disposed) {
48
+ this.loading = false;
49
+ this.update();
50
+ }
51
+ });
52
+ }
53
+
54
+ private update(): void {
55
+ this.invalidate();
56
+ this.tui.requestRender();
57
+ }
58
+
59
+ invalidate(): void {
60
+ // Rebuild themed strings on invalidation, including palette changes.
61
+ this.heading?.setText(this.theme.bold(this.theme.fg("accent", "Account usage")));
62
+ this.body?.setText(this.loading ? this.theme.fg("muted", "Loading account usage…")
63
+ : this.failed ? this.theme.fg("error", "Could not load usage. Press r to retry.")
64
+ : formatUsage(this.results ?? []));
65
+ this.heading?.invalidate();
66
+ this.body?.invalidate();
67
+ }
68
+
69
+ render(width: number): string[] {
70
+ // Pi 1.4 overlays call render() directly in BOTH screen modes, bypassing the
71
+ // layout engine. Supply the viewport to ScrollView and clip here so neither
72
+ // the footer nor later providers disappear below the overlay's maxHeight.
73
+ const height = Math.max(1, Math.floor((this.tui.terminal?.rows ?? 24) * 0.85));
74
+ const heading = height >= 4 ? this.heading.render(width).slice(0, 1) : [];
75
+ const footer = height >= 2 ? [truncateToWidth("r refresh · ↑/↓ scroll · PgUp/PgDn · Enter/Esc close", width)] : [];
76
+ const gap = height >= 8 ? [""] : [];
77
+ const viewport = Math.max(1, height - heading.length - footer.length - 2 * gap.length);
78
+ const content = this.scroll.render(width);
79
+ this.scroll.updateLayout(content.length, viewport, () => this.tui.requestRender());
80
+ const visible = content.slice(this.scroll.scrollTop, this.scroll.scrollTop + viewport);
81
+ if (footer.length) {
82
+ const position = content.length > viewport ? `${this.scroll.scrollTop + 1}–${Math.min(content.length, this.scroll.scrollTop + viewport)}/${content.length} · ` : "";
83
+ footer[0] = truncateToWidth(this.theme.fg("dim", position + "r refresh · ↑/↓ scroll · PgUp/PgDn · Enter/Esc close"), width);
84
+ }
85
+ return [...heading, ...gap, ...visible, ...gap, ...footer];
86
+ }
87
+
88
+ handleMouse(event: TuiMouseEvent): { handled: boolean } | undefined {
89
+ if (event.type !== "wheel") return;
90
+ this.scroll.scrollBy(event.wheelDelta ?? 0);
91
+ this.tui.requestRender();
92
+ return { handled: true };
93
+ }
94
+
95
+ handleInput(data: string): void {
96
+ if (this.disposed) return;
97
+ if (this.keys.matches(data, "tui.select.cancel") || this.keys.matches(data, "tui.select.confirm") || matchesKey(data, "q")) {
98
+ this.close();
99
+ return;
100
+ }
101
+ if (matchesKey(data, "r")) this.refresh();
102
+ else if (this.keys.matches(data, "tui.select.up")) this.scroll.scrollBy(-1);
103
+ else if (this.keys.matches(data, "tui.select.down")) this.scroll.scrollBy(1);
104
+ else if (matchesKey(data, Key.pageUp)) this.scroll.scrollBy(-Math.max(1, this.scroll.viewportHeight - 1));
105
+ else if (matchesKey(data, Key.pageDown)) this.scroll.scrollBy(Math.max(1, this.scroll.viewportHeight - 1));
106
+ else return;
107
+ this.tui.requestRender();
108
+ }
109
+
110
+ close(): void {
111
+ if (this.disposed) return;
112
+ this.dispose();
113
+ this.done();
114
+ }
115
+
116
+ dispose(): void {
117
+ if (this.disposed) return;
118
+ this.disposed = true;
119
+ this.request?.abort();
120
+ }
121
+ }