@llblab/pi-codex-usage 0.3.3

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/AGENTS.md ADDED
@@ -0,0 +1,13 @@
1
+ # Agent Notes
2
+
3
+ - `Statusline-only scope`: Keep this extension zero-configuration and statusline-only.
4
+ - Trigger: Considering commands, menus, persisted settings, or notification output.
5
+ - Action: Prefer deleting the surface unless it is required for the optimistic status widget.
6
+
7
+ - `Optimistic refresh`: Preserve the last good statusline bar during refresh and transient failures.
8
+ - Trigger: Updating quota polling or error handling.
9
+ - Action: Do not collapse the bar while a request is in flight; only show `n/a` or `error` after repeated failures or no usable quota.
10
+
11
+ - `Compact dual bar`: Encode the 5-hour and weekly quota windows in the five-character separated-sextant bar.
12
+ - Trigger: Changing statusline formatting.
13
+ - Action: Keep a fixed-width `[xxxxx]` bar where top sextants represent the 5-hour window and bottom sextants represent the weekly window.
package/BACKLOG.md ADDED
@@ -0,0 +1,4 @@
1
+ # Backlog
2
+
3
+ - [ ] `Runtime smoke test` Verify the extension inside a live Pi session against real Codex auth states: usable subscription, missing auth, missing subscription, and transient network failure.
4
+ - [ ] `Statusline polish` Tune the successful-refresh blink duration if the current 150ms redraw feels too visible or too subtle in the Pi footer.
package/CHANGELOG.md ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ - `Fork baseline` Imported `extensions/pi-codex-usage` from `narumiruna/pi-extensions` as a standalone `@llblab/pi-codex-usage` package. Impact: the extension can be installed and maintained independently.
4
+ - `Minimal statusline` Removed command-driven report output and narrowed the extension to a zero-configuration statusline widget. Impact: runtime behavior is automatic while `openai-codex` is active.
5
+ - `Primary quota focus` Ignored additional returned buckets such as Spark-specific limits. Impact: the statusline only represents primary Codex 5-hour and weekly quota windows.
6
+ - `Dual quota bar` Replaced textual percentages with a fixed-width separated-sextant bar. Impact: both 5-hour and weekly remaining quota are encoded in five statusline characters.
7
+ - `Optimistic refresh` Kept the last good bar during refresh and transient failures, with a short successful-redraw blink. Impact: the footer no longer shifts or collapses during polling.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 narumiruna
4
+ Copyright (c) 2026 llblab
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # pi-codex-usage
2
+
3
+ Minimal zero-configuration Pi extension for showing primary ChatGPT Codex usage limits in the statusline.
4
+
5
+ This repository is a minimal fork of [`narumiruna/pi-extensions/extensions/pi-codex-usage`](https://github.com/narumiruna/pi-extensions/tree/main/extensions/pi-codex-usage). It keeps the auth and quota-fetching path, but intentionally narrows the interface to the primary Codex 5-hour and weekly windows.
6
+
7
+ ## Start Here
8
+
9
+ - [Agent Notes](./AGENTS.md)
10
+ - [Backlog](./BACKLOG.md)
11
+ - [Changelog](./CHANGELOG.md)
12
+
13
+ ## Features
14
+
15
+ - Shows an empty statusline bar immediately, then refreshes every 30 seconds while the active Pi model uses `openai-codex`
16
+ - Statusline output stays compact, with the `codex` label accented and the values muted
17
+ - Additional returned buckets, including Spark-specific limits, are ignored
18
+ - Pi OpenAI Codex provider auth is used first
19
+ - Codex CLI app-server remains available as a fallback
20
+ - Missing auth, subscription, plan, or quota windows are shown as `n/a`, not as an error
21
+ - Successful updates briefly redraw the bar without changing its width
22
+ - Network/provider failures keep the last good bar briefly, then show `error`
23
+ - No commands or configuration are required
24
+
25
+ ## Install
26
+
27
+ From npm:
28
+
29
+ ```bash
30
+ pi install npm:@llblab/pi-codex-usage
31
+ ```
32
+
33
+ From git:
34
+
35
+ ```bash
36
+ pi install git:github.com/llblab/pi-codex-usage
37
+ ```
38
+
39
+ ## Statusline
40
+
41
+ Normal usage:
42
+
43
+ ```text
44
+ codex [𜺃𜺃𜹣𜹓𜹓]
45
+ ```
46
+
47
+ The five-character bar encodes two ten-step limits at once: the top sextants are the 5-hour limit, and the bottom sextants are the weekly limit.
48
+
49
+ Unavailable because Codex auth or subscription quota is not available:
50
+
51
+ ```text
52
+ codex n/a
53
+ ```
54
+
55
+ Runtime failure, such as a network or provider error:
56
+
57
+ ```text
58
+ codex error
59
+ ```
60
+
61
+ ## Auth
62
+
63
+ The extension tries usage sources in this order:
64
+
65
+ 1. Pi's `openai-codex` provider auth
66
+ 2. `codex app-server --listen stdio://`
67
+
68
+ OpenAI API keys are not ChatGPT Codex subscription auth and do not expose these quotas.
69
+
70
+ ## License
71
+
72
+ MIT. See [`LICENSE`](./LICENSE).
package/index.ts ADDED
@@ -0,0 +1,846 @@
1
+ import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
2
+ import { createInterface } from "node:readline";
3
+
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ const CODEX_PROVIDER_ID = "openai-codex";
7
+ const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
8
+ const DEFAULT_TIMEOUT_MS = 15_000;
9
+ const REFRESH_INTERVAL_MS = 30 * 1000;
10
+ const REDRAW_BLINK_MS = 150;
11
+ const STATUS_KEY = "codex-usage";
12
+ const MAX_ERROR_BODY_CHARS = 600;
13
+ const STATUS_LABEL_TEXT = "codex";
14
+ const DUAL_BAR_CHARS = [
15
+ " ",
16
+ "𜹑",
17
+ "𜹒",
18
+ "𜹓",
19
+ "𜹠",
20
+ "𜹡",
21
+ "𜹢",
22
+ "𜹣",
23
+ "𜹰",
24
+ "𜹱",
25
+ "𜹲",
26
+ "𜹳",
27
+ "𜺀",
28
+ "𜺁",
29
+ "𜺂",
30
+ "𜺃",
31
+ ];
32
+
33
+ type UsageSource = "pi-auth" | "codex-app-server";
34
+ type TimeoutHandle = ReturnType<typeof setTimeout> & { unref?: () => void };
35
+ type PiModel = NonNullable<ExtensionContext["model"]>;
36
+ export type CodexUsageModel = Pick<PiModel, "id" | "name" | "provider">;
37
+
38
+ type QueryUsageOptions = {
39
+ timeoutMs: number;
40
+ };
41
+
42
+ type CachedReport = {
43
+ createdAt: number;
44
+ report: CodexUsageReport;
45
+ };
46
+
47
+ type QueryUsageResult =
48
+ | { ok: true; report: CodexUsageReport }
49
+ | { ok: false; errors: UsageQueryError[] };
50
+
51
+ type UsageQueryError = {
52
+ source: UsageSource;
53
+ message: string;
54
+ cause?: unknown;
55
+ };
56
+
57
+ export type CodexUsageReport = {
58
+ snapshots: NormalizedRateLimitSnapshot[];
59
+ };
60
+
61
+ export type NormalizedRateLimitSnapshot = {
62
+ limitId: string;
63
+ primary?: NormalizedRateLimitWindow;
64
+ secondary?: NormalizedRateLimitWindow;
65
+ };
66
+
67
+ export type NormalizedRateLimitWindow = {
68
+ usedPercent: number;
69
+ };
70
+
71
+ type RateLimitStatusPayload = {
72
+ rate_limit?: unknown;
73
+ };
74
+
75
+ type BackendRateLimitDetails = {
76
+ primary_window?: unknown;
77
+ secondary_window?: unknown;
78
+ };
79
+
80
+ type BackendWindowSnapshot = {
81
+ used_percent?: unknown;
82
+ };
83
+
84
+ type AppServerRateLimitResponse = {
85
+ rateLimits?: unknown;
86
+ };
87
+
88
+ type AppServerRateLimitSnapshot = {
89
+ limitId?: unknown;
90
+ primary?: unknown;
91
+ secondary?: unknown;
92
+ };
93
+
94
+ type AppServerWindowSnapshot = {
95
+ usedPercent?: unknown;
96
+ };
97
+
98
+ type RpcResponse = {
99
+ id?: unknown;
100
+ result?: unknown;
101
+ error?: { message?: unknown; code?: unknown };
102
+ };
103
+
104
+ type PendingRpc = {
105
+ resolve: (value: unknown) => void;
106
+ reject: (error: Error) => void;
107
+ };
108
+
109
+ export default function codexUsage(pi: ExtensionAPI) {
110
+ let cache: CachedReport | undefined;
111
+ let failedRefreshes = 0;
112
+ let statuslineBlinkTimer: TimeoutHandle | undefined;
113
+ let statuslineClearTimer: TimeoutHandle | undefined;
114
+ let statuslineRefreshTimer: TimeoutHandle | undefined;
115
+ let statuslineRequestId = 0;
116
+
117
+ const clearStatuslineTimers = () => {
118
+ if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
119
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
120
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
121
+ statuslineBlinkTimer = undefined;
122
+ statuslineClearTimer = undefined;
123
+ statuslineRefreshTimer = undefined;
124
+ };
125
+
126
+ const clearUsageStatusline = (ctx: ExtensionContext) => {
127
+ statuslineRequestId += 1;
128
+ clearStatuslineTimers();
129
+ ctx.ui.setStatus(STATUS_KEY, undefined);
130
+ };
131
+
132
+ const scheduleTemporaryStatuslineClear = (ctx: ExtensionContext) => {
133
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
134
+ statuslineClearTimer = setTimeout(() => {
135
+ ctx.ui.setStatus(STATUS_KEY, undefined);
136
+ statuslineClearTimer = undefined;
137
+ }, REFRESH_INTERVAL_MS) as TimeoutHandle;
138
+ statuslineClearTimer.unref?.();
139
+ };
140
+
141
+ const scheduleStatuslineRefresh = (ctx: ExtensionContext) => {
142
+ if (statuslineRefreshTimer) clearTimeout(statuslineRefreshTimer);
143
+ statuslineRefreshTimer = setTimeout(() => {
144
+ void refreshCurrentCodexUsageStatusline(ctx, true);
145
+ }, REFRESH_INTERVAL_MS) as TimeoutHandle;
146
+ statuslineRefreshTimer.unref?.();
147
+ };
148
+
149
+ const setUsageStatusline = (
150
+ ctx: ExtensionContext,
151
+ report: CodexUsageReport,
152
+ options: {
153
+ autoRefresh: boolean;
154
+ blink: boolean;
155
+ model: CodexUsageModel | undefined;
156
+ },
157
+ ) => {
158
+ if (statuslineBlinkTimer) clearTimeout(statuslineBlinkTimer);
159
+ if (statuslineClearTimer) clearTimeout(statuslineClearTimer);
160
+ statuslineBlinkTimer = undefined;
161
+ statuslineClearTimer = undefined;
162
+ const text = formatCodexUsageStatusline(report, ctx, options.model);
163
+ if (options.blink) {
164
+ ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
165
+ statuslineBlinkTimer = setTimeout(() => {
166
+ ctx.ui.setStatus(STATUS_KEY, text);
167
+ statuslineBlinkTimer = undefined;
168
+ }, REDRAW_BLINK_MS) as TimeoutHandle;
169
+ statuslineBlinkTimer.unref?.();
170
+ } else {
171
+ ctx.ui.setStatus(STATUS_KEY, text);
172
+ }
173
+ if (options.autoRefresh) scheduleStatuslineRefresh(ctx);
174
+ else scheduleTemporaryStatuslineClear(ctx);
175
+ };
176
+
177
+ const refreshCurrentCodexUsageStatusline = async (
178
+ ctx: ExtensionContext,
179
+ force: boolean,
180
+ model = ctx.model,
181
+ ) => {
182
+ if (!isOpenAICodexModel(model)) {
183
+ clearUsageStatusline(ctx);
184
+ return;
185
+ }
186
+
187
+ if (!cache) ctx.ui.setStatus(STATUS_KEY, formatEmptyStatuslineBar(ctx));
188
+ const requestId = statuslineRequestId + 1;
189
+ statuslineRequestId = requestId;
190
+ const cached =
191
+ cache && Date.now() - cache.createdAt < REFRESH_INTERVAL_MS
192
+ ? cache
193
+ : undefined;
194
+ if (cached && !force) {
195
+ setUsageStatusline(ctx, cached.report, {
196
+ autoRefresh: true,
197
+ blink: false,
198
+ model,
199
+ });
200
+ return;
201
+ }
202
+
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
+ failedRefreshes += 1;
212
+ if (!cache || failedRefreshes >= 5) {
213
+ ctx.ui.setStatus(STATUS_KEY, formatStatuslineProblem(ctx, result.errors));
214
+ }
215
+ scheduleStatuslineRefresh(ctx);
216
+ return;
217
+ }
218
+
219
+ failedRefreshes = 0;
220
+ cache = { createdAt: Date.now(), report: result.report };
221
+ setUsageStatusline(ctx, result.report, {
222
+ autoRefresh: true,
223
+ blink: cache !== undefined,
224
+ model,
225
+ });
226
+ };
227
+
228
+ pi.on("session_start", (_event, ctx) => {
229
+ if (isOpenAICodexModel(ctx.model))
230
+ void refreshCurrentCodexUsageStatusline(ctx, false);
231
+ else clearUsageStatusline(ctx);
232
+ });
233
+
234
+ pi.on("session_tree", (_event, ctx) => {
235
+ if (isOpenAICodexModel(ctx.model))
236
+ void refreshCurrentCodexUsageStatusline(ctx, false);
237
+ else clearUsageStatusline(ctx);
238
+ });
239
+
240
+ pi.on("model_select", (event, ctx) => {
241
+ if (isOpenAICodexModel(event.model)) {
242
+ void refreshCurrentCodexUsageStatusline(ctx, false, event.model);
243
+ } else {
244
+ clearUsageStatusline(ctx);
245
+ }
246
+ });
247
+
248
+ pi.on("session_shutdown", (_event, ctx) => clearUsageStatusline(ctx));
249
+ }
250
+
251
+ function isOpenAICodexModel(
252
+ model: Pick<PiModel, "provider"> | undefined,
253
+ ): boolean {
254
+ return model?.provider === CODEX_PROVIDER_ID;
255
+ }
256
+
257
+ async function queryUsage(
258
+ ctx: ExtensionContext,
259
+ options: Pick<QueryUsageOptions, "timeoutMs">,
260
+ ): Promise<QueryUsageResult> {
261
+ const errors: UsageQueryError[] = [];
262
+
263
+ try {
264
+ const report = await queryViaPiAuth(ctx, options.timeoutMs);
265
+ return { ok: true, report };
266
+ } catch (cause) {
267
+ errors.push({ source: "pi-auth", message: errorMessage(cause), cause });
268
+ }
269
+
270
+ try {
271
+ const report = await queryViaCodexAppServer(options.timeoutMs);
272
+ return { ok: true, report };
273
+ } catch (cause) {
274
+ errors.push({
275
+ source: "codex-app-server",
276
+ message: errorMessage(cause),
277
+ cause,
278
+ });
279
+ }
280
+
281
+ return { ok: false, errors };
282
+ }
283
+
284
+ async function queryViaPiAuth(
285
+ ctx: ExtensionContext,
286
+ timeoutMs: number,
287
+ ): Promise<CodexUsageReport> {
288
+ const auth = await resolvePiCodexAuth(ctx);
289
+ if (!auth) {
290
+ throw new Error(
291
+ "No Pi OpenAI Codex subscription auth was available. Use a Pi OpenAI Codex model or run /login for OpenAI ChatGPT Plus/Pro (Codex).",
292
+ );
293
+ }
294
+
295
+ const response = await fetchWithTimeout(
296
+ CODEX_USAGE_URL,
297
+ { headers: auth.headers },
298
+ timeoutMs,
299
+ );
300
+ const text = await response.text();
301
+ if (!response.ok) {
302
+ throw new Error(
303
+ `Codex usage endpoint returned ${response.status} ${response.statusText}: ${redactErrorBody(text)}`,
304
+ );
305
+ }
306
+
307
+ const payload = parseJsonObject(text, "Codex usage endpoint response");
308
+ return normalizeBackendPayload(
309
+ payload as RateLimitStatusPayload,
310
+ Date.now(),
311
+ "pi-auth",
312
+ );
313
+ }
314
+
315
+ async function resolvePiCodexAuth(
316
+ ctx: ExtensionContext,
317
+ ): Promise<{ headers: Record<string, string> } | undefined> {
318
+ const models = codexAuthCandidateModels(ctx);
319
+ const errors: string[] = [];
320
+
321
+ for (const model of models) {
322
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
323
+ if (!auth.ok) {
324
+ errors.push(auth.error);
325
+ continue;
326
+ }
327
+
328
+ const headers = { ...(auth.headers ?? {}) };
329
+ if (!hasHeader(headers, "Authorization") && auth.apiKey) {
330
+ headers.Authorization = `Bearer ${auth.apiKey}`;
331
+ }
332
+ if (!hasHeader(headers, "User-Agent")) {
333
+ headers["User-Agent"] = "pi-codex-usage";
334
+ }
335
+ if (hasHeader(headers, "Authorization")) {
336
+ return { headers };
337
+ }
338
+ }
339
+
340
+ if (errors.length > 0) {
341
+ throw new Error(errors.join("; "));
342
+ }
343
+ return undefined;
344
+ }
345
+
346
+ function codexAuthCandidateModels(ctx: ExtensionContext): PiModel[] {
347
+ const candidates: PiModel[] = [];
348
+ const seen = new Set<string>();
349
+ const add = (model: PiModel | undefined) => {
350
+ if (!model || model.provider !== CODEX_PROVIDER_ID) return;
351
+ const key = `${model.provider}/${model.id}`;
352
+ if (seen.has(key)) return;
353
+ seen.add(key);
354
+ candidates.push(model);
355
+ };
356
+
357
+ add(ctx.model);
358
+ for (const model of ctx.modelRegistry.getAvailable()) add(model);
359
+ for (const model of ctx.modelRegistry.getAll()) add(model);
360
+ return candidates;
361
+ }
362
+
363
+ async function fetchWithTimeout(
364
+ url: string,
365
+ init: RequestInit,
366
+ timeoutMs: number,
367
+ ): Promise<Response> {
368
+ const controller = new AbortController();
369
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
370
+ try {
371
+ return await fetch(url, { ...init, signal: controller.signal });
372
+ } catch (error) {
373
+ if (controller.signal.aborted) {
374
+ throw new Error(
375
+ `Timed out after ${Math.round(timeoutMs / 1000)}s while fetching Codex usage.`,
376
+ );
377
+ }
378
+ throw error;
379
+ } finally {
380
+ clearTimeout(timeout);
381
+ }
382
+ }
383
+
384
+ async function queryViaCodexAppServer(
385
+ timeoutMs: number,
386
+ ): Promise<CodexUsageReport> {
387
+ const client = new CodexAppServerClient(timeoutMs);
388
+ try {
389
+ await client.start();
390
+ await client.request("initialize", {
391
+ clientInfo: {
392
+ name: "pi_codex_usage",
393
+ title: "Pi Codex Usage",
394
+ version: "0.1.0",
395
+ },
396
+ capabilities: {
397
+ experimentalApi: false,
398
+ requestAttestation: false,
399
+ optOutNotificationMethods: [],
400
+ },
401
+ });
402
+ client.notify("initialized");
403
+ const result = await client.request("account/rateLimits/read", undefined);
404
+ return normalizeAppServerResponse(
405
+ assertObject(
406
+ result,
407
+ "account/rateLimits/read result",
408
+ ) as AppServerRateLimitResponse,
409
+ Date.now(),
410
+ );
411
+ } finally {
412
+ client.dispose();
413
+ }
414
+ }
415
+
416
+ class CodexAppServerClient {
417
+ private child?: ChildProcessWithoutNullStreams;
418
+ private nextId = 1;
419
+ private stderr = "";
420
+ private readonly pending = new Map<number, PendingRpc>();
421
+ private startPromise?: Promise<void>;
422
+ private exitError?: Error;
423
+ private readonly timeoutMs: number;
424
+
425
+ constructor(timeoutMs: number) {
426
+ this.timeoutMs = timeoutMs;
427
+ }
428
+
429
+ start(): Promise<void> {
430
+ if (this.startPromise) return this.startPromise;
431
+
432
+ this.startPromise = new Promise((resolve, reject) => {
433
+ const child = spawn("codex", ["app-server", "--listen", "stdio://"], {
434
+ stdio: ["pipe", "pipe", "pipe"],
435
+ });
436
+ this.child = child;
437
+
438
+ const startupTimeout = setTimeout(() => {
439
+ reject(
440
+ new Error(
441
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s starting codex app-server.`,
442
+ ),
443
+ );
444
+ }, this.timeoutMs);
445
+
446
+ child.once("spawn", () => {
447
+ clearTimeout(startupTimeout);
448
+ resolve();
449
+ });
450
+
451
+ child.once("error", (error) => {
452
+ clearTimeout(startupTimeout);
453
+ reject(new Error(`Failed to start codex app-server: ${error.message}`));
454
+ this.rejectAll(error);
455
+ });
456
+
457
+ child.once("exit", (code, signal) => {
458
+ const suffix = this.stderr
459
+ ? ` stderr: ${redactErrorBody(this.stderr)}`
460
+ : "";
461
+ this.exitError = new Error(
462
+ `codex app-server exited before completing the request (code ${code ?? "unknown"}, signal ${signal ?? "none"}).${suffix}`,
463
+ );
464
+ this.rejectAll(this.exitError);
465
+ });
466
+
467
+ child.stderr.setEncoding("utf8");
468
+ child.stderr.on("data", (chunk: string) => {
469
+ this.stderr = truncateEnd(this.stderr + chunk, MAX_ERROR_BODY_CHARS);
470
+ });
471
+
472
+ const lines = createInterface({ input: child.stdout });
473
+ lines.on("line", (line) => this.handleLine(line));
474
+ });
475
+
476
+ return this.startPromise;
477
+ }
478
+
479
+ request(method: string, params: unknown): Promise<unknown> {
480
+ const child = this.child;
481
+ if (!child?.stdin.writable) {
482
+ throw new Error("codex app-server is not running.");
483
+ }
484
+ if (this.exitError) throw this.exitError;
485
+
486
+ const id = this.nextId++;
487
+ const payload =
488
+ params === undefined ? { method, id } : { method, id, params };
489
+ const response = new Promise<unknown>((resolve, reject) => {
490
+ const timeout = setTimeout(() => {
491
+ this.pending.delete(id);
492
+ reject(
493
+ new Error(
494
+ `Timed out after ${Math.round(this.timeoutMs / 1000)}s waiting for ${method}.`,
495
+ ),
496
+ );
497
+ }, this.timeoutMs);
498
+
499
+ this.pending.set(id, {
500
+ resolve: (value) => {
501
+ clearTimeout(timeout);
502
+ resolve(value);
503
+ },
504
+ reject: (error) => {
505
+ clearTimeout(timeout);
506
+ reject(error);
507
+ },
508
+ });
509
+ });
510
+
511
+ child.stdin.write(`${JSON.stringify(payload)}\n`);
512
+ return response;
513
+ }
514
+
515
+ notify(method: string): void {
516
+ const child = this.child;
517
+ if (!child?.stdin.writable) return;
518
+ child.stdin.write(`${JSON.stringify({ method })}\n`);
519
+ }
520
+
521
+ dispose(): void {
522
+ for (const [id, pending] of this.pending) {
523
+ pending.reject(new Error(`codex app-server request ${id} cancelled.`));
524
+ }
525
+ this.pending.clear();
526
+
527
+ const child = this.child;
528
+ if (!child) return;
529
+ child.stdin.end();
530
+ if (!child.killed) child.kill();
531
+ this.child = undefined;
532
+ }
533
+
534
+ private handleLine(line: string): void {
535
+ let parsed: RpcResponse;
536
+ try {
537
+ parsed = JSON.parse(line) as RpcResponse;
538
+ } catch {
539
+ return;
540
+ }
541
+
542
+ if (typeof parsed.id !== "number") return;
543
+ const pending = this.pending.get(parsed.id);
544
+ if (!pending) return;
545
+ this.pending.delete(parsed.id);
546
+
547
+ if (parsed.error) {
548
+ const message =
549
+ typeof parsed.error.message === "string"
550
+ ? parsed.error.message
551
+ : "unknown error";
552
+ pending.reject(new Error(`codex app-server request failed: ${message}`));
553
+ return;
554
+ }
555
+
556
+ pending.resolve(parsed.result);
557
+ }
558
+
559
+ private rejectAll(error: Error): void {
560
+ for (const pending of this.pending.values()) pending.reject(error);
561
+ this.pending.clear();
562
+ }
563
+ }
564
+
565
+ export function normalizeBackendPayload(
566
+ payload: RateLimitStatusPayload,
567
+ _capturedAt: number,
568
+ _source: UsageSource,
569
+ ): CodexUsageReport {
570
+ const snapshot = normalizeBackendSnapshot("codex", payload.rate_limit);
571
+ if (!snapshot) {
572
+ throw new Error(
573
+ "Codex usage endpoint returned no displayable rate-limit windows.",
574
+ );
575
+ }
576
+ return { snapshots: [snapshot] };
577
+ }
578
+
579
+ function normalizeBackendSnapshot(
580
+ limitId: string,
581
+ rateLimit: unknown,
582
+ ): NormalizedRateLimitSnapshot | undefined {
583
+ if (rateLimit === null || rateLimit === undefined) return undefined;
584
+ const details = assertObject(
585
+ rateLimit,
586
+ "rate limit",
587
+ ) as BackendRateLimitDetails;
588
+ const primary = normalizeBackendWindow(details.primary_window);
589
+ const secondary = normalizeBackendWindow(details.secondary_window);
590
+ if (!primary && !secondary) return undefined;
591
+ return { limitId, primary, secondary };
592
+ }
593
+
594
+ function normalizeBackendWindow(
595
+ value: unknown,
596
+ ): NormalizedRateLimitWindow | undefined {
597
+ if (value === null || value === undefined) return undefined;
598
+ const window = assertObject(
599
+ value,
600
+ "rate-limit window",
601
+ ) as BackendWindowSnapshot;
602
+ const usedPercent = asNumber(window.used_percent);
603
+ if (usedPercent === undefined) return undefined;
604
+ return { usedPercent };
605
+ }
606
+
607
+ export function normalizeAppServerResponse(
608
+ response: AppServerRateLimitResponse,
609
+ _capturedAt: number,
610
+ ): CodexUsageReport {
611
+ const snapshots: NormalizedRateLimitSnapshot[] = [];
612
+ const addSnapshot = (raw: unknown, fallbackId: string) => {
613
+ const snapshot = normalizeAppServerSnapshot(raw, fallbackId);
614
+ if (!snapshot) return;
615
+ const existingIndex = snapshots.findIndex(
616
+ (item) => item.limitId === snapshot.limitId,
617
+ );
618
+ if (existingIndex >= 0)
619
+ snapshots[existingIndex] = mergeSnapshot(
620
+ snapshots[existingIndex],
621
+ snapshot,
622
+ );
623
+ else snapshots.push(snapshot);
624
+ };
625
+
626
+ addSnapshot(response.rateLimits, "codex");
627
+ if (snapshots.length === 0) {
628
+ throw new Error(
629
+ "codex app-server returned no displayable rate-limit windows.",
630
+ );
631
+ }
632
+
633
+ return { snapshots };
634
+ }
635
+
636
+ function normalizeAppServerSnapshot(
637
+ raw: unknown,
638
+ fallbackId: string,
639
+ ): NormalizedRateLimitSnapshot | undefined {
640
+ if (raw === null || raw === undefined) return undefined;
641
+ const snapshot = assertObject(
642
+ raw,
643
+ "app-server rate-limit snapshot",
644
+ ) as AppServerRateLimitSnapshot;
645
+ const limitId = asString(snapshot.limitId) ?? fallbackId;
646
+ const primary = normalizeAppServerWindow(snapshot.primary);
647
+ const secondary = normalizeAppServerWindow(snapshot.secondary);
648
+ if (!primary && !secondary) return undefined;
649
+ return { limitId, primary, secondary };
650
+ }
651
+
652
+ function normalizeAppServerWindow(
653
+ value: unknown,
654
+ ): NormalizedRateLimitWindow | undefined {
655
+ if (value === null || value === undefined) return undefined;
656
+ const window = assertObject(
657
+ value,
658
+ "app-server rate-limit window",
659
+ ) as AppServerWindowSnapshot;
660
+ const usedPercent = asNumber(window.usedPercent);
661
+ if (usedPercent === undefined) return undefined;
662
+ return { usedPercent };
663
+ }
664
+
665
+ function mergeSnapshot(
666
+ left: NormalizedRateLimitSnapshot,
667
+ right: NormalizedRateLimitSnapshot,
668
+ ): NormalizedRateLimitSnapshot {
669
+ return {
670
+ limitId: right.limitId || left.limitId,
671
+ primary: right.primary ?? left.primary,
672
+ secondary: right.secondary ?? left.secondary,
673
+ };
674
+ }
675
+
676
+ export function formatCodexUsageStatusline(
677
+ report: CodexUsageReport,
678
+ ctx: ExtensionContext,
679
+ _model?: CodexUsageModel,
680
+ ): string {
681
+ const snapshot = selectPrimaryCodexSnapshot(report);
682
+ if (!snapshot) return formatStatuslineText(ctx, "n/a");
683
+
684
+ if (!snapshot.primary && !snapshot.secondary) return formatStatuslineText(ctx, "n/a");
685
+ return formatStatuslineText(
686
+ ctx,
687
+ `[${formatDualLimitBar(snapshot.primary, snapshot.secondary)}]`,
688
+ );
689
+ }
690
+
691
+ function formatStatuslineText(ctx: ExtensionContext, value: string): string {
692
+ const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
693
+ return `${label} ${ctx.ui.theme.fg("muted", value)}`;
694
+ }
695
+
696
+ function formatEmptyStatuslineBar(ctx: ExtensionContext): string {
697
+ return formatStatuslineText(ctx, "[\u00a0\u00a0\u00a0\u00a0\u00a0]");
698
+ }
699
+
700
+ function formatStatuslineProblem(
701
+ ctx: ExtensionContext,
702
+ errors: UsageQueryError[],
703
+ ): string {
704
+ const label = ctx.ui.theme.fg("accent", STATUS_LABEL_TEXT);
705
+ const value = isUnavailable(errors)
706
+ ? ctx.ui.theme.fg("muted", "n/a")
707
+ : ctx.ui.theme.fg("error", "error");
708
+ return `${label} ${value}`;
709
+ }
710
+
711
+ function isUnavailable(errors: UsageQueryError[]): boolean {
712
+ return errors.some((error) => {
713
+ const message = error.message.toLowerCase();
714
+ return (
715
+ message.includes("no pi openai codex subscription auth") ||
716
+ message.includes("no displayable rate-limit windows") ||
717
+ message.includes("returned no displayable rate-limit windows") ||
718
+ message.includes("returned 401") ||
719
+ message.includes("returned 403") ||
720
+ message.includes("unauthorized") ||
721
+ message.includes("forbidden") ||
722
+ message.includes("subscription") ||
723
+ message.includes("no active plan") ||
724
+ message.includes("plan unavailable") ||
725
+ message.includes("quota unavailable") ||
726
+ message.includes("rate limits unavailable")
727
+ );
728
+ });
729
+ }
730
+
731
+ function selectPrimaryCodexSnapshot(
732
+ report: CodexUsageReport,
733
+ ): NormalizedRateLimitSnapshot | undefined {
734
+ return report.snapshots.find(isPrimaryCodexSnapshot) ?? report.snapshots[0];
735
+ }
736
+
737
+ function normalizedUsageKey(value: string | undefined): string | undefined {
738
+ const key = value
739
+ ?.toLowerCase()
740
+ .replace(/[^a-z0-9]+/g, "-")
741
+ .replace(/^-+|-+$/g, "");
742
+ return key || undefined;
743
+ }
744
+
745
+ function formatDualLimitBar(
746
+ primary: NormalizedRateLimitWindow | undefined,
747
+ secondary: NormalizedRateLimitWindow | undefined,
748
+ ): string {
749
+ const primaryParts = filledTenths(primary);
750
+ const secondaryParts = filledTenths(secondary);
751
+ let value = "";
752
+ for (let index = 0; index < 5; index++) {
753
+ const leftPart = index * 2 + 1;
754
+ const rightPart = leftPart + 1;
755
+ let mask = 0;
756
+ if (primaryParts >= leftPart) mask |= 1;
757
+ if (primaryParts >= rightPart) mask |= 2;
758
+ if (secondaryParts >= leftPart) mask |= 4;
759
+ if (secondaryParts >= rightPart) mask |= 8;
760
+ value += DUAL_BAR_CHARS[mask];
761
+ }
762
+ return value;
763
+ }
764
+
765
+ function filledTenths(window: NormalizedRateLimitWindow | undefined): number {
766
+ if (!window) return 0;
767
+ return Math.round(remainingPercent(window) / 10);
768
+ }
769
+
770
+ function remainingPercent(window: NormalizedRateLimitWindow): number {
771
+ return 100 - clampPercent(window.usedPercent);
772
+ }
773
+
774
+ function isPrimaryCodexSnapshot(
775
+ snapshot: NormalizedRateLimitSnapshot,
776
+ ): boolean {
777
+ return normalizedUsageKey(snapshot.limitId) === "codex";
778
+ }
779
+
780
+ function clampPercent(value: number): number {
781
+ if (!Number.isFinite(value)) return 0;
782
+ return Math.min(100, Math.max(0, value));
783
+ }
784
+
785
+ function parseJsonObject(
786
+ text: string,
787
+ description: string,
788
+ ): Record<string, unknown> {
789
+ let parsed: unknown;
790
+ try {
791
+ parsed = JSON.parse(text) as unknown;
792
+ } catch (error) {
793
+ throw new Error(
794
+ `${description} was not valid JSON: ${errorMessage(error)}`,
795
+ );
796
+ }
797
+ return assertObject(parsed, description);
798
+ }
799
+
800
+ function assertObject(
801
+ value: unknown,
802
+ description: string,
803
+ ): Record<string, unknown> {
804
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
805
+ throw new Error(`${description} was not an object.`);
806
+ }
807
+ return value as Record<string, unknown>;
808
+ }
809
+
810
+ function asString(value: unknown): string | undefined {
811
+ return typeof value === "string" ? value : undefined;
812
+ }
813
+
814
+ function asNumber(value: unknown): number | undefined {
815
+ if (typeof value === "number" && Number.isFinite(value)) return value;
816
+ if (typeof value === "string" && value.trim()) {
817
+ const parsed = Number(value);
818
+ return Number.isFinite(parsed) ? parsed : undefined;
819
+ }
820
+ return undefined;
821
+ }
822
+
823
+ function hasHeader(headers: Record<string, string>, name: string): boolean {
824
+ return Object.keys(headers).some(
825
+ (key) => key.toLowerCase() === name.toLowerCase(),
826
+ );
827
+ }
828
+
829
+ function redactErrorBody(body: string): string {
830
+ return truncateEnd(
831
+ body
832
+ .replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer <redacted>")
833
+ .replace(/"access_token"\s*:\s*"[^"]+"/gi, '"access_token":"<redacted>"')
834
+ .trim(),
835
+ MAX_ERROR_BODY_CHARS,
836
+ );
837
+ }
838
+
839
+ function truncateEnd(value: string, maxChars: number): string {
840
+ if (value.length <= maxChars) return value;
841
+ return `${value.slice(0, maxChars - 1)}…`;
842
+ }
843
+
844
+ function errorMessage(error: unknown): string {
845
+ return error instanceof Error ? error.message : String(error);
846
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@llblab/pi-codex-usage",
3
+ "version": "0.3.3",
4
+ "private": false,
5
+ "description": "Minimal Pi extension that shows primary Codex ChatGPT subscription usage limits.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi",
10
+ "codex",
11
+ "usage",
12
+ "status"
13
+ ],
14
+ "type": "module",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "https://github.com/llblab/pi-codex-usage.git"
19
+ },
20
+ "homepage": "https://github.com/llblab/pi-codex-usage",
21
+ "bugs": {
22
+ "url": "https://github.com/llblab/pi-codex-usage/issues"
23
+ },
24
+ "engines": {
25
+ "node": ">=22.19.0"
26
+ },
27
+ "scripts": {
28
+ "check": "node --experimental-strip-types -e \"await import('./index.ts'); console.log('pi-codex-usage: extension import ok')\"",
29
+ "typecheck": "tsc --noEmit",
30
+ "pack:dry": "npm pack --dry-run",
31
+ "validate": "npm run typecheck && npm run check && npm run pack:dry"
32
+ },
33
+ "files": [
34
+ "index.ts",
35
+ "README.md",
36
+ "AGENTS.md",
37
+ "BACKLOG.md",
38
+ "CHANGELOG.md",
39
+ "LICENSE"
40
+ ],
41
+ "pi": {
42
+ "extensions": [
43
+ "./index.ts"
44
+ ]
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-agent-core": "*",
48
+ "@earendil-works/pi-ai": "*",
49
+ "@earendil-works/pi-coding-agent": "*",
50
+ "@sinclair/typebox": "*"
51
+ },
52
+ "devDependencies": {
53
+ "@types/node": "latest",
54
+ "typescript": "latest"
55
+ }
56
+ }