@victor-software-house/pi-multicodex 1.0.10 → 1.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
@@ -45,7 +45,9 @@ Run the extension directly during local development:
45
45
  pi -e ./index.ts
46
46
  ```
47
47
 
48
- ## Commands
48
+ ## Current commands
49
+
50
+ These commands reflect the current shipped implementation:
49
51
 
50
52
  - `/multicodex-use [identifier]`
51
53
  - Use an existing managed account, or start the Codex login flow when the account is missing or the stored auth is no longer valid.
@@ -55,6 +57,57 @@ pi -e ./index.ts
55
57
  - `/multicodex-footer`
56
58
  - Open an interactive panel to configure footer fields and ordering.
57
59
 
60
+ ## Planned command migration
61
+
62
+ The next user-facing milestone is a command-surface migration to one command family:
63
+
64
+ - `/multicodex`
65
+ - open the main interactive UI
66
+ - `/multicodex show`
67
+ - show runtime state and active-account summary
68
+ - `/multicodex use [identifier]`
69
+ - choose or activate an account
70
+ - `/multicodex footer`
71
+ - open footer settings
72
+ - `/multicodex rotation`
73
+ - open rotation settings
74
+ - `/multicodex verify`
75
+ - verify runtime health and local storage access
76
+ - `/multicodex path`
77
+ - show config and storage paths
78
+ - `/multicodex reset`
79
+ - reset selected extension state
80
+ - `/multicodex help`
81
+ - print compact usage text
82
+
83
+ Migration policy:
84
+
85
+ - the old top-level commands will be removed once `/multicodex` is ready
86
+ - no backward-compatibility aliases are planned
87
+ - README, roadmap, tests, and release notes will move together in the same change
88
+
89
+ ## Architecture overview
90
+
91
+ The implementation is currently organized around these modules:
92
+
93
+ - `provider.ts`
94
+ - overrides the normal `openai-codex` provider path
95
+ - mirrors Codex models and installs the managed stream wrapper
96
+ - `stream-wrapper.ts`
97
+ - account selection, retry, and quota-rotation path during streaming
98
+ - `account-manager.ts`
99
+ - managed account storage, token refresh, usage cache, activation logic, and auth import sync
100
+ - `auth.ts`
101
+ - reads pi's `~/.pi/agent/auth.json` and extracts importable `openai-codex` OAuth state
102
+ - `status.ts`
103
+ - footer rendering, footer settings persistence, footer settings panel, and footer status refresh logic
104
+ - `commands.ts`
105
+ - current slash command registrations and account-selection flows
106
+ - `hooks.ts`
107
+ - session-start and session-switch refresh behavior
108
+ - `storage.ts`
109
+ - persisted account state in `~/.pi/agent/codex-accounts.json`
110
+
58
111
  ## Project direction
59
112
 
60
113
  This project is maintained as its own package and release line.
@@ -63,15 +116,19 @@ Current direction:
63
116
 
64
117
  - package name: `@victor-software-house/pi-multicodex`
65
118
  - Codex-only scope
66
- - local state stored at `~/.pi/agent/codex-accounts.json`
67
- - internal logic split into focused modules
119
+ - local account state stored at `~/.pi/agent/codex-accounts.json`
120
+ - footer and future extension settings stored under `pi-multicodex` in `~/.pi/agent/settings.json`
121
+ - internal logic split into focused modules today, with a broader shared controller planned next
68
122
  - current roadmap tracked in `ROADMAP.md`
69
123
 
70
- Current next step:
124
+ Current next milestones:
71
125
 
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
126
+ 1. Replace the split command surface with the `/multicodex` command family.
127
+ 2. Add dynamic autocomplete for subcommands and managed account identifiers.
128
+ 3. Make account inspection and selection consistently actionable.
129
+ 4. Persist footer settings immediately instead of waiting for panel close.
130
+ 5. Add configurable rotation settings and document the rotation behavior contract.
131
+ 6. Broaden the current footer controller into a shared MultiCodex controller.
75
132
 
76
133
  ## Behavior contract
77
134
 
@@ -96,7 +153,7 @@ The current runtime behavior is:
96
153
  ### Retry policy
97
154
 
98
155
  - 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.
156
+ - Retries only happen for quota and rate-limit style failures that occur before output is forwarded.
100
157
  - Once output has started streaming, the original error is surfaced instead of rotating.
101
158
 
102
159
  ### Manual override behavior
@@ -132,38 +189,57 @@ pnpm check
132
189
  npm pack --dry-run
133
190
  ```
134
191
 
