tokenmaxxing 1.65.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
@@ -38,12 +38,12 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
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
 
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.65.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);
@@ -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/types.ts CHANGED
@@ -116,6 +116,11 @@ export const ConfigSchema = z
116
116
  checkIntervalMs: z.number().int().min(10_000).default(60_000),
117
117
  })
118
118
  .prefault({}),
119
+ hub: z
120
+ .object({
121
+ port: z.number().int().min(1).max(65_535).default(8317),
122
+ })
123
+ .prefault({}),
119
124
  })
120
125
  .refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
121
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();
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]" });