@narumitw/pi-codex-usage 0.1.12

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
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 ADDED
@@ -0,0 +1,121 @@
1
+ # 📊 pi-codex-usage — Codex Usage Status for Pi
2
+
3
+ [![npm](https://img.shields.io/npm/v/@narumitw/pi-codex-usage)](https://www.npmjs.com/package/@narumitw/pi-codex-usage) [![Pi extension](https://img.shields.io/badge/Pi-extension-blue)](https://pi.dev) [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](./LICENSE)
4
+
5
+ `@narumitw/pi-codex-usage` is a native [Pi coding agent](https://pi.dev) extension that adds `/codex-status`, a command for showing ChatGPT Codex subscription usage from inside Pi.
6
+
7
+ Use it when you want a quick Codex-style usage summary without leaving Pi or requiring Codex CLI to be installed.
8
+
9
+ ## ✨ Features
10
+
11
+ - Adds a `/codex-status` command to Pi.
12
+ - Shows Codex plan, 5-hour and weekly usage windows, reset times, and credits.
13
+ - Displays additional usage buckets when the Codex backend returns them.
14
+ - Automatically shows a compact statusline item while the current Pi model uses `openai-codex`.
15
+ - Uses Pi's own OpenAI Codex subscription auth first.
16
+ - Falls back to `codex app-server --listen stdio://` only when Pi auth is unavailable.
17
+ - Caches results briefly to avoid repeatedly calling the backend.
18
+ - Supports `--refresh` to bypass the in-memory cache.
19
+ - Works as an independently installable npm Pi extension package.
20
+
21
+ ## 📦 Install
22
+
23
+ ```bash
24
+ pi install npm:@narumitw/pi-codex-usage
25
+ ```
26
+
27
+ Try without installing permanently:
28
+
29
+ ```bash
30
+ pi -e npm:@narumitw/pi-codex-usage
31
+ ```
32
+
33
+ Try this package locally from the repository root:
34
+
35
+ ```bash
36
+ pi -e ./extensions/pi-codex-usage
37
+ ```
38
+
39
+ ## 🚀 Usage
40
+
41
+ ```text
42
+ /codex-status
43
+ /codex-status --refresh
44
+ /codex-status --no-statusline
45
+ /codex-status --clear-statusline
46
+ /codex-status --timeout 30
47
+ ```
48
+
49
+ Example output:
50
+
51
+ ```text
52
+ >_ OpenAI Codex Usage
53
+
54
+ Visit https://chatgpt.com/codex/settings/usage for up-to-date
55
+ information on rate limits and credits
56
+
57
+ 5h limit: [█████████████░░░░░░░] 64% left (resets 13:57)
58
+ Weekly limit: [████████████░░░░░░░░] 62% left (resets 14:37)
59
+ GPT-5.3-Codex-Spark limit:
60
+ 5h limit: [████████████████████] 100% left (resets 19:16)
61
+ Weekly limit: [████████████████████] 100% left (resets 00:10 on 21 May)
62
+ ```
63
+
64
+ ## 📊 Statusline behavior
65
+
66
+ When the selected Pi model provider is `openai-codex`, `pi-codex-usage` refreshes a compact statusline item automatically:
67
+
68
+ ```text
69
+ codex 59% 5h 61% wk
70
+ ```
71
+
72
+ The statusline value uses the cached usage snapshot and refreshes every five minutes while the current model remains `openai-codex`. Switching away from an OpenAI Codex model clears the item.
73
+
74
+ Use `/codex-status --no-statusline` for a one-off notification without updating the statusline, or `/codex-status --clear-statusline` to clear the item manually.
75
+
76
+ ## 🔐 Auth behavior
77
+
78
+ `pi-codex-usage` tries usage sources in this order:
79
+
80
+ 1. Pi's `openai-codex` provider auth through the Pi extension API.
81
+ 2. Codex CLI app-server fallback when Pi auth cannot provide usable subscription auth.
82
+
83
+ This means Codex CLI is optional. Users who already use a Pi OpenAI Codex model or have logged in to Pi with ChatGPT Plus/Pro subscription auth can use the direct Pi-auth path.
84
+
85
+ The extension does not read Pi or Codex auth files directly, and it does not expose bearer tokens in error messages.
86
+
87
+ ## 🚧 Limitations
88
+
89
+ - OpenAI API keys are not ChatGPT Codex subscription auth and do not expose this quota.
90
+ - Usage data is a snapshot. Statusline and command results are cached for five minutes unless `--refresh` is used.
91
+ - The fallback path requires Codex CLI to be installed and logged in.
92
+
93
+ ## 🗂️ Package layout
94
+
95
+ ```txt
96
+ extensions/pi-codex-usage/
97
+ ├── src/
98
+ │ └── codex-usage.ts
99
+ ├── README.md
100
+ ├── LICENSE
101
+ ├── tsconfig.json
102
+ └── package.json
103
+ ```
104
+
105
+ The package exposes its Pi extension through `package.json`:
106
+
107
+ ```json
108
+ {
109
+ "pi": {
110
+ "extensions": ["./src/codex-usage.ts"]
111
+ }
112
+ }
113
+ ```
114
+
115
+ ## 🔎 Keywords
116
+
117
+ Pi extension, Pi coding agent, Codex usage, Codex status, ChatGPT subscription usage, rate limits, TypeScript Pi package, npm Pi extension.
118
+
119
+ ## 📄 License
120
+
121
+ MIT. See [`LICENSE`](./LICENSE).
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@narumitw/pi-codex-usage",
3
+ "version": "0.1.12",
4
+ "description": "Pi extension that shows Codex ChatGPT subscription usage without requiring Codex CLI.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi",
12
+ "codex",
13
+ "usage",
14
+ "status"
15
+ ],
16
+ "files": [
17
+ "src",
18
+ "README.md",
19
+ "LICENSE"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/codex-usage.ts"
24
+ ]
25
+ },
26
+ "scripts": {
27
+ "check": "biome check . && npm run typecheck",
28
+ "format": "biome check --write .",
29
+ "typecheck": "tsc --noEmit"
30
+ },
31
+ "devDependencies": {
32
+ "@biomejs/biome": "2.4.14",
33
+ "@earendil-works/pi-coding-agent": "0.74.0",
34
+ "@types/node": "25.6.0",
35
+ "typescript": "6.0.3"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/narumiruna/pi-extensions",
40
+ "directory": "extensions/pi-codex-usage"
41
+ }
42
+ }
@@ -0,0 +1,993 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+ import type {
4
+ ExtensionAPI,
5
+ ExtensionCommandContext,
6
+ ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+
9
+ const COMMAND_NAME = "codex-status";
10
+ const CODEX_PROVIDER_ID = "openai-codex";
11
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
12
+ const DEFAULT_TIMEOUT_MS = 15_000;
13
+ const CACHE_TTL_MS = 5 * 60 * 1000;
14
+ const STATUS_KEY = "codex-usage";
15
+ const USAGE_SETTINGS_URL = "https://chatgpt.com/codex/settings/usage";
16
+ const BAR_SEGMENTS = 20;
17
+ const LIMIT_VALUE_COLUMN = 29;
18
+ const MAX_ERROR_BODY_CHARS = 600;
19
+ const RESET_FOREGROUND = "\x1b[39m";
20
+
21
+ type UsageSource = "pi-auth" | "codex-app-server";
22
+ type PiModel = NonNullable<ExtensionContext["model"]>;
23
+
24
+ type QueryUsageOptions = {
25
+ clearStatusline: boolean;
26
+ refresh: boolean;
27
+ statusline: boolean;
28
+ timeoutMs: number;
29
+ };
30
+
31
+ type CachedReport = {
32
+ createdAt: number;
33
+ report: CodexUsageReport;
34
+ };
35
+
36
+ type QueryUsageResult =
37
+ | { ok: true; report: CodexUsageReport }
38
+ | { ok: false; errors: UsageQueryError[] };
39
+
40
+ type UsageQueryError = {
41
+ source: UsageSource;
42
+ message: string;
43
+ cause?: unknown;
44
+ };
45
+
46
+ export type CodexUsageReport = {
47
+ source: UsageSource;
48
+ capturedAt: number;
49
+ planType?: string;
50
+ snapshots: NormalizedRateLimitSnapshot[];
51
+ };
52
+
53
+ export type NormalizedRateLimitSnapshot = {
54
+ limitId: string;
55
+ limitName?: string;
56
+ primary?: NormalizedRateLimitWindow;
57
+ secondary?: NormalizedRateLimitWindow;
58
+ credits?: NormalizedCredits;
59
+ };
60
+
61
+ export type NormalizedRateLimitWindow = {
62
+ usedPercent: number;
63
+ windowMinutes?: number;
64
+ resetsAt?: number;
65
+ };
66
+
67
+ export type NormalizedCredits = {
68
+ hasCredits: boolean;
69
+ unlimited: boolean;
70
+ balance?: string;
71
+ };
72
+
73
+ type RateLimitStatusPayload = {
74
+ plan_type?: unknown;
75
+ rate_limit?: unknown;
76
+ additional_rate_limits?: unknown;
77
+ credits?: unknown;
78
+ };
79
+
80
+ type BackendRateLimitDetails = {
81
+ primary_window?: unknown;
82
+ secondary_window?: unknown;
83
+ };
84
+
85
+ type BackendWindowSnapshot = {
86
+ used_percent?: unknown;
87
+ limit_window_seconds?: unknown;
88
+ reset_at?: unknown;
89
+ };
90
+
91
+ type BackendAdditionalRateLimit = {
92
+ limit_name?: unknown;
93
+ metered_feature?: unknown;
94
+ rate_limit?: unknown;
95
+ };
96
+
97
+ type BackendCreditsSnapshot = {
98
+ has_credits?: unknown;
99
+ unlimited?: unknown;
100
+ balance?: unknown;
101
+ };
102
+
103
+ type AppServerRateLimitResponse = {
104
+ rateLimits?: unknown;
105
+ rateLimitsByLimitId?: unknown;
106
+ };
107
+
108
+ type AppServerRateLimitSnapshot = {
109
+ limitId?: unknown;
110
+ limitName?: unknown;
111
+ primary?: unknown;
112
+ secondary?: unknown;
113
+ credits?: unknown;
114
+ planType?: unknown;
115
+ };
116
+
117
+ type AppServerWindowSnapshot = {
118
+ usedPercent?: unknown;
119
+ windowDurationMins?: unknown;
120
+ resetsAt?: unknown;
121
+ };
122
+
123
+ type AppServerCreditsSnapshot = {
124
+ hasCredits?: unknown;
125
+ unlimited?: unknown;
126
+ balance?: unknown;
127
+ };
128
+
129
+ type RpcResponse = {
130
+ id?: unknown;
131
+ result?: unknown;
132
+ error?: { message?: unknown; code?: unknown };
133
+ };
134
+
135
+ type PendingRpc = {
136
+ resolve: (value: unknown) => void;
137
+ reject: (error: Error) => void;
138
+ };
139
+
140
+ export default function codexUsage(pi: ExtensionAPI) {
141
+ let cache: CachedReport | undefined;
142
+ let statuslineClearTimer: ReturnType<typeof setTimeout> | undefined;
143
+ let statuslineRefreshTimer: ReturnType<typeof setTimeout> | undefined;
144
+ let statuslineRequestId = 0;
145
+
146
+ const clearStatuslineTimers = () => {
147
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
148
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
149
+ statuslineClearTimer = undefined;
150
+ statuslineRefreshTimer = undefined;
151
+ };
152
+
153
+ const clearUsageStatusline = (ctx: ExtensionContext) => {
154
+ statuslineRequestId += 1;
155
+ clearStatuslineTimers();
156
+ ctx.ui.setStatus(STATUS_KEY, undefined);
157
+ };
158
+
159
+ const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
160
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
161
+ statuslineClearTimer = setTimeout(() => {
162
+ ctx.ui.setStatus(STATUS_KEY, undefined);
163
+ statuslineClearTimer = undefined;
164
+ }, CACHE_TTL_MS);
165
+ statuslineClearTimer.unref?.();
166
+ };
167
+
168
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
169
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
170
+ statuslineRefreshTimer = setTimeout(() => {
171
+ void refreshCurrentCodexUsageStatusline(ctx, true);
172
+ }, CACHE_TTL_MS);
173
+ statuslineRefreshTimer.unref?.();
174
+ };
175
+
176
+ const setUsageStatusline = (
177
+ ctx: ExtensionContext,
178
+ report: CodexUsageReport,
179
+ options: { autoRefresh: boolean },
180
+ ) => {
181
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
182
+ statuslineClearTimer = undefined;
183
+ ctx.ui.setStatus(STATUS_KEY, formatCodexUsageStatusline(report));
184
+ if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
185
+ else scheduleTemporaryStatuslineClear(ctx);
186
+ };
187
+
188
+ const refreshCurrentCodexUsageStatusline = async (ctx: ExtensionContext, force: boolean) => {
189
+ if (!isOpenAICodexModel(ctx.model)) {
190
+ clearUsageStatusline(ctx);
191
+ return;
192
+ }
193
+
194
+ const requestId = statuslineRequestId + 1;
195
+ statuslineRequestId = requestId;
196
+ const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
197
+ if (cached && !force) {
198
+ setUsageStatusline(ctx, cached.report, { autoRefresh: true });
199
+ return;
200
+ }
201
+
202
+ ctx.ui.setStatus(STATUS_KEY, "codex usage: checking");
203
+ const result = await queryUsage(ctx, { timeoutMs: DEFAULT_TIMEOUT_MS });
204
+ if (requestId !== statuslineRequestId) return;
205
+ if (!isOpenAICodexModel(ctx.model)) {
206
+ clearUsageStatusline(ctx);
207
+ return;
208
+ }
209
+
210
+ if (!result.ok) {
211
+ ctx.ui.setStatus(STATUS_KEY, "codex usage error");
212
+ scheduleStatuslineRefresh(ctx);
213
+ return;
214
+ }
215
+
216
+ cache = { createdAt: Date.now(), report: result.report };
217
+ setUsageStatusline(ctx, result.report, { autoRefresh: true });
218
+ };
219
+
220
+ pi.registerCommand(COMMAND_NAME, {
221
+ description: "Show Codex ChatGPT subscription usage and rate-limit windows",
222
+ handler: async (args, ctx) => {
223
+ const options = parseArgs(args);
224
+ if (!options.ok) {
225
+ ctx.ui.notify(options.error, "warning");
226
+ return;
227
+ }
228
+
229
+ if (options.value.clearStatusline) {
230
+ clearUsageStatusline(ctx);
231
+ ctx.ui.notify("Codex usage statusline cleared.", "info");
232
+ return;
233
+ }
234
+
235
+ const cached = cache && Date.now() - cache.createdAt < CACHE_TTL_MS ? cache : undefined;
236
+ if (cached && !options.value.refresh) {
237
+ if (options.value.statusline) {
238
+ setUsageStatusline(ctx, cached.report, { autoRefresh: isOpenAICodexModel(ctx.model) });
239
+ }
240
+ showReport(ctx, cached.report, true);
241
+ return;
242
+ }
243
+
244
+ let keepStatusline = false;
245
+ if (options.value.statusline) ctx.ui.setStatus(STATUS_KEY, "codex usage: checking");
246
+ try {
247
+ const result = await queryUsage(ctx, options.value);
248
+ if (!result.ok) {
249
+ ctx.ui.notify(formatQueryErrors(result.errors), "error");
250
+ return;
251
+ }
252
+
253
+ cache = { createdAt: Date.now(), report: result.report };
254
+ if (options.value.statusline) {
255
+ setUsageStatusline(ctx, result.report, { autoRefresh: isOpenAICodexModel(ctx.model) });
256
+ keepStatusline = true;
257
+ }
258
+ showReport(ctx, result.report, false);
259
+ } finally {
260
+ if (options.value.statusline && !keepStatusline) ctx.ui.setStatus(STATUS_KEY, undefined);
261
+ }
262
+ },
263
+ });
264
+
265
+ pi.on("session_start", (_event, ctx) => {
266
+ if (isOpenAICodexModel(ctx.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
267
+ else clearUsageStatusline(ctx);
268
+ });
269
+
270
+ pi.on("session_tree", (_event, ctx) => {
271
+ if (isOpenAICodexModel(ctx.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
272
+ else clearUsageStatusline(ctx);
273
+ });
274
+
275
+ pi.on("model_select", (event, ctx) => {
276
+ if (isOpenAICodexModel(event.model)) void refreshCurrentCodexUsageStatusline(ctx, false);
277
+ else clearUsageStatusline(ctx);
278
+ });
279
+
280
+ pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
281
+ }
282
+
283
+ function parseArgs(
284
+ args: string,
285
+ ): { ok: true; value: QueryUsageOptions } | { ok: false; error: string } {
286
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
287
+ let clearStatusline = false;
288
+ let refresh = false;
289
+ let statusline = true;
290
+ let timeoutMs = DEFAULT_TIMEOUT_MS;
291
+
292
+ for (let index = 0; index < tokens.length; index++) {
293
+ const token = tokens[index];
294
+ if (token === "--clear-statusline") {
295
+ clearStatusline = true;
296
+ continue;
297
+ }
298
+ if (token === "--no-statusline") {
299
+ statusline = false;
300
+ continue;
301
+ }
302
+ if (token === "--refresh") {
303
+ refresh = true;
304
+ continue;
305
+ }
306
+ if (token === "--timeout") {
307
+ const rawValue = tokens[index + 1];
308
+ if (!rawValue)
309
+ return { ok: false, error: "Usage: /codex-status [--refresh] [--timeout seconds]" };
310
+ const parsed = Number(rawValue);
311
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 120) {
312
+ return { ok: false, error: "--timeout must be a number of seconds between 1 and 120." };
313
+ }
314
+ timeoutMs = Math.round(parsed * 1000);
315
+ index += 1;
316
+ continue;
317
+ }
318
+ return {
319
+ ok: false,
320
+ error: `Unknown option: ${token}. Usage: /codex-status [--refresh] [--no-statusline] [--clear-statusline] [--timeout seconds]`,
321
+ };
322
+ }
323
+
324
+ return { ok: true, value: { clearStatusline, refresh, statusline, timeoutMs } };
325
+ }
326
+
327
+ function isOpenAICodexModel(model: ExtensionContext["model"]): boolean {
328
+ return model?.provider === CODEX_PROVIDER_ID;
329
+ }
330
+
331
+ async function queryUsage(
332
+ ctx: ExtensionContext,
333
+ options: Pick<QueryUsageOptions, "timeoutMs">,
334
+ ): Promise<QueryUsageResult> {
335
+ const errors: UsageQueryError[] = [];
336
+
337
+ try {
338
+ const report = await queryViaPiAuth(ctx, options.timeoutMs);
339
+ return { ok: true, report };
340
+ } catch (cause) {
341
+ errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
342
+ }
343
+
344
+ try {
345
+ const report = await queryViaCodexAppServer(options.timeoutMs);
346
+ return { ok: true, report };
347
+ } catch (cause) {
348
+ errors.push({ source: "codex-app-server", message: errorMessage(cause), cause });
349
+ }
350
+
351
+ return { ok: false, errors };
352
+ }
353
+
354
+ async function queryViaPiAuth(
355
+ ctx: ExtensionContext,
356
+ timeoutMs: number,
357
+ ): Promise<CodexUsageReport> {
358
+ const auth = await resolvePiCodexAuth(ctx);
359
+ if (!auth) {
360
+ throw new Error(
361
+ "No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
362
+ );
363
+ }
364
+
365
+ const response = await fetchWithTimeout(CODEX_USAGE_URL, { headers: auth.headers }, timeoutMs);
366
+ const text = await response.text();
367
+ if (!response.ok) {
368
+ throw new Error(
369
+ `Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
370
+ );
371
+ }
372
+
373
+ const payload = parseJsonObject(text, "Codex usage endpoint response");
374
+ return normalizeBackendPayload(payload as RateLimitStatusPayload, Date.now(), "pi-auth");
375
+ }
376
+
377
+ async function resolvePiCodexAuth(
378
+ ctx: ExtensionContext,
379
+ ): Promise<{ headers: Record<string, string> } | undefined> {
380
+ const models = codexAuthCandidateModels(ctx);
381
+ const errors: string[] = [];
382
+
383
+ for (const model of models) {
384
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
385
+ if (!auth.ok) {
386
+ errors.push(auth.error);
387
+ continue;
388
+ }
389
+
390
+ const headers = { ...(auth.headers ?? {}) };
391
+ if (!hasHeader(headers, "Authorization") && auth.apiKey) {
392
+ headers.Authorization = `Bearer ${auth.apiKey}`;
393
+ }
394
+ if (!hasHeader(headers, "User-Agent")) {
395
+ headers["User-Agent"] = "pi-codex-usage";
396
+ }
397
+ if (hasHeader(headers, "Authorization")) {
398
+ return { headers };
399
+ }
400
+ }
401
+
402
+ if (errors.length > 0) {
403
+ throw new Error(errors.join("; "));
404
+ }
405
+ return undefined;
406
+ }
407
+
408
+ function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
409
+ const candidates: PiModel[] = [];
410
+ const seen = new Set<string>();
411
+ const add = (model: PiModel | undefined) => {
412
+ if (!model || model.provider !== CODEX_PROVIDER_ID) return;
413
+ const key = `${model.provider}/${model.id}`;
414
+ if (seen.has(key)) return;
415
+ seen.add(key);
416
+ candidates.push(model);
417
+ };
418
+
419
+ add(ctx.model);
420
+ for (const model of ctx.modelRegistry.getAvailable()) add(model);
421
+ for (const model of ctx.modelRegistry.getAll()) add(model);
422
+ return candidates;
423
+ }
424
+
425
+ async function fetchWithTimeout(
426
+ url: string,
427
+ init: RequestInit,
428
+ timeoutMs: number,
429
+ ): Promise<Response> {
430
+ const controller = new AbortController();
431
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
432
+ try {
433
+ return await fetch(url, { ...init, signal: controller.signal });
434
+ } catch (error) {
435
+ if (controller.signal.aborted) {
436
+ throw new Error(
437
+ `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
438
+ );
439
+ }
440
+ throw error;
441
+ } finally {
442
+ clearTimeout(timeout);
443
+ }
444
+ }
445
+
446
+ async function queryViaCodexAppServer(timeoutMs: number): Promise<CodexUsageReport> {
447
+ const client = new CodexAppServerClient(timeoutMs);
448
+ try {
449
+ await client.start();
450
+ await client.request("initialize", {
451
+ clientInfo: {
452
+ name: "pi_codex_usage",
453
+ title: "Pi Codex Usage",
454
+ version: "0.1.0",
455
+ },
456
+ capabilities: {
457
+ experimentalApi: false,
458
+ requestAttestation: false,
459
+ optOutNotificationMethods: [],
460
+ },
461
+ });
462
+ client.notify("initialized");
463
+ const result = await client.request("account/rateLimits/read", undefined);
464
+ return normalizeAppServerResponse(
465
+ assertObject(result, "account/rateLimits/read result") as AppServerRateLimitResponse,
466
+ Date.now(),
467
+ );
468
+ } finally {
469
+ client.dispose();
470
+ }
471
+ }
472
+
473
+ class CodexAppServerClient {
474
+ private child?: ChildProcessWithoutNullStreams;
475
+ private nextId = 1;
476
+ private stderr = "";
477
+ private readonly pending = new Map<number, PendingRpc>();
478
+ private startPromise?: Promise<void>;
479
+ private exitError?: Error;
480
+ private readonly timeoutMs: number;
481
+
482
+ constructor(timeoutMs: number) {
483
+ this.timeoutMs = timeoutMs;
484
+ }
485
+
486
+ start(): Promise<void> {
487
+ if (this.startPromise) return this.startPromise;
488
+
489
+ this.startPromise = new Promise((resolve, reject) => {
490
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
491
+ stdio: ["pipe", "pipe", "pipe"],
492
+ });
493
+ this.child = child;
494
+
495
+ const startupTimeout = setTimeout(() => {
496
+ reject(
497
+ new Error(
498
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
499
+ ),
500
+ );
501
+ }, this.timeoutMs);
502
+
503
+ child.once("spawn", () => {
504
+ clearTimeout(startupTimeout);
505
+ resolve();
506
+ });
507
+
508
+ child.once("error", (error) => {
509
+ clearTimeout(startupTimeout);
510
+ reject(new Error(`Failed to start codex app-server: ${error.message}`));
511
+ this.rejectAll(error);
512
+ });
513
+
514
+ child.once("exit", (code, signal) => {
515
+ const suffix = this.stderr ? ` stderr: ${redactErrorBody(this.stderr)}` : "";
516
+ this.exitError = new Error(
517
+ `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
518
+ );
519
+ this.rejectAll(this.exitError);
520
+ });
521
+
522
+ child.stderr.setEncoding("utf8");
523
+ child.stderr.on("data", (chunk: string) => {
524
+ this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
525
+ });
526
+
527
+ const lines = createInterface({ input: child.stdout });
528
+ lines.on("line", (line) => this.handleLine(line));
529
+ });
530
+
531
+ return this.startPromise;
532
+ }
533
+
534
+ request(method: string, params: unknown): Promise<unknown> {
535
+ const child = this.child;
536
+ if (!child?.stdin.writable) {
537
+ throw new Error("codex app-server is not running.");
538
+ }
539
+ if (this.exitError) throw this.exitError;
540
+
541
+ const id = this.nextId++;
542
+ const payload = params === undefined ? { method, id } : { method, id, params };
543
+ const response = new Promise<unknown>((resolve, reject) => {
544
+ const timeout = setTimeout(() => {
545
+ this.pending.delete(id);
546
+ reject(
547
+ new Error(`Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`),
548
+ );
549
+ }, this.timeoutMs);
550
+
551
+ this.pending.set(id, {
552
+ resolve: (value) => {
553
+ clearTimeout(timeout);
554
+ resolve(value);
555
+ },
556
+ reject: (error) => {
557
+ clearTimeout(timeout);
558
+ reject(error);
559
+ },
560
+ });
561
+ });
562
+
563
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
564
+ return response;
565
+ }
566
+
567
+ notify(method: string): void {
568
+ const child = this.child;
569
+ if (!child?.stdin.writable) return;
570
+ child.stdin.write(`${JSON.stringify({ method })}\n`);
571
+ }
572
+
573
+ dispose(): void {
574
+ for (const [id, pending] of this.pending) {
575
+ pending.reject(new Error(`codex app-server request ${id} cancelled.`));
576
+ }
577
+ this.pending.clear();
578
+
579
+ const child = this.child;
580
+ if (!child) return;
581
+ child.stdin.end();
582
+ if (!child.killed) child.kill();
583
+ this.child = undefined;
584
+ }
585
+
586
+ private handleLine(line: string): void {
587
+ let parsed: RpcResponse;
588
+ try {
589
+ parsed = JSON.parse(line) as RpcResponse;
590
+ } catch {
591
+ return;
592
+ }
593
+
594
+ if (typeof parsed.id !== "number") return;
595
+ const pending = this.pending.get(parsed.id);
596
+ if (!pending) return;
597
+ this.pending.delete(parsed.id);
598
+
599
+ if (parsed.error) {
600
+ const message =
601
+ typeof parsed.error.message === "string" ? parsed.error.message : "unknown error";
602
+ pending.reject(new Error(`codex app-server request failed: ${message}`));
603
+ return;
604
+ }
605
+
606
+ pending.resolve(parsed.result);
607
+ }
608
+
609
+ private rejectAll(error: Error): void {
610
+ for (const pending of this.pending.values()) pending.reject(error);
611
+ this.pending.clear();
612
+ }
613
+ }
614
+
615
+ export function normalizeBackendPayload(
616
+ payload: RateLimitStatusPayload,
617
+ capturedAt: number,
618
+ source: UsageSource,
619
+ ): CodexUsageReport {
620
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
621
+ const planType = asString(payload.plan_type);
622
+ const primary = normalizeBackendSnapshot("codex", undefined, payload.rate_limit, payload.credits);
623
+ if (primary) snapshots.push(primary);
624
+
625
+ const additional = Array.isArray(payload.additional_rate_limits)
626
+ ? payload.additional_rate_limits
627
+ : [];
628
+ for (const item of additional) {
629
+ const additionalLimit = assertObject(
630
+ item,
631
+ "additional rate limit",
632
+ ) as BackendAdditionalRateLimit;
633
+ const limitId =
634
+ asString(additionalLimit.metered_feature) ?? asString(additionalLimit.limit_name);
635
+ if (!limitId) continue;
636
+ const snapshot = normalizeBackendSnapshot(
637
+ limitId,
638
+ asString(additionalLimit.limit_name),
639
+ additionalLimit.rate_limit,
640
+ undefined,
641
+ );
642
+ if (snapshot) snapshots.push(snapshot);
643
+ }
644
+
645
+ if (snapshots.length === 0) {
646
+ throw new Error("Codex usage endpoint returned no displayable rate-limit windows.");
647
+ }
648
+
649
+ return { source, capturedAt, planType, snapshots };
650
+ }
651
+
652
+ function normalizeBackendSnapshot(
653
+ limitId: string,
654
+ limitName: string | undefined,
655
+ rateLimit: unknown,
656
+ credits: unknown,
657
+ ): NormalizedRateLimitSnapshot | undefined {
658
+ if (rateLimit === null || rateLimit === undefined) {
659
+ const normalizedCredits = normalizeBackendCredits(credits);
660
+ return normalizedCredits ? { limitId, limitName, credits: normalizedCredits } : undefined;
661
+ }
662
+
663
+ const details = assertObject(rateLimit, "rate limit") as BackendRateLimitDetails;
664
+ const primary = normalizeBackendWindow(details.primary_window);
665
+ const secondary = normalizeBackendWindow(details.secondary_window);
666
+ const normalizedCredits = normalizeBackendCredits(credits);
667
+
668
+ if (!primary && !secondary && !normalizedCredits) return undefined;
669
+ return { limitId, limitName, primary, secondary, credits: normalizedCredits };
670
+ }
671
+
672
+ function normalizeBackendWindow(value: unknown): NormalizedRateLimitWindow | undefined {
673
+ if (value === null || value === undefined) return undefined;
674
+ const window = assertObject(value, "rate-limit window") as BackendWindowSnapshot;
675
+ const usedPercent = asNumber(window.used_percent);
676
+ if (usedPercent === undefined) return undefined;
677
+ const limitSeconds = asNumber(window.limit_window_seconds);
678
+ const resetsAt = asNumber(window.reset_at);
679
+ return {
680
+ usedPercent,
681
+ windowMinutes: limitSeconds && limitSeconds > 0 ? Math.ceil(limitSeconds / 60) : undefined,
682
+ resetsAt,
683
+ };
684
+ }
685
+
686
+ function normalizeBackendCredits(value: unknown): NormalizedCredits | undefined {
687
+ if (value === null || value === undefined) return undefined;
688
+ const credits = assertObject(value, "credits") as BackendCreditsSnapshot;
689
+ const hasCredits = asBoolean(credits.has_credits);
690
+ const unlimited = asBoolean(credits.unlimited);
691
+ if (hasCredits === undefined || unlimited === undefined) return undefined;
692
+ return { hasCredits, unlimited, balance: asString(credits.balance) };
693
+ }
694
+
695
+ export function normalizeAppServerResponse(
696
+ response: AppServerRateLimitResponse,
697
+ capturedAt: number,
698
+ ): CodexUsageReport {
699
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
700
+ const addSnapshot = (raw: unknown, fallbackId: string) => {
701
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
702
+ if (!snapshot) return;
703
+ const existingIndex = snapshots.findIndex((item) => item.limitId === snapshot.limitId);
704
+ if (existingIndex >= 0)
705
+ snapshots[existingIndex] = mergeSnapshot(snapshots[existingIndex], snapshot);
706
+ else snapshots.push(snapshot);
707
+ };
708
+
709
+ addSnapshot(response.rateLimits, "codex");
710
+ if (response.rateLimitsByLimitId && typeof response.rateLimitsByLimitId === "object") {
711
+ for (const [limitId, raw] of Object.entries(response.rateLimitsByLimitId)) {
712
+ addSnapshot(raw, limitId);
713
+ }
714
+ }
715
+
716
+ if (snapshots.length === 0) {
717
+ throw new Error("codex app-server returned no displayable rate-limit windows.");
718
+ }
719
+
720
+ const planType = asAppServerPlanType(response.rateLimits);
721
+ return { source: "codex-app-server", capturedAt, planType, snapshots };
722
+ }
723
+
724
+ function asAppServerPlanType(raw: unknown): string | undefined {
725
+ if (raw === null || raw === undefined) return undefined;
726
+ const snapshot = assertObject(
727
+ raw,
728
+ "app-server rate-limit snapshot",
729
+ ) as AppServerRateLimitSnapshot;
730
+ return asString(snapshot.planType);
731
+ }
732
+
733
+ function normalizeAppServerSnapshot(
734
+ raw: unknown,
735
+ fallbackId: string,
736
+ ): NormalizedRateLimitSnapshot | undefined {
737
+ if (raw === null || raw === undefined) return undefined;
738
+ const snapshot = assertObject(
739
+ raw,
740
+ "app-server rate-limit snapshot",
741
+ ) as AppServerRateLimitSnapshot;
742
+ const limitId = asString(snapshot.limitId) ?? fallbackId;
743
+ const limitName = asString(snapshot.limitName);
744
+ const primary = normalizeAppServerWindow(snapshot.primary);
745
+ const secondary = normalizeAppServerWindow(snapshot.secondary);
746
+ const credits = normalizeAppServerCredits(snapshot.credits);
747
+ if (!primary && !secondary && !credits) return undefined;
748
+ return { limitId, limitName, primary, secondary, credits };
749
+ }
750
+
751
+ function normalizeAppServerWindow(value: unknown): NormalizedRateLimitWindow | undefined {
752
+ if (value === null || value === undefined) return undefined;
753
+ const window = assertObject(value, "app-server rate-limit window") as AppServerWindowSnapshot;
754
+ const usedPercent = asNumber(window.usedPercent);
755
+ if (usedPercent === undefined) return undefined;
756
+ return {
757
+ usedPercent,
758
+ windowMinutes: asNumber(window.windowDurationMins),
759
+ resetsAt: asNumber(window.resetsAt),
760
+ };
761
+ }
762
+
763
+ function normalizeAppServerCredits(value: unknown): NormalizedCredits | undefined {
764
+ if (value === null || value === undefined) return undefined;
765
+ const credits = assertObject(value, "app-server credits") as AppServerCreditsSnapshot;
766
+ const hasCredits = asBoolean(credits.hasCredits);
767
+ const unlimited = asBoolean(credits.unlimited);
768
+ if (hasCredits === undefined || unlimited === undefined) return undefined;
769
+ return { hasCredits, unlimited, balance: asString(credits.balance) };
770
+ }
771
+
772
+ function mergeSnapshot(
773
+ left: NormalizedRateLimitSnapshot,
774
+ right: NormalizedRateLimitSnapshot,
775
+ ): NormalizedRateLimitSnapshot {
776
+ return {
777
+ limitId: right.limitId || left.limitId,
778
+ limitName: right.limitName ?? left.limitName,
779
+ primary: right.primary ?? left.primary,
780
+ secondary: right.secondary ?? left.secondary,
781
+ credits: right.credits ?? left.credits,
782
+ };
783
+ }
784
+
785
+ export function formatCodexUsageReport(report: CodexUsageReport, _cacheAgeMs?: number): string {
786
+ const lines = [
787
+ " >_ OpenAI Codex Usage",
788
+ "",
789
+ `Visit ${USAGE_SETTINGS_URL} for up-to-date`,
790
+ "information on rate limits and credits",
791
+ "",
792
+ ];
793
+
794
+ for (const snapshot of report.snapshots) {
795
+ const label = snapshot.limitName ?? snapshot.limitId;
796
+ if (!isPrimaryCodexSnapshot(snapshot)) {
797
+ lines.push(` ${label} limit:`);
798
+ }
799
+ if (snapshot.primary) lines.push(formatWindowLine("5h limit:", snapshot.primary));
800
+ if (snapshot.secondary) lines.push(formatWindowLine("Weekly limit:", snapshot.secondary));
801
+ if (!snapshot.primary && !snapshot.secondary) {
802
+ lines.push(" Limits unavailable for this account");
803
+ }
804
+ }
805
+
806
+ return lines.join("\n");
807
+ }
808
+
809
+ export function formatCodexUsageStatusline(report: CodexUsageReport): string {
810
+ const snapshot =
811
+ report.snapshots.find((item) => item.limitId.toLowerCase() === "codex") ??
812
+ report.snapshots[0];
813
+ if (!snapshot) return "codex usage unavailable";
814
+
815
+ const parts = ["codex"];
816
+ if (snapshot.primary) parts.push(`${formatRemainingPercent(snapshot.primary)} 5h`);
817
+ if (snapshot.secondary) parts.push(`${formatRemainingPercent(snapshot.secondary)} wk`);
818
+ if (parts.length === 1 && snapshot.credits) parts.push(formatCredits(snapshot.credits));
819
+ return parts.join(" ");
820
+ }
821
+
822
+ function formatRemainingPercent(window: NormalizedRateLimitWindow): string {
823
+ return `${(100 - clampPercent(window.usedPercent)).toFixed(0)}%`;
824
+ }
825
+
826
+ function showReport(
827
+ ctx: ExtensionCommandContext,
828
+ report: CodexUsageReport,
829
+ fromCache: boolean,
830
+ ): void {
831
+ const text = formatCodexUsageReport(
832
+ report,
833
+ fromCache ? Date.now() - report.capturedAt : undefined,
834
+ );
835
+ ctx.ui.notify(ctx.hasUI ? brightenInfoNotification(text) : text, "info");
836
+ }
837
+
838
+ function brightenInfoNotification(text: string): string {
839
+ return `${RESET_FOREGROUND}${text}`;
840
+ }
841
+
842
+ function isPrimaryCodexSnapshot(snapshot: NormalizedRateLimitSnapshot): boolean {
843
+ return snapshot.limitId.toLowerCase() === "codex";
844
+ }
845
+
846
+ function formatWindowLine(label: string, window: NormalizedRateLimitWindow): string {
847
+ return ` ${label.padEnd(LIMIT_VALUE_COLUMN)}${formatWindow(window)}`;
848
+ }
849
+
850
+ function formatWindow(window: NormalizedRateLimitWindow): string {
851
+ const remaining = 100 - clampPercent(window.usedPercent);
852
+ const reset = window.resetsAt ? ` (resets ${formatReset(window.resetsAt)})` : "";
853
+ return `${progressBar(remaining)} ${remaining.toFixed(0)}% left${reset}`;
854
+ }
855
+
856
+ function progressBar(percentRemaining: number): string {
857
+ const filled = Math.round((clampPercent(percentRemaining) / 100) * BAR_SEGMENTS);
858
+ return `[${"█".repeat(filled)}${"░".repeat(BAR_SEGMENTS - filled)}]`;
859
+ }
860
+
861
+ function formatCredits(credits: NormalizedCredits): string {
862
+ if (!credits.hasCredits) return "no credits";
863
+ if (credits.unlimited) return "unlimited credits";
864
+ const balance = credits.balance?.trim();
865
+ if (!balance) return "credits available";
866
+ return `${formatNumber(Number(balance), balance)} credits`;
867
+ }
868
+
869
+ function formatReset(epochSeconds: number): string {
870
+ const reset = new Date(epochSeconds * 1000);
871
+ if (Number.isNaN(reset.getTime())) return "at an unknown time";
872
+
873
+ const now = new Date();
874
+ const time = `${reset.getHours().toString().padStart(2, "0")}:${reset
875
+ .getMinutes()
876
+ .toString()
877
+ .padStart(2, "0")}`;
878
+ if (reset.toDateString() === now.toDateString()) return time;
879
+ const day = reset.getDate().toString();
880
+ const month = reset.toLocaleDateString(undefined, { month: "short" });
881
+ return `${time} on ${day} ${month}`;
882
+ }
883
+
884
+ function formatQueryErrors(errors: UsageQueryError[]): string {
885
+ const lines = ["Unable to read Codex usage."];
886
+ for (const error of errors) {
887
+ const source = error.source === "pi-auth" ? "Pi auth direct" : "Codex app-server fallback";
888
+ lines.push(`- ${source}: ${error.message}`);
889
+ }
890
+ lines.push("");
891
+ lines.push(
892
+ "Tip: use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro. If Pi auth is unavailable, install Codex CLI and run codex login for the fallback.",
893
+ );
894
+ return lines.join("\n");
895
+ }
896
+
897
+ function formatPlanType(planType: string): string {
898
+ const key = planType
899
+ .replace(/([a-z])([A-Z])/g, "$1_$2")
900
+ .toLowerCase()
901
+ .replace(/[^a-z0-9]+/g, "_");
902
+ if (key === "pro_lite" || key === "prolite") return "Pro Lite";
903
+ if (key === "team" || key === "self_serve_business_usage_based" || key === "business") {
904
+ return "Business";
905
+ }
906
+ if (key === "enterprise_cbp_usage_based") return "Enterprise";
907
+
908
+ const normalized = planType
909
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
910
+ .replace(/[_-]+/g, " ")
911
+ .trim();
912
+ if (!normalized) return planType;
913
+ return normalized
914
+ .split(/\s+/)
915
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
916
+ .join(" ");
917
+ }
918
+
919
+ function formatDuration(milliseconds: number): string {
920
+ const seconds = Math.max(0, Math.round(milliseconds / 1000));
921
+ if (seconds < 60) return `${seconds}s`;
922
+ const minutes = Math.round(seconds / 60);
923
+ if (minutes < 60) return `${minutes}m`;
924
+ const hours = Math.round(minutes / 60);
925
+ return `${hours}h`;
926
+ }
927
+
928
+ function formatNumber(value: number, fallback: string): string {
929
+ if (!Number.isFinite(value)) return fallback;
930
+ return new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value);
931
+ }
932
+
933
+ function clampPercent(value: number): number {
934
+ if (!Number.isFinite(value)) return 0;
935
+ return Math.min(100, Math.max(0, value));
936
+ }
937
+
938
+ function parseJsonObject(text: string, description: string): Record<string, unknown> {
939
+ let parsed: unknown;
940
+ try {
941
+ parsed = JSON.parse(text) as unknown;
942
+ } catch (error) {
943
+ throw new Error(`${description} was not valid JSON: ${errorMessage(error)}`);
944
+ }
945
+ return assertObject(parsed, description);
946
+ }
947
+
948
+ function assertObject(value: unknown, description: string): Record<string, unknown> {
949
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
950
+ throw new Error(`${description} was not an object.`);
951
+ }
952
+ return value as Record<string, unknown>;
953
+ }
954
+
955
+ function asString(value: unknown): string | undefined {
956
+ return typeof value === "string" ? value : undefined;
957
+ }
958
+
959
+ function asNumber(value: unknown): number | undefined {
960
+ if (typeof value === "number" && Number.isFinite(value)) return value;
961
+ if (typeof value === "string" && value.trim()) {
962
+ const parsed = Number(value);
963
+ return Number.isFinite(parsed) ? parsed : undefined;
964
+ }
965
+ return undefined;
966
+ }
967
+
968
+ function asBoolean(value: unknown): boolean | undefined {
969
+ return typeof value === "boolean" ? value : undefined;
970
+ }
971
+
972
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
973
+ return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
974
+ }
975
+
976
+ function redactErrorBody(body: string): string {
977
+ return truncateEnd(
978
+ body
979
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
980
+ .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
981
+ .trim(),
982
+ MAX_ERROR_BODY_CHARS,
983
+ );
984
+ }
985
+
986
+ function truncateEnd(value: string, maxChars: number): string {
987
+ if (value.length <= maxChars) return value;
988
+ return `${value.slice(0, maxChars - 1)}…`;
989
+ }
990
+
991
+ function errorMessage(error: unknown): string {
992
+ return error instanceof Error ? error.message : String(error);
993
+ }