@victor-software-house/pi-multicodex 2.0.7 → 2.0.9

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
@@ -78,6 +78,7 @@ You can customize which fields appear and their ordering with `/multicodex foote
78
78
  - **Token refresh.** OAuth tokens are refreshed before expiry so requests do not fail due to stale credentials.
79
79
  - **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
80
  - **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
+ - **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.
81
82
 
82
83
  ## Local development
83
84
 
package/abort-utils.ts CHANGED
@@ -1,24 +1,9 @@
1
- export function createLinkedAbortController(
2
- signal?: AbortSignal,
3
- ): AbortController {
4
- const controller = new AbortController();
5
- if (signal?.aborted) {
6
- controller.abort();
7
- return controller;
8
- }
9
-
10
- signal?.addEventListener("abort", () => controller.abort(), { once: true });
11
- return controller;
12
- }
13
-
14
- export function createTimeoutController(
15
- signal: AbortSignal | undefined,
16
- timeoutMs: number,
17
- ): { controller: AbortController; clear: () => void } {
18
- const controller = createLinkedAbortController(signal);
19
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
20
- return {
21
- controller,
22
- clear: () => clearTimeout(timeout),
23
- };
24
- }
1
+ /**
2
+ * Re-export abort controller helpers from the shared package.
3
+ *
4
+ * Existing imports within this package continue to work unchanged.
5
+ */
6
+ export {
7
+ createLinkedAbortController,
8
+ createTimeoutController,
9
+ } from "pi-provider-utils/streams";
@@ -2,6 +2,8 @@ import {
2
2
  type OAuthCredentials,
3
3
  refreshOpenAICodexToken,
4
4
  } from "@mariozechner/pi-ai/oauth";
5
+ import { AuthStorage } from "@mariozechner/pi-coding-agent";
6
+ import { normalizeUnknownError } from "pi-provider-utils/streams";
5
7
  import { loadImportedOpenAICodexAuth } from "./auth";
6
8
  import { isAccountAvailable, pickBestAccount } from "./selection";
7
9
  import {
@@ -20,14 +22,10 @@ const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
20
22
  type WarningHandler = (message: string) => void;
21
23
  type StateChangeHandler = () => void;
22
24
 
23
- function getErrorMessage(error: unknown): string {
24
- if (error instanceof Error) return error.message;
25
- return typeof error === "string" ? error : JSON.stringify(error);
26
- }
27
-
28
25
  export class AccountManager {
29
26
  private data: StorageData;
30
27
  private usageCache = new Map<string, CodexUsageSnapshot>();
28
+ private refreshPromises = new Map<string, Promise<string>>();
31
29
  private warningHandler?: WarningHandler;
32
30
  private manualEmail?: string;
33
31
  private stateChangeHandlers = new Set<StateChangeHandler>();
@@ -258,7 +256,7 @@ export class AccountManager {
258
256
  return usage;
259
257
  } catch (error) {
260
258
  this.warningHandler?.(
261
- `Multicodex: failed to fetch usage for ${account.email}: ${getErrorMessage(
259
+ `Multicodex: failed to fetch usage for ${account.email}: ${normalizeUnknownError(
262
260
  error,
263
261
  )}`,
264
262
  );
@@ -346,17 +344,98 @@ export class AccountManager {
346
344
  return account.accessToken;
347
345
  }
348
346
 
349
- const result = await refreshOpenAICodexToken(account.refreshToken);
350
- account.accessToken = result.access;
351
- account.refreshToken = result.refresh;
352
- account.expiresAt = result.expires;
353
- const accountId =
354
- typeof result.accountId === "string" ? result.accountId : undefined;
355
- if (accountId) {
356
- account.accountId = accountId;
347
+ // For the imported pi account, delegate to AuthStorage so we share pi's
348
+ // file lock and never race with pi's own refresh path.
349
+ if (account.importSource === "pi-openai-codex") {
350
+ return this.ensureValidTokenForImportedAccount(account);
357
351
  }
358
- this.save();
359
- this.notifyStateChanged();
360
- return account.accessToken;
352
+
353
+ const inflight = this.refreshPromises.get(account.email);
354
+ if (inflight) {
355
+ return inflight;
356
+ }
357
+
358
+ const promise = (async () => {
359
+ try {
360
+ const result = await refreshOpenAICodexToken(account.refreshToken);
361
+ account.accessToken = result.access;
362
+ account.refreshToken = result.refresh;
363
+ account.expiresAt = result.expires;
364
+ const accountId =
365
+ typeof result.accountId === "string" ? result.accountId : undefined;
366
+ if (accountId) {
367
+ account.accountId = accountId;
368
+ }
369
+ this.save();
370
+ this.notifyStateChanged();
371
+ return account.accessToken;
372
+ } finally {
373
+ this.refreshPromises.delete(account.email);
374
+ }
375
+ })();
376
+
377
+ this.refreshPromises.set(account.email, promise);
378
+ return promise;
379
+ }
380
+
381
+ /**
382
+ * Refresh path for the imported pi account.
383
+ *
384
+ * Uses AuthStorage so our refresh is serialised by the same file lock that
385
+ * pi's own credential refresh uses. This prevents "refresh_token_reused"
386
+ * errors caused by pi and multicodex both refreshing the same token
387
+ * simultaneously.
388
+ */
389
+ private async ensureValidTokenForImportedAccount(
390
+ account: Account,
391
+ ): Promise<string> {
392
+ // Check if pi already refreshed since our last sync.
393
+ const latest = await loadImportedOpenAICodexAuth();
394
+ if (latest && Date.now() < latest.credentials.expires - 5 * 60 * 1000) {
395
+ account.accessToken = latest.credentials.access;
396
+ account.refreshToken = latest.credentials.refresh;
397
+ account.expiresAt = latest.credentials.expires;
398
+ account.importFingerprint = latest.fingerprint;
399
+ const accountId =
400
+ typeof latest.credentials.accountId === "string"
401
+ ? latest.credentials.accountId
402
+ : undefined;
403
+ if (accountId) {
404
+ account.accountId = accountId;
405
+ }
406
+ this.save();
407
+ this.notifyStateChanged();
408
+ return account.accessToken;
409
+ }
410
+
411
+ // Both our copy and auth.json are expired — let AuthStorage refresh with
412
+ // its file lock so only one caller (us or pi) fires the API call.
413
+ const authStorage = AuthStorage.create();
414
+ const apiKey = await authStorage.getApiKey("openai-codex");
415
+ if (!apiKey) {
416
+ throw new Error(
417
+ "OpenAI Codex: token refresh failed — please re-authenticate with /login",
418
+ );
419
+ }
420
+
421
+ // Read the refreshed tokens back from auth.json.
422
+ const refreshed = await loadImportedOpenAICodexAuth();
423
+ if (refreshed) {
424
+ account.accessToken = refreshed.credentials.access;
425
+ account.refreshToken = refreshed.credentials.refresh;
426
+ account.expiresAt = refreshed.credentials.expires;
427
+ account.importFingerprint = refreshed.fingerprint;
428
+ const accountId =
429
+ typeof refreshed.credentials.accountId === "string"
430
+ ? refreshed.credentials.accountId
431
+ : undefined;
432
+ if (accountId) {
433
+ account.accountId = accountId;
434
+ }
435
+ this.save();
436
+ this.notifyStateChanged();
437
+ }
438
+
439
+ return apiKey;
361
440
  }
362
441
  }
