@henryqw/pi-multi-codex 0.3.13 → 0.3.15
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 +4 -14
- package/extensions/multi-codex.ts +34 -73
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,18 +30,8 @@ pi install npm:@henryqw/pi-multi-codex
|
|
|
30
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
32
|
|
|
33
|
-
|
|
33
|
+
## State
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
pi remove npm:@henryqw/pi-multi-codex
|
|
39
|
-
```
|
|
40
|
-
|
|
41
|
-
## Development
|
|
42
|
-
|
|
43
|
-
```bash
|
|
44
|
-
npm test --workspace @henryqw/pi-multi-codex
|
|
45
|
-
npm run typecheck --workspace @henryqw/pi-multi-codex
|
|
46
|
-
npm run pack:check --workspace @henryqw/pi-multi-codex
|
|
47
|
-
```
|
|
35
|
+
| Path | Purpose |
|
|
36
|
+
| --- | --- |
|
|
37
|
+
| `~/.pi/agent/config/pi-multi-codex/usage.json` | Generated credential-free quota cache, maintained by the extension. |
|
|
@@ -43,7 +43,7 @@ type SlotIdentity = {
|
|
|
43
43
|
};
|
|
44
44
|
|
|
45
45
|
type SlotAuthResolver = (slot: number) => Promise<string | undefined>;
|
|
46
|
-
type QuotaRefresh = {
|
|
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 (
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
451
|
-
void this.refreshDue(refresh
|
|
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
|
|
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(
|
|
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 (
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
587
|
-
session: AbortController,
|
|
554
|
+
refresh: QuotaRefresh,
|
|
588
555
|
): Promise<boolean> {
|
|
589
|
-
if (
|
|
556
|
+
if (this.refreshContext !== refresh) return false;
|
|
590
557
|
const finished = await this.change((state) => {
|
|
591
|
-
if (
|
|
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 ||
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
})
|
|
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,
|
|
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,
|
|
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 =
|
|
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.
|
|
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") :
|
|
929
|
+
ctx.ui.notify(lines.length ? lines.join("\n") : NO_ACCOUNTS_MESSAGE, "info");
|
|
969
930
|
},
|
|
970
931
|
});
|
|
971
932
|
|