@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@victor-software-house/pi-multicodex",
3
- "version": "1.0.3",
3
+ "version": "1.0.6",
4
4
  "description": "Codex account rotation extension for pi",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,21 @@
30
30
  },
31
31
  "author": "Victor",
32
32
  "files": [
33
+ "abort-utils.ts",
34
+ "account-manager.ts",
35
+ "browser.ts",
36
+ "commands.ts",
37
+ "extension.ts",
38
+ "hooks.ts",
33
39
  "index.ts",
40
+ "provider.ts",
41
+ "quota.ts",
42
+ "selection.ts",
43
+ "status.ts",
44
+ "storage.ts",
45
+ "stream-wrapper.ts",
46
+ "usage-client.ts",
47
+ "usage.ts",
34
48
  "README.md",
35
49
  "LICENSE",
36
50
  "assets/**"
@@ -41,13 +55,16 @@
41
55
  "tsgo": "tsgo -p tsconfig.json",
42
56
  "check": "pnpm lint && pnpm tsgo && pnpm test",
43
57
  "pack:dry": "npm pack --dry-run",
44
- "publish:dry": "bun ./scripts/publish.ts --dry-run",
45
- "publish:release": "bun ./scripts/publish.ts"
58
+ "release:dry": "bun ./scripts/publish.ts --dry-run",
59
+ "release:prepare": "bun ./scripts/publish.ts"
46
60
  },
47
61
  "peerDependencies": {
48
62
  "@mariozechner/pi-ai": "*",
49
63
  "@mariozechner/pi-coding-agent": "*"
50
64
  },
