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

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.
@@ -2,12 +2,8 @@ import {
2
2
  type OAuthCredentials,
3
3
  refreshOpenAICodexToken,
4
4
  } from "@mariozechner/pi-ai/oauth";
5
- import { AuthStorage } from "@mariozechner/pi-coding-agent";
6
5
  import { normalizeUnknownError } from "pi-provider-utils/streams";
7
- import {
8
- loadImportedOpenAICodexAuth,
9
- writeActiveTokenToAuthJson,
10
- } from "./auth";
6
+ import { loadImportedOpenAICodexAuth } from "./auth";
11
7
  import { isAccountAvailable, pickBestAccount } from "./selection";
12
8
  import {
13
9
  type Account,
@@ -27,6 +23,7 @@ type StateChangeHandler = () => void;
27
23
 
28
24
  export class AccountManager {
29
25
  private data: StorageData;
26
+ private piAuthAccount?: Account;
30
27
  private usageCache = new Map<string, CodexUsageSnapshot>();
31
28
  private refreshPromises = new Map<string, Promise<string>>();
32
29
  private warningHandler?: WarningHandler;
@@ -48,23 +45,6 @@ export class AccountManager {
48
45
  }
49
46
  }
50
47
 
51
- /**
52
- * Write the active account's tokens to auth.json so pi's background features
53
- * (rename, compaction) can resolve a valid API key via AuthStorage.
54
- */
55
- private syncActiveTokenToAuthJson(account: Account): void {
56
- try {
57
- writeActiveTokenToAuthJson({
58
- access: account.accessToken,
59
- refresh: account.refreshToken,
60
- expires: account.expiresAt,
61
- accountId: account.accountId,
62
- });
63
- } catch {
64
- // Best-effort sync — do not block token resolution.
65
- }
66
- }
67
-
68
48
  onStateChange(handler: StateChangeHandler): () => void {
69
49
  this.stateChangeHandlers.add(handler);
70
50
  return () => {
@@ -73,13 +53,21 @@ export class AccountManager {
73
53
  }
74
54
 
75
55
  getAccounts(): Account[] {
56
+ if (this.piAuthAccount) {
57
+ return [...this.data.accounts, this.piAuthAccount];
58
+ }
76
59
  return this.data.accounts;
77
60
  }
78
61
 
79
62
  getAccount(email: string): Account | undefined {
63
+ if (this.piAuthAccount?.email === email) return this.piAuthAccount;
80
64
  return this.data.accounts.find((a) => a.email === email);
81
65
  }
82
66
 
67
+ isPiAuthAccount(account: Account): boolean {
68
+ return this.piAuthAccount !== undefined && account === this.piAuthAccount;
69
+ }
70
+
83
71
  setWarningHandler(handler?: WarningHandler): void {
84
72
  this.warningHandler = handler;
85
73
  }
@@ -93,32 +81,14 @@ export class AccountManager {
93
81
  return;
94
82
  }
95
83
  this.warnedAuthFailureEmails.add(account.email);
96
- const hint = account.importSource
97
- ? "/multicodex reauth"
84
+ const hint = this.isPiAuthAccount(account)
85
+ ? "/login openai-codex"
98
86
  : `/multicodex reauth ${account.email}`;
99
87
  this.warningHandler?.(
100
88
  `Multicodex skipped ${account.email} during rotation: ${normalizeUnknownError(error)}. Account is flagged in /multicodex accounts. Run ${hint} to repair it.`,
101
89
  );
102
90
  }
103
91
 
104
- private updateAccountEmail(account: Account, email: string): boolean {
105
- if (account.email === email) return false;
106
- const previousEmail = account.email;
107
- account.email = email;
108
- if (this.data.activeEmail === previousEmail) {
109
- this.data.activeEmail = email;
110
- }
111
- if (this.manualEmail === previousEmail) {
112
- this.manualEmail = email;
113
- }
114
- const cached = this.usageCache.get(previousEmail);
115
- if (cached) {
116
- this.usageCache.delete(previousEmail);
117
- this.usageCache.set(email, cached);
118
- }
119
- return true;
120
- }
121
-
122
92
  private removeAccountRecord(account: Account): boolean {
123
93
  const index = this.data.accounts.findIndex(
124
94
  (candidate) => candidate.email === account.email,
@@ -138,25 +108,7 @@ export class AccountManager {
138
108
  return true;
139
109
  }
140
110
 
141
- private findAccountByRefreshToken(
142
- refreshToken: string,
143
- excludeEmail?: string,
144
- ): Account | undefined {
145
- return this.data.accounts.find(
146
- (account) =>
147
- account.refreshToken === refreshToken && account.email !== excludeEmail,
148
- );
149
- }
150
-
151
- private applyCredentials(
152
- account: Account,
153
- creds: OAuthCredentials,
154
- options?: {
155
- importSource?: "pi-openai-codex";
156
- importMode?: "linked" | "synthetic";
157
- importFingerprint?: string;
158
- },
159
- ): boolean {
111
+ private applyCredentials(account: Account, creds: OAuthCredentials): boolean {
160
112
  const accountId =
161
113
  typeof creds.accountId === "string" ? creds.accountId : undefined;
162
114
  let changed = false;
@@ -176,24 +128,6 @@ export class AccountManager {
176
128
  account.accountId = accountId;
177
129
  changed = true;
178
130
  }
179
- if (
180
- options?.importSource &&
181
- account.importSource !== options.importSource
182
- ) {
183
- account.importSource = options.importSource;
184
- changed = true;
185
- }
186
- if (options?.importMode && account.importMode !== options.importMode) {
187
- account.importMode = options.importMode;
188
- changed = true;
189
- }
190
- if (
191
- options?.importFingerprint &&
192
- account.importFingerprint !== options.importFingerprint
193
- ) {
194
- account.importFingerprint = options.importFingerprint;
195
- changed = true;
196
- }
197
131
  if (account.needsReauth) {
198
132
  account.needsReauth = undefined;
199
133
  this.warnedAuthFailureEmails.delete(account.email);
@@ -202,66 +136,28 @@ export class AccountManager {
202
136
  return changed;
203
137
  }
204
138
 
205
- addOrUpdateAccount(
206
- email: string,
207
- creds: OAuthCredentials,
208
- options?: {
209
- importSource?: "pi-openai-codex";
210
- importMode?: "linked" | "synthetic";
211
- importFingerprint?: string;
212
- preserveActive?: boolean;
213
- },
214
- ): Account {
215
- const existing = this.getAccount(email);
216
- const duplicate = existing
217
- ? undefined
218
- : this.findAccountByRefreshToken(creds.refresh);
219
- let target = existing ?? duplicate;
220
- let changed = false;
221
-
222
- if (target) {
223
- if (
224
- duplicate?.importSource === "pi-openai-codex" &&
225
- duplicate.email !== email &&
226
- !this.getAccount(email)
227
- ) {
228
- changed = this.updateAccountEmail(duplicate, email) || changed;
229
- }
230
- changed =
231
- this.applyCredentials(target, creds, {
232
- ...options,
233
- importMode:
234
- options?.importMode ??
235
- (duplicate?.importMode === "synthetic" ? "linked" : undefined),
236
- }) || changed;
237
- } else {
238
- target = {
239
- email,
240
- accessToken: creds.access,
241
- refreshToken: creds.refresh,
242
- expiresAt: creds.expires,
243
- accountId:
244
- typeof creds.accountId === "string" ? creds.accountId : undefined,
245
- importSource: options?.importSource,
246
- importMode: options?.importMode,
247
- importFingerprint: options?.importFingerprint,
248
- };
249
- this.data.accounts.push(target);
250
- changed = true;
251
- }
252
-
253
- if (!options?.preserveActive) {
254
- if (this.data.activeEmail !== target.email) {
255
- this.setActiveAccount(target.email);
256
- return target;
139
+ addOrUpdateAccount(email: string, creds: OAuthCredentials): Account {
140
+ const existing = this.data.accounts.find((a) => a.email === email);
141
+ if (existing) {
142
+ const changed = this.applyCredentials(existing, creds);
143
+ if (changed) {
144
+ this.save();
145
+ this.notifyStateChanged();
257
146
  }
147
+ return existing;
258
148
  }
259
149
 
260
- if (changed) {
261
- this.save();
262
- this.notifyStateChanged();
263
- }
264
- return target;
150
+ const account: Account = {
151
+ email,
152
+ accessToken: creds.access,
153
+ refreshToken: creds.refresh,
154
+ expiresAt: creds.expires,
155
+ accountId:
156
+ typeof creds.accountId === "string" ? creds.accountId : undefined,
157
+ };
158
+ this.data.accounts.push(account);
159
+ this.setActiveAccount(email);
160
+ return account;
265
161
  }
266
162
 
267
163
  getActiveAccount(): Account | undefined {
@@ -307,140 +203,40 @@ export class AccountManager {
307
203
  this.notifyStateChanged();
308
204
  }
309
205
 
310
- getImportedAccount(): Account | undefined {
311
- return this.data.accounts.find(
312
- (account) => account.importSource === "pi-openai-codex",
313
- );
314
- }
315
-
316
- private getLinkedImportedAccount(): Account | undefined {
317
- return this.data.accounts.find(
318
- (account) =>
319
- account.importSource === "pi-openai-codex" &&
320
- account.importMode !== "synthetic",
321
- );
322
- }
323
-
324
- private getSyntheticImportedAccount(): Account | undefined {
325
- return this.data.accounts.find(
326
- (account) =>
327
- account.importSource === "pi-openai-codex" &&
328
- account.importMode === "synthetic",
329
- );
330
- }
331
-
332
- private findManagedImportedTarget(imported: {
333
- identifier: string;
334
- credentials: OAuthCredentials;
335
- }): Account | undefined {
336
- const byRefreshToken = this.data.accounts.find(
337
- (account) =>
338
- account.importMode !== "synthetic" &&
339
- account.refreshToken === imported.credentials.refresh,
340
- );
341
- if (byRefreshToken) {
342
- return byRefreshToken;
343
- }
344
-
345
- return this.data.accounts.find(
346
- (account) =>
347
- account.importMode !== "synthetic" &&
348
- account.email === imported.identifier,
349
- );
350
- }
351
-
352
- private clearImportedLink(account: Account): boolean {
353
- let changed = false;
354
- if (account.importSource) {
355
- account.importSource = undefined;
356
- changed = true;
357
- }
358
- if (account.importMode) {
359
- account.importMode = undefined;
360
- changed = true;
361
- }
362
- if (account.importFingerprint) {
363
- account.importFingerprint = undefined;
364
- changed = true;
365
- }
366
- return changed;
367
- }
368
-
369
- async syncImportedOpenAICodexAuth(): Promise<boolean> {
206
+ /**
207
+ * Read pi's openai-codex auth from auth.json and expose it as a
208
+ * memory-only ephemeral account. Never persists to codex-accounts.json.
209
+ * If the identity already exists as a managed account, skip it.
210
+ */
211
+ async loadPiAuth(): Promise<void> {
370
212
  const imported = await loadImportedOpenAICodexAuth();
371
- if (!imported) return false;
372
-
373
- const linkedImported = this.getLinkedImportedAccount();
374
- const syntheticImported = this.getSyntheticImportedAccount();
375
- const currentImported = linkedImported ?? syntheticImported;
376
- if (
377
- currentImported?.importFingerprint === imported.fingerprint &&
378
- (currentImported.importMode !== "synthetic" ||
379
- currentImported.email === imported.identifier)
380
- ) {
381
- return false;
382
- }
383
-
384
- const matchingAccount = this.findManagedImportedTarget(imported);
385
- if (matchingAccount) {
386
- let changed = this.applyCredentials(
387
- matchingAccount,
388
- imported.credentials,
389
- {
390
- importSource: "pi-openai-codex",
391
- importMode: "linked",
392
- importFingerprint: imported.fingerprint,
393
- },
394
- );
395
- if (linkedImported && linkedImported !== matchingAccount) {
396
- changed = this.clearImportedLink(linkedImported) || changed;
397
- }
398
- if (syntheticImported) {
399
- changed = this.removeAccountRecord(syntheticImported) || changed;
400
- }
401
- if (changed) {
402
- this.save();
403
- this.notifyStateChanged();
404
- }
405
- return changed;
213
+ if (!imported) {
214
+ this.piAuthAccount = undefined;
215
+ this.notifyStateChanged();
216
+ return;
406
217
  }
407
218
 
408
- if (linkedImported) {
409
- const changed = this.clearImportedLink(linkedImported);
410
- if (changed) {
411
- this.save();
412
- this.notifyStateChanged();
413
- }
414
- }
219
+ const alreadyManaged = this.data.accounts.find(
220
+ (a) => a.email === imported.identifier,
221
+ );
415
222
 
416
- if (syntheticImported) {
417
- let changed = false;
418
- if (syntheticImported.email !== imported.identifier) {
419
- changed = this.updateAccountEmail(
420
- syntheticImported,
421
- imported.identifier,
422
- );
423
- }
424
- changed =
425
- this.applyCredentials(syntheticImported, imported.credentials, {
426
- importSource: "pi-openai-codex",
427
- importMode: "synthetic",
428
- importFingerprint: imported.fingerprint,
429
- }) || changed;
430
- if (changed) {
431
- this.save();
432
- this.notifyStateChanged();
433
- }
434
- return changed;
223
+ if (alreadyManaged) {
224
+ this.piAuthAccount = undefined;
225
+ this.notifyStateChanged();
226
+ return;
435
227
  }
436
228
 
437
- this.addOrUpdateAccount(imported.identifier, imported.credentials, {
438
- importSource: "pi-openai-codex",
439
- importMode: "synthetic",
440
- importFingerprint: imported.fingerprint,
441
- preserveActive: true,
442
- });
443
- return true;
229
+ this.piAuthAccount = {
230
+ email: imported.identifier,
231
+ accessToken: imported.credentials.access,
232
+ refreshToken: imported.credentials.refresh,
233
+ expiresAt: imported.credentials.expires,
234
+ accountId:
235
+ typeof imported.credentials.accountId === "string"
236
+ ? imported.credentials.accountId
237
+ : undefined,
238
+ };
239
+ this.notifyStateChanged();
444
240
  }
445
241
 
446
242
  getAvailableManualAccount(options?: {
@@ -499,7 +295,9 @@ export class AccountManager {
499
295
 
500
296
  private markNeedsReauth(account: Account): void {
501
297
  account.needsReauth = true;
502
- this.save();
298
+ if (!this.isPiAuthAccount(account)) {
299
+ this.save();
300
+ }
503
301
  this.notifyStateChanged();
504
302
  }
505
303
 
@@ -571,7 +369,7 @@ export class AccountManager {
571
369
  }): Promise<Account | undefined> {
572
370
  const now = Date.now();
573
371
  this.clearExpiredExhaustion(now);
574
- const accounts = this.data.accounts;
372
+ const accounts = this.getAccounts();
575
373
  await this.refreshUsageIfStale(accounts, options);
576
374
 
577
375
  const selected = pickBestAccount(accounts, this.usageCache, {
@@ -615,7 +413,7 @@ export class AccountManager {
615
413
 
616
414
  async ensureValidToken(account: Account): Promise<string> {
617
415
  if (account.needsReauth) {
618
- const hint = account.importSource
416
+ const hint = this.isPiAuthAccount(account)
619
417
  ? "/login openai-codex"
620
418
  : `/multicodex use ${account.email}`;
621
419
  throw new Error(
@@ -624,14 +422,11 @@ export class AccountManager {
624
422
  }
625
423
 
626
424
  if (Date.now() < account.expiresAt - 5 * 60 * 1000) {
627
- this.syncActiveTokenToAuthJson(account);
628
425
  return account.accessToken;
629
426
  }
630
427
 
631
- // For the imported pi account, delegate to AuthStorage so we share pi's
632
- // file lock and never race with pi's own refresh path.
633
- if (account.importSource === "pi-openai-codex") {
634
- return this.ensureValidTokenForImportedAccount(account);
428
+ if (this.isPiAuthAccount(account)) {
429
+ return this.ensureValidTokenForPiAuth(account);
635
430
  }
636
431
 
637
432
  const inflight = this.refreshPromises.get(account.email);
@@ -652,7 +447,6 @@ export class AccountManager {
652
447
  }
653
448
  this.save();
654
449
  this.notifyStateChanged();
655
- this.syncActiveTokenToAuthJson(account);
656
450
  return account.accessToken;
657
451
  } catch (error) {
658
452
  this.markNeedsReauth(account);
@@ -667,23 +461,15 @@ export class AccountManager {
667
461
  }
668
462
 
669
463
  /**
670
- * Refresh path for the imported pi account.
671
- *
672
- * Uses AuthStorage so our refresh is serialised by the same file lock that
673
- * pi's own credential refresh uses. This prevents "refresh_token_reused"
674
- * errors caused by pi and multicodex both refreshing the same token
675
- * simultaneously.
464
+ * Read-only refresh for the ephemeral pi auth account.
465
+ * Re-reads auth.json for fresh tokens. Never writes anything.
676
466
  */
677
- private async ensureValidTokenForImportedAccount(
678
- account: Account,
679
- ): Promise<string> {
680
- // Check if pi already refreshed since our last sync.
467
+ private async ensureValidTokenForPiAuth(account: Account): Promise<string> {
681
468
  const latest = await loadImportedOpenAICodexAuth();
682
469
  if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
683
470
  account.accessToken = latest.credentials.access;
684
471
  account.refreshToken = latest.credentials.refresh;
685
472
  account.expiresAt = latest.credentials.expires;
686
- account.importFingerprint = latest.fingerprint;
687
473
  const accountId =
688
474
  typeof latest.credentials.accountId === "string"
689
475
  ? latest.credentials.accountId
@@ -691,45 +477,14 @@ export class AccountManager {
691
477
  if (accountId) {
692
478
  account.accountId = accountId;
693
479
  }
694
- this.save();
695
480
  this.notifyStateChanged();
696
481
  return account.accessToken;
697
482
  }
698
483
 
699
- // Both our copy and auth.json are expired — let AuthStorage refresh with
700
- // its file lock so only one caller (us or pi) fires the API call.
701
- let apiKey: string | undefined;
702
- try {
703
- const authStorage = AuthStorage.create();
704
- apiKey = await authStorage.getApiKey("openai-codex");
705
- } catch {
706
- // AuthStorage refresh failed; mark for re-auth below.
707
- }
708
- if (!apiKey) {
709
- this.markNeedsReauth(account);
710
- throw new Error(
711
- `${account.email}: token refresh failed — run /login openai-codex to re-authenticate`,
712
- );
713
- }
714
-
715
- // Read the refreshed tokens back from auth.json.
716
- const refreshed = await loadImportedOpenAICodexAuth();
717
- if (refreshed) {
718
- account.accessToken = refreshed.credentials.access;
719
- account.refreshToken = refreshed.credentials.refresh;
720
- account.expiresAt = refreshed.credentials.expires;
721
- account.importFingerprint = refreshed.fingerprint;
722
- const accountId =
723
- typeof refreshed.credentials.accountId === "string"
724
- ? refreshed.credentials.accountId
725
- : undefined;
726
- if (accountId) {
727
- account.accountId = accountId;
728
- }
729
- this.save();
730
- this.notifyStateChanged();
731
- }
732
-
733
- return apiKey;
484
+ this.piAuthAccount = undefined;
485
+ this.notifyStateChanged();
486
+ throw new Error(
487
+ `${account.email}: pi auth expired — run /login openai-codex`,
488
+ );
734
489
  }
735
490
  }
package/auth.ts CHANGED
@@ -1,9 +1,4 @@
1
- import {
2
- existsSync,
3
- promises as fs,
4
- readFileSync,
5
- writeFileSync,
6
- } from "node:fs";
1
+ import { promises as fs } from "node:fs";
7
2
  import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
8
3
  import { getAgentAuthPath } from "pi-provider-utils/agent-paths";
9
4
 
@@ -127,47 +122,6 @@ export function parseImportedOpenAICodexAuth(
127
122
  };
128
123
  }
129
124
 
130
- /**
131
- * Write the active account's tokens to auth.json so pi's background features
132
- * (rename, compaction, inline suggestions) can resolve a valid API key through
133
- * the normal AuthStorage path.
134
- */
135
- /**
136
- * Synchronously write the active account's tokens to auth.json so pi's
137
- * background features (rename, compaction) can resolve a valid API key.
138
- *
139
- * Uses synchronous I/O to avoid interleaved writes with pi's own code.
140
- */
141
- export function writeActiveTokenToAuthJson(creds: {
142
- access: string;
143
- refresh: string;
144
- expires: number;
145
- accountId?: string;
146
- }): void {
147
- let auth: Record<string, unknown> = {};
148
- try {
149
- if (existsSync(AUTH_FILE)) {
150
- const raw = readFileSync(AUTH_FILE, "utf8");
151
- const parsed = JSON.parse(raw) as unknown;
152
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
153
- auth = parsed as Record<string, unknown>;
154
- }
155
- }
156
- } catch {
157
- // File missing or corrupt — start fresh.
158
- }
159
-
160
- auth["openai-codex"] = {
161
- type: "oauth",
162
- access: creds.access,
163
- refresh: creds.refresh,
164
- expires: creds.expires,
165
- accountId: creds.accountId,
166
- };
167
-
168
- writeFileSync(AUTH_FILE, JSON.stringify(auth, null, 2));
169
- }
170
-
171
125
  export async function loadImportedOpenAICodexAuth(): Promise<
172
126
  ImportedOpenAICodexAuth | undefined
173
127
  > {
package/commands.ts CHANGED
@@ -18,7 +18,6 @@ import {
18
18
  import { getAgentSettingsPath } from "pi-provider-utils/agent-paths";
19
19
  import { normalizeUnknownError } from "pi-provider-utils/streams";
20
20
  import type { AccountManager } from "./account-manager";
21
- import { writeActiveTokenToAuthJson } from "./auth";
22
21
  import { openLoginInBrowser } from "./browser";
23
22
  import type { createUsageStatusController } from "./status";
24
23
  import { type Account, STORAGE_FILE } from "./storage";
@@ -103,19 +102,14 @@ function getAccountTags(
103
102
  const manual = accountManager.getManualAccount();
104
103
  const quotaHit =
105
104
  account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
106
- const imported = account.importSource
107
- ? account.importMode === "synthetic"
108
- ? "pi auth only"
109
- : "pi auth"
110
- : null;
111
105
  return [
112
106
  active?.email === account.email ? "active" : null,
113
107
  manual?.email === account.email ? "manual" : null,
108
+ accountManager.isPiAuthAccount(account) ? "pi auth" : null,
114
109
  account.needsReauth ? "needs reauth" : null,
115
110
  isPlaceholderAccount(account) ? "placeholder" : null,
116
111
  quotaHit ? "quota" : null,
117
112
  isUsageUntouched(usage) ? "untouched" : null,
118
- imported,
119
113
  ].filter((value): value is string => Boolean(value));
120
114
  }
121
115
 
@@ -247,16 +241,6 @@ async function loginAndActivateAccount(
247
241
  });
248
242
 
249
243
  const account = accountManager.addOrUpdateAccount(identifier, creds);
250
- if (account.importSource) {
251
- writeActiveTokenToAuthJson({
252
- access: creds.access,
253
- refresh: creds.refresh,
254
- expires: creds.expires,
255
- accountId:
256
- typeof creds.accountId === "string" ? creds.accountId : undefined,
257
- });
258
- await accountManager.syncImportedOpenAICodexAuth();
259
- }
260
244
  accountManager.setManualAccount(account.email);
261
245
  ctx.ui.notify(`Now using ${account.email}`, "info");
262
246
  return account.email;
@@ -666,7 +650,6 @@ async function runAccountsSubcommand(
666
650
  statusController: ReturnType<typeof createUsageStatusController>,
667
651
  rest: string,
668
652
  ): Promise<void> {
669
- await accountManager.syncImportedOpenAICodexAuth();
670
653
  await accountManager.refreshUsageForAllAccounts();
671
654
 
672
655
  if (rest) {
@@ -729,7 +712,7 @@ async function runRotationSubcommand(
729
712
  "Current policy: manual account first, then untouched accounts, then earliest weekly reset, then random fallback.",
730
713
  "If token validation fails before a request starts, MultiCodex skips that account and retries another one.",
731
714
  "If a request hits quota or rate limit before any output streams, MultiCodex marks the account on cooldown and retries.",
732
- "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.",
733
716
  ];
734
717
 
735
718
  if (!ctx.hasUI) {
@@ -758,8 +741,10 @@ async function runVerifySubcommand(
758
741
  ): Promise<void> {
759
742
  const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
760
743
  const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
761
- const authImported = await accountManager.syncImportedOpenAICodexAuth();
762
744
  await statusController.loadPreferences(ctx);
745
+ const hasPiAuth = accountManager
746
+ .getAccounts()
747
+ .some((a) => accountManager.isPiAuthAccount(a));
763
748
  const accounts = accountManager.getAccounts().length;
764
749
  const active = accountManager.getActiveAccount()?.email ?? "none";
765
750
  const needsReauth = accountManager.getAccountsNeedingReauth().length;
@@ -767,7 +752,7 @@ async function runVerifySubcommand(
767
752
 
768
753
  if (!ctx.hasUI) {
769
754
  ctx.ui.notify(
770
- `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}`,
771
756
  ok ? "info" : "warning",
772
757
  );
773
758
  return;
@@ -778,8 +763,8 @@ async function runVerifySubcommand(
778
763
  `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
779
764
  `managed accounts: ${accounts}`,
780
765
  `active account: ${active}`,
766
+ `pi auth (ephemeral): ${hasPiAuth ? "loaded" : "none"}`,
781
767
  `accounts needing re-authentication: ${needsReauth}`,
782
- `auth import changed state: ${authImported ? "yes" : "no"}`,
783
768
  ];
784
769
  await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
785
770
  }
@@ -878,7 +863,6 @@ async function runRefreshSubcommand(
878
863
  statusController: ReturnType<typeof createUsageStatusController>,
879
864
  rest: string,
880
865
  ): Promise<void> {
881
- await accountManager.syncImportedOpenAICodexAuth();
882
866
  if (!rest || rest === "all") {
883
867
  if (!ctx.hasUI || rest === "all") {
884
868
  await refreshAllAccounts(ctx, accountManager);
@@ -899,7 +883,6 @@ async function runReauthSubcommand(
899
883
  statusController: ReturnType<typeof createUsageStatusController>,
900
884
  rest: string,
901
885
  ): Promise<void> {
902
- await accountManager.syncImportedOpenAICodexAuth();
903
886
  if (rest) {
904
887
  await reauthenticateAccount(pi, ctx, accountManager, rest);
905
888
  await statusController.refreshFor(ctx);
package/hooks.ts CHANGED
@@ -6,13 +6,13 @@ async function refreshAndActivateBestAccount(
6
6
  accountManager: AccountManager,
7
7
  warningHandler?: WarningHandler,
8
8
  ): Promise<void> {
9
- await accountManager.syncImportedOpenAICodexAuth();
9
+ await accountManager.loadPiAuth();
10
10
  await accountManager.refreshUsageForAllAccounts({ force: true });
11
11
 
12
12
  const needsReauth = accountManager.getAccountsNeedingReauth();
13
13
  if (needsReauth.length > 0) {
14
14
  const hints = needsReauth.map((a) => {
15
- const cmd = a.importSource
15
+ const cmd = accountManager.isPiAuthAccount(a)
16
16
  ? "/login openai-codex"
17
17
  : `/multicodex use ${a.email}`;
18
18
  return `${a.email} (${cmd})`;
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
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
  }
package/provider.ts CHANGED
@@ -53,8 +53,7 @@ function getActiveApiKey(accountManager: AccountManager): string {
53
53
  return account.accessToken;
54
54
  }
55
55
  }
56
- // Placeholder AuthStorage will override on every actual API call
57
- // as long as auth.json has valid tokens.
56
+ // Fallback placeholder until MultiCodex resolves a usable managed account.
58
57
  return "pending-login";
59
58
  }
60
59
 
@@ -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/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,6 @@ export function createStreamWrapper(
39
39
 
40
40
  (async () => {
41
41
  try {
42
- await accountManager.syncImportedOpenAICodexAuth();
43
42
  const excludedEmails = new Set<string>();
44
43
  for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
45
44
  const now = Date.now();