pi-chatgpt-pool 0.1.0 → 0.2.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 CHANGED
@@ -32,14 +32,33 @@ The pool offers the ChatGPT subscription models: the OpenAI models that pi also
32
32
 
33
33
  | Command | What it does |
34
34
  | --- | --- |
35
- | `/chatgpt-pool` | Open the pool: each account's usage, reset times, and banked resets. Pick an account to spend a reset, clear its limit, or remove it |
35
+ | `/chatgpt-pool` | Open the pool panel (see below) |
36
36
  | `/chatgpt-pool add <label>` | Add an account slot. Sign in to it with `/login` |
37
37
  | `/chatgpt-pool remove <label>` | Remove an account. Run `/logout` for it first to delete its token |
38
38
  | `/chatgpt-pool spend <label>` | Spend one banked reset on that account, after you confirm |
39
39
  | `/chatgpt-pool strategy <name>` | Set the routing strategy: `fill-first`, `round-robin`, `least-used`, or `use-it-or-lose-it` |
40
40
  | `/chatgpt-pool reset` | Forget recorded limits, for example after a limit clears early |
41
41
 
42
- The command autocompletes its actions and account labels. The footer shows `chatgpt: <account>` while a pooled model is selected, plus how many accounts are limited and when the next one comes back.
42
+ The command autocompletes its actions and account labels.
43
+
44
+ ## Pool panel
45
+
46
+ `/chatgpt-pool` opens a panel with every account, its state (ready, limited with a countdown, or not signed in), a usage bar per window with the time until it resets, and its banked resets.
47
+
48
+ | Key | Action |
49
+ | --- | --- |
50
+ | `↑` `↓` or `j` `k` | Select an account |
51
+ | `s` | Spend a banked reset on the selected account, after you confirm |
52
+ | `c` | Clear the selected account's recorded limit |
53
+ | `d` | Remove the selected account |
54
+ | `a` | Add an account |
55
+ | `t` | Choose the strategy |
56
+ | `r` | Refresh usage |
57
+ | `esc` or `q` | Close |
58
+
59
+ Over RPC the same actions are offered as a list instead.
60
+
61
+ While a pooled model is selected, the footer shows the account in use with its usage and reset time, for example `chatgpt: work 42% · resets in 3d 4h`, plus how many accounts are limited. When a conversation moves to another account, pi shows a notice such as `ChatGPT pool: personal is limited (back in 1h 30m), switched to work.`
43
62
 
44
63
  `/chatgpt-pool` and `spend` need the interactive UI; in `--print` or `--mode json` they stop with an error instead of spending anything. From a shell, use pi's own commands:
45
64
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chatgpt-pool",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Pool several Sign in with ChatGPT accounts in pi, with routing strategies, failover, and banked resets",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -37,11 +37,13 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@earendil-works/pi-ai": "*",
40
- "@earendil-works/pi-coding-agent": "*"
40
+ "@earendil-works/pi-coding-agent": "*",
41
+ "@earendil-works/pi-tui": "*"
41
42
  },
42
43
  "devDependencies": {
43
44
  "@earendil-works/pi-ai": "^0.99.1",
44
45
  "@earendil-works/pi-coding-agent": "^0.99.1",
46
+ "@earendil-works/pi-tui": "^0.99.1",
45
47
  "@types/node": "^24.0.0",
46
48
  "typescript": "^5.9.0"
47
49
  },
