@victor-software-house/pi-multicodex 1.0.11 → 2.0.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
@@ -45,15 +45,55 @@ Run the extension directly during local development:
45
45
  pi -e ./index.ts
46
46
  ```
47
47
 
48
- ## Commands
49
-
50
- - `/multicodex-use [identifier]`
51
- - Use an existing managed account, or start the Codex login flow when the account is missing or the stored auth is no longer valid.
52
- - With no argument, opens an account picker.
53
- - `/multicodex-status`
54
- - Show managed account state and cached usage information.
55
- - `/multicodex-footer`
56
- - Open an interactive panel to configure footer fields and ordering.
48
+ ## Command family
49
+
50
+ The extension now uses one command family:
51
+
52
+ - `/multicodex`
53
+ - open the main interactive UI
54
+ - `/multicodex show`
55
+ - show managed account status and cached usage
56
+ - `/multicodex use [identifier]`
57
+ - with an identifier, activate existing auth or trigger login
58
+ - with no identifier, open the account picker
59
+ - in the picker, `Backspace` removes the highlighted account after confirmation
60
+ - `/multicodex footer`
61
+ - open footer settings in interactive mode
62
+ - show footer settings summary in non-interactive mode
63
+ - `/multicodex rotation`
64
+ - show current hard-coded rotation policy
65
+ - `/multicodex verify`
66
+ - verify writable local paths and report runtime summary
67
+ - `/multicodex path`
68
+ - show storage and settings file paths
69
+ - `/multicodex reset [manual|quota|all]`
70
+ - reset manual override state, quota cooldown state, or both
71
+ - `/multicodex help`
72
+ - print compact usage text
73
+
74
+ Dynamic autocomplete is available for subcommands and for `/multicodex use <identifier>`.
75
+
76
+ ## Architecture overview
77
+
78
+ The implementation is currently organized around these modules:
79
+
80
+ - `provider.ts`
81
+ - overrides the normal `openai-codex` provider path
82
+ - mirrors Codex models and installs the managed stream wrapper
83
+ - `stream-wrapper.ts`
84
+ - account selection, retry, and quota-rotation path during streaming
85
+ - `account-manager.ts`
86
+ - managed account storage, token refresh, usage cache, activation logic, and auth import sync
87
+ - `auth.ts`
88
+ - reads pi's `~/.pi/agent/auth.json` and extracts importable `openai-codex` OAuth state
89
+ - `status.ts`
90
+ - footer rendering, footer settings persistence, footer settings panel, and footer status refresh logic
91
+ - `commands.ts`
92
+ - `/multicodex` command-family routing, autocomplete, and account-selection flows
93
+ - `hooks.ts`
94
+ - session-start and session-switch refresh behavior
95
+ - `storage.ts`
96
+ - persisted account state in `~/.pi/agent/codex-accounts.json`
57
97
 
58
98
  ## Project direction
59
99
 
@@ -63,15 +103,17 @@ Current direction:
63
103
 
64
104
  - package name: `@victor-software-house/pi-multicodex`
65
105
  - Codex-only scope
66
- - local state stored at `~/.pi/agent/codex-accounts.json`
67
- - internal logic split into focused modules
106
+ - local account state stored at `~/.pi/agent/codex-accounts.json`
107
+ - footer and future extension settings stored under `pi-multicodex` in `~/.pi/agent/settings.json`
108
+ - internal logic split into focused modules today, with a broader shared controller planned next
68
109
  - current roadmap tracked in `ROADMAP.md`
69
110
 
70
- Current next step:
111
+ Current next milestones:
71
112
 
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
113
+ 1. Persist footer settings immediately instead of waiting for panel close.
114
+ 2. Add configurable rotation settings and document the rotation behavior contract.
115
+ 3. Broaden the current footer controller into a shared MultiCodex controller.
116
+ 4. Improve imported-account labels by deriving email identity safely when possible.
75
117
 
76
118
  ## Behavior contract
77
119
 
@@ -79,7 +121,7 @@ The current runtime behavior is:
79
121
 
80
122
  ### Account selection priority
81
123
 
82
- 1. Use the manual account selected with `/multicodex-use` when it is still available.
124
+ 1. Use the manual account selected with `/multicodex use` when it is still available.
83
125
  2. Otherwise clear the stale manual override and select the best available managed account.
84
126
  3. Best-account selection prefers:
85
127
  - untouched accounts with usage data
@@ -96,13 +138,13 @@ The current runtime behavior is:
96
138
  ### Retry policy
97
139
 
98
140
  - 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.
141
+ - Retries only happen for quota and rate-limit style failures that occur before output is forwarded.
100
142
  - Once output has started streaming, the original error is surfaced instead of rotating.
101
143
 
102
144
  ### Manual override behavior
103
145
 
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.
146
+ - `/multicodex use <identifier>` sets the manual account override immediately.
147
+ - `/multicodex use` with no argument opens the account picker and sets the selected manual override.
106
148
  - Manual override is session-local state.
107
149
  - Manual override clears automatically when the selected account is no longer available or when it hits quota during rotation.
108
150
 
@@ -132,38 +174,57 @@ pnpm check
132
174
  npm pack --dry-run
133
175
  ```
