@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.
- package/account-manager.ts +71 -270
- package/commands.ts +7 -17
- package/hooks.ts +2 -2
- package/index.ts +1 -4
- package/package.json +6 -3
- package/schemas/codex-accounts.schema.json +77 -0
- package/status.ts +1 -5
- package/storage.ts +155 -18
- package/stream-wrapper.ts +0 -1
package/account-manager.ts
CHANGED
|
@@ -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
|
|
76
|
-
? "/
|
|
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
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
325
|
-
(
|
|
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
|
-
|
|
332
|
-
|
|
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.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
-
|
|
621
|
-
|
|
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
|
|
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
|
|
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.
|
|
484
|
+
this.piAuthAccount = undefined;
|
|
485
|
+
this.notifyStateChanged();
|
|
685
486
|
throw new Error(
|
|
686
|
-
`${account.email}:
|
|
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
|
-
"
|
|
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}
|
|
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.
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|