@henryqw/pi-multi-codex 0.3.16 → 0.3.18

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
@@ -4,8 +4,8 @@ Use multiple ChatGPT Codex OAuth accounts in Pi.
4
4
 
5
5
  ## Why
6
6
 
7
- - **Created for**: Using multiple ChatGPT Codex OAuth accounts from one Pi installation.
8
- - **Advantage**: Before work starts, quota-aware routing selects the managed slot with the most remaining seven-day quota without changing in-progress sessions.
7
+ - **Created for**: Use multiple ChatGPT Codex OAuth accounts from one Pi installation.
8
+ - **Advantage**: Before work starts, quota-aware routing selects the eligible managed slot with the most remaining seven-day quota. It does not change in-progress sessions.
9
9
 
10
10
  ## Install
11
11
 
@@ -17,21 +17,26 @@ pi install npm:@henryqw/pi-multi-codex
17
17
 
18
18
  | Package | Why |
19
19
  | --- | --- |
20
- | `@henryqw/pi-task-models` | Improves. Numbered slots share one profile route. |
20
+ | `@henryqw/pi-footer` | Improves. Shows the active slot's quota or five-hour block in the footer. |
21
21
  | `@henryqw/pi-subagent` | Improves. Isolated children keep Main's active Codex slot. |
22
+ | `@henryqw/pi-task-models` | Improves. Numbered slots share one profile route. |
22
23
 
23
24
  ## Use
24
25
 
25
26
  | Surface | Type | Purpose |
26
27
  | --- | --- | --- |
27
28
  | `/codex-add` | command | Create the next numbered slot, then authenticate that slot. |
28
- | `/codex-status` | command | Show shared seven-day quota snapshots. Never waits on network. |
29
+ | `/codex-status` | command | Show shared quota snapshots and five-hour blocks. Never waits on network. |
29
30
  | `/codex-switch` | command | Pick an authenticated slot. |
30
31
 
31
- Extension reads `auth.json` and never writes or refreshes credentials. Before the first agent start, a fresh snapshot routes a managed Codex model to the slot with the most seven-day quota. Routing preserves model ID and never changes in-progress work. Footer shows the active slot and fresh seven-day quota. Scoped sessions can switch only to exact scoped aliases; restart or update model scope after adding a slot.
32
+ A numbered slot is one Codex account position in Pi.
32
33
 
33
- ## State
34
+ - The extension reads `auth.json`. It never writes or refreshes credentials.
35
+ - Before the first agent start, a fresh snapshot routes a managed Codex model to the eligible slot with the most seven-day quota.
36
+ - A slot whose reported five-hour window has reached 100% is excluded until that window resets.
37
+ - Routing preserves model ID and never changes in-progress work.
38
+ - The footer shows the active slot's fresh quota or five-hour block.
39
+ - Scoped sessions can switch only to exact scoped aliases.
40
+ - Restart or update model scope after adding a slot.
34
41
 
35
- | Path | Purpose |
36
- | --- | --- |
37
- | `~/.pi/agent/config/pi-multi-codex/usage.json` | Generated credential-free quota cache, maintained by the extension. |
42
+ Generated credential-free quota cache: `~/.pi/agent/config/pi-multi-codex/usage.json`. The extension maintains it.
@@ -3,6 +3,7 @@ import { readFileSync } from "node:fs";
3
3
  import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { setTimeout as delay } from "node:timers/promises";
6
+ import { extensionConfigDir } from "@henryqw/pi-config-store";
6
7
  import { lock } from "proper-lockfile";