134
176
 
135
- Release flow:
177
+ ## Release process
178
+
179
+ This repository uses `semantic-release` with npm trusted publishing.
180
+
181
+ Maintainer flow:
182
+
183
+ 1. Write Conventional Commits.
184
+ 2. The local `commit-msg` hook validates commit messages with Lefthook + commitlint.
185
+ 3. CI validates commit messages again and runs release checks.
186
+ 4. Merge to `main`.
187
+ 5. GitHub Actions runs `semantic-release` from `.github/workflows/publish.yml`.
188
+ 6. `semantic-release` computes the next version, creates the git tag and GitHub release, updates `package.json` and `CHANGELOG.md`, and publishes to npm through trusted publishing.
136
189
 
137
- 1. Prepare the release locally.
138
- 2. Commit the version bump.
139
- 3. Create and push a matching `v*` tag.
140
- 4. Let GitHub Actions publish through trusted publishing.
190
+ Local verification:
191
+
192
+ ```bash
193
+ pnpm check
194
+ npm pack --dry-run
195
+ pnpm release:dry
196
+ ```
141
197
 
142
198
  Local push protection:
143
199
 
144
200
  - `lefthook` runs `mise run pre-push`
145
- - the `pre-push` mise task runs the same core validations as the publish workflow:
201
+ - the `pre-push` mise task runs the same core validations as CI:
146
202
  - `pnpm check`
147
203
  - `npm pack --dry-run`
148
204
 
149
- Prepare locally:
205
+ Do not use local `npm publish` for normal releases in this repo.
150
206
 
151
- ```bash
152
- npm run release:prepare -- <version>
153
- ```
207
+ ## npm trusted publishing setup
154
208
 
155
- The helper updates `package.json` with `bun pm pkg set` and then runs the release checks.
209
+ npm-side setup is required in addition to the workflow.
156
210
 
157
- Example:
211
+ Trusted publisher mapping:
212
+
213
+ - package: `@victor-software-house/pi-multicodex`
214
+ - repository: `victor-founder/pi-multicodex`
215
+ - workflow file: `.github/workflows/publish.yml`
216
+
217
+ Useful commands:
158
218
 
159
219
  ```bash
160
- git add package.json
161
- git commit -m "release: v<version>"
162
- git tag v<version>
163
- git push origin main --tags
220
+ npm trust list @victor-software-house/pi-multicodex
221
+ script -q /dev/null bash -lc 'npm trust github @victor-software-house/pi-multicodex --repository victor-founder/pi-multicodex --file publish.yml --yes'
164
222
  ```
165
223
 
166
- Do not use local `npm publish` for normal releases in this repo.
224
+ ## Related docs
225
+
226
+ - `ROADMAP.md` for planned milestones and acceptance criteria
227
+ - `AGENTS.md` for repository-specific agent guidance
167
228
 
168
229
  ## Acknowledgment
169
230
 
@@ -195,6 +195,40 @@ export class AccountManager {
195
195
  }
196
196
  }
197
197
 
198
+ clearAllQuotaExhaustion(): number {
199
+ let cleared = 0;
200
+ for (const account of this.data.accounts) {
201
+ if (account.quotaExhaustedUntil) {
202
+ account.quotaExhaustedUntil = undefined;
203
+ cleared += 1;
204
+ }
205
+ }
206
+ if (cleared > 0) {
207
+ this.save();
208
+ this.notifyStateChanged();
209
+ }
210
+ return cleared;
211
+ }
212
+
213
+ removeAccount(email: string): boolean {
214
+ const index = this.data.accounts.findIndex(
215
+ (account) => account.email === email,
216
+ );
217
+ if (index < 0) return false;
218
+
219
+ this.data.accounts.splice(index, 1);
220
+ this.usageCache.delete(email);
221
+ if (this.manualEmail === email) {
222
+ this.manualEmail = undefined;
223
+ }
224
+ if (this.data.activeEmail === email) {
225
+ this.data.activeEmail = this.data.accounts[0]?.email;
226
+ }
227
+ this.save();
228
+ this.notifyStateChanged();
229
+ return true;
230
+ }
231
+
198
232
  getCachedUsage(email: string): CodexUsageSnapshot | undefined {
199
233
  return this.usageCache.get(email);
200
234
  }
package/commands.ts CHANGED
@@ -1,18 +1,181 @@
1
+ import { promises as fs, constants as fsConstants } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth";
2
5
  import type {
3
6
  ExtensionAPI,
4
7
  ExtensionCommandContext,
5
8
  } from "@mariozechner/pi-coding-agent";