135
- Release flow:
192
+ ## Release process
193
+
194
+ This repository uses `semantic-release` with npm trusted publishing.
195
+
196
+ Maintainer flow:
197
+
198
+ 1. Write Conventional Commits.
199
+ 2. The local `commit-msg` hook validates commit messages with Lefthook + commitlint.
200
+ 3. CI validates commit messages again and runs release checks.
201
+ 4. Merge to `main`.
202
+ 5. GitHub Actions runs `semantic-release` from `.github/workflows/publish.yml`.
203
+ 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
204
 
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.
205
+ Local verification:
206
+
207
+ ```bash
208
+ pnpm check
209
+ npm pack --dry-run
210
+ pnpm release:dry
211
+ ```
141
212
 
142
213
  Local push protection:
143
214
 
144
215
  - `lefthook` runs `mise run pre-push`
145
- - the `pre-push` mise task runs the same core validations as the publish workflow:
216
+ - the `pre-push` mise task runs the same core validations as CI:
146
217
  - `pnpm check`
147
218
  - `npm pack --dry-run`
148
219
 
149
- Prepare locally:
220
+ Do not use local `npm publish` for normal releases in this repo.
150
221
 
151
- ```bash
152
- npm run release:prepare -- <version>
153
- ```
222
+ ## npm trusted publishing setup
154
223
 
155
- The helper updates `package.json` with `bun pm pkg set` and then runs the release checks.
224
+ npm-side setup is required in addition to the workflow.
156
225
 
157
- Example:
226
+ Trusted publisher mapping:
227
+
228
+ - package: `@victor-software-house/pi-multicodex`
229
+ - repository: `victor-founder/pi-multicodex`
230
+ - workflow file: `.github/workflows/publish.yml`
231
+
232
+ Useful commands:
158
233
 
159
234
  ```bash
160
- git add package.json
161
- git commit -m "release: v<version>"
162
- git tag v<version>
163
- git push origin main --tags
235
+ npm trust list @victor-software-house/pi-multicodex
236
+ script -q /dev/null bash -lc 'npm trust github @victor-software-house/pi-multicodex --repository victor-founder/pi-multicodex --file publish.yml --yes'
164
237
  ```
165
238
 
166
- Do not use local `npm publish` for normal releases in this repo.
239
+ ## Related docs
240
+
241
+ - `ROADMAP.md` for planned milestones and acceptance criteria
242
+ - `AGENTS.md` for repository-specific agent guidance
167
243
 
168
244
  ## Acknowledgment
169
245
 
@@ -195,6 +195,25 @@ export class AccountManager {
195
195
  }
196
196
  }
197
197
 
198
+ removeAccount(email: string): boolean {
199
+ const index = this.data.accounts.findIndex(
200
+ (account) => account.email === email,
201
+ );
202
+ if (index < 0) return false;
203
+
204
+ this.data.accounts.splice(index, 1);
205
+ this.usageCache.delete(email);
206
+ if (this.manualEmail === email) {
207
+ this.manualEmail = undefined;
208
+ }
209
+ if (this.data.activeEmail === email) {
210
+ this.data.activeEmail = this.data.accounts[0]?.email;
211
+ }
212
+ this.save();
213
+ this.notifyStateChanged();
214
+ return true;
215
+ }
216
+
198
217
  getCachedUsage(email: string): CodexUsageSnapshot | undefined {
199
218
  return this.usageCache.get(email);
200
219
  }
package/commands.ts CHANGED
@@ -3,6 +3,14 @@ import type {
3
3
  ExtensionAPI,
4
4
  ExtensionCommandContext,
5
5
  } from "@mariozechner/pi-coding-agent";
6
+ import { getSelectListTheme } from "@mariozechner/pi-coding-agent";
7
+ import {
8
+ Container,
9
+ Key,
10
+ matchesKey,
11
+ SelectList,
12
+ Text,
13
+ } from "@mariozechner/pi-tui";
6
14
  import type { AccountManager } from "./account-manager";
7
15
  import { openLoginInBrowser } from "./browser";
8
16
  import type { createUsageStatusController } from "./status";
@@ -68,6 +76,109 @@ async function useOrLoginAccount(
68
76
  await loginAndActivateAccount(pi, ctx, accountManager, identifier);
69
77
  }
70
78
 
