@victor-software-house/pi-multicodex 2.0.12 → 2.1.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/README.md CHANGED
@@ -23,12 +23,13 @@ To manage your accounts inside a session, type `/multicodex`.
23
23
  When you start a session, MultiCodex:
24
24
 
25
25
  1. Imports your existing pi Codex auth automatically (if present).
26
- 2. Checks usage data across all managed accounts.
27
- 3. Picks the best available account untouched accounts first, then the one whose weekly reset window ends soonest, then a random available account as fallback.
26
+ 2. Merges duplicate imported credentials into the managed pool so one account does not consume multiple rotation slots.
27
+ 3. Checks usage data across all managed accounts.
28
+ 4. Picks the best available account — untouched accounts first, then the one whose weekly reset window ends soonest, then a random available account as fallback.
28
29
 
29
- If you pin a specific account with `/multicodex use`, that account is used until it hits quota or you clear the override.
30
+ If you pin a specific account from `/multicodex accounts` or `/multicodex use`, that account is used until it hits quota, fails auth validation, or you clear the override.
30
31
 
31
- When a request hits a quota or rate limit **before** any output is streamed, MultiCodex marks that account exhausted, picks the next available one, and retries. This happens up to 5 times transparently. If the manual override account fails, the override is cleared and rotation continues with the remaining accounts. Once output has started streaming, the error is surfaced as-is — no mid-stream account switching.
32
+ When a request hits a quota or rate limit **before** any output is streamed, MultiCodex marks that account exhausted, picks the next available one, and retries. This happens up to 5 times transparently. If token validation or token refresh fails before the request starts, MultiCodex skips that account and retries another healthy one. If the manual override account fails, the override is cleared and rotation continues with the remaining accounts. Once output has started streaming, the error is surfaced as-is — no mid-stream account switching.
32
33
 
33
34
  ## Commands
34
35
 
@@ -37,27 +38,35 @@ Everything lives under one command: `/multicodex`.
37
38
  | Command | What it does |
38
39
  |---|---|
39
40
  | `/multicodex` | Open the main interactive menu |
40
- | `/multicodex show` | Print account status and cached usage |
41
- | `/multicodex use [identifier]` | Activate an account, or open the picker if no identifier given |
41
+ | `/multicodex accounts [identifier]` | Inspect account health, select an account, add one, or directly activate/login by identifier |
42
+ | `/multicodex use [identifier]` | Alias for `/multicodex accounts [identifier]` |
43
+ | `/multicodex show` | Alias for the account-management view; in non-interactive mode it prints per-account health lines |
44
+ | `/multicodex refresh [identifier\|all]` | Refresh token validity and usage data for one account or all accounts |
45
+ | `/multicodex reauth [identifier]` | Re-authenticate one account explicitly |
42
46
  | `/multicodex footer` | Configure the usage footer display |
43
47
  | `/multicodex rotation` | Show the current rotation policy |
44
- | `/multicodex verify` | Check that local storage paths are writable |
48
+ | `/multicodex verify` | Check storage, settings, auth import, and reauth health |
45
49
  | `/multicodex path` | Print storage and settings file locations |
46
50
  | `/multicodex reset [manual\|quota\|all]` | Clear manual override, quota cooldowns, or both |
47
51
  | `/multicodex help` | Print a compact usage line |
48
52
 
49
- All subcommands support dynamic autocomplete. `/multicodex use` also autocompletes from your managed account list.
53
+ All subcommands support dynamic autocomplete. Account-focused subcommands autocomplete from the managed account list.
50
54
 
51
- Commands that do not need a UI panel (`show`, `verify`, `path`, `reset`, `help`) work in non-interactive mode too.
55
+ Commands that do not need a UI panel (`show`, `refresh`, `verify`, `path`, `reset`, `help`) work in non-interactive mode too.
52
56
 
53
- ## Account picker
57
+ ## Account manager
54
58
 
55
- The `/multicodex use` picker lets you select, add, and remove accounts in one place.
59
+ The `/multicodex accounts` panel merges the old `show` and `use` flows into one place.
56
60
 
57
61
  ![MultiCodex use picker](./assets/multicodex-use-picker.png)
58
62
 
59
63
  - **Enter** activates the highlighted account.
60
- - **Backspace** removes it (after confirmation).
64
+ - **U** refreshes token and usage health for the selected account.
65
+ - **R** re-authenticates the selected account.
66
+ - **N** starts login for a new managed account.
67
+ - **Backspace** removes the selected account after confirmation.
68
+
69
+ Each row shows the account identifier, active/manual state, reauth state, quota state, linked imported auth state, and cached 5-hour and weekly usage windows.
61
70
 
62
71
  When you remove an active account, MultiCodex switches to the next available one automatically.
63
72
 