package/src/format.ts ADDED
@@ -0,0 +1,30 @@
1
+ export function formatWindow(seconds: number): string {
2
+ if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
3
+ if (seconds % 86400 === 0) return seconds === 86400 ? "daily" : `${seconds / 86400}-day`;
4
+ if (seconds % 3600 === 0) return `${seconds / 3600}h`;
5
+ return `${Math.round(seconds / 60)}m`;
6
+ }
7
+
8
+ export function formatReset(at: number): string {
9
+ const date = new Date(at);
10
+ const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
11
+ return date.toDateString() === new Date().toDateString()
12
+ ? `at ${time}`
13
+ : `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
14
+ }
15
+
16
+ export function formatIn(ms: number): string {
17
+ const minutes = Math.max(1, Math.ceil(ms / 60_000));
18
+ const days = Math.floor(minutes / 1440);
19
+ const hours = Math.floor((minutes % 1440) / 60);
20
+ const rest = minutes % 60;
21
+ if (days > 0) return hours > 0 ? `${days}d ${hours}h` : `${days}d`;
22
+ if (hours > 0) return rest > 0 ? `${hours}h ${rest}m` : `${hours}h`;
23
+ return `${rest}m`;
24
+ }
25
+
26
+ export function usageBar(percent: number, width: number) {
27
+ const clamped = Math.min(100, Math.max(0, percent));
28
+ const filled = Math.round((clamped / 100) * width);
29
+ return { filled: "█".repeat(filled), empty: "░".repeat(width - filled) };
30
+ }
package/src/index.ts CHANGED
@@ -6,6 +6,7 @@ import { BorderedLoader, type ExtensionAPI, type ExtensionContext, getAgentDir }
6
6
  import {
7
7
  type Account,
8
8
  isStrategy,
9
+ failoverMessage,
9
10
  limitCooldown,
10
11
  nextAccountId,
11
12
  orderAccounts,
@@ -15,6 +16,8 @@ import {
15
16
  type Strategy,
16
17
  type UsageSnapshot,
17
18
  } from "./pool.ts";
19
+ import { formatIn, formatReset, formatWindow } from "./format.ts";
20
+ import { type AccountStatus, type PanelAction, PoolPanel } from "./panel.ts";
18
21
  import { fetchUsage, spendReset, type Usage } from "./usage.ts";
19
22
 
20
23
  const POOL_PROVIDER = "chatgpt";
@@ -96,20 +99,6 @@ function accountProvider(openai: Provider, account: Account, onReset: ResetListe
96
99
  };
97
100
  }
98
101
 
99
- function formatWindow(seconds: number): string {
100
- if (seconds % 604800 === 0) return seconds === 604800 ? "weekly" : `${seconds / 604800}-week`;
101
- if (seconds % 3600 === 0) return `${seconds / 3600}h`;
102
- return `${Math.round(seconds / 60)}m`;
103
- }
104
-
105
- function formatReset(at: number): string {
106
- const date = new Date(at);
107
- const time = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
108
- return date.toDateString() === new Date().toDateString()
109
- ? `at ${time}`
110
- : `${date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" })} at ${time}`;
111
- }
112
-
113
102
  export default function chatgptPool(pi: ExtensionAPI) {
114
103
  const providers = builtinProviders();
115
104
  const openai = providers.find((provider) => provider.id === "openai");
@@ -142,6 +131,14 @@ export default function chatgptPool(pi: ExtensionAPI) {
142
131
  );
143
132
  return new Map([...usageCache].map(([id, entry]) => [id, entry.snapshot]));
144
133
  };
134
+ const refreshUsage = async (ctx: ExtensionContext, accountId: string) => {
135
+ const cached = usageCache.get(accountId);
136
+ if (cached && Date.now() - cached.at < USAGE_TTL_MS) return;
137
+ const token = await ctx.modelRegistry.getApiKeyForProvider(accountId).catch(() => undefined);
138
+ if (!token) return;
139
+ await fetchUsage(token).then((usage) => rememberUsage(accountId, usage), () => undefined);
140
+ };
141
+
145
142
  const reportedResets = new Map<string, number>();
146
143
  const onReset: ResetListener = (accountId, resetsAt) => reportedResets.set(accountId, resetsAt);
147
144
 
@@ -165,11 +162,16 @@ export default function chatgptPool(pi: ExtensionAPI) {
165
162
  if (provider !== POOL_PROVIDER) return ctx.ui.setStatus(STATUS_KEY, undefined);
166
163
  const now = Date.now();
167
164
  const active = accounts.find((account) => account.id === lastRouted);
165
+ const snapshot = active && usageCache.get(active.id)?.snapshot;
168
166
  const limited = accounts.filter((account) => (account.resetsAt ?? 0) > now);
169
167
  const parts = [`chatgpt: ${active?.label ?? "pool"}`];
168
+ if (snapshot) {
169
+ parts[0] += ` ${Math.round(snapshot.usedPercent)}%`;
170
+ parts.push(`resets in ${formatIn(snapshot.resetsAt - now)}`);
171
+ }
170
172
  if (limited.length > 0) {
171
173
  const next = Math.min(...limited.map((account) => account.resetsAt ?? now));
172
- parts.push(`${limited.length} limited, next back ${formatReset(next)}`);
174
+ parts.push(`${limited.length} limited, next back in ${formatIn(next - now)}`);
173
175
  }
174
176
  ctx.ui.setStatus(STATUS_KEY, parts.join(" · "));
175
177
  };
@@ -212,10 +214,18 @@ export default function chatgptPool(pi: ExtensionAPI) {
212
214
  : "Every ChatGPT account in the pool is signed out or limited. Run /chatgpt-pool to see reset times and banked resets.",
213
215
  );
214
216
  }
