@henryqw/pi-multi-codex 0.3.15 → 0.3.17

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.
@@ -53,6 +53,7 @@ type UsageSnapshot = {
53
53
  fetchedAt?: number;
54
54
  remaining?: number;
55
55
  reset?: number;
56
+ limitedUntil?: number | null;
56
57
  };
57
58
 
58
59
  type UsageLock = {
@@ -65,9 +66,10 @@ type UsageLock = {
65
66
  type UsageState = {
66
67
  slots: Map<number, UsageSnapshot>;
67
68
  locks: Map<number, UsageLock>;
69
+ untrusted?: true;
68
70
  };
69
71
 
70
- type ParsedUsage = { remaining: number; reset: number; tier?: string };
72
+ type ParsedUsage = { remaining: number; reset: number; limitedUntil?: number; tier?: string };
71
73
 
72
74
  const NATIVE_PROVIDER_ID = "openai-codex";
73
75
  const CODEX_ALIAS_PATTERN = /^openai-codex-([2-9]|[1-9]\d+)$/;
@@ -171,9 +173,14 @@ function validTier(value: unknown): value is string {
171
173
  function readSnapshot(value: unknown): UsageSnapshot | undefined {
172
174
  if (!isRecord(value) || !validSlot(value.slot) || !validHash(value.accountHash) || !validTime(value.checkedAt)) return undefined;
173
175
  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
- }
176
+ if (successful && (
177
+ !validTime(value.fetchedAt) ||
178
+ typeof value.remaining !== "number" ||
179
+ !Number.isFinite(value.remaining) ||
180
+ !validTime(value.reset) ||
181
+ (value.limitedUntil !== null && !validTime(value.limitedUntil))
182
+ )) return undefined;
183
+ if (!successful && value.limitedUntil !== undefined) return undefined;
177
184
  return {
178
185
  slot: value.slot,
179
186
  accountHash: value.accountHash,
@@ -184,6 +191,7 @@ function readSnapshot(value: unknown): UsageSnapshot | undefined {
184
191
  fetchedAt: value.fetchedAt as number,
185
192
  remaining: Math.max(0, Math.min(100, value.remaining as number)),
186
193
  reset: value.reset as number,
194
+ limitedUntil: value.limitedUntil as number | null,
187
195
  }
188
196
  : {}),
189
197
  };
@@ -196,43 +204,50 @@ function readLock(value: unknown): UsageLock | undefined {
196
204
  return { slot: value.slot, owner: value.owner, accountHash: value.accountHash, heartbeatAt: value.heartbeatAt };
197
205
  }
198
206
 
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 : []) {
207
+ function parseState(value: unknown): UsageState | undefined {
208
+ if (!isRecord(value) || !Array.isArray(value.slots) || !Array.isArray(value.locks)) return undefined;
209
+ const state = emptyState();
210
+ for (const snapshot of value.slots) {
203
211
  const parsed = readSnapshot(snapshot);
204
- if (parsed) state.slots.set(parsed.slot, parsed);
212
+ if (!parsed || state.slots.has(parsed.slot)) return undefined;
213
+ state.slots.set(parsed.slot, parsed);
205
214
  }
206
- for (const lock of Array.isArray(value.locks) ? value.locks : []) {
215
+ for (const lock of value.locks) {
207
216
  const parsed = readLock(lock);
208
- if (parsed) state.locks.set(parsed.slot, parsed);
217
+ if (!parsed || state.locks.has(parsed.slot)) return undefined;
218
+ state.locks.set(parsed.slot, parsed);
209
219
  }
210
220
  return state;
211
221
  }
212
222
 
213
- function emptyState(): UsageState {
214
- return { slots: new Map(), locks: new Map() };
223
+ function emptyState(untrusted = false): UsageState {
224
+ return { slots: new Map(), locks: new Map(), ...(untrusted ? { untrusted: true } : {}) };
225
+ }
226
+
227
+ function isMissingCacheFile(error: unknown): boolean {
228
+ return Boolean(error && typeof error === "object" && "code" in error && String(error.code) === "ENOENT");
215
229
  }
216
230
 
217
231
  function loadStateSync(): UsageState {
218
232
  try {
219
- return parseState(JSON.parse(readFileSync(cachePath(), "utf8")));
220
- } catch {
221
- return emptyState();
233
+ return parseState(JSON.parse(readFileSync(cachePath(), "utf8"))) ?? emptyState(true);
234
+ } catch (error) {
235
+ return emptyState(!isMissingCacheFile(error));
222
236
  }
223
237
  }
224
238
 
225
239
  async function loadState(signal?: AbortSignal): Promise<UsageState> {
226
240
  try {
227
- return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal })));
241
+ return parseState(JSON.parse(await readFile(cachePath(), { encoding: "utf8", signal }))) ?? emptyState(true);
228
242
  } catch (error) {
229
243
  signal?.throwIfAborted();
230
- return emptyState();
244
+ return emptyState(!isMissingCacheFile(error));
231
245
  }
232
246
  }