package/auth.ts CHANGED
@@ -1,9 +1,8 @@
1
1
  import { promises as fs } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
2
  import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
3
+ import { getAgentAuthPath } from "pi-provider-utils/agent-paths";
5
4
 
6
- const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
5
+ const AUTH_FILE = getAgentAuthPath();
7
6
  const IMPORTED_ACCOUNT_PREFIX = "OpenAI Codex";
8
7
 
9
8
  interface AuthEntry {
package/commands.ts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { promises as fs, constants as fsConstants } from "node:fs";
2
- import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth";
5
4
  import type {
@@ -15,13 +14,15 @@ import {
15
14
  SelectList,
16
15
  Text,
17
16
  } from "@mariozechner/pi-tui";
17
+ import { getAgentSettingsPath } from "pi-provider-utils/agent-paths";
18
+ import { normalizeUnknownError } from "pi-provider-utils/streams";
18
19
  import type { AccountManager } from "./account-manager";
19
20
  import { openLoginInBrowser } from "./browser";
20
21
  import type { createUsageStatusController } from "./status";
21
22
  import { STORAGE_FILE } from "./storage";
22
23
  import { formatResetAt, isUsageUntouched } from "./usage";
23
24
 
24
- const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
25
+ const SETTINGS_FILE = getAgentSettingsPath();
25
26
  const NO_ACCOUNTS_MESSAGE =
26
27
  "No managed accounts found. Use /multicodex use <identifier> first.";
27
28
  const HELP_TEXT =
@@ -46,11 +47,6 @@ type AccountPanelResult =
46
47
  | { action: "remove"; email: string }
47
48
  | undefined;
48
49
 
49
- function getErrorMessage(error: unknown): string {
50
- if (error instanceof Error) return error.message;
51
- return typeof error === "string" ? error : JSON.stringify(error);
52
- }
53
-
54
50
  function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
55
51
  return values.map((value) => ({ value, label: value }));
56
52
  }
@@ -202,7 +198,7 @@ async function loginAndActivateAccount(
202
198
  ctx.ui.notify(`Now using ${identifier}`, "info");
203
199
  return true;
204
200
  } catch (error) {
205
- ctx.ui.notify(`Login failed: ${getErrorMessage(error)}`, "error");
201
+ ctx.ui.notify(`Login failed: ${normalizeUnknownError(error)}`, "error");
206
202
  return false;
207
203
  }
208
204
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -78,9 +78,9 @@
78
78
  "@biomejs/biome": "^2.4.7",
79
79
  "@commitlint/cli": "^20.4.4",
80
80
  "@commitlint/config-conventional": "^20.4.4",
81
- "@mariozechner/pi-ai": "^0.58.1",
82
- "@mariozechner/pi-coding-agent": "^0.58.1",
83
- "@mariozechner/pi-tui": "^0.58.1",
81
+ "@mariozechner/pi-ai": "^0.63.1",
82
+ "@mariozechner/pi-coding-agent": "^0.63.1",
83
+ "@mariozechner/pi-tui": "^0.63.1",
84
84
  "@semantic-release/changelog": "^6.0.3",
85
85
  "@semantic-release/commit-analyzer": "^13.0.1",
86
86
  "@semantic-release/git": "^10.0.1",
@@ -89,11 +89,15 @@
89
89
  "@semantic-release/release-notes-generator": "^14.1.0",
90
90
  "@types/node": "^25.5.0",
91
91
  "@typescript/native-preview": "7.0.0-dev.20260314.1",
92
+ "conventional-changelog-conventionalcommits": "^9.3.0",
92
93
  "semantic-release": "^25.0.3",
93
94
  "typescript": "^5.9.3",
94
95
  "vitest": "^4.1.0"
95
96
  },
