@victor-software-house/pi-multicodex 2.2.1 → 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.
@@ -23,17 +23,40 @@ type StateChangeHandler = () => void;
23
23
 
24
24
  export class AccountManager {
25
25
  private data: StorageData;
26
+ private piAuthAccount?: Account;
26
27
  private usageCache = new Map<string, CodexUsageSnapshot>();
27
28
  private refreshPromises = new Map<string, Promise<string>>();
28
29
  private warningHandler?: WarningHandler;
29
30
  private manualEmail?: string;
30
31
  private stateChangeHandlers = new Set<StateChangeHandler>();
31
32
  private warnedAuthFailureEmails = new Set<string>();
33
+ private readyPromise: Promise<void> = Promise.resolve();
34
+ private readyResolve?: () => void;
32
35
 
33
36
  constructor() {
34
37
  this.data = loadStorage();
35
38
  }
36
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
+
37
60
  private save(): void {
38
61
  saveStorage(this.data);
39
62
  }
@@ -52,13 +75,21 @@ export class AccountManager {
52
75
  }
53
76
 
54
77
  getAccounts(): Account[] {
78
+ if (this.piAuthAccount) {
79
+ return [...this.data.accounts, this.piAuthAccount];
80
+ }
55
81
  return this.data.accounts;
56
82
  }
57
83
 
58
84
  getAccount(email: string): Account | undefined {
85
+ if (this.piAuthAccount?.email === email) return this.piAuthAccount;
59
86
  return this.data.accounts.find((a) => a.email === email);
60
87
  }
61
88
 
89
+ isPiAuthAccount(account: Account): boolean {
90
+ return this.piAuthAccount !== undefined && account === this.piAuthAccount;
91
+ }
92
+
62
93
  setWarningHandler(handler?: WarningHandler): void {
63
94
  this.warningHandler = handler;
64
95
  }
@@ -72,32 +103,14 @@ export class AccountManager {
72
103
  return;
73
104
  }
74
105
  this.warnedAuthFailureEmails.add(account.email);
75
- const hint = account.importSource
76
- ? "/multicodex reauth"
106
+ const hint = this.isPiAuthAccount(account)
107
+ ? "/login openai-codex"
77
108
  : `/multicodex reauth ${account.email}`;
78
109
  this.warningHandler?.(
79
110
  `Multicodex skipped ${account.email} during rotation: ${normalizeUnknownError(error)}. Account is flagged in /multicodex accounts. Run ${hint} to repair it.`,
80
111
  );
81
112
  }
82
113
 
