@henryqw/pi-multi-codex 0.3.12 → 0.3.14

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
@@ -17,7 +17,6 @@ pi install npm:@henryqw/pi-multi-codex
17
17
 
18
18
  | Package | Why |
19
19
  | --- | --- |
20
- | `@henryqw/pi-model-thinking` | Improves. Thinking level follows the model across slots. |
21
20
  | `@henryqw/pi-task-models` | Improves. Numbered slots share one profile route. |
22
21
  | `@henryqw/pi-subagent` | Improves. Isolated children keep Main's active Codex slot. |
23
22
 
@@ -43,7 +43,7 @@ type SlotIdentity = {
43
43
  };
44
44
 
45
45
  type SlotAuthResolver = (slot: number) => Promise<string | undefined>;
46
- type QuotaRefresh = { generation: number; session: AbortController; resolveSlotAuth: SlotAuthResolver };
46
+ type QuotaRefresh = { session: AbortController; resolveSlotAuth: SlotAuthResolver };
47
47
 
48
48
  type UsageSnapshot = {
49
49
  slot: number;
@@ -122,10 +122,6 @@ function readCodexCredentials(): Map<number, CodexOAuthCredential> {
122
122
  return credentials;
123
123
  }
124
124
 
125
- function discoverCodexSlots(): Set<number> {
126
- return new Set(readCodexCredentials().keys());
127
- }
128
-
129
125
  function accountIdFromAccessToken(access: string): string | undefined {
130
126
  try {
131
127
  const encoded = access.split(".")[1];
@@ -256,10 +252,6 @@ async function saveState(state: UsageState, signal?: AbortSignal): Promise<void>
256
252
  }
257
253
  }
258
254
 
259
- function errorCode(error: unknown): string | undefined {
260
- return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
261
- }
262
-
263
255
  async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>, signal?: AbortSignal): Promise<T> {
264
256
  signal?.throwIfAborted();
265
257
  await mkdir(join(getAgentDir(), "config", "pi-multi-codex"), { recursive: true, mode: 0o700 });
@@ -284,7 +276,7 @@ async function withCacheMutex<T>(operation: (signal: AbortSignal) => Promise<T>,
284
276
  await release().catch(() => undefined);
285
277
  }
286
278
  } catch (error) {
287
- if (errorCode(error) !== "ELOCKED") throw error;
279
+ if (!(error && typeof error === "object" && "code" in error && String(error.code) === "ELOCKED")) throw error;
288
280
  await delay(CACHE_MUTEX_RETRY_MS, undefined, { signal, ref: false });
289
281
  }
290
282
  }
@@ -380,8 +372,6 @@ function formatDuration(ms: number): string {
380
372
  }
381
373
 