79
+ type AccountPanelResult =
80
+ | { action: "select"; email: string }
81
+ | { action: "remove"; email: string }
82
+ | undefined;
83
+
84
+ function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
85
+ if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
86
+ return email;
87
+ }
88
+ return `${email} (Quota)`;
89
+ }
90
+
91
+ async function openAccountSelectionPanel(
92
+ ctx: ExtensionCommandContext,
93
+ accountManager: AccountManager,
94
+ ): Promise<AccountPanelResult> {
95
+ const accounts = accountManager.getAccounts();
96
+ const items = accounts.map((account) => ({
97
+ value: account.email,
98
+ label: getAccountLabel(account.email, account.quotaExhaustedUntil),
99
+ }));
100
+
101
+ return ctx.ui.custom<AccountPanelResult>((_tui, theme, _kb, done) => {
102
+ const container = new Container();
103
+ container.addChild(
104
+ new Text(theme.fg("accent", theme.bold("Select Account")), 1, 0),
105
+ );
106
+ container.addChild(
107
+ new Text(
108
+ theme.fg("dim", "Enter: use Backspace: remove account Esc: cancel"),
109
+ 1,
110
+ 0,
111
+ ),
112
+ );
113
+
114
+ const selectList = new SelectList(items, 10, getSelectListTheme());
115
+ selectList.onSelect = (item) => {
116
+ done({ action: "select", email: item.value });
117
+ };
118
+ selectList.onCancel = () => done(undefined);
119
+ container.addChild(selectList);
120
+
121
+ return {
122
+ render: (width: number) => container.render(width),
123
+ invalidate: () => container.invalidate(),
124
+ handleInput: (data: string) => {
125
+ if (matchesKey(data, Key.backspace)) {
126
+ const selected = selectList.getSelectedItem();
127
+ if (selected) {
128
+ done({ action: "remove", email: selected.value });
129
+ }
130
+ return;
131
+ }
132
+ selectList.handleInput(data);
133
+ },
134
+ };
135
+ });
136
+ }
137
+
138
+ async function openAccountSelectionFlow(
139
+ ctx: ExtensionCommandContext,
140
+ accountManager: AccountManager,
141
+ statusController: ReturnType<typeof createUsageStatusController>,
142
+ ): Promise<void> {
143
+ while (true) {
144
+ const accounts = accountManager.getAccounts();
145
+ if (accounts.length === 0) {
146
+ ctx.ui.notify(
147
+ "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
148
+ "warning",
149
+ );
150
+ return;
151
+ }
152
+
153
+ const result = await openAccountSelectionPanel(ctx, accountManager);
154
+ if (!result) return;
155
+
156
+ if (result.action === "select") {
157
+ accountManager.setManualAccount(result.email);
158
+ ctx.ui.notify(`Now using ${result.email}`, "info");
159
+ await statusController.refreshFor(ctx);
160
+ return;
161
+ }
162
+
163
+ const accountToRemove = accountManager.getAccount(result.email);
164
+ if (!accountToRemove) continue;
165
+
166
+ const active = accountManager.getActiveAccount();
167
+ const isActive = active?.email === result.email;
168
+ const message = isActive
169
+ ? `Remove ${result.email}? This account is currently active and MultiCodex will switch to another account.`
170
+ : `Remove ${result.email}?`;
171
+ const confirmed = await ctx.ui.confirm("Remove account", message);
172
+ if (!confirmed) continue;
173
+
174
+ const removed = accountManager.removeAccount(result.email);
175
+ if (!removed) continue;
176
+
177
+ ctx.ui.notify(`Removed ${result.email}`, "info");
178
+ await statusController.refreshFor(ctx);
179
+ }
180
+ }
181
+
71
182
  export function registerCommands(
72
183
  pi: ExtensionAPI,
73
184
  accountManager: AccountManager,
@@ -88,30 +199,7 @@ export function registerCommands(
88
199
  }
89
200
 
90
201
  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
- );
97
- return;
98
- }
99
-
100
- const options = accounts.map(
101
- (account) =>
102
- account.email +
103
- (account.quotaExhaustedUntil &&
104
- account.quotaExhaustedUntil > Date.now()
105
- ? " (Quota)"
106
- : ""),
107
- );
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);
202
+ await openAccountSelectionFlow(ctx, accountManager, statusController);
115
203
  },
116
204
  });
117
205
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.10",
3
+ "version": "1.1.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/status.ts CHANGED
@@ -24,6 +24,7 @@ const REFRESH_INTERVAL_MS = 60_000;
24
24
  const MODEL_SELECT_REFRESH_DEBOUNCE_MS = 250;
25
25
  const UNKNOWN_PERCENT = "--";
26
26
  const BRAND_LABEL = "Codex";
27
+ const SEGMENT_SEPARATOR = "·";
27
28
  const FIVE_HOUR_LABEL = "5h:";
28
29
  const SEVEN_DAY_LABEL = "7d:";
29
30
 
@@ -148,25 +149,40 @@ function formatLoading(ctx: ExtensionContext): string {
148
149
  return ctx.ui.theme.fg("muted", "loading...");
149
150
  }
150
151
 