83
- private updateAccountEmail(account: Account, email: string): boolean {
84
- if (account.email === email) return false;
85
- const previousEmail = account.email;
86
- account.email = email;
87
- if (this.data.activeEmail === previousEmail) {
88
- this.data.activeEmail = email;
89
- }
90
- if (this.manualEmail === previousEmail) {
91
- this.manualEmail = email;
92
- }
93
- const cached = this.usageCache.get(previousEmail);
94
- if (cached) {
95
- this.usageCache.delete(previousEmail);
96
- this.usageCache.set(email, cached);
97
- }
98
- return true;
99
- }
100
-
101
114
  private removeAccountRecord(account: Account): boolean {
102
115
  const index = this.data.accounts.findIndex(
103
116
  (candidate) => candidate.email === account.email,
@@ -117,25 +130,7 @@ export class AccountManager {
117
130
  return true;
118
131
  }
119
132
 
120
- private findAccountByRefreshToken(
121
- refreshToken: string,
122
- excludeEmail?: string,
123
- ): Account | undefined {
124
- return this.data.accounts.find(
125
- (account) =>
126
- account.refreshToken === refreshToken && account.email !== excludeEmail,
127
- );
128
- }
129
-
130
- private applyCredentials(
131
- account: Account,
132
- creds: OAuthCredentials,
133
- options?: {
134
- importSource?: "pi-openai-codex";
135
- importMode?: "linked" | "synthetic";
136
- importFingerprint?: string;
137
- },
138
- ): boolean {
133
+ private applyCredentials(account: Account, creds: OAuthCredentials): boolean {
139
134
  const accountId =
140
135
  typeof creds.accountId === "string" ? creds.accountId : undefined;
141
136
  let changed = false;
@@ -155,24 +150,6 @@ export class AccountManager {
155
150
  account.accountId = accountId;
156
151
  changed = true;
157
152
  }
158
- if (
159
- options?.importSource &&
160
- account.importSource !== options.importSource
161
- ) {
162
- account.importSource = options.importSource;
163
- changed = true;
164
- }
165
- if (options?.importMode && account.importMode !== options.importMode) {
166
- account.importMode = options.importMode;
167
- changed = true;
168
- }
169
- if (
170
- options?.importFingerprint &&
171
- account.importFingerprint !== options.importFingerprint
172
- ) {
173
- account.importFingerprint = options.importFingerprint;
174
- changed = true;
175
- }
176
153
  if (account.needsReauth) {
177
154
  account.needsReauth = undefined;
178
155
  this.warnedAuthFailureEmails.delete(account.email);
@@ -181,66 +158,28 @@ export class AccountManager {
181
158
  return changed;
182
159
  }
183
160
 
184
- addOrUpdateAccount(
185
- email: string,
186
- creds: OAuthCredentials,
187
- options?: {
188
- importSource?: "pi-openai-codex";
189
- importMode?: "linked" | "synthetic";
190
- importFingerprint?: string;
191
- preserveActive?: boolean;
192
- },
193
- ): Account {
194
- const existing = this.getAccount(email);
195
- const duplicate = existing
196
- ? undefined
197
- : this.findAccountByRefreshToken(creds.refresh);
198
- let target = existing ?? duplicate;
199
- let changed = false;
200
-
201
- if (target) {
202
- if (
203
- duplicate?.importSource === "pi-openai-codex" &&
204
- duplicate.email !== email &&
205
- !this.getAccount(email)
206
- ) {
207
- changed = this.updateAccountEmail(duplicate, email) || changed;
208
- }
209
- changed =
210
- this.applyCredentials(target, creds, {
211
- ...options,
212
- importMode:
213
- options?.importMode ??
214
- (duplicate?.importMode === "synthetic" ? "linked" : undefined),
215
- }) || changed;
216
- } else {
217
- target = {
218
- email,
219
- accessToken: creds.access,
220
- refreshToken: creds.refresh,
221
- expiresAt: creds.expires,
222
- accountId:
223
- typeof creds.accountId === "string" ? creds.accountId : undefined,
224
- importSource: options?.importSource,
225
- importMode: options?.importMode,
226
- importFingerprint: options?.importFingerprint,
227
- };
228
- this.data.accounts.push(target);
229
- changed = true;
230
- }
231
-
232
- if (!options?.preserveActive) {
233
- if (this.data.activeEmail !== target.email) {
234
- this.setActiveAccount(target.email);
235
- return target;
161
+ addOrUpdateAccount(email: string, creds: OAuthCredentials): Account {
162
+ const existing = this.data.accounts.find((a) => a.email === email);
163
+ if (existing) {
164
+ const changed = this.applyCredentials(existing, creds);
165
+ if (changed) {
166
+ this.save();
167
+ this.notifyStateChanged();
236
168
  }
169
+ return existing;
237
170
  }
238
171
 
239
- if (changed) {
240
- this.save();
241
- this.notifyStateChanged();
242
- }
243
- return target;
172
+ const account: Account = {
173
+ email,
174
+ accessToken: creds.access,
175
+ refreshToken: creds.refresh,
176
+ expiresAt: creds.expires,
177
+ accountId:
178
+ typeof creds.accountId === "string" ? creds.accountId : undefined,
179
+ };
180
+ this.data.accounts.push(account);
181
+ this.setActiveAccount(email);
182
+ return account;
244
183
  }
245
184
 
246
185
  getActiveAccount(): Account | undefined {
@@ -286,151 +225,40 @@ export class AccountManager {
286
225
  this.notifyStateChanged();
287
226
  }
288
227
 
289
- getImportedAccount(): Account | undefined {
290
- return this.data.accounts.find(
291
- (account) => account.importSource === "pi-openai-codex",
292
- );
293
- }
294
-
295
- private getLinkedImportedAccount(): Account | undefined {
296
- return this.data.accounts.find(
297
- (account) =>
298
- account.importSource === "pi-openai-codex" &&
299
- account.importMode !== "synthetic",
300
- );
301
- }
302
-
303
- private getSyntheticImportedAccount(): Account | undefined {
304
- return this.data.accounts.find(
305
- (account) =>
306
- account.importSource === "pi-openai-codex" &&
307
- account.importMode === "synthetic",
308
- );
309
- }
310
-
311
- private findManagedImportedTarget(imported: {
312
- identifier: string;
313
- credentials: OAuthCredentials;
314
- }): Account | undefined {
315
- const byRefreshToken = this.data.accounts.find(
316
- (account) =>
317
- account.importMode !== "synthetic" &&
318
- account.refreshToken === imported.credentials.refresh,
319
- );
320
- if (byRefreshToken) {
321
- return byRefreshToken;
228
+ /**
229
+ * Read pi's openai-codex auth from auth.json and expose it as a
230
+ * memory-only ephemeral account. Never persists to codex-accounts.json.
231
+ * If the identity already exists as a managed account, skip it.
232
+ */
233
+ async loadPiAuth(): Promise<void> {
234
+ const imported = await loadImportedOpenAICodexAuth();
235
+ if (!imported) {
236
+ this.piAuthAccount = undefined;
237
+ this.notifyStateChanged();
238
+ return;
322
239
  }
323
240
 
324
- return this.data.accounts.find(
325
- (account) =>
326
- account.importMode !== "synthetic" &&
327
- account.email === imported.identifier,
241
+ const alreadyManaged = this.data.accounts.find(
242
+ (a) => a.email === imported.identifier,
328
243
  );
329
- }
330
-
331
- private clearImportedLink(account: Account): boolean {
332
- let changed = false;
333
- if (account.importSource) {
334
- account.importSource = undefined;
335
- changed = true;
336
- }
337
- if (account.importMode) {
338
- account.importMode = undefined;
339
- changed = true;
340
- }
341
- if (account.importFingerprint) {
342
- account.importFingerprint = undefined;
343
- changed = true;
344
- }
345
- return changed;
346
- }
347
244
 
348
- detachImportedAuth(email: string): boolean {
349
- const account = this.getAccount(email);
350
- if (!account) return false;
351
- const changed = this.clearImportedLink(account);
352
- if (changed) {
353
- this.save();
245
+ if (alreadyManaged) {
246
+ this.piAuthAccount = undefined;
354
247
  this.notifyStateChanged();
355
- }
356
- return changed;
357
- }
358
-
359
- async syncImportedOpenAICodexAuth(): Promise<boolean> {
360
- const imported = await loadImportedOpenAICodexAuth();
361
- if (!imported) return false;
362
-
363
- const linkedImported = this.getLinkedImportedAccount();
364
- const syntheticImported = this.getSyntheticImportedAccount();
365
- const currentImported = linkedImported ?? syntheticImported;
366
- if (
367
- currentImported?.importFingerprint === imported.fingerprint &&
368
- (currentImported.importMode !== "synthetic" ||
369
- currentImported.email === imported.identifier)
370
- ) {
371
- return false;
372
- }
373
-
374
- const matchingAccount = this.findManagedImportedTarget(imported);
375
- if (matchingAccount) {
376
- let changed = this.applyCredentials(
377
- matchingAccount,
378
- imported.credentials,
379
- {
380
- importSource: "pi-openai-codex",
381
- importMode: "linked",
382
- importFingerprint: imported.fingerprint,
383
- },
384
- );
385
- if (linkedImported && linkedImported !== matchingAccount) {
386
- changed = this.clearImportedLink(linkedImported) || changed;
387
- }
388
- if (syntheticImported) {
389
- changed = this.removeAccountRecord(syntheticImported) || changed;
390
- }
391
- if (changed) {
392
- this.save();
393
- this.notifyStateChanged();
394
- }
395
- return changed;
396
- }
397
-
398
- if (linkedImported) {
399
- const changed = this.clearImportedLink(linkedImported);
400
- if (changed) {
401
- this.save();
402
- this.notifyStateChanged();
403
- }
404
- }
405
-
406
- if (syntheticImported) {
407
- let changed = false;
408
- if (syntheticImported.email !== imported.identifier) {
409
- changed = this.updateAccountEmail(
410
- syntheticImported,
411
- imported.identifier,
412
- );
413
- }
414
- changed =
415
- this.applyCredentials(syntheticImported, imported.credentials, {
416
- importSource: "pi-openai-codex",
417
- importMode: "synthetic",
418
- importFingerprint: imported.fingerprint,
419
- }) || changed;
420
- if (changed) {
421
- this.save();
422
- this.notifyStateChanged();
423
- }
424
- return changed;
248
+ return;
425
249
  }
426
250
 
427
- this.addOrUpdateAccount(imported.identifier, imported.credentials, {
428
- importSource: "pi-openai-codex",
429
- importMode: "synthetic",
430
- importFingerprint: imported.fingerprint,
431
- preserveActive: true,
432
- });
433
- return true;
251
+ this.piAuthAccount = {
252
+ email: imported.identifier,
253
+ accessToken: imported.credentials.access,
254
+ refreshToken: imported.credentials.refresh,
255
+ expiresAt: imported.credentials.expires,
256
+ accountId:
257
+ typeof imported.credentials.accountId === "string"
258
+ ? imported.credentials.accountId
259
+ : undefined,
260
+ };
261
+ this.notifyStateChanged();
434
262
  }
435
263
 
436
264
  getAvailableManualAccount(options?: {
@@ -449,21 +277,29 @@ export class AccountManager {
449
277
  const account = this.getAccount(email);
450
278
  if (account) {
451
279
  account.quotaExhaustedUntil = until;
452
- this.save();
280
+ if (!this.isPiAuthAccount(account)) {
281
+ this.save();
282
+ }
453
283
  this.notifyStateChanged();
454
284
  }
455
285
  }
456
286
 
457
287
  clearAllQuotaExhaustion(): number {
458
288
  let cleared = 0;
459
- for (const account of this.data.accounts) {
289
+ let managedChanged = false;
290
+ for (const account of this.getAccounts()) {
460
291
  if (account.quotaExhaustedUntil) {
461
292
  account.quotaExhaustedUntil = undefined;
462
293
  cleared += 1;
294
+ if (!this.isPiAuthAccount(account)) {
295
+ managedChanged = true;
296
+ }
463
297
  }
464
298
  }
465
- if (cleared > 0) {
299
+ if (managedChanged) {
466
300
  this.save();
301
+ }
302
+ if (cleared > 0) {
467
303
  this.notifyStateChanged();
468
304
  }
469
305
  return cleared;
@@ -489,7 +325,9 @@ export class AccountManager {
489
325
 
490
326
  private markNeedsReauth(account: Account): void {
491
327
  account.needsReauth = true;
492
- this.save();
328
+ if (!this.isPiAuthAccount(account)) {
329
+ this.save();
330
+ }
493
331
  this.notifyStateChanged();
494
332
  }
495
333
 
@@ -561,7 +399,7 @@ export class AccountManager {
561
399
  }): Promise<Account | undefined> {
562
400
  const now = Date.now();
563
401
  this.clearExpiredExhaustion(now);
564
- const accounts = this.data.accounts;
402
+ const accounts = this.getAccounts();
565
403
  await this.refreshUsageIfStale(accounts, options);
566
404
 
567
405
  const selected = pickBestAccount(accounts, this.usageCache, {
@@ -569,7 +407,14 @@ export class AccountManager {
569
407
  now,
570
408
  });
571
409
  if (selected) {
572
- 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
+ }
573
418
  }
574
419
  return selected;
575
420
  }
@@ -590,22 +435,28 @@ export class AccountManager {
590
435
  }
591
436
 
592
437
  private clearExpiredExhaustion(now: number): void {
593
- let changed = false;
594
- for (const account of this.data.accounts) {
438
+ let managedChanged = false;
439
+ let anyChanged = false;
440
+ for (const account of this.getAccounts()) {
595
441
  if (account.quotaExhaustedUntil && account.quotaExhaustedUntil <= now) {
596
442
  account.quotaExhaustedUntil = undefined;
597
- changed = true;
443
+ anyChanged = true;
444
+ if (!this.isPiAuthAccount(account)) {
445
+ managedChanged = true;
446
+ }
598
447
  }
599
448
  }
600
- if (changed) {
449
+ if (managedChanged) {
601
450
  this.save();
451
+ }
452
+ if (anyChanged) {
602
453
  this.notifyStateChanged();
603
454
  }
604
455
  }
605
456
 
606
457
  async ensureValidToken(account: Account): Promise<string> {
607
458
  if (account.needsReauth) {
608
- const hint = account.importSource
459
+ const hint = this.isPiAuthAccount(account)
609
460
  ? "/login openai-codex"
610
461
  : `/multicodex use ${account.email}`;
611
462
  throw new Error(
@@ -617,10 +468,8 @@ export class AccountManager {
617
468
  return account.accessToken;
618
469
  }
619
470
 
620
- // Imported auth is read-only. MultiCodex never refreshes or writes
621
- // auth.json and instead requires the user to repair pi auth explicitly.
622
- if (account.importSource === "pi-openai-codex") {
623
- return this.ensureValidTokenForImportedAccount(account);
471
+ if (this.isPiAuthAccount(account)) {
472
+ return this.ensureValidTokenForPiAuth(account);
624
473
  }
625
474
 
626
475
  const inflight = this.refreshPromises.get(account.email);
@@ -655,20 +504,15 @@ export class AccountManager {
655
504
  }
656
505
 
657
506
  /**
658
- * Read-only path for imported pi auth.
659
- *
660
- * MultiCodex may read auth.json to mirror pi's currently active Codex auth,
661
- * but it must never refresh or write auth.json itself.
507
+ * Read-only refresh for the ephemeral pi auth account.
508
+ * Re-reads auth.json for fresh tokens. Never writes anything.
662
509
  */
663
- private async ensureValidTokenForImportedAccount(
664
- account: Account,
665
- ): Promise<string> {
510
+ private async ensureValidTokenForPiAuth(account: Account): Promise<string> {
666
511
  const latest = await loadImportedOpenAICodexAuth();
667
512
  if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
668
513
  account.accessToken = latest.credentials.access;
669
514
  account.refreshToken = latest.credentials.refresh;
670
515
  account.expiresAt = latest.credentials.expires;
671
- account.importFingerprint = latest.fingerprint;
672
516
  const accountId =
673
517
  typeof latest.credentials.accountId === "string"
674
518
  ? latest.credentials.accountId
@@ -676,14 +520,14 @@ export class AccountManager {
676
520
  if (accountId) {
677
521
  account.accountId = accountId;
678
522
  }
679
- this.save();
680
523
  this.notifyStateChanged();
681
524
  return account.accessToken;
682
525
  }
683
526
 
684
- this.markNeedsReauth(account);
527
+ this.piAuthAccount = undefined;
528
+ this.notifyStateChanged();
685
529
  throw new Error(
686
- `${account.email}: imported pi auth is expired — run /login openai-codex to re-authenticate`,
530
+ `${account.email}: pi auth expired — run /login openai-codex`,
687
531
  );
688
532
  }
689
533
  }
package/commands.ts CHANGED
@@ -102,19 +102,14 @@ function getAccountTags(
102
102
  const manual = accountManager.getManualAccount();
103
103
  const quotaHit =
104
104
  account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
105
- const imported = account.importSource
106
- ? account.importMode === "synthetic"
107
- ? "pi auth only"
108
- : "pi auth"
109
- : null;
110
105
  return [
111
106
  active?.email === account.email ? "active" : null,
112
107
  manual?.email === account.email ? "manual" : null,
108
+ accountManager.isPiAuthAccount(account) ? "pi auth" : null,
113
109
  account.needsReauth ? "needs reauth" : null,
114
110
  isPlaceholderAccount(account) ? "placeholder" : null,
115
111
  quotaHit ? "quota" : null,
116
112
  isUsageUntouched(usage) ? "untouched" : null,
117
- imported,
118
113
  ].filter((value): value is string => Boolean(value));
119
114
  }
120
115
 
@@ -245,11 +240,7 @@ async function loginAndActivateAccount(
245
240
  onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
246
241
  });
247
242
 
248
- const existing = accountManager.getAccount(identifier);
249
243
  const account = accountManager.addOrUpdateAccount(identifier, creds);
250
- if (existing?.importSource) {
251
- accountManager.detachImportedAuth(account.email);
252
- }
253
244
  accountManager.setManualAccount(account.email);
254
245
  ctx.ui.notify(`Now using ${account.email}`, "info");
255
246
  return account.email;
@@ -659,7 +650,6 @@ async function runAccountsSubcommand(
659
650
  statusController: ReturnType<typeof createUsageStatusController>,
660
651
  rest: string,
661
652
  ): Promise<void> {
662
- await accountManager.syncImportedOpenAICodexAuth();
663
653
  await accountManager.refreshUsageForAllAccounts();
664
654
 
665
655
  if (rest) {
@@ -722,7 +712,7 @@ async function runRotationSubcommand(
722
712
  "Current policy: manual account first, then untouched accounts, then earliest weekly reset, then random fallback.",
723
713
  "If token validation fails before a request starts, MultiCodex skips that account and retries another one.",
724
714
  "If a request hits quota or rate limit before any output streams, MultiCodex marks the account on cooldown and retries.",
725
- "Imported pi auth is merged into the managed pool so duplicate credentials do not consume extra rotation slots.",
715
+ "If pi auth is active, it participates in rotation as an ephemeral account without being persisted.",
726
716
  ];
727
717
 
728
718
  if (!ctx.hasUI) {
@@ -751,8 +741,10 @@ async function runVerifySubcommand(
751
741
  ): Promise<void> {
752
742
  const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
753
743
  const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
754
- const authImported = await accountManager.syncImportedOpenAICodexAuth();
755
744
  await statusController.loadPreferences(ctx);
745
+ const hasPiAuth = accountManager
746
+ .getAccounts()
747
+ .some((a) => accountManager.isPiAuthAccount(a));
756
748
  const accounts = accountManager.getAccounts().length;
757
749
  const active = accountManager.getActiveAccount()?.email ?? "none";
758
750
  const needsReauth = accountManager.getAccountsNeedingReauth().length;
@@ -760,7 +752,7 @@ async function runVerifySubcommand(
760
752
 
761
753
  if (!ctx.hasUI) {
762
754
  ctx.ui.notify(
763
- `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"} needsReauth=${needsReauth}`,
755
+ `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} piAuth=${hasPiAuth ? "loaded" : "none"} needsReauth=${needsReauth}`,
764
756
  ok ? "info" : "warning",
765
757
  );
766
758
  return;
@@ -771,8 +763,8 @@ async function runVerifySubcommand(
771
763
  `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
772
764
  `managed accounts: ${accounts}`,
773
765
  `active account: ${active}`,
766
+ `pi auth (ephemeral): ${hasPiAuth ? "loaded" : "none"}`,
774
767
  `accounts needing re-authentication: ${needsReauth}`,
775
- `auth import changed state: ${authImported ? "yes" : "no"}`,
776
768
  ];
777
769
  await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
778
770
  }
@@ -871,7 +863,6 @@ async function runRefreshSubcommand(
871
863
  statusController: ReturnType<typeof createUsageStatusController>,
872
864
  rest: string,
873
865
  ): Promise<void> {
874
- await accountManager.syncImportedOpenAICodexAuth();
875
866
  if (!rest || rest === "all") {
876
867
  if (!ctx.hasUI || rest === "all") {
877
868
  await refreshAllAccounts(ctx, accountManager);
@@ -892,7 +883,6 @@ async function runReauthSubcommand(
892
883
  statusController: ReturnType<typeof createUsageStatusController>,
893
884
  rest: string,
894
885
  ): Promise<void> {
895
- await accountManager.syncImportedOpenAICodexAuth();
896
886
  if (rest) {
897
887
  await reauthenticateAccount(pi, ctx, accountManager, rest);
898
888
  await statusController.refreshFor(ctx);
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.syncImportedOpenAICodexAuth();
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 = a.importSource
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
@@ -1,8 +1,5 @@
1
1
  export { AccountManager } from "./account-manager";
2
- export {
3
- loadImportedOpenAICodexAuth,
4
- parseImportedOpenAICodexAuth,
5
- } from "./auth";
2
+ export { parseImportedOpenAICodexAuth } from "./auth";
6
3
  export { default } from "./extension";
7
4
  export {
8
5
  buildMulticodexProviderConfig,
@@ -25,6 +22,7 @@ export { createStreamWrapper } from "./stream-wrapper";
25
22
  export type { CodexUsageSnapshot } from "./usage";
26
23
  export {
27
24
  formatResetAt,
25
+ getMaxUsedPercent,
28
26
  getNextResetAt,
29
27
  getWeeklyResetAt,
30
28
  isUsageUntouched,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.2.1",
3
+ "version": "2.3.1",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -48,12 +48,14 @@
48
48
  "usage.ts",
49
49
  "README.md",
50
50
  "LICENSE",
51
- "assets/**"
51
+ "assets/**",
52
+ "schemas/**"
52
53
  ],
53
54
  "scripts": {
54
55
  "lint": "biome check .",
55
56
  "test": "vitest run -c vitest.config.ts",
56
57
  "tsgo": "tsgo -p tsconfig.json",
58
+ "generate:schema": "bun scripts/generate-schema.ts",
57
59
  "check": "pnpm lint && pnpm tsgo && pnpm test",
58
60
  "pack:dry": "npm pack --dry-run",
59
61
  "release:dry": "pnpm exec semantic-release --dry-run"
@@ -98,6 +100,7 @@
98
100
  "node": "24.14.0"
99
101
  },
100
102
  "dependencies": {
101
- "pi-provider-utils": "^0.0.0"
103
+ "pi-provider-utils": "^0.0.0",
104
+ "zod": "^4.3.6"
102
105
  }
103
106
  }
@@ -0,0 +1,77 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "type": "object",
4
+ "properties": {
5
+ "$schema": {
6
+ "description": "JSON Schema reference for editor support",
7
+ "type": "string"
8
+ },
9
+ "version": {
10
+ "type": "integer",
11
+ "exclusiveMinimum": 0,
12
+ "maximum": 9007199254740991,
13
+ "description": "Storage schema version"
14
+ },
15
+ "accounts": {
16
+ "type": "array",
17
+ "items": {
18
+ "$ref": "#/$defs/Account"
19
+ },
20
+ "description": "Managed account entries"
21
+ },
22
+ "activeEmail": {
23
+ "description": "Currently active account email",
24
+ "type": "string"
25
+ }
26
+ },
27
+ "required": ["version", "accounts"],
28
+ "additionalProperties": false,
29
+ "id": "MultiCodexStorage",
30
+ "description": "MultiCodex managed account storage",
31
+ "$defs": {
32
+ "Account": {
33
+ "type": "object",
34
+ "properties": {
35
+ "email": {
36
+ "type": "string",
37
+ "minLength": 1,
38
+ "description": "Account email identifier"
39
+ },
40
+ "accessToken": {
41
+ "type": "string",
42
+ "minLength": 1,
43
+ "description": "OAuth access token (JWT)"
44
+ },
45
+ "refreshToken": {
46
+ "type": "string",
47
+ "minLength": 1,
48
+ "description": "OAuth refresh token"
49
+ },
50
+ "expiresAt": {
51
+ "type": "number",
52
+ "description": "Token expiry timestamp (ms since epoch)"
53
+ },
54
+ "accountId": {
55
+ "description": "OpenAI account ID",
56
+ "type": "string"
57
+ },
58
+ "lastUsed": {
59
+ "description": "Last manual selection timestamp (ms)",
60
+ "type": "number"
61
+ },
62
+ "quotaExhaustedUntil": {
63
+ "description": "Quota cooldown expiry (ms)",
64
+ "type": "number"
65
+ },
66
+ "needsReauth": {
67
+ "description": "Account needs re-authentication",
68
+ "type": "boolean"
69
+ }
70
+ },
71
+ "required": ["email", "accessToken", "refreshToken", "expiresAt"],
72
+ "additionalProperties": false,
73
+ "id": "Account",
74
+ "description": "A managed OpenAI Codex account"
75
+ }
76
+ }
77
+ }
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/status.ts CHANGED
@@ -411,11 +411,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
411
411
 
412
412
  renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
413
413
 
414
- let activeAccount = accountManager.getActiveAccount();
415
- if (!activeAccount) {
416
- await accountManager.syncImportedOpenAICodexAuth();
417
- activeAccount = accountManager.getActiveAccount();
418
- }
414
+ const activeAccount = accountManager.getActiveAccount();
419
415
  if (!activeAccount) {
420
416
  ctx.ui.setStatus(
421
417
  STATUS_KEY,
package/storage.ts CHANGED
@@ -1,38 +1,169 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { getAgentPath } from "pi-provider-utils/agent-paths";
4
+ import { z } from "zod";
4
5
 
5
- export interface Account {
6
- email: string;
7
- accessToken: string;
8
- refreshToken: string;
9
- expiresAt: number;
10
- accountId?: string;
11
- lastUsed?: number;
12
- quotaExhaustedUntil?: number;
13
- importSource?: "pi-openai-codex";
14
- importMode?: "linked" | "synthetic";
15
- importFingerprint?: string;
16
- needsReauth?: boolean;
6
+ // ---------------------------------------------------------------------------
7
+ // Schema
8
+ // ---------------------------------------------------------------------------
9
+
10
+ const CURRENT_VERSION = 1;
11
+
12
+ const SCHEMA_URL =
13
+ "https://raw.githubusercontent.com/victor-software-house/pi-multicodex/main/schemas/codex-accounts.schema.json";
14
+
15
+ const AccountSchema = z
16
+ .object({
17
+ email: z.string().min(1).meta({ description: "Account email identifier" }),
18
+ accessToken: z
19
+ .string()
20
+ .min(1)
21
+ .meta({ description: "OAuth access token (JWT)" }),
22
+ refreshToken: z
23
+ .string()
24
+ .min(1)
25
+ .meta({ description: "OAuth refresh token" }),
26
+ expiresAt: z
27
+ .number()
28
+ .meta({ description: "Token expiry timestamp (ms since epoch)" }),
29
+ accountId: z.string().optional().meta({ description: "OpenAI account ID" }),
30
+ lastUsed: z
31
+ .number()
32
+ .optional()
33
+ .meta({ description: "Last manual selection timestamp (ms)" }),
34
+ quotaExhaustedUntil: z
35
+ .number()
36
+ .optional()
37
+ .meta({ description: "Quota cooldown expiry (ms)" }),
38
+ needsReauth: z
39
+ .boolean()
40
+ .optional()
41
+ .meta({ description: "Account needs re-authentication" }),
42
+ })
43
+ .meta({ id: "Account", description: "A managed OpenAI Codex account" });
44
+
45
+ export const StorageSchema = z
46
+ .object({
47
+ $schema: z
48
+ .string()
49
+ .optional()
50
+ .meta({ description: "JSON Schema reference for editor support" }),
51
+ version: z
52
+ .number()
53
+ .int()
54
+ .positive()
55
+ .meta({ description: "Storage schema version" }),
56
+ accounts: z
57
+ .array(AccountSchema)
58
+ .meta({ description: "Managed account entries" }),
59
+ activeEmail: z
60
+ .string()
61
+ .optional()
62
+ .meta({ description: "Currently active account email" }),
63
+ })
64
+ .meta({
65
+ id: "MultiCodexStorage",
66
+ description: "MultiCodex managed account storage",
67
+ });
68
+
69
+ export type Account = z.infer<typeof AccountSchema>;
70
+ export type StorageData = z.infer<typeof StorageSchema>;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Migration
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const LEGACY_FIELDS = [
77
+ "importSource",
78
+ "importMode",
79
+ "importFingerprint",
80
+ ] as const;
81
+
82
+ function stripLegacyFields(raw: Record<string, unknown>): boolean {
83
+ let stripped = false;
84
+ for (const key of LEGACY_FIELDS) {
85
+ if (key in raw) {
86
+ delete raw[key];
87
+ stripped = true;
88
+ }
89
+ }
90
+ return stripped;
17
91
  }
18
92
 
19
- export interface StorageData {
20
- accounts: Account[];
21
- activeEmail?: string;
93
+ function migrateRawStorage(raw: unknown): StorageData {
94
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
95
+ return { version: CURRENT_VERSION, accounts: [], activeEmail: undefined };
96
+ }
97
+
98
+ const record = raw as Record<string, unknown>;
99
+
100
+ // Strip legacy import fields from each account
101
+ const rawAccounts = Array.isArray(record.accounts) ? record.accounts : [];
102
+ for (const entry of rawAccounts) {
103
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
104
+ stripLegacyFields(entry as Record<string, unknown>);
105
+ }
106
+ }
107
+
108
+ // Add version if missing (pre-v1 files)
109
+ if (!("version" in record) || typeof record.version !== "number") {
110
+ record.version = CURRENT_VERSION;
111
+ }
112
+
113
+ const result = StorageSchema.safeParse(record);
114
+ if (result.success) {
115
+ return result.data;
116
+ }
117
+
118
+ // Schema validation failed — salvage what we can
119
+ const accounts: Account[] = [];
120
+ for (const entry of rawAccounts) {
121
+ const parsed = AccountSchema.safeParse(entry);
122
+ if (parsed.success) {
123
+ accounts.push(parsed.data);
124
+ }
125
+ }
126
+ return { version: CURRENT_VERSION, accounts, activeEmail: undefined };
22
127
  }
23
128
 
129
+ // ---------------------------------------------------------------------------
130
+ // I/O
131
+ // ---------------------------------------------------------------------------
132
+
24
133
  export const STORAGE_FILE = getAgentPath("codex-accounts.json");
25
134
 
26
135
  export function loadStorage(): StorageData {
27
136
  try {
28
137
  if (fs.existsSync(STORAGE_FILE)) {
29
- return JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as StorageData;
138
+ const text = fs.readFileSync(STORAGE_FILE, "utf-8");
139
+ const raw = JSON.parse(text) as Record<string, unknown>;
140
+ const needsMigration =
141
+ !("version" in raw) ||
142
+ raw.version !== CURRENT_VERSION ||
143
+ needsLegacyStrip(raw);
144
+ const data = migrateRawStorage(raw);
145
+ if (needsMigration) {
146
+ saveStorage(data);
147
+ }
148
+ return data;
30
149
  }
31
150
  } catch (error) {
32
151
  console.error("Failed to load multicodex accounts:", error);
33
152
  }
34
153
 
35
- return { accounts: [] };
154
+ return { version: CURRENT_VERSION, accounts: [], activeEmail: undefined };
155
+ }
156
+
157
+ function needsLegacyStrip(raw: Record<string, unknown>): boolean {
158
+ const accounts = Array.isArray(raw.accounts) ? raw.accounts : [];
159
+ for (const entry of accounts) {
160
+ if (entry && typeof entry === "object" && !Array.isArray(entry)) {
161
+ for (const key of LEGACY_FIELDS) {
162
+ if (key in (entry as Record<string, unknown>)) return true;
163
+ }
164
+ }
165
+ }
166
+ return false;
36
167
  }
37
168
 
38
169
  export function saveStorage(data: StorageData): void {
@@ -41,7 +172,13 @@ export function saveStorage(data: StorageData): void {
41
172
  if (!fs.existsSync(dir)) {
42
173
  fs.mkdirSync(dir, { recursive: true });
43
174
  }
44
- fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2));
175
+ const output = {
176
+ $schema: SCHEMA_URL,
177
+ version: CURRENT_VERSION,
178
+ accounts: data.accounts,
179
+ activeEmail: data.activeEmail,
180
+ };
181
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(output, null, 2));
45
182
  } catch (error) {
46
183
  console.error("Failed to save multicodex accounts:", error);
47
184
  }
package/stream-wrapper.ts CHANGED
@@ -39,7 +39,7 @@ export function createStreamWrapper(
39
39
 
40
40
  (async () => {
41
41
  try {
42
- await accountManager.syncImportedOpenAICodexAuth();
42
+ await accountManager.waitUntilReady();
43
43
  const excludedEmails = new Set<string>();
44
44
  for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
45
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 {