7
8
  import {
8
9
  createAssistantMessageEventStream,
@@ -53,6 +54,7 @@ type UsageSnapshot = {
53
54
  fetchedAt?: number;
54
55
  remaining?: number;
55
56
  reset?: number;
57
+ limitedUntil?: number | null;
56
58
  };
57
59
 
58
60
  type UsageLock = {
@@ -65,9 +67,10 @@ type UsageLock = {
65
67
  type UsageState = {
66
68
  slots: Map<number, UsageSnapshot>;
67
69
  locks: Map<number, UsageLock>;
70
+ untrusted?: true;
68
71
  };
69
72
 
70
- type ParsedUsage = { remaining: number; reset: number; tier?: string };
73
+ type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
71
74
 
72
75
  const NATIVE_PROVIDER_ID = "openai-codex";
73
76
  const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
@@ -80,7 +83,7 @@ const HEARTBEAT_MS = 5_000;
80
83
  const OPERATION_TIMEOUT_MS = 10_000;
81
84
  const CACHE_MUTEX_RETRY_MS = 20;
82
85
  const CLEANUP_TIMEOUT_MS = 250;
83
- const cachePath = () => join(getAgentDir(), "config", "pi-multi-codex", "usage.json");
86
+ const cachePath = () => join(extensionConfigDir("pi-multi-codex"), "usage.json");
84
87
  const cacheMutexPath = () => `${cachePath()}.mutex`;
85
88
 
86
89
  function isRecord(value: unknown): value is JsonRecord {
@@ -171,9 +174,14 @@ function validTier(value: unknown): value is string {
171
174
  function readSnapshot(value: unknown): UsageSnapshot | undefined {
172
175
  if (!isRecord(value) || !validSlot(value.slot) || !validHash(value.accountHash) || !validTime(value.checkedAt)) return undefined;
173
176
  const successful = value.fetchedAt !== undefined || value.remaining !== undefined || value.reset !== undefined;
174
- if (successful && (!validTime(value.fetchedAt) || typeof value.remaining !== "number" || !Number.isFinite(value.remaining) || !validTime(value.reset))) {
175
- return undefined;
176
- }
177
+ if (successful && (
178
+ !validTime(value.fetchedAt) ||
179
+ typeof value.remaining !== "number" ||
180
+ !Number.isFinite(value.remaining) ||
181
+ !validTime(value.reset) ||
182
+ (value.limitedUntil !== null && !validTime(value.limitedUntil))
183
+ )) return undefined;
184
+ if (!successful && value.limitedUntil !== undefined) return undefined;
177
185
  return {
178
186
  slot: value.slot,
179
187
  accountHash: value.accountHash,
@@ -184,6 +192,7 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
184
192
  fetchedAt: value.fetchedAt as number,
185
193
  remaining: Math.max(0, Math.min(100, value.remaining as number)),
186
194
  reset: value.reset as number,
195
+ limitedUntil: value.limitedUntil as number | null,
187
196
  }
188
197
  : {}),
189
198
  };
@@ -196,45 +205,52 @@ function readLock(value: unknown): UsageLock | undefined {
196
205
  return { slot: value.slot, owner: value.owner, accountHash: value.accountHash, heartbeatAt: value.heartbeatAt };
197
206
  }
198
207
 
199
- function parseState(value: unknown): UsageState {
200
- const state: UsageState = { slots: new Map(), locks: new Map() };
201
- if (!isRecord(value)) return state;
202
- for (const snapshot of Array.isArray(value.slots) ? value.slots : []) {
208
+ function parseState(value: unknown): UsageState | undefined {
209
+ if (!isRecord(value) || !Array.isArray(value.slots) || !Array.isArray(value.locks)) return undefined;
210
+ const state = emptyState();
211
+ for (const snapshot of value.slots) {
203
212
  const parsed = readSnapshot(snapshot);
204
- if (parsed) state.slots.set(parsed.slot, parsed);
213
+ if (!parsed || state.slots.has(parsed.slot)) return undefined;
214
+ state.slots.set(parsed.slot, parsed);
205
215
  }
206
- for (const lock of Array.isArray(value.locks) ? value.locks : []) {
216
+ for (const lock of value.locks) {
207
217
  const parsed = readLock(lock);
208
- if (parsed) state.locks.set(parsed.slot, parsed);
218
+ if (!parsed || state.locks.has(parsed.slot)) return undefined;
219
+ state.locks.set(parsed.slot, parsed);
209
220
  }
210
221
  return state;
211
222
  }
212
223
 
213
- function emptyState(): UsageState {
214
- return { slots: new Map(), locks: new Map() };
224
+ function emptyState(untrusted = false): UsageState {
225
+ return { slots: new Map(), locks: new Map(), ...(untrusted ? { untrusted: true } : {}) };
226
+ }
227
+
228
+ function isMissingCacheFile(error: unknown): boolean {
229
+ return Boolean(error && typeof error === "object" && "code" in error && String(error.code) === "ENOENT");
215
230
  }
216
231
 
217
232
  function loadStateSync(): UsageState {
218
233
  try {
219
- return parseState(JSON.parse(readFileSync(cachePath(), "utf8")));
220
- } catch {
221
- return emptyState();
234
+ return parseState(JSON.parse(readFileSync(cachePath(), "utf8"))) ?? emptyState(true);
235
+ } catch (error) {
236
+ return emptyState(!isMissingCacheFile(error));
222
237
  }
223
238
  }
224
239
 
225
240
  async function loadState(signal?: AbortSignal): Promise<UsageState> {
226
241
  try {
227
- return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal })));
242
+ return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal }))) ?? emptyState(true);
228
243
  } catch (error) {
229
244
  signal?.throwIfAborted();
230
- return emptyState();
245
+ return emptyState(!isMissingCacheFile(error));
231
246
  }
232
247
  }
