@ravshansbox/pi-quota 0.0.0-stage → 0.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +32 -2
  3. package/index.ts +637 -0
  4. package/package.json +49 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ravshan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # pi-quota
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
+ Anthropic and OpenAI Codex quota status extension for pi.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install git:github.com/ravshansbox/pi-quota
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Pi loads the extension from `./index.ts` and shows the remaining quota for the provider backing the active model in the footer status line.
14
+
15
+ - Polls Anthropic and OpenAI Codex usage endpoints at every 10-minute wall-clock mark (`HH:00`, `HH:10`, `HH:20`, `HH:30`, `HH:40`, `HH:50`); polls immediately on session start, then aligns to the next mark
16
+ - Shows a footer status for the provider backing the **active model**, and only when quota data for that provider has been polled; switching models re-renders it immediately
17
+ - The status shares the footer line with other extensions, so it uses no extra terminal row
18
+ - The text starts with the provider label (`Claude` or `Codex`) and stays compact, e.g. `Claude: 93% 1h29m | 67% 3d13h`, where the 5-hour window comes first and the 7-day window second, and each window shows its remaining percentage followed by time until reset
19
+ - Available resets, when present, are appended as `| 2x 5h` (count, then time until the soonest expires)
20
+ - Both providers are still polled whenever their credentials exist, so quota is already fresh when the active model changes
21
+ - Refreshes Anthropic and OpenAI Codex OAuth access tokens from `~/.pi/agent/auth.json` when needed, writes updated credentials back (re-reading the file first to avoid clobbering concurrent updates), and notifies on the first successful refresh per provider each session
22
+ - Appends poll and token-refresh errors to `~/.pi/agent/pi-quota.log`
23
+
24
+ ## Configuration
25
+
26
+ No configuration is required. OAuth credentials for Anthropic and OpenAI Codex are read from `~/.pi/agent/auth.json`.
27
+
28
+ ## Development
29
+
30
+ ```bash
31
+ npm install
32
+ npm run check
33
+ ```
package/index.ts ADDED
@@ -0,0 +1,637 @@
1
+ /**
2
+ * pi-quota — tracks Anthropic and OpenAI Codex subscription quota and renders a footer status.
3
+ *
4
+ * NOTE: This extension deliberately has no automated tests. It is a single-file extension whose
5
+ * behaviour is dominated by external HTTP APIs (Anthropic, OpenAI Codex) and pi runtime events;
6
+ * it is verified manually by running it in pi. Do not add a test suite here.
7
+ */
8
+
9
+ import { spawn } from 'node:child_process';
10
+ import { readFile, writeFile, appendFile } from 'node:fs/promises';
11
+ import { join } from 'node:path';
12
+ import { homedir } from 'node:os';
13
+ import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
14
+
15
+ interface QuotaConfig {
16
+ codexResets?: { autoRedeem?: boolean };
17
+ }
18
+
19
+ interface QuotaState {
20
+ provider: 'anthropic' | 'openai-codex';
21
+ fiveHourRemaining: number | null;
22
+ fiveHourReset: Date | null;
23
+ sevenDayRemaining: number | null;
24
+ sevenDayReset: Date | null;
25
+ resetsAvailable: number;
26
+ resetSoonestExpiry: Date | null;
27
+ lastUpdated: Date;
28
+ }
29
+
30
+ type OAuthAuthRecord = {
31
+ access?: string | undefined;
32
+ refresh?: string | undefined;
33
+ expires?: number | undefined;
34
+ };
35
+
36
+ type AuthFile = Record<string, OAuthAuthRecord>;
37
+
38
+ type OAuthTokenResponse = {
39
+ access_token?: string;
40
+ refresh_token?: string;
41
+ expires_in?: number;
42
+ };
43
+
44
+ type AnthropicUsageWindow = { utilization?: number; resets_at?: string };
45
+
46
+ type AnthropicUsageResponse = {
47
+ five_hour?: AnthropicUsageWindow;
48
+ seven_day?: AnthropicUsageWindow;
49
+ };
50
+
51
+ type OpenAIUsageWindow = { used_percent?: number; reset_at?: number };
52
+
53
+ type OpenAIUsageResponse = {
54
+ rate_limit?: {
55
+ primary_window?: OpenAIUsageWindow;
56
+ secondary_window?: OpenAIUsageWindow;
57
+ };
58
+ rate_limit_reset_credits?: { available_count?: number };
59
+ };
60
+
61
+ type CodexResetCredit = {
62
+ id: string;
63
+ status?: string;
64
+ expires_at?: string | number;
65
+ };
66
+
67
+ type CodexResetCreditList = {
68
+ credits: CodexResetCredit[];
69
+ available_count?: number;
70
+ };
71
+
72
+ type CodexResetConsumeResponse = { code?: string };
73
+
74
+ const MODEL_PROVIDER_NAMES: Record<QuotaState['provider'], string> = {
75
+ anthropic: 'anthropic',
76
+ 'openai-codex': 'openai',
77
+ };
78
+
79
+ const QUOTA_LABELS: Record<QuotaState['provider'], string> = {
80
+ anthropic: 'Claude',
81
+ 'openai-codex': 'Codex',
82
+ };
83
+
84
+ const REQUEST_TIMEOUT_MS = 30_000;
85
+
86
+ function nextMarkAfter(time: Date): Date {
87
+ const next = new Date(time.getTime());
88
+ next.setSeconds(0, 0);
89
+ const minute = next.getMinutes();
90
+ const bump = 10 - (minute % 10);
91
+ next.setMinutes(minute + (bump === 0 ? 10 : bump));
92
+ return next;
93
+ }
94
+
95
+ function clampPercent(value: number): number {
96
+ return Math.min(100, Math.max(0, Math.round(value)));
97
+ }
98
+
99
+ function parseCreditExpiry(value: string | number | undefined): Date | null {
100
+ if (value === undefined || value === null) return null;
101
+ if (typeof value === 'number') {
102
+ const ms = value > 10_000_000_000 ? value : value * 1000;
103
+ const date = new Date(ms);
104
+ return Number.isNaN(date.getTime()) ? null : date;
105
+ }
106
+ const text = value.trim();
107
+ const date = /^\d+$/.test(text) ? new Date(Number(text) * 1000) : new Date(text);
108
+ return Number.isNaN(date.getTime()) ? null : date;
109
+ }
110
+
111
+ function formatResetTime(reset: Date): string {
112
+ const diff = reset.getTime() - Date.now();
113
+ if (diff <= 0) return 'now';
114
+
115
+ const days = Math.floor(diff / 86400000);
116
+ const hours = Math.floor((diff % 86400000) / 3600000);
117
+ const minutes = Math.floor((diff % 3600000) / 60000);
118
+
119
+ if (days > 0) return `${days}d${hours}h`;
120
+ if (hours > 0) return `${hours}h${minutes}m`;
121
+ if (minutes > 0) return `${minutes}m`;
122
+ return 'now';
123
+ }
124
+
125
+ async function loadConfig(): Promise<QuotaConfig | null> {
126
+ try {
127
+ const settingsPath = join(homedir(), '.pi', 'agent', 'settings.json');
128
+ const settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
129
+ return settings.quota ?? null;
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+
135
+ function authPath() {
136
+ return join(homedir(), '.pi', 'agent', 'auth.json');
137
+ }
138
+
139
+ async function loadAuth(): Promise<AuthFile | null> {
140
+ try {
141
+ return JSON.parse(await readFile(authPath(), 'utf-8')) as AuthFile;
142
+ } catch {
143
+ return null;
144
+ }
145
+ }
146
+
147
+ async function saveAuth(auth: AuthFile): Promise<void> {
148
+ await writeFile(authPath(), JSON.stringify(auth, null, 2));
149
+ }
150
+
151
+ async function persistAuthRecord(provider: string, record: OAuthAuthRecord): Promise<void> {
152
+ const current = (await loadAuth()) ?? {};
153
+ current[provider] = record;
154
+ await saveAuth(current);
155
+ }
156
+
157
+ function logPath() {
158
+ return join(homedir(), '.pi', 'agent', 'pi-quota.log');
159
+ }
160
+
161
+ async function logError(message: string, error?: unknown): Promise<void> {
162
+ const detail =
163
+ error instanceof Error
164
+ ? (error.stack ?? error.message)
165
+ : error !== undefined
166
+ ? String(error)
167
+ : '';
168
+ const line = `[${new Date().toISOString()}] ${message}${detail ? ` ${detail}` : ''}\n`;
169
+ try {
170
+ await appendFile(logPath(), line);
171
+ } catch (error) {
172
+ console.error('pi-quota: could not write log:', error);
173
+ }
174
+ }
175
+
176
+ export default function (pi: ExtensionAPI) {
177
+ const states: QuotaState[] = [];
178
+ const refreshNotified = new Set<string>();
179
+ let checkTimer: ReturnType<typeof setTimeout> | null = null;
180
+ let ctxRef: ExtensionContext | null = null;
181
+ let codexRedeemAttempted = false;
182
+ let cycleRunning = false;
183
+
184
+ function notifyRefreshOnce(provider: string, message: string) {
185
+ if (refreshNotified.has(provider)) return;
186
+ refreshNotified.add(provider);
187
+ ctxRef?.ui.notify(message, 'info');
188
+ }
189
+
190
+ function buildStatusText(): string | undefined {
191
+ // Only ever render the provider backing the active model; anything else
192
+ // would not apply to the next request.
193
+ const activeProvider = ctxRef?.model?.provider;
194
+ if (!activeProvider) return undefined;
195
+ const state = states.find((s) => MODEL_PROVIDER_NAMES[s.provider] === activeProvider);
196
+
197
+ if (!state) return undefined;
198
+
199
+ const parts: string[] = [];
200
+ if (state.fiveHourRemaining !== null) {
201
+ const resetStr = state.fiveHourReset ? formatResetTime(state.fiveHourReset) : '?';
202
+ parts.push(`${state.fiveHourRemaining}% ${resetStr}`);
203
+ }
204
+ if (state.sevenDayRemaining !== null) {
205
+ const resetStr = state.sevenDayReset ? formatResetTime(state.sevenDayReset) : '?';
206
+ parts.push(`${state.sevenDayRemaining}% ${resetStr}`);
207
+ }
208
+ if (state.resetsAvailable > 0) {
209
+ const expiryStr = state.resetSoonestExpiry
210
+ ? ` ${formatResetTime(state.resetSoonestExpiry)}`
211
+ : '';
212
+ parts.push(`${state.resetsAvailable}x${expiryStr}`);
213
+ }
214
+ return parts.length > 0 ? `${QUOTA_LABELS[state.provider]}: ${parts.join(' | ')}` : undefined;
215
+ }
216
+
217
+ function updateStatus() {
218
+ if (!ctxRef) return;
219
+
220
+ const text = buildStatusText();
221
+
222
+ ctxRef.ui.setStatus(
223
+ 'pi-quota',
224
+ text === undefined ? undefined : ctxRef.ui.theme.fg('muted', text),
225
+ );
226
+ }
227
+
228
+ function windowResetDetected(
229
+ previousReset: Date | null,
230
+ nextReset: Date | null,
231
+ previousRemaining: number | null,
232
+ nextRemaining: number | null,
233
+ ): boolean {
234
+ if (!previousReset || !nextReset) return false;
235
+ if (nextReset.getTime() <= previousReset.getTime()) return false;
236
+ if (previousRemaining === null || nextRemaining === null) return false;
237
+ return nextRemaining > previousRemaining;
238
+ }
239
+
240
+ function shouldPingOnReset(previous: QuotaState, next: QuotaState): boolean {
241
+ return (
242
+ windowResetDetected(
243
+ previous.fiveHourReset,
244
+ next.fiveHourReset,
245
+ previous.fiveHourRemaining,
246
+ next.fiveHourRemaining,
247
+ ) ||
248
+ windowResetDetected(
249
+ previous.sevenDayReset,
250
+ next.sevenDayReset,
251
+ previous.sevenDayRemaining,
252
+ next.sevenDayRemaining,
253
+ )
254
+ );
255
+ }
256
+
257
+ function pingProviderOnReset(provider: QuotaState['provider']) {
258
+ const ctx = ctxRef;
259
+ if (!ctx?.model) return;
260
+ if (ctx.model.provider !== MODEL_PROVIDER_NAMES[provider]) return;
261
+
262
+ const child = spawn(
263
+ 'pi',
264
+ [
265
+ '--no-session',
266
+ '--no-extensions',
267
+ '--no-context-files',
268
+ '--no-tools',
269
+ '--provider',
270
+ ctx.model.provider,
271
+ '--model',
272
+ ctx.model.id,
273
+ '-p',
274
+ 'hi',
275
+ ],
276
+ { cwd: ctx.cwd, stdio: 'ignore' },
277
+ );
278
+
279
+ child.on('error', (error: unknown) => {
280
+ void logError(`Reset ping failed for ${provider}:`, error);
281
+ });
282
+
283
+ child.unref();
284
+ }
285
+
286
+ function updateState(parsed: QuotaState) {
287
+ const existing = states.find((s) => s.provider === parsed.provider);
288
+ if (!existing) {
289
+ states.push(parsed);
290
+ return;
291
+ }
292
+
293
+ const shouldPing = shouldPingOnReset(existing, parsed);
294
+
295
+ existing.fiveHourRemaining = parsed.fiveHourRemaining;
296
+ existing.fiveHourReset = parsed.fiveHourReset;
297
+ existing.sevenDayRemaining = parsed.sevenDayRemaining;
298
+ existing.sevenDayReset = parsed.sevenDayReset;
299
+ existing.resetsAvailable = parsed.resetsAvailable;
300
+ existing.resetSoonestExpiry = parsed.resetSoonestExpiry;
301
+ existing.lastUpdated = parsed.lastUpdated;
302
+
303
+ if (shouldPing) {
304
+ pingProviderOnReset(parsed.provider);
305
+ }
306
+ }
307
+
308
+ async function ensureAnthropicAccess(auth: AuthFile): Promise<OAuthAuthRecord | undefined> {
309
+ const record = auth['anthropic'];
310
+ if (!record?.refresh) return record;
311
+ if (record.expires && record.expires > Date.now() + 60_000 && record.access) return record;
312
+
313
+ const response = await fetch('https://api.anthropic.com/v1/oauth/token', {
314
+ method: 'POST',
315
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
316
+ body: new URLSearchParams({
317
+ grant_type: 'refresh_token',
318
+ refresh_token: record.refresh,
319
+ client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
320
+ }),
321
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
322
+ });
323
+
324
+ if (!response.ok) {
325
+ logError(`Anthropic token refresh failed: ${response.status}`);
326
+ return record;
327
+ }
328
+
329
+ const data = (await response.json()) as OAuthTokenResponse;
330
+ const refreshed: OAuthAuthRecord = {
331
+ ...record,
332
+ access: data.access_token ?? record.access,
333
+ refresh: data.refresh_token ?? record.refresh,
334
+ expires: data.expires_in ? Date.now() + data.expires_in * 1000 : record.expires,
335
+ };
336
+ auth['anthropic'] = refreshed;
337
+ await persistAuthRecord('anthropic', refreshed);
338
+ notifyRefreshOnce('anthropic', 'pi-quota: refreshed Anthropic auth');
339
+ return refreshed;
340
+ }
341
+
342
+ async function ensureOpenAIAccess(auth: AuthFile): Promise<OAuthAuthRecord | undefined> {
343
+ const record = auth['openai-codex'];
344
+ if (!record?.refresh) return record;
345
+ if (record.expires && record.expires > Date.now() + 60_000 && record.access) return record;
346
+
347
+ const response = await fetch('https://auth.openai.com/oauth/token', {
348
+ method: 'POST',
349
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
350
+ body: new URLSearchParams({
351
+ grant_type: 'refresh_token',
352
+ refresh_token: record.refresh,
353
+ client_id: 'app_EMoamEEZ73f0CkXaXp7hrann',
354
+ }),
355
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
356
+ });
357
+
358
+ if (!response.ok) {
359
+ logError(`OpenAI Codex token refresh failed: ${response.status}`);
360
+ return record;
361
+ }
362
+
363
+ const data = (await response.json()) as OAuthTokenResponse;
364
+ const refreshed: OAuthAuthRecord = {
365
+ ...record,
366
+ access: data.access_token ?? record.access,
367
+ refresh: data.refresh_token ?? record.refresh,
368
+ expires: data.expires_in ? Date.now() + data.expires_in * 1000 : record.expires,
369
+ };
370
+ auth['openai-codex'] = refreshed;
371
+ await persistAuthRecord('openai-codex', refreshed);
372
+ notifyRefreshOnce('openai-codex', 'pi-quota: refreshed OpenAI Codex auth');
373
+ return refreshed;
374
+ }
375
+
376
+ async function listCodexResetCredits(accessToken: string): Promise<CodexResetCreditList | null> {
377
+ try {
378
+ const response = await fetch(
379
+ 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits',
380
+ {
381
+ headers: {
382
+ Authorization: `Bearer ${accessToken}`,
383
+ 'User-Agent': 'pi-quota/1.0',
384
+ accept: 'application/json',
385
+ },
386
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
387
+ },
388
+ );
389
+
390
+ if (!response.ok) {
391
+ logError(`List reset credits failed: ${response.status}`);
392
+ return null;
393
+ }
394
+
395
+ return (await response.json()) as CodexResetCreditList;
396
+ } catch (error) {
397
+ logError('List reset credits error:', error);
398
+ return null;
399
+ }
400
+ }
401
+
402
+ async function fetchCodexSoonestResetExpiry(accessToken: string): Promise<Date | null> {
403
+ const creditList = await listCodexResetCredits(accessToken);
404
+ if (!creditList) return null;
405
+ const now = Date.now();
406
+ let soonest: Date | null = null;
407
+ for (const credit of creditList.credits) {
408
+ if (credit.status && credit.status !== 'available') continue;
409
+ const expiry = parseCreditExpiry(credit.expires_at);
410
+ if (!expiry || expiry.getTime() <= now) continue;
411
+ if (!soonest || expiry.getTime() < soonest.getTime()) soonest = expiry;
412
+ }
413
+ return soonest;
414
+ }
415
+
416
+ async function consumeCodexResetCredit(
417
+ accessToken: string,
418
+ creditId: string,
419
+ ): Promise<CodexResetConsumeResponse | null> {
420
+ try {
421
+ const redeemRequestId = crypto.randomUUID();
422
+ const response = await fetch(
423
+ 'https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume',
424
+ {
425
+ method: 'POST',
426
+ headers: {
427
+ Authorization: `Bearer ${accessToken}`,
428
+ 'User-Agent': 'pi-quota/1.0',
429
+ 'Content-Type': 'application/json',
430
+ accept: 'application/json',
431
+ },
432
+ body: JSON.stringify({
433
+ credit_id: creditId,
434
+ redeem_request_id: redeemRequestId,
435
+ }),
436
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
437
+ },
438
+ );
439
+
440
+ if (!response.ok) {
441
+ logError(`Consume reset credit failed: ${response.status}`);
442
+ return null;
443
+ }
444
+
445
+ return (await response.json()) as CodexResetConsumeResponse;
446
+ } catch (error) {
447
+ logError('Consume reset credit error:', error);
448
+ return null;
449
+ }
450
+ }
451
+
452
+ async function pollQuotaStatus() {
453
+ try {
454
+ const auth = await loadAuth();
455
+ if (!auth) return;
456
+
457
+ const anthropicAuth = await ensureAnthropicAccess(auth);
458
+ if (anthropicAuth?.access) {
459
+ const response = await fetch('https://api.anthropic.com/api/oauth/usage', {
460
+ headers: {
461
+ Authorization: `Bearer ${anthropicAuth.access}`,
462
+ 'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
463
+ accept: 'application/json',
464
+ },
465
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
466
+ });
467
+
468
+ if (response.ok) {
469
+ const data = (await response.json()) as AnthropicUsageResponse;
470
+ const fiveHour = data.five_hour;
471
+ const sevenDay = data.seven_day;
472
+ updateState({
473
+ provider: 'anthropic',
474
+ fiveHourRemaining: fiveHour ? clampPercent(100 - (fiveHour.utilization ?? 0)) : null,
475
+ fiveHourReset: fiveHour?.resets_at ? new Date(fiveHour.resets_at) : null,
476
+ sevenDayRemaining: sevenDay ? clampPercent(100 - (sevenDay.utilization ?? 0)) : null,
477
+ sevenDayReset: sevenDay?.resets_at ? new Date(sevenDay.resets_at) : null,
478
+ resetsAvailable: 0,
479
+ resetSoonestExpiry: null,
480
+ lastUpdated: new Date(),
481
+ });
482
+ } else {
483
+ logError(`Anthropic usage request failed: ${response.status}`);
484
+ }
485
+ }
486
+
487
+ const openaiAuth = await ensureOpenAIAccess(auth);
488
+ if (openaiAuth?.access) {
489
+ const response = await fetch('https://chatgpt.com/backend-api/wham/usage', {
490
+ headers: {
491
+ Authorization: `Bearer ${openaiAuth.access}`,
492
+ 'User-Agent': 'pi-quota/1.0',
493
+ accept: 'application/json',
494
+ },
495
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
496
+ });
497
+
498
+ if (response.ok) {
499
+ const data = (await response.json()) as OpenAIUsageResponse;
500
+ if (data.rate_limit) {
501
+ const primary = data.rate_limit.primary_window;
502
+ const secondary = data.rate_limit.secondary_window;
503
+ // During OpenAI's temporary 5h-quota removal, the only returned
504
+ // window is the weekly quota, still named `primary_window`.
505
+ const fiveHour = secondary ? primary : undefined;
506
+ const sevenDay = secondary ?? primary;
507
+ const resetsAvailable = data.rate_limit_reset_credits?.available_count;
508
+ const resetCount =
509
+ resetsAvailable !== undefined ? Math.max(0, Math.trunc(resetsAvailable)) : 0;
510
+ updateState({
511
+ provider: 'openai-codex',
512
+ fiveHourRemaining: fiveHour ? clampPercent(100 - (fiveHour.used_percent ?? 0)) : null,
513
+ fiveHourReset: fiveHour?.reset_at ? new Date(fiveHour.reset_at * 1000) : null,
514
+ sevenDayRemaining: sevenDay ? clampPercent(100 - (sevenDay.used_percent ?? 0)) : null,
515
+ sevenDayReset: sevenDay?.reset_at ? new Date(sevenDay.reset_at * 1000) : null,
516
+ resetsAvailable: resetCount,
517
+ resetSoonestExpiry:
518
+ resetCount > 0 ? await fetchCodexSoonestResetExpiry(openaiAuth.access) : null,
519
+ lastUpdated: new Date(),
520
+ });
521
+ }
522
+ } else {
523
+ logError(`OpenAI Codex usage request failed: ${response.status}`);
524
+ }
525
+ }
526
+ } catch (error) {
527
+ logError('Poll error:', error);
528
+ }
529
+ }
530
+
531
+ async function tryAutoRedeemCodexReset() {
532
+ const config = await loadConfig();
533
+ if (!config?.codexResets?.autoRedeem) return;
534
+
535
+ const codexState = states.find((s) => s.provider === 'openai-codex');
536
+ if (!codexState) return;
537
+
538
+ // Reset the flag when the weekly window recovers
539
+ if (codexState.sevenDayRemaining !== null && codexState.sevenDayRemaining > 0) {
540
+ codexRedeemAttempted = false;
541
+ }
542
+
543
+ // Only redeem if weekly is exhausted and we haven't tried yet
544
+ if (codexState.sevenDayRemaining !== 0) return;
545
+ if (codexState.resetsAvailable === 0) return;
546
+ if (codexRedeemAttempted) return;
547
+
548
+ codexRedeemAttempted = true;
549
+
550
+ const auth = await loadAuth();
551
+ if (!auth) return;
552
+
553
+ const openaiAuth = await ensureOpenAIAccess(auth);
554
+ if (!openaiAuth?.access) return;
555
+
556
+ ctxRef?.ui.notify('pi-quota: weekly limit exhausted, redeeming saved reset...', 'info');
557
+
558
+ const creditList = await listCodexResetCredits(openaiAuth.access);
559
+ if (!creditList || creditList.credits.length === 0) {
560
+ logError('No reset credits available despite reported count');
561
+ return;
562
+ }
563
+
564
+ const availableCredit = creditList.credits.find((c) => c.status === 'available' || !c.status);
565
+ if (!availableCredit) {
566
+ logError('No available reset credit found');
567
+ return;
568
+ }
569
+
570
+ const result = await consumeCodexResetCredit(openaiAuth.access, availableCredit.id);
571
+ if (!result) {
572
+ ctxRef?.ui.notify('pi-quota: failed to redeem reset', 'warning');
573
+ return;
574
+ }
575
+
576
+ if (result.code === 'reset') {
577
+ ctxRef?.ui.notify('pi-quota: saved reset redeemed successfully', 'info');
578
+ await pollQuotaStatus();
579
+ updateStatus();
580
+ } else {
581
+ logError(`Reset redeem returned code: ${result.code}`);
582
+ ctxRef?.ui.notify(`pi-quota: reset redeem failed (${result.code})`, 'warning');
583
+ }
584
+ }
585
+
586
+ async function runCycle() {
587
+ if (cycleRunning) return;
588
+ cycleRunning = true;
589
+ try {
590
+ await pollQuotaStatus();
591
+ await tryAutoRedeemCodexReset();
592
+ updateStatus();
593
+ } catch (error) {
594
+ await logError('Run cycle error:', error);
595
+ } finally {
596
+ cycleRunning = false;
597
+ }
598
+ }
599
+
600
+ function scheduleCheck() {
601
+ const next = nextMarkAfter(new Date());
602
+ const delay = Math.max(0, next.getTime() - Date.now());
603
+ checkTimer = setTimeout(async () => {
604
+ await runCycle();
605
+ scheduleCheck();
606
+ }, delay);
607
+ }
608
+
609
+ pi.on('session_start', (_event, ctx) => {
610
+ if (checkTimer) {
611
+ clearTimeout(checkTimer);
612
+ checkTimer = null;
613
+ }
614
+ states.length = 0;
615
+ refreshNotified.clear();
616
+ ctxRef = ctx;
617
+ codexRedeemAttempted = false;
618
+
619
+ // Do not await: quota polling is network-bound and would block startup.
620
+ void runCycle();
621
+ scheduleCheck();
622
+ });
623
+
624
+ // The status tracks the active model's provider, so re-render on switch
625
+ // rather than waiting for the next poll (up to 10 minutes away).
626
+ pi.on('model_select', (_event, ctx) => {
627
+ ctxRef = ctx;
628
+ updateStatus();
629
+ });
630
+
631
+ pi.on('session_shutdown', async () => {
632
+ if (checkTimer) {
633
+ clearTimeout(checkTimer);
634
+ checkTimer = null;
635
+ }
636
+ });
637
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-quota",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "Anthropic and OpenAI Codex quota status extension for pi",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ravshansbox/pi-quota.git"
10
+ },
11
+ "homepage": "https://github.com/ravshansbox/pi-quota#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/ravshansbox/pi-quota/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "pi-extension",
18
+ "quota"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "./index.ts"
23
+ ]
24
+ },
25
+ "scripts": {
26
+ "typecheck": "tsc --noEmit",
27
+ "lint": "eslint .",
28
+ "lint:fix": "eslint . --fix",
29
+ "format": "prettier --write .",
30
+ "format:check": "prettier --check .",
31
+ "check": "npm run typecheck && npm run lint && npm run format:check"
32
+ },
33
+ "peerDependencies": {
34
+ "@earendil-works/pi-coding-agent": "*"
35
+ },
36
+ "devDependencies": {
37
+ "@eslint/js": "^10.0.1",
38
+ "@types/node": "^26.6.4",
39
+ "eslint": "^10.12.0",
40
+ "eslint-config-prettier": "^10.1.8",
41
+ "prettier": "^3.9.9",
42
+ "typescript": "^6.0.3",
43
+ "typescript-eslint": "^8.71.1"
44
+ },
45
+ "files": [
46
+ "index.ts"
47
+ ],
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }