@victor-software-house/pi-multicodex 2.2.1 → 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.
@@ -23,6 +23,7 @@ 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;
@@ -52,13 +53,21 @@ export class AccountManager {
52
53
  }
53
54
 
54
55
  getAccounts(): Account[] {
56
+ if (this.piAuthAccount) {
57
+ return [...this.data.accounts, this.piAuthAccount];
58
+ }
55
59
  return this.data.accounts;
56
60
  }
57
61
 
58
62
  getAccount(email: string): Account | undefined {
63
+ if (this.piAuthAccount?.email === email) return this.piAuthAccount;
59
64
  return this.data.accounts.find((a) => a.email === email);
60
65
  }
61
66
 
67
+ isPiAuthAccount(account: Account): boolean {
68
+ return this.piAuthAccount !== undefined && account === this.piAuthAccount;
69
+ }
70
+
62
71
  setWarningHandler(handler?: WarningHandler): void {
63
72
  this.warningHandler = handler;
64
73
  }
@@ -72,32 +81,14 @@ export class AccountManager {
72
81
  return;
73
82
  }
74
83
  this.warnedAuthFailureEmails.add(account.email);
75
- const hint = account.importSource
76
- ? "/multicodex reauth"
84
+ const hint = this.isPiAuthAccount(account)
85
+ ? "/login openai-codex"
77
86
  : `/multicodex reauth ${account.email}`;
78
87
  this.warningHandler?.(
79
88
  `Multicodex skipped ${account.email} during rotation: ${normalizeUnknownError(error)}. Account is flagged in /multicodex accounts. Run ${hint} to repair it.`,
80
89
  );
81
90
  }
82
91
 
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
92
  private removeAccountRecord(account: Account): boolean {
102
93
  const index = this.data.accounts.findIndex(
103
94
  (candidate) => candidate.email === account.email,
@@ -117,25 +108,7 @@ export class AccountManager {
117
108
  return true;
118
109
  }
119
110
 
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 {
111
+ private applyCredentials(account: Account, creds: OAuthCredentials): boolean {
139
112
  const accountId =
140
113
  typeof creds.accountId === "string" ? creds.accountId : undefined;
141
114
  let changed = false;
@@ -155,24 +128,6 @@ export class AccountManager {
155
128
  account.accountId = accountId;
156
129
  changed = true;
157
130
  }
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
131
  if (account.needsReauth) {
177
132
  account.needsReauth = undefined;
178
133
  this.warnedAuthFailureEmails.delete(account.email);
@@ -181,66 +136,28 @@ export class AccountManager {
181
136
  return changed;
182
137
  }
183
138
 
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;
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();
236
146
  }
147
+ return existing;
237
148
  }
238
149
 
239
- if (changed) {
240
- this.save();
241
- this.notifyStateChanged();
242
- }
243
- 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;
244
161
  }
245
162
 
246
163
  getActiveAccount(): Account | undefined {
@@ -286,151 +203,40 @@ export class AccountManager {
286
203
  this.notifyStateChanged();
287
204
  }
288
205
 
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;
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> {
212
+ const imported = await loadImportedOpenAICodexAuth();
213
+ if (!imported) {
214
+ this.piAuthAccount = undefined;
215
+ this.notifyStateChanged();
216
+ return;
322
217
  }
323
218
 
324
- return this.data.accounts.find(
325
- (account) =>
326
- account.importMode !== "synthetic" &&
327
- account.email === imported.identifier,
219
+ const alreadyManaged = this.data.accounts.find(
220
+ (a) => a.email === imported.identifier,
328
221
  );
329
- }
330
222
 
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
-
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();
223
+ if (alreadyManaged) {
224
+ this.piAuthAccount = undefined;
354
225
  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;
226
+ return;
425
227
  }
426
228
 
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;
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();
434
240
  }
435
241
 
436
242
  getAvailableManualAccount(options?: {
@@ -489,7 +295,9 @@ export class AccountManager {
489
295
 
490
296
  private markNeedsReauth(account: Account): void {
491
297
  account.needsReauth = true;
492
- this.save();
298
+ if (!this.isPiAuthAccount(account)) {
299
+ this.save();
300
+ }
493
301
  this.notifyStateChanged();
494
302
  }
495
303
 
@@ -561,7 +369,7 @@ export class AccountManager {
561
369
  }): Promise<Account | undefined> {
562
370
  const now = Date.now();
563
371
  this.clearExpiredExhaustion(now);
564
- const accounts = this.data.accounts;
372
+ const accounts = this.getAccounts();
565
373
  await this.refreshUsageIfStale(accounts, options);
566
374
 
567
375
  const selected = pickBestAccount(accounts, this.usageCache, {
@@ -605,7 +413,7 @@ export class AccountManager {
605
413
 
606
414
  async ensureValidToken(account: Account): Promise<string> {
607
415
  if (account.needsReauth) {
608
- const hint = account.importSource
416
+ const hint = this.isPiAuthAccount(account)
609
417
  ? "/login openai-codex"
610
418
  : `/multicodex use ${account.email}`;
611
419
  throw new Error(
@@ -617,10 +425,8 @@ export class AccountManager {
617
425
  return account.accessToken;
618
426
  }
619
427
 
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);
428
+ if (this.isPiAuthAccount(account)) {
429
+ return this.ensureValidTokenForPiAuth(account);
624
430
  }
625
431
 
626
432
  const inflight = this.refreshPromises.get(account.email);
@@ -655,20 +461,15 @@ export class AccountManager {
655
461
  }
656
462
 
657
463
  /**
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.
464
+ * Read-only refresh for the ephemeral pi auth account.
465
+ * Re-reads auth.json for fresh tokens. Never writes anything.
662
466
  */
663
- private async ensureValidTokenForImportedAccount(
664
- account: Account,
665
- ): Promise<string> {
467
+ private async ensureValidTokenForPiAuth(account: Account): Promise<string> {
666
468
  const latest = await loadImportedOpenAICodexAuth();
667
469
  if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
668
470
  account.accessToken = latest.credentials.access;
669
471
  account.refreshToken = latest.credentials.refresh;
670
472
  account.expiresAt = latest.credentials.expires;
671
- account.importFingerprint = latest.fingerprint;
672
473
  const accountId =
673
474
  typeof latest.credentials.accountId === "string"
674
475
  ? latest.credentials.accountId
@@ -676,14 +477,14 @@ export class AccountManager {
676
477
  if (accountId) {
677
478
  account.accountId = accountId;
678
479
  }
679
- this.save();
680
480
  this.notifyStateChanged();
681
481
  return account.accessToken;
682
482
  }
683
483
 
684
- this.markNeedsReauth(account);
484
+ this.piAuthAccount = undefined;
485
+ this.notifyStateChanged();
685
486
  throw new Error(
686
- `${account.email}: imported pi auth is expired — run /login openai-codex to re-authenticate`,
487
+ `${account.email}: pi auth expired — run /login openai-codex`,
687
488
  );
688
489
  }
689
490
  }
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,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.1",
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
  }
@@ -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();