@victor-software-house/pi-multicodex 1.1.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -37
- package/account-manager.ts +15 -0
- package/commands.ts +476 -85
- package/package.json +1 -1
- package/stream-wrapper.ts +1 -1
package/README.md
CHANGED
|
@@ -45,46 +45,33 @@ Run the extension directly during local development:
|
|
|
45
45
|
pi -e ./index.ts
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
##
|
|
48
|
+
## Command family
|
|
49
49
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
- `/multicodex-use [identifier]`
|
|
53
|
-
- Use an existing managed account, or start the Codex login flow when the account is missing or the stored auth is no longer valid.
|
|
54
|
-
- With no argument, opens an account picker.
|
|
55
|
-
- `/multicodex-status`
|
|
56
|
-
- Show managed account state and cached usage information.
|
|
57
|
-
- `/multicodex-footer`
|
|
58
|
-
- Open an interactive panel to configure footer fields and ordering.
|
|
59
|
-
|
|
60
|
-
## Planned command migration
|
|
61
|
-
|
|
62
|
-
The next user-facing milestone is a command-surface migration to one command family:
|
|
50
|
+
The extension now uses one command family:
|
|
63
51
|
|
|
64
52
|
- `/multicodex`
|
|
65
53
|
- open the main interactive UI
|
|
66
54
|
- `/multicodex show`
|
|
67
|
-
- show
|
|
55
|
+
- show managed account status and cached usage
|
|
68
56
|
- `/multicodex use [identifier]`
|
|
69
|
-
-
|
|
57
|
+
- with an identifier, activate existing auth or trigger login
|
|
58
|
+
- with no identifier, open the account picker
|
|
59
|
+
- in the picker, `Backspace` removes the highlighted account after confirmation
|
|
70
60
|
- `/multicodex footer`
|
|
71
|
-
- open footer settings
|
|
61
|
+
- open footer settings in interactive mode
|
|
62
|
+
- show footer settings summary in non-interactive mode
|
|
72
63
|
- `/multicodex rotation`
|
|
73
|
-
-
|
|
64
|
+
- show current hard-coded rotation policy
|
|
74
65
|
- `/multicodex verify`
|
|
75
|
-
- verify
|
|
66
|
+
- verify writable local paths and report runtime summary
|
|
76
67
|
- `/multicodex path`
|
|
77
|
-
- show
|
|
78
|
-
- `/multicodex reset`
|
|
79
|
-
- reset
|
|
68
|
+
- show storage and settings file paths
|
|
69
|
+
- `/multicodex reset [manual|quota|all]`
|
|
70
|
+
- reset manual override state, quota cooldown state, or both
|
|
80
71
|
- `/multicodex help`
|
|
81
72
|
- print compact usage text
|
|
82
73
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
- the old top-level commands will be removed once `/multicodex` is ready
|
|
86
|
-
- no backward-compatibility aliases are planned
|
|
87
|
-
- README, roadmap, tests, and release notes will move together in the same change
|
|
74
|
+
Dynamic autocomplete is available for subcommands and for `/multicodex use <identifier>`.
|
|
88
75
|
|
|
89
76
|
## Architecture overview
|
|
90
77
|
|
|
@@ -102,7 +89,7 @@ The implementation is currently organized around these modules:
|
|
|
102
89
|
- `status.ts`
|
|
103
90
|
- footer rendering, footer settings persistence, footer settings panel, and footer status refresh logic
|
|
104
91
|
- `commands.ts`
|
|
105
|
-
-
|
|
92
|
+
- `/multicodex` command-family routing, autocomplete, and account-selection flows
|
|
106
93
|
- `hooks.ts`
|
|
107
94
|
- session-start and session-switch refresh behavior
|
|
108
95
|
- `storage.ts`
|
|
@@ -123,12 +110,10 @@ Current direction:
|
|
|
123
110
|
|
|
124
111
|
Current next milestones:
|
|
125
112
|
|
|
126
|
-
1.
|
|
127
|
-
2. Add
|
|
128
|
-
3.
|
|
129
|
-
4.
|
|
130
|
-
5. Add configurable rotation settings and document the rotation behavior contract.
|
|
131
|
-
6. Broaden the current footer controller into a shared MultiCodex controller.
|
|
113
|
+
1. Persist footer settings immediately instead of waiting for panel close.
|
|
114
|
+
2. Add configurable rotation settings and document the rotation behavior contract.
|
|
115
|
+
3. Broaden the current footer controller into a shared MultiCodex controller.
|
|
116
|
+
4. Improve imported-account labels by deriving email identity safely when possible.
|
|
132
117
|
|
|
133
118
|
## Behavior contract
|
|
134
119
|
|
|
@@ -136,7 +121,7 @@ The current runtime behavior is:
|
|
|
136
121
|
|
|
137
122
|
### Account selection priority
|
|
138
123
|
|
|
139
|
-
1. Use the manual account selected with `/multicodex
|
|
124
|
+
1. Use the manual account selected with `/multicodex use` when it is still available.
|
|
140
125
|
2. Otherwise clear the stale manual override and select the best available managed account.
|
|
141
126
|
3. Best-account selection prefers:
|
|
142
127
|
- untouched accounts with usage data
|
|
@@ -158,8 +143,8 @@ The current runtime behavior is:
|
|
|
158
143
|
|
|
159
144
|
### Manual override behavior
|
|
160
145
|
|
|
161
|
-
- `/multicodex
|
|
162
|
-
- `/multicodex
|
|
146
|
+
- `/multicodex use <identifier>` sets the manual account override immediately.
|
|
147
|
+
- `/multicodex use` with no argument opens the account picker and sets the selected manual override.
|
|
163
148
|
- Manual override is session-local state.
|
|
164
149
|
- Manual override clears automatically when the selected account is no longer available or when it hits quota during rotation.
|
|
165
150
|
|
package/account-manager.ts
CHANGED
|
@@ -195,6 +195,21 @@ export class AccountManager {
|
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
clearAllQuotaExhaustion(): number {
|
|
199
|
+
let cleared = 0;
|
|
200
|
+
for (const account of this.data.accounts) {
|
|
201
|
+
if (account.quotaExhaustedUntil) {
|
|
202
|
+
account.quotaExhaustedUntil = undefined;
|
|
203
|
+
cleared += 1;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
if (cleared > 0) {
|
|
207
|
+
this.save();
|
|
208
|
+
this.notifyStateChanged();
|
|
209
|
+
}
|
|
210
|
+
return cleared;
|
|
211
|
+
}
|
|
212
|
+
|
|
198
213
|
removeAccount(email: string): boolean {
|
|
199
214
|
const index = this.data.accounts.findIndex(
|
|
200
215
|
(account) => account.email === email,
|
package/commands.ts
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { promises as fs, constants as fsConstants } from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { loginOpenAICodex } from "@mariozechner/pi-ai/oauth";
|
|
2
5
|
import type {
|
|
3
6
|
ExtensionAPI,
|
|
@@ -5,6 +8,7 @@ import type {
|
|
|
5
8
|
} from "@mariozechner/pi-coding-agent";
|
|
6
9
|
import { getSelectListTheme } from "@mariozechner/pi-coding-agent";
|
|
7
10
|
import {
|
|
11
|
+
type AutocompleteItem,
|
|
8
12
|
Container,
|
|
9
13
|
Key,
|
|
10
14
|
matchesKey,
|
|
@@ -14,13 +18,164 @@ import {
|
|
|
14
18
|
import type { AccountManager } from "./account-manager";
|
|
15
19
|
import { openLoginInBrowser } from "./browser";
|
|
16
20
|
import type { createUsageStatusController } from "./status";
|
|
21
|
+
import { STORAGE_FILE } from "./storage";
|
|
17
22
|
import { formatResetAt, isUsageUntouched } from "./usage";
|
|
18
23
|
|
|
24
|
+
const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
|
|
25
|
+
const NO_ACCOUNTS_MESSAGE =
|
|
26
|
+
"No managed accounts found. Use /multicodex use <identifier> first.";
|
|
27
|
+
const HELP_TEXT =
|
|
28
|
+
"Usage: /multicodex [show|use [identifier]|footer|rotation|verify|path|reset [manual|quota|all]|help]";
|
|
29
|
+
const SUBCOMMANDS = [
|
|
30
|
+
"show",
|
|
31
|
+
"use",
|
|
32
|
+
"footer",
|
|
33
|
+
"rotation",
|
|
34
|
+
"verify",
|
|
35
|
+
"path",
|
|
36
|
+
"reset",
|
|
37
|
+
"help",
|
|
38
|
+
] as const;
|
|
39
|
+
const RESET_TARGETS = ["manual", "quota", "all"] as const;
|
|
40
|
+
|
|
41
|
+
type Subcommand = (typeof SUBCOMMANDS)[number];
|
|
42
|
+
type ResetTarget = (typeof RESET_TARGETS)[number];
|
|
43
|
+
|
|
44
|
+
type AccountPanelResult =
|
|
45
|
+
| { action: "select"; email: string }
|
|
46
|
+
| { action: "remove"; email: string }
|
|
47
|
+
| undefined;
|
|
48
|
+
|
|
19
49
|
function getErrorMessage(error: unknown): string {
|
|
20
50
|
if (error instanceof Error) return error.message;
|
|
21
51
|
return typeof error === "string" ? error : JSON.stringify(error);
|
|
22
52
|
}
|
|
23
53
|
|
|
54
|
+
function toAutocompleteItems(values: readonly string[]): AutocompleteItem[] {
|
|
55
|
+
return values.map((value) => ({ value, label: value }));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseCommandArgs(args: string): {
|
|
59
|
+
subcommand: string | undefined;
|
|
60
|
+
rest: string;
|
|
61
|
+
} {
|
|
62
|
+
const trimmed = args.trim();
|
|
63
|
+
if (!trimmed) {
|
|
64
|
+
return { subcommand: undefined, rest: "" };
|
|
65
|
+
}
|
|
66
|
+
const firstSpaceIndex = trimmed.indexOf(" ");
|
|
67
|
+
if (firstSpaceIndex < 0) {
|
|
68
|
+
return { subcommand: trimmed.toLowerCase(), rest: "" };
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
subcommand: trimmed.slice(0, firstSpaceIndex).toLowerCase(),
|
|
72
|
+
rest: trimmed.slice(firstSpaceIndex + 1).trim(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isSubcommand(value: string): value is Subcommand {
|
|
77
|
+
return SUBCOMMANDS.some((subcommand) => subcommand === value);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseResetTarget(value: string): ResetTarget | undefined {
|
|
81
|
+
if (value === "manual" || value === "quota" || value === "all") {
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
|
|
88
|
+
if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
|
|
89
|
+
return email;
|
|
90
|
+
}
|
|
91
|
+
return `${email} (Quota)`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatAccountStatusLine(
|
|
95
|
+
accountManager: AccountManager,
|
|
96
|
+
email: string,
|
|
97
|
+
): string {
|
|
98
|
+
const account = accountManager.getAccount(email);
|
|
99
|
+
if (!account) return email;
|
|
100
|
+
const usage = accountManager.getCachedUsage(account.email);
|
|
101
|
+
const active = accountManager.getActiveAccount();
|
|
102
|
+
const manual = accountManager.getManualAccount();
|
|
103
|
+
const quotaHit =
|
|
104
|
+
account.quotaExhaustedUntil && account.quotaExhaustedUntil > Date.now();
|
|
105
|
+
const untouched = isUsageUntouched(usage) ? "untouched" : null;
|
|
106
|
+
const imported = account.importSource ? "imported" : null;
|
|
107
|
+
const tags = [
|
|
108
|
+
active?.email === account.email ? "active" : null,
|
|
109
|
+
manual?.email === account.email ? "manual" : null,
|
|
110
|
+
quotaHit ? "quota" : null,
|
|
111
|
+
untouched,
|
|
112
|
+
imported,
|
|
113
|
+
]
|
|
114
|
+
.filter(Boolean)
|
|
115
|
+
.join(", ");
|
|
116
|
+
const suffix = tags ? ` (${tags})` : "";
|
|
117
|
+
const primaryUsed = usage?.primary?.usedPercent;
|
|
118
|
+
const secondaryUsed = usage?.secondary?.usedPercent;
|
|
119
|
+
const primaryReset = usage?.primary?.resetAt;
|
|
120
|
+
const secondaryReset = usage?.secondary?.resetAt;
|
|
121
|
+
const primaryLabel =
|
|
122
|
+
primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
|
|
123
|
+
const secondaryLabel =
|
|
124
|
+
secondaryUsed === undefined ? "unknown" : `${Math.round(secondaryUsed)}%`;
|
|
125
|
+
const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
|
|
126
|
+
return `${account.email}${suffix} - ${usageSummary}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getSubcommandCompletions(prefix: string): AutocompleteItem[] | null {
|
|
130
|
+
const matches = SUBCOMMANDS.filter((value) => value.startsWith(prefix));
|
|
131
|
+
return matches.length > 0 ? toAutocompleteItems(matches) : null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function getUseCompletions(
|
|
135
|
+
prefix: string,
|
|
136
|
+
accountManager: AccountManager,
|
|
137
|
+
): AutocompleteItem[] | null {
|
|
138
|
+
const matches = accountManager
|
|
139
|
+
.getAccounts()
|
|
140
|
+
.map((account) => account.email)
|
|
141
|
+
.filter((value) => value.startsWith(prefix));
|
|
142
|
+
if (matches.length === 0) return null;
|
|
143
|
+
return matches.map((value) => ({ value: `use ${value}`, label: value }));
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function getResetCompletions(prefix: string): AutocompleteItem[] | null {
|
|
147
|
+
const matches = RESET_TARGETS.filter((value) => value.startsWith(prefix));
|
|
148
|
+
if (matches.length === 0) return null;
|
|
149
|
+
return matches.map((value) => ({ value: `reset ${value}`, label: value }));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function getCommandCompletions(
|
|
153
|
+
argumentPrefix: string,
|
|
154
|
+
accountManager: AccountManager,
|
|
155
|
+
): AutocompleteItem[] | null {
|
|
156
|
+
const trimmedStart = argumentPrefix.trimStart();
|
|
157
|
+
if (!trimmedStart) {
|
|
158
|
+
return toAutocompleteItems(SUBCOMMANDS);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const firstSpaceIndex = trimmedStart.indexOf(" ");
|
|
162
|
+
if (firstSpaceIndex < 0) {
|
|
163
|
+
return getSubcommandCompletions(trimmedStart.toLowerCase());
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const subcommand = trimmedStart.slice(0, firstSpaceIndex).toLowerCase();
|
|
167
|
+
const rest = trimmedStart.slice(firstSpaceIndex + 1).trimStart();
|
|
168
|
+
|
|
169
|
+
if (subcommand === "use") {
|
|
170
|
+
return getUseCompletions(rest, accountManager);
|
|
171
|
+
}
|
|
172
|
+
if (subcommand === "reset") {
|
|
173
|
+
return getResetCompletions(rest);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
24
179
|
async function loginAndActivateAccount(
|
|
25
180
|
pi: ExtensionAPI,
|
|
26
181
|
ctx: ExtensionCommandContext,
|
|
@@ -76,18 +231,6 @@ async function useOrLoginAccount(
|
|
|
76
231
|
await loginAndActivateAccount(pi, ctx, accountManager, identifier);
|
|
77
232
|
}
|
|
78
233
|
|
|
79
|
-
type AccountPanelResult =
|
|
80
|
-
| { action: "select"; email: string }
|
|
81
|
-
| { action: "remove"; email: string }
|
|
82
|
-
| undefined;
|
|
83
|
-
|
|
84
|
-
function getAccountLabel(email: string, quotaExhaustedUntil?: number): string {
|
|
85
|
-
if (!quotaExhaustedUntil || quotaExhaustedUntil <= Date.now()) {
|
|
86
|
-
return email;
|
|
87
|
-
}
|
|
88
|
-
return `${email} (Quota)`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
234
|
async function openAccountSelectionPanel(
|
|
92
235
|
ctx: ExtensionCommandContext,
|
|
93
236
|
accountManager: AccountManager,
|
|
@@ -143,10 +286,7 @@ async function openAccountSelectionFlow(
|
|
|
143
286
|
while (true) {
|
|
144
287
|
const accounts = accountManager.getAccounts();
|
|
145
288
|
if (accounts.length === 0) {
|
|
146
|
-
ctx.ui.notify(
|
|
147
|
-
"No managed accounts found. Use /login or /multicodex-use <identifier> first.",
|
|
148
|
-
"warning",
|
|
149
|
-
);
|
|
289
|
+
ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
|
|
150
290
|
return;
|
|
151
291
|
}
|
|
152
292
|
|
|
@@ -179,90 +319,341 @@ async function openAccountSelectionFlow(
|
|
|
179
319
|
}
|
|
180
320
|
}
|
|
181
321
|
|
|
322
|
+
async function runShowSubcommand(
|
|
323
|
+
ctx: ExtensionCommandContext,
|
|
324
|
+
accountManager: AccountManager,
|
|
325
|
+
): Promise<void> {
|
|
326
|
+
await accountManager.syncImportedOpenAICodexAuth();
|
|
327
|
+
await accountManager.refreshUsageForAllAccounts();
|
|
328
|
+
const accounts = accountManager.getAccounts();
|
|
329
|
+
if (accounts.length === 0) {
|
|
330
|
+
ctx.ui.notify(NO_ACCOUNTS_MESSAGE, "warning");
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (!ctx.hasUI) {
|
|
335
|
+
const active = accountManager.getActiveAccount()?.email ?? "none";
|
|
336
|
+
const manual = accountManager.getManualAccount()?.email ?? "none";
|
|
337
|
+
ctx.ui.notify(
|
|
338
|
+
`multicodex: accounts=${accounts.length} active=${active} manual=${manual}`,
|
|
339
|
+
"info",
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const options = accounts.map((account) =>
|
|
345
|
+
formatAccountStatusLine(accountManager, account.email),
|
|
346
|
+
);
|
|
347
|
+
await ctx.ui.select("MultiCodex Accounts", options);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function runFooterSubcommand(
|
|
351
|
+
ctx: ExtensionCommandContext,
|
|
352
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
353
|
+
): Promise<void> {
|
|
354
|
+
if (!ctx.hasUI) {
|
|
355
|
+
await statusController.loadPreferences(ctx);
|
|
356
|
+
const preferences = statusController.getPreferences();
|
|
357
|
+
ctx.ui.notify(
|
|
358
|
+
`footer: usageMode=${preferences.usageMode} resetWindow=${preferences.resetWindow} showAccount=${preferences.showAccount ? "on" : "off"} showReset=${preferences.showReset ? "on" : "off"} order=${preferences.order}`,
|
|
359
|
+
"info",
|
|
360
|
+
);
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
await statusController.openPreferencesPanel(ctx);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function runRotationSubcommand(
|
|
368
|
+
ctx: ExtensionCommandContext,
|
|
369
|
+
): Promise<void> {
|
|
370
|
+
const lines = [
|
|
371
|
+
"Rotation settings are not configurable yet.",
|
|
372
|
+
"Current policy: manual account, then untouched accounts, then earliest weekly reset, then random fallback.",
|
|
373
|
+
"Quota cooldown uses next known reset time, with 1 hour fallback when unknown.",
|
|
374
|
+
];
|
|
375
|
+
|
|
376
|
+
if (!ctx.hasUI) {
|
|
377
|
+
ctx.ui.notify(
|
|
378
|
+
"rotation: manual->untouched->earliest-weekly-reset->random, cooldown=next-reset-or-1h",
|
|
379
|
+
"info",
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
await ctx.ui.select("MultiCodex Rotation", lines);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function isWritableDirectoryFor(filePath: string): Promise<boolean> {
|
|
388
|
+
try {
|
|
389
|
+
const directory = path.dirname(filePath);
|
|
390
|
+
await fs.mkdir(directory, { recursive: true });
|
|
391
|
+
await fs.access(directory, fsConstants.R_OK | fsConstants.W_OK);
|
|
392
|
+
return true;
|
|
393
|
+
} catch {
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function runVerifySubcommand(
|
|
399
|
+
ctx: ExtensionCommandContext,
|
|
400
|
+
accountManager: AccountManager,
|
|
401
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
const storageWritable = await isWritableDirectoryFor(STORAGE_FILE);
|
|
404
|
+
const settingsWritable = await isWritableDirectoryFor(SETTINGS_FILE);
|
|
405
|
+
const authImported = await accountManager.syncImportedOpenAICodexAuth();
|
|
406
|
+
await statusController.loadPreferences(ctx);
|
|
407
|
+
const accounts = accountManager.getAccounts().length;
|
|
408
|
+
const active = accountManager.getActiveAccount()?.email ?? "none";
|
|
409
|
+
const ok = storageWritable && settingsWritable;
|
|
410
|
+
|
|
411
|
+
if (!ctx.hasUI) {
|
|
412
|
+
ctx.ui.notify(
|
|
413
|
+
`verify: ${ok ? "PASS" : "WARN"} storage=${storageWritable ? "ok" : "fail"} settings=${settingsWritable ? "ok" : "fail"} accounts=${accounts} active=${active} authImport=${authImported ? "updated" : "unchanged"}`,
|
|
414
|
+
ok ? "info" : "warning",
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const lines = [
|
|
420
|
+
`storage directory writable: ${storageWritable ? "yes" : "no"}`,
|
|
421
|
+
`settings directory writable: ${settingsWritable ? "yes" : "no"}`,
|
|
422
|
+
`managed accounts: ${accounts}`,
|
|
423
|
+
`active account: ${active}`,
|
|
424
|
+
`auth import changed state: ${authImported ? "yes" : "no"}`,
|
|
425
|
+
];
|
|
426
|
+
await ctx.ui.select(`MultiCodex Verify (${ok ? "PASS" : "WARN"})`, lines);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
async function runPathSubcommand(ctx: ExtensionCommandContext): Promise<void> {
|
|
430
|
+
if (!ctx.hasUI) {
|
|
431
|
+
ctx.ui.notify(
|
|
432
|
+
`paths: storage=${STORAGE_FILE} settings=${SETTINGS_FILE}`,
|
|
433
|
+
"info",
|
|
434
|
+
);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
await ctx.ui.select("MultiCodex Paths", [
|
|
439
|
+
`Managed account storage: ${STORAGE_FILE}`,
|
|
440
|
+
`Extension settings: ${SETTINGS_FILE}`,
|
|
441
|
+
]);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
async function chooseResetTarget(
|
|
445
|
+
ctx: ExtensionCommandContext,
|
|
446
|
+
argument: string,
|
|
447
|
+
): Promise<ResetTarget | undefined> {
|
|
448
|
+
const explicitTarget = parseResetTarget(argument.toLowerCase());
|
|
449
|
+
if (explicitTarget) {
|
|
450
|
+
return explicitTarget;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (argument) {
|
|
454
|
+
ctx.ui.notify(
|
|
455
|
+
"Unknown reset target. Use: /multicodex reset [manual|quota|all]",
|
|
456
|
+
"warning",
|
|
457
|
+
);
|
|
458
|
+
return undefined;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (!ctx.hasUI) {
|
|
462
|
+
return "all";
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const options = [
|
|
466
|
+
"manual - clear manual account override",
|
|
467
|
+
"quota - clear quota cooldown markers",
|
|
468
|
+
"all - clear manual override and quota cooldown markers",
|
|
469
|
+
];
|
|
470
|
+
const selected = await ctx.ui.select("Reset MultiCodex State", options);
|
|
471
|
+
if (!selected) return undefined;
|
|
472
|
+
if (selected.startsWith("manual")) return "manual";
|
|
473
|
+
if (selected.startsWith("quota")) return "quota";
|
|
474
|
+
return "all";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
async function runResetSubcommand(
|
|
478
|
+
ctx: ExtensionCommandContext,
|
|
479
|
+
accountManager: AccountManager,
|
|
480
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
481
|
+
rest: string,
|
|
482
|
+
): Promise<void> {
|
|
483
|
+
const target = await chooseResetTarget(ctx, rest);
|
|
484
|
+
if (!target) return;
|
|
485
|
+
|
|
486
|
+
if (target === "all" && ctx.hasUI) {
|
|
487
|
+
const confirmed = await ctx.ui.confirm(
|
|
488
|
+
"Reset MultiCodex state",
|
|
489
|
+
"Clear manual account override and all quota cooldown markers?",
|
|
490
|
+
);
|
|
491
|
+
if (!confirmed) return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const hadManual = accountManager.hasManualAccount();
|
|
495
|
+
if (target === "manual" || target === "all") {
|
|
496
|
+
accountManager.clearManualAccount();
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
let clearedQuota = 0;
|
|
500
|
+
if (target === "quota" || target === "all") {
|
|
501
|
+
clearedQuota = accountManager.clearAllQuotaExhaustion();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const manualCleared = hadManual && !accountManager.hasManualAccount();
|
|
505
|
+
ctx.ui.notify(
|
|
506
|
+
`reset: target=${target} manualCleared=${manualCleared ? "yes" : "no"} quotaCleared=${clearedQuota}`,
|
|
507
|
+
"info",
|
|
508
|
+
);
|
|
509
|
+
await statusController.refreshFor(ctx);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function runHelpSubcommand(ctx: ExtensionCommandContext): void {
|
|
513
|
+
ctx.ui.notify(HELP_TEXT, "info");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function runUseSubcommand(
|
|
517
|
+
pi: ExtensionAPI,
|
|
518
|
+
ctx: ExtensionCommandContext,
|
|
519
|
+
accountManager: AccountManager,
|
|
520
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
521
|
+
rest: string,
|
|
522
|
+
): Promise<void> {
|
|
523
|
+
await accountManager.syncImportedOpenAICodexAuth();
|
|
524
|
+
|
|
525
|
+
if (rest) {
|
|
526
|
+
await useOrLoginAccount(pi, ctx, accountManager, rest);
|
|
527
|
+
await statusController.refreshFor(ctx);
|
|
528
|
+
return;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (!ctx.hasUI) {
|
|
532
|
+
ctx.ui.notify(
|
|
533
|
+
"/multicodex use requires an identifier in non-interactive mode.",
|
|
534
|
+
"warning",
|
|
535
|
+
);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
await openAccountSelectionFlow(ctx, accountManager, statusController);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
async function runSubcommand(
|
|
543
|
+
subcommand: Subcommand,
|
|
544
|
+
rest: string,
|
|
545
|
+
pi: ExtensionAPI,
|
|
546
|
+
ctx: ExtensionCommandContext,
|
|
547
|
+
accountManager: AccountManager,
|
|
548
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
549
|
+
): Promise<void> {
|
|
550
|
+
if (subcommand === "show") {
|
|
551
|
+
await runShowSubcommand(ctx, accountManager);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
if (subcommand === "use") {
|
|
555
|
+
await runUseSubcommand(pi, ctx, accountManager, statusController, rest);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
if (subcommand === "footer") {
|
|
559
|
+
await runFooterSubcommand(ctx, statusController);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (subcommand === "rotation") {
|
|
563
|
+
await runRotationSubcommand(ctx);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (subcommand === "verify") {
|
|
567
|
+
await runVerifySubcommand(ctx, accountManager, statusController);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (subcommand === "path") {
|
|
571
|
+
await runPathSubcommand(ctx);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (subcommand === "reset") {
|
|
575
|
+
await runResetSubcommand(ctx, accountManager, statusController, rest);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
runHelpSubcommand(ctx);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
async function openMainPanel(
|
|
583
|
+
pi: ExtensionAPI,
|
|
584
|
+
ctx: ExtensionCommandContext,
|
|
585
|
+
accountManager: AccountManager,
|
|
586
|
+
statusController: ReturnType<typeof createUsageStatusController>,
|
|
587
|
+
): Promise<void> {
|
|
588
|
+
const actions = [
|
|
589
|
+
"use: select, activate, or remove managed account",
|
|
590
|
+
"show: managed account and usage summary",
|
|
591
|
+
"footer: footer settings panel",
|
|
592
|
+
"rotation: current rotation behavior",
|
|
593
|
+
"verify: runtime health checks",
|
|
594
|
+
"path: storage and settings locations",
|
|
595
|
+
"reset: clear manual or quota state",
|
|
596
|
+
"help: command usage",
|
|
597
|
+
];
|
|
598
|
+
|
|
599
|
+
const selected = await ctx.ui.select("MultiCodex", actions);
|
|
600
|
+
if (!selected) return;
|
|
601
|
+
|
|
602
|
+
const subcommandText = selected.split(":")[0]?.trim() ?? "";
|
|
603
|
+
if (!isSubcommand(subcommandText)) {
|
|
604
|
+
ctx.ui.notify(`Unknown subcommand: ${subcommandText}`, "warning");
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
await runSubcommand(
|
|
608
|
+
subcommandText,
|
|
609
|
+
"",
|
|
610
|
+
pi,
|
|
611
|
+
ctx,
|
|
612
|
+
accountManager,
|
|
613
|
+
statusController,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
182
617
|
export function registerCommands(
|
|
183
618
|
pi: ExtensionAPI,
|
|
184
619
|
accountManager: AccountManager,
|
|
185
620
|
statusController: ReturnType<typeof createUsageStatusController>,
|
|
186
621
|
): void {
|
|
187
|
-
pi.registerCommand("multicodex
|
|
188
|
-
description:
|
|
189
|
-
|
|
622
|
+
pi.registerCommand("multicodex", {
|
|
623
|
+
description: "Manage MultiCodex accounts, rotation, and footer settings",
|
|
624
|
+
getArgumentCompletions: (argumentPrefix: string) =>
|
|
625
|
+
getCommandCompletions(argumentPrefix, accountManager),
|
|
190
626
|
handler: async (
|
|
191
627
|
args: string,
|
|
192
628
|
ctx: ExtensionCommandContext,
|
|
193
629
|
): Promise<void> => {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
|
|
197
|
-
|
|
630
|
+
const parsed = parseCommandArgs(args);
|
|
631
|
+
if (!parsed.subcommand) {
|
|
632
|
+
if (!ctx.hasUI) {
|
|
633
|
+
ctx.ui.notify(
|
|
634
|
+
"/multicodex requires a subcommand in non-interactive mode. Use /multicodex help.",
|
|
635
|
+
"warning",
|
|
636
|
+
);
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
await openMainPanel(pi, ctx, accountManager, statusController);
|
|
198
640
|
return;
|
|
199
641
|
}
|
|
200
642
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
pi.registerCommand("multicodex-status", {
|
|
207
|
-
description: "Show all Codex accounts and active status",
|
|
208
|
-
handler: async (
|
|
209
|
-
_args: string,
|
|
210
|
-
ctx: ExtensionCommandContext,
|
|
211
|
-
): Promise<void> => {
|
|
212
|
-
await accountManager.syncImportedOpenAICodexAuth();
|
|
213
|
-
await accountManager.refreshUsageForAllAccounts();
|
|
214
|
-
const accounts = accountManager.getAccounts();
|
|
215
|
-
if (accounts.length === 0) {
|
|
216
|
-
ctx.ui.notify(
|
|
217
|
-
"No managed accounts found. Use /login or /multicodex-use <identifier> first.",
|
|
218
|
-
"warning",
|
|
219
|
-
);
|
|
643
|
+
if (!isSubcommand(parsed.subcommand)) {
|
|
644
|
+
ctx.ui.notify(`Unknown subcommand: ${parsed.subcommand}`, "warning");
|
|
645
|
+
runHelpSubcommand(ctx);
|
|
220
646
|
return;
|
|
221
647
|
}
|
|
222
648
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const imported = account.importSource ? "imported" : null;
|
|
232
|
-
const tags = [
|
|
233
|
-
isActive ? "active" : null,
|
|
234
|
-
quotaHit ? "quota" : null,
|
|
235
|
-
untouched,
|
|
236
|
-
imported,
|
|
237
|
-
]
|
|
238
|
-
.filter(Boolean)
|
|
239
|
-
.join(", ");
|
|
240
|
-
const suffix = tags ? ` (${tags})` : "";
|
|
241
|
-
const primaryUsed = usage?.primary?.usedPercent;
|
|
242
|
-
const secondaryUsed = usage?.secondary?.usedPercent;
|
|
243
|
-
const primaryReset = usage?.primary?.resetAt;
|
|
244
|
-
const secondaryReset = usage?.secondary?.resetAt;
|
|
245
|
-
const primaryLabel =
|
|
246
|
-
primaryUsed === undefined ? "unknown" : `${Math.round(primaryUsed)}%`;
|
|
247
|
-
const secondaryLabel =
|
|
248
|
-
secondaryUsed === undefined
|
|
249
|
-
? "unknown"
|
|
250
|
-
: `${Math.round(secondaryUsed)}%`;
|
|
251
|
-
const usageSummary = `5h ${primaryLabel} reset:${formatResetAt(primaryReset)} | weekly ${secondaryLabel} reset:${formatResetAt(secondaryReset)}`;
|
|
252
|
-
return `${isActive ? "•" : " "} ${account.email}${suffix} - ${usageSummary}`;
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
await ctx.ui.select("MultiCodex Accounts", options);
|
|
256
|
-
},
|
|
257
|
-
});
|
|
258
|
-
|
|
259
|
-
pi.registerCommand("multicodex-footer", {
|
|
260
|
-
description: "Configure the MultiCodex usage footer",
|
|
261
|
-
handler: async (
|
|
262
|
-
_args: string,
|
|
263
|
-
ctx: ExtensionCommandContext,
|
|
264
|
-
): Promise<void> => {
|
|
265
|
-
await statusController.openPreferencesPanel(ctx);
|
|
649
|
+
await runSubcommand(
|
|
650
|
+
parsed.subcommand,
|
|
651
|
+
parsed.rest,
|
|
652
|
+
pi,
|
|
653
|
+
ctx,
|
|
654
|
+
accountManager,
|
|
655
|
+
statusController,
|
|
656
|
+
);
|
|
266
657
|
},
|
|
267
658
|
});
|
|
268
659
|
}
|
package/package.json
CHANGED
package/stream-wrapper.ts
CHANGED
|
@@ -101,7 +101,7 @@ export function createStreamWrapper(
|
|
|
101
101
|
}
|
|
102
102
|
if (!account) {
|
|
103
103
|
throw new Error(
|
|
104
|
-
"No available Multicodex accounts. Please use /multicodex
|
|
104
|
+
"No available Multicodex accounts. Please use /multicodex use <identifier>.",
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
|