tokenmaxxing 1.64.0 → 1.66.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/DESIGN.md CHANGED
@@ -31,19 +31,19 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
31
31
  - Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin/grokBin/opencodeBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
34
+ - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, usageRetryAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
35
35
  - `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
36
36
  - `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
37
37
  - `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
38
38
  - `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
39
39
  - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, and the launch time the marker was written for.
40
40
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points.
41
- - `sessions/<sid>.json`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
41
+ - `sessions/<sid>.json`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
42
42
  - Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
43
43
 
44
44
  - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET), deletes leftover dead state files at the state root, and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
45
45
 
46
- The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
46
+ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. `tokenmaxxing serve`, the optional [usage hub](docs/content/docs/hub.mdx) that answers the CLIProxyAPI management subset for a dashboard from the cached figures, is a foreground server outside the switching path. The `claude` binary, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
47
47
 
48
48
  ---
49
49
 
@@ -84,7 +84,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
84
84
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
85
85
 
86
86
  - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
87
- - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. An expired stored access token stays unmeasured until a session on that store refreshes it.
87
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it.
88
88
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
89
89
  - Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
90
90
 
package/README.md CHANGED
@@ -73,6 +73,7 @@ claude # use claude as always; each session gets its own accoun
73
73
  | `tokenmaxxing check` | fold fresh tees, sample up to three stale accounts, and self-update a Bun global install once a day; the periodic timer runs this every tick |
74
74
  | `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, setup-token age, and shell aliases that shadow `claude` |
75
75
  | `tokenmaxxing rename [--codex \| --grok \| --opencode-go] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
76
+ | `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run. Details in [Usage hub](docs/content/docs/hub.mdx) |
76
77
  | `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
77
78
  | `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
78
79
  | `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.64.0",
