@victor-software-house/pi-multicodex 1.0.3 → 1.0.6
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/LICENSE +1 -1
- package/README.md +42 -20
- package/abort-utils.ts +24 -0
- package/account-manager.ts +262 -0
- package/browser.ts +34 -0
- package/commands.ts +150 -0
- package/extension.ts +63 -0
- package/hooks.ts +22 -0
- package/index.ts +28 -985
- package/package.json +20 -3
- package/provider.ts +75 -0
- package/quota.ts +5 -0
- package/selection.ts +69 -0
- package/status.ts +462 -0
- package/storage.ts +49 -0
- package/stream-wrapper.ts +191 -0
- package/usage-client.ts +50 -0
- package/usage.ts +86 -0
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|
|
|
5
|
-
`@victor-software-house/pi-multicodex` is a pi extension
|
|
5
|
+
`@victor-software-house/pi-multicodex` is a pi extension that rotates multiple ChatGPT Codex OAuth accounts for the `openai-codex-responses` API.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## What it does
|
|
8
8
|
|
|
9
|
-
- rotates on quota and rate-limit failures
|
|
9
|
+
- rotates accounts on quota and rate-limit failures
|
|
10
10
|
- prefers untouched accounts when usage data is available
|
|
11
11
|
- otherwise prefers the account whose weekly window resets first
|
|
12
|
-
-
|
|
12
|
+
- keeps the implementation focused on Codex account rotation
|
|
13
13
|
|
|
14
14
|
## Install
|
|
15
15
|
|
|
@@ -21,7 +21,7 @@ Restart `pi` after installation.
|
|
|
21
21
|
|
|
22
22
|
## Local development
|
|
23
23
|
|
|
24
|
-
This repo uses `mise` to pin
|
|
24
|
+
This repo uses `mise` to pin tools and `pnpm` for dependency management.
|
|
25
25
|
|
|
26
26
|
```bash
|
|
27
27
|
mise install
|
|
@@ -51,21 +51,31 @@ pi -e ./index.ts
|
|
|
51
51
|
- Select an account manually for the current session.
|
|
52
52
|
- `/multicodex-status`
|
|
53
53
|
- Show account state and cached usage information.
|
|
54
|
+
- `/multicodex-footer`
|
|
55
|
+
- Open an interactive panel to configure footer fields and ordering.
|
|
54
56
|
|
|
55
|
-
##
|
|
57
|
+
## Project direction
|
|
56
58
|
|
|
57
|
-
This
|
|
59
|
+
This project is maintained as its own package and release line.
|
|
58
60
|
|
|
59
61
|
Current direction:
|
|
60
62
|
|
|
61
63
|
- package name: `@victor-software-house/pi-multicodex`
|
|
62
|
-
- hard break from previous storage compatibility
|
|
63
64
|
- Codex-only scope
|
|
64
|
-
-
|
|
65
|
+
- local state stored at `~/.pi/agent/codex-accounts.json`
|
|
66
|
+
- internal logic split into focused modules
|
|
67
|
+
- current roadmap tracked in `ROADMAP.md`
|
|
65
68
|
|
|
66
|
-
|
|
69
|
+
Current next step:
|
|
70
|
+
|
|
71
|
+
- add active-account usage visibility in pi for this extension's managed Codex accounts
|
|
72
|
+
- 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
|
+
- 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
|
|
67
77
|
|
|
68
|
-
|
|
78
|
+
## Release validation
|
|
69
79
|
|
|
70
80
|
Minimum release checks:
|
|
71
81
|
|
|
@@ -74,22 +84,34 @@ pnpm check
|
|
|
74
84
|
npm pack --dry-run
|
|
75
85
|
```
|
|
76
86
|
|
|
77
|
-
|
|
87
|
+
Release flow:
|
|
78
88
|
|
|
79
|
-
1.
|
|
80
|
-
2.
|
|
81
|
-
3.
|
|
89
|
+
1. Prepare the release locally.
|
|
90
|
+
2. Commit the version bump.
|
|
91
|
+
3. Create and push a matching `v*` tag.
|
|
92
|
+
4. Let GitHub Actions publish through trusted publishing.
|
|
82
93
|
|
|
83
|
-
|
|
94
|
+
Prepare locally:
|
|
84
95
|
|
|
85
96
|
```bash
|
|
86
|
-
npm run
|
|
87
|
-
npm publish --access public --otp=<code>
|
|
97
|
+
npm run release:prepare -- <version>
|
|
88
98
|
```
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
The helper updates `package.json` with `bun pm pkg set` and then runs the release checks.
|
|
101
|
+
|
|
102
|
+
Example:
|
|
91
103
|
|
|
92
104
|
```bash
|
|
105
|
+
git add package.json
|
|
106
|
+
git commit -m "release: v<version>"
|
|
93
107
|
git tag v<version>
|
|
94
|
-
git push origin
|
|
108
|
+
git push origin main --tags
|
|
95
109
|
```
|
|
110
|
+
|
|
111
|
+
Do not use local `npm publish` for normal releases in this repo.
|
|
112
|
+
|
|
113
|
+
## Acknowledgment
|
|
114
|
+
|
|
115
|
+
This project descends from earlier MultiCodex work. Thanks to the original creator for the starting point that made this package possible.
|
|
116
|
+
|
|
117
|
+
The active-account usage footer work also draws on ideas from `calesennett/pi-codex-usage`. Thanks to its author for the reference implementation and footer design.
|
package/abort-utils.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type OAuthCredentials,
|
|
3
|
+
refreshOpenAICodexToken,
|
|
4
|
+
} from "@mariozechner/pi-ai/oauth";
|
|
5
|
+
import { isAccountAvailable, pickBestAccount } from "./selection";
|
|
6
|
+
import {
|
|
7
|
+
type Account,
|
|
8
|
+
loadStorage,
|
|
9
|
+
type StorageData,
|
|
10
|
+
saveStorage,
|
|
11
|
+
} from "./storage";
|
|
12
|
+
import { type CodexUsageSnapshot, getNextResetAt } from "./usage";
|
|
13
|
+
import { fetchCodexUsage } from "./usage-client";
|
|
14
|
+
|
|
15
|
+
const USAGE_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
16
|
+
const USAGE_REQUEST_TIMEOUT_MS = 10 * 1000;
|
|
17
|
+
const QUOTA_COOLDOWN_MS = 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
type WarningHandler = (message: string) => void;
|
|
20
|
+
|
|
21
|
+
function getErrorMessage(error: unknown): string {
|
|
22
|
+
if (error instanceof Error) return error.message;
|
|
23
|
+
return typeof error === "string" ? error : JSON.stringify(error);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export class AccountManager {
|
|
27
|
+
private data: StorageData;
|
|
28
|
+
private usageCache = new Map<string, CodexUsageSnapshot>();
|
|
29
|
+
private warningHandler?: WarningHandler;
|
|
30
|
+
private manualEmail?: string;
|
|
31
|
+
|
|
32
|
+
constructor() {
|
|
33
|
+
this.data = loadStorage();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private save(): void {
|
|
37
|
+
saveStorage(this.data);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getAccounts(): Account[] {
|
|
41
|
+
return this.data.accounts;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getAccount(email: string): Account | undefined {
|
|
45
|
+
return this.data.accounts.find((a) => a.email === email);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
setWarningHandler(handler?: WarningHandler): void {
|
|
49
|
+
this.warningHandler = handler;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
addOrUpdateAccount(email: string, creds: OAuthCredentials): void {
|
|
53
|
+
const existing = this.getAccount(email);
|
|
54
|
+
const accountId =
|
|
55
|
+
typeof creds.accountId === "string" ? creds.accountId : undefined;
|
|
56
|
+
if (existing) {
|
|
57
|
+
existing.accessToken = creds.access;
|
|
58
|
+
existing.refreshToken = creds.refresh;
|
|
59
|
+
existing.expiresAt = creds.expires;
|
|
60
|
+
if (accountId) {
|
|
61
|
+
existing.accountId = accountId;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
this.data.accounts.push({
|
|
65
|
+
email,
|
|
66
|
+
accessToken: creds.access,
|
|
67
|
+
refreshToken: creds.refresh,
|
|
68
|
+
expiresAt: creds.expires,
|
|
69
|
+
accountId,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
this.setActiveAccount(email);
|
|
73
|
+
this.save();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
getActiveAccount(): Account | undefined {
|
|
77
|
+
const manual = this.getManualAccount();
|
|
78
|
+
if (manual) return manual;
|
|
79
|
+
if (this.data.activeEmail) {
|
|
80
|
+
return this.getAccount(this.data.activeEmail);
|
|
81
|
+
}
|
|
82
|
+
return this.data.accounts[0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
getManualAccount(): Account | undefined {
|
|
86
|
+
if (!this.manualEmail) return undefined;
|
|
87
|
+
const account = this.getAccount(this.manualEmail);
|
|
88
|
+
if (!account) {
|
|
89
|
+
this.manualEmail = undefined;
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return account;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
hasManualAccount(): boolean {
|
|
96
|
+
return Boolean(this.manualEmail);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setActiveAccount(email: string): void {
|
|
100
|
+
this.data.activeEmail = email;
|
|
101
|
+
this.save();
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setManualAccount(email: string): void {
|
|
105
|
+
const account = this.getAccount(email);
|
|
106
|
+
if (!account) return;
|
|
107
|
+
this.manualEmail = email;
|
|
108
|
+
account.lastUsed = Date.now();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
clearManualAccount(): void {
|
|
112
|
+
this.manualEmail = undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getAvailableManualAccount(options?: {
|
|
116
|
+
excludeEmails?: Set<string>;
|
|
117
|
+
now?: number;
|
|
118
|
+
}): Account | undefined {
|
|
119
|
+
const manual = this.getManualAccount();
|
|
120
|
+
if (!manual) return undefined;
|
|
121
|
+
const now = options?.now ?? Date.now();
|
|
122
|
+
if (!isAccountAvailable(manual, now)) return undefined;
|
|
123
|
+
if (options?.excludeEmails?.has(manual.email)) return undefined;
|
|
124
|
+
return manual;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
markExhausted(email: string, until: number): void {
|
|
128
|
+
const account = this.getAccount(email);
|
|
129
|
+
if (account) {
|
|
130
|
+
account.quotaExhaustedUntil = until;
|
|
131
|
+
this.save();
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getCachedUsage(email: string): CodexUsageSnapshot | undefined {
|
|
136
|
+
return this.usageCache.get(email);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async refreshUsageForAccount(
|
|
140
|
+
account: Account,
|
|
141
|
+
options?: { force?: boolean; signal?: AbortSignal },
|
|
142
|
+
): Promise<CodexUsageSnapshot | undefined> {
|
|
143
|
+
const cached = this.usageCache.get(account.email);
|
|
144
|
+
const now = Date.now();
|
|
145
|
+
if (
|
|
146
|
+
cached &&
|
|
147
|
+
!options?.force &&
|
|
148
|
+
now - cached.fetchedAt < USAGE_CACHE_TTL_MS
|
|
149
|
+
) {
|
|
150
|
+
return cached;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
const token = await this.ensureValidToken(account);
|
|
155
|
+
const usage = await fetchCodexUsage(token, account.accountId, {
|
|
156
|
+
signal: options?.signal,
|
|
157
|
+
timeoutMs: USAGE_REQUEST_TIMEOUT_MS,
|
|
158
|
+
});
|
|
159
|
+
this.usageCache.set(account.email, usage);
|
|
160
|
+
return usage;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
this.warningHandler?.(
|
|
163
|
+
`Multicodex: failed to fetch usage for ${account.email}: ${getErrorMessage(
|
|
164
|
+
error,
|
|
165
|
+
)}`,
|
|
166
|
+
);
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async refreshUsageForAllAccounts(options?: {
|
|
172
|
+
force?: boolean;
|
|
173
|
+
signal?: AbortSignal;
|
|
174
|
+
}): Promise<void> {
|
|
175
|
+
const accounts = this.getAccounts();
|
|
176
|
+
await Promise.all(
|
|
177
|
+
accounts.map((account) => this.refreshUsageForAccount(account, options)),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async refreshUsageIfStale(
|
|
182
|
+
accounts: Account[],
|
|
183
|
+
options?: { signal?: AbortSignal },
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
const now = Date.now();
|
|
186
|
+
const stale = accounts.filter((account) => {
|
|
187
|
+
const cached = this.usageCache.get(account.email);
|
|
188
|
+
return !cached || now - cached.fetchedAt >= USAGE_CACHE_TTL_MS;
|
|
189
|
+
});
|
|
190
|
+
if (stale.length === 0) return;
|
|
191
|
+
await Promise.all(
|
|
192
|
+
stale.map((account) =>
|
|
193
|
+
this.refreshUsageForAccount(account, { force: true, ...options }),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async activateBestAccount(options?: {
|
|
199
|
+
excludeEmails?: Set<string>;
|
|
200
|
+
signal?: AbortSignal;
|
|
201
|
+
}): Promise<Account | undefined> {
|
|
202
|
+
const now = Date.now();
|
|
203
|
+
this.clearExpiredExhaustion(now);
|
|
204
|
+
const accounts = this.data.accounts;
|
|
205
|
+
await this.refreshUsageIfStale(accounts, options);
|
|
206
|
+
|
|
207
|
+
const selected = pickBestAccount(accounts, this.usageCache, {
|
|
208
|
+
excludeEmails: options?.excludeEmails,
|
|
209
|
+
now,
|
|
210
|
+
});
|
|
211
|
+
if (selected) {
|
|
212
|
+
this.setActiveAccount(selected.email);
|
|
213
|
+
}
|
|
214
|
+
return selected;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async handleQuotaExceeded(
|
|
218
|
+
account: Account,
|
|
219
|
+
options?: { signal?: AbortSignal },
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const usage = await this.refreshUsageForAccount(account, {
|
|
222
|
+
force: true,
|
|
223
|
+
signal: options?.signal,
|
|
224
|
+
});
|
|
225
|
+
const now = Date.now();
|
|
226
|
+
const resetAt = getNextResetAt(usage);
|
|
227
|
+
const fallback = now + QUOTA_COOLDOWN_MS;
|
|
228
|
+
const until = resetAt && resetAt > now ? resetAt : fallback;
|
|
229
|
+
this.markExhausted(account.email, until);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private clearExpiredExhaustion(now: number): void {
|
|
233
|
+
let changed = false;
|
|
234
|
+
for (const account of this.data.accounts) {
|
|
235
|
+
if (account.quotaExhaustedUntil && account.quotaExhaustedUntil <= now) {
|
|
236
|
+
account.quotaExhaustedUntil = undefined;
|
|
237
|
+
changed = true;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (changed) {
|
|
241
|
+
this.save();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async ensureValidToken(account: Account): Promise<string> {
|
|
246
|
+
if (Date.now() < account.expiresAt - 5 * 60 * 1000) {
|
|
247
|
+
return account.accessToken;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const result = await refreshOpenAICodexToken(account.refreshToken);
|
|
251
|
+
account.accessToken = result.access;
|
|
252
|
+
account.refreshToken = result.refresh;
|
|
253
|
+
account.expiresAt = result.expires;
|
|
254
|
+
const accountId =
|
|
255
|
+
typeof result.accountId === "string" ? result.accountId : undefined;
|
|
256
|
+
if (accountId) {
|
|
257
|
+
account.accountId = accountId;
|
|
258
|
+
}
|
|
259
|
+
this.save();
|
|
260
|
+
return account.accessToken;
|
|
261
|
+
}
|
|
262
|
+
}
|
package/browser.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export async function openLoginInBrowser(
|
|
7
|
+
pi: ExtensionAPI,
|
|
8
|
+
ctx: ExtensionCommandContext,
|
|
9
|
+
url: string,
|
|
10
|
+
): Promise<void> {
|
|
11
|
+
let command: string;
|
|
12
|
+
let args: string[];
|
|
13
|
+
|
|
14
|
+
if (process.platform === "darwin") {
|
|
15
|
+
command = "open";
|
|
16
|
+
args = [url];
|
|
17
|
+
} else if (process.platform === "win32") {
|
|
18
|
+
command = "cmd";
|
|
19
|
+
args = ["/c", "start", "", url];
|
|
20
|
+
} else {
|
|
21
|
+
command = "xdg-open";
|
|
22
|
+
args = [url];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await pi.exec(command, args);
|
|
27
|
+
} catch (error) {
|
|
28
|
+
ctx.ui.notify(
|
|
29
|
+
"Could not open a browser automatically. Please open the login URL manually.",
|
|
30
|
+
"warning",
|
|
31
|
+
);
|
|
32
|
+
console.warn("[multicodex] Failed to open browser:", error);
|
|
33
|
+
}
|
|
34
|
+
}
|
package/commands.ts
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth";
|
|
2
|
+
import type {
|
|
3
|
+
ExtensionAPI,
|
|
4
|
+
ExtensionCommandContext,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import type { AccountManager } from "./account-manager";
|
|
7
|
+
import { openLoginInBrowser } from "./browser";
|
|
8
|
+
import type { createUsageStatusController } from "./status";
|
|
9
|
+
import { formatResetAt, isUsageUntouched } from "./usage";
|
|
10
|
+
|
|
11
|
+
function getErrorMessage(error: unknown): string {
|
|
12
|
+
if (error instanceof Error) return error.message;
|
|
13
|
+
return typeof error === "string" ? error : JSON.stringify(error);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function registerCommands(
|
|
17
|
+
pi: ExtensionAPI,
|
|
18
|
+
accountManager: AccountManager,
|
|
19
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
20
|
+
): void {
|
|
21
|
+
pi.registerCommand("multicodex-login", {
|
|
22
|
+
description: "Login to an OpenAI Codex account for the rotation pool",
|
|
23
|
+
handler: async (
|
|
24
|
+
args: string,
|
|
25
|
+
ctx: ExtensionCommandContext,
|
|
26
|
+
): 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
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
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> => {
|
|
65
|
+
const accounts = accountManager.getAccounts();
|
|
66
|
+
if (accounts.length === 0) {
|
|
67
|
+
ctx.ui.notify(
|
|
68
|
+
"No accounts logged in. Use /multicodex-login first.",
|
|
69
|
+
"warning",
|
|
70
|
+
);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const options = accounts.map(
|
|
75
|
+
(account) =>
|
|
76
|
+
account.email +
|
|
77
|
+
(account.quotaExhaustedUntil &&
|
|
78
|
+
account.quotaExhaustedUntil > Date.now()
|
|
79
|
+
? " (Quota)"
|
|
80
|
+
: ""),
|
|
81
|
+
);
|
|
82
|
+
const selected = await ctx.ui.select("Select Account", options);
|
|
83
|
+
if (!selected) return;
|
|
84
|
+
|
|
85
|
+
const email = selected.split(" ")[0];
|
|
86
|
+
accountManager.setManualAccount(email);
|
|
87
|
+
ctx.ui.notify(`Switched to ${email}`, "info");
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.registerCommand("multicodex-status", {
|
|
92
|
+
description: "Show all Codex accounts and active status",
|
|
93
|
+
handler: async (
|
|
94
|
+
_args: string,
|
|
95
|
+
ctx: ExtensionCommandContext,
|
|
96
|
+
): Promise<void> => {
|
|
97
|
+
await accountManager.refreshUsageForAllAccounts();
|
|
98
|
+
const accounts = accountManager.getAccounts();
|
|
99
|
+
if (accounts.length === 0) {
|
|
100
|
+
ctx.ui.notify(
|
|
101
|
+
"No accounts logged in. Use /multicodex-login first.",
|
|
102
|
+
"warning",
|
|
103
|
+
);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const active = accountManager.getActiveAccount();
|
|
108
|
+
const options = accounts.map((account) => {
|
|
109
|
+
const usage = accountManager.getCachedUsage(account.email);
|
|
110
|
+
const isActive = active?.email === account.email;
|
|
111
|
+
const quotaHit =
|
|
112
|
+
account.quotaExhaustedUntil &&
|
|
113
|
+
account.quotaExhaustedUntil > Date.now();
|
|
114
|
+
const untouched = isUsageUntouched(usage) ? "untouched" : null;
|
|
115
|
+
const tags = [
|
|
116
|
+
isActive ? "active" : null,
|
|
117
|
+
quotaHit ? "quota" : null,
|
|
118
|
+
untouched,
|
|
119
|
+
]
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.join(", ");
|
|
122
|
+
const suffix = tags ? ` (${tags})` : "";
|
|
123
|
+
const primaryUsed = usage?.primary?.usedPercent;
|
|
124
|
+
const secondaryUsed = usage?.secondary?.usedPercent;
|
|
125
|
+
const primaryReset = usage?.primary?.resetAt;
|
|
126
|
+
const secondaryReset = usage?.secondary?.resetAt;
|
|
127
|
+
const primaryLabel =
|
|
128
|
+
primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
|
|
129
|
+
const secondaryLabel =
|
|
130
|
+
secondaryUsed === undefined
|
|
131
|
+
? "unknown"
|
|
132
|
+
: `${Math.round(secondaryUsed)}%`;
|
|
133
|
+
const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
|
|
134
|
+
return `${isActive ? "•" : " "} ${account.email}${suffix} - ${usageSummary}`;
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
await ctx.ui.select("MultiCodex Accounts", options);
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
pi.registerCommand("multicodex-footer", {
|
|
142
|
+
description: "Configure the MultiCodex usage footer",
|
|
143
|
+
handler: async (
|
|
144
|
+
_args: string,
|
|
145
|
+
ctx: ExtensionCommandContext,
|
|
146
|
+
): Promise<void> => {
|
|
147
|
+
await statusController.openPreferencesPanel(ctx);
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
}
|
package/extension.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionContext,
|
|
4
|
+
} from "@mariozechner/pi-coding-agent";
|
|
5
|
+
import { AccountManager } from "./account-manager";
|
|
6
|
+
import { registerCommands } from "./commands";
|
|
7
|
+
import { handleNewSessionSwitch, handleSessionStart } from "./hooks";
|
|
8
|
+
import { buildMulticodexProviderConfig, PROVIDER_ID } from "./provider";
|
|
9
|
+
import { createUsageStatusController } from "./status";
|
|
10
|
+
|
|
11
|
+
export default function multicodexExtension(pi: ExtensionAPI) {
|
|
12
|
+
const accountManager = new AccountManager();
|
|
13
|
+
const statusController = createUsageStatusController(accountManager);
|
|
14
|
+
let lastContext: ExtensionContext | undefined;
|
|
15
|
+
|
|
16
|
+
accountManager.setWarningHandler((message) => {
|
|
17
|
+
if (lastContext) {
|
|
18
|
+
lastContext.ui.notify(message, "warning");
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
pi.registerProvider(
|
|
23
|
+
PROVIDER_ID,
|
|
24
|
+
buildMulticodexProviderConfig(accountManager),
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
registerCommands(pi, accountManager, statusController);
|
|
28
|
+
|
|
29
|
+
pi.on("session_start", (_event: unknown, ctx: ExtensionContext) => {
|
|
30
|
+
lastContext = ctx;
|
|
31
|
+
handleSessionStart(accountManager);
|
|
32
|
+
statusController.startAutoRefresh();
|
|
33
|
+
void (async () => {
|
|
34
|
+
await statusController.loadPreferences(ctx);
|
|
35
|
+
await statusController.refreshFor(ctx);
|
|
36
|
+
})();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
pi.on(
|
|
40
|
+
"session_switch",
|
|
41
|
+
(event: { reason?: string }, ctx: ExtensionContext) => {
|
|
42
|
+
lastContext = ctx;
|
|
43
|
+
if (event.reason === "new") {
|
|
44
|
+
handleNewSessionSwitch(accountManager);
|
|
45
|
+
}
|
|
46
|
+
void statusController.refreshFor(ctx);
|
|
47
|
+
},
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
pi.on("turn_end", (_event: unknown, ctx: ExtensionContext) => {
|
|
51
|
+
lastContext = ctx;
|
|
52
|
+
void statusController.refreshFor(ctx);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
pi.on("model_select", (_event: unknown, ctx: ExtensionContext) => {
|
|
56
|
+
lastContext = ctx;
|
|
57
|
+
void statusController.refreshFor(ctx);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
pi.on("session_shutdown", (_event: unknown, ctx: ExtensionContext) => {
|
|
61
|
+
statusController.stopAutoRefresh(ctx);
|
|
62
|
+
});
|
|
63
|
+
}
|
package/hooks.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AccountManager } from "./account-manager";
|
|
2
|
+
|
|
3
|
+
async function refreshAndActivateBestAccount(
|
|
4
|
+
accountManager: AccountManager,
|
|
5
|
+
): Promise<void> {
|
|
6
|
+
await accountManager.refreshUsageForAllAccounts({ force: true });
|
|
7
|
+
const manual = accountManager.getAvailableManualAccount();
|
|
8
|
+
if (manual) return;
|
|
9
|
+
if (accountManager.hasManualAccount()) {
|
|
10
|
+
accountManager.clearManualAccount();
|
|
11
|
+
}
|
|
12
|
+
await accountManager.activateBestAccount();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function handleSessionStart(accountManager: AccountManager): void {
|
|
16
|
+
if (accountManager.getAccounts().length === 0) return;
|
|
17
|
+
void refreshAndActivateBestAccount(accountManager);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function handleNewSessionSwitch(accountManager: AccountManager): void {
|
|
21
|
+
void refreshAndActivateBestAccount(accountManager);
|
|
22
|
+
}
|