9
+ import { getSelectListTheme } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ type AutocompleteItem,
12
+ Container,
13
+ Key,
14
+ matchesKey,
15
+ SelectList,
16
+ Text,
17
+ } from "@mariozechner/pi-tui";
6
18
  import type { AccountManager } from "./account-manager";
7
19
  import { openLoginInBrowser } from "./browser";
8
20
  import type { createUsageStatusController } from "./status";
21
+ import { STORAGE_FILE } from "./storage";
9
22
  import { formatResetAt, isUsageUntouched } from "./usage";
10
23
 
24
+ const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
25
+ const NO_ACCOUNTS_MESSAGE =
26
+ "No managed accounts found. Use /multicodex use <identifier> first.";
27
+ const HELP_TEXT =
28
+ "Usage: /multicodex [show|use [identifier]|footer|rotation|verify|path|reset [manual|quota|all]|help]";
29
+ const SUBCOMMANDS = [
30
+ "show",
31
+ "use",
32
+ "footer",
33
+ "rotation",
34
+ "verify",
35
+ "path",
36
+ "reset",
37
+ "help",
38
+ ] as const;
39
+ const RESET_TARGETS = ["manual", "quota", "all"] as const;
40
+
41
+ type Subcommand = (typeof SUBCOMMANDS)[number];
42
+ type ResetTarget = (typeof RESET_TARGETS)[number];
43
+
44
+ type AccountPanelResult =
45
+ | { action: "select"; email: string }
46
+ | { action: "remove"; email: string }
47
+ | undefined;
48
+
11
49
  function getErrorMessage(error: unknown): string {
12
50
  if (error instanceof Error) return error.message;
13
51
  return typeof error === "string" ? error : JSON.stringify(error);
14
52
  }
15
53
 