3
+ "version": "1.66.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,222 @@
1
+ import { randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { sortBy } from "es-toolkit";
4
+ import { z } from "zod";
5
+ import { writeFileAtomic } from "../lib/atomic.ts";
6
+ import { claude } from "../lib/claude.ts";
7
+ import { codex, observeCodex } from "../lib/codex.ts";
8
+ import { CODEX_USAGE_URL } from "../lib/codexusage.ts";
9
+ import { errorMessage, log } from "../lib/log.ts";
10
+ import { paths } from "../lib/paths.ts";
11
+ import { isExhausted, thresholdBars } from "../lib/picker.ts";
12
+ import type { Provider } from "../lib/provider.ts";
13
+ import { foldTee } from "../lib/sample.ts";
14
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
15
+ import { OAUTH_USAGE_URL } from "../lib/usage.ts";
16
+ import type { Account, Config } from "../lib/types.ts";
17
+ import { c, emitError } from "./render.ts";
18
+ import { usageReport, type UsageReport, type WindowReport } from "./status.ts";
19
+
20
+ const MANAGEMENT_PREFIX = "/v0/management/";
21
+ const STALE_AFTER_MS = 2 * 60 * 60_000;
22
+
23
+ type HubPool = {
24
+ provider: Provider;
25
+ usageUrl: string;
26
+ fold: (account: Account) => void;
27
+ read: (account: Account, cfg: Config, now: number) => Promise<Account>;
28
+ body: (usage: UsageReport, account: Account) => unknown;
29
+ };
30
+
31
+ function iso(epochMs: number | null): string | null {
32
+ return epochMs == null ? null : new Date(epochMs).toISOString();
33
+ }
34
+
35
+ function claudeBody(usage: UsageReport): unknown {
36
+ const win = (w: WindowReport | null) => (w ? { utilization: w.usedPercentage, resets_at: iso(w.resetsAt) } : null);
37
+ return {
38
+ five_hour: win(usage.fiveHour),
39
+ seven_day: win(usage.week),
40
+ limits: usage.limits.map((w) => ({ kind: "weekly_scoped", percent: w.usedPercentage, resets_at: iso(w.resetsAt), scope: { model: { display_name: w.name } } })),
41
+ };
42
+ }
43
+
44
+ function codexBody(usage: UsageReport, account: Account): unknown {
45
+ const win = (w: WindowReport | undefined) =>
46
+ w
47
+ ? {
48
+ used_percent: w.usedPercentage,
49
+ reset_at: w.resetsAt == null ? null : Math.round(w.resetsAt / 1000),
50
+ ...(w.windowSeconds == null ? {} : { limit_window_seconds: w.windowSeconds }),
51
+ }
52
+ : null;
53
+ const pair = (windows: WindowReport[]) => {
54
+ const ordered = sortBy(windows, [(w) => w.windowSeconds ?? Number.POSITIVE_INFINITY]);
55
+ return { primary_window: win(ordered[0]), secondary_window: win(ordered[1]) };
56
+ };
57
+ const named = new Map<string, WindowReport[]>();
58
+ for (const w of usage.limits) named.set(w.name, [...(named.get(w.name) ?? []), w]);
59
+ return {
60
+ ...(account.tier ? { plan_type: account.tier } : {}),
61
+ rate_limit: pair([usage.fiveHour, usage.week].filter((w) => w != null)),
62
+ ...(named.size === 0 ? {} : { additional_rate_limits: [...named].map(([limit_name, windows]) => ({ limit_name, rate_limit: pair(windows) })) }),
63
+ };
64
+ }
65
+
66
+ const POOLS: HubPool[] = [
67
+ {
68
+ provider: claude,
69
+ usageUrl: OAUTH_USAGE_URL,
70
+ fold: foldTee,
71
+ read: async (account) => account,
72
+ body: claudeBody,
73
+ },
74
+ {
75
+ provider: codex,
76
+ usageUrl: CODEX_USAGE_URL,
77
+ fold: () => {},
78
+ read: async (account, cfg, now) => {
79
+ await observeCodex(account, cfg, now, { probe: true, refresh: false });
80
+ return loadAccounts(codex.pool).accounts.find((a) => a.id === account.id) ?? account;
81
+ },
82
+ body: codexBody,
83
+ },
84
+ ];
85
+
86
+ function authIndex(pool: HubPool, account: Account): string {
87
+ return new Bun.CryptoHasher("sha256").update(`${pool.provider.name}:${account.id}`).digest("hex").slice(0, 16);
88
+ }
89
+
90
+ function json(value: unknown, status = 200): Response {
91
+ return Response.json(value, { status });
92
+ }
93
+
94
+ function authFiles(cfg: Config, now: number): unknown {
95
+ const files = POOLS.flatMap((pool) => {
96
+ const ctx = { now, thresholds: thresholdBars(cfg), currentId: null, families: pool.provider.gatedFamilies(cfg), seats: null };
97
+ return loadAccounts(pool.provider.pool).accounts.map((a) => {
98
+ pool.fold(a);
99
+ return {
100
+ id: a.id,
101
+ auth_index: authIndex(pool, a),
102
+ name: a.label,
103
+ type: pool.provider.name,
104
+ provider: pool.provider.name,
105
+ label: a.label,
106
+ ...(a.email ? { email: a.email } : {}),
107
+ status: a.needsReauth === true ? "error" : "active",
108
+ status_message: a.needsReauth === true ? `needs reauth - run \`tokenmaxxing auth${pool.provider.flag} ${a.label}\`` : "",
109
+ disabled: false,
110
+ unavailable: isExhausted(a, ctx),
111
+ };
112
+ });
113
+ });
114
+ return { observed_at: new Date(now).toISOString(), files };
115
+ }
116
+
117
+ const ApiCallSchema = z.looseObject({
118
+ auth_index: z.string().optional(),
119
+ authIndex: z.string().optional(),
120
+ AuthIndex: z.string().optional(),
121
+ method: z.string().optional(),
122
+ url: z.string().optional(),
123
+ });
124
+
125
+ async function apiCall(req: Request): Promise<Response> {
126
+ const parsed = ApiCallSchema.safeParse(await req.json().catch(() => undefined));
127
+ if (!parsed.success) return json({ error: "invalid body" }, 400);
128
+ const call = parsed.data;
129
+ const method = (call.method ?? "").trim().toUpperCase();
130
+ if (method === "") return json({ error: "missing method" }, 400);
131
+ if (!call.url) return json({ error: "missing url" }, 400);
132
+ let target: URL;
133
+ try {
134
+ target = new URL(call.url);
135
+ } catch {
136
+ return json({ error: "invalid url" }, 400);
137
+ }
138
+ const pool = POOLS.find((p) => p.usageUrl === `${target.origin}${target.pathname}`);
139
+ if (method !== "GET" || !pool) {
140
+ return json({ error: `unsupported request: this hub serves ${POOLS.map((p) => `GET ${p.usageUrl}`).join(" and ")} only` }, 400);
141
+ }
142
+ const index = call.auth_index ?? call.authIndex ?? call.AuthIndex ?? "";
143
+ const account = loadAccounts(pool.provider.pool).accounts.find((a) => authIndex(pool, a) === index);
144
+ if (!account) return json({ error: "auth credential not found for auth_index" }, 400);
145
+ const now = Date.now();
146
+ const current = await pool.read(account, loadConfig(), now);
147
+ pool.fold(current);
148
+ const usage = usageReport({ ...current, windows: current.windows.filter((w) => now - w.sampledAt <= STALE_AFTER_MS) }, now);
149
+ if (!usage || (!usage.fiveHour && !usage.week)) return json({ error: `no usage figure newer than ${STALE_AFTER_MS / 3_600_000}h for this account` }, 502);
150
+ return json({ status_code: 200, header: { "Content-Type": ["application/json"] }, body: JSON.stringify(pool.body(usage, current)) });
151
+ }
152
+
153
+ function presentedKey(req: Request): string {
154
+ const auth = req.headers.get("authorization") ?? "";
155
+ if (auth !== "") {
156
+ const at = auth.indexOf(" ");
157
+ return at > 0 && auth.slice(0, at).toLowerCase() === "bearer" ? auth.slice(at + 1) : auth;
158
+ }
159
+ return req.headers.get("x-management-key") ?? "";
160
+ }
161
+
162
+ function keyMatches(presented: string, key: string): boolean {
163
+ const a = Buffer.from(presented);
164
+ const b = Buffer.from(key);
165
+ return a.length === b.length && timingSafeEqual(a, b);
166
+ }
167
+
168
+ async function handle(req: Request, key: string): Promise<Response> {
169
+ const url = new URL(req.url);
170
+ if (!url.pathname.startsWith(MANAGEMENT_PREFIX)) return json({ error: "not found" }, 404);
171
+ const presented = presentedKey(req);
172
+ if (presented === "") return json({ error: "missing management key" }, 401);
173
+ if (!keyMatches(presented, key)) return json({ error: "invalid management key" }, 401);
174
+ const route = url.pathname.slice(MANAGEMENT_PREFIX.length);
175
+ if (route === "auth-files" && req.method === "GET") return json(authFiles(loadConfig(), Date.now()));
176
+ if (route === "api-call" && req.method === "POST") return apiCall(req);
177
+ return json({ error: "not found" }, 404);
178
+ }
179
+
180
+ function managementKey(): string {
181
+ if (existsSync(paths.hubKeyFile)) {
182
+ const mode = statSync(paths.hubKeyFile).mode & 0o777;
183
+ if (mode & 0o077) throw new Error(`${paths.hubKeyFile} is mode ${mode.toString(8)}, readable by other local users - chmod 600 it`);
184
+ const key = readFileSync(paths.hubKeyFile, "utf8").trim();
185
+ if (key === "") throw new Error(`${paths.hubKeyFile} is empty - write a management key into it, or delete it and serve mints one`);
186
+ return key;
187
+ }
188
+ const key = randomBytes(32).toString("hex");
189
+ writeFileAtomic(paths.hubKeyFile, `${key}\n`);
190
+ log("hub.key_minted", { file: paths.hubKeyFile });
191
+ return key;
192
+ }
193
+
194
+ export async function cmdServe(args: string[]): Promise<number> {
195
+ if (args.length > 0) {
196
+ emitError({ message: `unknown serve option: ${args[0]} (serve takes no options; set hub.port in ${paths.configJson})` });
197
+ return 2;
198
+ }
199
+ const cfg = loadConfig();
200
+ const key = managementKey();
201
+ const server = Bun.serve({
202
+ hostname: "localhost",
203
+ port: cfg.hub.port,
204
+ fetch: (req) => handle(req, key),
205
+ error: (e) => {
206
+ log("hub.error", { err: errorMessage(e) });
207
+ return json({ error: "internal error" }, 500);
208
+ },
209
+ });
210
+ console.log(`${c.green("✓")} serving the CLIProxyAPI-compatible usage API at http://localhost:${server.port}/v0/management`);
211
+ console.log(c.dim(`management key: the contents of ${paths.hubKeyFile}`));
212
+ log("hub.started", { port: server.port });
213
+ return new Promise<number>((resolve) => {
214
+ const stop = (signal: string) => {
215
+ server.stop(true);
216
+ log("hub.stopped", { signal });
217
+ resolve(0);
218
+ };
219
+ process.once("SIGINT", () => stop("SIGINT"));
220
+ process.once("SIGTERM", () => stop("SIGTERM"));
221
+ });
222
+ }
package/src/cli/status.ts CHANGED
@@ -11,7 +11,9 @@ import type { Provider, SampleReport } from "../lib/provider.ts";
11
11
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
12
12
  import type { Account, Config, Thresholds, Window } from "../lib/types.ts";
13
13
 
14
- type WindowReport = { usedPercentage: number; resetsAt: number | null; windowSeconds: number | null };
14
+ export type WindowReport = { usedPercentage: number; resetsAt: number | null; windowSeconds: number | null };
15
+
16
+ export type UsageReport = { fiveHour: WindowReport | null; week: WindowReport | null; limits: (WindowReport & { name: string })[] };
15
17
 
16
18
  type StatusAccount = {
17
19
  label: string;
@@ -22,7 +24,7 @@ type StatusAccount = {
22
24
  sessions: number;
23
25
  needsReauth: boolean;
24
26
  exhausted: boolean;
25
- usage: { fiveHour: WindowReport | null; week: WindowReport | null; limits: (WindowReport & { name: string })[] } | null;
27
+ usage: UsageReport | null;
26
28
  usageAt: number | null;
27
29
  limitsAt: number | null;
28
30
  sample: SampleReport | { ok: true; source: "cached" };
@@ -47,7 +49,7 @@ function currentWindow(w: Window, now: number): WindowReport {
47
49
  };
48
50
  }
49
51
 
50
- function usageReport(a: Account, now: number): StatusAccount["usage"] {
52
+ export function usageReport(a: Account, now: number): UsageReport | null {
51
53
  if (a.lastUsageAt == null) return null;
52
54
  const session = sessionWindow(a);
53
55
  const week = weeklyWindow(a);
package/src/lib/claude.ts CHANGED
@@ -12,7 +12,7 @@ import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
12
12
  import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { seatCounts } from "./presence.ts";
14
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
15
- import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
15
+ import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
16
16
  import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
@@ -43,7 +43,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
43
43
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
44
44
  const snap = loadUsageSnapshot(account.id);
45
45
  const fresh = snap != null && now - snap.at <= ttl;
46
- if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
46
+ if (opts.probe && !probeAttempted && usageBlockedUntil(account, now) == null && (!fresh || opts.perModel)) {
47
47
  const startedAt = Date.now();
48
48
  const outcome = await sampleAccountUsage(account);
49
49
  await withLock(claudePool.lockFile, () => {
@@ -61,6 +61,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
61
61
  }
62
62
  } else {
63
63
  a.probeFails = (a.probeFails ?? 0) + 1;
64
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
64
65
  }
65
66
  saveAccounts(claudePool, idx);
66
67
  });
@@ -82,6 +83,7 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
82
83
  }
83
84
  const outcome = await sampleAccountUsage(a);
84
85
  if (!outcome.ok) {
86
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
85
87
  reports.set(a.id, { ok: false, reason: outcome.reason });
86
88
  return;
87
89
  }
@@ -215,7 +217,7 @@ async function login(): Promise<Harvest | null> {
215
217
 
216
218
  console.log(c.dim("sampling usage..."));
217
219
  const sampled = await fetchUsageDirect(blob.claudeAiOauth.accessToken);
218
- if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
220
+ if (!sampled.ok) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
219
221
  const at = Date.now();
220
222
  const id = oauthAccount.accountUuid;
221
223
  return {
@@ -223,7 +225,8 @@ async function login(): Promise<Harvest | null> {
223
225
  email: oauthAccount.emailAddress,
224
226
  tier: claudeTierLabel(blob.claudeAiOauth),
225
227
  oauthAccount,
226
- sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
228
+ sample: sampled.ok ? { windows: windowsOf(sampled.usage, at), at } : null,
229
+ ...(!sampled.ok && sampled.retryAt != null ? { usageRetryAt: sampled.retryAt } : {}),
227
230
  park: () => writeItem(storeTarget(id), claudeAiOauthOnly(blobRaw)),
228
231
  };
229
232
  } finally {
@@ -6,7 +6,8 @@ import { JsonTextSchema, type CodexAuthJson, type CodexUsage, type Window } from
6
6
  import { codexIdentityOf } from "./codexauth.ts";
7
7
  import { familyTokens } from "./usage.ts";
8
8
 
9
- const USAGE_URL = env("TOKENMAXXING_CODEX_USAGE_URL", "https://chatgpt.com/backend-api/wham/usage");
9
+ export const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
10
+ const USAGE_URL = env("TOKENMAXXING_CODEX_USAGE_URL", CODEX_USAGE_URL);
10
11
 
11
12
  export class CodexUsageReadError extends Error {
12
13
  constructor(detail: string) {
package/src/lib/paths.ts CHANGED
@@ -33,6 +33,7 @@ export const paths = {
33
33
  cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
34
34
  cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
35
35
  cloudLockFile: join(TM_HOME, "cloud", "lock"),
36
+ hubKeyFile: join(TM_HOME, "hub-key"),
36
37
 
37
38
  claudeSettings: env(
38
39
  "TOKENMAXXING_CLAUDE_SETTINGS",
package/src/lib/sample.ts CHANGED
@@ -9,7 +9,7 @@ import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
9
9
  import { fetchUsageDirect, mergeWindows, windowsOf } from "./usage.ts";
10
10
  import type { Account, Config, UsageWindows } from "./types.ts";
11
11
 
12
- export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string };
12
+ export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string; retryAt?: number };
13
13
 
14
14
  type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
15
15
 
@@ -48,13 +48,26 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
48
48
  return { ok: true, token: creds.accessToken };
49
49
  }
50
50
 
51
+ export function usageBlockedUntil(account: Account, now: number): number | null {
52
+ return account.usageRetryAt != null && account.usageRetryAt > now ? account.usageRetryAt : null;
53
+ }
54
+
55
+ function rateLimitedReason(retryAt: number, now: number): string {
56
+ return `the usage endpoint rate limited this account, next read in ${Math.ceil((retryAt - now) / 60_000)}m`;
57
+ }
58
+
51
59
  export async function runSample(account: Account, token: string | null): Promise<SampleOutcome> {
52
60
  if (!token) return { ok: false, reason: "no usable access token in the store - run `tokenmaxxing auth`" };
53
- const usage = await fetchUsageDirect(token);
54
- return usage ? { ok: true, usage, via: "get" } : { ok: false, reason: "usage read failed (see log)" };
61
+ const read = await fetchUsageDirect(token);
62
+ if (read.ok) return { ok: true, usage: read.usage, via: "get" };
63
+ if (read.retryAt == null) return { ok: false, reason: "usage read failed (see log)" };
64
+ return { ok: false, reason: rateLimitedReason(read.retryAt, Date.now()), retryAt: read.retryAt };
55
65
  }
56
66
 
57
67
  export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
68
+ const now = Date.now();
69
+ const blockedUntil = usageBlockedUntil(account, now);
70
+ if (blockedUntil != null) return { ok: false, reason: rateLimitedReason(blockedUntil, now) };
58
71
  const prepared = await prepareSample(account);
59
72
  return prepared.ok ? runSample(account, prepared.token) : prepared;
60
73
  }
@@ -89,7 +102,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
89
102
  continue;
90
103
  }
91
104
  target.storeFails = 0;
92
- batch.push({ account: target, token: prepared.token });
105
+ if (usageBlockedUntil(target, now) == null) batch.push({ account: target, token: prepared.token });
93
106
  }
94
107
  saveAccounts(claudePool, idx);
95
108
  return batch;
@@ -106,6 +119,10 @@ export async function sampleOldest(cfg: Config): Promise<void> {
106
119
  stored.needsReauth = true;
107
120
  dirty = true;
108
121
  }
122
+ if (stored && !outcome.ok && outcome.retryAt != null) {
123
+ stored.usageRetryAt = outcome.retryAt;
124
+ dirty = true;
125
+ }
109
126
  if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
110
127
  stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
111
128
  stored.lastUsageAt = startedAt;
package/src/lib/state.ts CHANGED
@@ -73,6 +73,7 @@ export type Harvest = {
73
73
  tier: string | null;
74
74
  oauthAccount?: Account["oauthAccount"];
75
75
  sample: { windows: Window[]; at: number } | null;
76
+ usageRetryAt?: number;
76
77
  park: () => Promise<void>;
77
78
  };
78
79
 
@@ -92,6 +93,7 @@ export function upsertAccount(
92
93
  lastUsageAt: h.sample ? h.sample.at : existing?.lastUsageAt,
93
94
  needsReauth: false,
94
95
  ...(h.oauthAccount ? { oauthAccount: h.oauthAccount } : {}),
96
+ usageRetryAt: h.sample ? undefined : (h.usageRetryAt ?? existing?.usageRetryAt),
95
97
  };
96
98
  if (existing) Object.assign(existing, fresh);
97
99
  else idx.accounts.push(fresh);
package/src/lib/types.ts CHANGED
@@ -69,6 +69,7 @@ export const AccountSchema = z.object({
69
69
  lastProbeAt: z.number().optional(),
70
70
  probeFails: z.number().optional(),
71
71
  storeFails: z.number().optional(),
72
+ usageRetryAt: z.number().optional(),
72
73
  enforcedUntil: z.number().optional(),
73
74
  needsReauth: z.boolean().optional(),
74
75
  oauthAccount: OAuthAccountSchema.optional(),
@@ -115,6 +116,11 @@ export const ConfigSchema = z
115
116
  checkIntervalMs: z.number().int().min(10_000).default(60_000),
116
117
  })
117
118
  .prefault({}),
119
+ hub: z
120
+ .object({
121
+ port: z.number().int().min(1).max(65_535).default(8317),
122
+ })
123
+ .prefault({}),
118
124
  })
