@victor-software-house/pi-multicodex 2.0.8 → 2.0.10

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,6 +2,7 @@ import {
2
2
  type OAuthCredentials,
3
3
  refreshOpenAICodexToken,
4
4
  } from "@mariozechner/pi-ai/oauth";
5
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
5
6
  import { normalizeUnknownError } from "pi-provider-utils/streams";
6
7
  import { loadImportedOpenAICodexAuth } from "./auth";
7
8
  import { isAccountAvailable, pickBestAccount } from "./selection";
@@ -79,6 +80,7 @@ export class AccountManager {
79
80
  existing.expiresAt = creds.expires;
80
81
  existing.importSource = options?.importSource;
81
82
  existing.importFingerprint = options?.importFingerprint;
83
+ existing.needsReauth = undefined;
82
84
  if (accountId) {
83
85
  existing.accountId = accountId;
84
86
  }
@@ -230,10 +232,22 @@ export class AccountManager {
230
232
  return this.usageCache.get(email);
231
233
  }
232
234
 
235
+ getAccountsNeedingReauth(): Account[] {
236
+ return this.data.accounts.filter((a) => a.needsReauth);
237
+ }
238
+
239
+ private markNeedsReauth(account: Account): void {
240
+ account.needsReauth = true;
241
+ this.save();
242
+ this.notifyStateChanged();
243
+ }
244
+
233
245
  async refreshUsageForAccount(
234
246
  account: Account,
235
247
  options?: { force?: boolean; signal?: AbortSignal },
236
248
  ): Promise<CodexUsageSnapshot | undefined> {
249
+ if (account.needsReauth) return this.usageCache.get(account.email);
250
+
237
251
  const cached = this.usageCache.get(account.email);
238
252
  const now = Date.now();
239
253
  if (
@@ -339,10 +353,25 @@ export class AccountManager {
339
353
  }
340
354
 
341
355
  async ensureValidToken(account: Account): Promise<string> {
356
+ if (account.needsReauth) {
357
+ const hint = account.importSource
358
+ ? "/login openai-codex"
359
+ : `/multicodex use ${account.email}`;
360
+ throw new Error(
361
+ `${account.email}: re-authentication required — run ${hint}`,
362
+ );
363
+ }
364
+
342
365
  if (Date.now() < account.expiresAt - 5 * 60 * 1000) {
343
366
  return account.accessToken;
344
367
  }
345
368
 
369
+ // For the imported pi account, delegate to AuthStorage so we share pi's
370
+ // file lock and never race with pi's own refresh path.
371
+ if (account.importSource === "pi-openai-codex") {
372
+ return this.ensureValidTokenForImportedAccount(account);
373
+ }
374
+
346
375
  const inflight = this.refreshPromises.get(account.email);
347
376
  if (inflight) {
348
377
  return inflight;
@@ -362,6 +391,9 @@ export class AccountManager {
362
391
  this.save();
363
392
  this.notifyStateChanged();
364
393
  return account.accessToken;
394
+ } catch (error) {
395
+ this.markNeedsReauth(account);
396
+ throw error;
365
397
  } finally {
366
398
  this.refreshPromises.delete(account.email);
367
399
  }
@@ -370,4 +402,71 @@ export class AccountManager {
370
402
  this.refreshPromises.set(account.email, promise);
371
403
  return promise;
372
404
  }
405
+
406
+ /**
407
+ * Refresh path for the imported pi account.
408
+ *
409
+ * Uses AuthStorage so our refresh is serialised by the same file lock that
410
+ * pi's own credential refresh uses. This prevents "refresh_token_reused"
411
+ * errors caused by pi and multicodex both refreshing the same token
412
+ * simultaneously.
413
+ */
414
+ private async ensureValidTokenForImportedAccount(
415
+ account: Account,
416
+ ): Promise<string> {
417
+ // Check if pi already refreshed since our last sync.
418
+ const latest = await loadImportedOpenAICodexAuth();
419
+ if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
420
+ account.accessToken = latest.credentials.access;
421
+ account.refreshToken = latest.credentials.refresh;
422
+ account.expiresAt = latest.credentials.expires;
423
+ account.importFingerprint = latest.fingerprint;
424
+ const accountId =
425
+ typeof latest.credentials.accountId === "string"
426
+ ? latest.credentials.accountId
427
+ : undefined;
428
+ if (accountId) {
429
+ account.accountId = accountId;
430
+ }
431
+ this.save();
432
+ this.notifyStateChanged();
433
+ return account.accessToken;
434
+ }
435
+
436
+ // Both our copy and auth.json are expired — let AuthStorage refresh with
437
+ // its file lock so only one caller (us or pi) fires the API call.
438
+ let apiKey: string | undefined;
439
+ try {
440
+ const authStorage = AuthStorage.create();
441
+ apiKey = await authStorage.getApiKey("openai-codex");
442
+ } catch {
443
+ // AuthStorage refresh failed; mark for re-auth below.
444
+ }
445
+ if (!apiKey) {
446
+ this.markNeedsReauth(account);
447
+ throw new Error(
448
+ `${account.email}: token refresh failed — run /login openai-codex to re-authenticate`,
449
+ );
450
+ }
451
+
452
+ // Read the refreshed tokens back from auth.json.
453
+ const refreshed = await loadImportedOpenAICodexAuth();
454
+ if (refreshed) {
455
+ account.accessToken = refreshed.credentials.access;
456
+ account.refreshToken = refreshed.credentials.refresh;
457
+ account.expiresAt = refreshed.credentials.expires;
458
+ account.importFingerprint = refreshed.fingerprint;
459
+ const accountId =
460
+ typeof refreshed.credentials.accountId === "string"
461
+ ? refreshed.credentials.accountId
462
+ : undefined;
463
+ if (accountId) {
464
+ account.accountId = accountId;
465
+ }
466
+ this.save();
467
+ this.notifyStateChanged();
468
+ }
469
+
470
+ return apiKey;
471
+ }
373
472
  }
package/commands.ts CHANGED
@@ -80,11 +80,16 @@ function parseResetTarget(value: string): ResetTarget | undefined {
80
80
  return undefined;
81
81
  }
82
82
 
83
- function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
84
- if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
85
- return email;
86
- }
87
- return `${email} (Quota)`;
83
+ function getAccountLabel(
84
+ email: string,
85
+ options?: { quotaExhaustedUntil?: number; needsReauth?: boolean },
86
+ ): string {
87
+ const tags: string[] = [];
88
+ if (options?.needsReauth) tags.push("Reauth");
89
+ if (options?.quotaExhaustedUntil && options.quotaExhaustedUntil > Date.now())
90
+ tags.push("Quota");
91
+ if (tags.length === 0) return email;
92
+ return `${email} (${tags.join(", ")})`;
88
93
  }
89
94
 
90
95
  function formatAccountStatusLine(
@@ -100,9 +105,11 @@ function formatAccountStatusLine(
100
105
  account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
101
106
  const untouched = isUsageUntouched(usage) ? "untouched" : null;
102
107
  const imported = account.importSource ? "imported" : null;
108
+ const reauth = account.needsReauth ? "needs reauth" : null;
103
109
  const tags = [
104
110
  active?.email === account.email ? "active" : null,
105
111
  manual?.email === account.email ? "manual" : null,
112
+ reauth,
106
113
  quotaHit ? "quota" : null,
107
114
  untouched,
108
115
  imported,
@@ -234,7 +241,10 @@ async function openAccountSelectionPanel(
234
241
  const accounts = accountManager.getAccounts();
235
242
  const items = accounts.map((account) => ({
236
243
  value: account.email,
237
- label: getAccountLabel(account.email, account.quotaExhaustedUntil),
244
+ label: getAccountLabel(account.email, {
245
+ quotaExhaustedUntil: account.quotaExhaustedUntil,
246
+ needsReauth: account.needsReauth,
247
+ }),
238
248
  }));
239
249
 
240
250
  return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
package/extension.ts CHANGED
@@ -28,7 +28,7 @@ export default function multicodexExtension(pi: ExtensionAPI) {
28
28
 
29
29
  pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
30
30
  lastContext = ctx;
31
- handleSessionStart(accountManager);
31
+ handleSessionStart(accountManager, (msg) => ctx.ui.notify(msg, "warning"));
32
32
  statusController.startAutoRefresh();
33
33
  void (async () => {
34
34
  await statusController.loadPreferences(ctx);
@@ -41,7 +41,9 @@ export default function multicodexExtension(pi: ExtensionAPI) {
41
41
  (event: { reason?: string }, ctx: ExtensionContext) => {
42
42
  lastContext = ctx;
43
43
  if (event.reason === "new") {
44
- handleNewSessionSwitch(accountManager);
44
+ handleNewSessionSwitch(accountManager, (msg) =>
45
+ ctx.ui.notify(msg, "warning"),
46
+ );
45
47
  }
46
48
  void statusController.refreshFor(ctx);
47
49
  },
package/hooks.ts CHANGED
@@ -1,10 +1,27 @@
1
1
  import type { AccountManager } from "./account-manager";
2
2
 
3
+ type WarningHandler = (message: string) => void;
4
+
3
5
  async function refreshAndActivateBestAccount(
4
6
  accountManager: AccountManager,
7
+ warningHandler?: WarningHandler,
5
8
  ): Promise<void> {
6
9
  await accountManager.syncImportedOpenAICodexAuth();
7
10
  await accountManager.refreshUsageForAllAccounts({ force: true });
11
+
12
+ const needsReauth = accountManager.getAccountsNeedingReauth();
13
+ if (needsReauth.length > 0) {
14
+ const hints = needsReauth.map((a) => {
15
+ const cmd = a.importSource
16
+ ? "/login openai-codex"
17
+ : `/multicodex use ${a.email}`;
18
+ return `${a.email} (${cmd})`;
19
+ });
20
+ warningHandler?.(
21
+ `Multicodex: ${needsReauth.length} account(s) need re-authentication: ${hints.join(", ")}`,
22
+ );
23
+ }
24
+
8
25
  const manual = accountManager.getAvailableManualAccount();
9
26
  if (manual) return;
10
27
  if (accountManager.hasManualAccount()) {
@@ -13,11 +30,17 @@ async function refreshAndActivateBestAccount(
13
30
  await accountManager.activateBestAccount();
14
31
  }
15
32
 
16
- export function handleSessionStart(accountManager: AccountManager): void {
33
+ export function handleSessionStart(
34
+ accountManager: AccountManager,
35
+ warningHandler?: WarningHandler,
36
+ ): void {
17
37
  if (accountManager.getAccounts().length === 0) return;
18
- void refreshAndActivateBestAccount(accountManager);
38
+ void refreshAndActivateBestAccount(accountManager, warningHandler);
19
39
  }
20
40
 
21
- export function handleNewSessionSwitch(accountManager: AccountManager): void {
22
- void refreshAndActivateBestAccount(accountManager);
41
+ export function handleNewSessionSwitch(
42
+ accountManager: AccountManager,
43
+ warningHandler?: WarningHandler,
44
+ ): void {
45
+ void refreshAndActivateBestAccount(accountManager, warningHandler);
23
46
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.0.8",
3
+ "version": "2.0.10",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -89,6 +89,7 @@
89
89
  "@semantic-release/release-notes-generator": "^14.1.0",
90
90
  "@types/node": "^25.5.0",
91
91
  "@typescript/native-preview": "7.0.0-dev.20260314.1",
92
+ "conventional-changelog-conventionalcommits": "^9.3.0",
92
93
  "semantic-release": "^25.0.3",
93
94
  "typescript": "^5.9.3",
94
95
  "vitest": "^4.1.0"
package/selection.ts CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  } from "./usage";
7
7
 
8
8
  export function isAccountAvailable(account: Account, now: number): boolean {
9
+ if (account.needsReauth) return false;
9
10
  return !account.quotaExhaustedUntil || account.quotaExhaustedUntil <= now;
10
11
  }
11
12
 
package/storage.ts CHANGED
@@ -12,6 +12,7 @@ export interface Account {
12
12
  quotaExhaustedUntil?: number;
13
13
  importSource?: "pi-openai-codex";
14
14
  importFingerprint?: string;
15
+ needsReauth?: boolean;
15
16
  }
16
17
 
17
18
  export interface StorageData {