233
247
 
234
248
  async function saveState(state: UsageState, signal?: AbortSignal): Promise<void> {
235
249
  signal?.throwIfAborted();
250
+ if (state.untrusted) return;
236
251
  const file = cachePath();
237
252
  const directory = join(getAgentDir(), "config", "pi-multi-codex");
238
253
  await mkdir(directory, { recursive: true, mode: 0o700 });
@@ -295,6 +310,10 @@ function isFresh(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, no
295
310
  );
296
311
  }
297
312
 
313
+ function isFiveHourLimited(snapshot: UsageSnapshot, now: number): boolean {
314
+ return validTime(snapshot.limitedUntil) && snapshot.limitedUntil > now;
315
+ }
316
+
298
317
  function checkedRecently(snapshot: UsageSnapshot | undefined, identity: SlotIdentity, now: number): boolean {
299
318
  return Boolean(
300
319
  snapshot &&
@@ -324,20 +343,28 @@ function resetTime(window: JsonRecord, now: number): number | undefined {
324
343
  return resetAfter === undefined || resetAfter < 0 ? undefined : now + resetAfter * 1000;
325
344
  }
326
345
 
327
- /** Extract seven-day window, or only usable window when service reports one. */
346
+ /** Extract seven-day quota and any active five-hour block. */
328
347
  export function parseCodexUsage(value: unknown, now = Date.now()): ParsedUsage | undefined {
329
348
  if (!isRecord(value)) return undefined;
330
349
  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 }] : []));
350
+ const windows = Object.values(rateLimit).flatMap((entry) => {
351
+ if (!isRecord(entry)) return [];
352
+ const used = numberValue(entry.used_percent);
353
+ const reset = resetTime(entry, now);
354
+ return used === undefined || reset === undefined ? [] : [{ value: entry, used, reset }];
355
+ });
333
356
  const sevenDay = windows.find((window) => numberValue(window.value.limit_window_seconds) === 7 * 24 * 60 * 60)
334
357
  ?? (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;
358
+ if (!sevenDay || sevenDay.reset <= now) return undefined;
359
+ const fiveHour = windows.find((window) => numberValue(window.value.limit_window_seconds) === 5 * 60 * 60);
360
+ const limitedUntil = fiveHour && fiveHour.used >= 100 && fiveHour.reset > now ? fiveHour.reset : undefined;
339
361
  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 } : {}) };
362
+ return {
363
+ remaining: Math.max(0, Math.min(100, 100 - sevenDay.used)),
364
+ reset: sevenDay.reset,
365
+ ...(limitedUntil ? { limitedUntil } : {}),
366
+ ...(tier ? { tier } : {}),
367
+ };
341
368
  }
342
369
 
343
370
  function raceWithSignal<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
@@ -392,11 +419,12 @@ class CodexQuotaStatus {
392
419
  return loadState();
393
420
  }
394
421
 