382
374
  class CodexQuotaStatus {
383
- private generation = 0;
384
- private session: AbortController | undefined;
385
375
  private refreshContext: QuotaRefresh | undefined;
386
376
  private timer: Timer | undefined;
387
377
  private readonly heartbeats = new Map<number, { owner: string; timer: Timer }>();
@@ -420,16 +410,12 @@ class CodexQuotaStatus {
420
410
 
421
411
  start(resolveSlotAuth: SlotAuthResolver): void {
422
412
  this.stop();
423
- const session = new AbortController();
424
- this.session = session;
425
- this.refreshContext = { generation: ++this.generation, session, resolveSlotAuth };
413
+ this.refreshContext = { session: new AbortController(), resolveSlotAuth };
426
414
  this.requestRefresh();
427
415
  }
428
416
 
429
417
  stop(): void {
430
- this.generation++;
431
- this.session?.abort();
432
- this.session = undefined;
418
+ this.refreshContext?.session.abort();
433
419
  this.refreshContext = undefined;
434
420
  if (this.timer) clearTimeout(this.timer);
435
421
  this.timer = undefined;
@@ -437,18 +423,10 @@ class CodexQuotaStatus {
437
423
  this.heartbeats.clear();
438
424
  }
439
425
 
440
- private alive(generation: number, session: AbortController): boolean {
441
- return this.generation === generation && this.session === session && !session.signal.aborted;
442
- }
443
-
444
- refreshNow(): void {
445
- this.requestRefresh();
446
- }
447
-
448
- private requestRefresh(): void {
426
+ requestRefresh(): void {
449
427
  const refresh = this.refreshContext;
450
- if (!refresh || !this.alive(refresh.generation, refresh.session)) return;
451
- void this.refreshDue(refresh.generation, refresh.session, refresh.resolveSlotAuth)
428
+ if (!refresh) return;
429
+ void this.refreshDue(refresh)
452
430
  .catch(() => undefined)
453
431
  .then(() => {
454
432
  if (this.refreshContext === refresh) this.scheduleRefresh();
@@ -457,7 +435,7 @@ class CodexQuotaStatus {
457
435
 
458
436
  private scheduleRefresh(): void {
459
437
  const refresh = this.refreshContext;
460
- if (!refresh || !this.alive(refresh.generation, refresh.session)) return;
438
+ if (!refresh) return;
461
439
  if (this.timer) clearTimeout(this.timer);
462
440
  this.timer = undefined;
463
441
 
@@ -490,33 +468,27 @@ class CodexQuotaStatus {
490
468
  this.timer.unref?.();
491
469
  }
492
470
 
493
- private async refreshDue(generation: number, session: AbortController, resolveSlotAuth: SlotAuthResolver): Promise<void> {
471
+ private async refreshDue(refresh: QuotaRefresh): Promise<void> {
494
472
  const credentials = readCodexCredentials();
495
473
  const state = await this.state();
496
474
  await Promise.all([...credentials.entries()].map(async ([slot, credential]) => {
497
- if (!this.alive(generation, session) || this.active.has(slot)) return;
475
+ if (this.refreshContext !== refresh || this.active.has(slot)) return;
498
476
  const identity = identityFor(credential);
499
477
  if (!identity) return;
500
478
  const snapshot = state.slots.get(slot);
501
479
  if (isFresh(snapshot, identity, Date.now()) || checkedRecently(snapshot, identity, Date.now())) return;
502
- this.launch(slot, identity, generation, session, resolveSlotAuth);
480
+ this.launch(slot, identity, refresh);
503
481
  })).catch(() => undefined);
504
482
  }
505
483
 
506
- private launch(
507
- slot: number,
508
- identity: SlotIdentity,
509
- generation: number,
510
- session: AbortController,
511
- resolveSlotAuth: SlotAuthResolver,
512
- ): void {
484
+ private launch(slot: number, identity: SlotIdentity, refresh: QuotaRefresh): void {
513
485
  if (this.active.has(slot)) return;
514
- const task = this.refreshSlot(slot, identity, generation, session, resolveSlotAuth);
486
+ const task = this.refreshSlot(slot, identity, refresh);
515
487
  this.active.set(slot, task);
516
488
  const cleanup = () => {
517
489
  if (this.active.get(slot) !== task) return;
518
490
  this.active.delete(slot);
519
- if (this.refreshContext?.generation === generation) this.scheduleRefresh();
491
+ if (this.refreshContext === refresh) this.scheduleRefresh();
520
492
  else this.requestRefresh();
521
493
  };
522
494
  void task.then(cleanup, cleanup);
@@ -574,21 +546,16 @@ class CodexQuotaStatus {
574
546
  return currentIdentity(slot)?.accountHash === accountHash;
575
547
  }
576
548
 
577
- private async resolveAuth(slot: number, resolveSlotAuth: SlotAuthResolver, signal: AbortSignal): Promise<string | undefined> {
578
- return raceWithSignal(resolveSlotAuth(slot), signal);
579
- }
580
-
581
549
  private async finish(
582
550
  slot: number,
583
551
  owner: string,
584
552
  identity: SlotIdentity,
585
553
  outcome: ParsedUsage | undefined,
586
- generation: number,
587
- session: AbortController,
554
+ refresh: QuotaRefresh,
588
555
  ): Promise<boolean> {
589
- if (!this.alive(generation, session)) return false;
556
+ if (this.refreshContext !== refresh) return false;
590
557
  const finished = await this.change((state) => {
591
- if (!this.alive(generation, session) || !this.identityStillCurrent(slot, identity.accountHash)) {
558
+ if (this.refreshContext !== refresh || !this.identityStillCurrent(slot, identity.accountHash)) {
592
559
  return { value: false, write: false };
593
560
  }
594
561
  const lock = state.locks.get(slot);
@@ -612,7 +579,7 @@ class CodexQuotaStatus {
612
579
  }
613
580
  state.locks.delete(slot);
614
581
  return { value: true, write: true };
615
- }, session.signal);
582
+ }, refresh.session.signal);
616
583
  if (finished) this.onChange();
617
584
  return finished;
618
585
  }
@@ -626,36 +593,30 @@ class CodexQuotaStatus {
626
593
  }, signal);
627
594
  }
628
595
 
629
- private async refreshSlot(
630
- slot: number,
631
- identity: SlotIdentity,
632
- generation: number,
633
- session: AbortController,
634
- resolveSlotAuth: SlotAuthResolver,
635
- ): Promise<void> {
596
+ private async refreshSlot(slot: number, identity: SlotIdentity, refresh: QuotaRefresh): Promise<void> {
636
597
  let owner: string | undefined;
637
598
  let discard = false;
638
599
  try {
639
- owner = await this.claim(slot, identity, session.signal);
640
- if (!owner || !this.alive(generation, session)) return;
641
- this.startHeartbeat(slot, owner, identity.accountHash, session.signal);
600
+ owner = await this.claim(slot, identity, refresh.session.signal);
601
+ if (!owner || this.refreshContext !== refresh) return;
602
+ this.startHeartbeat(slot, owner, identity.accountHash, refresh.session.signal);
642
603
 
643
- const authSignal = AbortSignal.any([session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
644
- const apiKey = await this.resolveAuth(slot, resolveSlotAuth, authSignal);
604
+ const authSignal = AbortSignal.any([refresh.session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
605
+ const apiKey = await raceWithSignal(refresh.resolveSlotAuth(slot), authSignal);
645
606
  if (!apiKey) throw new Error("Codex OAuth auth did not provide a bearer token.");
646
- if (!this.alive(generation, session) || !this.identityStillCurrent(slot, identity.accountHash) || !(await this.stillOwn(slot, owner, identity.accountHash))) {
607
+ if (this.refreshContext !== refresh || !this.identityStillCurrent(slot, identity.accountHash) || !(await this.stillOwn(slot, owner, identity.accountHash))) {
647
608
  discard = true;
648
609
  return;
649
610
  }
650
611
 
651
- const fetchSignal = AbortSignal.any([session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
652
- const response = await raceWithSignal(fetch(USAGE_URL, {
612
+ const fetchSignal = AbortSignal.any([refresh.session.signal, AbortSignal.timeout(OPERATION_TIMEOUT_MS)]);
613
+ const response = await fetch(USAGE_URL, {
653
614
  headers: {
654
615
  Authorization: `Bearer ${apiKey}`,
655
616
  "ChatGPT-Account-Id": identity.accountId,
656
617
  },
657
618
  signal: fetchSignal,
658
- }), fetchSignal);
619
+ });
659
620
  if (!response.ok) throw new Error(`Codex usage request failed (${response.status}).`);
660
621
  const usage = parseCodexUsage(await raceWithSignal(response.json(), fetchSignal));
661
622
  if (!usage) throw new Error("Codex usage response has no usable seven-day window.");
@@ -663,13 +624,13 @@ class CodexQuotaStatus {
663
624
  discard = true;
664
625
  return;
665
626
  }
666
- await this.finish(slot, owner, identity, usage, generation, session);
627
+ await this.finish(slot, owner, identity, usage, refresh);
667
628
  } catch {
668
- if (!session.signal.aborted && !discard && owner) await this.finish(slot, owner, identity, undefined, generation, session);
629
+ if (!refresh.session.signal.aborted && !discard && owner) await this.finish(slot, owner, identity, undefined, refresh);
669
630
  } finally {
670
631
  if (owner) {
671
632
  this.stopHeartbeat(slot, owner);
672
- await this.release(slot, owner, session.signal.aborted ? AbortSignal.timeout(CLEANUP_TIMEOUT_MS) : session.signal).catch(() => undefined);
633
+ await this.release(slot, owner, refresh.session.signal.aborted ? AbortSignal.timeout(CLEANUP_TIMEOUT_MS) : refresh.session.signal).catch(() => undefined);
673
634
  }
674
635
  }
675
636
  }
@@ -835,7 +796,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
835
796
  };
836
797
 
837
798
  const syncSlots = (): Set<number> => {
838
- const slots = discoverCodexSlots();
799
+ const slots = new Set(readCodexCredentials().keys());
839
800
  for (const slot of [...slots].sort((a, b) => a - b)) registerSlot(slot);
840
801
  return slots;
841
802
  };
@@ -940,7 +901,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
940
901
  // Explicit selector choice wins over startup routing.
941
902
  automaticOpen = false;
942
903
  syncSlots();
943
- quota.refreshNow();
904
+ quota.requestRefresh();
944
905
  updateFooter(ctx);
945
906
  });
946
907
 
@@ -965,7 +926,7 @@ export default function multiCodex(pi: ExtensionAPI): void {
965
926
  description: "show shared Codex seven-day quota status",
966
927
  handler: async (_args, ctx: ExtensionContext) => {
967
928
  const lines = await quota.statusLines();
968
- ctx.ui.notify(lines.length ? lines.join("\n") : "No Codex OAuth accounts found.", "info");
929
+ ctx.ui.notify(lines.length ? lines.join("\n") : NO_ACCOUNTS_MESSAGE, "info");
969
930
  },
970
931
  });
971
932
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-multi-codex",
3
- "version": "0.3.12",
3
+ "version": "0.3.14",
4
4
  "description": "Use multiple ChatGPT Codex OAuth accounts in Pi.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,8 +24,8 @@
24
24
  "pack:check": "npm pack --dry-run"
25
25
  },
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-ai": "*",
28
- "@earendil-works/pi-coding-agent": "*"
27
+ "@earendil-works/pi-ai": "^0.84.3",
28
+ "@earendil-works/pi-coding-agent": "^0.84.3"
29
29
  },
30
30
  "repository": {
31
31
  "type": "git",