96
97
  "engines": {
97
98
  "node": "24.14.0"
99
+ },
100
+ "dependencies": {
101
+ "pi-provider-utils": "^0.0.0"
98
102
  }
99
103
  }
package/provider.ts CHANGED
@@ -1,12 +1,5 @@
1
- import {
2
- type Api,
3
- type AssistantMessageEventStream,
4
- type Context,
5
- getApiProvider,
6
- getModels,
7
- type Model,
8
- type SimpleStreamOptions,
9
- } from "@mariozechner/pi-ai";
1
+ import { getApiProvider } from "@mariozechner/pi-ai";
2
+ import { mirrorProvider } from "pi-provider-utils/providers";
10
3
  import type { AccountManager } from "./account-manager";
11
4
  import { createStreamWrapper } from "./stream-wrapper";
12
5
 
@@ -31,32 +24,25 @@ export function getOpenAICodexMirror(): {
31
24
  baseUrl: string;
32
25
  models: ProviderModelDef[];
33
26
  } {
34
- const sourceModels = getModels("openai-codex");
27
+ const mirror = mirrorProvider("openai-codex");
28
+ if (!mirror) {
29
+ return { baseUrl: "https://chatgpt.com/backend-api", models: [] };
30
+ }
35
31
  return {
36
- baseUrl: sourceModels[0]?.baseUrl || "https://chatgpt.com/backend-api",
37
- models: sourceModels.map((model) => ({
38
- id: model.id,
39
- name: model.name,
40
- reasoning: model.reasoning,
41
- input: model.input,
42
- cost: model.cost,
43
- contextWindow: model.contextWindow,
44
- maxTokens: model.maxTokens,
32
+ baseUrl: mirror.baseUrl,
33
+ models: mirror.models.map((m) => ({
34
+ id: m.id,
35
+ name: m.name,
36
+ reasoning: m.reasoning,
37
+ input: [...m.input],
38
+ cost: { ...m.cost },
39
+ contextWindow: m.contextWindow,
40
+ maxTokens: m.maxTokens,
45
41
  })),
46
42
  };
47
43
  }
48
44
 
49
- export function buildMulticodexProviderConfig(accountManager: AccountManager): {
50
- baseUrl: string;
51
- apiKey: string;
52
- api: "openai-codex-responses";
53
- streamSimple: (
54
- model: Model<Api>,
55
- context: Context,
56
- options?: SimpleStreamOptions,
57
- ) => AssistantMessageEventStream;
58
- models: ProviderModelDef[];
59
- } {
45
+ export function buildMulticodexProviderConfig(accountManager: AccountManager) {
60
46
  const mirror = getOpenAICodexMirror();
61
47
  const baseProvider = getApiProvider("openai-codex-responses");
62
48
  if (!baseProvider) {
@@ -68,7 +54,7 @@ export function buildMulticodexProviderConfig(accountManager: AccountManager): {
68
54
  return {
69
55
  baseUrl: mirror.baseUrl,
70
56
  apiKey: "managed-by-extension",
71
- api: "openai-codex-responses",
57
+ api: "openai-codex-responses" as const,
72
58
  streamSimple: createStreamWrapper(accountManager, baseProvider),
73
59
  models: mirror.models,
74
60
  };
package/status.ts CHANGED
@@ -1,6 +1,3 @@
1
- import { promises as fs } from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
1
  import type { Api, Model } from "@mariozechner/pi-ai";
5
2
  import type {
6
3
  ExtensionCommandContext,
@@ -13,13 +10,18 @@ import {
13
10
  SettingsList,
14
11
  Text,
15
12
  } from "@mariozechner/pi-tui";
13
+ import {
14
+ getAgentSettingsPath,
15
+ readJsonObjectFileAsync,
16
+ writeJsonObjectFileAsync,
17
+ } from "pi-provider-utils/agent-paths";
16
18
  import type { AccountManager } from "./account-manager";
17
19
  import { PROVIDER_ID } from "./provider";
18
20
  import type { CodexUsageSnapshot } from "./usage";
19
21
 
20
22
  const STATUS_KEY = "multicodex-usage";
21
23
  const SETTINGS_KEY = "pi-multicodex";
22
- const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
24
+ const SETTINGS_FILE = getAgentSettingsPath();
23
25
  const REFRESH_INTERVAL_MS = 60_000;
24
26
  const MODEL_SELECT_REFRESH_DEBOUNCE_MS = 250;
25
27
  const UNKNOWN_PERCENT = "--";
@@ -90,26 +92,13 @@ function normalizePreferences(value: unknown): FooterPreferences {
90
92
  }
91
93
 
92
94
  async function readSettingsFile(): Promise<Record<string, unknown>> {
93
- try {
94
- const raw = await fs.readFile(SETTINGS_FILE, "utf8");
95
- const parsed = JSON.parse(raw) as unknown;
96
- return asObject(parsed) ?? {};
97
- } catch (error) {
98
- const withCode = error as Error & { code?: string };
99
- if (withCode.code === "ENOENT") return {};
100
- throw error;
101
- }
95
+ return readJsonObjectFileAsync(SETTINGS_FILE);
102
96
  }
103
97
 
104
98
  async function writeSettingsFile(
105
99
  settings: Record<string, unknown>,
106
100
  ): Promise<void> {
107
- await fs.mkdir(path.dirname(SETTINGS_FILE), { recursive: true });
108
- await fs.writeFile(
109
- SETTINGS_FILE,
110
- `${JSON.stringify(settings, null, 2)}\n`,
111
- "utf8",
112
- );
101
+ await writeJsonObjectFileAsync(SETTINGS_FILE, settings);
113
102
  }
114
103
 
115
104
  export async function loadFooterPreferences(): Promise<FooterPreferences> {
package/storage.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
- import * as os from "node:os";
3
2
  import * as path from "node:path";
3
+ import { getAgentPath } from "pi-provider-utils/agent-paths";
4
4
 
5
5
  export interface Account {
6
6
  email: string;
@@ -19,12 +19,7 @@ export interface StorageData {
19
19
  activeEmail?: string;
20
20
  }
21
21
 
22
- export const STORAGE_FILE = path.join(
23
- os.homedir(),
24
- ".pi",
25
- "agent",
26
- "codex-accounts.json",
27
- );
22
+ export const STORAGE_FILE = getAgentPath("codex-accounts.json");
28
23
 
29
24
  export function loadStorage(): StorageData {
30
25
  try {
package/stream-wrapper.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  type Api,
3
- type AssistantMessage,
4
3
  type AssistantMessageEvent,
5
4
  type AssistantMessageEventStream,
6
5
  type Context,
@@ -8,7 +7,12 @@ import {
8
7
  type Model,
9
8
  type SimpleStreamOptions,
10
9
  } from "@mariozechner/pi-ai";
11
- import { createLinkedAbortController } from "./abort-utils";
10
+ import {
11
+ createErrorAssistantMessage,
12
+ createLinkedAbortController,
13
+ normalizeUnknownError,
14
+ rewriteProviderOnEvent,
15
+ } from "pi-provider-utils/streams";
12
16
  import type { AccountManager } from "./account-manager";
13
17
  import { isQuotaErrorMessage } from "./quota";
14
18
 
@@ -22,51 +26,6 @@ type ApiProviderRef = {
22
26
  ) => AssistantMessageEventStream;
23
27
  };
24
28
 
25
- function withProvider(
26
- event: AssistantMessageEvent,
27
- provider: string,
28
- ): AssistantMessageEvent {
29
- if ("partial" in event) {
30
- return { ...event, partial: { ...event.partial, provider } };
31
- }
32
- if (event.type === "done") {
33
- return { ...event, message: { ...event.message, provider } };
34
- }
35
- if (event.type === "error") {
36
- return { ...event, error: { ...event.error, provider } };
37
- }
38
- return event;
39
- }
40
-
41
- function createErrorAssistantMessage(
42
- model: Model<Api>,
43
- message: string,
44
- ): AssistantMessage {
45
- return {
46
- role: "assistant",
47
- content: [],
48
- api: model.api,
49
- provider: model.provider,
50
- model: model.id,
51
- usage: {
52
- input: 0,
53
- output: 0,
54
- cacheRead: 0,
55
- cacheWrite: 0,
56
- totalTokens: 0,
57
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
58
- },
59
- stopReason: "error",
60
- errorMessage: message,
61
- timestamp: Date.now(),
62
- };
63
- }
64
-
65
- function getErrorMessage(error: unknown): string {
66
- if (error instanceof Error) return error.message;
67
- return typeof error === "string" ? error : JSON.stringify(error);
68
- }
69
-
70
29
  export function createStreamWrapper(
71
30
  accountManager: AccountManager,
72
31
  baseProvider: ApiProviderRef,
@@ -151,13 +110,13 @@ export function createStreamWrapper(
151
110
  break;
152
111
  }
153
112
 
154
- stream.push(withProvider(event, model.provider));
113
+ stream.push(rewriteProviderOnEvent(event, model.provider));
155
114
  stream.end();
156
115
  return;
157
116
  }
158
117
 
159
118
  forwardedAny = true;
160
- stream.push(withProvider(event, model.provider));
119
+ stream.push(rewriteProviderOnEvent(event, model.provider));
161
120
 
162
121
  if (event.type === "done") {
163
122
  stream.end();
@@ -173,7 +132,7 @@ export function createStreamWrapper(
173
132
  return;
174
133
  }
175
134
  } catch (error) {
176
- const message = getErrorMessage(error);
135
+ const message = normalizeUnknownError(error);
177
136
  const errorEvent: AssistantMessageEvent = {
178
137
  type: "error",
179
138
  reason: "error",
@@ -182,7 +141,7 @@ export function createStreamWrapper(
182
141
  `Multicodex failed: ${message}`,
183
142
  ),
184
143
  };
185
- stream.push(withProvider(errorEvent, model.provider));
144
+ stream.push(rewriteProviderOnEvent(errorEvent, model.provider));
186
145
  stream.end();
187
146
  }
188
147
  })();