pi-usage-lite 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +160 -2
  3. package/package.json +52 -4
  4. package/src/index.ts +558 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 HossinAmin
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,161 @@
1
- # Temporary Holding Version
1
+ # pi-usage-lite
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
+ A minimal, dependency-free [Pi Coding Agent](https://pi.dev) extension that keeps selected provider usage and quota information visible in the footer.
4
+
5
+ The extension is intentionally small: one TypeScript file, no runtime package dependencies, automatic footer refreshes, and two commands.
6
+
7
+ > **Status:** early release. Some provider usage endpoints are undocumented and may change without notice.
8
+
9
+ ## Features
10
+
11
+ - Shows usage in Pi's footer for the active model provider.
12
+ - Refreshes when a session starts, when the model changes, after each agent run, and every 60 seconds.
13
+ - Keeps the last known values visible when a later refresh fails.
14
+ - Provides `/usage` to refresh the active provider.
15
+ - Provides `/usage-all` to show usage for every supported provider currently connected to Pi.
16
+
17
+ ## Supported providers
18
+
19
+ | Provider | Information shown |
20
+ | --- | --- |
21
+ | Kimi for Coding | Five-hour and weekly plan windows |
22
+ | OpenAI Codex | Available rate-limit windows and reset times |
23
+ | Claude Code Bridge | Five-hour, weekly, Opus, Sonnet, and extra-usage information when available |
24
+ | OpenCode Zen | Numeric credit balance and spending information |
25
+
26
+ Quota-window providers use the same footer style: window label, usage bar, used percentage, and reset countdown. OpenCode Zen remains numeric because it is credit-based rather than quota-based.
27
+
28
+ Provider data is shown only when Pi has compatible credentials configured.
29
+
30
+ ## Install
31
+
32
+ Install a GitHub release tag. Pinning a release prevents later changes on `main` or `dev` from affecting your installation unexpectedly:
33
+
34
+ ```bash
35
+ pi install git:github.com/HossinAmin/pi-usage-lite@v0.1.1
36
+ ```
37
+
38
+ Check [GitHub Releases](https://github.com/HossinAmin/pi-usage-lite/releases) for the newest tag, then restart Pi or run:
39
+
40
+ ```text
41
+ /reload
42
+ ```
43
+
44
+ To try a release without installing it permanently:
45
+
46
+ ```bash
47
+ pi -e git:github.com/HossinAmin/pi-usage-lite@v0.1.1
48
+ ```
49
+
50
+ For local development:
51
+
52
+ ```bash
53
+ git clone git@github.com:HossinAmin/pi-usage-lite.git
54
+ cd pi-usage-lite
55
+ pi -e .
56
+ ```
57
+
58
+ ## Commands
59
+
60
+ ### `/usage`
61
+
62
+ Refreshes the footer entry for the currently selected model's provider.
63
+
64
+ ### `/usage-all`
65
+
66
+ Checks every supported provider that appears connected and displays the available usage information in a notification.
67
+
68
+ ## OpenCode Zen dashboard fallback
69
+
70
+ The extension first tries OpenCode's official credits API using Pi's configured `opencode` provider credentials. Credit information is displayed numerically, without a quota bar.
71
+
72
+ If that API is unavailable, it can use an optional dashboard configuration. Create this file only if you need the fallback:
73
+
74
+ ```text
75
+ ~/.pi/agent/usage-zen.json
76
+ ```
77
+
78
+ Example shape:
79
+
80
+ ```json
81
+ {
82
+ "workspaceId": "your-workspace-id",
83
+ "authCookie": "your-open-code-auth-cookie"
84
+ }
85
+ ```
86
+
87
+ The `authCookie` is a sensitive credential. Keep the file private:
88
+
89
+ ```bash
90
+ chmod 600 ~/.pi/agent/usage-zen.json
91
+ ```
92
+
93
+ Do not commit this file. It is listed in this repository's `.gitignore` in case it is accidentally copied into a checkout.
94
+
95
+ You can also provide the fallback configuration through environment variables:
96
+
97
+ ```bash
98
+ export OPENCODE_ZEN_WORKSPACE_ID="your-workspace-id"
99
+ export OPENCODE_ZEN_AUTH_COOKIE="your-open-code-auth-cookie"
100
+ ```
101
+
102
+ ## Security and privacy
103
+
104
+ - This repository does not contain provider API keys, OAuth tokens, cookies, or workspace credentials.
105
+ - At runtime, the extension reads credentials from Pi's provider authentication, supported environment variables, Claude Code's OAuth configuration, or the optional local configuration files described above.
106
+ - Credentials are used only to request usage information from their corresponding provider endpoints.
107
+ - Like every Pi extension, this extension runs with your operating-system user permissions. Review the source before installing it.
108
+ - The OpenCode dashboard fallback relies on web-session cookies and undocumented page/server behavior. It is more fragile than the official credits API and may stop working after an OpenCode deployment.
109
+
110
+ ## Compatibility notes
111
+
112
+ - The extension registers `/usage`. Do not load it alongside another extension that registers the same command.
113
+ - Usage endpoints and response formats are provider-controlled and may change.
114
+ - The footer displays the last successful value if a refresh fails, so values can be stale until the next successful refresh.
115
+ - This project is not affiliated with Pi, OpenAI, Anthropic, Moonshot AI, Kimi, or OpenCode.
116
+
117
+ ## Release workflow
118
+
119
+ Development happens on the `dev` branch or feature branches. Releases are created from `main`.
120
+
121
+ 1. Make changes on `dev` or a feature branch.
122
+ 2. Bump `package.json` before opening a pull request into `main`:
123
+
124
+ ```bash
125
+ bun pm pkg set version=0.1.2
126
+ ```
127
+
128
+ Use the next appropriate semver version instead of `0.1.2`.
129
+
130
+ 3. Merge the pull request into `main`.
131
+ 4. The [`Release`](./.github/workflows/release.yml) workflow validates the version, creates the matching `vX.Y.Z` tag, and publishes a GitHub Release with generated notes.
132
+
133
+ The [`Version check`](./.github/workflows/version-check.yml) workflow blocks pull requests into `main` when `package.json` still has the base branch's version. This keeps every merge into `main` releasable.
134
+
135
+ Users should install release tags such as `@v0.1.1`, not the moving `main` or `dev` branches.
136
+
137
+ ## Development
138
+
139
+ The extension source is [`src/index.ts`](./src/index.ts).
140
+
141
+ Useful local commands:
142
+
143
+ ```bash
144
+ # Install development dependencies
145
+ bun install
146
+
147
+ # Typecheck the extension
148
+ bun run typecheck
149
+
150
+ # Try the package locally
151
+ pi -e .
152
+
153
+ # Inspect the extension source
154
+ ${EDITOR:-nano} src/index.ts
155
+ ```
156
+
157
+ There is no build step for users; Pi loads the TypeScript source through Jiti. Development dependencies are used only for local typechecking and release validation.
158
+
159
+ ## License
160
+
161
+ [MIT](./LICENSE)
package/package.json CHANGED
@@ -1,6 +1,54 @@
1
1
  {
2
2
  "name": "pi-usage-lite",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "A minimal Pi Coding Agent extension that keeps selected provider quotas visible in the footer.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "HossinAmin",
9
+ "email": "hossinamin70@gmail.com"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+ssh://git@github.com/HossinAmin/pi-usage-lite.git"
14
+ },
15
+ "homepage": "https://github.com/HossinAmin/pi-usage-lite#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/HossinAmin/pi-usage-lite/issues"
18
+ },
19
+ "keywords": [
20
+ "pi-package",
21
+ "pi-extension",
22
+ "pi",
23
+ "pi-coding-agent",
24
+ "usage",
25
+ "quota",
26
+ "statusline",
27
+ "codex",
28
+ "kimi",
29
+ "claude",
30
+ "opencode"
31
+ ],
32
+ "files": [
33
+ "src",
34
+ "README.md",
35
+ "LICENSE"
36
+ ],
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/index.ts"
40
+ ]
41
+ },
42
+ "peerDependencies": {
43
+ "@earendil-works/pi-coding-agent": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "1.0.4",
47
+ "@types/node": "^26.6.4",
48
+ "typescript": "^7.0.2"
49
+ },
50
+ "scripts": {
51
+ "typecheck": "tsc --noEmit"
52
+ },
53
+ "packageManager": "bun@1.3.14"
54
+ }
package/src/index.ts ADDED
@@ -0,0 +1,558 @@
1
+ /**
2
+ * Usage Extension
3
+ *
4
+ * Shows provider usage/quota in the footer. Currently supports Kimi for
5
+ * Coding (5-hour + weekly quotas), OpenAI Codex (its available rate-limit
6
+ * windows), Claude Code Bridge (Claude plan windows), and OpenCode Zen credits.
7
+ * The status only appears while the active
8
+ * model belongs to a supported provider. `/usage-all` shows quota information
9
+ * for every supported plan currently connected to pi.
10
+ *
11
+ * Updates after every prompt completion (agent_settled) and every 60s.
12
+ *
13
+ * To add more providers later, push another entry into USAGE_PROVIDERS.
14
+ */
15
+
16
+ import { Buffer } from "node:buffer";
17
+ import { readFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { join } from "node:path";
20
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+
22
+ const STATUS_ID = "usage";
23
+ const REFRESH_MS = 60_000;
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Helpers
27
+ // ---------------------------------------------------------------------------
28
+
29
+ function fmtDuration(ms: number): string {
30
+ if (!Number.isFinite(ms)) return "?";
31
+ if (ms <= 0) return "now";
32
+ const m = Math.floor(ms / 60000);
33
+ if (m < 60) return `${m}m`;
34
+ const h = Math.floor(m / 60);
35
+ if (h < 24) return `${h}h${m % 60}m`;
36
+ const d = Math.floor(h / 24);
37
+ return `${d}d${h % 24}h`;
38
+ }
39
+
40
+ function fmtCountdown(resetIso: string): string {
41
+ return fmtDuration(new Date(resetIso).getTime() - Date.now());
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Provider handlers
46
+ // ---------------------------------------------------------------------------
47
+
48
+ interface UsageProvider {
49
+ /** Provider id used to resolve authentication. */
50
+ providerId: string;
51
+ /** Short label shown in the footer and command output. */
52
+ label: string;
53
+ /** Return true if this handler serves the given provider id. */
54
+ matches(provider: string): boolean;
55
+ /** Optional custom connection check for providers with extra credentials. */
56
+ connected?(ctx: ExtensionContext): Promise<boolean>;
57
+ /** Fetch and format the usage text, or undefined on failure. */
58
+ fetch(ctx: ExtensionContext): Promise<string | undefined>;
59
+ }
60
+
61
+ async function isConnected(handler: UsageProvider, ctx: ExtensionContext): Promise<boolean> {
62
+ if (handler.connected) return handler.connected(ctx);
63
+ const result = await ctx.modelRegistry.getProviderAuth(handler.providerId);
64
+ if (result?.auth?.apiKey) return true;
65
+ return Object.keys(result?.auth?.headers ?? {}).some((key) => key.toLowerCase() === "authorization");
66
+ }
67
+
68
+ interface KimiQuotaDetail {
69
+ limit?: string;
70
+ used?: string;
71
+ remaining?: string;
72
+ resetTime?: string;
73
+ }
74
+
75
+ interface KimiUsagesResponse {
76
+ usage?: KimiQuotaDetail; // weekly quota
77
+ limits?: Array<{
78
+ window?: { duration?: number; timeUnit?: string };
79
+ detail?: KimiQuotaDetail; // session (5-hour) window
80
+ }>;
81
+ }
82
+
83
+ function parseKimiCount(value: string | undefined): number | undefined {
84
+ if (!value) return undefined;
85
+ const parsed = Number(value.replace(/,/g, ""));
86
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : undefined;
87
+ }
88
+
89
+ function formatKimiWindow(label: string, detail: KimiQuotaDetail | undefined): string | undefined {
90
+ const used = parseKimiCount(detail?.used);
91
+ const limit = parseKimiCount(detail?.limit);
92
+ if (used === undefined || limit === undefined || limit <= 0) return undefined;
93
+ const percent = Math.round((used / limit) * 100);
94
+ return `${label} ${progressBar(percent)} ${percent}% ↻${fmtCountdown(detail?.resetTime ?? "")}`;
95
+ }
96
+
97
+ const kimiCoding: UsageProvider = {
98
+ providerId: "kimi-coding",
99
+ label: "kimi",
100
+ matches: (provider) => /kimi/i.test(provider),
101
+ async fetch(ctx) {
102
+ const result = await ctx.modelRegistry.getProviderAuth("kimi-coding");
103
+ const apiKey = result?.auth?.apiKey;
104
+ const authHeaders = result?.auth?.headers ?? {};
105
+ const hasAuthorization = Object.keys(authHeaders).some((key) => key.toLowerCase() === "authorization");
106
+ if (!apiKey && !hasAuthorization) return undefined;
107
+
108
+ const base = (result?.auth?.baseUrl ?? "https://api.kimi.com/coding").replace(/\/+$/, "");
109
+ const res = await fetch(`${base}/v1/usages`, {
110
+ headers: {
111
+ "User-Agent": "KimiCLI/1.5",
112
+ ...authHeaders,
113
+ // OAuth providers (including Kimi) supply Authorization in authHeaders.
114
+ // API-key providers use the fallback below.
115
+ ...(hasAuthorization || !apiKey ? {} : { Authorization: `Bearer ${apiKey}` }),
116
+ },
117
+ });
118
+ if (!res.ok) return undefined;
119
+
120
+ const data = (await res.json()) as KimiUsagesResponse;
121
+ const session = data.limits?.[0]?.detail; // 5-hour session window
122
+ const weekly = data.usage; // weekly quota
123
+
124
+ const parts = [
125
+ formatKimiWindow("5h", session),
126
+ formatKimiWindow("wk", weekly),
127
+ ].filter((part): part is string => part !== undefined);
128
+ return parts.length > 0 ? parts.join(" · ") : undefined;
129
+ },
130
+ };
131
+
132
+ interface CodexUsageWindow {
133
+ used_percent?: number;
134
+ limit_window_seconds?: number;
135
+ reset_after_seconds?: number;
136
+ reset_at?: number;
137
+ }
138
+
139
+ interface CodexUsageResponse {
140
+ rate_limit?: {
141
+ primary_window?: CodexUsageWindow | null;
142
+ secondary_window?: CodexUsageWindow | null;
143
+ };
144
+ }
145
+
146
+ function codexWindowLabel(seconds?: number): string {
147
+ if (seconds == null) return "limit";
148
+ if (seconds >= 6 * 24 * 60 * 60) return "wk";
149
+ if (seconds >= 20 * 60 * 60 && seconds <= 28 * 60 * 60) return "day";
150
+ if (seconds >= 4 * 60 * 60 && seconds <= 6 * 60 * 60) return "5h";
151
+ const hours = Math.round(seconds / 3600);
152
+ return hours >= 24 && hours % 24 === 0 ? `${hours / 24}d` : `${hours}h`;
153
+ }
154
+
155
+ function progressBar(percent: number, width = 8): string {
156
+ const clamped = Math.max(0, Math.min(100, percent));
157
+ const filled = Math.round((clamped / 100) * width);
158
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
159
+ }
160
+
161
+ function formatCodexWindow(window: CodexUsageWindow | null | undefined): string | undefined {
162
+ if (typeof window?.used_percent !== "number") return undefined;
163
+ const reset = typeof window.reset_after_seconds === "number"
164
+ ? fmtDuration(window.reset_after_seconds * 1000)
165
+ : typeof window.reset_at === "number"
166
+ ? fmtDuration(window.reset_at * 1000 - Date.now())
167
+ : "?";
168
+ const percent = Math.round(window.used_percent);
169
+ return `${codexWindowLabel(window.limit_window_seconds)} ${progressBar(percent)} ${percent}% ↻${reset}`;
170
+ }
171
+
172
+ function codexAccountId(token: string): string | undefined {
173
+ try {
174
+ const payload = token.split(".")[1];
175
+ if (!payload) return undefined;
176
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
177
+ "https://api.openai.com/auth"?: { chatgpt_account_id?: string };
178
+ };
179
+ const accountId = claims["https://api.openai.com/auth"]?.chatgpt_account_id;
180
+ return typeof accountId === "string" && accountId ? accountId : undefined;
181
+ } catch {
182
+ return undefined;
183
+ }
184
+ }
185
+
186
+ const openaiCodex: UsageProvider = {
187
+ providerId: "openai-codex",
188
+ label: "codex",
189
+ matches: (provider) => provider === "openai-codex",
190
+ async fetch(ctx) {
191
+ const result = await ctx.modelRegistry.getProviderAuth("openai-codex");
192
+ const token = result?.auth?.apiKey;
193
+ const accountId = token ? codexAccountId(token) : undefined;
194
+ if (!token || !accountId) return undefined;
195
+
196
+ const base = (result.auth.baseUrl ?? "https://chatgpt.com/backend-api").replace(/\/+$/, "");
197
+ const res = await fetch(`${base}/wham/usage`, {
198
+ headers: {
199
+ ...result.auth.headers,
200
+ Authorization: `Bearer ${token}`,
201
+ "chatgpt-account-id": accountId,
202
+ originator: "pi",
203
+ "User-Agent": "pi",
204
+ },
205
+ });
206
+ if (!res.ok) return undefined;
207
+
208
+ const data = (await res.json()) as CodexUsageResponse;
209
+ const parts = [
210
+ formatCodexWindow(data.rate_limit?.primary_window),
211
+ formatCodexWindow(data.rate_limit?.secondary_window),
212
+ ].filter((part): part is string => part !== undefined);
213
+ return parts.length > 0 ? parts.join(" · ") : undefined;
214
+ },
215
+ };
216
+
217
+ interface ClaudeCodeCredentials {
218
+ claudeAiOauth?: { accessToken?: string };
219
+ }
220
+
221
+ interface ClaudeUsageWindow {
222
+ utilization?: number;
223
+ resets_at?: string | null;
224
+ }
225
+
226
+ interface ClaudeUsageResponse {
227
+ five_hour?: ClaudeUsageWindow | null;
228
+ seven_day?: ClaudeUsageWindow | null;
229
+ seven_day_opus?: ClaudeUsageWindow | null;
230
+ seven_day_sonnet?: ClaudeUsageWindow | null;
231
+ extra_usage?: {
232
+ is_enabled?: boolean;
233
+ used_credits?: number | null;
234
+ monthly_limit?: number | null;
235
+ currency?: string | null;
236
+ };
237
+ }
238
+
239
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
240
+ const CLAUDE_CACHE_MS = 5 * 60_000;
241
+ let claudeUsageCache: { fetchedAt: number; data: ClaudeUsageResponse } | undefined;
242
+
243
+ function claudeCredentialsPath(): string {
244
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
245
+ return join(configDir, ".credentials.json");
246
+ }
247
+
248
+ async function loadClaudeAccessToken(): Promise<string | undefined> {
249
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return process.env.CLAUDE_CODE_OAUTH_TOKEN;
250
+ try {
251
+ const credentials = JSON.parse(await readFile(claudeCredentialsPath(), "utf8")) as ClaudeCodeCredentials;
252
+ return credentials.claudeAiOauth?.accessToken;
253
+ } catch {
254
+ return undefined;
255
+ }
256
+ }
257
+
258
+ function formatClaudeWindow(label: string, window: ClaudeUsageWindow | null | undefined): string | undefined {
259
+ if (typeof window?.utilization !== "number") return undefined;
260
+ const percent = Math.round(window.utilization);
261
+ const reset = window.resets_at ? fmtCountdown(window.resets_at) : "?";
262
+ return `${label} ${progressBar(percent)} ${percent}% ↻${reset}`;
263
+ }
264
+
265
+ function formatClaudeUsage(data: ClaudeUsageResponse): string | undefined {
266
+ const parts = [
267
+ formatClaudeWindow("5h", data.five_hour),
268
+ formatClaudeWindow("wk", data.seven_day),
269
+ formatClaudeWindow("opus", data.seven_day_opus),
270
+ formatClaudeWindow("sonnet", data.seven_day_sonnet),
271
+ ].filter((part): part is string => part !== undefined);
272
+
273
+ if (data.extra_usage?.is_enabled) {
274
+ const used = data.extra_usage.used_credits;
275
+ const limit = data.extra_usage.monthly_limit;
276
+ const currency = data.extra_usage.currency ?? "USD";
277
+ parts.push(
278
+ typeof used === "number" && typeof limit === "number"
279
+ ? `extra ${used.toFixed(2)}/${limit.toFixed(2)} ${currency}`
280
+ : "extra enabled",
281
+ );
282
+ }
283
+ return parts.length > 0 ? parts.join(" · ") : undefined;
284
+ }
285
+
286
+ const claudeCodeBridge: UsageProvider = {
287
+ // Claude Code owns authentication; claude-bridge does not expose it through
288
+ // pi's model registry.
289
+ providerId: "claude-bridge",
290
+ label: "claude",
291
+ matches: (provider) => provider === "claude-bridge",
292
+ async connected() {
293
+ return Boolean(await loadClaudeAccessToken());
294
+ },
295
+ async fetch() {
296
+ if (claudeUsageCache && Date.now() - claudeUsageCache.fetchedAt < CLAUDE_CACHE_MS) {
297
+ return formatClaudeUsage(claudeUsageCache.data);
298
+ }
299
+
300
+ const token = await loadClaudeAccessToken();
301
+ if (!token) return undefined;
302
+ const res = await fetch(CLAUDE_USAGE_URL, {
303
+ headers: {
304
+ Accept: "application/json",
305
+ Authorization: `Bearer ${token}`,
306
+ "anthropic-beta": "oauth-2025-04-20",
307
+ "User-Agent": "claude-code/usage-extension",
308
+ },
309
+ });
310
+ if (!res.ok) return claudeUsageCache ? formatClaudeUsage(claudeUsageCache.data) : undefined;
311
+
312
+ const data = (await res.json()) as ClaudeUsageResponse;
313
+ claudeUsageCache = { fetchedAt: Date.now(), data };
314
+ return formatClaudeUsage(data);
315
+ },
316
+ };
317
+
318
+ interface ZenDashboardConfig {
319
+ workspaceId: string;
320
+ authCookie: string;
321
+ serverId?: string;
322
+ serverInstance?: string;
323
+ }
324
+
325
+ interface ZenCreditsResponse {
326
+ data?: {
327
+ total_credits?: number;
328
+ used_credits?: number;
329
+ remaining_credits?: number;
330
+ };
331
+ }
332
+
333
+ const ZEN_CONFIG_PATH = join(getAgentDir(), "usage-zen.json");
334
+ const ZEN_SERVER_ID = "c83b78a614689c38ebee981f9b39a8b377716db85c1fd7dbab604adc02d3313d";
335
+ const ZEN_SERVER_INSTANCE = "server-fn:2";
336
+ const ZEN_USER_AGENT =
337
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/151.0.0.0 Safari/537.36";
338
+ let zenCreditsApiSupported: boolean | undefined;
339
+
340
+ async function loadZenConfig(): Promise<ZenDashboardConfig | undefined> {
341
+ const envWorkspace = process.env.OPENCODE_ZEN_WORKSPACE_ID;
342
+ const envCookie = process.env.OPENCODE_ZEN_AUTH_COOKIE;
343
+ if (envWorkspace && envCookie) return { workspaceId: envWorkspace, authCookie: envCookie };
344
+ try {
345
+ const config = JSON.parse(await readFile(ZEN_CONFIG_PATH, "utf8")) as Partial<ZenDashboardConfig>;
346
+ if (typeof config.workspaceId !== "string" || typeof config.authCookie !== "string") return undefined;
347
+ return config as ZenDashboardConfig;
348
+ } catch {
349
+ return undefined;
350
+ }
351
+ }
352
+
353
+ function zenCookieHeader(cookie: string): string {
354
+ return cookie.trim().startsWith("auth=") ? cookie.trim() : `auth=${cookie.trim()}`;
355
+ }
356
+
357
+ function microCentsToDollars(value: number): number {
358
+ return value / 100_000_000;
359
+ }
360
+
361
+ function parseZenDashboardResponse(body: string): string | undefined {
362
+ const balance = body.match(/\bbalance:\s*(-?\d+)/)?.[1];
363
+ if (balance == null) return undefined;
364
+ const parts = [`balance $${microCentsToDollars(Number(balance)).toFixed(2)}`];
365
+ const monthlyUsage = body.match(/\bmonthlyUsage:\s*(-?\d+)/)?.[1];
366
+ if (monthlyUsage != null) parts.push(`mo $${microCentsToDollars(Number(monthlyUsage)).toFixed(2)}`);
367
+ const reload = body.match(/\breload:\s*(true|false|null)/)?.[1];
368
+ const reloadAmount = body.match(/\breloadAmount:\s*(\d+(?:\.\d+)?)/)?.[1];
369
+ const reloadTrigger = body.match(/\breloadTrigger:\s*(\d+(?:\.\d+)?)/)?.[1];
370
+ if (reload === "true" && reloadAmount && reloadTrigger) {
371
+ parts.push(`auto $${reloadAmount} @ $${reloadTrigger}`);
372
+ }
373
+ return parts.join(" · ");
374
+ }
375
+
376
+ async function fetchZenDashboard(config: ZenDashboardConfig): Promise<string | undefined> {
377
+ const serverId = config.serverId ?? ZEN_SERVER_ID;
378
+ const serverInstance = config.serverInstance ?? ZEN_SERVER_INSTANCE;
379
+ const args = JSON.stringify({
380
+ t: { t: 9, i: 0, l: 1, a: [{ t: 1, s: config.workspaceId }], o: 0 },
381
+ f: 31,
382
+ m: [],
383
+ });
384
+ const headers = {
385
+ Accept: "*/*",
386
+ Cookie: zenCookieHeader(config.authCookie),
387
+ Referer: `https://opencode.ai/workspace/${config.workspaceId}`,
388
+ "User-Agent": ZEN_USER_AGENT,
389
+ "x-server-id": serverId,
390
+ "x-server-instance": serverInstance,
391
+ };
392
+
393
+ const endpoint = new URL("https://opencode.ai/_server");
394
+ endpoint.searchParams.set("id", serverId);
395
+ endpoint.searchParams.set("args", args);
396
+ const direct = await fetch(endpoint, { headers });
397
+ if (direct.ok) {
398
+ const usage = parseZenDashboardResponse(await direct.text());
399
+ if (usage) return usage;
400
+ }
401
+
402
+ // The server-function hash can change after a Zen deployment. The workspace
403
+ // page is slower but exposes the same billing object in its SSR hydration.
404
+ const page = await fetch(`https://opencode.ai/workspace/${encodeURIComponent(config.workspaceId)}`, {
405
+ headers: {
406
+ Accept: "text/html",
407
+ Cookie: zenCookieHeader(config.authCookie),
408
+ "User-Agent": ZEN_USER_AGENT,
409
+ },
410
+ });
411
+ return page.ok ? parseZenDashboardResponse(await page.text()) : undefined;
412
+ }
413
+
414
+ async function fetchZenCreditsApi(ctx: ExtensionContext): Promise<string | undefined> {
415
+ if (zenCreditsApiSupported === false) return undefined;
416
+ const result = await ctx.modelRegistry.getProviderAuth("opencode");
417
+ const apiKey = result?.auth?.apiKey;
418
+ if (!apiKey) return undefined;
419
+ const res = await fetch("https://api.opencode.ai/v1/credits", {
420
+ headers: { Accept: "application/json", Authorization: `Bearer ${apiKey}` },
421
+ });
422
+ if (!res.ok) return undefined;
423
+ if (!(res.headers.get("content-type") ?? "").toLowerCase().includes("json")) {
424
+ zenCreditsApiSupported = false;
425
+ return undefined;
426
+ }
427
+ zenCreditsApiSupported = true;
428
+ const data = ((await res.json()) as ZenCreditsResponse).data;
429
+ if (typeof data?.total_credits !== "number" || typeof data.used_credits !== "number") return undefined;
430
+ const total = data.total_credits;
431
+ const used = data.used_credits;
432
+ const remaining = typeof data.remaining_credits === "number" ? data.remaining_credits : total - used;
433
+ const percent = total > 0 ? Math.round((used / total) * 100) : 0;
434
+ return `${remaining.toFixed(2)}/${total.toFixed(2)} credits · ${percent}% used`;
435
+ }
436
+
437
+ const openCodeZen: UsageProvider = {
438
+ providerId: "opencode",
439
+ label: "zen",
440
+ matches: (provider) => provider === "opencode",
441
+ async connected(ctx) {
442
+ if (await loadZenConfig()) return true;
443
+ const result = await ctx.modelRegistry.getProviderAuth("opencode");
444
+ return Boolean(result?.auth?.apiKey);
445
+ },
446
+ async fetch(ctx) {
447
+ const official = await fetchZenCreditsApi(ctx);
448
+ if (official) return official;
449
+ const config = await loadZenConfig();
450
+ return config ? fetchZenDashboard(config) : undefined;
451
+ },
452
+ };
453
+
454
+ // Add more provider handlers here later.
455
+ const USAGE_PROVIDERS: UsageProvider[] = [kimiCoding, openaiCodex, claudeCodeBridge, openCodeZen];
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Extension
459
+ // ---------------------------------------------------------------------------
460
+
461
+ export default function (pi: ExtensionAPI) {
462
+ let timer: ReturnType<typeof setInterval> | undefined;
463
+ let visible = false;
464
+ const succeededProviders = new Set<string>();
465
+
466
+ function handlerFor(ctx: ExtensionContext): UsageProvider | undefined {
467
+ const provider = ctx.model?.provider;
468
+ if (!provider) return undefined;
469
+ return USAGE_PROVIDERS.find((p) => p.matches(provider));
470
+ }
471
+
472
+ async function refresh(ctx: ExtensionContext): Promise<void> {
473
+ if (!ctx.hasUI) return;
474
+
475
+ const handler = handlerFor(ctx);
476
+ if (!handler) {
477
+ if (visible) {
478
+ ctx.ui.setStatus(STATUS_ID, undefined);
479
+ visible = false;
480
+ }
481
+ return;
482
+ }
483
+
484
+ try {
485
+ const text = await handler.fetch(ctx);
486
+ const model = ctx.model?.id ?? "";
487
+ const provider = ctx.model?.provider ?? "";
488
+ if (text) {
489
+ ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("dim", `${handler.label} ${model} · ${text}`));
490
+ succeededProviders.add(provider);
491
+ } else if (!succeededProviders.has(provider)) {
492
+ // Never fetched successfully for this provider: show that the extension is alive.
493
+ ctx.ui.setStatus(STATUS_ID, ctx.ui.theme.fg("dim", `${handler.label} ${model} · usage n/a`));
494
+ }
495
+ // On later failures: keep the last known values on screen.
496
+ visible = true;
497
+ } catch {
498
+ // Network/auth errors: keep the last known values on screen.
499
+ }
500
+ }
501
+
502
+ pi.on("session_start", async (_event, ctx) => {
503
+ await refresh(ctx);
504
+ // Keep values and renewal countdowns fresh.
505
+ timer = setInterval(() => {
506
+ void refresh(ctx);
507
+ }, REFRESH_MS);
508
+ });
509
+
510
+ pi.on("session_shutdown", async () => {
511
+ if (timer) {
512
+ clearInterval(timer);
513
+ timer = undefined;
514
+ }
515
+ });
516
+
517
+ // Refresh after every prompt completes.
518
+ pi.on("agent_settled", async (_event, ctx) => {
519
+ await refresh(ctx);
520
+ });
521
+
522
+ // Show/hide when the active model changes.
523
+ pi.on("model_select", async (_event, ctx) => {
524
+ await refresh(ctx);
525
+ });
526
+
527
+ // Manual refresh: /usage
528
+ pi.registerCommand("usage", {
529
+ description: "Refresh the usage display in the footer",
530
+ handler: async (_args, ctx) => {
531
+ await refresh(ctx);
532
+ ctx.ui.notify("Usage refreshed", "info");
533
+ },
534
+ });
535
+
536
+ pi.registerCommand("usage-all", {
537
+ description: "Show credits for all connected plans",
538
+ handler: async (_args, ctx) => {
539
+ const results = await Promise.all(
540
+ USAGE_PROVIDERS.map(async (handler) => {
541
+ try {
542
+ if (!(await isConnected(handler, ctx))) return undefined;
543
+ const usage = await handler.fetch(ctx);
544
+ return `${handler.label}: ${usage ?? "usage unavailable"}`;
545
+ } catch {
546
+ return `${handler.label}: usage unavailable`;
547
+ }
548
+ }),
549
+ );
550
+ const connected = results.filter((result): result is string => result !== undefined);
551
+ if (connected.length === 0) {
552
+ ctx.ui.notify("No supported usage plans are connected", "warning");
553
+ return;
554
+ }
555
+ ctx.ui.notify(connected.join("\n"), "info");
556
+ },
557
+ });
558
+ }