@victor-software-house/pi-multicodex 1.0.7 → 1.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 +17 -10
- package/account-manager.ts +68 -2
- package/auth.ts +106 -0
- package/commands.ts +71 -41
- package/extension.ts +1 -1
- package/hooks.ts +1 -0
- package/index.ts +4 -0
- package/package.json +2 -1
- package/provider.ts +1 -1
- package/status.ts +145 -37
- package/storage.ts +2 -0
- package/stream-wrapper.ts +2 -1
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-
|
|
49
|
-
-
|
|
50
|
-
-
|
|
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,11 @@ 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
72
|
- mirror the existing codex usage footer style, including support for displaying both reset countdowns
|
|
73
|
-
-
|
|
74
|
-
-
|
|
75
|
-
-
|
|
76
|
-
-
|
|
73
|
+
- debounce expensive refresh work during rapid model cycling
|
|
74
|
+
- move each reset countdown next to its matching usage period
|
|
75
|
+
- add live preview to the `/multicodex-footer` panel before locking the final style
|
|
76
|
+
- tighten footer updates so account switches and quota rotation are reflected immediately
|
|
77
77
|
|
|
78
78
|
## Release validation
|
|
79
79
|
|
|
@@ -91,6 +91,13 @@ Release flow:
|
|
|
91
91
|
3. Create and push a matching `v*` tag.
|
|
92
92
|
4. Let GitHub Actions publish through trusted publishing.
|
|
93
93
|
|
|
94
|
+
Local push protection:
|
|
95
|
+
|
|
96
|
+
- `lefthook` runs `mise run pre-push`
|
|
97
|
+
- the `pre-push` mise task runs the same core validations as the publish workflow:
|
|
98
|
+
- `pnpm check`
|
|
99
|
+
- `npm pack --dry-run`
|
|
100
|
+
|
|
94
101
|
Prepare locally:
|
|
95
102
|
|
|
96
103
|
```bash
|
package/account-manager.ts
CHANGED
|
@@ -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,
|
|
@@ -17,6 +18,7 @@ const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
|
17
18
|
const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
|
|
18
19
|
|
|
19
20
|
type WarningHandler = (message: string) => void;
|
|
21
|
+
type StateChangeHandler = () => void;
|
|
20
22
|
|
|
21
23
|
function getErrorMessage(error: unknown): string {
|
|
22
24
|
if (error instanceof Error) return error.message;
|
|
@@ -28,6 +30,7 @@ export class AccountManager {
|
|
|
28
30
|
private usageCache = new Map<string, CodexUsageSnapshot>();
|
|
29
31
|
private warningHandler?: WarningHandler;
|
|
30
32
|
private manualEmail?: string;
|
|
33
|
+
private stateChangeHandlers = new Set<StateChangeHandler>();
|
|
31
34
|
|
|
32
35
|
constructor() {
|
|
33
36
|
this.data = loadStorage();
|
|
@@ -37,6 +40,19 @@ export class AccountManager {
|
|
|
37
40
|
saveStorage(this.data);
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
private notifyStateChanged(): void {
|
|
44
|
+
for (const handler of this.stateChangeHandlers) {
|
|
45
|
+
handler();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onStateChange(handler: StateChangeHandler): () => void {
|
|
50
|
+
this.stateChangeHandlers.add(handler);
|
|
51
|
+
return () => {
|
|
52
|
+
this.stateChangeHandlers.delete(handler);
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
40
56
|
getAccounts(): Account[] {
|
|
41
57
|
return this.data.accounts;
|
|
42
58
|
}
|
|
@@ -49,7 +65,14 @@ export class AccountManager {
|
|
|
49
65
|
this.warningHandler = handler;
|
|
50
66
|
}
|
|
51
67
|
|
|
52
|
-
addOrUpdateAccount(
|
|
68
|
+
addOrUpdateAccount(
|
|
69
|
+
email: string,
|
|
70
|
+
creds: OAuthCredentials,
|
|
71
|
+
options?: {
|
|
72
|
+
importSource?: "pi-openai-codex";
|
|
73
|
+
importFingerprint?: string;
|
|
74
|
+
},
|
|
75
|
+
): void {
|
|
53
76
|
const existing = this.getAccount(email);
|
|
54
77
|
const accountId =
|
|
55
78
|
typeof creds.accountId === "string" ? creds.accountId : undefined;
|
|
@@ -57,6 +80,8 @@ export class AccountManager {
|
|
|
57
80
|
existing.accessToken = creds.access;
|
|
58
81
|
existing.refreshToken = creds.refresh;
|
|
59
82
|
existing.expiresAt = creds.expires;
|
|
83
|
+
existing.importSource = options?.importSource;
|
|
84
|
+
existing.importFingerprint = options?.importFingerprint;
|
|
60
85
|
if (accountId) {
|
|
61
86
|
existing.accountId = accountId;
|
|
62
87
|
}
|
|
@@ -67,10 +92,11 @@ export class AccountManager {
|
|
|
67
92
|
refreshToken: creds.refresh,
|
|
68
93
|
expiresAt: creds.expires,
|
|
69
94
|
accountId,
|
|
95
|
+
importSource: options?.importSource,
|
|
96
|
+
importFingerprint: options?.importFingerprint,
|
|
70
97
|
});
|
|
71
98
|
}
|
|
72
99
|
this.setActiveAccount(email);
|
|
73
|
-
this.save();
|
|
74
100
|
}
|
|
75
101
|
|
|
76
102
|
getActiveAccount(): Account | undefined {
|
|
@@ -99,6 +125,7 @@ export class AccountManager {
|
|
|
99
125
|
setActiveAccount(email: string): void {
|
|
100
126
|
this.data.activeEmail = email;
|
|
101
127
|
this.save();
|
|
128
|
+
this.notifyStateChanged();
|
|
102
129
|
}
|
|
103
130
|
|
|
104
131
|
setManualAccount(email: string): void {
|
|
@@ -106,10 +133,45 @@ export class AccountManager {
|
|
|
106
133
|
if (!account) return;
|
|
107
134
|
this.manualEmail = email;
|
|
108
135
|
account.lastUsed = Date.now();
|
|
136
|
+
this.notifyStateChanged();
|
|
109
137
|
}
|
|
110
138
|
|
|
111
139
|
clearManualAccount(): void {
|
|
140
|
+
if (!this.manualEmail) return;
|
|
112
141
|
this.manualEmail = undefined;
|
|
142
|
+
this.notifyStateChanged();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
getImportedAccount(): Account | undefined {
|
|
146
|
+
return this.data.accounts.find(
|
|
147
|
+
(account) => account.importSource === "pi-openai-codex",
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async syncImportedOpenAICodexAuth(): Promise<boolean> {
|
|
152
|
+
const imported = await loadImportedOpenAICodexAuth();
|
|
153
|
+
if (!imported) return false;
|
|
154
|
+
|
|
155
|
+
const existingImported = this.getImportedAccount();
|
|
156
|
+
if (
|
|
157
|
+
existingImported?.importFingerprint === imported.fingerprint &&
|
|
158
|
+
existingImported.email === imported.identifier
|
|
159
|
+
) {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (existingImported && existingImported.email !== imported.identifier) {
|
|
164
|
+
const target = this.getAccount(imported.identifier);
|
|
165
|
+
if (!target) {
|
|
166
|
+
existingImported.email = imported.identifier;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.addOrUpdateAccount(imported.identifier, imported.credentials, {
|
|
171
|
+
importSource: "pi-openai-codex",
|
|
172
|
+
importFingerprint: imported.fingerprint,
|
|
173
|
+
});
|
|
174
|
+
return true;
|
|
113
175
|
}
|
|
114
176
|
|
|
115
177
|
getAvailableManualAccount(options?: {
|
|
@@ -129,6 +191,7 @@ export class AccountManager {
|
|
|
129
191
|
if (account) {
|
|
130
192
|
account.quotaExhaustedUntil = until;
|
|
131
193
|
this.save();
|
|
194
|
+
this.notifyStateChanged();
|
|
132
195
|
}
|
|
133
196
|
}
|
|
134
197
|
|
|
@@ -157,6 +220,7 @@ export class AccountManager {
|
|
|
157
220
|
timeoutMs: USAGE_REQUEST_TIMEOUT_MS,
|
|
158
221
|
});
|
|
159
222
|
this.usageCache.set(account.email, usage);
|
|
223
|
+
this.notifyStateChanged();
|
|
160
224
|
return usage;
|
|
161
225
|
} catch (error) {
|
|
162
226
|
this.warningHandler?.(
|
|
@@ -239,6 +303,7 @@ export class AccountManager {
|
|
|
239
303
|
}
|
|
240
304
|
if (changed) {
|
|
241
305
|
this.save();
|
|
306
|
+
this.notifyStateChanged();
|
|
242
307
|
}
|
|
243
308
|
}
|
|
244
309
|
|
|
@@ -257,6 +322,7 @@ export class AccountManager {
|
|
|
257
322
|
account.accountId = accountId;
|
|
258
323
|
}
|
|
259
324
|
this.save();
|
|
325
|
+
this.notifyStateChanged();
|
|
260
326
|
return account.accessToken;
|
|
261
327
|
}
|
|
262
328
|
}
|
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-
|
|
22
|
-
description:
|
|
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
|
|
28
|
-
if (
|
|
29
|
-
ctx
|
|
30
|
-
|
|
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
|
-
|
|
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
|
|
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(`
|
|
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
|
|
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/extension.ts
CHANGED
|
@@ -54,7 +54,7 @@ export default function multicodexExtension(pi: ExtensionAPI) {
|
|
|
54
54
|
|
|
55
55
|
pi.on("model_select", (_event: unknown, ctx: ExtensionContext) => {
|
|
56
56
|
lastContext = ctx;
|
|
57
|
-
|
|
57
|
+
statusController.scheduleModelSelectRefresh(ctx);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@victor-software-house/pi-multicodex",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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 = "
|
|
13
|
+
export const PROVIDER_ID = "openai-codex";
|
|
14
14
|
|
|
15
15
|
export interface ProviderModelDef {
|
|
16
16
|
id: string;
|
package/status.ts
CHANGED
|
@@ -21,6 +21,7 @@ const STATUS_KEY = "multicodex-usage";
|
|
|
21
21
|
const SETTINGS_KEY = "pi-multicodex";
|
|
22
22
|
const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
23
23
|
const REFRESH_INTERVAL_MS = 60_000;
|
|
24
|
+
const MODEL_SELECT_REFRESH_DEBOUNCE_MS = 250;
|
|
24
25
|
const UNKNOWN_PERCENT = "--";
|
|
25
26
|
const FIVE_HOUR_LABEL = "5h:";
|
|
26
27
|
const SEVEN_DAY_LABEL = "7d:";
|
|
@@ -172,6 +173,40 @@ function formatResetCountdown(resetAt: number | undefined): string | undefined {
|
|
|
172
173
|
return `${seconds}s`;
|
|
173
174
|
}
|
|
174
175
|
|
|
176
|
+
function shouldShowReset(
|
|
177
|
+
preferences: FooterPreferences,
|
|
178
|
+
window: Exclude<ResetWindowMode, "both">,
|
|
179
|
+
): boolean {
|
|
180
|
+
if (!preferences.showReset) return false;
|
|
181
|
+
return (
|
|
182
|
+
preferences.resetWindow === "both" || preferences.resetWindow === window
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function formatUsageSegment(
|
|
187
|
+
ctx: ExtensionContext,
|
|
188
|
+
label: string,
|
|
189
|
+
usedPercent: number | undefined,
|
|
190
|
+
resetAt: number | undefined,
|
|
191
|
+
showReset: boolean,
|
|
192
|
+
preferences: FooterPreferences,
|
|
193
|
+
): string {
|
|
194
|
+
const parts = [
|
|
195
|
+
`${ctx.ui.theme.fg("dim", label)}${formatPercent(
|
|
196
|
+
ctx,
|
|
197
|
+
usedToDisplayPercent(usedPercent, preferences.usageMode),
|
|
198
|
+
preferences.usageMode,
|
|
199
|
+
)}`,
|
|
200
|
+
];
|
|
201
|
+
if (showReset) {
|
|
202
|
+
const countdown = formatResetCountdown(resetAt);
|
|
203
|
+
if (countdown) {
|
|
204
|
+
parts.push(ctx.ui.theme.fg("dim", `(↺${countdown})`));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return parts.join(" ");
|
|
208
|
+
}
|
|
209
|
+
|
|
175
210
|
export function isManagedModel(model: MaybeModel): boolean {
|
|
176
211
|
return model?.provider === PROVIDER_ID;
|
|
177
212
|
}
|
|
@@ -195,41 +230,22 @@ export function formatActiveAccountStatus(
|
|
|
195
230
|
.join(" ");
|
|
196
231
|
}
|
|
197
232
|
|
|
198
|
-
const fiveHour =
|
|
233
|
+
const fiveHour = formatUsageSegment(
|
|
199
234
|
ctx,
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
235
|
+
FIVE_HOUR_LABEL,
|
|
236
|
+
usage.primary?.usedPercent,
|
|
237
|
+
usage.primary?.resetAt,
|
|
238
|
+
shouldShowReset(preferences, "5h"),
|
|
239
|
+
preferences,
|
|
240
|
+
);
|
|
241
|
+
const sevenDay = formatUsageSegment(
|
|
204
242
|
ctx,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const sevenDayReset = preferences.showReset
|
|
212
|
-
? formatResetCountdown(usage.secondary?.resetAt)
|
|
213
|
-
: undefined;
|
|
214
|
-
const resetText =
|
|
215
|
-
preferences.resetWindow === "5h"
|
|
216
|
-
? fiveHourReset
|
|
217
|
-
? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
|
|
218
|
-
: undefined
|
|
219
|
-
: preferences.resetWindow === "7d"
|
|
220
|
-
? sevenDayReset
|
|
221
|
-
? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
|
|
222
|
-
: undefined
|
|
223
|
-
: [
|
|
224
|
-
fiveHourReset
|
|
225
|
-
? ctx.ui.theme.fg("dim", `(${FIVE_HOUR_LABEL}↺${fiveHourReset})`)
|
|
226
|
-
: undefined,
|
|
227
|
-
sevenDayReset
|
|
228
|
-
? ctx.ui.theme.fg("dim", `(${SEVEN_DAY_LABEL}↺${sevenDayReset})`)
|
|
229
|
-
: undefined,
|
|
230
|
-
]
|
|
231
|
-
.filter(Boolean)
|
|
232
|
-
.join(" ") || undefined;
|
|
243
|
+
SEVEN_DAY_LABEL,
|
|
244
|
+
usage.secondary?.usedPercent,
|
|
245
|
+
usage.secondary?.resetAt,
|
|
246
|
+
shouldShowReset(preferences, "7d"),
|
|
247
|
+
preferences,
|
|
248
|
+
);
|
|
233
249
|
|
|
234
250
|
const leading =
|
|
235
251
|
preferences.order === "account-first"
|
|
@@ -238,7 +254,7 @@ export function formatActiveAccountStatus(
|
|
|
238
254
|
const trailing =
|
|
239
255
|
preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
|
|
240
256
|
|
|
241
|
-
return [...leading, fiveHour, sevenDay,
|
|
257
|
+
return [...leading, fiveHour, sevenDay, ...trailing]
|
|
242
258
|
.filter(Boolean)
|
|
243
259
|
.join(" ");
|
|
244
260
|
}
|
|
@@ -315,10 +331,17 @@ function applyPreferenceChange(
|
|
|
315
331
|
|
|
316
332
|
export function createUsageStatusController(accountManager: AccountManager) {
|
|
317
333
|
let refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
334
|
+
let modelSelectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
318
335
|
let activeContext: ExtensionContext | undefined;
|
|
319
336
|
let refreshInFlight = false;
|
|
320
337
|
let queuedRefresh = false;
|
|
321
338
|
let preferences: FooterPreferences = DEFAULT_PREFERENCES;
|
|
339
|
+
let livePreviewPreferences: FooterPreferences | undefined;
|
|
340
|
+
|
|
341
|
+
accountManager.onStateChange(() => {
|
|
342
|
+
if (!activeContext) return;
|
|
343
|
+
renderCachedStatus(activeContext, livePreviewPreferences ?? preferences);
|
|
344
|
+
});
|
|
322
345
|
|
|
323
346
|
function clearStatus(ctx?: ExtensionContext): void {
|
|
324
347
|
ctx?.ui.setStatus(STATUS_KEY, undefined);
|
|
@@ -328,6 +351,42 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
328
351
|
preferences = await loadFooterPreferences();
|
|
329
352
|
}
|
|
330
353
|
|
|
354
|
+
function getStatusText(
|
|
355
|
+
ctx: ExtensionContext,
|
|
356
|
+
preferencesOverride?: FooterPreferences,
|
|
357
|
+
): string | undefined {
|
|
358
|
+
if (!ctx.hasUI) return undefined;
|
|
359
|
+
if (!isManagedModel(ctx.model)) return undefined;
|
|
360
|
+
|
|
361
|
+
const activeAccount = accountManager.getActiveAccount();
|
|
362
|
+
if (!activeAccount) {
|
|
363
|
+
return ctx.ui.theme.fg("warning", "Multicodex no active account");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return formatActiveAccountStatus(
|
|
367
|
+
ctx,
|
|
368
|
+
activeAccount.email,
|
|
369
|
+
accountManager.getCachedUsage(activeAccount.email),
|
|
370
|
+
preferencesOverride ?? preferences,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function renderCachedStatus(
|
|
375
|
+
ctx: ExtensionContext,
|
|
376
|
+
preferencesOverride?: FooterPreferences,
|
|
377
|
+
): void {
|
|
378
|
+
if (!ctx.hasUI) return;
|
|
379
|
+
if (!isManagedModel(ctx.model)) {
|
|
380
|
+
clearStatus(ctx);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const text = getStatusText(ctx, preferencesOverride);
|
|
385
|
+
if (text) {
|
|
386
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
331
390
|
async function updateStatus(ctx: ExtensionContext): Promise<void> {
|
|
332
391
|
if (!ctx.hasUI) return;
|
|
333
392
|
if (!isManagedModel(ctx.model)) {
|
|
@@ -335,7 +394,13 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
335
394
|
return;
|
|
336
395
|
}
|
|
337
396
|
|
|
338
|
-
|
|
397
|
+
renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
|
|
398
|
+
|
|
399
|
+
let activeAccount = accountManager.getActiveAccount();
|
|
400
|
+
if (!activeAccount) {
|
|
401
|
+
await accountManager.syncImportedOpenAICodexAuth();
|
|
402
|
+
activeAccount = accountManager.getActiveAccount();
|
|
403
|
+
}
|
|
339
404
|
if (!activeAccount) {
|
|
340
405
|
ctx.ui.setStatus(
|
|
341
406
|
STATUS_KEY,
|
|
@@ -350,7 +415,12 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
350
415
|
cachedUsage;
|
|
351
416
|
ctx.ui.setStatus(
|
|
352
417
|
STATUS_KEY,
|
|
353
|
-
formatActiveAccountStatus(
|
|
418
|
+
formatActiveAccountStatus(
|
|
419
|
+
ctx,
|
|
420
|
+
activeAccount.email,
|
|
421
|
+
usage,
|
|
422
|
+
livePreviewPreferences ?? preferences,
|
|
423
|
+
),
|
|
354
424
|
);
|
|
355
425
|
}
|
|
356
426
|
|
|
@@ -373,6 +443,19 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
373
443
|
}
|
|
374
444
|
}
|
|
375
445
|
|
|
446
|
+
function scheduleModelSelectRefresh(ctx: ExtensionContext): void {
|
|
447
|
+
activeContext = ctx;
|
|
448
|
+
renderCachedStatus(ctx, livePreviewPreferences ?? preferences);
|
|
449
|
+
if (modelSelectTimer) {
|
|
450
|
+
clearTimeout(modelSelectTimer);
|
|
451
|
+
}
|
|
452
|
+
modelSelectTimer = setTimeout(() => {
|
|
453
|
+
modelSelectTimer = undefined;
|
|
454
|
+
void refreshFor(ctx);
|
|
455
|
+
}, MODEL_SELECT_REFRESH_DEBOUNCE_MS);
|
|
456
|
+
modelSelectTimer.unref?.();
|
|
457
|
+
}
|
|
458
|
+
|
|
376
459
|
function startAutoRefresh(): void {
|
|
377
460
|
if (refreshTimer) clearInterval(refreshTimer);
|
|
378
461
|
refreshTimer = setInterval(() => {
|
|
@@ -387,6 +470,11 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
387
470
|
clearInterval(refreshTimer);
|
|
388
471
|
refreshTimer = undefined;
|
|
389
472
|
}
|
|
473
|
+
if (modelSelectTimer) {
|
|
474
|
+
clearTimeout(modelSelectTimer);
|
|
475
|
+
modelSelectTimer = undefined;
|
|
476
|
+
}
|
|
477
|
+
livePreviewPreferences = undefined;
|
|
390
478
|
clearStatus(ctx ?? activeContext);
|
|
391
479
|
activeContext = undefined;
|
|
392
480
|
queuedRefresh = false;
|
|
@@ -404,11 +492,23 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
404
492
|
}
|
|
405
493
|
}
|
|
406
494
|
|
|
495
|
+
function renderPreviewLabel(
|
|
496
|
+
ctx: ExtensionContext,
|
|
497
|
+
theme: ExtensionCommandContext["ui"]["theme"],
|
|
498
|
+
draft: FooterPreferences,
|
|
499
|
+
): string {
|
|
500
|
+
const previewText =
|
|
501
|
+
getStatusText(ctx, draft) ?? ctx.ui.theme.fg("dim", "Codex loading...");
|
|
502
|
+
return `${theme.fg("dim", "Preview")}: ${previewText}`;
|
|
503
|
+
}
|
|
504
|
+
|
|
407
505
|
async function openPreferencesPanel(
|
|
408
506
|
ctx: ExtensionCommandContext,
|
|
409
507
|
): Promise<void> {
|
|
410
508
|
await loadPreferences(ctx);
|
|
411
509
|
let draft = preferences;
|
|
510
|
+
livePreviewPreferences = draft;
|
|
511
|
+
renderCachedStatus(ctx, livePreviewPreferences);
|
|
412
512
|
|
|
413
513
|
await ctx.ui.custom((_tui, theme, _kb, done) => {
|
|
414
514
|
const container = new Container();
|
|
@@ -425,14 +525,20 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
425
525
|
0,
|
|
426
526
|
),
|
|
427
527
|
);
|
|
528
|
+
const previewText = new Text(renderPreviewLabel(ctx, theme, draft), 1, 0);
|
|
529
|
+
container.addChild(previewText);
|
|
428
530
|
|
|
429
531
|
const settingsList = new SettingsList(
|
|
430
532
|
createSettingsItems(draft),
|
|
431
|
-
|
|
533
|
+
9,
|
|
432
534
|
getSettingsListTheme(),
|
|
433
535
|
(id: string, newValue: string) => {
|
|
434
536
|
draft = applyPreferenceChange(draft, id, newValue);
|
|
537
|
+
livePreviewPreferences = draft;
|
|
435
538
|
settingsList.updateValue(id, newValue);
|
|
539
|
+
previewText.setText(renderPreviewLabel(ctx, theme, draft));
|
|
540
|
+
container.invalidate();
|
|
541
|
+
renderCachedStatus(ctx, draft);
|
|
436
542
|
},
|
|
437
543
|
() => done(undefined),
|
|
438
544
|
{ enableSearch: true },
|
|
@@ -447,6 +553,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
447
553
|
});
|
|
448
554
|
|
|
449
555
|
preferences = draft;
|
|
556
|
+
livePreviewPreferences = undefined;
|
|
450
557
|
await persistFooterPreferences(preferences);
|
|
451
558
|
await refreshFor(ctx);
|
|
452
559
|
}
|
|
@@ -455,6 +562,7 @@ export function createUsageStatusController(accountManager: AccountManager) {
|
|
|
455
562
|
loadPreferences,
|
|
456
563
|
openPreferencesPanel,
|
|
457
564
|
refreshFor,
|
|
565
|
+
scheduleModelSelectRefresh,
|
|
458
566
|
startAutoRefresh,
|
|
459
567
|
stopAutoRefresh,
|
|
460
568
|
getPreferences: () => preferences,
|
package/storage.ts
CHANGED
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-
|
|
104
|
+
"No available Multicodex accounts. Please use /multicodex-use <identifier>.",
|
|
104
105
|
);
|
|
105
106
|
}
|
|
106
107
|
|