233
248
 
234
249
  async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
235
250
  signal?.throwIfAborted();
251
+ if (state.untrusted) return;
236
252
  const file = cachePath();
237
- const directory = join(getAgentDir(), "config", "pi-multi-codex");
253
+ const directory = extensionConfigDir("pi-multi-codex");
238
254
  await mkdir(directory, { recursive: true, mode: 0o700 });
239
255
  const data = `${JSON.stringify({
240
256
  slots: [...state.slots.values()].sort((a, b) => a.slot - b.slot),
@@ -254,7 +270,7 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
254
270
 
255
271
  async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
256
272
  signal?.throwIfAborted();
257
- await mkdir(join(getAgentDir(), "config", "pi-multi-codex"), { recursive: true, mode: 0o700 });
273
+ await mkdir(extensionConfigDir("pi-multi-codex"), { recursive: true, mode: 0o700 });
258
274
  let release: (() => Promise<void>) | undefined;
259
275
  while (!release) {
260
276
  signal?.throwIfAborted();
@@ -295,6 +311,10 @@ function isFresh(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, no
295
311
  );
296
312
  }
297
313
 
314
+ function isFiveHourLimited(snapshot: UsageSnapshot, now: number): boolean {
315
+ return validTime(snapshot.limitedUntil) && snapshot.limitedUntil > now;
316
+ }
317
+
298
318
  function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
299
319
  return Boolean(
300
320
  snapshot &&
@@ -324,20 +344,28 @@ function resetTime(window: JsonRecord, now: number): number | undefined {
324
344
  return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
325
345
  }
326
346
 
327
- /** Extract seven-day window, or only usable window when service reports one. */
347
+ /** Extract seven-day quota and any active five-hour block. */
328
348
  export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
329
349
  if (!isRecord(value)) return undefined;
330
350
  const rateLimit = isRecord(value.rate_limit) ? value.rate_limit : value;
331
- const windows = Object.entries(rateLimit)
332
- .flatMap(([name, entry]) => (isRecord(entry) && numberValue(entry.used_percent) !== undefined && resetTime(entry, now) !== undefined ? [{ name, value: entry }] : []));
351
+ const windows = Object.values(rateLimit).flatMap((entry) => {
352
+ if (!isRecord(entry)) return [];
353
+ const used = numberValue(entry.used_percent);
354
+ const reset = resetTime(entry, now);
355
+ return used === undefined || reset === undefined ? [] : [{ value: entry, used, reset }];
356
+ });
333
357
  const sevenDay = windows.find((window) => numberValue(window.value.limit_window_seconds) === 7 * 24 * 60 * 60)
334
358
  ?? (windows.length === 1 && numberValue(windows[0].value.limit_window_seconds) === undefined ? windows[0] : undefined);
335
- if (!sevenDay) return undefined;
336
- const used = numberValue(sevenDay.value.used_percent);
337
- const reset = resetTime(sevenDay.value, now);
338
- if (used === undefined || reset === undefined || reset <= now) return undefined;
359
+ if (!sevenDay || sevenDay.reset <= now) return undefined;
360
+ const fiveHour = windows.find((window) => numberValue(window.value.limit_window_seconds) === 5 * 60 * 60);
361
+ const limitedUntil = fiveHour && fiveHour.used >= 100 && fiveHour.reset > now ? fiveHour.reset : undefined;
339
362
  const tier = validTier(value.plan_type) ? value.plan_type : validTier(value.tier) ? value.tier : undefined;
340
- return { remaining: Math.max(0, Math.min(100, 100 - used)), reset, ...(tier ? { tier } : {}) };
363
+ return {
364
+ remaining: Math.max(0, Math.min(100, 100 - sevenDay.used)),
365
+ reset: sevenDay.reset,
366
+ ...(limitedUntil ? { limitedUntil } : {}),
367
+ ...(tier ? { tier } : {}),
368
+ };
341
369
  }
342
370
 
343
371
  function raceWithSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
@@ -392,11 +420,12 @@ class CodexQuotaStatus {
392
420
  return loadState();
393
421
  }
394
422
 
395
- private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T> {
423
+ private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T | undefined> {
396
424
  const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
397
425
  operationSignal.throwIfAborted();
398
426
  const state = await loadState(operationSignal);
399
427
  operationSignal.throwIfAborted();
428
+ if (state.untrusted) return undefined;
400
429
  const result = change(state);
401
430
  if (result.write) {
402
431
  operationSignal.throwIfAborted();
@@ -441,6 +470,7 @@ class CodexQuotaStatus {
441
470
 
442
471
  const now = Date.now();
443
472
  const state = loadStateSync();
473
+ if (state.untrusted) return;
444
474
  let dueAt: number | undefined;
445
475
  for (const [slot, credential] of readCodexCredentials()) {
446
476
  if (this.active.has(slot)) continue;
@@ -571,6 +601,7 @@ class CodexQuotaStatus {
571
601
  fetchedAt: checkedAt,
572
602
  remaining: outcome.remaining,
573
603
  reset: outcome.reset,
604
+ limitedUntil: outcome.limitedUntil && outcome.limitedUntil > checkedAt ? outcome.limitedUntil : null,
574
605
  });
575
606
  } else {
576
607
  state.slots.set(slot, previous?.accountHash === identity.accountHash && (!validTime(previous.reset) || previous.reset > checkedAt)
@@ -581,7 +612,7 @@ class CodexQuotaStatus {
581
612
  return { value: true, write: true };
582
613
  }, refresh.session.signal);
583
614
  if (finished) this.onChange();
584
- return finished;
615
+ return finished ?? false;
585
616
  }
586
617
 
587
618
  private async release(slot: number, owner: string, signal: AbortSignal): Promise<void> {
@@ -649,6 +680,9 @@ class CodexQuotaStatus {
649
680
  }
650
681
  const tier = snapshot.tier ? ` (${snapshot.tier})` : "";
651
682
  const status = isFresh(snapshot, identity, now) ? "measured" : "stale";
683
+ if (isFiveHourLimited(snapshot, now)) {
684
+ return `Codex slot ${slot}${tier}: five-hour limit reached, resets in ${formatDuration(snapshot.limitedUntil! - now)} (${status})`;
685
+ }
652
686
  return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
653
687
  });
654
688
  }
@@ -814,7 +848,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
814
848
  if ((slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
815
849
  const identity = identityFor(credential);
816
850
  const snapshot = state.slots.get(slot);
817
- if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number") return [];
851
+ if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number" || isFiveHourLimited(snapshot, now)) return [];
818
852
  return [{ slot, remaining: snapshot.remaining }];
819
853
  });
820
854
  };
@@ -839,8 +873,13 @@ export default function multiCodex(pi: ExtensionAPI): void {
839
873
  if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
840
874
  return `${prefix} · unavailable`;
841
875
  }
842
- if (!isFresh(snapshot, identity, Date.now())) return `${prefix} · stale`;
843
- const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - Date.now())}`;
876
+ const now = Date.now();
877
+ if (isFiveHourLimited(snapshot, now)) {
878
+ const text = `${prefix} · 5h limit · ${formatDuration(snapshot.limitedUntil! - now)}`;
879
+ return ctx.ui.theme?.fg ? ctx.ui.theme.fg("error", text) : text;
880
+ }
881
+ if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
882
+ const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - now)}`;
844
883
  const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
845
884
  return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
846
885
  };
@@ -923,7 +962,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
923
962
  });
924
963
 
925
964
  pi.registerCommand("codex-status", {
926
- description: "show shared Codex seven-day quota status",
965
+ description: "show shared Codex quota status",
927
966
  handler: async (_args, ctx: ExtensionContext) => {
928
967
  const lines = await quota.statusLines();
929
968
  ctx.ui.notify(lines.length ? lines.join("\n") : NO_ACCOUNTS_MESSAGE, "info");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-multi-codex",
3
- "version": "0.3.16",
3
+ "version": "0.3.18",
4
4
  "description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -14,9 +14,9 @@
14
14
  },
15
15
  "license": "MIT",
16
16
  "files": [
17
+ "LICENSE",
17
18
  "extensions",
18
- "README.md",
19
- "LICENSE"
19
+ "README.md"
20
20
  ],
21
21
  "scripts": {
22
22
  "test": "node --test test/*.test.ts",
@@ -44,6 +44,7 @@
44
44
  ]
45
45
  },
46
46
  "dependencies": {
47
+ "@henryqw/pi-config-store": "^0.1.0",
47
48
  "proper-lockfile": "^4.1.2"
48
49
  },
49
50
  "devDependencies": {