@victor-software-house/pi-multicodex 1.0.8 → 1.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.
package/README.md CHANGED
@@ -69,12 +69,59 @@ Current direction:
69
69
 
70
70
  Current next step:
71
71
 
72
- - make MultiCodex own the normal `openai-codex` path directly
73
- - auto-import pi's existing `openai-codex` auth when it is new or changed
74
- - mirror the existing codex usage footer style, including support for displaying both reset countdowns
75
- - show the active account identifier beside the 5h and 7d usage metrics
76
- - keep footer configuration in an interactive panel
77
- - tighten footer updates so account switches and quota rotation are reflected immediately
72
+ - refine the footer color palette with small visual adjustments only
73
+ - document the account-rotation behavior contract explicitly
74
+ - improve the `/multicodex-use` and `/multicodex-status` everyday UX
75
+
76
+ ## Behavior contract
77
+
78
+ The current runtime behavior is:
79
+
80
+ ### Account selection priority
81
+
82
+ 1. Use the manual account selected with `/multicodex-use` when it is still available.
83
+ 2. Otherwise clear the stale manual override and select the best available managed account.
84
+ 3. Best-account selection prefers:
85
+ - untouched accounts with usage data
86
+ - then the account whose weekly reset window ends first
87
+ - then a random available account as fallback
88
+
89
+ ### Quota exhaustion semantics
90
+
91
+ - Quota and rate-limit style failures are detected from provider error text.
92
+ - When a request fails before any output is streamed, MultiCodex marks that account exhausted and retries on another account.
93
+ - Exhaustion lasts until the next known reset time.
94
+ - If usage data does not provide a reset time, exhaustion falls back to a 1 hour cooldown.
95
+
96
+ ### Retry policy
97
+
98
+ - MultiCodex retries account rotation up to 5 times for a single request.
99
+ - Retries only happen for quota/rate-limit style failures that occur before output is forwarded.
100
+ - Once output has started streaming, the original error is surfaced instead of rotating.
101
+
102
+ ### Manual override behavior
103
+
104
+ - `/multicodex-use <identifier>` sets the manual account override immediately.
105
+ - `/multicodex-use` with no argument opens the account picker and sets the selected manual override.
106
+ - Manual override is session-local state.
107
+ - Manual override clears automatically when the selected account is no longer available or when it hits quota during rotation.
108
+
109
+ ### Usage cache and refresh rules
110
+
111
+ - Usage is cached in memory for 5 minutes per account.
112
+ - Footer updates render cached usage immediately and refresh in the background when needed.
113
+ - Rapid `model_select` changes debounce background refresh work so non-Codex model switching clears the footer immediately.
114
+
115
+ ### Error classification
116
+
117
+ Quota rotation currently treats these error classes as interchangeable:
118
+
119
+ - HTTP `429`
120
+ - `quota`
121
+ - `usage limit`
122
+ - `rate limit`
123
+ - `too many requests`
124
+ - `limit reached`
78
125
 
79
126
  ## Release validation
80
127
 
@@ -18,6 +18,7 @@ const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
18
18
  const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
19
19
 
20
20
  type WarningHandler = (message: string) => void;
21
+ type StateChangeHandler = () => void;
21
22
 
22
23
  function getErrorMessage(error: unknown): string {
23
24
  if (error instanceof Error) return error.message;
@@ -29,6 +30,7 @@ export class AccountManager {
29
30
  private usageCache = new Map<string, CodexUsageSnapshot>();
30
31
  private warningHandler?: WarningHandler;
31
32
  private manualEmail?: string;
33
+ private stateChangeHandlers = new Set<StateChangeHandler>();
32
34
 
33
35
  constructor() {
34
36
  this.data = loadStorage();
@@ -38,6 +40,19 @@ export class AccountManager {
38
40
  saveStorage(this.data);
39
41
  }
40
42
 
43
+ private notifyStateChanged(): void {
44
+ for (const handler of this.stateChangeHandlers) {
45
+ handler();
46
+ }
47
+ }
48
+
49
+ onStateChange(handler: StateChangeHandler): () => void {
50
+ this.stateChangeHandlers.add(handler);
51
+ return () => {
52
+ this.stateChangeHandlers.delete(handler);
53
+ };
54
+ }
55
+
41
56
  getAccounts(): Account[] {
42
57
  return this.data.accounts;
43
58
  }
@@ -82,7 +97,6 @@ export class AccountManager {
82
97
  });
83
98
  }
84
99
  this.setActiveAccount(email);
85
- this.save();
86
100
  }