65
+ "dependencies": {
66
+ "@mariozechner/pi-tui": "^0.58.1"
67
+ },
51
68
  "devDependencies": {
52
69
  "@biomejs/biome": "^2.4.7",
53
70
  "@mariozechner/pi-ai": "^0.58.1",
package/provider.ts ADDED
@@ -0,0 +1,75 @@
1
+ import {
2
+ type Api,
3
+ type AssistantMessageEventStream,
4
+ type Context,
5
+ getApiProvider,
6
+ getModels,
7
+ type Model,
8
+ type SimpleStreamOptions,
9
+ } from "@mariozechner/pi-ai";
10
+ import type { AccountManager } from "./account-manager";
11
+ import { createStreamWrapper } from "./stream-wrapper";
12
+
13
+ export const PROVIDER_ID = "multicodex";
14
+
15
+ export interface ProviderModelDef {
16
+ id: string;
17
+ name: string;
18
+ reasoning: boolean;
19
+ input: ("text" | "image")[];
20
+ cost: {
21
+ input: number;
22
+ output: number;
23
+ cacheRead: number;
24
+ cacheWrite: number;
25
+ };
26
+ contextWindow: number;
27
+ maxTokens: number;
28
+ }
29
+
30
+ export function getOpenAICodexMirror(): {
31
+ baseUrl: string;
32
+ models: ProviderModelDef[];
33
+ } {
34
+ const sourceModels = getModels("openai-codex");
35
+ return {
36
+ baseUrl: sourceModels[0]?.baseUrl || "https://chatgpt.com/backend-api",
37
+ models: sourceModels.map((model) => ({
38
+ id: model.id,
39
+ name: model.name,
40
+ reasoning: model.reasoning,
41
+ input: model.input,
42
+ cost: model.cost,
43
+ contextWindow: model.contextWindow,
44
+ maxTokens: model.maxTokens,
45
+ })),
46
+ };
47
+ }
48
+
49
+ export function buildMulticodexProviderConfig(accountManager: AccountManager): {
50
+ baseUrl: string;
51
+ apiKey: string;
52
+ api: "openai-codex-responses";
53
+ streamSimple: (
54
+ model: Model<Api>,
55
+ context: Context,
56
+ options?: SimpleStreamOptions,
57
+ ) => AssistantMessageEventStream;
58
+ models: ProviderModelDef[];
59
+ } {
60
+ const mirror = getOpenAICodexMirror();
61
+ const baseProvider = getApiProvider("openai-codex-responses");
62
+ if (!baseProvider) {
63
+ throw new Error(
64
+ "OpenAI Codex provider not available. Please update pi to include openai-codex support.",
65
+ );
66
+ }
67
+
68
+ return {
69
+ baseUrl: mirror.baseUrl,
70
+ apiKey: "managed-by-extension",
71
+ api: "openai-codex-responses",
72
+ streamSimple: createStreamWrapper(accountManager, baseProvider),
73
+ models: mirror.models,
74
+ };
75
+ }
package/quota.ts ADDED
@@ -0,0 +1,5 @@
1
+ export function isQuotaErrorMessage(message: string): boolean {
2
+ return /\b429\b|quota|usage limit|rate.?limit|too many requests|limit reached/i.test(
3
+ message,
4
+ );
5
+ }
package/selection.ts ADDED
@@ -0,0 +1,69 @@
1
+ import type { Account } from "./storage";
2
+ import {
3
+ type CodexUsageSnapshot,
4
+ getWeeklyResetAt,
5
+ isUsageUntouched,
6
+ } from "./usage";
7
+
8
+ export function isAccountAvailable(account: Account, now: number): boolean {
9
+ return !account.quotaExhaustedUntil || account.quotaExhaustedUntil <= now;
10
+ }
11
+
12
+ function pickRandomAccount(accounts: Account[]): Account | undefined {
13
+ if (accounts.length === 0) return undefined;
14
+ return accounts[Math.floor(Math.random() * accounts.length)];
15
+ }
16
+
17
+ function pickEarliestWeeklyResetAccount(
18
+ accounts: Account[],
19
+ usageByEmail: Map<string, CodexUsageSnapshot>,
20
+ ): Account | undefined {
21
+ const candidates = accounts
22
+ .map((account) => ({
23
+ account,
24
+ resetAt: getWeeklyResetAt(usageByEmail.get(account.email)),
25
+ }))
26
+ .filter(
27
+ (entry): entry is { account: Account; resetAt: number } =>
28
+ typeof entry.resetAt === "number",
29
+ )
30
+ .sort((a, b) => a.resetAt - b.resetAt);
31
+
32
+ return candidates[0]?.account;
33
+ }
34
+
35
+ export function pickBestAccount(
36
+ accounts: Account[],
37
+ usageByEmail: Map<string, CodexUsageSnapshot>,
38
+ options?: { excludeEmails?: Set<string>; now?: number },
39
+ ): Account | undefined {
40
+ const now = options?.now ?? Date.now();
41
+ const available = accounts.filter(
42
+ (account) =>
43
+ isAccountAvailable(account, now) &&
44
+ !options?.excludeEmails?.has(account.email),
45
+ );
46
+ if (available.length === 0) return undefined;
47
+
48
+ const withUsage = available.filter((account) =>
49
+ usageByEmail.has(account.email),
50
+ );
51
+ const untouched = withUsage.filter((account) =>
52
+ isUsageUntouched(usageByEmail.get(account.email)),
53
+ );
54
+
55
+ if (untouched.length > 0) {
56
+ return (
57
+ pickEarliestWeeklyResetAccount(untouched, usageByEmail) ??
58
+ pickRandomAccount(untouched)
59
+ );
60
+ }
61
+
62
+ const earliestWeeklyReset = pickEarliestWeeklyResetAccount(
63
+ withUsage,
64
+ usageByEmail,
65
+ );
66
+ if (earliestWeeklyReset) return earliestWeeklyReset;
67
+
68
+ return pickRandomAccount(available);
69
+ }
package/status.ts ADDED
@@ -0,0 +1,462 @@
1
+ import { promises as fs } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import type { Api, Model } from "@mariozechner/pi-ai";
5
+ import type {
6
+ ExtensionCommandContext,
7
+ ExtensionContext,
8
+ } from "@mariozechner/pi-coding-agent";
9
+ import { getSettingsListTheme } from "@mariozechner/pi-coding-agent";
10
+ import {
11
+ Container,
12
+ type SettingItem,
13
+ SettingsList,
14
+ Text,
15
+ } from "@mariozechner/pi-tui";
16
+ import type { AccountManager } from "./account-manager";
17
+ import { PROVIDER_ID } from "./provider";
18
+ import type { CodexUsageSnapshot } from "./usage";
19
+
20
+ const STATUS_KEY = "multicodex-usage";
21
+ const SETTINGS_KEY = "pi-multicodex";
22
+ const SETTINGS_FILE = path.join(os.homedir(), ".pi", "agent", "settings.json");
23
+ const REFRESH_INTERVAL_MS = 60_000;
24
+ const UNKNOWN_PERCENT = "--";
25
+ const FIVE_HOUR_LABEL = "5h:";
26
+ const SEVEN_DAY_LABEL = "7d:";
27
+
28
+ type MaybeModel = Model<Api> | undefined;
29
+ export type PercentDisplayMode = "left" | "used";
30
+ export type ResetWindowMode = "5h" | "7d" | "both";
31
+ export type StatusOrder = "account-first" | "usage-first";
32
+
33
+ export interface FooterPreferences {
34
+ usageMode: PercentDisplayMode;
35
+ resetWindow: ResetWindowMode;
36
+ showAccount: boolean;
37
+ showReset: boolean;
38
+ order: StatusOrder;
39
+ }
40
+
41
+ const DEFAULT_PREFERENCES: FooterPreferences = {
42
+ usageMode: "left",
43
+ resetWindow: "7d",
44
+ showAccount: true,
45
+ showReset: true,
46
+ order: "account-first",
47
+ };
48
+
49
+ function asObject(value: unknown): Record<string, unknown> | null {
50
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
51
+ return value as Record<string, unknown>;
52
+ }
53
+
54
+ function isPercentDisplayMode(value: unknown): value is PercentDisplayMode {
55
+ return value === "left" || value === "used";
56
+ }
57
+
58
+ function isResetWindowMode(value: unknown): value is ResetWindowMode {
59
+ return value === "5h" || value === "7d" || value === "both";
60
+ }
61
+
62
+ function isStatusOrder(value: unknown): value is StatusOrder {
63
+ return value === "account-first" || value === "usage-first";
64
+ }
65
+
66
+ function normalizePreferences(value: unknown): FooterPreferences {
67
+ const record = asObject(value);
68
+ return {
69
+ usageMode: isPercentDisplayMode(record?.usageMode)
70
+ ? record.usageMode
71
+ : DEFAULT_PREFERENCES.usageMode,
72
+ resetWindow: isResetWindowMode(record?.resetWindow)
73
+ ? record.resetWindow
74
+ : DEFAULT_PREFERENCES.resetWindow,
75
+ showAccount:
76
+ typeof record?.showAccount === "boolean"
77
+ ? record.showAccount
78
+ : DEFAULT_PREFERENCES.showAccount,
79
+ showReset:
80
+ typeof record?.showReset === "boolean"
81
+ ? record.showReset
82
+ : DEFAULT_PREFERENCES.showReset,
83
+ order: isStatusOrder(record?.order)
84
+ ? record.order
85
+ : DEFAULT_PREFERENCES.order,
86
+ };
87
+ }
88
+
89
+ async function readSettingsFile(): Promise<Record<string, unknown>> {
90
+ try {
91
+ const raw = await fs.readFile(SETTINGS_FILE, "utf8");
92
+ const parsed = JSON.parse(raw) as unknown;
93
+ return asObject(parsed) ?? {};
94
+ } catch (error) {
95
+ const withCode = error as Error & { code?: string };
96
+ if (withCode.code === "ENOENT") return {};
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ async function writeSettingsFile(
102
+ settings: Record<string, unknown>,
103
+ ): Promise<void> {
104
+ await fs.mkdir(path.dirname(SETTINGS_FILE), { recursive: true });
105
+ await fs.writeFile(
106
+ SETTINGS_FILE,
107
+ `${JSON.stringify(settings, null, 2)}\n`,
108
+ "utf8",
109
+ );
110
+ }
111
+
112
+ export async function loadFooterPreferences(): Promise<FooterPreferences> {
113
+ const settings = await readSettingsFile();
114
+ return normalizePreferences(settings[SETTINGS_KEY]);
115
+ }
116
+
117
+ export async function persistFooterPreferences(
118
+ preferences: FooterPreferences,
119
+ ): Promise<void> {
120
+ const settings = await readSettingsFile();
121
+ settings[SETTINGS_KEY] = {
122
+ ...asObject(settings[SETTINGS_KEY]),
123
+ ...preferences,
124
+ };
125
+ await writeSettingsFile(settings);
126
+ }
127
+
128
+ function clampPercent(value: number): number {
129
+ return Math.min(100, Math.max(0, value));
130
+ }
131
+
132
+ function usedToDisplayPercent(
133
+ value: number | undefined,
134
+ mode: PercentDisplayMode,
135
+ ): number | undefined {
136
+ if (typeof value !== "number" || Number.isNaN(value)) return undefined;
137
+ const left = clampPercent(100 - value);
138
+ return mode === "left" ? left : clampPercent(100 - left);
139
+ }
140
+
141
+ function formatPercent(
142
+ ctx: ExtensionContext,
143
+ displayPercent: number | undefined,
144
+ mode: PercentDisplayMode,
145
+ ): string {
146
+ if (typeof displayPercent !== "number" || Number.isNaN(displayPercent)) {
147
+ return ctx.ui.theme.fg("muted", UNKNOWN_PERCENT);
148
+ }
149
+
150
+ const text = `${Math.round(clampPercent(displayPercent))}% ${mode}`;
151
+ if (mode === "left") {
152
+ if (displayPercent <= 10) return ctx.ui.theme.fg("error", text);
153
+ if (displayPercent <= 25) return ctx.ui.theme.fg("warning", text);
154
+ return ctx.ui.theme.fg("success", text);
155
+ }
156
+
157
+ if (displayPercent >= 90) return ctx.ui.theme.fg("error", text);
158
+ if (displayPercent >= 75) return ctx.ui.theme.fg("warning", text);
159
+ return ctx.ui.theme.fg("success", text);
160
+ }
161
+
162
+ function formatResetCountdown(resetAt: number | undefined): string | undefined {
163
+ if (typeof resetAt !== "number" || Number.isNaN(resetAt)) return undefined;
164
+ const totalSeconds = Math.max(0, Math.round((resetAt - Date.now()) / 1000));
165
+ const days = Math.floor(totalSeconds / 86_400);
166
+ const hours = Math.floor((totalSeconds % 86_400) / 3_600);
167
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
168
+ const seconds = totalSeconds % 60;
169
+ if (days > 0) return `${days}d${hours}h`;
170
+ if (hours > 0) return `${hours}h${minutes}m`;
171
+ if (minutes > 0) return `${minutes}m`;
172
+ return `${seconds}s`;
173
+ }
174
+
175
+ export function isManagedModel(model: MaybeModel): boolean {
176
+ return model?.provider === PROVIDER_ID;
177
+ }
178
+
179
+ export function formatActiveAccountStatus(
180
+ ctx: ExtensionContext,
181
+ accountEmail: string,
182
+ usage: CodexUsageSnapshot | undefined,
183
+ preferences: FooterPreferences,
184
+ ): string {
185
+ const accountText = preferences.showAccount
186
+ ? ctx.ui.theme.fg("muted", accountEmail)
187
+ : undefined;
188
+ if (!usage) {
189
+ return [
190
+ ctx.ui.theme.fg("dim", "Codex"),
191
+ accountText,
192
+ ctx.ui.theme.fg("dim", "loading..."),
193
+ ]
194
+ .filter(Boolean)
195
+ .join(" ");
196
+ }
197
+
198
+ const fiveHour = `${ctx.ui.theme.fg("dim", FIVE_HOUR_LABEL)}${formatPercent(
199
+ ctx,
200
+ usedToDisplayPercent(usage.primary?.usedPercent, preferences.usageMode),
201
+ preferences.usageMode,
202
+ )}`;
203
+ const sevenDay = `${ctx.ui.theme.fg("dim", SEVEN_DAY_LABEL)}${formatPercent(
204
+ ctx,
205
+ usedToDisplayPercent(usage.secondary?.usedPercent, preferences.usageMode),
206
+ preferences.usageMode,
207
+ )}`;
208
+ const fiveHourReset = preferences.showReset
209
+ ? formatResetCountdown(usage.primary?.resetAt)
210
+ : undefined;
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;
233
+
234
+ const leading =
235
+ preferences.order === "account-first"
236
+ ? [ctx.ui.theme.fg("dim", "Codex"), accountText]
237
+ : [ctx.ui.theme.fg("dim", "Codex")];
238
+ const trailing =
239
+ preferences.order === "account-first" ? [] : [accountText].filter(Boolean);
240
+
241
+ return [...leading, fiveHour, sevenDay, resetText, ...trailing]
242
+ .filter(Boolean)
243
+ .join(" ");
244
+ }
245
+
246
+ function getBooleanLabel(value: boolean): string {
247
+ return value ? "on" : "off";
248
+ }
249
+
250
+ function createSettingsItems(preferences: FooterPreferences): SettingItem[] {
251
+ return [
252
+ {
253
+ id: "usageMode",
254
+ label: "Usage display",
255
+ description: "Show remaining or consumed quota percentages",
256
+ currentValue: preferences.usageMode,
257
+ values: ["left", "used"],
258
+ },
259
+ {
260
+ id: "resetWindow",
261
+ label: "Reset countdown window",
262
+ description:
263
+ "Choose whether the footer shows the 5h countdown, the 7d countdown, or both",
264
+ currentValue: preferences.resetWindow,
265
+ values: ["5h", "7d", "both"],
266
+ },
267
+ {
268
+ id: "showAccount",
269
+ label: "Show account",
270
+ description: "Display the active account identifier in the footer",
271
+ currentValue: getBooleanLabel(preferences.showAccount),
272
+ values: ["on", "off"],
273
+ },
274
+ {
275
+ id: "showReset",
276
+ label: "Show reset countdown",
277
+ description:
278
+ "Display a reset countdown like the codex usage footer extension",
279
+ currentValue: getBooleanLabel(preferences.showReset),
280
+ values: ["on", "off"],
281
+ },
282
+ {
283
+ id: "order",
284
+ label: "Footer order",
285
+ description:
286
+ "Choose whether the account appears before or after usage fields",
287
+ currentValue: preferences.order,
288
+ values: ["account-first", "usage-first"],
289
+ },
290
+ ];
291
+ }
292
+
293
+ function applyPreferenceChange(
294
+ preferences: FooterPreferences,
295
+ id: string,
296
+ newValue: string,
297
+ ): FooterPreferences {
298
+ if (id === "usageMode" && isPercentDisplayMode(newValue)) {
299
+ return { ...preferences, usageMode: newValue };
300
+ }
301
+ if (id === "resetWindow" && isResetWindowMode(newValue)) {
302
+ return { ...preferences, resetWindow: newValue };
303
+ }
304
+ if (id === "showAccount") {
305
+ return { ...preferences, showAccount: newValue === "on" };
306
+ }
307
+ if (id === "showReset") {
308
+ return { ...preferences, showReset: newValue === "on" };
309
+ }
310
+ if (id === "order" && isStatusOrder(newValue)) {
311
+ return { ...preferences, order: newValue };
312
+ }
313
+ return preferences;
314
+ }
315
+
316
+ export function createUsageStatusController(accountManager: AccountManager) {
317
+ let refreshTimer: ReturnType<typeof setInterval> | undefined;
318
+ let activeContext: ExtensionContext | undefined;
319
+ let refreshInFlight = false;
320
+ let queuedRefresh = false;
321
+ let preferences: FooterPreferences = DEFAULT_PREFERENCES;
322
+
323
+ function clearStatus(ctx?: ExtensionContext): void {
324
+ ctx?.ui.setStatus(STATUS_KEY, undefined);
325
+ }
326
+
327
+ async function ensurePreferencesLoaded(): Promise<void> {
328
+ preferences = await loadFooterPreferences();
329
+ }
330
+
331
+ async function updateStatus(ctx: ExtensionContext): Promise<void> {
332
+ if (!ctx.hasUI) return;
333
+ if (!isManagedModel(ctx.model)) {
334
+ clearStatus(ctx);
335
+ return;
336
+ }
337
+
338
+ const activeAccount = accountManager.getActiveAccount();
339
+ if (!activeAccount) {
340
+ ctx.ui.setStatus(
341
+ STATUS_KEY,
342
+ ctx.ui.theme.fg("warning", "Multicodex no active account"),
343
+ );
344
+ return;
345
+ }
346
+
347
+ const cachedUsage = accountManager.getCachedUsage(activeAccount.email);
348
+ const usage =
349
+ (await accountManager.refreshUsageForAccount(activeAccount)) ??
350
+ cachedUsage;
351
+ ctx.ui.setStatus(
352
+ STATUS_KEY,
353
+ formatActiveAccountStatus(ctx, activeAccount.email, usage, preferences),
354
+ );
355
+ }
356
+
357
+ async function refreshFor(ctx: ExtensionContext): Promise<void> {
358
+ activeContext = ctx;
359
+ if (refreshInFlight) {
360
+ queuedRefresh = true;
361
+ return;
362
+ }
363
+
364
+ refreshInFlight = true;
365
+ try {
366
+ await updateStatus(ctx);
367
+ } finally {
368
+ refreshInFlight = false;
369
+ if (queuedRefresh && activeContext) {
370
+ queuedRefresh = false;
371
+ await refreshFor(activeContext);
372
+ }
373
+ }
374
+ }
375
+
376
+ function startAutoRefresh(): void {
377
+ if (refreshTimer) clearInterval(refreshTimer);
378
+ refreshTimer = setInterval(() => {
379
+ if (!activeContext) return;
380
+ void refreshFor(activeContext);
381
+ }, REFRESH_INTERVAL_MS);
382
+ refreshTimer.unref?.();
383
+ }
384
+
385
+ function stopAutoRefresh(ctx?: ExtensionContext): void {
386
+ if (refreshTimer) {
387
+ clearInterval(refreshTimer);
388
+ refreshTimer = undefined;
389
+ }
390
+ clearStatus(ctx ?? activeContext);
391
+ activeContext = undefined;
392
+ queuedRefresh = false;
393
+ }
394
+
395
+ async function loadPreferences(ctx?: ExtensionContext): Promise<void> {
396
+ try {
397
+ await ensurePreferencesLoaded();
398
+ } catch (error) {
399
+ preferences = DEFAULT_PREFERENCES;
400
+ ctx?.ui.notify(
401
+ `Multicodex: failed to load ${SETTINGS_FILE}: ${String(error)}`,
402
+ "warning",
403
+ );
404
+ }
405
+ }
406
+
407
+ async function openPreferencesPanel(
408
+ ctx: ExtensionCommandContext,
409
+ ): Promise<void> {
410
+ await loadPreferences(ctx);
411
+ let draft = preferences;
412
+
413
+ await ctx.ui.custom((_tui, theme, _kb, done) => {
414
+ const container = new Container();
415
+ container.addChild(
416
+ new Text(theme.fg("accent", theme.bold("MultiCodex Footer")), 1, 0),
417
+ );
418
+ container.addChild(
419
+ new Text(
420
+ theme.fg(
421
+ "dim",
422
+ "Configure the usage footer to match the codex usage extension style.",
423
+ ),
424
+ 1,
425
+ 0,
426
+ ),
427
+ );
428
+
429
+ const settingsList = new SettingsList(
430
+ createSettingsItems(draft),
431
+ 7,
432
+ getSettingsListTheme(),
433
+ (id: string, newValue: string) => {
434
+ draft = applyPreferenceChange(draft, id, newValue);
435
+ settingsList.updateValue(id, newValue);
436
+ },
437
+ () => done(undefined),
438
+ { enableSearch: true },
439
+ );
440
+ container.addChild(settingsList);
441
+
442
+ return {
443
+ render: (width: number) => container.render(width),
444
+ invalidate: () => container.invalidate(),
445
+ handleInput: (data: string) => settingsList.handleInput(data),
446
+ };
447
+ });
448
+
449
+ preferences = draft;
450
+ await persistFooterPreferences(preferences);
451
+ await refreshFor(ctx);
452
+ }
453
+
454
+ return {
455
+ loadPreferences,
456
+ openPreferencesPanel,
457
+ refreshFor,
458
+ startAutoRefresh,
459
+ stopAutoRefresh,
460
+ getPreferences: () => preferences,
461
+ };
462
+ }
package/storage.ts ADDED
@@ -0,0 +1,49 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+
5
+ export interface Account {
6
+ email: string;
7
+ accessToken: string;
8
+ refreshToken: string;
9
+ expiresAt: number;
10
+ accountId?: string;
11
+ lastUsed?: number;
12
+ quotaExhaustedUntil?: number;
13
+ }
14
+
15
+ export interface StorageData {
16
+ accounts: Account[];
17
+ activeEmail?: string;
18
+ }
19
+
20
+ export const STORAGE_FILE = path.join(
21
+ os.homedir(),
22
+ ".pi",
23
+ "agent",
24
+ "codex-accounts.json",
25
+ );
26
+
27
+ export function loadStorage(): StorageData {
28
+ try {
29
+ if (fs.existsSync(STORAGE_FILE)) {
30
+ return JSON.parse(fs.readFileSync(STORAGE_FILE, "utf-8")) as StorageData;
31
+ }
32
+ } catch (error) {
33
+ console.error("Failed to load multicodex accounts:", error);
34
+ }
35
+
36
+ return { accounts: [] };
37
+ }
38
+
39
+ export function saveStorage(data: StorageData): void {
40
+ try {
41
+ const dir = path.dirname(STORAGE_FILE);
42
+ if (!fs.existsSync(dir)) {
43
+ fs.mkdirSync(dir, { recursive: true });
44
+ }
45
+ fs.writeFileSync(STORAGE_FILE, JSON.stringify(data, null, 2));
46
+ } catch (error) {
47
+ console.error("Failed to save multicodex accounts:", error);
48
+ }
49
+ }