@rol1n/oai-subquota 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (5) hide show
  1. package/README.md +21 -2
  2. package/index.ts +286 -0
  3. package/package.json +37 -4
  4. package/snapshot.ts +293 -0
  5. package/ui.ts +183 -0
package/README.md CHANGED
@@ -1,3 +1,22 @@
1
- # Temporary Holding Version
1
+ # oai-subquota
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Monitor OpenAI Codex subscription quota (5-hour and 7-day windows, resets, and credits) when using the `openai-codex` provider.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ pi install npm:@rol1n/oai-subquota
9
+ ```
10
+
11
+ Also included in `@rol1n/pi-kit`.
12
+
13
+ ## Commands
14
+
15
+ | Command | Action |
16
+ |---|---|
17
+ | `/oai-subquota codex` | Render boxed quota summary and refresh footer |
18
+ | `/oai-subquota refresh` | Force a fresh backend query |
19
+ | `/oai-subquota statusline` | Print compact single-line quota text |
20
+ | `/oai-subquota chatgpt` | Inspect parked ChatGPT OAuth quota status |
21
+
22
+ Authenticate with `/login openai-codex`. Snapshots are cached under `$XDG_CACHE_HOME/oai-subquota/usage.json` (or `~/.cache/oai-subquota/usage.json`) for 60 seconds.
package/index.ts ADDED
@@ -0,0 +1,286 @@
1
+ /**
2
+ * oai-subquota — OpenAI Codex subscription quota & status monitor for Pi
3
+ *
4
+ * Capabilities:
5
+ * - Queries ChatGPT Codex backend usage quota (5-hour window, 7-day weekly window,
6
+ * banked rate-limit resets, and flexible credits).
7
+ * - Renders a boxed quota summary in chat transcript on `/oai-subquota [codex]`.
8
+ * - Displays a persistent, right-aligned single-line status in the footer:
9
+ * 5h:100% 7d:71% ↺4d5h
10
+ * - Provider-scoped: active exclusively when current model provider is "openai-codex".
11
+ * Automatically clears when switching away and restores upon switching back.
12
+ * - Turn-based hot refresh: passively collects x-codex-* headers during agent loops
13
+ * and refreshes the footer on turn completion (agent_end / agent_settled).
14
+ * - ChatGPT OAuth (provider: "openai") quota querying is parked pending official API.
15
+ */
16
+
17
+ import type { AutocompleteItem, ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import {
19
+ extractAccountFromToken,
20
+ mergeSnapshots,
21
+ parseCodexRateLimitHeaders,
22
+ parseUsageApiResponse,
23
+ queryUsage,
24
+ readCachedSnapshot,
25
+ writeCachedSnapshot,
26
+ type AccountSnapshot,
27
+ type CodexUsageSnapshot,
28
+ type CreditsSnapshot,
29
+ type LimitWindow,
30
+ type RateLimit,
31
+ type ResetsSnapshot,
32
+ } from "./snapshot.ts";
33
+ import { dimText, formatBoxedStatus, formatRightAlignedStatusline, formatStatusline, renderStatusMessage } from "./ui.ts";
34
+
35
+ export type { LimitWindow, RateLimit, CreditsSnapshot, ResetsSnapshot, AccountSnapshot, CodexUsageSnapshot };
36
+ export { bar, formatDuration, formatStatusline, formatBoxedStatus, formatRightAlignedStatusline } from "./ui.ts";
37
+
38
+ const CODEX_PROVIDER = "openai-codex";
39
+ const CHATGPT_PROVIDER = "openai";
40
+ const STATUS_KEY = "oai-subquota";
41
+ const MESSAGE_TYPE = "codex-quota";
42
+ const CACHE_TTL_MS = 60_000;
43
+
44
+ function isCodexProvider(ctx: ExtensionContext | ExtensionCommandContext): boolean {
45
+ return ctx.model?.provider === CODEX_PROVIDER;
46
+ }
47
+
48
+ let activeCtx: ExtensionContext | ExtensionCommandContext | null = null;
49
+ let latestSnapshot: CodexUsageSnapshot | undefined;
50
+
51
+ function setFooterStatus(ctx: ExtensionContext | ExtensionCommandContext, snapshot: CodexUsageSnapshot | undefined): void {
52
+ latestSnapshot = snapshot;
53
+ activeCtx = ctx;
54
+ try {
55
+ if (!ctx.ui?.setStatus) return;
56
+ if (!snapshot || !isCodexProvider(ctx)) {
57
+ ctx.ui.setStatus(STATUS_KEY, undefined);
58
+ return;
59
+ }
60
+ ctx.ui.setStatus(STATUS_KEY, dimText(ctx, formatRightAlignedStatusline(snapshot)));
61
+ } catch {
62
+ // UI unavailable
63
+ }
64
+ }
65
+
66
+ async function refreshCodexSnapshot(
67
+ ctx: ExtensionContext | ExtensionCommandContext,
68
+ force = false,
69
+ ): Promise<CodexUsageSnapshot | undefined> {
70
+ const cached = await readCachedSnapshot();
71
+ if (cached) {
72
+ setFooterStatus(ctx, cached);
73
+ if (!force && Date.now() - Date.parse(cached.fetchedAt) < CACHE_TTL_MS) {
74
+ return cached;
75
+ }
76
+ }
77
+
78
+ const resolved = await ctx.modelRegistry.getProviderAuth(CODEX_PROVIDER).catch(() => undefined);
79
+ const accessToken = resolved?.auth.apiKey;
80
+ if (!accessToken) {
81
+ if (!cached) setFooterStatus(ctx, undefined);
82
+ return undefined;
83
+ }
84
+
85
+ const tokenAccount = extractAccountFromToken(accessToken);
86
+ const rawBody = await queryUsage(accessToken, tokenAccount.accountId);
87
+ const snapshot = parseUsageApiResponse(rawBody, tokenAccount);
88
+
89
+ await writeCachedSnapshot(snapshot);
90
+ setFooterStatus(ctx, snapshot);
91
+ return snapshot;
92
+ }
93
+
94
+ // ----------------- Extension Entrypoint -----------------
95
+
96
+ export default function (pi: ExtensionAPI): void {
97
+ pi.registerMessageRenderer(MESSAGE_TYPE, renderStatusMessage);
98
+
99
+ const onResize = () => {
100
+ if (activeCtx && latestSnapshot && isCodexProvider(activeCtx)) {
101
+ setFooterStatus(activeCtx, latestSnapshot);
102
+ }
103
+ };
104
+ process.stdout?.on?.("resize", onResize);
105
+
106
+ let queryInFlight = false;
107
+ let isLoopActive = false;
108
+ let latestLoopHeaders: Record<string, string> | null = null;
109
+ let lastLoopRefreshTime = 0;
110
+
111
+ pi.on("session_shutdown", () => {
112
+ process.stdout?.off?.("resize", onResize);
113
+ activeCtx = null;
114
+ });
115
+
116
+ // Session start: activate only if current model provider is openai-codex
117
+ pi.on("session_start", async (_event, ctx) => {
118
+ if (!isCodexProvider(ctx)) {
119
+ setFooterStatus(ctx, undefined);
120
+ return;
121
+ }
122
+ try {
123
+ const cached = await readCachedSnapshot();
124
+ if (cached) setFooterStatus(ctx, cached);
125
+ void refreshCodexSnapshot(ctx, false).catch(() => undefined);
126
+ } catch {
127
+ // Non-blocking
128
+ }
129
+ });
130
+
131
+ // Dynamically react to model selection changes (/model or Ctrl+P)
132
+ pi.on("model_select", async (event, ctx) => {
133
+ if (event.model.provider === CODEX_PROVIDER) {
134
+ try {
135
+ const cached = await readCachedSnapshot();
136
+ if (cached) setFooterStatus(ctx, cached);
137
+ void refreshCodexSnapshot(ctx, false).catch(() => undefined);
138
+ } catch {
139
+ // Non-blocking
140
+ }
141
+ } else {
142
+ setFooterStatus(ctx, undefined);
143
+ }
144
+ });
145
+
146
+ // Turn start: activate hot-capture only for openai-codex
147
+ pi.on("before_agent_start", (_event, ctx) => {
148
+ if (!isCodexProvider(ctx)) {
149
+ isLoopActive = false;
150
+ latestLoopHeaders = null;
151
+ return;
152
+ }
153
+ isLoopActive = true;
154
+ latestLoopHeaders = null;
155
+ });
156
+
157
+ // Provider response: passively buffer rate limit headers during turn
158
+ pi.on("after_provider_response", (event, ctx) => {
159
+ if (!isCodexProvider(ctx) || !isLoopActive || !event.headers) return;
160
+ for (const [k, v] of Object.entries(event.headers)) {
161
+ if (k.toLowerCase().startsWith("x-codex-")) {
162
+ if (!latestLoopHeaders) latestLoopHeaders = {};
163
+ latestLoopHeaders[k] = v;
164
+ }
165
+ }
166
+ });
167
+
168
+ // Turn completion: refresh quota on agent loop boundary
169
+ const onAgentLoopFinished = async (ctx: ExtensionContext) => {
170
+ if (!isCodexProvider(ctx)) {
171
+ isLoopActive = false;
172
+ latestLoopHeaders = null;
173
+ return;
174
+ }
175
+ isLoopActive = false;
176
+ const now = Date.now();
177
+ if (now - lastLoopRefreshTime < 1500) return;
178
+ lastLoopRefreshTime = now;
179
+
180
+ try {
181
+ if (latestLoopHeaders) {
182
+ const parsed = parseCodexRateLimitHeaders(latestLoopHeaders);
183
+ latestLoopHeaders = null;
184
+ if (parsed) {
185
+ const cached = await readCachedSnapshot();
186
+ const merged = mergeSnapshots(cached, parsed);
187
+ await writeCachedSnapshot(merged);
188
+ setFooterStatus(ctx, merged);
189
+ return;
190
+ }
191
+ }
192
+ void refreshCodexSnapshot(ctx, false).catch(() => undefined);
193
+ } catch {
194
+ // Non-blocking
195
+ }
196
+ };
197
+
198
+ pi.on("agent_end", (_event, ctx) => {
199
+ void onAgentLoopFinished(ctx);
200
+ });
201
+ pi.on("agent_settled", (_event, ctx) => {
202
+ void onAgentLoopFinished(ctx);
203
+ });
204
+
205
+ // Slash command registration with English schema
206
+ pi.registerCommand("oai-subquota", {
207
+ description: "Check OpenAI Codex subscription quota (5h, 7d, resets); new ChatGPT OAuth quota is parked",
208
+ getArgumentCompletions: (prefix: string): AutocompleteItem[] | null => {
209
+ const items: AutocompleteItem[] = [
210
+ { value: "codex", label: "codex", description: "Display Codex quota summary box and refresh footer" },
211
+ { value: "refresh", label: "refresh", description: "Force fresh quota query from OpenAI backend" },
212
+ { value: "statusline", label: "statusline", description: "Print compact single-line quota text" },
213
+ { value: "chatgpt", label: "chatgpt", description: "Inspect new ChatGPT OAuth status (parked)" },
214
+ ];
215
+ const filtered = items.filter((item) => item.value.startsWith(prefix.trimStart()));
216
+ return filtered.length > 0 ? filtered : null;
217
+ },
218
+ handler: async (args, ctx) => {
219
+ const sub = args.trim().toLowerCase();
220
+
221
+ // ChatGPT new provider parked placeholder
222
+ if (sub === "chatgpt") {
223
+ const resolved = await ctx.modelRegistry.getProviderAuth(CHATGPT_PROVIDER).catch(() => undefined);
224
+ const hasAuth = !!resolved?.auth.apiKey;
225
+ const msg = hasAuth
226
+ ? "ChatGPT subscription (provider: openai) is parked: official 5h/weekly rate limit endpoint is not yet supported."
227
+ : "ChatGPT subscription (provider: openai) is not authenticated. Quota querying is parked.";
228
+
229
+ if (ctx.hasUI) {
230
+ ctx.ui.notify(msg, "info");
231
+ } else {
232
+ console.log(msg);
233
+ }
234
+ return;
235
+ }
236
+
237
+ if (queryInFlight) {
238
+ if (ctx.hasUI) ctx.ui.notify("oai-subquota: Query in progress.", "warning");
239
+ return;
240
+ }
241
+
242
+ queryInFlight = true;
243
+ try {
244
+ const force = sub === "refresh";
245
+ const snapshot = await refreshCodexSnapshot(ctx, force);
246
+ if (!snapshot) {
247
+ throw new Error("No openai-codex OAuth credentials found. Please run /login openai-codex first.");
248
+ }
249
+
250
+ if (sub === "statusline") {
251
+ const line = formatStatusline(snapshot);
252
+ if (ctx.hasUI === false) {
253
+ console.log(line);
254
+ } else {
255
+ ctx.ui.notify(line, "info");
256
+ }
257
+ return;
258
+ }
259
+
260
+ // Render boxed quota summary
261
+ const boxed = formatBoxedStatus(snapshot);
262
+ if (ctx.hasUI === false) {
263
+ console.log(boxed);
264
+ } else if (pi.sendMessage) {
265
+ await pi.sendMessage({
266
+ customType: MESSAGE_TYPE,
267
+ content: boxed,
268
+ display: true,
269
+ details: snapshot,
270
+ });
271
+ } else {
272
+ ctx.ui.notify(formatStatusline(snapshot), "info");
273
+ }
274
+ } catch (err) {
275
+ const message = err instanceof Error ? err.message : String(err);
276
+ if (ctx.hasUI) {
277
+ ctx.ui.notify(`oai-subquota error: ${message}`, "error");
278
+ } else {
279
+ console.error(`oai-subquota error: ${message}`);
280
+ }
281
+ } finally {
282
+ queryInFlight = false;
283
+ }
284
+ },
285
+ });
286
+ }
package/package.json CHANGED
@@ -1,6 +1,39 @@
1
1
  {
2
2
  "name": "@rol1n/oai-subquota",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "oai-subquota extension from pi-kit",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/RolinShmily/pi-kit.git",
12
+ "directory": "oai-subquota"
13
+ },
14
+ "homepage": "https://github.com/RolinShmily/pi-kit#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/RolinShmily/pi-kit/issues"
17
+ },
18
+ "engines": {
19
+ "node": ">=22.19.0"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "peerDependencies": {
25
+ "@earendil-works/pi-ai": "*",
26
+ "@earendil-works/pi-agent-core": "*",
27
+ "@earendil-works/pi-coding-agent": "*",
28
+ "@earendil-works/pi-tui": "*",
29
+ "typebox": "*"
30
+ },
31
+ "pi": {
32
+ "extensions": [
33
+ "./index.ts"
34
+ ],
35
+ "skills": [],
36
+ "prompts": [],
37
+ "themes": []
38
+ }
39
+ }
package/snapshot.ts ADDED
@@ -0,0 +1,293 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+
6
+ export interface LimitWindow {
7
+ usedPercent: number;
8
+ leftPercent: number;
9
+ windowSeconds?: number;
10
+ resetAt?: number;
11
+ resetAfterSeconds?: number;
12
+ }
13
+
14
+ export interface RateLimit {
15
+ id: string;
16
+ name: string;
17
+ primary?: LimitWindow;
18
+ secondary?: LimitWindow;
19
+ }
20
+
21
+ export interface CreditsSnapshot {
22
+ hasCredits?: boolean;
23
+ unlimited?: boolean;
24
+ balance?: number | string;
25
+ }
26
+
27
+ export interface ResetsSnapshot {
28
+ availableCount?: number;
29
+ applicableCount?: number;
30
+ }
31
+
32
+ export interface AccountSnapshot {
33
+ email?: string;
34
+ plan?: string;
35
+ accountId?: string;
36
+ }
37
+
38
+ export interface CodexUsageSnapshot {
39
+ source: "api" | "headers" | "cache";
40
+ fetchedAt: string;
41
+ account: AccountSnapshot;
42
+ defaultLimit?: RateLimit;
43
+ credits?: CreditsSnapshot;
44
+ resets?: ResetsSnapshot;
45
+ }
46
+
47
+ export function isRecord(val: unknown): val is Record<string, unknown> {
48
+ return typeof val === "object" && val !== null && !Array.isArray(val);
49
+ }
50
+
51
+ function normalizeResetAt(val: unknown): number | undefined {
52
+ if (typeof val !== "number" || !Number.isFinite(val) || val <= 0) return undefined;
53
+ return val > 10_000_000_000 ? Math.round(val / 1000) : val;
54
+ }
55
+
56
+ function normalizeWindow(val: unknown, nowMs = Date.now()): LimitWindow | undefined {
57
+ if (!isRecord(val)) return undefined;
58
+
59
+ const rawUsed = typeof val.used_percent === "number" ? val.used_percent : val.usedPercent;
60
+ if (typeof rawUsed !== "number" || !Number.isFinite(rawUsed) || rawUsed < 0) return undefined;
61
+
62
+ const usedPercent = Math.min(100, Math.max(0, rawUsed));
63
+ const leftPercent = Math.max(0, Math.min(100, 100 - usedPercent));
64
+
65
+ const resetAt = normalizeResetAt(val.reset_at ?? val.resetAt);
66
+ const rawAfter = typeof val.reset_after_seconds === "number" ? val.reset_after_seconds : val.resetAfterSeconds;
67
+ const resetAfterSeconds =
68
+ typeof rawAfter === "number" && Number.isFinite(rawAfter) && rawAfter >= 0
69
+ ? rawAfter
70
+ : resetAt !== undefined
71
+ ? Math.max(0, Math.round(resetAt - nowMs / 1000))
72
+ : undefined;
73
+
74
+ const rawSec = typeof val.limit_window_seconds === "number" ? val.limit_window_seconds : val.windowSeconds;
75
+ const rawMin = typeof val.window_minutes === "number" ? val.window_minutes : val.windowMinutes;
76
+ const windowSeconds =
77
+ typeof rawSec === "number" && rawSec > 0
78
+ ? rawSec
79
+ : typeof rawMin === "number" && rawMin > 0
80
+ ? rawMin * 60
81
+ : undefined;
82
+
83
+ return {
84
+ usedPercent,
85
+ leftPercent,
86
+ ...(windowSeconds !== undefined ? { windowSeconds } : {}),
87
+ ...(resetAt !== undefined ? { resetAt } : {}),
88
+ ...(resetAfterSeconds !== undefined ? { resetAfterSeconds } : {}),
89
+ };
90
+ }
91
+
92
+ function parseCredits(val: unknown): CreditsSnapshot | undefined {
93
+ if (!isRecord(val)) return undefined;
94
+ const hasCredits = val.has_credits === true;
95
+ const unlimited = val.unlimited === true;
96
+ const rawBalance = val.balance;
97
+ const balance =
98
+ typeof rawBalance === "number"
99
+ ? rawBalance
100
+ : typeof rawBalance === "string"
101
+ ? Number(rawBalance.trim())
102
+ : undefined;
103
+
104
+ return {
105
+ hasCredits,
106
+ unlimited,
107
+ ...(balance !== undefined && Number.isFinite(balance) ? { balance } : {}),
108
+ };
109
+ }
110
+
111
+ function parseResets(val: unknown): ResetsSnapshot | undefined {
112
+ if (!isRecord(val)) return undefined;
113
+ const available = typeof val.available_count === "number" ? val.available_count : undefined;
114
+ const applicable = typeof val.applicable_available_count === "number" ? val.applicable_available_count : undefined;
115
+ if (available === undefined && applicable === undefined) return undefined;
116
+ return {
117
+ ...(available !== undefined ? { availableCount: available } : {}),
118
+ ...(applicable !== undefined ? { applicableCount: applicable } : {}),
119
+ };
120
+ }
121
+
122
+ export function parseUsageApiResponse(data: unknown, tokenAccount: AccountSnapshot): CodexUsageSnapshot {
123
+ if (!isRecord(data)) throw new Error("Usage API returned non-object response");
124
+
125
+ const rateLimit = isRecord(data.rate_limit) ? data.rate_limit : undefined;
126
+ const primary = normalizeWindow(rateLimit?.primary_window);
127
+ const secondary = normalizeWindow(rateLimit?.secondary_window);
128
+
129
+ const email = (typeof data.email === "string" && data.email) || tokenAccount.email;
130
+ const plan = (typeof data.plan_type === "string" && data.plan_type) || tokenAccount.plan;
131
+ const accountId = (typeof data.account_id === "string" && data.account_id) || tokenAccount.accountId;
132
+
133
+ return {
134
+ source: "api",
135
+ fetchedAt: new Date().toISOString(),
136
+ account: { email, plan, accountId },
137
+ defaultLimit: {
138
+ id: "codex",
139
+ name: "Codex",
140
+ ...(primary ? { primary } : {}),
141
+ ...(secondary ? { secondary } : {}),
142
+ },
143
+ credits: parseCredits(data.credits),
144
+ resets: parseResets(data.rate_limit_reset_credits),
145
+ };
146
+ }
147
+
148
+ export function parseCodexRateLimitHeaders(headers: Record<string, string>): CodexUsageSnapshot | undefined {
149
+ const norm: Record<string, string> = {};
150
+ for (const [k, v] of Object.entries(headers)) norm[k.toLowerCase()] = String(v);
151
+
152
+ const num = (name: string): number | undefined => {
153
+ const v = norm[name];
154
+ if (v === undefined || v === "") return undefined;
155
+ const n = Number(v);
156
+ return Number.isFinite(n) ? n : undefined;
157
+ };
158
+ const bool = (name: string): boolean | undefined => {
159
+ const v = norm[name]?.trim().toLowerCase();
160
+ return v === "true" || v === "1" ? true : v === "false" || v === "0" ? false : undefined;
161
+ };
162
+
163
+ const primaryUsed = num("x-codex-primary-used-percent");
164
+ const secondaryUsed = num("x-codex-secondary-used-percent");
165
+ if (primaryUsed === undefined && secondaryUsed === undefined) return undefined;
166
+
167
+ const primary = normalizeWindow({
168
+ used_percent: primaryUsed,
169
+ window_minutes: num("x-codex-primary-window-minutes"),
170
+ reset_at: normalizeResetAt(num("x-codex-primary-reset-at")),
171
+ });
172
+ const secondary = normalizeWindow({
173
+ used_percent: secondaryUsed,
174
+ window_minutes: num("x-codex-secondary-window-minutes"),
175
+ reset_at: normalizeResetAt(num("x-codex-secondary-reset-at")),
176
+ });
177
+
178
+ return {
179
+ source: "headers",
180
+ fetchedAt: new Date().toISOString(),
181
+ account: {},
182
+ defaultLimit: {
183
+ id: "codex",
184
+ name: "Codex",
185
+ ...(primary ? { primary } : {}),
186
+ ...(secondary ? { secondary } : {}),
187
+ },
188
+ credits: {
189
+ hasCredits: bool("x-codex-credits-has-credits"),
190
+ unlimited: bool("x-codex-credits-unlimited"),
191
+ balance: num("x-codex-credits-balance"),
192
+ },
193
+ };
194
+ }
195
+
196
+ export function mergeSnapshots(existing: CodexUsageSnapshot | undefined, update: CodexUsageSnapshot): CodexUsageSnapshot {
197
+ if (!existing) return update;
198
+ const defaultLimit = update.defaultLimit ?? existing.defaultLimit;
199
+ if (defaultLimit && existing.defaultLimit) {
200
+ defaultLimit.primary = update.defaultLimit?.primary ?? existing.defaultLimit.primary;
201
+ defaultLimit.secondary = update.defaultLimit?.secondary ?? existing.defaultLimit.secondary;
202
+ }
203
+ return {
204
+ ...existing,
205
+ ...update,
206
+ account: { ...existing.account, ...update.account },
207
+ ...(defaultLimit ? { defaultLimit } : {}),
208
+ credits: update.credits ?? existing.credits,
209
+ resets: update.resets ?? existing.resets,
210
+ };
211
+ }
212
+
213
+ const JWT_AUTH_CLAIM = "https://api.openai.com/auth";
214
+ const JWT_PROFILE_CLAIM = "https://api.openai.com/profile";
215
+
216
+ function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
217
+ const part = token.split(".")[1];
218
+ if (!part) return undefined;
219
+ try {
220
+ const json = JSON.parse(Buffer.from(part, "base64url").toString("utf8"));
221
+ return isRecord(json) ? json : undefined;
222
+ } catch {
223
+ return undefined;
224
+ }
225
+ }
226
+
227
+ export function extractAccountFromToken(token: string): AccountSnapshot {
228
+ const payload = decodeJwtPayload(token);
229
+ if (!payload) return {};
230
+
231
+ const auth = isRecord(payload[JWT_AUTH_CLAIM]) ? payload[JWT_AUTH_CLAIM] : undefined;
232
+ const profile = isRecord(payload[JWT_PROFILE_CLAIM]) ? payload[JWT_PROFILE_CLAIM] : undefined;
233
+
234
+ const accountId = typeof auth?.chatgpt_account_id === "string" ? auth.chatgpt_account_id : undefined;
235
+ const plan = typeof auth?.chatgpt_plan_type === "string" ? auth.chatgpt_plan_type : undefined;
236
+ const email =
237
+ (typeof profile?.email === "string" && profile.email) ||
238
+ (typeof payload.email === "string" && payload.email) ||
239
+ undefined;
240
+
241
+ return { accountId, plan, email };
242
+ }
243
+
244
+ const USAGE_ENDPOINT = "https://chatgpt.com/backend-api/wham/usage";
245
+ const REQUEST_TIMEOUT_MS = 15_000;
246
+
247
+ export async function queryUsage(accessToken: string, accountId?: string): Promise<Record<string, unknown>> {
248
+ const headers: Record<string, string> = {
249
+ Authorization: `Bearer ${accessToken}`,
250
+ "User-Agent": "codex-cli",
251
+ Accept: "application/json",
252
+ };
253
+ if (accountId) headers["ChatGPT-Account-Id"] = accountId;
254
+
255
+ const res = await fetch(USAGE_ENDPOINT, {
256
+ method: "GET",
257
+ headers,
258
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
259
+ });
260
+
261
+ if (res.status === 401 || res.status === 403) throw new Error(`Authentication rejected (HTTP ${res.status}). Please run /login openai-codex.`);
262
+ if (res.status === 429) throw new Error("Rate limit exceeded for usage endpoint (HTTP 429). Please try again later.");
263
+ if (!res.ok) throw new Error(`Usage request failed (HTTP ${res.status}).`);
264
+
265
+ const body = await res.json();
266
+ if (!isRecord(body)) throw new Error("Invalid JSON received from usage endpoint.");
267
+ return body;
268
+ }
269
+
270
+ function getCachePath(): string {
271
+ const base = process.env.XDG_CACHE_HOME || join(homedir() || ".", ".cache");
272
+ return join(base, "oai-subquota", "usage.json");
273
+ }
274
+
275
+ export async function readCachedSnapshot(): Promise<CodexUsageSnapshot | undefined> {
276
+ try {
277
+ const raw = await readFile(getCachePath(), "utf8");
278
+ const parsed = JSON.parse(raw);
279
+ return isRecord(parsed) ? (parsed as unknown as CodexUsageSnapshot) : undefined;
280
+ } catch {
281
+ return undefined;
282
+ }
283
+ }
284
+
285
+ export async function writeCachedSnapshot(snapshot: CodexUsageSnapshot): Promise<void> {
286
+ try {
287
+ const file = getCachePath();
288
+ await mkdir(dirname(file), { recursive: true });
289
+ await writeFile(file, `${JSON.stringify(snapshot, null, 2)}\n`, { mode: 0o600 });
290
+ } catch {
291
+ // Non-fatal if filesystem is read-only
292
+ }
293
+ }
package/ui.ts ADDED
@@ -0,0 +1,183 @@
1
+ import type { ExtensionCommandContext, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text, visibleWidth } from "@earendil-works/pi-tui";
3
+ import type { CodexUsageSnapshot, LimitWindow } from "./snapshot.ts";
4
+
5
+ const USAGE_SETTINGS_URL = "https://chatgpt.com/codex/settings/usage";
6
+ const MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
7
+
8
+ function pct(value: number | undefined): string {
9
+ if (value === undefined || !Number.isFinite(value)) return "?%";
10
+ return Number.isInteger(value) ? `${value}%` : `${value.toFixed(1)}%`;
11
+ }
12
+
13
+ function padLabel(label: string, width = 28): string {
14
+ return `${label}:`.padEnd(width, " ");
15
+ }
16
+
17
+ function formatTime(d: Date): string {
18
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19
+ }
20
+
21
+ function sameDay(a: Date, b: Date): boolean {
22
+ return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
23
+ }
24
+
25
+ function formatReset(resetAt: number | undefined, now = new Date()): string {
26
+ if (!resetAt) return "";
27
+ const reset = new Date(resetAt * 1000);
28
+ if (!Number.isFinite(reset.getTime())) return "";
29
+ const time = formatTime(reset);
30
+ if (sameDay(reset, now)) return `resets ${time}`;
31
+ const date = `${reset.getDate()} ${MONTH_NAMES[reset.getMonth()]}`;
32
+ return reset.getFullYear() === now.getFullYear() ? `resets ${time} on ${date}` : `resets ${time} on ${date} ${reset.getFullYear()}`;
33
+ }
34
+
35
+ export function formatDuration(seconds: number | undefined): string {
36
+ if (seconds === undefined || !Number.isFinite(seconds) || seconds <= 0) return "";
37
+ const rounded = Math.round(seconds);
38
+ const days = Math.floor(rounded / 86_400);
39
+ const hours = Math.floor((rounded % 86_400) / 3600);
40
+ const mins = Math.floor((rounded % 3600) / 60);
41
+ if (days > 0) return `${days}d${hours ? `${hours}h` : ""}`;
42
+ if (hours > 0) return `${hours}h${mins ? `${mins}m` : ""}`;
43
+ return `${mins || 1}m`;
44
+ }
45
+
46
+ function resetSeconds(window: LimitWindow | undefined, now: Date): number | undefined {
47
+ if (!window) return undefined;
48
+ if (window.resetAfterSeconds !== undefined) return window.resetAfterSeconds;
49
+ return window.resetAt !== undefined ? Math.max(0, Math.round(window.resetAt - now.getTime() / 1000)) : undefined;
50
+ }
51
+
52
+ export function bar(leftPercent: number | undefined, width = 20): string {
53
+ const left = Math.max(0, Math.min(100, leftPercent ?? 0));
54
+ const filled = Math.max(0, Math.min(width, Math.round((left / 100) * width)));
55
+ return `[${"█".repeat(filled)}${"░".repeat(width - filled)}]`;
56
+ }
57
+
58
+ function formatWindowLine(label: string, window: LimitWindow | undefined, now: Date): string | undefined {
59
+ if (!window) return undefined;
60
+ const reset = formatReset(window.resetAt, now) || (formatDuration(window.resetAfterSeconds) ? `resets in ${formatDuration(window.resetAfterSeconds)}` : "");
61
+ const suffix = reset ? ` (${reset})` : "";
62
+ return `${padLabel(label)}${bar(window.leftPercent)} ${pct(window.leftPercent)} left${suffix}`;
63
+ }
64
+
65
+ function formatCreditsLine(snapshot: CodexUsageSnapshot): string | undefined {
66
+ const c = snapshot.credits;
67
+ if (!c) return undefined;
68
+ if (c.unlimited) return `${padLabel("Credits")}unlimited`;
69
+ if (c.balance !== undefined) {
70
+ const val = Number(c.balance);
71
+ if (!Number.isFinite(val) || val <= 0) return undefined;
72
+ return `${padLabel("Credits")}${Math.floor(val).toLocaleString()} credits`;
73
+ }
74
+ return c.hasCredits ? `${padLabel("Credits")}available` : undefined;
75
+ }
76
+
77
+ function formatResetsLine(snapshot: CodexUsageSnapshot): string | undefined {
78
+ const count = snapshot.resets?.availableCount;
79
+ return typeof count === "number" && count > 0 ? `${padLabel("Resets")}${count} available` : undefined;
80
+ }
81
+
82
+ export function formatStatusline(snapshot: CodexUsageSnapshot, now = new Date()): string {
83
+ const parts: string[] = [];
84
+ const primary = snapshot.defaultLimit?.primary;
85
+ const secondary = snapshot.defaultLimit?.secondary;
86
+
87
+ if (primary) {
88
+ parts.push(`5h:${pct(primary.leftPercent)}`);
89
+ }
90
+
91
+ if (secondary) {
92
+ parts.push(`7d:${pct(secondary.leftPercent)}`);
93
+ const r7d = formatDuration(resetSeconds(secondary, now));
94
+ if (r7d) parts.push(`↺ ${r7d}`);
95
+ }
96
+
97
+ return parts.length > 0 ? parts.join(" ") : "unavailable";
98
+ }
99
+
100
+ function makeBox(lines: string[], reqWidth?: number): string {
101
+ const contentWidth = Math.max(...lines.map((l) => visibleWidth(l)), 1);
102
+ const width = Math.max(contentWidth, reqWidth ?? 0);
103
+ const top = `╭${"─".repeat(width + 2)}╮`;
104
+ const bottom = `╰${"─".repeat(width + 2)}╯`;
105
+ const body = lines.map((l) => `│ ${l}${" ".repeat(Math.max(0, width - visibleWidth(l)))} │`);
106
+ return [top, ...body, bottom].join("\n");
107
+ }
108
+
109
+ export function formatBoxedStatus(snapshot: CodexUsageSnapshot, now = new Date()): string {
110
+ const lines: string[] = [
111
+ ">_ Codex usage",
112
+ "",
113
+ `Visit ${USAGE_SETTINGS_URL} for up-to-date`,
114
+ "information on rate limits and credits",
115
+ "",
116
+ ];
117
+
118
+ const acct: string[] = [];
119
+ if (snapshot.account.email) acct.push(snapshot.account.email);
120
+ if (snapshot.account.plan) acct.push(`(${snapshot.account.plan[0]?.toUpperCase()}${snapshot.account.plan.slice(1)})`);
121
+ if (acct.length > 0) lines.push(`${padLabel("Account")}${acct.join(" ")}`);
122
+ if (snapshot.fetchedAt) lines.push(`${padLabel("Updated")}${new Date(snapshot.fetchedAt).toLocaleString()}`);
123
+ lines.push("");
124
+
125
+ const p = formatWindowLine("5h limit", snapshot.defaultLimit?.primary, now);
126
+ const s = formatWindowLine("Weekly limit", snapshot.defaultLimit?.secondary, now);
127
+ if (p) lines.push(p);
128
+ if (s) lines.push(s);
129
+
130
+ const credits = formatCreditsLine(snapshot);
131
+ if (credits) lines.push(credits);
132
+
133
+ const resets = formatResetsLine(snapshot);
134
+ if (resets) lines.push(resets);
135
+
136
+ return makeBox(lines);
137
+ }
138
+
139
+ function colorForLeft(left: number): "success" | "warning" | "error" {
140
+ return left >= 60 ? "success" : left >= 25 ? "warning" : "error";
141
+ }
142
+
143
+ function colorizeStatusLine(line: string, theme: Theme): string {
144
+ let out = line;
145
+ const match = line.match(/(\[[█░]+\])\s+(\d+(?:\.\d+)?)% left/);
146
+ if (match?.[1] && match[2]) {
147
+ const barText = match[1];
148
+ const pctText = match[2];
149
+ const color = colorForLeft(Number(pctText) || 0);
150
+ out = out.replace(barText, theme.fg(color, barText));
151
+ out = out.replace(`${pctText}% left`, theme.fg(color, `${pctText}% left`));
152
+ }
153
+ out = out.replace(">_ Codex usage", theme.fg("accent", theme.bold(">_ Codex usage")));
154
+ out = out.replace(/(Account|Updated|5h limit|Weekly limit|Credits|Resets):/g, (lbl) => theme.fg("muted", lbl));
155
+ out = out.replace(/(Visit https:\/\/chatgpt\.com\/codex\/settings\/usage for up-to-date|information on rate limits and credits)/g, (txt) => theme.fg("dim", txt));
156
+
157
+ if (out.startsWith("╭") || out.startsWith("╰")) return theme.fg("dim", out);
158
+ if (out.startsWith("│") && out.endsWith("│")) {
159
+ return `${theme.fg("dim", out[0] ?? "")}${out.slice(1, -1)}${theme.fg("dim", out.at(-1) ?? "")}`;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ export function renderStatusMessage(message: { content: string }, _opt: unknown, theme: Theme): Text {
165
+ return new Text(message.content.split("\n").map((l) => colorizeStatusLine(l, theme)).join("\n"), 0, 0);
166
+ }
167
+
168
+ export function dimText(ctx: ExtensionContext | ExtensionCommandContext, text: string): string {
169
+ try {
170
+ return ctx.ui?.theme?.fg ? ctx.ui.theme.fg("dim", text) : text;
171
+ } catch {
172
+ return text;
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Format quota text for Pi's shared extension-status row.
178
+ * It must stay compact because Pi combines all extension statuses before
179
+ * applying the footer width limit.
180
+ */
181
+ export function formatRightAlignedStatusline(snapshot: CodexUsageSnapshot, now = new Date()): string {
182
+ return formatStatusline(snapshot, now);
183
+ }