87
101
 
88
102
  getActiveAccount(): Account | undefined {
@@ -111,6 +125,7 @@ export class AccountManager {
111
125
  setActiveAccount(email: string): void {
112
126
  this.data.activeEmail = email;
113
127
  this.save();
128
+ this.notifyStateChanged();
114
129
  }
115
130
 
116
131
  setManualAccount(email: string): void {
@@ -118,10 +133,13 @@ export class AccountManager {
118
133
  if (!account) return;
119
134
  this.manualEmail = email;
120
135
  account.lastUsed = Date.now();
136
+ this.notifyStateChanged();
121
137
  }
122
138
 
123
139
  clearManualAccount(): void {
140
+ if (!this.manualEmail) return;
124
141
  this.manualEmail = undefined;
142
+ this.notifyStateChanged();
125
143
  }
126
144
 
127
145
  getImportedAccount(): Account | undefined {
@@ -173,6 +191,7 @@ export class AccountManager {
173
191
  if (account) {
174
192
  account.quotaExhaustedUntil = until;
175
193
  this.save();
194
+ this.notifyStateChanged();
176
195
  }
177
196
  }
178
197
 
@@ -201,6 +220,7 @@ export class AccountManager {
201
220
  timeoutMs: USAGE_REQUEST_TIMEOUT_MS,
202
221
  });
203
222
  this.usageCache.set(account.email, usage);
223
+ this.notifyStateChanged();
204
224
  return usage;
205
225
  } catch (error) {
206
226
  this.warningHandler?.(
@@ -283,6 +303,7 @@ export class AccountManager {
283
303
  }
284
304
  if (changed) {
285
305
  this.save();
306
+ this.notifyStateChanged();
286
307
  }
287
308
  }
288
309
 
@@ -301,6 +322,7 @@ export class AccountManager {
301
322
  account.accountId = accountId;
302
323
  }
303
324
  this.save();
325
+ this.notifyStateChanged();
304
326
  return account.accessToken;
305
327
  }
306
328
  }
package/extension.ts CHANGED
@@ -54,7 +54,7 @@ export default function multicodexExtension(pi: ExtensionAPI) {
54
54
 
55
55
  pi.on("model_select", (_event: unknown, ctx: ExtensionContext) => {
56
56
  lastContext = ctx;
57
- void statusController.refreshFor(ctx);
57
+ statusController.scheduleModelSelectRefresh(ctx);
58
58
  });
59
59
 
60
60
  pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.8",
3
+ "version": "1.0.10",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/status.ts CHANGED
@@ -21,7 +21,9 @@ const STATUS_KEY = "multicodex-usage";
21
21
  const SETTINGS_KEY = "pi-multicodex";
22
22
  const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
23
23
  const REFRESH_INTERVAL_MS = 60_000;
24
+ const MODEL_SELECT_REFRESH_DEBOUNCE_MS = 250;
24
25
  const UNKNOWN_PERCENT = "--";
26
+ const BRAND_LABEL = "Codex";
25
27
  const FIVE_HOUR_LABEL = "5h:";
26
28
  const SEVEN_DAY_LABEL = "7d:";
27
29
 
@@ -138,13 +140,21 @@ function usedToDisplayPercent(
138
140
  return mode === "left" ? left : clampPercent(100 - left);
139
141
  }
140
142
 
143
+ function formatBrand(ctx: ExtensionContext): string {
144
+ return ctx.ui.theme.fg("muted", BRAND_LABEL);
145
+ }
146
+
147
+ function formatLoading(ctx: ExtensionContext): string {
148
+ return ctx.ui.theme.fg("muted", "loading...");
149
+ }
150
+
141
151
  function formatPercent(
142
152
  ctx: ExtensionContext,
143
153
  displayPercent: number | undefined,
144
154
  mode: PercentDisplayMode,
145
155
  ): string {
146
156
  if (typeof displayPercent !== "number" || Number.isNaN(displayPercent)) {
147
- return ctx.ui.theme.fg("muted", UNKNOWN_PERCENT);
157
+ return ctx.ui.theme.fg("dim", UNKNOWN_PERCENT);
148
158
  }
149
159
 
150
160
  const text = `${Math.round(clampPercent(displayPercent))}% ${mode}`;
@@ -172,6 +182,40 @@ function formatResetCountdown(resetAt: number | undefined): string | undefined {
172
182
  return `${seconds}s`;
173
183
  }
174
184
 
185
+ function shouldShowReset(
186
+ preferences: FooterPreferences,
187
+ window: Exclude<ResetWindowMode, "both">,
188
+ ): boolean {
189
+ if (!preferences.showReset) return false;
190
+ return (
191
+ preferences.resetWindow === "both" || preferences.resetWindow === window
192
+ );
193
+ }
194
+
195
+ function formatUsageSegment(
196
+ ctx: ExtensionContext,
197
+ label: string,
198
+ usedPercent: number | undefined,
199
+ resetAt: number | undefined,
200
+ showReset: boolean,
201
+ preferences: FooterPreferences,
202
+ ): string {
203
+ const parts = [
204
+ `${ctx.ui.theme.fg("dim", label)}${formatPercent(
205
+ ctx,
206
+ usedToDisplayPercent(usedPercent, preferences.usageMode),
207
+ preferences.usageMode,
208
+ )}`,
209
+ ];
210
+ if (showReset) {
211
+ const countdown = formatResetCountdown(resetAt);
212
+ if (countdown) {
213
+ parts.push(ctx.ui.theme.fg("muted", `(↺${countdown})`));
214
+ }
215
+ }
216
+ return parts.join(" ");
217
+ }
218
+
175
219
  export function isManagedModel(model: MaybeModel): boolean {
176
220
  return model?.provider === PROVIDER_ID;
177
221
  }
@@ -186,59 +230,36 @@ export function formatActiveAccountStatus(
186
230
  ? ctx.ui.theme.fg("muted", accountEmail)
187
231
  : undefined;
188
232
  if (!usage) {
189
- return [
190
- ctx.ui.theme.fg("dim", "Codex"),
191
- accountText,
192
- ctx.ui.theme.fg("dim", "loading..."),
193
- ]
233
+ return [formatBrand(ctx), accountText, formatLoading(ctx)]
194
234
  .filter(Boolean)
195
235
  .join(" ");
196
236
  }
197
237
 
198
- const fiveHour = `${ctx.ui.theme.fg("dim", FIVE_HOUR_LABEL)}${formatPercent(
238
+ const fiveHour = formatUsageSegment(
199
239
  ctx,
200
- usedToDisplayPercent(usage.primary?.usedPercent, preferences.usageMode),
201
- preferences.usageMode,
202
- )}`;
203
- const sevenDay = `${ctx.ui.theme.fg("dim", SEVEN_DAY_LABEL)}${formatPercent(
240
+ FIVE_HOUR_LABEL,
241
+ usage.primary?.usedPercent,
242
+ usage.primary?.resetAt,
243
+ shouldShowReset(preferences, "5h"),
244
+ preferences,
245
+ );
246
+ const sevenDay = formatUsageSegment(
204
247
  ctx,
205
- usedToDisplayPercent(usage.secondary?.usedPercent, preferences.usageMode),
206
- preferences.usageMode,
207
- )}`;
208
- const fiveHourReset = preferences.showReset
209
- ? formatResetCountdown(usage.primary?.resetAt)
210
- : undefined;
211
- const sevenDayReset = preferences.showReset
212
- ? formatResetCountdown(usage.secondary?.resetAt)
213
- : undefined;
214
- const resetText =
215
- preferences.resetWindow === "5h"
216
- ? fiveHourReset
217
- ? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
218
- : undefined
219
- : preferences.resetWindow === "7d"
220
- ? sevenDayReset
221
- ? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
222
- : undefined
223
- : [
224
- fiveHourReset
225
- ? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
226
- : undefined,
227
- sevenDayReset
228
- ? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
229
- : undefined,
230
- ]
231
- .filter(Boolean)
232
- .join(" ") || undefined;
248
+ SEVEN_DAY_LABEL,
249
+ usage.secondary?.usedPercent,
250
+ usage.secondary?.resetAt,
251
+ shouldShowReset(preferences, "7d"),
252
+ preferences,
253
+ );
233
254
 
234
255
  const leading =
235
256
  preferences.order === "account-first"
236
- ? [ctx.ui.theme.fg("dim", "Codex"), accountText]
237
- : [ctx.ui.theme.fg("dim", "Codex")];
257
+ ? [formatBrand(ctx), accountText]
258
+ : [formatBrand(ctx)];
238
259
  const trailing =
239
260
  preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
240
261
 
241
- return [...leading, fiveHour, sevenDay, resetText, ...trailing]
262
+ return [...leading, fiveHour, sevenDay, ...trailing]
242
263
  .filter(Boolean)
243
264
  .join(" ");
244
265
  }
@@ -315,10 +336,17 @@ function applyPreferenceChange(
315
336
 
316
337
  export function createUsageStatusController(accountManager: AccountManager) {
317
338
  let refreshTimer: ReturnType<typeof setInterval> | undefined;
339
+ let modelSelectTimer: ReturnType<typeof setTimeout> | undefined;
318
340
  let activeContext: ExtensionContext | undefined;
319
341
  let refreshInFlight = false;
320
342
  let queuedRefresh = false;
321
343
  let preferences: FooterPreferences = DEFAULT_PREFERENCES;
344
+ let livePreviewPreferences: FooterPreferences | undefined;
345
+
346
+ accountManager.onStateChange(() => {
347
+ if (!activeContext) return;
348
+ renderCachedStatus(activeContext, livePreviewPreferences ?? preferences);
349
+ });
322
350
 
323
351
  function clearStatus(ctx?: ExtensionContext): void {
324
352
  ctx?.ui.setStatus(STATUS_KEY, undefined);
@@ -328,6 +356,42 @@ export function createUsageStatusController(accountManager: AccountManager) {
328
356
  preferences = await loadFooterPreferences();
329
357
  }
330
358
 
359
+ function getStatusText(
360
+ ctx: ExtensionContext,
361
+ preferencesOverride?: FooterPreferences,
362
+ ): string | undefined {
363
+ if (!ctx.hasUI) return undefined;
364
+ if (!isManagedModel(ctx.model)) return undefined;
365
+
366
+ const activeAccount = accountManager.getActiveAccount();
367
+ if (!activeAccount) {
368
+ return ctx.ui.theme.fg("warning", "Multicodex no active account");
369
+ }
370
+
371
+ return formatActiveAccountStatus(
372
+ ctx,
373
+ activeAccount.email,
374
+ accountManager.getCachedUsage(activeAccount.email),
375
+ preferencesOverride ?? preferences,
376
+ );
377
+ }
378
+
379
+ function renderCachedStatus(
380
+ ctx: ExtensionContext,
381
+ preferencesOverride?: FooterPreferences,
382
+ ): void {
383
+ if (!ctx.hasUI) return;
384
+ if (!isManagedModel(ctx.model)) {
385
+ clearStatus(ctx);
386
+ return;
387
+ }
388
+
389
+ const text = getStatusText(ctx, preferencesOverride);
390
+ if (text) {
391
+ ctx.ui.setStatus(STATUS_KEY, text);
392
+ }
393
+ }
394
+
331
395
  async function updateStatus(ctx: ExtensionContext): Promise<void> {
332
396
  if (!ctx.hasUI) return;
333
397
  if (!isManagedModel(ctx.model)) {
@@ -335,6 +399,8 @@ export function createUsageStatusController(accountManager: AccountManager) {
335
399
  return;
336
400
  }
337
401
 
402
+ renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
403
+
338
404
  let activeAccount = accountManager.getActiveAccount();
339
405
  if (!activeAccount) {
340
406
  await accountManager.syncImportedOpenAICodexAuth();
@@ -354,7 +420,12 @@ export function createUsageStatusController(accountManager: AccountManager) {
354
420
  cachedUsage;
355
421
  ctx.ui.setStatus(
356
422
  STATUS_KEY,
357
- formatActiveAccountStatus(ctx, activeAccount.email, usage, preferences),
423
+ formatActiveAccountStatus(
424
+ ctx,
425
+ activeAccount.email,
426
+ usage,
427
+ livePreviewPreferences ?? preferences,
428
+ ),
358
429
  );
359
430
  }
360
431
 
@@ -377,6 +448,19 @@ export function createUsageStatusController(accountManager: AccountManager) {
377
448
  }
378
449
  }
379
450
 
451
+ function scheduleModelSelectRefresh(ctx: ExtensionContext): void {
452
+ activeContext = ctx;
453
+ renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
454
+ if (modelSelectTimer) {
455
+ clearTimeout(modelSelectTimer);
456
+ }
457
+ modelSelectTimer = setTimeout(() => {
458
+ modelSelectTimer = undefined;
459
+ void refreshFor(ctx);
460
+ }, MODEL_SELECT_REFRESH_DEBOUNCE_MS);
461
+ modelSelectTimer.unref?.();
462
+ }
463
+
380
464
  function startAutoRefresh(): void {
381
465
  if (refreshTimer) clearInterval(refreshTimer);
382
466
  refreshTimer = setInterval(() => {
@@ -391,6 +475,11 @@ export function createUsageStatusController(accountManager: AccountManager) {
391
475
  clearInterval(refreshTimer);
392
476
  refreshTimer = undefined;
393
477
  }
478
+ if (modelSelectTimer) {
479
+ clearTimeout(modelSelectTimer);
480
+ modelSelectTimer = undefined;
481
+ }
482
+ livePreviewPreferences = undefined;
394
483
  clearStatus(ctx ?? activeContext);
395
484
  activeContext = undefined;
396
485
  queuedRefresh = false;
@@ -408,11 +497,23 @@ export function createUsageStatusController(accountManager: AccountManager) {
408
497
  }
409
498
  }
410
499
 
500
+ function renderPreviewLabel(
501
+ ctx: ExtensionContext,
502
+ theme: ExtensionCommandContext["ui"]["theme"],
503
+ draft: FooterPreferences,
504
+ ): string {
505
+ const previewText =
506
+ getStatusText(ctx, draft) ?? `${formatBrand(ctx)} ${formatLoading(ctx)}`;
507
+ return `${theme.fg("dim", "Preview")}: ${previewText}`;
508
+ }
509
+
411
510
  async function openPreferencesPanel(
412
511
  ctx: ExtensionCommandContext,
413
512
  ): Promise<void> {
414
513
  await loadPreferences(ctx);
415
514
  let draft = preferences;
515
+ livePreviewPreferences = draft;
516
+ renderCachedStatus(ctx, livePreviewPreferences);
416
517
 
417
518
  await ctx.ui.custom((_tui, theme, _kb, done) => {
418
519
  const container = new Container();
@@ -429,14 +530,20 @@ export function createUsageStatusController(accountManager: AccountManager) {
429
530
  0,
430
531
  ),
431
532
  );
533
+ const previewText = new Text(renderPreviewLabel(ctx, theme, draft), 1, 0);
534
+ container.addChild(previewText);
432
535
 
433
536
  const settingsList = new SettingsList(
434
537
  createSettingsItems(draft),
435
- 7,
538
+ 9,
436
539
  getSettingsListTheme(),
437
540
  (id: string, newValue: string) => {
438
541
  draft = applyPreferenceChange(draft, id, newValue);
542
+ livePreviewPreferences = draft;
439
543
  settingsList.updateValue(id, newValue);
544
+ previewText.setText(renderPreviewLabel(ctx, theme, draft));
545
+ container.invalidate();
546
+ renderCachedStatus(ctx, draft);
440
547
  },
441
548
  () => done(undefined),
442
549
  { enableSearch: true },
@@ -451,6 +558,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
451
558
  });
452
559
 
453
560
  preferences = draft;
561
+ livePreviewPreferences = undefined;
454
562
  await persistFooterPreferences(preferences);
455
563
  await refreshFor(ctx);
456
564
  }
@@ -459,6 +567,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
459
567
  loadPreferences,
460
568
  openPreferencesPanel,
461
569
  refreshFor,
570
+ scheduleModelSelectRefresh,
462
571
  startAutoRefresh,
463
572
  stopAutoRefresh,
464
573
  getPreferences: () => preferences,