54
+ function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
55
+ return values.map((value) => ({ value, label: value }));
56
+ }
57
+
58
+ function parseCommandArgs(args: string): {
59
+ subcommand: string | undefined;
60
+ rest: string;
61
+ } {
62
+ const trimmed = args.trim();
63
+ if (!trimmed) {
64
+ return { subcommand: undefined, rest: "" };
65
+ }
66
+ const firstSpaceIndex = trimmed.indexOf(" ");
67
+ if (firstSpaceIndex < 0) {
68
+ return { subcommand: trimmed.toLowerCase(), rest: "" };
69
+ }
70
+ return {
71
+ subcommand: trimmed.slice(0, firstSpaceIndex).toLowerCase(),
72
+ rest: trimmed.slice(firstSpaceIndex + 1).trim(),
73
+ };
74
+ }
75
+
76
+ function isSubcommand(value: string): value is Subcommand {
77
+ return SUBCOMMANDS.some((subcommand) => subcommand === value);
78
+ }
79
+
80
+ function parseResetTarget(value: string): ResetTarget | undefined {
81
+ if (value === "manual" || value === "quota" || value === "all") {
82
+ return value;
83
+ }
84
+ return undefined;
85
+ }
86
+
87
+ function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
88
+ if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
89
+ return email;
90
+ }
91
+ return `${email} (Quota)`;
92
+ }
93
+
94
+ function formatAccountStatusLine(
95
+ accountManager: AccountManager,
96
+ email: string,
97
+ ): string {
98
+ const account = accountManager.getAccount(email);
99
+ if (!account) return email;
100
+ const usage = accountManager.getCachedUsage(account.email);
101
+ const active = accountManager.getActiveAccount();
102
+ const manual = accountManager.getManualAccount();
103
+ const quotaHit =
104
+ account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
105
+ const untouched = isUsageUntouched(usage) ? "untouched" : null;
106
+ const imported = account.importSource ? "imported" : null;
107
+ const tags = [
108
+ active?.email === account.email ? "active" : null,
109
+ manual?.email === account.email ? "manual" : null,
110
+ quotaHit ? "quota" : null,
111
+ untouched,
112
+ imported,
113
+ ]
114
+ .filter(Boolean)
115
+ .join(", ");
116
+ const suffix = tags ? ` (${tags})` : "";
117
+ const primaryUsed = usage?.primary?.usedPercent;
118
+ const secondaryUsed = usage?.secondary?.usedPercent;
119
+ const primaryReset = usage?.primary?.resetAt;
120
+ const secondaryReset = usage?.secondary?.resetAt;
121
+ const primaryLabel =
122
+ primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
123
+ const secondaryLabel =
124
+ secondaryUsed === undefined ? "unknown" : `${Math.round(secondaryUsed)}%`;
125
+ const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
126
+ return `${account.email}${suffix} - ${usageSummary}`;
127
+ }
128
+
129
+ function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
130
+ const matches = SUBCOMMANDS.filter((value) => value.startsWith(prefix));
131
+ return matches.length > 0 ? toAutocompleteItems(matches) : null;
132
+ }
133
+
134
+ function getUseCompletions(
135
+ prefix: string,
136
+ accountManager: AccountManager,
137
+ ): AutocompleteItem[] | null {
138
+ const matches = accountManager
139
+ .getAccounts()
140
+ .map((account) => account.email)
141
+ .filter((value) => value.startsWith(prefix));
142
+ if (matches.length === 0) return null;
143
+ return matches.map((value) => ({ value: `use ${value}`, label: value }));
144
+ }
145
+
146
+ function getResetCompletions(prefix: string): AutocompleteItem[] | null {
147
+ const matches = RESET_TARGETS.filter((value) => value.startsWith(prefix));
148
+ if (matches.length === 0) return null;
149
+ return matches.map((value) => ({ value: `reset ${value}`, label: value }));
150
+ }
151
+
152
+ function getCommandCompletions(
153
+ argumentPrefix: string,
154
+ accountManager: AccountManager,
155
+ ): AutocompleteItem[] | null {
156
+ const trimmedStart = argumentPrefix.trimStart();
157
+ if (!trimmedStart) {
158
+ return toAutocompleteItems(SUBCOMMANDS);
159
+ }
160
+
161
+ const firstSpaceIndex = trimmedStart.indexOf(" ");
162
+ if (firstSpaceIndex < 0) {
163
+ return getSubcommandCompletions(trimmedStart.toLowerCase());
164
+ }
165
+
166
+ const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
167
+ const rest = trimmedStart.slice(firstSpaceIndex + 1).trimStart();
168
+
169
+ if (subcommand === "use") {
170
+ return getUseCompletions(rest, accountManager);
171
+ }
172
+ if (subcommand === "reset") {
173
+ return getResetCompletions(rest);
174
+ }
175
+
176
+ return null;
177
+ }
178
+
16
179
  async function loginAndActivateAccount(
17
180
  pi: ExtensionAPI,
18
181
  ctx: ExtensionCommandContext,
@@ -68,113 +231,429 @@ async function useOrLoginAccount(
68
231
  await loginAndActivateAccount(pi, ctx, accountManager, identifier);
69
232
  }
70
233
 
234
+ async function openAccountSelectionPanel(
235
+ ctx: ExtensionCommandContext,
236
+ accountManager: AccountManager,
237
+ ): Promise<AccountPanelResult> {
238
+ const accounts = accountManager.getAccounts();
239
+ const items = accounts.map((account) => ({
240
+ value: account.email,
241
+ label: getAccountLabel(account.email, account.quotaExhaustedUntil),
242
+ }));
243
+
244
+ return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
245
+ const container = new Container();
246
+ container.addChild(
247
+ new Text(theme.fg("accent", theme.bold("Select Account")), 1, 0),
248
+ );
249
+ container.addChild(
250
+ new Text(
251
+ theme.fg("dim", "Enter: use Backspace: remove account Esc: cancel"),
252
+ 1,
253
+ 0,
254
+ ),
255
+ );
256
+
257
+ const selectList = new SelectList(items, 10, getSelectListTheme());
258
+ selectList.onSelect = (item) => {
259
+ done({ action: "select", email: item.value });
260
+ };
261
+ selectList.onCancel = () => done(undefined);
262
+ container.addChild(selectList);
263
+
264
+ return {
265
+ render: (width: number) => container.render(width),
266
+ invalidate: () => container.invalidate(),
267
+ handleInput: (data: string) => {
268
+ if (matchesKey(data, Key.backspace)) {
269
+ const selected = selectList.getSelectedItem();
270
+ if (selected) {
271
+ done({ action: "remove", email: selected.value });
272
+ }
273
+ return;
274
+ }
275
+ selectList.handleInput(data);
276
+ },
277
+ };
278
+ });
279
+ }
280
+
281
+ async function openAccountSelectionFlow(
282
+ ctx: ExtensionCommandContext,
283
+ accountManager: AccountManager,
284
+ statusController: ReturnType<typeof createUsageStatusController>,
285
+ ): Promise<void> {
286
+ while (true) {
287
+ const accounts = accountManager.getAccounts();
288
+ if (accounts.length === 0) {
289
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
290
+ return;
291
+ }
292
+
293
+ const result = await openAccountSelectionPanel(ctx, accountManager);
294
+ if (!result) return;
295
+
296
+ if (result.action === "select") {
297
+ accountManager.setManualAccount(result.email);
298
+ ctx.ui.notify(`Now using ${result.email}`, "info");
299
+ await statusController.refreshFor(ctx);
300
+ return;
301
+ }
302
+
303
+ const accountToRemove = accountManager.getAccount(result.email);
304
+ if (!accountToRemove) continue;
305
+
306
+ const active = accountManager.getActiveAccount();
307
+ const isActive = active?.email === result.email;
308
+ const message = isActive
309
+ ? `Remove ${result.email}? This account is currently active and MultiCodex will switch to another account.`
310
+ : `Remove ${result.email}?`;
311
+ const confirmed = await ctx.ui.confirm("Remove account", message);
312
+ if (!confirmed) continue;
313
+
314
+ const removed = accountManager.removeAccount(result.email);
315
+ if (!removed) continue;
316
+
317
+ ctx.ui.notify(`Removed ${result.email}`, "info");
318
+ await statusController.refreshFor(ctx);
319
+ }
320
+ }
321
+
322
+ async function runShowSubcommand(
323
+ ctx: ExtensionCommandContext,
324
+ accountManager: AccountManager,
325
+ ): Promise<void> {
326
+ await accountManager.syncImportedOpenAICodexAuth();
327
+ await accountManager.refreshUsageForAllAccounts();
328
+ const accounts = accountManager.getAccounts();
329
+ if (accounts.length === 0) {
330
+ ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
331
+ return;
332
+ }
333
+
334
+ if (!ctx.hasUI) {
335
+ const active = accountManager.getActiveAccount()?.email ?? "none";
336
+ const manual = accountManager.getManualAccount()?.email ?? "none";
337
+ ctx.ui.notify(
338
+ `multicodex: accounts=${accounts.length} active=${active} manual=${manual}`,
339
+ "info",
340
+ );
341
+ return;
342
+ }
343
+
344
+ const options = accounts.map((account) =>
345
+ formatAccountStatusLine(accountManager, account.email),
346
+ );
347
+ await ctx.ui.select("MultiCodex Accounts", options);
348
+ }
349
+
350
+ async function runFooterSubcommand(
351
+ ctx: ExtensionCommandContext,
352
+ statusController: ReturnType<typeof createUsageStatusController>,
353
+ ): Promise<void> {
354
+ if (!ctx.hasUI) {
355
+ await statusController.loadPreferences(ctx);
356
+ const preferences = statusController.getPreferences();
357
+ ctx.ui.notify(
358
+ `footer: usageMode=${preferences.usageMode} resetWindow=${preferences.resetWindow} showAccount=${preferences.showAccount ? "on" : "off"} showReset=${preferences.showReset ? "on" : "off"} order=${preferences.order}`,
359
+ "info",
360
+ );
361
+ return;
362
+ }
363
+
364
+ await statusController.openPreferencesPanel(ctx);
365
+ }
366
+
367
+ async function runRotationSubcommand(
368
+ ctx: ExtensionCommandContext,
369
+ ): Promise<void> {
370
+ const lines = [
371
+ "Rotation settings are not configurable yet.",
372
+ "Current policy: manual account, then untouched accounts, then earliest weekly reset, then random fallback.",
373
+ "Quota cooldown uses next known reset time, with 1 hour fallback when unknown.",
374
+ ];
375
+
376
+ if (!ctx.hasUI) {
377
+ ctx.ui.notify(
378
+ "rotation: manual->untouched->earliest-weekly-reset->random, cooldown=next-reset-or-1h",
379
+ "info",
380
+ );
381
+ return;
382
+ }
383
+
384
+ await ctx.ui.select("MultiCodex Rotation", lines);
385
+ }
386
+
387
+ async function isWritableDirectoryFor(filePath: string): Promise<boolean> {
388
+ try {
389
+ const directory = path.dirname(filePath);
390
+ await fs.mkdir(directory, { recursive: true });
391
+ await fs.access(directory, fsConstants.R_OK | fsConstants.W_OK);
392
+ return true;
393
+ } catch {
394
+ return false;
395
+ }
396
+ }
397
+
398
+ async function runVerifySubcommand(
399
+ ctx: ExtensionCommandContext,
400
+ accountManager: AccountManager,
401
+ statusController: ReturnType<typeof createUsageStatusController>,
402
+ ): Promise<void> {
403
+ const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
404
+ const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
405
+ const authImported = await accountManager.syncImportedOpenAICodexAuth();
406
+ await statusController.loadPreferences(ctx);
407
+ const accounts = accountManager.getAccounts().length;
408
+ const active = accountManager.getActiveAccount()?.email ?? "none";
409
+ const ok = storageWritable && settingsWritable;
410
+
411
+ if (!ctx.hasUI) {
412
+ ctx.ui.notify(
413
+ `verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"}`,
414
+ ok ? "info" : "warning",
415
+ );
416
+ return;
417
+ }
418
+
419
+ const lines = [
420
+ `storage directory writable: ${storageWritable ? "yes" : "no"}`,
421
+ `settings directory writable: ${settingsWritable ? "yes" : "no"}`,
422
+ `managed accounts: ${accounts}`,
423
+ `active account: ${active}`,
424
+ `auth import changed state: ${authImported ? "yes" : "no"}`,
425
+ ];
426
+ await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
427
+ }
428
+
429
+ async function runPathSubcommand(ctx: ExtensionCommandContext): Promise<void> {
430
+ if (!ctx.hasUI) {
431
+ ctx.ui.notify(
432
+ `paths: storage=${STORAGE_FILE} settings=${SETTINGS_FILE}`,
433
+ "info",
434
+ );
435
+ return;
436
+ }
437
+
438
+ await ctx.ui.select("MultiCodex Paths", [
439
+ `Managed account storage: ${STORAGE_FILE}`,
440
+ `Extension settings: ${SETTINGS_FILE}`,
441
+ ]);
442
+ }
443
+
444
+ async function chooseResetTarget(
445
+ ctx: ExtensionCommandContext,
446
+ argument: string,
447
+ ): Promise<ResetTarget | undefined> {
448
+ const explicitTarget = parseResetTarget(argument.toLowerCase());
449
+ if (explicitTarget) {
450
+ return explicitTarget;
451
+ }
452
+
453
+ if (argument) {
454
+ ctx.ui.notify(
455
+ "Unknown reset target. Use: /multicodex reset [manual|quota|all]",
456
+ "warning",
457
+ );
458
+ return undefined;
459
+ }
460
+
461
+ if (!ctx.hasUI) {
462
+ return "all";
463
+ }
464
+
465
+ const options = [
466
+ "manual - clear manual account override",
467
+ "quota - clear quota cooldown markers",
468
+ "all - clear manual override and quota cooldown markers",
469
+ ];
470
+ const selected = await ctx.ui.select("Reset MultiCodex State", options);
471
+ if (!selected) return undefined;
472
+ if (selected.startsWith("manual")) return "manual";
473
+ if (selected.startsWith("quota")) return "quota";
474
+ return "all";
475
+ }
476
+
477
+ async function runResetSubcommand(
478
+ ctx: ExtensionCommandContext,
479
+ accountManager: AccountManager,
480
+ statusController: ReturnType<typeof createUsageStatusController>,
481
+ rest: string,
482
+ ): Promise<void> {
483
+ const target = await chooseResetTarget(ctx, rest);
484
+ if (!target) return;
485
+
486
+ if (target === "all" && ctx.hasUI) {
487
+ const confirmed = await ctx.ui.confirm(
488
+ "Reset MultiCodex state",
489
+ "Clear manual account override and all quota cooldown markers?",
490
+ );
491
+ if (!confirmed) return;
492
+ }
493
+
494
+ const hadManual = accountManager.hasManualAccount();
495
+ if (target === "manual" || target === "all") {
496
+ accountManager.clearManualAccount();
497
+ }
498
+
499
+ let clearedQuota = 0;
500
+ if (target === "quota" || target === "all") {
501
+ clearedQuota = accountManager.clearAllQuotaExhaustion();
502
+ }
503
+
504
+ const manualCleared = hadManual && !accountManager.hasManualAccount();
505
+ ctx.ui.notify(
506
+ `reset: target=${target} manualCleared=${manualCleared ? "yes" : "no"} quotaCleared=${clearedQuota}`,
507
+ "info",
508
+ );
509
+ await statusController.refreshFor(ctx);
510
+ }
511
+
512
+ function runHelpSubcommand(ctx: ExtensionCommandContext): void {
513
+ ctx.ui.notify(HELP_TEXT, "info");
514
+ }
515
+
516
+ async function runUseSubcommand(
517
+ pi: ExtensionAPI,
518
+ ctx: ExtensionCommandContext,
519
+ accountManager: AccountManager,
520
+ statusController: ReturnType<typeof createUsageStatusController>,
521
+ rest: string,
522
+ ): Promise<void> {
523
+ await accountManager.syncImportedOpenAICodexAuth();
524
+
525
+ if (rest) {
526
+ await useOrLoginAccount(pi, ctx, accountManager, rest);
527
+ await statusController.refreshFor(ctx);
528
+ return;
529
+ }
530
+
531
+ if (!ctx.hasUI) {
532
+ ctx.ui.notify(
533
+ "/multicodex use requires an identifier in non-interactive mode.",
534
+ "warning",
535
+ );
536
+ return;
537
+ }
538
+
539
+ await openAccountSelectionFlow(ctx, accountManager, statusController);
540
+ }
541
+
542
+ async function runSubcommand(
543
+ subcommand: Subcommand,
544
+ rest: string,
545
+ pi: ExtensionAPI,
546
+ ctx: ExtensionCommandContext,
547
+ accountManager: AccountManager,
548
+ statusController: ReturnType<typeof createUsageStatusController>,
549
+ ): Promise<void> {
550
+ if (subcommand === "show") {
551
+ await runShowSubcommand(ctx, accountManager);
552
+ return;
553
+ }
554
+ if (subcommand === "use") {
555
+ await runUseSubcommand(pi, ctx, accountManager, statusController, rest);
556
+ return;
557
+ }
558
+ if (subcommand === "footer") {
559
+ await runFooterSubcommand(ctx, statusController);
560
+ return;
561
+ }
562
+ if (subcommand === "rotation") {
563
+ await runRotationSubcommand(ctx);
564
+ return;
565
+ }
566
+ if (subcommand === "verify") {
567
+ await runVerifySubcommand(ctx, accountManager, statusController);
568
+ return;
569
+ }
570
+ if (subcommand === "path") {
571
+ await runPathSubcommand(ctx);
572
+ return;
573
+ }
574
+ if (subcommand === "reset") {
575
+ await runResetSubcommand(ctx, accountManager, statusController, rest);
576
+ return;
577
+ }
578
+
579
+ runHelpSubcommand(ctx);
580
+ }
581
+
582
+ async function openMainPanel(
583
+ pi: ExtensionAPI,
584
+ ctx: ExtensionCommandContext,
585
+ accountManager: AccountManager,
586
+ statusController: ReturnType<typeof createUsageStatusController>,
587
+ ): Promise<void> {
588
+ const actions = [
589
+ "use: select, activate, or remove managed account",
590
+ "show: managed account and usage summary",
591
+ "footer: footer settings panel",
592
+ "rotation: current rotation behavior",
593
+ "verify: runtime health checks",
594
+ "path: storage and settings locations",
595
+ "reset: clear manual or quota state",
596
+ "help: command usage",
597
+ ];
598
+
599
+ const selected = await ctx.ui.select("MultiCodex", actions);
600
+ if (!selected) return;
601
+
602
+ const subcommandText = selected.split(":")[0]?.trim() ?? "";
603
+ if (!isSubcommand(subcommandText)) {
604
+ ctx.ui.notify(`Unknown subcommand: ${subcommandText}`, "warning");
605
+ return;
606
+ }
607
+ await runSubcommand(
608
+ subcommandText,
609
+ "",
610
+ pi,
611
+ ctx,
612
+ accountManager,
613
+ statusController,
614
+ );
615
+ }
616
+
71
617
  export function registerCommands(
72
618
  pi: ExtensionAPI,
73
619
  accountManager: AccountManager,
74
620
  statusController: ReturnType<typeof createUsageStatusController>,
75
621
  ): void {
76
- pi.registerCommand("multicodex-use", {
77
- description:
78
- "Use an existing Codex account, or log in when the identifier is missing",
622
+ pi.registerCommand("multicodex", {
623
+ description: "Manage MultiCodex accounts, rotation, and footer settings",
624
+ getArgumentCompletions: (argumentPrefix: string) =>
625
+ getCommandCompletions(argumentPrefix, accountManager),
79
626
  handler: async (
80
627
  args: string,
81
628
  ctx: ExtensionCommandContext,
82
629
  ): Promise<void> => {
83
- const identifier = args.trim();
84
- if (identifier) {
85
- await useOrLoginAccount(pi, ctx, accountManager, identifier);
86
- await statusController.refreshFor(ctx);
630
+ const parsed = parseCommandArgs(args);
631
+ if (!parsed.subcommand) {
632
+ if (!ctx.hasUI) {
633
+ ctx.ui.notify(
634
+ "/multicodex requires a subcommand in non-interactive mode. Use /multicodex help.",
635
+ "warning",
636
+ );
637
+ return;
638
+ }
639
+ await openMainPanel(pi, ctx, accountManager, statusController);
87
640
  return;
88
641
  }
89
642
 
90
- await accountManager.syncImportedOpenAICodexAuth();
91
- const accounts = accountManager.getAccounts();
92
- if (accounts.length === 0) {
93
- ctx.ui.notify(
94
- "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
95
- "warning",
96
- );
643
+ if (!isSubcommand(parsed.subcommand)) {
644
+ ctx.ui.notify(`Unknown subcommand: ${parsed.subcommand}`, "warning");
645
+ runHelpSubcommand(ctx);
97
646
  return;
98
647
  }
99
648
 
100
- const options = accounts.map(
101
- (account) =>
102
- account.email +
103
- (account.quotaExhaustedUntil &&
104
- account.quotaExhaustedUntil > Date.now()
105
- ? " (Quota)"
106
- : ""),
649
+ await runSubcommand(
650
+ parsed.subcommand,
651
+ parsed.rest,
652
+ pi,
653
+ ctx,
654
+ accountManager,
655
+ statusController,
107
656
  );
108
- const selected = await ctx.ui.select("Select Account", options);
109
- if (!selected) return;
110
-
111
- const email = selected.split(" (")[0] ?? selected;
112
- accountManager.setManualAccount(email);
113
- ctx.ui.notify(`Now using ${email}`, "info");
114
- await statusController.refreshFor(ctx);
115
- },
116
- });
117
-
118
- pi.registerCommand("multicodex-status", {
119
- description: "Show all Codex accounts and active status",
120
- handler: async (
121
- _args: string,
122
- ctx: ExtensionCommandContext,
123
- ): Promise<void> => {
124
- await accountManager.syncImportedOpenAICodexAuth();
125
- await accountManager.refreshUsageForAllAccounts();
126
- const accounts = accountManager.getAccounts();
127
- if (accounts.length === 0) {
128
- ctx.ui.notify(
129
- "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
130
- "warning",
131
- );
132
- return;
133
- }
134
-
135
- const active = accountManager.getActiveAccount();
136
- const options = accounts.map((account) => {
137
- const usage = accountManager.getCachedUsage(account.email);
138
- const isActive = active?.email === account.email;
139
- const quotaHit =
140
- account.quotaExhaustedUntil &&
141
- account.quotaExhaustedUntil > Date.now();
142
- const untouched = isUsageUntouched(usage) ? "untouched" : null;
143
- const imported = account.importSource ? "imported" : null;
144
- const tags = [
145
- isActive ? "active" : null,
146
- quotaHit ? "quota" : null,
147
- untouched,
148
- imported,
149
- ]
150
- .filter(Boolean)
151
- .join(", ");
152
- const suffix = tags ? ` (${tags})` : "";
153
- const primaryUsed = usage?.primary?.usedPercent;
154
- const secondaryUsed = usage?.secondary?.usedPercent;
155
- const primaryReset = usage?.primary?.resetAt;
156
- const secondaryReset = usage?.secondary?.resetAt;
157
- const primaryLabel =
158
- primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
159
- const secondaryLabel =
160
- secondaryUsed === undefined
161
- ? "unknown"
162
- : `${Math.round(secondaryUsed)}%`;
163
- const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
164
- return `${isActive ? "•" : " "} ${account.email}${suffix} - ${usageSummary}`;
165
- });
166
-
167
- await ctx.ui.select("MultiCodex Accounts", options);
168
- },
169
- });
170
-
171
- pi.registerCommand("multicodex-footer", {
172
- description: "Configure the MultiCodex usage footer",
173
- handler: async (
174
- _args: string,
175
- ctx: ExtensionCommandContext,
176
- ): Promise<void> => {
177
- await statusController.openPreferencesPanel(ctx);
178
657
  },
179
658
  });
180
659
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.11",
3
+ "version": "2.0.0",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -56,8 +56,7 @@
56
56
  "tsgo": "tsgo -p tsconfig.json",
57
57
  "check": "pnpm lint && pnpm tsgo && pnpm test",
58
58
  "pack:dry": "npm pack --dry-run",
59
- "release:dry": "bun ./scripts/publish.ts --dry-run",
60
- "release:prepare": "bun ./scripts/publish.ts"
59
+ "release:dry": "pnpm exec semantic-release --dry-run"
61
60
  },
62
61
  "peerDependencies": {
63
62
  "@mariozechner/pi-ai": "*",
@@ -77,11 +76,20 @@
77
76
  },
78
77
  "devDependencies": {
79
78
  "@biomejs/biome": "^2.4.7",
79
+ "@commitlint/cli": "^20.4.4",
80
+ "@commitlint/config-conventional": "^20.4.4",
80
81
  "@mariozechner/pi-ai": "^0.58.1",
81
82
  "@mariozechner/pi-coding-agent": "^0.58.1",
82
83
  "@mariozechner/pi-tui": "^0.58.1",
84
+ "@semantic-release/changelog": "^6.0.3",
85
+ "@semantic-release/commit-analyzer": "^13.0.1",
86
+ "@semantic-release/git": "^10.0.1",
87
+ "@semantic-release/github": "^12.0.6",
88
+ "@semantic-release/npm": "^13.1.5",
89
+ "@semantic-release/release-notes-generator": "^14.1.0",
83
90
  "@types/node": "^25.5.0",
84
91
  "@typescript/native-preview": "7.0.0-dev.20260314.1",
92
+ "semantic-release": "^25.0.3",
85
93
  "typescript": "^5.9.3",
86
94
  "vitest": "^4.1.0"
87
95
  },
package/stream-wrapper.ts CHANGED
@@ -101,7 +101,7 @@ export function createStreamWrapper(
101
101
  }
102
102
  if (!account) {
103
103
  throw new Error(
104
- "No available Multicodex accounts. Please use /multicodex-use <identifier>.",
104
+ "No available Multicodex accounts. Please use /multicodex use <identifier>.",
105
105
  );
106
106
  }
107
107