@ravshansbox/pi-quota 0.3.1 → 0.4.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 (3) hide show
  1. package/README.md +14 -3
  2. package/index.ts +21 -34
  3. package/package.json +8 -11
package/README.md CHANGED
@@ -8,13 +8,14 @@ Anthropic and OpenAI Codex quota status extension for pi.
8
8
  pi install npm:@ravshansbox/pi-quota
9
9
  ```
10
10
 
11
+ Add `-l` to install it in project settings.
12
+
11
13
  ## Usage
12
14
 
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.
15
+ Pi loads the extension from `./index.ts` and shows the remaining quota for the provider backing the active model above the editor.
14
16
 
15
17
  - 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
+ - Shows a widget above the editor for the provider backing the **active model**, and only when quota data for that provider has been polled; switching models re-renders it immediately
18
19
  - The text stays compact, e.g. `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
20
  - Available resets, when present, are appended as `· 2x 5h` (count, then time until the soonest expires)
20
21
  - Both providers are still polled whenever their credentials exist, so quota is already fresh when the active model changes
@@ -25,6 +26,16 @@ Pi loads the extension from `./index.ts` and shows the remaining quota for the p
25
26
 
26
27
  No configuration is required. OAuth credentials for Anthropic and OpenAI Codex are read from `~/.pi/agent/auth.json`.
27
28
 
29
+ To redeem a saved OpenAI Codex reset automatically when the 7-day window is exhausted, add this to `~/.pi/agent/settings.json`:
30
+
31
+ ```json
32
+ {
33
+ "quota": {
34
+ "codexResets": { "autoRedeem": true }
35
+ }
36
+ }
37
+ ```
38
+
28
39
  ## Development
29
40
 