151
- function formatPercent(
152
- ctx: ExtensionContext,
152
+ function formatSeparator(ctx: ExtensionContext): string {
153
+ return ctx.ui.theme.fg("muted", SEGMENT_SEPARATOR);
154
+ }
155
+
156
+ function getUsageSeverityToken(
153
157
  displayPercent: number | undefined,
154
158
  mode: PercentDisplayMode,
155
- ): string {
159
+ ): "success" | "thinkingMedium" | "warning" | "error" | "dim" {
156
160
  if (typeof displayPercent !== "number" || Number.isNaN(displayPercent)) {
157
- return ctx.ui.theme.fg("dim", UNKNOWN_PERCENT);
161
+ return "dim";
158
162
  }
159
163
 
160
- const text = `${Math.round(clampPercent(displayPercent))}% ${mode}`;
161
164
  if (mode === "left") {
162
- if (displayPercent <= 10) return ctx.ui.theme.fg("error", text);
163
- if (displayPercent <= 25) return ctx.ui.theme.fg("warning", text);
164
- return ctx.ui.theme.fg("success", text);
165
+ if (displayPercent <= 10) return "error";
166
+ if (displayPercent <= 25) return "warning";
167
+ if (displayPercent <= 50) return "thinkingMedium";
168
+ return "success";
165
169
  }
166
170
 
167
- if (displayPercent >= 90) return ctx.ui.theme.fg("error", text);
168
- if (displayPercent >= 75) return ctx.ui.theme.fg("warning", text);
169
- return ctx.ui.theme.fg("success", text);
171
+ if (displayPercent >= 90) return "error";
172
+ if (displayPercent >= 75) return "warning";
173
+ if (displayPercent >= 50) return "thinkingMedium";
174
+ return "success";
175
+ }
176
+
177
+ function formatPercent(
178
+ displayPercent: number | undefined,
179
+ mode: PercentDisplayMode,
180
+ ): string {
181
+ if (typeof displayPercent !== "number" || Number.isNaN(displayPercent)) {
182
+ return UNKNOWN_PERCENT;
183
+ }
184
+
185
+ return `${Math.round(clampPercent(displayPercent))}% ${mode}`;
170
186
  }
171
187
 
172
188
  function formatResetCountdown(resetAt: number | undefined): string | undefined {
@@ -200,20 +216,23 @@ function formatUsageSegment(
200
216
  showReset: boolean,
201
217
  preferences: FooterPreferences,
202
218
  ): string {
219
+ const displayPercent = usedToDisplayPercent(
220
+ usedPercent,
221
+ preferences.usageMode,
222
+ );
203
223
  const parts = [
204
- `${ctx.ui.theme.fg("dim", label)}${formatPercent(
205
- ctx,
206
- usedToDisplayPercent(usedPercent, preferences.usageMode),
207
- preferences.usageMode,
208
- )}`,
224
+ `${label}${formatPercent(displayPercent, preferences.usageMode)}`,
209
225
  ];
210
226
  if (showReset) {
211
227
  const countdown = formatResetCountdown(resetAt);
212
228
  if (countdown) {
213
- parts.push(ctx.ui.theme.fg("muted", `(↺${countdown})`));
229
+ parts.push(`(↺${countdown})`);
214
230
  }
215
231
  }
216
- return parts.join(" ");
232
+ return ctx.ui.theme.fg(
233
+ getUsageSeverityToken(displayPercent, preferences.usageMode),
234
+ parts.join(" "),
235
+ );
217
236
  }
218
237
 
219
238
  export function isManagedModel(model: MaybeModel): boolean {
@@ -227,7 +246,7 @@ export function formatActiveAccountStatus(
227
246
  preferences: FooterPreferences,
228
247
  ): string {
229
248
  const accountText = preferences.showAccount
230
- ? ctx.ui.theme.fg("muted", accountEmail)
249
+ ? ctx.ui.theme.fg("text", accountEmail)
231
250
  : undefined;
232
251
  if (!usage) {
233
252
  return [formatBrand(ctx), accountText, formatLoading(ctx)]
@@ -252,16 +271,18 @@ export function formatActiveAccountStatus(
252
271
  preferences,
253
272
  );
254
273
 
274
+ const usageSegments = [fiveHour, sevenDay].filter(Boolean);
275
+ const usageText = usageSegments.join(` ${formatSeparator(ctx)} `);
255
276
  const leading =
256
277
  preferences.order === "account-first"
257
- ? [formatBrand(ctx), accountText]
258
- : [formatBrand(ctx)];
278
+ ? [formatBrand(ctx), accountText, usageText]
279
+ : [formatBrand(ctx), usageText];
259
280
  const trailing =
260
281
  preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
261
282
 
262
- return [...leading, fiveHour, sevenDay, ...trailing]
283
+ return [...leading, ...trailing]
263
284
  .filter(Boolean)
264
- .join(" ");
285
+ .join(` ${formatSeparator(ctx)} `);
265
286
  }
266
287
 
267
288
  function getBooleanLabel(value: boolean): string {