119
125
  .refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
120
126
  path: ["policy", "projectionMargin"],
package/src/lib/usage.ts CHANGED
@@ -181,7 +181,8 @@ export function scrubCredentialEnv(env: Record<string, string>): Record<string,
181
181
  return scrubbed;
182
182
  }
183
183
 
184
- const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", "https://api.anthropic.com/api/oauth/usage");
184
+ export const OAUTH_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
185
+ const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", OAUTH_USAGE_URL);
185
186
  const USAGE_DEADLINE_MS = 10_000;
186
187
 
187
188
  const ResetsAtSchema = z.iso.datetime({ offset: true }).transform((iso) => Date.parse(iso)).nullish();
@@ -208,7 +209,11 @@ function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
208
209
  return perModel;
209
210
  }
210
211
 
211
- export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
212
+ const RetryAfterSecondsSchema = z.coerce.number().int().positive();
213
+
214
+ export type UsageRead = { ok: true; usage: UsageWindows } | { ok: false; retryAt: number | null };
215
+
216
+ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead> {
212
217
  let res: Response;
213
218
  try {
214
219
  res = await http.get(USAGE_URL, {
@@ -218,17 +223,18 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindow
218
223
  });
219
224
  } catch (e) {
220
225
  log("usage.get_failed", { err: errorMessage(e) });
221
- return null;
226
+ return { ok: false, retryAt: null };
222
227
  }
223
228
  if (!res.ok) {
224
- log("usage.get_failed", { status: res.status });
225
- return null;
229
+ const retryAfter = res.status === 429 ? RetryAfterSecondsSchema.safeParse(res.headers.get("retry-after")).data : undefined;
230
+ log("usage.get_failed", { status: res.status, retryAfter });
231
+ return { ok: false, retryAt: retryAfter != null ? Date.now() + retryAfter * 1000 : null };
226
232
  }
227
233
  const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
228
234
  if (!parsed.success || !parsed.data.five_hour || !parsed.data.seven_day) {
229
235
  log("usage.get_incomplete", { ok: parsed.success, issue: parsed.success ? undefined : z.prettifyError(parsed.error).slice(0, 200) });
230
- return null;
236
+ return { ok: false, retryAt: null };
231
237
  }
232
238
  const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: w.resets_at ?? null });