30
41
  ```bash
package/index.ts CHANGED
@@ -1,16 +1,11 @@
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
1
  import { spawn } from 'node:child_process';
10
2
  import { readFile, writeFile, appendFile } from 'node:fs/promises';
11
3
  import { join } from 'node:path';
12
- import { homedir } from 'node:os';
13
- import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
4
+ import {
5
+ getAgentDir,
6
+ type ExtensionAPI,
7
+ type ExtensionContext,
8
+ } from '@earendil-works/pi-coding-agent';
14
9
 
15
10
  interface QuotaConfig {
16
11
  codexResets?: { autoRedeem?: boolean };
@@ -71,12 +66,12 @@ type CodexResetCreditList = {
71
66
 
72
67
  type CodexResetConsumeResponse = { code?: string };
73
68
 
74
- const MODEL_PROVIDER_NAMES: Record<QuotaState['provider'], string> = {
69
+ const modelProviderNames: Record<QuotaState['provider'], string> = {
75
70
  anthropic: 'anthropic',
76
71
  'openai-codex': 'openai',
77
72
  };
78
73
 
79
- const REQUEST_TIMEOUT_MS = 30_000;
74
+ const requestTimeoutMs = 30_000;
80
75
 
81
76
  function nextMarkAfter(time: Date): Date {
82
77
  const next = new Date(time.getTime());
@@ -119,7 +114,7 @@ function formatResetTime(reset: Date): string {
119
114
 
120
115
  async function loadConfig(): Promise<QuotaConfig | null> {
121
116
  try {
122
- const settingsPath = join(homedir(), '.pi', 'agent', 'settings.json');
117
+ const settingsPath = join(getAgentDir(), 'settings.json');
123
118
  const settings = JSON.parse(await readFile(settingsPath, 'utf-8'));
124
119
  return settings.quota ?? null;
125
120
  } catch {
@@ -128,7 +123,7 @@ async function loadConfig(): Promise<QuotaConfig | null> {
128
123
  }
129
124
 
130
125
  function authPath() {
131
- return join(homedir(), '.pi', 'agent', 'auth.json');
126
+ return join(getAgentDir(), 'auth.json');
132
127
  }
133
128
 
134
129
  async function loadAuth(): Promise<AuthFile | null> {
@@ -150,7 +145,7 @@ async function persistAuthRecord(provider: string, record: OAuthAuthRecord): Pro
150
145
  }
151
146
 
152
147
  function logPath() {
153
- return join(homedir(), '.pi', 'agent', 'pi-quota.log');
148
+ return join(getAgentDir(), 'pi-quota.log');
154
149
  }
155
150
 
156
151
  async function logError(message: string, error?: unknown): Promise<void> {
@@ -183,11 +178,9 @@ export default function (pi: ExtensionAPI) {
183
178
  }
184
179
 
185
180
  function buildStatusText(): string | undefined {
186
- // Only ever render the provider backing the active model; anything else
187
- // would not apply to the next request.
188
181
  const activeProvider = ctxRef?.model?.provider;
189
182
  if (!activeProvider) return undefined;
190
- const state = states.find((s) => MODEL_PROVIDER_NAMES[s.provider] === activeProvider);
183
+ const state = states.find((s) => modelProviderNames[s.provider] === activeProvider);
191
184
 
192
185
  if (!state) return undefined;
193
186
 
@@ -214,9 +207,10 @@ export default function (pi: ExtensionAPI) {
214
207
 
215
208
  const text = buildStatusText();
216
209
 
217
- ctxRef.ui.setStatus(
210
+ ctxRef.ui.setWidget(
218
211
  'pi-quota',
219
- text === undefined ? undefined : ctxRef.ui.theme.fg('muted', text),
212
+ text === undefined ? undefined : [ctxRef.ui.theme.fg('muted', text)],
213
+ { placement: 'aboveEditor' },
220
214
  );
221
215
  }
222
216
 
@@ -252,7 +246,7 @@ export default function (pi: ExtensionAPI) {
252
246
  function pingProviderOnReset(provider: QuotaState['provider']) {
253
247
  const ctx = ctxRef;
254
248
  if (!ctx?.model) return;
255
- if (ctx.model.provider !== MODEL_PROVIDER_NAMES[provider]) return;
249
+ if (ctx.model.provider !== modelProviderNames[provider]) return;
256
250
 
257
251
  const child = spawn(
258
252
  'pi',
@@ -313,7 +307,7 @@ export default function (pi: ExtensionAPI) {
313
307
  refresh_token: record.refresh,
314
308
  client_id: '9d1c250a-e61b-44d9-88ed-5944d1962f5e',
315
309
  }),
316
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
310
+ signal: AbortSignal.timeout(requestTimeoutMs),
317
311
  });
318
312
 
319
313
  if (!response.ok) {
@@ -347,7 +341,7 @@ export default function (pi: ExtensionAPI) {
347
341
  refresh_token: record.refresh,
348
342
  client_id: 'app_EMoamEEZ73f0CkXaXp7hrann',
349
343
  }),
350
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
344
+ signal: AbortSignal.timeout(requestTimeoutMs),
351
345
  });
352
346
 
353
347
  if (!response.ok) {
@@ -378,7 +372,7 @@ export default function (pi: ExtensionAPI) {
378
372
  'User-Agent': 'pi-quota/1.0',
379
373
  accept: 'application/json',
380
374
  },
381
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
375
+ signal: AbortSignal.timeout(requestTimeoutMs),
382
376
  },
383
377
  );
384
378
 
@@ -428,7 +422,7 @@ export default function (pi: ExtensionAPI) {
428
422
  credit_id: creditId,
429
423
  redeem_request_id: redeemRequestId,
430
424
  }),
431
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
425
+ signal: AbortSignal.timeout(requestTimeoutMs),
432
426
  },
433
427
  );
434
428
 
@@ -457,7 +451,7 @@ export default function (pi: ExtensionAPI) {
457
451
  'anthropic-beta': 'claude-code-20250219,oauth-2025-04-20',
458
452
  accept: 'application/json',
459
453
  },
460
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
454
+ signal: AbortSignal.timeout(requestTimeoutMs),
461
455
  });
462
456
 
463
457
  if (response.ok) {
@@ -487,7 +481,7 @@ export default function (pi: ExtensionAPI) {
487
481
  'User-Agent': 'pi-quota/1.0',
488
482
  accept: 'application/json',
489
483
  },
490
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
484
+ signal: AbortSignal.timeout(requestTimeoutMs),
491
485
  });
492
486
 
493
487
  if (response.ok) {
@@ -495,8 +489,6 @@ export default function (pi: ExtensionAPI) {
495
489
  if (data.rate_limit) {
496
490
  const primary = data.rate_limit.primary_window;
497
491
  const secondary = data.rate_limit.secondary_window;
498
- // During OpenAI's temporary 5h-quota removal, the only returned
499
- // window is the weekly quota, still named `primary_window`.
500
492
  const fiveHour = secondary ? primary : undefined;
501
493
  const sevenDay = secondary ?? primary;
502
494
  const resetsAvailable = data.rate_limit_reset_credits?.available_count;
@@ -530,12 +522,10 @@ export default function (pi: ExtensionAPI) {
530
522
  const codexState = states.find((s) => s.provider === 'openai-codex');
531
523
  if (!codexState) return;
532
524
 
533
- // Reset the flag when the weekly window recovers
534
525
  if (codexState.sevenDayRemaining !== null && codexState.sevenDayRemaining > 0) {
535
526
  codexRedeemAttempted = false;
536
527
  }
537
528
 
538
- // Only redeem if weekly is exhausted and we haven't tried yet
539
529
  if (codexState.sevenDayRemaining !== 0) return;
540
530
  if (codexState.resetsAvailable === 0) return;
541
531
  if (codexRedeemAttempted) return;
@@ -611,13 +601,10 @@ export default function (pi: ExtensionAPI) {
611
601
  ctxRef = ctx;
612
602
  codexRedeemAttempted = false;
613
603
 
614
- // Do not await: quota polling is network-bound and would block startup.
615
604
  void runCycle();
616
605
  scheduleCheck();
617
606
  });
618
607
 
619
- // The status tracks the active model's provider, so re-render on switch
620
- // rather than waiting for the next poll (up to 10 minutes away).
621
608
  pi.on('model_select', (_event, ctx) => {
622
609
  ctxRef = ctx;
623
610
  updateStatus();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ravshansbox/pi-quota",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Anthropic and OpenAI Codex quota status extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -24,23 +24,20 @@
24
24
  },
25
25
  "scripts": {
26
26
  "typecheck": "tsc --noEmit",
27
- "lint": "eslint .",
28
- "lint:fix": "eslint . --fix",
29
- "format": "prettier --write .",
30
- "format:check": "prettier --check .",
27
+ "lint": "oxlint --deny-warnings",
28
+ "lint:fix": "oxlint --fix",
29
+ "format": "oxfmt",
30
+ "format:check": "oxfmt --check",
31
31
  "check": "npm run typecheck && npm run lint && npm run format:check"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "@earendil-works/pi-coding-agent": "*"
35
35
  },
36
36
  "devDependencies": {
37
- "@eslint/js": "^10.0.1",
38
37
  "@types/node": "^26.6.5",
39
- "eslint": "^10.13.0",
40
- "eslint-config-prettier": "^10.1.8",
41
- "prettier": "^3.9.10",
42
- "typescript": "^6.0.3",
43
- "typescript-eslint": "^8.72.0"
38
+ "oxfmt": "^0.74.0",
39
+ "oxlint": "^1.89.0",
40
+ "typescript": "^7.0.2"
44
41
  },
45
42
  "files": [
46
43
  "index.ts"