217
+ if (sticky !== undefined && sticky !== routed.provider && ctx.hasUI) {
218
+ const from = accounts.find((candidate) => candidate.id === sticky);
219
+ const until = from?.resetsAt && from.resetsAt > Date.now() ? ` (back in ${formatIn(from.resetsAt - Date.now())})` : "";
220
+ ctx.ui.notify(`ChatGPT pool: ${from?.label ?? sticky} is limited${until}, switched to ${account.label}.`, "info");
221
+ }
215
222
  if (lastRouted !== routed.provider) {
216
223
  lastRouted = routed.provider;
217
224
  updateStatus(ctx);
218
225
  }
226
+ refreshUsage(ctx, routed.provider)
227
+ .then(() => updateStatus(ctx, POOL_PROVIDER))
228
+ .catch(() => undefined);
219
229
  return { model: routed, thinkingLevel: request.thinkingLevel };
220
230
  },
221
231
  });
@@ -237,13 +247,11 @@ export default function chatgptPool(pi: ExtensionAPI) {
237
247
  return {
238
248
  message: {
239
249
  ...message,
240
- errorMessage: `ChatGPT account "${account.label}" is rate limited; switching to another account in the pool.`,
250
+ errorMessage: failoverMessage(account.label),
241
251
  },
242
252
  };
243
253
  });
244
254
 
245
- type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
246
-
247
255
  const loadStatuses = (ctx: ExtensionContext, signal?: AbortSignal): Promise<AccountStatus[]> =>