233
- return { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) };
239
+ return { ok: true, usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) } };
234
240
  }
package/src/main.ts CHANGED
@@ -25,6 +25,7 @@ import { cmdSeat } from "./cli/seat.ts";
25
25
  import { cmdSetupToken } from "./cli/setuptoken.ts";
26
26
  import { cmdCloudRun } from "./cli/cloudrun.ts";
27
27
  import { cmdCursorInit } from "./cli/cursorinit.ts";
28
+ import { cmdServe } from "./cli/serve.ts";
28
29
  import { onLoginHome, timerDeactivationHint, uninstallSupervisor, uninstallTargets } from "./lib/install.ts";
29
30
  import { errorMessage } from "./lib/log.ts";
30
31
  import { HOME } from "./lib/paths.ts";
@@ -58,6 +59,7 @@ function printHelp(): void {
58
59
  ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --opencode-go] <sel> <label>
59
60
  ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --opencode-go] <sel>
60
61
  ${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, reserved until <pid> exits; exit 1 = none usable, fall back to the ambient login
62
+ ${c.cyan("tokenmaxxing serve")} serve the CLIProxyAPI-compatible usage API on http://localhost:<hub.port> (default 8317) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows every pooled Claude and Codex account's quota; the management key is the contents of hub-key in the state directory
61
63
  ${c.cyan("tokenmaxxing uninstall")} [--yes] print the targets, then remove supervisor + settings entries (refused without ${c.cyan("--yes")} when HOME is the login home)
62
64
  ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
63
65
  ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
@@ -161,6 +163,7 @@ async function main(): Promise<number> {
161
163
  return cmdSeat(args[1], args.slice(2));
162
164
  }
163
165
  case "setup-token": return cmdSetupToken(args.slice(1));
166
+ case "serve": return cmdServe(args.slice(1));
164
167
  case "cursor": {
165
168
  if (args[1] === "init") return cmdCursorInit(args.slice(2));
166
169
  emitError({ message: "usage: tokenmaxxing cursor init [dir]" });