@99percentpeople/pi-codex-api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/usage.ts ADDED
@@ -0,0 +1,628 @@
1
+ import { watch, type FSWatcher } from "node:fs";
2
+ import { basename, dirname, join } from "node:path";
3
+ import {
4
+ getAgentDir,
5
+ type ExtensionAPI,
6
+ type ExtensionContext,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import { createCodexApiClient } from "./client.ts";
9
+ import type { CodexApiConfig } from "./config.ts";
10
+
11
+ const USAGE_PATH = "../wham/usage";
12
+ const USAGE_REFRESH_INTERVAL_MS = 60_000;
13
+ const AUTH_WATCH_DEBOUNCE_MS = 100;
14
+
15
+ const STATUS_KEY = "codex-api-usage";
16
+
17
+ export interface CodexRateLimitWindow {
18
+ usedPercent: number;
19
+ windowMinutes?: number;
20
+ resetsAt?: number;
21
+ }
22
+
23
+ export interface CodexCreditsSnapshot {
24
+ hasCredits: boolean;
25
+ unlimited: boolean;
26
+ balance?: string;
27
+ }
28
+
29
+ export interface CodexRateLimitSnapshot {
30
+ limitId: string;
31
+ limitName?: string;
32
+ primary?: CodexRateLimitWindow;
33
+ secondary?: CodexRateLimitWindow;
34
+ credits?: CodexCreditsSnapshot;
35
+ }
36
+
37
+ function object(value: unknown): Record<string, unknown> | undefined {
38
+ return value && typeof value === "object" && !Array.isArray(value)
39
+ ? value as Record<string, unknown>
40
+ : undefined;
41
+ }
42
+
43
+ function property(value: Record<string, unknown>, snake: string, camel: string): unknown {
44
+ return value[snake] ?? value[camel];
45
+ }
46
+
47
+ function payloadNumber(value: unknown): number | undefined {
48
+ const number = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
49
+ return Number.isFinite(number) ? number : undefined;
50
+ }
51
+
52
+ function payloadBool(value: unknown): boolean | undefined {
53
+ if (typeof value === "boolean") return value;
54
+ if (value === 1 || value === "1" || (typeof value === "string" && value.toLowerCase() === "true")) return true;
55
+ if (value === 0 || value === "0" || (typeof value === "string" && value.toLowerCase() === "false")) return false;
56
+ return undefined;
57
+ }
58
+
59
+ function payloadWindow(value: unknown): CodexRateLimitWindow | undefined {
60
+ const input = object(value);
61
+ if (!input) return undefined;
62
+ const usedPercent = payloadNumber(property(input, "used_percent", "usedPercent"));
63
+ if (usedPercent === undefined) return undefined;
64
+ const seconds = payloadNumber(property(input, "limit_window_seconds", "limitWindowSeconds"));
65
+ return {
66
+ usedPercent,
67
+ windowMinutes: seconds !== undefined && seconds > 0 ? Math.ceil(seconds / 60) : undefined,
68
+ resetsAt: payloadNumber(property(input, "reset_at", "resetAt")),
69
+ };
70
+ }
71
+
72
+ function payloadCredits(value: unknown): CodexCreditsSnapshot | undefined {
73
+ const input = object(value);
74
+ if (!input) return undefined;
75
+ const hasCredits = payloadBool(property(input, "has_credits", "hasCredits"));
76
+ const unlimited = payloadBool(input.unlimited);
77
+ if (hasCredits === undefined || unlimited === undefined) return undefined;
78
+ const balance = input.balance;
79
+ return {
80
+ hasCredits,
81
+ unlimited,
82
+ balance: typeof balance === "string" && balance ? balance : undefined,
83
+ };
84
+ }
85
+
86
+ function payloadSnapshot(
87
+ limitId: string,
88
+ limitName: string | undefined,
89
+ rateLimitValue: unknown,
90
+ creditsValue?: unknown,
91
+ ): CodexRateLimitSnapshot {
92
+ const rateLimit = object(rateLimitValue);
93
+ return {
94
+ limitId,
95
+ limitName,
96
+ primary: payloadWindow(rateLimit && property(rateLimit, "primary_window", "primaryWindow")),
97
+ secondary: payloadWindow(rateLimit && property(rateLimit, "secondary_window", "secondaryWindow")),
98
+ credits: payloadCredits(creditsValue),
99
+ };
100
+ }
101
+
102
+ export function parseCodexUsagePayload(value: unknown): CodexRateLimitSnapshot[] {
103
+ const input = object(value);
104
+ if (!input) return [];
105
+ const rateLimit = property(input, "rate_limit", "rateLimit");
106
+ const snapshots = rateLimit !== undefined || input.credits !== undefined
107
+ ? [payloadSnapshot("codex", undefined, rateLimit, input.credits)]
108
+ : [];
109
+ const additional = property(input, "additional_rate_limits", "additionalRateLimits");
110
+ if (Array.isArray(additional)) {
111
+ for (const value of additional) {
112
+ const item = object(value);
113
+ if (!item) continue;
114
+ const id = property(item, "metered_feature", "meteredFeature");
115
+ if (typeof id !== "string" || !id.trim()) continue;
116
+ const name = property(item, "limit_name", "limitName");
117
+ snapshots.push(payloadSnapshot(
118
+ id.trim().toLowerCase().replace(/-/g, "_"),
119
+ typeof name === "string" && name.trim() ? name.trim() : undefined,
120
+ property(item, "rate_limit", "rateLimit"),
121
+ ));
122
+ }
123
+ }
124
+ return snapshots;
125
+ }
126
+
127
+ function normalizedHeaders(headers: Record<string, string>): Record<string, string> {
128
+ return Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
129
+ }
130
+
131
+ function finiteNumber(value: string | undefined): number | undefined {
132
+ if (value === undefined) return undefined;
133
+ const number = Number(value);
134
+ return Number.isFinite(number) ? number : undefined;
135
+ }
136
+
137
+ function bool(value: string | undefined): boolean | undefined {
138
+ if (value === "1" || value?.toLowerCase() === "true") return true;
139
+ if (value === "0" || value?.toLowerCase() === "false") return false;
140
+ return undefined;
141
+ }
142
+
143
+ function windowFor(headers: Record<string, string>, prefix: string): CodexRateLimitWindow | undefined {
144
+ const usedPercent = finiteNumber(headers[`${prefix}-used-percent`]);
145
+ if (usedPercent === undefined) return undefined;
146
+ return {
147
+ usedPercent,
148
+ windowMinutes: finiteNumber(headers[`${prefix}-window-minutes`]),
149
+ resetsAt: finiteNumber(headers[`${prefix}-reset-at`]),
150
+ };
151
+ }
152
+
153
+ export function parseCodexRateLimits(input: Record<string, string>): CodexRateLimitSnapshot[] {
154
+ const headers = normalizedHeaders(input);
155
+ const prefixes = new Set<string>();
156
+ for (const name of Object.keys(headers)) {
157
+ const match = /^x-(.+)-primary-used-percent$/.exec(name);
158
+ if (match) prefixes.add(`x-${match[1]}`);
159
+ }
160
+ if (Object.keys(headers).some((name) => name.startsWith("x-codex-"))) prefixes.add("x-codex");
161
+
162
+ return [...prefixes].sort().flatMap((prefix) => {
163
+ const primary = windowFor(headers, `${prefix}-primary`);
164
+ const secondary = windowFor(headers, `${prefix}-secondary`);
165
+ const hasCredits = bool(headers["x-codex-credits-has-credits"]);
166
+ const unlimited = bool(headers["x-codex-credits-unlimited"]);
167
+ const credits = prefix === "x-codex" && hasCredits !== undefined && unlimited !== undefined
168
+ ? {
169
+ hasCredits,
170
+ unlimited,
171
+ balance: headers["x-codex-credits-balance"],
172
+ }
173
+ : undefined;
174
+ if (!primary && !secondary && !credits) return [];
175
+ return [{
176
+ limitId: prefix.slice(2).replace(/-/g, "_"),
177
+ limitName: headers[`${prefix}-limit-name`],
178
+ primary,
179
+ secondary,
180
+ credits,
181
+ }];
182
+ });
183
+ }
184
+
185
+ function percent(value: number): string {
186
+ return Number.isInteger(value) ? String(value) : value.toFixed(1);
187
+ }
188
+
189
+ function resetText(epochSeconds: number | undefined, now = Date.now()): string | undefined {
190
+ if (epochSeconds === undefined) return undefined;
191
+ const remainingMs = epochSeconds * 1000 - now;
192
+ if (remainingMs <= 0) return undefined;
193
+ const minutes = Math.ceil(remainingMs / 60_000);
194
+ if (minutes < 60) return `${minutes}m`;
195
+ const hours = Math.ceil(minutes / 60);
196
+ if (hours < 48) return `${hours}h`;
197
+ return `${Math.ceil(hours / 24)}d`;
198
+ }
199
+
200
+ const KNOWN_WINDOWS = [
201
+ { minutes: 5 * 60, label: "5h" },
202
+ { minutes: 24 * 60, label: "daily" },
203
+ { minutes: 7 * 24 * 60, label: "weekly" },
204
+ { minutes: 30 * 24 * 60, label: "monthly" },
205
+ { minutes: 365 * 24 * 60, label: "annual" },
206
+ ] as const;
207
+
208
+ function windowLabel(window: CodexRateLimitWindow, fallback: string): string {
209
+ if (window.windowMinutes === undefined) return fallback;
210
+ const known = KNOWN_WINDOWS.find(({ minutes }) =>
211
+ window.windowMinutes! >= minutes * 0.95 && window.windowMinutes! <= minutes * 1.05
212
+ );
213
+ return known?.label ?? fallback;
214
+ }
215
+
216
+ function activeWindow(window: CodexRateLimitWindow | undefined, now: number): window is CodexRateLimitWindow {
217
+ if (!window) return false;
218
+ const resetIsStale = window.resetsAt !== undefined && window.resetsAt * 1000 <= now;
219
+ if (window.usedPercent === 0 && resetIsStale) return false;
220
+ return window.usedPercent > 0
221
+ || (window.windowMinutes !== undefined && window.windowMinutes > 0)
222
+ || (window.resetsAt !== undefined && window.resetsAt * 1000 > now);
223
+ }
224
+
225
+ interface LabeledWindow {
226
+ label: string;
227
+ window: CodexRateLimitWindow;
228
+ }
229
+
230
+ function activeWindows(snapshot: CodexRateLimitSnapshot, now: number): LabeledWindow[] {
231
+ return [
232
+ activeWindow(snapshot.primary, now)
233
+ ? { label: windowLabel(snapshot.primary, "usage"), window: snapshot.primary }
234
+ : undefined,
235
+ activeWindow(snapshot.secondary, now)
236
+ ? { label: windowLabel(snapshot.secondary, "secondary usage"), window: snapshot.secondary }
237
+ : undefined,
238
+ ].filter((value): value is LabeledWindow => value !== undefined);
239
+ }
240
+
241
+ const USAGE_BAR_WIDTH = 20;
242
+
243
+ function remainingPercent(window: CodexRateLimitWindow): number {
244
+ return Math.min(100, Math.max(0, 100 - window.usedPercent));
245
+ }
246
+
247
+ function usageBar(remaining: number): string {
248
+ const filled = Math.round(remaining / 100 * USAGE_BAR_WIDTH);
249
+ return `[${"█".repeat(filled)}${"░".repeat(USAGE_BAR_WIDTH - filled)}]`;
250
+ }
251
+
252
+ function windowText(item: LabeledWindow, labelWidth: number, now: number): string {
253
+ const reset = resetText(item.window.resetsAt, now);
254
+ const remaining = remainingPercent(item.window);
255
+ return `${item.label.padEnd(labelWidth)} ${usageBar(remaining)} ${percent(remaining)}% left${reset ? ` resets in ${reset}` : ""}`;
256
+ }
257
+
258
+ function creditsText(credits: CodexCreditsSnapshot): string {
259
+ if (credits.unlimited) return "unlimited additional credits";
260
+ if (credits.hasCredits) {
261
+ return `additional credits available${credits.balance ? ` (${credits.balance})` : ""}`;
262
+ }
263
+ return "no additional credits";
264
+ }
265
+
266
+ export function formatCodexUsage(
267
+ snapshots: CodexRateLimitSnapshot[],
268
+ now = Date.now(),
269
+ ): string {
270
+ if (snapshots.length === 0) {
271
+ return "No Codex usage data is available. Run /codex-usage with an active Codex subscription model to refresh it.";
272
+ }
273
+ const lines = ["Codex usage"];
274
+ for (const snapshot of snapshots) {
275
+ const name = snapshot.limitName ?? snapshot.limitId;
276
+ const windows = activeWindows(snapshot, now);
277
+ const labelWidth = Math.max(0, ...windows.map((window) => window.label.length));
278
+ lines.push("", name);
279
+ if (windows.length === 0) lines.push(" no active usage windows");
280
+ else lines.push(...windows.map((window) => ` ${windowText(window, labelWidth, now)}`));
281
+ if (snapshot.credits) lines.push(` ${creditsText(snapshot.credits)}`);
282
+ }
283
+ return lines.join("\n");
284
+ }
285
+
286
+ export function formatCodexStatus(
287
+ snapshots: CodexRateLimitSnapshot[],
288
+ fastMode: boolean,
289
+ now = Date.now(),
290
+ ): string | undefined {
291
+ const snapshot = snapshots.find((item) => item.limitId === "codex") ?? snapshots[0];
292
+ if (!snapshot) return undefined;
293
+ const shortest = activeWindows(snapshot, now)
294
+ .sort((left, right) => {
295
+ const leftWindow = left.window.windowMinutes ?? Number.POSITIVE_INFINITY;
296
+ const rightWindow = right.window.windowMinutes ?? Number.POSITIVE_INFINITY;
297
+ if (leftWindow !== rightWindow) return leftWindow - rightWindow;
298
+ return (left.window.resetsAt ?? Number.POSITIVE_INFINITY) - (right.window.resetsAt ?? Number.POSITIVE_INFINITY);
299
+ })[0];
300
+ if (!shortest) return undefined;
301
+ const remaining = remainingPercent(shortest.window);
302
+ const reset = resetText(shortest.window.resetsAt, now);
303
+ return `Codex ${shortest.label} ${percent(remaining)}%${reset ? ` ${reset}` : ""}${fastMode ? " Fast" : ""}`;
304
+ }
305
+
306
+ export function applyFastModePayload(payload: unknown, enabled: boolean): unknown {
307
+ if (!enabled || !payload || typeof payload !== "object" || Array.isArray(payload)) return payload;
308
+ return { ...(payload as Record<string, unknown>), service_tier: "priority" };
309
+ }
310
+
311
+ interface UsageController {
312
+ getConfig(): CodexApiConfig;
313
+ updateConfig(config: CodexApiConfig, ctx: ExtensionContext): void;
314
+ }
315
+
316
+ interface AccountUsageFetch {
317
+ revision: number;
318
+ promise: Promise<void>;
319
+ }
320
+
321
+ interface AccountUsageState {
322
+ snapshots: CodexRateLimitSnapshot[];
323
+ lastFetchAt: number;
324
+ usageFetch?: AccountUsageFetch;
325
+ }
326
+
327
+ export interface CodexUsageHandle {
328
+ getSnapshots(): CodexRateLimitSnapshot[];
329
+ refreshStatus(ctx: ExtensionContext): void;
330
+ refreshUsage(ctx: ExtensionContext, force?: boolean): Promise<void>;
331
+ }
332
+
333
+ export interface CodexUsageOptions {
334
+ /** Internal/test override. Production watches Pi's agent-dir auth.json. */
335
+ authPath?: string;
336
+ }
337
+
338
+ export function registerCodexUsageAndFast(
339
+ pi: ExtensionAPI,
340
+ controller: UsageController,
341
+ options: CodexUsageOptions = {},
342
+ ): CodexUsageHandle {
343
+ const usageByAccount = new Map<string, AccountUsageState>();
344
+ let activeAccountId: string | undefined;
345
+ let credentialRevision = 0;
346
+ let latestContext: ExtensionContext | undefined;
347
+ let accountCheck: Promise<void> | undefined;
348
+ let accountObserverActive = false;
349
+ let authWatcher: FSWatcher | undefined;
350
+ let authWatchDebounce: ReturnType<typeof setTimeout> | undefined;
351
+
352
+ const usageEnabled = (ctx: ExtensionContext): boolean => {
353
+ const config = controller.getConfig();
354
+ return config.usageStatus
355
+ && (ctx.model?.provider === "openai-codex" || config.allowOtherProviders);
356
+ };
357
+
358
+ const setStatus = (ctx: ExtensionContext, value: string | undefined): void => {
359
+ ctx.ui.setStatus(STATUS_KEY, value && ctx.ui.theme
360
+ ? ctx.ui.theme.fg("muted", value)
361
+ : value);
362
+ };
363
+
364
+ const currentState = (): AccountUsageState | undefined =>
365
+ activeAccountId ? usageByAccount.get(activeAccountId) : undefined;
366
+
367
+ const refreshStatus = (ctx: ExtensionContext) => {
368
+ latestContext = ctx;
369
+ if (!usageEnabled(ctx)) {
370
+ setStatus(ctx, undefined);
371
+ return;
372
+ }
373
+ setStatus(ctx, formatCodexStatus(currentState()?.snapshots ?? [], controller.getConfig().fastMode));
374
+ };
375
+
376
+ const showSyncingStatus = (ctx: ExtensionContext): void => {
377
+ latestContext = ctx;
378
+ setStatus(ctx, usageEnabled(ctx) ? "Codex syncing…" : undefined);
379
+ };
380
+
381
+ const invalidateAuthState = (ctx: ExtensionContext, action: "set" | "remove"): void => {
382
+ credentialRevision += 1;
383
+ activeAccountId = undefined;
384
+ usageByAccount.clear();
385
+ if (action === "set") showSyncingStatus(ctx);
386
+ else setStatus(ctx, undefined);
387
+ };
388
+
389
+ const activateAccount = (accountId: string, ctx: ExtensionContext): boolean => {
390
+ if (activeAccountId === accountId) return false;
391
+ credentialRevision += 1;
392
+ activeAccountId = accountId;
393
+ usageByAccount.clear();
394
+ showSyncingStatus(ctx);
395
+ return true;
396
+ };
397
+
398
+ const accountState = (accountId: string): AccountUsageState => {
399
+ let state = usageByAccount.get(accountId);
400
+ if (!state) {
401
+ state = { snapshots: [], lastFetchAt: 0 };
402
+ usageByAccount.set(accountId, state);
403
+ }
404
+ return state;
405
+ };
406
+
407
+ const resolveActiveClient = async (ctx: ExtensionContext, config: CodexApiConfig) => {
408
+ for (let attempt = 0; attempt < 2; attempt += 1) {
409
+ const revision = credentialRevision;
410
+ const client = await createCodexApiClient(ctx, {
411
+ allowOtherProviders: config.allowOtherProviders,
412
+ });
413
+ if (revision !== credentialRevision) continue;
414
+ const accountChanged = activateAccount(client.accountId, ctx);
415
+ return {
416
+ accountChanged,
417
+ accountId: client.accountId,
418
+ client,
419
+ revision: credentialRevision,
420
+ };
421
+ }
422
+ throw new Error("Codex account changed while resolving subscription usage; retry the refresh");
423
+ };
424
+
425
+ const refreshUsage = async (ctx: ExtensionContext, force = false): Promise<void> => {
426
+ latestContext = ctx;
427
+ const config = controller.getConfig();
428
+ if (ctx.model?.provider !== "openai-codex" && !config.allowOtherProviders) {
429
+ throw new Error(
430
+ "An active openai-codex model is required to refresh subscription usage. "
431
+ + "Enable Other providers in /99settings to use the logged-in Codex subscription from another model.",
432
+ );
433
+ }
434
+
435
+ const resolved = await resolveActiveClient(ctx, config);
436
+ const state = accountState(resolved.accountId);
437
+ const now = Date.now();
438
+ if (
439
+ !force
440
+ && !resolved.accountChanged
441
+ && state.snapshots.length > 0
442
+ && now - state.lastFetchAt < USAGE_REFRESH_INTERVAL_MS
443
+ ) {
444
+ refreshStatus(ctx);
445
+ return;
446
+ }
447
+
448
+ let usageFetch = state.usageFetch;
449
+ if (!usageFetch || usageFetch.revision !== resolved.revision) {
450
+ const operation = (async () => {
451
+ const payload = await resolved.client.get<unknown>(USAGE_PATH);
452
+ const parsed = parseCodexUsagePayload(payload);
453
+ if (parsed.length === 0) throw new Error("Codex usage API returned no usage data");
454
+ state.snapshots = parsed;
455
+ state.lastFetchAt = Date.now();
456
+ })();
457
+ let nextFetch: AccountUsageFetch;
458
+ const pending = operation.finally(() => {
459
+ if (state.usageFetch === nextFetch) state.usageFetch = undefined;
460
+ });
461
+ nextFetch = { revision: resolved.revision, promise: pending };
462
+ state.usageFetch = nextFetch;
463
+ usageFetch = nextFetch;
464
+ }
465
+
466
+ await usageFetch.promise;
467
+ if (activeAccountId === resolved.accountId && credentialRevision === resolved.revision) {
468
+ refreshStatus(ctx);
469
+ }
470
+ };
471
+
472
+ const refreshInBackground = (ctx: ExtensionContext, force = false) => {
473
+ latestContext = ctx;
474
+ void refreshUsage(ctx, force).catch(() => refreshStatus(ctx));
475
+ };
476
+
477
+ const codexOAuthAvailable = (ctx: ExtensionContext, config: CodexApiConfig): boolean => {
478
+ const model = ctx.model?.provider === "openai-codex"
479
+ ? ctx.model
480
+ : config.allowOtherProviders
481
+ ? ctx.modelRegistry.getAll().find((candidate) =>
482
+ candidate.provider === "openai-codex" && ctx.modelRegistry.isUsingOAuth(candidate)
483
+ )
484
+ : undefined;
485
+ return !!model && ctx.modelRegistry.isUsingOAuth(model);
486
+ };
487
+
488
+ const checkCurrentAccount = (ctx: ExtensionContext): Promise<void> => {
489
+ latestContext = ctx;
490
+ if (accountCheck) return accountCheck;
491
+ const operation = (async () => {
492
+ const config = controller.getConfig();
493
+ if (!codexOAuthAvailable(ctx, config)) {
494
+ if (activeAccountId !== undefined) invalidateAuthState(ctx, "remove");
495
+ return;
496
+ }
497
+ let accountId: string;
498
+ try {
499
+ const client = await createCodexApiClient(ctx, {
500
+ allowOtherProviders: config.allowOtherProviders,
501
+ });
502
+ accountId = client.accountId;
503
+ } catch {
504
+ // Keep the latest valid snapshot on transient credential-refresh failures.
505
+ return;
506
+ }
507
+ if (!accountObserverActive || latestContext !== ctx) return;
508
+ const accountChanged = activateAccount(accountId, ctx);
509
+ if (config.usageStatus && (accountChanged || (currentState()?.snapshots.length ?? 0) === 0)) {
510
+ await refreshUsage(ctx, true);
511
+ }
512
+ })();
513
+ const pending = operation.finally(() => {
514
+ if (accountCheck === pending) accountCheck = undefined;
515
+ });
516
+ accountCheck = pending;
517
+ return pending;
518
+ };
519
+
520
+ const startAccountObserver = (ctx: ExtensionContext): void => {
521
+ latestContext = ctx;
522
+ accountObserverActive = true;
523
+ if (authWatcher) return;
524
+ const authPath = options.authPath ?? join(getAgentDir(), "auth.json");
525
+ const authFilename = basename(authPath);
526
+ try {
527
+ const watcher = watch(dirname(authPath), { persistent: false }, (_event, filename) => {
528
+ if (filename !== null && filename.toString() !== authFilename) return;
529
+ if (authWatchDebounce) clearTimeout(authWatchDebounce);
530
+ authWatchDebounce = setTimeout(() => {
531
+ authWatchDebounce = undefined;
532
+ const activeContext = latestContext;
533
+ if (!activeContext) return;
534
+ void (async () => {
535
+ await activeContext.modelRegistry.refresh();
536
+ if (latestContext !== activeContext) return;
537
+ await checkCurrentAccount(activeContext);
538
+ })().catch(() => {});
539
+ }, AUTH_WATCH_DEBOUNCE_MS);
540
+ authWatchDebounce.unref?.();
541
+ });
542
+ watcher.on("error", () => {
543
+ watcher.close();
544
+ if (authWatcher === watcher) authWatcher = undefined;
545
+ });
546
+ authWatcher = watcher;
547
+ } catch {
548
+ // The normal agent directory exists; natural usage events remain a fallback.
549
+ }
550
+ void checkCurrentAccount(ctx).catch(() => {});
551
+ };
552
+
553
+ const storeHeaderSnapshots = async (
554
+ ctx: ExtensionContext,
555
+ snapshots: CodexRateLimitSnapshot[],
556
+ ): Promise<void> => {
557
+ const resolved = await resolveActiveClient(ctx, controller.getConfig());
558
+ const state = accountState(resolved.accountId);
559
+ state.snapshots = snapshots;
560
+ state.lastFetchAt = Date.now();
561
+ if (activeAccountId === resolved.accountId && credentialRevision === resolved.revision) {
562
+ refreshStatus(ctx);
563
+ }
564
+ };
565
+
566
+ pi.registerCommand("codex-usage", {
567
+ description: "Refresh and show Codex subscription usage limits and credits",
568
+ handler: async (_args, ctx) => {
569
+ try {
570
+ await refreshUsage(ctx, true);
571
+ } catch (error) {
572
+ const message = error instanceof Error ? error.message : String(error);
573
+ const snapshots = currentState()?.snapshots ?? [];
574
+ if (snapshots.length === 0) {
575
+ ctx.ui.notify(`Failed to refresh Codex usage: ${message}`, "error");
576
+ return;
577
+ }
578
+ ctx.ui.notify(`Failed to refresh Codex usage; showing the latest snapshot: ${message}`, "warning");
579
+ }
580
+ ctx.ui.notify(formatCodexUsage(currentState()?.snapshots ?? []), "info");
581
+ },
582
+ });
583
+
584
+ pi.on("before_provider_request", (event, ctx) => {
585
+ if (ctx.model?.provider !== "openai-codex") return;
586
+ refreshInBackground(ctx);
587
+ return applyFastModePayload(event.payload, controller.getConfig().fastMode);
588
+ });
589
+
590
+ pi.on("after_provider_response", (event, ctx) => {
591
+ if (ctx.model?.provider !== "openai-codex") return;
592
+ const parsed = parseCodexRateLimits(event.headers);
593
+ if (parsed.length > 0) {
594
+ void storeHeaderSnapshots(ctx, parsed).catch(() => refreshInBackground(ctx));
595
+ return;
596
+ }
597
+ refreshInBackground(ctx);
598
+ });
599
+
600
+ pi.on("model_select", (_event, ctx) => {
601
+ startAccountObserver(ctx);
602
+ void checkCurrentAccount(ctx).catch(() => {});
603
+ refreshInBackground(ctx, true);
604
+ });
605
+ pi.on("session_start", (_event, ctx) => {
606
+ startAccountObserver(ctx);
607
+ refreshInBackground(ctx, true);
608
+ });
609
+ pi.on("session_shutdown", (_event, ctx) => {
610
+ credentialRevision += 1;
611
+ activeAccountId = undefined;
612
+ usageByAccount.clear();
613
+ latestContext = undefined;
614
+ accountObserverActive = false;
615
+ if (authWatchDebounce) clearTimeout(authWatchDebounce);
616
+ authWatchDebounce = undefined;
617
+ authWatcher?.close();
618
+ authWatcher = undefined;
619
+ accountCheck = undefined;
620
+ setStatus(ctx, undefined);
621
+ });
622
+
623
+ return {
624
+ getSnapshots: () => structuredClone(currentState()?.snapshots ?? []),
625
+ refreshStatus,
626
+ refreshUsage,
627
+ };
628
+ }