@victor-software-house/pi-multicodex 1.0.7 → 1.0.8

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
@@ -6,6 +6,8 @@
6
6
 
7
7
  ## What it does
8
8
 
9
+ - overrides the normal `openai-codex` path instead of requiring a separate provider to be selected
10
+ - auto-imports pi's stored `openai-codex` auth when it is new or changed
9
11
  - rotates accounts on quota and rate-limit failures
10
12
  - prefers untouched accounts when usage data is available
11
13
  - otherwise prefers the account whose weekly window resets first
@@ -45,12 +47,11 @@ pi -e ./index.ts
45
47
 
46
48
  ## Commands
47
49
 
48
- - `/multicodex-login <email>`
49
- - Add or update a Codex account in the rotation pool.
50
- - `/multicodex-use`
51
- - Select an account manually for the current session.
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.
52
53
  - `/multicodex-status`
53
- - Show account state and cached usage information.
54
+ - Show managed account state and cached usage information.
54
55
  - `/multicodex-footer`
55
56
  - Open an interactive panel to configure footer fields and ordering.
56
57
 
@@ -68,12 +69,12 @@ Current direction:
68
69
 
69
70
  Current next step:
70
71
 
71
- - add active-account usage visibility in pi for this extension's managed Codex accounts
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
72
74
  - mirror the existing codex usage footer style, including support for displaying both reset countdowns
73
- - show footer usage only when the selected model uses the `multicodex` provider override
74
75
  - show the active account identifier beside the 5h and 7d usage metrics
75
- - configure footer fields and ordering through an interactive panel
76
- - refresh the footer from the active managed account without polling aggressively
76
+ - keep footer configuration in an interactive panel
77
+ - tighten footer updates so account switches and quota rotation are reflected immediately
77
78
 
78
79
  ## Release validation
79
80
 
@@ -91,6 +92,13 @@ Release flow:
91
92
  3. Create and push a matching `v*` tag.
92
93
  4. Let GitHub Actions publish through trusted publishing.
93
94
 
95
+ Local push protection:
96
+
97
+ - `lefthook` runs `mise run pre-push`
98
+ - the `pre-push` mise task runs the same core validations as the publish workflow:
99
+ - `pnpm check`
100
+ - `npm pack --dry-run`
101
+
94
102
  Prepare locally:
95
103
 