@@ -74,8 +83,8 @@ You can customize which fields appear and their ordering with `/multicodex foote
74
83
  ## What it does under the hood
75
84
 
76
85
  - **Provider override.** MultiCodex registers itself as the `openai-codex` provider. You do not need to select a different provider or change your model — it works with whatever Codex model you already use.
77
- - **Auth import.** When pi has stored Codex OAuth credentials, MultiCodex imports them automatically. You can also add accounts manually with `/multicodex use <email>`.
78
- - **Token refresh.** OAuth tokens are refreshed before expiry so requests do not fail due to stale credentials.
86
+ - **Auth import.** When pi has stored Codex OAuth credentials, MultiCodex imports them automatically and merges duplicate credentials into existing managed accounts when possible.
87
+ - **Token refresh.** OAuth tokens are refreshed before expiry so requests do not fail due to stale credentials. You can also force a health refresh with `/multicodex refresh` or re-authenticate explicitly with `/multicodex reauth`.
79
88
  - **Usage tracking.** Usage data is fetched from the Codex API and cached for 5 minutes per account. The footer renders cached data immediately and refreshes in the background.
80
89
  - **Quota cooldown.** When an account is exhausted, it stays on cooldown until its next known reset time (or 1 hour if the reset time is unknown).
81
90
  - **Shared utility seams.** Provider mirroring, stream primitives, and `~/.pi/agent/*` path helpers are shared with `pi-credential-vault` through `@victor-software-house/pi-provider-utils`. MultiCodex still owns account storage, token policy, footer behavior, and command UX.
@@ -83,39 +83,153 @@ export class AccountManager {
83
83
  this.warningHandler = handler;
84
84
  }
85
85
 
86
+ private updateAccountEmail(account: Account, email: string): boolean {
87
+ if (account.email === email) return false;
88
+ const previousEmail = account.email;
89
+ account.email = email;
90
+ if (this.data.activeEmail === previousEmail) {
91
+ this.data.activeEmail = email;
92
+ }
93
+ if (this.manualEmail === previousEmail) {
94
+ this.manualEmail = email;
95
+ }
96
+ const cached = this.usageCache.get(previousEmail);
97
+ if (cached) {
98
+ this.usageCache.delete(previousEmail);
99
+ this.usageCache.set(email, cached);
100
+ }
101
+ return true;
102
+ }
103
+
104
+ private removeAccountRecord(account: Account): boolean {
105
+ const index = this.data.accounts.findIndex(
106
+ (candidate) => candidate.email === account.email,
107
+ );
108
+ if (index < 0) return false;
109
+ const removedEmail = this.data.accounts[index]?.email;
110
+ this.data.accounts.splice(index, 1);
111
+ if (removedEmail) {
112
+ this.usageCache.delete(removedEmail);
113
+ if (this.manualEmail === removedEmail) {
114
+ this.manualEmail = undefined;
115
+ }
116
+ if (this.data.activeEmail === removedEmail) {
117
+ this.data.activeEmail = this.data.accounts[0]?.email;
118
+ }
119
+ }
120
+ return true;
121
+ }
122
+
123
+ private findAccountByRefreshToken(
124
+ refreshToken: string,
125
+ excludeEmail?: string,
126
+ ): Account | undefined {
127
+ return this.data.accounts.find(
128
+ (account) =>
129
+ account.refreshToken === refreshToken && account.email !== excludeEmail,
130
+ );
131
+ }
132
+
133
+ private applyCredentials(
134
+ account: Account,
135
+ creds: OAuthCredentials,
136
+ options?: {
137
+ importSource?: "pi-openai-codex";
138
+ importFingerprint?: string;
139
+ },
140
+ ): boolean {
141
+ const accountId =
142
+ typeof creds.accountId === "string" ? creds.accountId : undefined;
143
+ let changed = false;
144
+ if (account.accessToken !== creds.access) {
145
+ account.accessToken = creds.access;
146
+ changed = true;
147
+ }
148
+ if (account.refreshToken !== creds.refresh) {
149
+ account.refreshToken = creds.refresh;
150
+ changed = true;
151
+ }
152
+ if (account.expiresAt !== creds.expires) {
153
+ account.expiresAt = creds.expires;
154
+ changed = true;
155
+ }
156
+ if (accountId && account.accountId !== accountId) {
157
+ account.accountId = accountId;
158
+ changed = true;
159
+ }
160
+ if (
161
+ options?.importSource &&
162
+ account.importSource !== options.importSource
163
+ ) {
164
+ account.importSource = options.importSource;
165
+ changed = true;
166
+ }
167
+ if (
168
+ options?.importFingerprint &&
169
+ account.importFingerprint !== options.importFingerprint
170
+ ) {
171
+ account.importFingerprint = options.importFingerprint;
172
+ changed = true;
173
+ }
174
+ if (account.needsReauth) {
175
+ account.needsReauth = undefined;
176
+ changed = true;
177
+ }
178
+ return changed;
179
+ }
180
+
86
181
  addOrUpdateAccount(
87
182
  email: string,
88
183
  creds: OAuthCredentials,
89
184
  options?: {
90
185
  importSource?: "pi-openai-codex";
91
186
  importFingerprint?: string;
187
+ preserveActive?: boolean;
92
188
  },
93
- ): void {
189
+ ): Account {
94
190
  const existing = this.getAccount(email);
95
- const accountId =
96
- typeof creds.accountId === "string" ? creds.accountId : undefined;
97
- if (existing) {
98
- existing.accessToken = creds.access;
99
- existing.refreshToken = creds.refresh;
100
- existing.expiresAt = creds.expires;
101
- existing.importSource = options?.importSource;
102
- existing.importFingerprint = options?.importFingerprint;
103
- existing.needsReauth = undefined;
104
- if (accountId) {
105
- existing.accountId = accountId;
191
+ const duplicate = existing
192
+ ? undefined
193
+ : this.findAccountByRefreshToken(creds.refresh);
194
+ let target = existing ?? duplicate;
195
+ let changed = false;
196
+
197
+ if (target) {
198
+ if (
199
+ duplicate?.importSource === "pi-openai-codex" &&
200
+ duplicate.email !== email &&
201
+ !this.getAccount(email)
202
+ ) {
203
+ changed = this.updateAccountEmail(duplicate, email) || changed;
106
204
  }
205
+ changed = this.applyCredentials(target, creds, options) || changed;
107
206
  } else {
108
- this.data.accounts.push({
207
+ target = {
109
208
  email,
110
209
  accessToken: creds.access,
111
210
  refreshToken: creds.refresh,
112
211
  expiresAt: creds.expires,
113
- accountId,
212
+ accountId:
213
+ typeof creds.accountId === "string" ? creds.accountId : undefined,
114
214
  importSource: options?.importSource,
115
215
  importFingerprint: options?.importFingerprint,
116
- });
216
+ };
217
+ this.data.accounts.push(target);
218
+ changed = true;
219
+ }
220
+
221
+ if (!options?.preserveActive) {
222
+ if (this.data.activeEmail !== target.email) {
223
+ this.setActiveAccount(target.email);
224
+ return target;
225
+ }
226
+ }
227
+
228
+ if (changed) {
229
+ this.save();
230
+ this.notifyStateChanged();
117
231
  }
118
- this.setActiveAccount(email);
232
+ return target;
119
233
  }
120
234
 
121
235
  getActiveAccount(): Account | undefined {
@@ -172,23 +286,68 @@ export class AccountManager {
172
286
  if (!imported) return false;
173
287
 
174
288
  const existingImported = this.getImportedAccount();
175
- if (
176
- existingImported?.importFingerprint === imported.fingerprint &&
177
- existingImported.email === imported.identifier
178
- ) {
289
+ if (existingImported?.importFingerprint === imported.fingerprint) {
179
290
  return false;
180
291
  }
181
292
 
182
- if (existingImported && existingImported.email !== imported.identifier) {
293
+ const matchingAccount = this.findAccountByRefreshToken(
294
+ imported.credentials.refresh,
295
+ existingImported?.email,
296
+ );
297
+ if (matchingAccount) {
298
+ const wasActiveImported =
299
+ existingImported && this.data.activeEmail === existingImported.email;
300
+ const wasManualImported =
301
+ existingImported && this.manualEmail === existingImported.email;
302
+ let changed = this.applyCredentials(
303
+ matchingAccount,
304
+ imported.credentials,
305
+ {
306
+ importSource: "pi-openai-codex",
307
+ importFingerprint: imported.fingerprint,
308
+ },
309
+ );
310
+ if (existingImported && existingImported !== matchingAccount) {
311
+ changed = this.removeAccountRecord(existingImported) || changed;
312
+ if (wasActiveImported) {
313
+ this.data.activeEmail = matchingAccount.email;
314
+ }
315
+ if (wasManualImported) {
316
+ this.manualEmail = matchingAccount.email;
317
+ }
318
+ }
319
+ if (changed) {
320
+ this.save();
321
+ this.notifyStateChanged();
322
+ }
323
+ return changed;
324
+ }
325
+
326
+ if (existingImported) {
183
327
  const target = this.getAccount(imported.identifier);
184
- if (!target) {
185
- existingImported.email = imported.identifier;
328
+ let changed = false;
329
+ if (!target && existingImported.email !== imported.identifier) {
330
+ changed = this.updateAccountEmail(
331
+ existingImported,
332
+ imported.identifier,
333
+ );
334
+ }
335
+ changed =
336
+ this.applyCredentials(existingImported, imported.credentials, {
337
+ importSource: "pi-openai-codex",
338
+ importFingerprint: imported.fingerprint,
339
+ }) || changed;
340
+ if (changed) {
341
+ this.save();
342
+ this.notifyStateChanged();
186
343
  }
344
+ return changed;
187
345
  }
188
346
 
189
347
  this.addOrUpdateAccount(imported.identifier, imported.credentials, {
190
348
  importSource: "pi-openai-codex",
191
349
  importFingerprint: imported.fingerprint,
350
+ preserveActive: true,
192
351
  });
193
352
  return true;
194
353
  }
@@ -230,19 +389,10 @@ export class AccountManager {
230
389
  }
231
390
 
232
391
  removeAccount(email: string): boolean {
233
- const index = this.data.accounts.findIndex(
234
- (account) => account.email === email,
235
- );
236
- if (index < 0) return false;
237
-
238
- this.data.accounts.splice(index, 1);
239
- this.usageCache.delete(email);
240
- if (this.manualEmail === email) {
241
- this.manualEmail = undefined;
242
- }
243
- if (this.data.activeEmail === email) {
244
- this.data.activeEmail = this.data.accounts[0]?.email;
245
- }
392
+ const account = this.getAccount(email);
393
+ if (!account) return false;
394
+ const removed = this.removeAccountRecord(account);
395
+ if (!removed) return false;
246
396
  this.save();
247
397
  this.notifyStateChanged();
248
398
  return true;
package/commands.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  import { getAgentSettingsPath } from "pi-provider-utils/agent-paths";
18
18
  import { normalizeUnknownError } from "pi-provider-utils/streams";
19
19
  import type { AccountManager } from "./account-manager";
20
+ import { writeActiveTokenToAuthJson } from "./auth";
20
21
  import { openLoginInBrowser } from "./browser";
21
22
  import type { createUsageStatusController } from "./status";
22
23
  import { STORAGE_FILE } from "./storage";
@@ -24,12 +25,15 @@ import { formatResetAt, isUsageUntouched } from "./usage";
24
25
 
25
26
  const SETTINGS_FILE = getAgentSettingsPath();
26
27
  const NO_ACCOUNTS_MESSAGE =
27
- "No managed accounts found. Use /multicodex use <identifier> first.";
28
+ "No managed accounts found. Open /multicodex accounts to add one.";
28
29
  const HELP_TEXT =
29
- "Usage: /multicodex [show|use [identifier]|footer|rotation|verify|path|reset [manual|quota|all]|help]";
30
+ "Usage: /multicodex [accounts [identifier]|use [identifier]|show|refresh [identifier|all]|reauth [identifier]|footer|rotation|verify|path|reset [manual|quota|all]|help]";
30
31
  const SUBCOMMANDS = [
31
- "show",
32
+ "accounts",
32
33
  "use",
34
+ "show",
35
+ "refresh",
36
+ "reauth",
33
37
  "footer",
34
38
  "rotation",
35
39
  "verify",
@@ -44,7 +48,10 @@ type ResetTarget = (typeof RESET_TARGETS)[number];
44
48
 
45
49
  type AccountPanelResult =
46
50
  | { action: "select"; email: string }
51
+ | { action: "refresh"; email: string }
52
+ | { action: "reauth"; email: string }
47
53
  | { action: "remove"; email: string }
54
+ | { action: "add" }
48
55
  | undefined;
49
56
 
50
57
  function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
@@ -80,18 +87,6 @@ function parseResetTarget(value: string): ResetTarget | undefined {
80
87
  return undefined;
81
88
  }
82
89
 
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(", ")})`;
93
- }
94
-
95
90
  function formatAccountStatusLine(
96
91
  accountManager: AccountManager,
97
92
  email: string,
@@ -104,7 +99,7 @@ function formatAccountStatusLine(
104
99
  const quotaHit =
105
100
  account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
106
101
  const untouched = isUsageUntouched(usage) ? "untouched" : null;
107
- const imported = account.importSource ? "imported" : null;
102
+ const imported = account.importSource ? "linked-auth" : null;
108
103
  const reauth = account.needsReauth ? "needs reauth" : null;
109
104
  const tags = [
110
105
  active?.email === account.email ? "active" : null,
@@ -134,7 +129,8 @@ function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
134
129
  return matches.length > 0 ? toAutocompleteItems(matches) : null;
135
130
  }
136
131
 
137
- function getUseCompletions(
132
+ function getAccountCompletions(
133
+ subcommand: "accounts" | "use" | "reauth",
138
134
  prefix: string,
139
135
  accountManager: AccountManager,
140
136
  ): AutocompleteItem[] | null {
@@ -143,7 +139,26 @@ function getUseCompletions(
143
139
  .map((account) => account.email)
144
140
  .filter((value) => value.startsWith(prefix));
145
141
  if (matches.length === 0) return null;
146
- return matches.map((value) => ({ value: `use ${value}`, label: value }));
142
+ return matches.map((value) => ({
143
+ value: `${subcommand} ${value}`,
144
+ label: value,
145
+ }));
146
+ }
147
+
148
+ function getRefreshCompletions(
149
+ prefix: string,
150
+ accountManager: AccountManager,
151
+ ): AutocompleteItem[] | null {
152
+ const values = [
153
+ "all",
154
+ ...accountManager.getAccounts().map((account) => account.email),
155
+ ].filter((value, index, array) => array.indexOf(value) === index);
156
+ const matches = values.filter((value) => value.startsWith(prefix));
157
+ if (matches.length === 0) return null;
158
+ return matches.map((value) => ({
159
+ value: `refresh ${value}`,
160
+ label: value,
161
+ }));
147
162
  }
148
163
 
149
164
  function getResetCompletions(prefix: string): AutocompleteItem[] | null {
@@ -169,8 +184,17 @@ function getCommandCompletions(
169
184
  const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
170
185
  const rest = trimmedStart.slice(firstSpaceIndex + 1).trimStart();
171
186
 
187
+ if (subcommand === "accounts") {
188
+ return getAccountCompletions("accounts", rest, accountManager);
189
+ }
172
190
  if (subcommand === "use") {
173
- return getUseCompletions(rest, accountManager);
191
+ return getAccountCompletions("use", rest, accountManager);
192
+ }
193
+ if (subcommand === "reauth") {
194
+ return getAccountCompletions("reauth", rest, accountManager);
195
+ }
196
+ if (subcommand === "refresh") {
197
+ return getRefreshCompletions(rest, accountManager);
174
198
  }
175
199
  if (subcommand === "reset") {
176
200
  return getResetCompletions(rest);
@@ -184,7 +208,7 @@ async function loginAndActivateAccount(
184
208
  ctx: ExtensionCommandContext,
185
209
  accountManager: AccountManager,
186
210
  identifier: string,
187
- ): Promise<boolean> {
211
+ ): Promise<string | undefined> {
188
212
  try {
189
213
  ctx.ui.notify(
190
214
  `Starting login for ${identifier}... Check your browser.`,
@@ -200,13 +224,23 @@ async function loginAndActivateAccount(
200
224
  onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
201
225
  });
202
226
 
203
- accountManager.addOrUpdateAccount(identifier, creds);
204
- accountManager.setManualAccount(identifier);
205
- ctx.ui.notify(`Now using ${identifier}`, "info");
206
- return true;
227
+ const account = accountManager.addOrUpdateAccount(identifier, creds);
228
+ if (account.importSource) {
229
+ writeActiveTokenToAuthJson({
230
+ access: creds.access,
231
+ refresh: creds.refresh,
232
+ expires: creds.expires,
233
+ accountId:
234
+ typeof creds.accountId === "string" ? creds.accountId : undefined,
235
+ });
236
+ await accountManager.syncImportedOpenAICodexAuth();
237
+ }
238
+ accountManager.setManualAccount(account.email);
239
+ ctx.ui.notify(`Now using ${account.email}`, "info");
240
+ return account.email;
207
241
  } catch (error) {
208
242
  ctx.ui.notify(`Login failed: ${normalizeUnknownError(error)}`, "error");
209
- return false;
243
+ return undefined;
210
244
  }
211
245
  }
212
246
 
@@ -220,12 +254,12 @@ async function useOrLoginAccount(
220
254
  if (existing) {
221
255
  try {
222
256
  await accountManager.ensureValidToken(existing);
223
- accountManager.setManualAccount(identifier);
224
- ctx.ui.notify(`Now using ${identifier}`, "info");
257
+ accountManager.setManualAccount(existing.email);
258
+ ctx.ui.notify(`Now using ${existing.email}`, "info");
225
259
  return;
226
260
  } catch {
227
261
  ctx.ui.notify(
228
- `Stored auth for ${identifier} is no longer valid. Starting login again.`,
262
+ `Stored auth for ${existing.email} is no longer valid. Starting login again.`,
229
263
  "warning",
230
264
  );
231
265
  }
@@ -234,33 +268,100 @@ async function useOrLoginAccount(
234
268
  await loginAndActivateAccount(pi, ctx, accountManager, identifier);
235
269
  }
236
270
 
237
- async function openAccountSelectionPanel(
271
+ async function refreshSingleAccount(
272
+ ctx: ExtensionCommandContext,
273
+ accountManager: AccountManager,
274
+ email: string,
275
+ ): Promise<void> {
276
+ const account = accountManager.getAccount(email);
277
+ if (!account) {
278
+ ctx.ui.notify(`Unknown account: ${email}`, "warning");
279
+ return;
280
+ }
281
+
282
+ try {
283
+ await accountManager.ensureValidToken(account);
284
+ } catch (error) {
285
+ ctx.ui.notify(
286
+ `refresh ${email}: ${normalizeUnknownError(error)}`,
287
+ "warning",
288
+ );
289
+ return;
290
+ }
291
+
292
+ await accountManager.refreshUsageForAccount(account, { force: true });
293
+ ctx.ui.notify(
294
+ `refreshed ${formatAccountStatusLine(accountManager, email)}`,
295
+ "info",
296
+ );
297
+ }
298
+
299
+ async function refreshAllAccounts(
300
+ ctx: ExtensionCommandContext,
301
+ accountManager: AccountManager,
302
+ ): Promise<void> {
303
+ await accountManager.refreshUsageForAllAccounts({ force: true });
304
+ const accounts = accountManager.getAccounts();
305
+ const needsReauth = accountManager.getAccountsNeedingReauth().length;
306
+ const summary =
307
+ accounts.length === 0
308
+ ? NO_ACCOUNTS_MESSAGE
309
+ : `refreshed ${accounts.length} account(s); reauth needed=${needsReauth}`;
310
+ ctx.ui.notify(summary, needsReauth > 0 ? "warning" : "info");
311
+ }
312
+
313
+ async function reauthenticateAccount(
314
+ pi: ExtensionAPI,
315
+ ctx: ExtensionCommandContext,
316
+ accountManager: AccountManager,
317
+ email: string,
318
+ ): Promise<void> {
319
+ const account = accountManager.getAccount(email);
320
+ if (!account) {
321
+ ctx.ui.notify(`Unknown account: ${email}`, "warning");
322
+ return;
323
+ }
324
+ await loginAndActivateAccount(pi, ctx, accountManager, account.email);
325
+ }
326
+
327
+ async function promptForNewAccountIdentifier(
328
+ ctx: ExtensionCommandContext,
329
+ ): Promise<string | undefined> {
330
+ const identifier = (await ctx.ui.input("Account identifier"))?.trim();
331
+ if (!identifier) {
332
+ ctx.ui.notify("Account creation cancelled.", "warning");
333
+ return undefined;
334
+ }
335
+ return identifier;
336
+ }
337
+
338
+ async function openAccountManagementPanel(
238
339
  ctx: ExtensionCommandContext,
239
340
  accountManager: AccountManager,
240
341
  ): Promise<AccountPanelResult> {
241
342
  const accounts = accountManager.getAccounts();
242
343
  const items = accounts.map((account) => ({
243
344
  value: account.email,
244
- label: getAccountLabel(account.email, {
245
- quotaExhaustedUntil: account.quotaExhaustedUntil,
246
- needsReauth: account.needsReauth,
247
- }),
345
+ label: formatAccountStatusLine(accountManager, account.email),
248
346
  }));
249
347
 
250
348
  return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
251
349
  const container = new Container();
252
350
  container.addChild(
253
- new Text(theme.fg("accent", theme.bold("Select Account")), 1, 0),
351
+ new Text(theme.fg("accent", theme.bold("MultiCodex Accounts")), 1, 0),
254
352
  );
255
353
  container.addChild(
256
354
  new Text(
257
- theme.fg("dim", "Enter: use Backspace: remove account Esc: cancel"),
355
+ theme.fg(
356
+ "dim",
357
+ "Enter: use U: refresh R: re-auth N: add Backspace: remove Esc: cancel",
358
+ ),
258
359
  1,
259
360
  0,
260
361
  ),
261
362
  );
262
363
 
263
- const selectList = new SelectList(items, 10, getSelectListTheme());
364
+ const selectList = new SelectList(items, 12, getSelectListTheme());
264
365
  selectList.onSelect = (item) => {
265
366
  done({ action: "select", email: item.value });
266
367
  };
@@ -271,8 +372,20 @@ async function openAccountSelectionPanel(
271
372
  render: (width: number) => container.render(width),
272
373
  invalidate: () => container.invalidate(),
273
374
  handleInput: (data: string) => {
375
+ if (data.toLowerCase() === "n") {
376
+ done({ action: "add" });
377
+ return;
378
+ }
379
+ const selected = selectList.getSelectedItem();
380
+ if (selected && data.toLowerCase() === "u") {
381
+ done({ action: "refresh", email: selected.value });
382
+ return;
383
+ }
384
+ if (selected && data.toLowerCase() === "r") {
385
+ done({ action: "reauth", email: selected.value });
386
+ return;
387
+ }
274
388
  if (matchesKey(data, Key.backspace)) {
275
- const selected = selectList.getSelectedItem();
276
389
  if (selected) {
277
390
  done({ action: "remove", email: selected.value });
278
391
  }
@@ -284,7 +397,8 @@ async function openAccountSelectionPanel(
284
397
  });
285
398
  }
286
399
 
287
- async function openAccountSelectionFlow(
400
+ async function openAccountManagementFlow(
401
+ pi: ExtensionAPI,
288
402
  ctx: ExtensionCommandContext,
289
403
  accountManager: AccountManager,
290
404
  statusController: ReturnType<typeof createUsageStatusController>,
@@ -292,20 +406,42 @@ async function openAccountSelectionFlow(
292
406
  while (true) {
293
407
  const accounts = accountManager.getAccounts();
294
408
  if (accounts.length === 0) {
295
- ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
296
- return;
409
+ const identifier = await promptForNewAccountIdentifier(ctx);
410
+ if (!identifier) return;
411
+ await loginAndActivateAccount(pi, ctx, accountManager, identifier);
412
+ await statusController.refreshFor(ctx);
413
+ continue;
297
414
  }
298
415
 
299
- const result = await openAccountSelectionPanel(ctx, accountManager);
416
+ const result = await openAccountManagementPanel(ctx, accountManager);
300
417
  if (!result) return;
301
418
 
419
+ if (result.action === "add") {
420
+ const identifier = await promptForNewAccountIdentifier(ctx);
421
+ if (!identifier) continue;
422
+ await loginAndActivateAccount(pi, ctx, accountManager, identifier);
423
+ await statusController.refreshFor(ctx);
424
+ continue;
425
+ }
426
+
302
427
  if (result.action === "select") {
303
- accountManager.setManualAccount(result.email);
304
- ctx.ui.notify(`Now using ${result.email}`, "info");
428
+ await useOrLoginAccount(pi, ctx, accountManager, result.email);
305
429
  await statusController.refreshFor(ctx);
306
430
  return;
307
431
  }
308
432
 
433
+ if (result.action === "refresh") {
434
+ await refreshSingleAccount(ctx, accountManager, result.email);
435
+ await statusController.refreshFor(ctx);
436
+ continue;
437
+ }
438
+
439
+ if (result.action === "reauth") {
440
+ await reauthenticateAccount(pi, ctx, accountManager, result.email);
441
+ await statusController.refreshFor(ctx);
442
+ continue;
443
+ }
444
+
309
445
  const accountToRemove = accountManager.getAccount(result.email);
310
446
  if (!accountToRemove) continue;
311
447
 
@@ -325,32 +461,50 @@ async function openAccountSelectionFlow(
325
461
  }
326
462
  }
327
463
 
328
- async function runShowSubcommand(
464
+ async function runAccountsSubcommand(
465
+ pi: ExtensionAPI,
329
466
  ctx: ExtensionCommandContext,
330
467
  accountManager: AccountManager,
468
+ statusController: ReturnType<typeof createUsageStatusController>,
469
+ rest: string,
331
470
  ): Promise<void> {
332
471
  await accountManager.syncImportedOpenAICodexAuth();
333
472
  await accountManager.refreshUsageForAllAccounts();
473
+
474
+ if (rest) {
475
+ await useOrLoginAccount(pi, ctx, accountManager, rest);
476
+ await statusController.refreshFor(ctx);
477
+ return;
478
+ }
479
+
334
480
  const accounts = accountManager.getAccounts();
335
481
  if (accounts.length === 0) {
336
- ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
482
+ if (!ctx.hasUI) {
483
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
484
+ return;
485
+ }
486
+ await openAccountManagementFlow(pi, ctx, accountManager, statusController);
337
487
  return;
338
488
  }
339
489
 
340
490
  if (!ctx.hasUI) {
341
- const active = accountManager.getActiveAccount()?.email ?? "none";
342
- const manual = accountManager.getManualAccount()?.email ?? "none";
343
- ctx.ui.notify(
344
- `multicodex: accounts=${accounts.length} active=${active} manual=${manual}`,
345
- "info",
491
+ const lines = accounts.map((account) =>
492
+ formatAccountStatusLine(accountManager, account.email),
346
493
  );
494
+ ctx.ui.notify(lines.join("\n"), "info");
347
495
  return;
348
496
  }
349
497
 
350
- const options = accounts.map((account) =>
351
- formatAccountStatusLine(accountManager, account.email),
352
- );
353
- await ctx.ui.select("MultiCodex Accounts", options);
498
+ await openAccountManagementFlow(pi, ctx, accountManager, statusController);
499
+ }
500
+
501
+ async function runShowSubcommand(
502
+ pi: ExtensionAPI,
503
+ ctx: ExtensionCommandContext,
504
+ accountManager: AccountManager,
505
+ statusController: ReturnType<typeof createUsageStatusController>,
506
+ ): Promise<void> {
507
+ await runAccountsSubcommand(pi, ctx, accountManager, statusController, "");
354
508
  }
355
509
 
356
510
  async function runFooterSubcommand(
@@ -374,16 +528,14 @@ async function runRotationSubcommand(
374
528
  ctx: ExtensionCommandContext,
375
529
  ): Promise<void> {
376
530
  const lines = [
377
- "Rotation settings are not configurable yet.",
378
- "Current policy: manual account, then untouched accounts, then earliest weekly reset, then random fallback.",
379
- "Quota cooldown uses next known reset time, with 1 hour fallback when unknown.",
531
+ "Current policy: manual account first, then untouched accounts, then earliest weekly reset, then random fallback.",
532
+ "If token validation fails before a request starts, MultiCodex skips that account and retries another one.",
533
+ "If a request hits quota or rate limit before any output streams, MultiCodex marks the account on cooldown and retries.",
534
+ "Imported pi auth is merged into the managed pool so duplicate credentials do not consume extra rotation slots.",
380
535
  ];
381
536
 
382
537
  if (!ctx.hasUI) {
383
- ctx.ui.notify(
384
- "rotation: manual->untouched->earliest-weekly-reset->random, cooldown=next-reset-or-1h",
385
- "info",
386
- );
538
+ ctx.ui.notify(lines.join(" "), "info");
387
539
  return;
388
540
  }
389
541
 
@@ -412,11 +564,12 @@ async function runVerifySubcommand(
412
564
  await statusController.loadPreferences(ctx);
413
565
  const accounts = accountManager.getAccounts().length;
414
566
  const active = accountManager.getActiveAccount()?.email ?? "none";
415
- const ok = storageWritable && settingsWritable;
567
+ const needsReauth = accountManager.getAccountsNeedingReauth().length;
568
+ const ok = storageWritable && settingsWritable && needsReauth === 0;
416
569
 
417
570
  if (!ctx.hasUI) {
418
571
  ctx.ui.notify(
419
- `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"}`,
572
+ `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"} needsReauth=${needsReauth}`,
420
573
  ok ? "info" : "warning",
421
574
  );
422
575
  return;
@@ -427,6 +580,7 @@ async function runVerifySubcommand(
427
580
  `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
428
581
  `managed accounts: ${accounts}`,
429
582
  `active account: ${active}`,
583
+ `accounts needing re-authentication: ${needsReauth}`,
430
584
  `auth import changed state: ${authImported ? "yes" : "no"}`,
431
585
  ];
432
586
  await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
@@ -519,7 +673,7 @@ function runHelpSubcommand(ctx: ExtensionCommandContext): void {
519
673
  ctx.ui.notify(HELP_TEXT, "info");
520
674
  }
521
675
 
522
- async function runUseSubcommand(
676
+ async function runRefreshSubcommand(
523
677
  pi: ExtensionAPI,
524
678
  ctx: ExtensionCommandContext,
525
679
  accountManager: AccountManager,
@@ -527,22 +681,42 @@ async function runUseSubcommand(
527
681
  rest: string,
528
682
  ): Promise<void> {
529
683
  await accountManager.syncImportedOpenAICodexAuth();
684
+ if (!rest || rest === "all") {
685
+ if (!ctx.hasUI || rest === "all") {
686
+ await refreshAllAccounts(ctx, accountManager);
687
+ await statusController.refreshFor(ctx);
688
+ return;
689
+ }
690
+ await openAccountManagementFlow(pi, ctx, accountManager, statusController);
691
+ return;
692
+ }
693
+ await refreshSingleAccount(ctx, accountManager, rest);
694
+ await statusController.refreshFor(ctx);
695
+ }
530
696
 
697
+ async function runReauthSubcommand(
698
+ pi: ExtensionAPI,
699
+ ctx: ExtensionCommandContext,
700
+ accountManager: AccountManager,
701
+ statusController: ReturnType<typeof createUsageStatusController>,
702
+ rest: string,
703
+ ): Promise<void> {
704
+ await accountManager.syncImportedOpenAICodexAuth();
531
705
  if (rest) {
532
- await useOrLoginAccount(pi, ctx, accountManager, rest);
706
+ await reauthenticateAccount(pi, ctx, accountManager, rest);
533
707
  await statusController.refreshFor(ctx);
534
708
  return;
535
709
  }
536
-
537
710
  if (!ctx.hasUI) {
538
- ctx.ui.notify(
539
- "/multicodex use requires an identifier in non-interactive mode.",
540
- "warning",
541
- );
711
+ const active = accountManager.getActiveAccount();
712
+ if (!active) {
713
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
714
+ return;
715
+ }
716
+ await reauthenticateAccount(pi, ctx, accountManager, active.email);
542
717
  return;
543
718
  }
544
-
545
- await openAccountSelectionFlow(ctx, accountManager, statusController);
719
+ await openAccountManagementFlow(pi, ctx, accountManager, statusController);
546
720
  }
547
721
 
548
722
  async function runSubcommand(
@@ -553,12 +727,26 @@ async function runSubcommand(
553
727
  accountManager: AccountManager,
554
728
  statusController: ReturnType<typeof createUsageStatusController>,
555
729
  ): Promise<void> {
730
+ if (subcommand === "accounts" || subcommand === "use") {
731
+ await runAccountsSubcommand(
732
+ pi,
733
+ ctx,
734
+ accountManager,
735
+ statusController,
736
+ rest,
737
+ );
738
+ return;
739
+ }
556
740
  if (subcommand === "show") {
557
- await runShowSubcommand(ctx, accountManager);
741
+ await runShowSubcommand(pi, ctx, accountManager, statusController);
558
742
  return;
559
743
  }
560
- if (subcommand === "use") {
561
- await runUseSubcommand(pi, ctx, accountManager, statusController, rest);
744
+ if (subcommand === "refresh") {
745
+ await runRefreshSubcommand(pi, ctx, accountManager, statusController, rest);
746
+ return;
747
+ }
748
+ if (subcommand === "reauth") {
749
+ await runReauthSubcommand(pi, ctx, accountManager, statusController, rest);
562
750
  return;
563
751
  }
564
752
  if (subcommand === "footer") {
@@ -592,8 +780,9 @@ async function openMainPanel(
592
780
  statusController: ReturnType<typeof createUsageStatusController>,
593
781
  ): Promise<void> {
594
782
  const actions = [
595
- "use: select, activate, or remove managed account",
596
- "show: managed account and usage summary",
783
+ "accounts: inspect, select, refresh, re-authenticate, add, or remove managed account",
784
+ "refresh: force a health and usage refresh",
785
+ "reauth: re-authenticate an account",
597
786
  "footer: footer settings panel",
598
787
  "rotation: current rotation behavior",
599
788
  "verify: runtime health checks",
@@ -626,7 +815,8 @@ export function registerCommands(
626
815
  statusController: ReturnType<typeof createUsageStatusController>,
627
816
  ): void {
628
817
  pi.registerCommand("multicodex", {
629
- description: "Manage MultiCodex accounts, rotation, and footer settings",
818
+ description:
819
+ "Manage MultiCodex accounts, health, rotation, and footer settings",
630
820
  getArgumentCompletions: (argumentPrefix: string) =>
631
821
  getCommandCompletions(argumentPrefix, accountManager),
632
822
  handler: async (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.0.12",
3
+ "version": "2.1.0",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/provider.ts CHANGED
@@ -42,6 +42,22 @@ export function getOpenAICodexMirror(): {
42
42
  };
43
43
  }
44
44
 
45
+ function getActiveApiKey(accountManager: AccountManager): string {
46
+ const active = accountManager.getActiveAccount();
47
+ if (active && !active.needsReauth) {
48
+ return active.accessToken;
49
+ }
50
+ // Fallback: first available account with a valid token.
51
+ for (const account of accountManager.getAccounts()) {
52
+ if (!account.needsReauth && account.accessToken) {
53
+ return account.accessToken;
54
+ }
55
+ }
56
+ // Placeholder — AuthStorage will override on every actual API call
57
+ // as long as auth.json has valid tokens.
58
+ return "pending-login";
59
+ }
60
+
45
61
  export function buildMulticodexProviderConfig(accountManager: AccountManager) {
46
62
  const mirror = getOpenAICodexMirror();
47
63
  const baseProvider = getApiProvider("openai-codex-responses");
@@ -53,7 +69,7 @@ export function buildMulticodexProviderConfig(accountManager: AccountManager) {
53
69
 
54
70
  return {
55
71
  baseUrl: mirror.baseUrl,
56
- apiKey: "managed-by-extension",
72
+ apiKey: getActiveApiKey(accountManager),
57
73
  api: "openai-codex-responses" as const,
58
74
  streamSimple: createStreamWrapper(accountManager, baseProvider),
59
75
  models: mirror.models,
package/stream-wrapper.ts CHANGED
@@ -64,7 +64,19 @@ export function createStreamWrapper(
64
64
  );
65
65
  }
66
66
 
67
- const token = await accountManager.ensureValidToken(account);
67
+ let token: string;
68
+ try {
69
+ token = await accountManager.ensureValidToken(account);
70
+ } catch (error) {
71
+ if (usingManual) {
72
+ accountManager.clearManualAccount();
73
+ }
74
+ excludedEmails.add(account.email);
75
+ if (attempt < MAX_ROTATION_RETRIES) {
76
+ continue;
77
+ }
78
+ throw error;
79
+ }
68
80
  const abortController = createLinkedAbortController(options?.signal);
69
81
 
70
82
  const internalModel: Model<"openai-codex-responses"> = {