248
256
  Promise.all(
249
257
  accounts.map(async (account): Promise<AccountStatus> => {
@@ -352,7 +360,50 @@ export default function chatgptPool(pi: ExtensionAPI) {
352
360
  if (next && isStrategy(next)) setStrategy(ctx, next);
353
361
  };
354
362
 
363
+ const runPanel = async (ctx: ExtensionContext) => {
364
+ let statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
365
+ while (statuses) {
366
+ updateStatus(ctx);
367
+ const current = statuses.map((status) => ({
368
+ ...status,
369
+ account: accounts.find((account) => account.id === status.account.id) ?? status.account,
370
+ }));
371
+ const strategyName = STRATEGIES[strategy].split(":")[0];
372
+ const action = await ctx.ui.custom<PanelAction>(
373
+ (tui, theme, _keybindings, done) => new PoolPanel(tui, theme, current, strategyName, lastRouted, done),
374
+ );
375
+ if (!action || action.type === "close") return;
376
+ const status = "accountId" in action ? current.find((entry) => entry.account.id === action.accountId) : undefined;
377
+ let reload = false;
378
+ if (action.type === "refresh") {
379
+ reload = true;
380
+ } else if (action.type === "add") {
381
+ const label = (await ctx.ui.input("Label for the new ChatGPT account", "work"))?.trim();
382
+ if (label) addAccount(ctx, label);
383
+ reload = !!label;
384
+ } else if (action.type === "strategy") {
385
+ await chooseStrategy(ctx);
386
+ } else if (status && action.type === "spend") {
387
+ await spend(ctx, status.account, status.usage);
388
+ reload = true;
389
+ } else if (status && action.type === "clear") {
390
+ setResetsAt(status.account.id, undefined);
391
+ ctx.ui.notify(`ChatGPT (${status.account.label}) is back in rotation.`, "info");
392
+ } else if (status && action.type === "remove") {
393
+ if (await ctx.ui.confirm(`Remove ${status.account.label}?`, "It will no longer be used by the pool.")) {
394
+ removeAccount(ctx, status.account);
395
+ statuses = statuses.filter((entry) => entry.account.id !== status.account.id);
396
+ }
397
+ }
398
+ if (reload) {
399
+ usageCache.clear();
400
+ statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
401
+ }
402
+ }
403
+ };
404
+
355
405
  const openPool = async (ctx: ExtensionContext) => {
406
+ if (ctx.mode === "tui") return runPanel(ctx);
356
407
  const statuses = await withLoader(ctx, "Checking ChatGPT accounts...", (signal) => loadStatuses(ctx, signal));
357
408
  if (!statuses) return;
358
409
  updateStatus(ctx);
package/src/panel.ts ADDED
@@ -0,0 +1,111 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, Key, matchesKey, truncateToWidth, type TUI, visibleWidth } from "@earendil-works/pi-tui";
3
+ import { formatIn, formatWindow, usageBar } from "./format.ts";
4
+ import type { Account } from "./pool.ts";
5
+ import type { Usage } from "./usage.ts";
6
+
7
+ export type AccountStatus = { account: Account; signedIn: boolean; usage?: Usage; error?: string };
8
+
9
+ export type PanelAction =
10
+ | { type: "close" }
11
+ | { type: "refresh" }
12
+ | { type: "add" }
13
+ | { type: "strategy" }
14
+ | { type: "spend" | "clear" | "remove"; accountId: string };
15
+
16
+ const ACCOUNT_KEYS = new Map<string, "spend" | "clear" | "remove">([
17
+ ["s", "spend"],
18
+ ["c", "clear"],
19
+ ["d", "remove"],
20
+ ]);
21
+ const POOL_KEYS = new Map<string, "refresh" | "add" | "strategy">([
22
+ ["r", "refresh"],
23
+ ["a", "add"],
24
+ ["t", "strategy"],
25
+ ]);
26
+ const BAR_WIDTH = 16;
27
+ const HINTS = [
28
+ "↑↓ select · s spend reset · c clear limit · d remove · a add · t strategy · r refresh · esc close",
29
+ "↑↓ · s spend · c clear · d remove · a add · t strategy · r refresh · esc",
30
+ "s spend · c clear · d del · a add · t strat · esc",
31
+ ];
32
+
33
+ export class PoolPanel implements Component {
34
+ private selected = 0;
35
+
36
+ constructor(
37
+ private readonly tui: TUI,
38
+ private readonly theme: Theme,
39
+ private readonly statuses: readonly AccountStatus[],
40
+ private readonly strategyName: string,
41
+ private readonly activeId: string | undefined,
42
+ private readonly done: (action: PanelAction) => void,
43
+ ) {}
44
+
45
+ handleInput(data: string): void {
46
+ if (matchesKey(data, Key.escape) || data === "q") return this.done({ type: "close" });
47
+ if (matchesKey(data, Key.up) || data === "k") return this.move(-1);
48
+ if (matchesKey(data, Key.down) || data === "j") return this.move(1);
49
+ const poolAction = POOL_KEYS.get(data);
50
+ const accountAction = ACCOUNT_KEYS.get(data);
51
+ const account = this.statuses[this.selected]?.account;
52
+ if (poolAction) this.done({ type: poolAction });
53
+ else if (accountAction && account) this.done({ type: accountAction, accountId: account.id });
54
+ }
55
+
56
+ invalidate(): void {}
57
+
58
+ render(width: number): string[] {
59
+ const { theme } = this;
60
+ const rule = theme.fg("borderAccent", "─".repeat(width));
61
+ const lines = [
62
+ rule,
63
+ `${theme.fg("accent", theme.bold("ChatGPT pool"))} ${theme.fg("muted", `strategy: ${this.strategyName}`)}`,
64
+ "",
65
+ ];
66
+ if (this.statuses.length === 0) lines.push(theme.fg("muted", "No accounts yet. Press a to add one."), "");
67
+ this.statuses.forEach((status, index) => lines.push(...this.renderAccount(status, index === this.selected), ""));
68
+ const hint = HINTS.find((text) => visibleWidth(text) <= width) ?? HINTS[HINTS.length - 1];
69
+ lines.push(theme.fg("dim", hint), rule);
70
+ return lines.map((line) => truncateToWidth(line, width));
71
+ }
72
+
73
+ private move(delta: number): void {
74
+ if (this.statuses.length === 0) return;
75
+ this.selected = (this.selected + delta + this.statuses.length) % this.statuses.length;
76
+ this.tui.requestRender();
77
+ }
78
+
79
+ private renderAccount({ account, signedIn, usage, error }: AccountStatus, selected: boolean): string[] {
80
+ const { theme } = this;
81
+ const now = Date.now();
82
+ const pointer = selected ? theme.fg("accent", "› ") : " ";
83
+ const name = selected ? theme.fg("accent", theme.bold(account.label)) : theme.bold(account.label);
84
+ const active = account.id === this.activeId ? theme.fg("muted", " (in use)") : "";
85
+ const limitedUntil = account.resetsAt && account.resetsAt > now ? account.resetsAt : undefined;
86
+ const state = !signedIn
87
+ ? theme.fg("muted", "not signed in")
88
+ : limitedUntil
89
+ ? theme.fg("warning", `limited · back in ${formatIn(limitedUntil - now)}`)
90
+ : theme.fg("success", "ready");
91
+ const banked =
92
+ usage && usage.banked > 0
93
+ ? theme.fg("accent", ` ${usage.banked} banked reset${usage.banked === 1 ? "" : "s"}`) +
94
+ (usage.usableNow > 0 ? theme.fg("success", ` (${usage.usableNow} usable now)`) : "")
95
+ : "";
96
+ const header = `${pointer}${name}${active} ${state}${banked}`;
97
+
98
+ if (!signedIn) return [header, theme.fg("dim", ` run /login and pick ChatGPT (${account.label})`)];
99
+ if (!usage) return [header, theme.fg("dim", ` usage unavailable${error ? `: ${error}` : ""}`)];
100
+ return [
101
+ header,
102
+ ...usage.windows.map((window) => {
103
+ const bar = usageBar(window.usedPercent, BAR_WIDTH);
104
+ const color = window.usedPercent >= 90 ? "error" : window.usedPercent >= 70 ? "warning" : "success";
105
+ const label = formatWindow(window.windowSeconds).padEnd(7);
106
+ const percent = `${Math.round(window.usedPercent)}%`.padStart(4);
107
+ return ` ${theme.fg("muted", label)} ${theme.fg(color, bar.filled)}${theme.fg("dim", bar.empty)} ${percent} ${theme.fg("muted", `resets in ${formatIn(window.resetsAt - now)}`)}`;
108
+ }),
109
+ ];
110
+ }
111
+ }
package/src/pool.ts CHANGED
@@ -3,6 +3,10 @@ export type Account = { id: string; label: string; resetsAt?: number };
3
3
  const USAGE_LIMIT = /usage.?limit|subscription_sharing_usage_limit_exceeded|insufficient_quota|quota/i;
4
4
  const RATE_LIMIT = /rate.?limit|too many requests|\b429\b/i;
5
5
 
6
+ export function failoverMessage(label: string): string {
7
+ return `ChatGPT account "${label}" hit its rate limit; switching to another account in the pool.`;
8
+ }
9
+
6
10
  export const USAGE_COOLDOWN_MS = 60 * 60 * 1000;
7
11
  export const RATE_COOLDOWN_MS = 60 * 1000;
8
12