@victor-software-house/pi-multicodex 2.3.0 → 2.3.1

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.
@@ -30,11 +30,33 @@ export class AccountManager {
30
30
  private manualEmail?: string;
31
31
  private stateChangeHandlers = new Set<StateChangeHandler>();
32
32
  private warnedAuthFailureEmails = new Set<string>();
33
+ private readyPromise: Promise<void> = Promise.resolve();
34
+ private readyResolve?: () => void;
33
35
 
34
36
  constructor() {
35
37
  this.data = loadStorage();
36
38
  }
37
39
 
40
+ /**
41
+ * Mark the account manager as initializing. The returned promise
42
+ * resolves when {@link markReady} is called. Stream requests wait
43
+ * on {@link waitUntilReady} so they don't race the startup refresh.
44
+ */
45
+ beginInitialization(): void {
46
+ this.readyPromise = new Promise<void>((resolve) => {
47
+ this.readyResolve = resolve;
48
+ });
49
+ }
50
+
51
+ markReady(): void {
52
+ this.readyResolve?.();
53
+ this.readyResolve = undefined;
54
+ }
55
+
56
+ waitUntilReady(): Promise<void> {
57
+ return this.readyPromise;
58
+ }
59
+
38
60
  private save(): void {
39
61
  saveStorage(this.data);
40
62
  }
@@ -255,21 +277,29 @@ export class AccountManager {
255
277
  const account = this.getAccount(email);
256
278
  if (account) {
257
279
  account.quotaExhaustedUntil = until;
258
- this.save();
280
+ if (!this.isPiAuthAccount(account)) {
281
+ this.save();
282
+ }
259
283
  this.notifyStateChanged();
260
284
  }
261
285
  }
262
286
 
263
287
  clearAllQuotaExhaustion(): number {
264
288
  let cleared = 0;
265
- for (const account of this.data.accounts) {
289
+ let managedChanged = false;
290
+ for (const account of this.getAccounts()) {
266
291
  if (account.quotaExhaustedUntil) {
267
292
  account.quotaExhaustedUntil = undefined;
268
293
  cleared += 1;
294
+ if (!this.isPiAuthAccount(account)) {
295
+ managedChanged = true;
296
+ }
269
297
  }
270
298
  }
271
- if (cleared > 0) {
299
+ if (managedChanged) {
272
300
  this.save();
301
+ }
302
+ if (cleared > 0) {
273
303
  this.notifyStateChanged();
274
304
  }
275
305
  return cleared;
@@ -377,7 +407,14 @@ export class AccountManager {
377
407
  now,
378
408
  });
379
409
  if (selected) {
380
- this.setActiveAccount(selected.email);
410
+ if (this.isPiAuthAccount(selected)) {
411
+ // Don't persist ephemeral pi auth email to disk — it would
412
+ // become a stale activeEmail after restart.
413
+ this.data.activeEmail = selected.email;
414
+ this.notifyStateChanged();
415
+ } else {
416
+ this.setActiveAccount(selected.email);
417
+ }
381
418
  }
382
419
  return selected;
383
420
  }
@@ -398,15 +435,21 @@ export class AccountManager {
398
435
  }
399
436
 
400
437
  private clearExpiredExhaustion(now: number): void {
401
- let changed = false;
402
- for (const account of this.data.accounts) {
438
+ let managedChanged = false;
439
+ let anyChanged = false;
440
+ for (const account of this.getAccounts()) {
403
441
  if (account.quotaExhaustedUntil && account.quotaExhaustedUntil <= now) {
404
442
  account.quotaExhaustedUntil = undefined;
405
- changed = true;
443
+ anyChanged = true;
444
+ if (!this.isPiAuthAccount(account)) {
445
+ managedChanged = true;
446
+ }
406
447
  }
407
448
  }
408
- if (changed) {
449
+ if (managedChanged) {
409
450
  this.save();
451
+ }
452
+ if (anyChanged) {
410
453
  this.notifyStateChanged();
411
454
  }
412
455
  }
package/hooks.ts CHANGED
@@ -6,28 +6,33 @@ async function refreshAndActivateBestAccount(
6
6
  accountManager: AccountManager,
7
7
  warningHandler?: WarningHandler,
8
8
  ): Promise<void> {
9
- await accountManager.loadPiAuth();
10
- await accountManager.refreshUsageForAllAccounts({ force: true });
9
+ accountManager.beginInitialization();
10
+ try {
11
+ await accountManager.loadPiAuth();
12
+ await accountManager.refreshUsageForAllAccounts({ force: true });
11
13
 
12
- const needsReauth = accountManager.getAccountsNeedingReauth();
13
- if (needsReauth.length > 0) {
14
- const hints = needsReauth.map((a) => {
15
- const cmd = accountManager.isPiAuthAccount(a)
16
- ? "/login openai-codex"
17
- : `/multicodex use ${a.email}`;
18
- return `${a.email} (${cmd})`;
19
- });
20
- warningHandler?.(
21
- `Multicodex: ${needsReauth.length} account(s) need re-authentication: ${hints.join(", ")}`,
22
- );
23
- }
14
+ const needsReauth = accountManager.getAccountsNeedingReauth();
15
+ if (needsReauth.length > 0) {
16
+ const hints = needsReauth.map((a) => {
17
+ const cmd = accountManager.isPiAuthAccount(a)
18
+ ? "/login openai-codex"
19
+ : `/multicodex use ${a.email}`;
20
+ return `${a.email} (${cmd})`;
21
+ });
22
+ warningHandler?.(
23
+ `Multicodex: ${needsReauth.length} account(s) need re-authentication: ${hints.join(", ")}`,
24
+ );
25
+ }
24
26
 
25
- const manual = accountManager.getAvailableManualAccount();
26
- if (manual) return;
27
- if (accountManager.hasManualAccount()) {
28
- accountManager.clearManualAccount();
27
+ const manual = accountManager.getAvailableManualAccount();
28
+ if (manual) return;
29
+ if (accountManager.hasManualAccount()) {
30
+ accountManager.clearManualAccount();
31
+ }
32
+ await accountManager.activateBestAccount();
33
+ } finally {
34
+ accountManager.markReady();
29
35
  }
30
- await accountManager.activateBestAccount();
31
36
  }
32
37
 
33
38
  export function handleSessionStart(
@@ -35,12 +40,12 @@ export function handleSessionStart(
35
40
  warningHandler?: WarningHandler,
36
41
  ): void {
37
42
  if (accountManager.getAccounts().length === 0) return;
38
- void refreshAndActivateBestAccount(accountManager, warningHandler);
43
+ refreshAndActivateBestAccount(accountManager, warningHandler).catch(() => {});
39
44
  }
40
45
 
41
46
  export function handleNewSessionSwitch(
42
47
  accountManager: AccountManager,
43
48
  warningHandler?: WarningHandler,
44
49
  ): void {
45
- void refreshAndActivateBestAccount(accountManager, warningHandler);
50
+ refreshAndActivateBestAccount(accountManager, warningHandler).catch(() => {});
46
51
  }
package/index.ts CHANGED
@@ -22,6 +22,7 @@ export { createStreamWrapper } from "./stream-wrapper";
22
22
  export type { CodexUsageSnapshot } from "./usage";
23
23
  export {
24
24
  formatResetAt,
25
+ getMaxUsedPercent,
25
26
  getNextResetAt,
26
27
  getWeeklyResetAt,
27
28
  isUsageUntouched,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/selection.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { Account } from "./storage";
2
2
  import {
3
3
  type CodexUsageSnapshot,
4
+ getMaxUsedPercent,
4
5
  getWeeklyResetAt,
5
6
  isUsageUntouched,
6
7
  } from "./usage";
@@ -15,20 +16,26 @@ function pickRandomAccount(accounts: Account[]): Account | undefined {
15
16
  return accounts[Math.floor(Math.random() * accounts.length)];
16
17
  }
17
18
 
18
- function pickEarliestWeeklyResetAccount(
19
+ function pickLowestUsageAccount(
19
20
  accounts: Account[],
20
21
  usageByEmail: Map<string, CodexUsageSnapshot>,
21
22
  ): Account | undefined {
22
23
  const candidates = accounts
23
- .map((account) => ({
24
- account,
25
- resetAt: getWeeklyResetAt(usageByEmail.get(account.email)),
26
- }))
27
- .filter(
28
- (entry): entry is { account: Account; resetAt: number } =>
29
- typeof entry.resetAt === "number",
30
- )
31
- .sort((a, b) => a.resetAt - b.resetAt);
24
+ .map((account) => {
25
+ const usage = usageByEmail.get(account.email);
26
+ return {
27
+ account,
28
+ usedPercent: getMaxUsedPercent(usage) ?? 100,
29
+ resetAt: getWeeklyResetAt(usage) ?? Number.MAX_SAFE_INTEGER,
30
+ };
31
+ })
32
+ .sort((a, b) => {
33
+ // Primary: lowest usage first
34
+ const usageDiff = a.usedPercent - b.usedPercent;
35
+ if (usageDiff !== 0) return usageDiff;
36
+ // Tiebreaker: earliest weekly reset first
37
+ return a.resetAt - b.resetAt;
38
+ });
32
39
 
33
40
  return candidates[0]?.account;
34
41
  }
@@ -55,16 +62,13 @@ export function pickBestAccount(
55
62
 
56
63
  if (untouched.length > 0) {
57
64
  return (
58
- pickEarliestWeeklyResetAccount(untouched, usageByEmail) ??
65
+ pickLowestUsageAccount(untouched, usageByEmail) ??
59
66
  pickRandomAccount(untouched)
60
67
  );
61
68
  }
62
69
 
63
- const earliestWeeklyReset = pickEarliestWeeklyResetAccount(
64
- withUsage,
65
- usageByEmail,
66
- );
67
- if (earliestWeeklyReset) return earliestWeeklyReset;
70
+ const lowestUsage = pickLowestUsageAccount(withUsage, usageByEmail);
71
+ if (lowestUsage) return lowestUsage;
68
72
 
69
73
  return pickRandomAccount(available);
70
74
  }
package/stream-wrapper.ts CHANGED
@@ -39,6 +39,7 @@ export function createStreamWrapper(
39
39
 
40
40
  (async () => {
41
41
  try {
42
+ await accountManager.waitUntilReady();
42
43
  const excludedEmails = new Set<string>();
43
44
  for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
44
45
  const now = Date.now();
package/usage.ts CHANGED
@@ -66,6 +66,17 @@ export function getNextResetAt(usage?: CodexUsageSnapshot): number | undefined {
66
66
  return Math.min(...candidates);
67
67
  }
68
68
 
69
+ export function getMaxUsedPercent(
70
+ usage?: CodexUsageSnapshot,
71
+ ): number | undefined {
72
+ const candidates = [
73
+ usage?.primary?.usedPercent,
74
+ usage?.secondary?.usedPercent,
75
+ ].filter((value): value is number => typeof value === "number");
76
+ if (candidates.length === 0) return undefined;
77
+ return Math.max(...candidates);
78
+ }
79
+
69
80
  export function getWeeklyResetAt(
70
81
  usage?: CodexUsageSnapshot,
71
82
  ): number | undefined {