@henryqw/pi-multi-codex 1.0.3 → 1.0.5

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/README.md CHANGED
@@ -13,11 +13,11 @@ pi install npm:@henryqw/pi-multi-codex
13
13
 
14
14
  ## Works with
15
15
 
16
- | Package | Why |
17
- | --- | --- |
18
- | [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves. Shows the active slot's quota or five-hour block in the footer. |
19
- | [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves. Isolated children keep Main's active Codex slot. |
20
- | [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves. Numbered slots share one profile route. |
16
+ | Package | Relationship | Purpose |
17
+ | --- | --- | --- |
18
+ | [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) | Improves | Shows the active slot's quota or five-hour block in the footer. |
19
+ | [`@henryqw/pi-subagent`](https://pi.henry.wang/extensions/pi-subagent) | Improves | Isolated children keep Main's active Codex slot. |
20
+ | [`@henryqw/pi-task-models`](https://pi.henry.wang/extensions/pi-task-models) | Improves | Numbered slots share one profile route. |
21
21
 
22
22
  ## Use
23
23
 
@@ -25,6 +25,8 @@ Run `/login` and authenticate `OpenAI Codex` for slot 1 first. Run `/codex-add`,
25
25
 
26
26
  `/codex-status` lists the new slot. It shows cached quota when available, or `unavailable` until the first successful snapshot.
27
27
 
28
+ The footer and `/codex-status` show the five-hour reset countdown for Free, Go, and Plus tiers. They show the seven-day reset countdown for Pro Lite and other tiers.
29
+
28
30
  | Surface | Type | Purpose |
29
31
  | --- | --- | --- |
30
32
  | `/codex-add` | command | Create the next numbered slot, then authenticate that slot. |
@@ -47,24 +49,20 @@ A numbered slot is one Codex account position in Pi.
47
49
 
48
50
  ## Config
49
51
 
50
- Automatic HTTP 429 switching is on by default. To disable it, create `~/.pi/agent/config/pi-multi-codex/config.json`:
52
+ Package-owned: `~/.pi/agent/config/pi-multi-codex/config.json`
51
53
 
52
- ```json
53
- {
54
- "autoSwitchOn429": false
55
- }
56
- ```
54
+ | Name | Description | Values | Default |
55
+ | --- | --- | --- | --- |
56
+ | `autoSwitchOn429` | Switches to another eligible slot after an HTTP 429. | Boolean. | `true` |
57
57
 
58
- The config must contain only `autoSwitchOn429` as a boolean. Invalid config is preserved and disables automatic switching.
58
+ Set `autoSwitchOn429` to `false` to disable automatic switching. The config must contain only this field as a boolean. Invalid config is preserved and disables automatic switching.
59
59
 
60
60
  ## State and storage
61
61
 
62
62
  The extension maintains a generated, credential-free quota cache at `~/.pi/agent/config/pi-multi-codex/usage.json`.
63
63
 
64
- ## Data, cost, and privacy
64
+ ## Limits and recovery
65
65
 
66
66
  The extension reads `auth.json`. It never writes or refreshes credentials.
67
67
 
68
- ## Limits and recovery
69
-
70
68
  Scoped sessions can switch only to exact scoped aliases.
@@ -1,9 +1,9 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { readFileSync } from "node:fs";
3
- import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
- import { createConfigStore, extensionConfigDir } from "@henryqw/pi-config-store";
6
+ import { createConfigStore, extensionConfigDir, writePrivateTextFileAtomically } from "@henryqw/pi-config-store";
7
7
  import { lock } from "proper-lockfile";
8
8
  import {
9
9
  createAssistantMessageEventStream,
@@ -54,6 +54,7 @@ type UsageSnapshot = {
54
54
  fetchedAt?: number;
55
55
  remaining?: number;
56
56
  reset?: number;
57
+ fiveHourReset?: number;
57
58
  limitedUntil?: number | null;
58
59
  };
59
60
 
@@ -70,7 +71,7 @@ type UsageState = {
70
71
  untrusted?: true;
71
72
  };
72
73
 
73
- type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
74
+ type ParsedUsage = { remaining: number; reset: number; fiveHourReset?: number; limitedUntil?: number; tier?: string };
74
75
  type Config = { autoSwitchOn429: boolean };
75
76
 
76
77
  const NATIVE_PROVIDER_ID = "openai-codex";
@@ -187,9 +188,10 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
187
188
  typeof value.remaining !== "number" ||
188
189
  !Number.isFinite(value.remaining) ||
189
190
  !validTime(value.reset) ||
191
+ (value.fiveHourReset !== undefined && !validTime(value.fiveHourReset)) ||
190
192
  (value.limitedUntil !== null && !validTime(value.limitedUntil))
191
193
  )) return undefined;
192
- if (!successful && value.limitedUntil !== undefined) return undefined;
194
+ if (!successful && (value.fiveHourReset !== undefined || value.limitedUntil !== undefined)) return undefined;
193
195
  return {
194
196
  slot: value.slot,
195
197
  accountHash: value.accountHash,
@@ -200,6 +202,7 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
200
202
  fetchedAt: value.fetchedAt as number,
201
203
  remaining: Math.max(0, Math.min(100, value.remaining as number)),
202
204
  reset: value.reset as number,
205
+ ...(validTime(value.fiveHourReset) ? { fiveHourReset: value.fiveHourReset } : {}),
203
206
  limitedUntil: value.limitedUntil as number | null,
204
207
  }
205
208
  : {}),
@@ -258,22 +261,11 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
258
261
  signal?.throwIfAborted();
259
262
  if (state.untrusted) return;
260
263
  const file = cachePath();
261
- const directory = extensionConfigDir("pi-multi-codex");
262
- await mkdir(directory, { recursive: true, mode: 0o700 });
263
264
  const data = `${JSON.stringify({
264
265
  slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
265
266
  locks: [...state.locks.values()].sort((a, b) => a.slot - b.slot),
266
267
  })}\n`;
267
- const temporary = `${file}.${randomUUID()}.tmp`;
268
- try {
269
- await writeFile(temporary, data, { encoding: "utf8", mode: 0o600, flag: "wx", signal });
270
- signal?.throwIfAborted();
271
- await rename(temporary, file);
272
- signal?.throwIfAborted();
273
- await chmod(file, 0o600);
274
- } finally {
275
- await rm(temporary, { force: true }).catch(() => undefined);
276
- }
268
+ await writePrivateTextFileAtomically(file, data, { signal });
277
269
  }
278
270
 
279
271
  async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
@@ -323,6 +315,13 @@ function isFiveHourLimited(snapshot: UsageSnapshot, now: number): boolean {
323
315
  return validTime(snapshot.limitedUntil) && snapshot.limitedUntil > now;
324
316
  }
325
317
 
318
+ function displayedReset(snapshot: UsageSnapshot, now: number): { label: "5h" | "7d"; time: number } {
319
+ if (["free", "go", "plus"].includes(snapshot.tier ?? "") && validTime(snapshot.fiveHourReset) && snapshot.fiveHourReset > now) {
320
+ return { label: "5h", time: snapshot.fiveHourReset };
321
+ }
322
+ return { label: "7d", time: snapshot.reset! };
323
+ }
324
+
326
325
  function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
327
326
  return Boolean(
328
327
  snapshot &&
@@ -352,7 +351,7 @@ function resetTime(window: JsonRecord, now: number): number | undefined {
352
351
  return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
353
352
  }
354
353
 
355
- /** Extract seven-day quota and any active five-hour block. */
354
+ /** Extract seven-day quota and the five-hour window. */
356
355
  export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
357
356
  if (!isRecord(value)) return undefined;
358
357
  const rateLimit = isRecord(value.rate_limit) ? value.rate_limit : value;
@@ -366,11 +365,13 @@ export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage |
366
365
  ?? (windows.length === 1 && numberValue(windows[0].value.limit_window_seconds) === undefined ? windows[0] : undefined);
367
366
  if (!sevenDay || sevenDay.reset <= now) return undefined;
368
367
  const fiveHour = windows.find((window) => numberValue(window.value.limit_window_seconds) === 5 * 60 * 60);
369
- const limitedUntil = fiveHour && fiveHour.used >= 100 && fiveHour.reset > now ? fiveHour.reset : undefined;
368
+ const fiveHourReset = fiveHour && fiveHour.reset > now ? fiveHour.reset : undefined;
369
+ const limitedUntil = fiveHour && fiveHour.used >= 100 ? fiveHourReset : undefined;
370
370
  const tier = validTier(value.plan_type) ? value.plan_type : validTier(value.tier) ? value.tier : undefined;
371
371
  return {
372
372
  remaining: Math.max(0, Math.min(100, 100 - sevenDay.used)),
373
373
  reset: sevenDay.reset,
374
+ ...(fiveHourReset ? { fiveHourReset } : {}),
374
375
  ...(limitedUntil ? { limitedUntil } : {}),
375
376
  ...(tier ? { tier } : {}),
376
377
  };
@@ -609,6 +610,7 @@ class CodexQuotaStatus {
609
610
  fetchedAt: checkedAt,
610
611
  remaining: outcome.remaining,
611
612
  reset: outcome.reset,
613
+ ...(outcome.fiveHourReset && outcome.fiveHourReset > checkedAt ? { fiveHourReset: outcome.fiveHourReset } : {}),
612
614
  limitedUntil: outcome.limitedUntil && outcome.limitedUntil > checkedAt ? outcome.limitedUntil : null,
613
615
  });
614
616
  } else {
@@ -691,7 +693,9 @@ class CodexQuotaStatus {
691
693
  if (isFiveHourLimited(snapshot, now)) {
692
694
  return `Codex slot ${slot}${tier}: five-hour limit reached, resets in ${formatDuration(snapshot.limitedUntil! - now)} (${status})`;
693
695
  }
694
- return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
696
+ const display = displayedReset(snapshot, now);
697
+ const resetWindow = display.label === "5h" ? "five-hour" : "seven-day";
698
+ return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% of the seven-day quota remaining; ${resetWindow} reset in ${formatDuration(display.time - now)} (${status})`;
695
699
  });
696
700
  }
697
701
  }
@@ -914,7 +918,8 @@ export default function multiCodex(pi: ExtensionAPI): void {
914
918
  return ctx.ui.theme?.fg ? ctx.ui.theme.fg("error", text) : text;
915
919
  }
916
920
  if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
917
- const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - now)}`;
921
+ const display = displayedReset(snapshot, now);
922
+ const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · ${display.label} ${formatDuration(display.time - now)}`;
918
923
  const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
919
924
  return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
920
925
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-multi-codex",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -46,7 +46,7 @@
46
46
  "image": "https://raw.githubusercontent.com/HenryQW/pi-harness/main/extensions/pi-multi-codex/example.png"
47
47
  },
48
48
  "dependencies": {
49
- "@henryqw/pi-config-store": "^1.0.0",
49
+ "@henryqw/pi-config-store": "^1.1.0",
50
50
  "proper-lockfile": "^4.1.2"
51
51
  },
52
52
  "devDependencies": {