96
104
  ```bash
@@ -2,6 +2,7 @@ import {
2
2
  type OAuthCredentials,
3
3
  refreshOpenAICodexToken,
4
4
  } from "@mariozechner/pi-ai/oauth";
5
+ import { loadImportedOpenAICodexAuth } from "./auth";
5
6
  import { isAccountAvailable, pickBestAccount } from "./selection";
6
7
  import {
7
8
  type Account,
@@ -49,7 +50,14 @@ export class AccountManager {
49
50
  this.warningHandler = handler;
50
51
  }
51
52
 
52
- addOrUpdateAccount(email: string, creds: OAuthCredentials): void {
53
+ addOrUpdateAccount(
54
+ email: string,
55
+ creds: OAuthCredentials,
56
+ options?: {
57
+ importSource?: "pi-openai-codex";
58
+ importFingerprint?: string;
59
+ },
60
+ ): void {
53
61
  const existing = this.getAccount(email);
54
62
  const accountId =
55
63
  typeof creds.accountId === "string" ? creds.accountId : undefined;
@@ -57,6 +65,8 @@ export class AccountManager {
57
65
  existing.accessToken = creds.access;
58
66
  existing.refreshToken = creds.refresh;
59
67
  existing.expiresAt = creds.expires;
68
+ existing.importSource = options?.importSource;
69
+ existing.importFingerprint = options?.importFingerprint;
60
70
  if (accountId) {
61
71
  existing.accountId = accountId;
62
72
  }
@@ -67,6 +77,8 @@ export class AccountManager {
67
77
  refreshToken: creds.refresh,
68
78
  expiresAt: creds.expires,
69
79
  accountId,
80
+ importSource: options?.importSource,
81
+ importFingerprint: options?.importFingerprint,
70
82
  });
71
83
  }
72
84
  this.setActiveAccount(email);
@@ -112,6 +124,38 @@ export class AccountManager {
112
124
  this.manualEmail = undefined;
113
125
  }
114
126
 
127
+ getImportedAccount(): Account | undefined {
128
+ return this.data.accounts.find(
129
+ (account) => account.importSource === "pi-openai-codex",
130
+ );
131
+ }
132
+
133
+ async syncImportedOpenAICodexAuth(): Promise<boolean> {
134
+ const imported = await loadImportedOpenAICodexAuth();
135
+ if (!imported) return false;
136
+
137
+ const existingImported = this.getImportedAccount();
138
+ if (
139
+ existingImported?.importFingerprint === imported.fingerprint &&
140
+ existingImported.email === imported.identifier
141
+ ) {
142
+ return false;
143
+ }
144
+
145
+ if (existingImported && existingImported.email !== imported.identifier) {
146
+ const target = this.getAccount(imported.identifier);
147
+ if (!target) {
148
+ existingImported.email = imported.identifier;
149
+ }
150
+ }
151
+
152
+ this.addOrUpdateAccount(imported.identifier, imported.credentials, {
153
+ importSource: "pi-openai-codex",
154
+ importFingerprint: imported.fingerprint,
155
+ });
156
+ return true;
157
+ }
158
+
115
159
  getAvailableManualAccount(options?: {
116
160
  excludeEmails?: Set<string>;
117
161
  now?: number;
package/auth.ts ADDED
@@ -0,0 +1,106 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { OAuthCredentials } from "@mariozechner/pi-ai/oauth";
5
+
6
+ const AUTH_FILE = path.join(os.homedir(), ".pi", "agent", "auth.json");
7
+ const IMPORTED_ACCOUNT_PREFIX = "OpenAI Codex";
8
+
9
+ interface AuthEntry {
10
+ type?: string;
11
+ access?: string | null;
12
+ refresh?: string | null;
13
+ expires?: number | null;
14
+ accountId?: string | null;
15
+ account_id?: string | null;
16
+ }
17
+
18
+ export interface ImportedOpenAICodexAuth {
19
+ identifier: string;
20
+ fingerprint: string;
21
+ credentials: OAuthCredentials;
22
+ }
23
+
24
+ function asAuthEntry(value: unknown): AuthEntry | undefined {
25
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
26
+ return undefined;
27
+ }
28
+ return value as AuthEntry;
29
+ }
30
+
31
+ function getAccountId(entry: AuthEntry): string | undefined {
32
+ const accountId = entry.accountId ?? entry.account_id;
33
+ return typeof accountId === "string" && accountId.trim()
34
+ ? accountId.trim()
35
+ : undefined;
36
+ }
37
+
38
+ function getRequiredString(
39
+ value: string | null | undefined,
40
+ ): string | undefined {
41
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
42
+ }
43
+
44
+ function createImportedIdentifier(accountId: string): string {
45
+ return `${IMPORTED_ACCOUNT_PREFIX} ${accountId.slice(0, 8)}`;
46
+ }
47
+
48
+ function createFingerprint(entry: {
49
+ access: string;
50
+ refresh: string;
51
+ expires: number;
52
+ accountId?: string;
53
+ }): string {
54
+ return JSON.stringify({
55
+ access: entry.access,
56
+ refresh: entry.refresh,
57
+ expires: entry.expires,
58
+ accountId: entry.accountId ?? null,
59
+ });
60
+ }
61
+
62
+ export function parseImportedOpenAICodexAuth(
63
+ auth: Record<string, unknown>,
64
+ ): ImportedOpenAICodexAuth | undefined {
65
+ const entry = asAuthEntry(auth["openai-codex"]);
66
+ if (entry?.type !== "oauth") return undefined;
67
+
68
+ const access = getRequiredString(entry.access);
69
+ const refresh = getRequiredString(entry.refresh);
70
+ const accountId = getAccountId(entry);
71
+ const expires = entry.expires;
72
+ if (!access || !refresh || typeof expires !== "number") {
73
+ return undefined;
74
+ }
75
+
76
+ const credentials: OAuthCredentials = {
77
+ access,
78
+ refresh,
79
+ expires,
80
+ accountId,
81
+ };
82
+ return {
83
+ identifier: createImportedIdentifier(accountId ?? "default"),
84
+ fingerprint: createFingerprint({ access, refresh, expires, accountId }),
85
+ credentials,
86
+ };
87
+ }
88
+
89
+ export async function loadImportedOpenAICodexAuth(): Promise<
90
+ ImportedOpenAICodexAuth | undefined
91
+ > {
92
+ try {
93
+ const raw = await fs.readFile(AUTH_FILE, "utf8");
94
+ const parsed = JSON.parse(raw) as unknown;
95
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
96
+ return undefined;
97
+ }
98
+ return parseImportedOpenAICodexAuth(parsed as Record<string, unknown>);
99
+ } catch (error) {
100
+ const withCode = error as Error & { code?: string };
101
+ if (withCode.code === "ENOENT") {
102
+ return undefined;
103
+ }
104
+ throw error;
105
+ }
106
+ }
package/commands.ts CHANGED
@@ -13,59 +13,85 @@ function getErrorMessage(error: unknown): string {
13
13
  return typeof error === "string" ? error : JSON.stringify(error);
14
14
  }
15
15
 
16
+ async function loginAndActivateAccount(
17
+ pi: ExtensionAPI,
18
+ ctx: ExtensionCommandContext,
19
+ accountManager: AccountManager,
20
+ identifier: string,
21
+ ): Promise<boolean> {
22
+ try {
23
+ ctx.ui.notify(
24
+ `Starting login for ${identifier}... Check your browser.`,
25
+ "info",
26
+ );
27
+
28
+ const creds = await loginOpenAICodex({
29
+ onAuth: ({ url }) => {
30
+ void openLoginInBrowser(pi, ctx, url);
31
+ ctx.ui.notify(`Please open this URL to login: ${url}`, "info");
32
+ console.log(`[multicodex] Login URL: ${url}`);
33
+ },
34
+ onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
35
+ });
36
+
37
+ accountManager.addOrUpdateAccount(identifier, creds);
38
+ accountManager.setManualAccount(identifier);
39
+ ctx.ui.notify(`Now using ${identifier}`, "info");
40
+ return true;
41
+ } catch (error) {
42
+ ctx.ui.notify(`Login failed: ${getErrorMessage(error)}`, "error");
43
+ return false;
44
+ }
45
+ }
46
+
47
+ async function useOrLoginAccount(
48
+ pi: ExtensionAPI,
49
+ ctx: ExtensionCommandContext,
50
+ accountManager: AccountManager,
51
+ identifier: string,
52
+ ): Promise<void> {
53
+ const existing = accountManager.getAccount(identifier);
54
+ if (existing) {
55
+ try {
56
+ await accountManager.ensureValidToken(existing);
57
+ accountManager.setManualAccount(identifier);
58
+ ctx.ui.notify(`Now using ${identifier}`, "info");
59
+ return;
60
+ } catch {
61
+ ctx.ui.notify(
62
+ `Stored auth for ${identifier} is no longer valid. Starting login again.`,
63
+ "warning",
64
+ );
65
+ }
66
+ }
67
+
68
+ await loginAndActivateAccount(pi, ctx, accountManager, identifier);
69
+ }
70
+
16
71
  export function registerCommands(
17
72
  pi: ExtensionAPI,
18
73
  accountManager: AccountManager,
19
74
  statusController: ReturnType<typeof createUsageStatusController>,
20
75
  ): void {
21
- pi.registerCommand("multicodex-login", {
22
- description: "Login to an OpenAI Codex account for the rotation pool",
76
+ pi.registerCommand("multicodex-use", {
77
+ description:
78
+ "Use an existing Codex account, or log in when the identifier is missing",
23
79
  handler: async (
24
80
  args: string,
25
81
  ctx: ExtensionCommandContext,
26
82
  ): Promise<void> => {
27
- const email = args.trim();
28
- if (!email) {
29
- ctx.ui.notify(
30
- "Please provide an email/identifier: /multicodex-login my@email.com",
31
- "error",
32
- );
83
+ const identifier = args.trim();
84
+ if (identifier) {
85
+ await useOrLoginAccount(pi, ctx, accountManager, identifier);
86
+ await statusController.refreshFor(ctx);
33
87
  return;
34
88
  }
35
89
 
36
- try {
37
- ctx.ui.notify(
38
- `Starting login for ${email}... Check your browser.`,
39
- "info",
40
- );
41
-
42
- const creds = await loginOpenAICodex({
43
- onAuth: ({ url }) => {
44
- void openLoginInBrowser(pi, ctx, url);
45
- ctx.ui.notify(`Please open this URL to login: ${url}`, "info");
46
- console.log(`[multicodex] Login URL: ${url}`);
47
- },
48
- onPrompt: async ({ message }) => (await ctx.ui.input(message)) || "",
49
- });
50
-
51
- accountManager.addOrUpdateAccount(email, creds);
52
- ctx.ui.notify(`Successfully logged in as ${email}`, "info");
53
- } catch (error) {
54
- ctx.ui.notify(`Login failed: ${getErrorMessage(error)}`, "error");
55
- }
56
- },
57
- });
58
-
59
- pi.registerCommand("multicodex-use", {
60
- description: "Switch active Codex account for this session",
61
- handler: async (
62
- _args: string,
63
- ctx: ExtensionCommandContext,
64
- ): Promise<void> => {
90
+ await accountManager.syncImportedOpenAICodexAuth();
65
91
  const accounts = accountManager.getAccounts();
66
92
  if (accounts.length === 0) {
67
93
  ctx.ui.notify(
68
- "No accounts logged in. Use /multicodex-login first.",
94
+ "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
69
95
  "warning",
70
96
  );
71
97
  return;
@@ -82,9 +108,10 @@ export function registerCommands(
82
108
  const selected = await ctx.ui.select("Select Account", options);
83
109
  if (!selected) return;
84
110
 
85
- const email = selected.split(" ")[0];
111
+ const email = selected.split(" (")[0] ?? selected;
86
112
  accountManager.setManualAccount(email);
87
- ctx.ui.notify(`Switched to ${email}`, "info");
113
+ ctx.ui.notify(`Now using ${email}`, "info");
114
+ await statusController.refreshFor(ctx);
88
115
  },
89
116
  });
90
117
 
@@ -94,11 +121,12 @@ export function registerCommands(
94
121
  _args: string,
95
122
  ctx: ExtensionCommandContext,
96
123
  ): Promise<void> => {
124
+ await accountManager.syncImportedOpenAICodexAuth();
97
125
  await accountManager.refreshUsageForAllAccounts();
98
126
  const accounts = accountManager.getAccounts();
99
127
  if (accounts.length === 0) {
100
128
  ctx.ui.notify(
101
- "No accounts logged in. Use /multicodex-login first.",
129
+ "No managed accounts found. Use /login or /multicodex-use <identifier> first.",
102
130
  "warning",
103
131
  );
104
132
  return;
@@ -112,10 +140,12 @@ export function registerCommands(
112
140
  account.quotaExhaustedUntil &&
113
141
  account.quotaExhaustedUntil > Date.now();
114
142
  const untouched = isUsageUntouched(usage) ? "untouched" : null;
143
+ const imported = account.importSource ? "imported" : null;
115
144
  const tags = [
116
145
  isActive ? "active" : null,
117
146
  quotaHit ? "quota" : null,
118
147
  untouched,
148
+ imported,
119
149
  ]
120
150
  .filter(Boolean)
121
151
  .join(", ");
package/hooks.ts CHANGED
@@ -3,6 +3,7 @@ import type { AccountManager } from "./account-manager";
3
3
  async function refreshAndActivateBestAccount(
4
4
  accountManager: AccountManager,
5
5
  ): Promise<void> {
6
+ await accountManager.syncImportedOpenAICodexAuth();
6
7
  await accountManager.refreshUsageForAllAccounts({ force: true });
7
8
  const manual = accountManager.getAvailableManualAccount();
8
9
  if (manual) return;
package/index.ts CHANGED
@@ -1,4 +1,8 @@
1
1
  export { AccountManager } from "./account-manager";
2
+ export {
3
+ loadImportedOpenAICodexAuth,
4
+ parseImportedOpenAICodexAuth,
5
+ } from "./auth";
2
6
  export { default } from "./extension";
3
7
  export {
4
8
  buildMulticodexProviderConfig,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,7 @@
32
32
  "files": [
33
33
  "abort-utils.ts",
34
34
  "account-manager.ts",
35
+ "auth.ts",
35
36
  "browser.ts",
36
37
  "commands.ts",
37
38
  "extension.ts",
package/provider.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  import type { AccountManager } from "./account-manager";
11
11
  import { createStreamWrapper } from "./stream-wrapper";
12
12
 
13
- export const PROVIDER_ID = "multicodex";
13
+ export const PROVIDER_ID = "openai-codex";
14
14
 
15
15
  export interface ProviderModelDef {
16
16
  id: string;
package/status.ts CHANGED
@@ -335,7 +335,11 @@ export function createUsageStatusController(accountManager: AccountManager) {
335
335
  return;
336
336
  }
337
337
 
338
- const activeAccount = accountManager.getActiveAccount();
338
+ let activeAccount = accountManager.getActiveAccount();
339
+ if (!activeAccount) {
340
+ await accountManager.syncImportedOpenAICodexAuth();
341
+ activeAccount = accountManager.getActiveAccount();
342
+ }
339
343
  if (!activeAccount) {
340
344
  ctx.ui.setStatus(
341
345
  STATUS_KEY,
package/storage.ts CHANGED
@@ -10,6 +10,8 @@ export interface Account {
10
10
  accountId?: string;
11
11
  lastUsed?: number;
12
12
  quotaExhaustedUntil?: number;
13
+ importSource?: "pi-openai-codex";
14
+ importFingerprint?: string;
13
15
  }
14
16
 
15
17
  export interface StorageData {
package/stream-wrapper.ts CHANGED
@@ -80,6 +80,7 @@ export function createStreamWrapper(
80
80
 
81
81
  (async () => {
82
82
  try {
83
+ await accountManager.syncImportedOpenAICodexAuth();
83
84
  const excludedEmails = new Set<string>();
84
85
  for (let attempt = 0; attempt <= MAX_ROTATION_RETRIES; attempt++) {
85
86
  const now = Date.now();
@@ -100,7 +101,7 @@ export function createStreamWrapper(
100
101
  }
101
102
  if (!account) {
102
103
  throw new Error(
103
- "No available Multicodex accounts. Please use /multicodex-login.",
104
+ "No available Multicodex accounts. Please use /multicodex-use <identifier>.",
104
105
  );
105
106
  }
106
107