395
- private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T> {
422
+ private change<T>(change: (state: UsageState) => { value: T; write: boolean }, signal?: AbortSignal): Promise<T | undefined> {
396
423
  const operation = this.writes.then(() => withCacheMutex(async (operationSignal) => {
397
424
  operationSignal.throwIfAborted();
398
425
  const state = await loadState(operationSignal);
399
426
  operationSignal.throwIfAborted();
427
+ if (state.untrusted) return undefined;
400
428
  const result = change(state);
401
429
  if (result.write) {
402
430
  operationSignal.throwIfAborted();
@@ -441,6 +469,7 @@ class CodexQuotaStatus {
441
469
 
442
470
  const now = Date.now();
443
471
  const state = loadStateSync();
472
+ if (state.untrusted) return;
444
473
  let dueAt: number | undefined;
445
474
  for (const [slot, credential] of readCodexCredentials()) {
446
475
  if (this.active.has(slot)) continue;
@@ -571,6 +600,7 @@ class CodexQuotaStatus {
571
600
  fetchedAt: checkedAt,
572
601
  remaining: outcome.remaining,
573
602
  reset: outcome.reset,
603
+ limitedUntil: outcome.limitedUntil && outcome.limitedUntil > checkedAt ? outcome.limitedUntil : null,
574
604
  });
575
605
  } else {
576
606
  state.slots.set(slot, previous?.accountHash === identity.accountHash && (!validTime(previous.reset) || previous.reset > checkedAt)
@@ -581,7 +611,7 @@ class CodexQuotaStatus {
581
611
  return { value: true, write: true };
582
612
  }, refresh.session.signal);
583
613
  if (finished) this.onChange();
584
- return finished;
614
+ return finished ?? false;
585
615
  }
586
616
 
587
617
  private async release(slot: number, owner: string, signal: AbortSignal): Promise<void> {
@@ -649,6 +679,9 @@ class CodexQuotaStatus {
649
679
  }
650
680
  const tier = snapshot.tier ? ` (${snapshot.tier})` : "";
651
681
  const status = isFresh(snapshot, identity, now) ? "measured" : "stale";
682
+ if (isFiveHourLimited(snapshot, now)) {
683
+ return `Codex slot ${slot}${tier}: five-hour limit reached, resets in ${formatDuration(snapshot.limitedUntil! - now)} (${status})`;
684
+ }
652
685
  return `Codex slot ${slot}${tier}: ${formatPercent(snapshot.remaining)}% remaining, resets in ${formatDuration(snapshot.reset - now)} (${status})`;
653
686
  });
654
687
  }
@@ -814,7 +847,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
814
847
  if ((slot !== 1 && !registered.has(slot)) || !allowsModel(ctx, model, slot)) return [];
815
848
  const identity = identityFor(credential);
816
849
  const snapshot = state.slots.get(slot);
817
- if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number") return [];
850
+ if (!identity || !isFresh(snapshot, identity, now) || typeof snapshot?.remaining !== "number" || isFiveHourLimited(snapshot, now)) return [];
818
851
  return [{ slot, remaining: snapshot.remaining }];
819
852
  });
820
853
  };
@@ -839,8 +872,13 @@ export default function multiCodex(pi: ExtensionAPI): void {
839
872
  if (!identity || !snapshot || snapshot.accountHash !== identity.accountHash || !validTime(snapshot.fetchedAt) || typeof snapshot.remaining !== "number" || !validTime(snapshot.reset)) {
840
873
  return `${prefix} · unavailable`;
841
874
  }
842
- if (!isFresh(snapshot, identity, Date.now())) return `${prefix} · stale`;
843
- const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - Date.now())}`;
875
+ const now = Date.now();
876
+ if (isFiveHourLimited(snapshot, now)) {
877
+ const text = `${prefix} · 5h limit · ${formatDuration(snapshot.limitedUntil! - now)}`;
878
+ return ctx.ui.theme?.fg ? ctx.ui.theme.fg("error", text) : text;
879
+ }
880
+ if (!isFresh(snapshot, identity, now)) return `${prefix} · stale`;
881
+ const text = `${prefix} · ${formatPercent(snapshot.remaining)}% · 7d ${formatDuration(snapshot.reset - now)}`;
844
882
  const color = snapshot.remaining >= 50 ? "success" : snapshot.remaining >= 25 ? "warning" : "error";
845
883
  return ctx.ui.theme?.fg ? ctx.ui.theme.fg(color, text) : text;
846
884
  };
@@ -923,7 +961,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
923
961
  });
924
962
 
925
963
  pi.registerCommand("codex-status", {
926
- description: "show shared Codex seven-day quota status",
964
+ description: "show shared Codex quota status",
927
965
  handler: async (_args, ctx: ExtensionContext) => {
928
966
  const lines = await quota.statusLines();
929
967
  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.15",
3
+ "version": "0.3.17",
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",
@@ -24,8 +24,8 @@
24
24
  "pack:check": "npm pack --dry-run"
25
25
  },
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-ai": "^0.84.3",
28
- "@earendil-works/pi-coding-agent": "^0.84.3"
27
+ "@earendil-works/pi-ai": "^0.84.4",
28
+ "@earendil-works/pi-coding-agent": "^0.84.4"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",