tokenmaxxing 1.52.0 → 1.54.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
@@ -41,7 +41,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
41
41
  - `sessions/<sid>.json`, `sample/`, `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).
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
- - 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 `/usage` attempt is oldest (the direct no-spend usage GET for an idle account with a fresh token, the `/usage` probe under the store otherwise), and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
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 first, the `/usage` probe under the store for a near-expiry token or a failed read), 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
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.
47
47
 
@@ -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 `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under each account's store, 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` child.
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, or the `/usage` child under its store when the token is near expiry or the read fails, 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 read or the `/usage` child.
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
 
@@ -124,7 +124,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
124
124
  ---
125
125
 
126
126
  ## 8. Stack
127
- TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` and `codex` supervisors, the statusLine and subagent statusLine shims, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (a longer `/bin/sh` shim under Nix that drops its own directory from PATH; the Stop path runs every turn, and bun's start-up stays low-millisecond). A Bun global install refreshes itself from npm on the check tick, once a day. Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio (a relay pipe on stdin for a stream-json session) and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
127
+ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` and `codex` supervisors, the statusLine and subagent statusLine shims, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (a longer `/bin/sh` shim under Nix that drops its own directory from PATH; the Stop path runs every turn, and bun's start-up stays low-millisecond). A Bun global install refreshes itself from npm on the check tick, once a day. Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a merge that changes a packed file releases itself: CI assigns the next minor version on main, publishes, and tags it (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio (a relay pipe on stdin for a stream-json session) and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
128
128
 
129
129
  ---
130
130
 
package/README.md CHANGED
@@ -90,7 +90,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
90
90
 
91
91
  Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
92
92
 
93
- - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`: an idle account with a fresh token through the direct no-spend usage read, the rest through `claude -p '/usage'` under the account's store; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
93
+ - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`: the direct no-spend usage read first, and `claude -p '/usage'` under the account's store only for a near-expiry token or a failed read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
94
94
  - When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
95
95
 
96
96
  See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.52.0",
3
+ "version": "1.54.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",
package/src/lib/claude.ts CHANGED
@@ -12,11 +12,11 @@ import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./pat
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, probeAccountUsage, teeObservation } from "./sample.ts";
15
+ import { foldTee, sampleAccountUsage, teeObservation } 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";
19
- import { CRED_ENV_OVERRIDES, gatedFamilies, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
19
+ import { CRED_ENV_OVERRIDES, fetchUsageDirect, gatedFamilies, mergeWindows, windowsOf } from "./usage.ts";
20
20
  import { CredentialBlobSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
21
21
  import { c } from "../cli/render.ts";
22
22
 
@@ -46,7 +46,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
46
46
  const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
47
47
  if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
48
48
  const startedAt = Date.now();
49
- const outcome = await probeAccountUsage(account, { retries: 0 });
49
+ const outcome = await sampleAccountUsage(account, { retries: 0 });
50
50
  await withLock(claudePool.lockFile, () => {
51
51
  const idx = loadAccounts(claudePool);
52
52
  const a = idx.accounts.find((x) => x.id === account.id);
@@ -81,7 +81,7 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
81
81
  reports.set(a.id, { ok: true, source: "statusline" });
82
82
  return;
83
83
  }
84
- const outcome = await probeAccountUsage(a);
84
+ const outcome = await sampleAccountUsage(a);
85
85
  if (!outcome.ok) {
86
86
  reports.set(a.id, { ok: false, reason: outcome.reason });
87
87
  return;
@@ -217,7 +217,7 @@ async function login(): Promise<Harvest | null> {
217
217
  }
218
218
 
219
219
  console.log(c.dim("sampling usage..."));
220
- const sampled = await probeUsage({ configDir: onboardDir });
220
+ const sampled = await fetchUsageDirect(blob.claudeAiOauth.accessToken);
221
221
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
222
222
  const at = Date.now();
223
223
  const id = oauthAccount.accountUuid;
package/src/lib/sample.ts CHANGED
@@ -14,16 +14,16 @@ import { fetchUsageDirect, mergeWindows, probeUsage, windowsOf } from "./usage.t
14
14
  import { UsageWindowsSchema, type Account, type Config } from "./types.ts";
15
15
 
16
16
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
17
- z.object({ ok: z.literal(true), usage: UsageWindowsSchema }),
17
+ z.object({ ok: z.literal(true), usage: UsageWindowsSchema, via: z.enum(["get", "probe"]) }),
18
18
  z.object({ ok: z.literal(false), reason: z.string() }),
19
19
  ]);
20
20
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
21
21
 
22
- const PreparedProbeSchema = z.discriminatedUnion("ok", [
23
- z.object({ ok: z.literal(true), dir: z.string() }),
22
+ const PreparedSampleSchema = z.discriminatedUnion("ok", [
23
+ z.object({ ok: z.literal(true), token: z.string().nullable() }),
24
24
  z.object({ ok: z.literal(false), reason: z.string() }),
25
25
  ]);
26
- type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
26
+ type PreparedSample = z.infer<typeof PreparedSampleSchema>;
27
27
 
28
28
  export function teeObservation(account: Account): Observation | null {
29
29
  const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
@@ -43,7 +43,7 @@ export function foldTee(account: Account): boolean {
43
43
  return true;
44
44
  }
45
45
 
46
- export async function prepareProbe(account: Account, suffix = ""): Promise<PreparedProbe> {
46
+ export async function prepareSample(account: Account): Promise<PreparedSample> {
47
47
  let creds;
48
48
  try {
49
49
  creds = await readStore(account.id);
@@ -57,20 +57,28 @@ export async function prepareProbe(account: Account, suffix = ""): Promise<Prepa
57
57
  }
58
58
  if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
59
59
  account.tier = claudeTierLabel(creds) ?? account.tier;
60
+ return { ok: true, token: isAccessTokenExpiring(creds) ? null : creds.accessToken };
61
+ }
62
+
63
+ async function runProbe(account: Account, suffix: string, retries: number | undefined): Promise<SampleOutcome> {
60
64
  const dir = sampleDirFor(account.id, suffix);
61
65
  mkdirSync(dir, { recursive: true });
62
66
  writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
63
- return { ok: true, dir };
67
+ const usage = await probeUsage({ configDir: dir, store: storeDirFor(account.id) }, Date.now(), { retries });
68
+ return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
64
69
  }
65
70
 
66
- export async function runProbe(account: Account, dir: string, opts: { retries?: number }): Promise<SampleOutcome> {
67
- const usage = await probeUsage({ configDir: dir, store: storeDirFor(account.id) }, Date.now(), { retries: opts.retries });
68
- return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
71
+ export async function runSample(account: Account, token: string | null, opts: { retries?: number; suffix?: string }): Promise<SampleOutcome> {
72
+ if (token) {
73
+ const usage = await fetchUsageDirect(token);
74
+ if (usage) return { ok: true, usage, via: "get" };
75
+ }
76
+ return runProbe(account, opts.suffix ?? "", opts.retries);
69
77
  }
70
78
 
71
- export async function probeAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
72
- const prepared = await prepareProbe(account);
73
- return prepared.ok ? runProbe(account, prepared.dir, opts) : prepared;
79
+ export async function sampleAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
80
+ const prepared = await prepareSample(account);
81
+ return prepared.ok ? runSample(account, prepared.token, opts) : prepared;
74
82
  }
75
83
 
76
84
  const SAMPLE_BATCH = 3;
@@ -92,10 +100,10 @@ export async function sampleOldest(cfg: Config): Promise<void> {
92
100
  if (dirty) saveAccounts(claudePool, idx);
93
101
  return [];
94
102
  }
95
- const batch: { account: Account; dir: string; token: string | null }[] = [];
103
+ const batch: { account: Account; token: string | null }[] = [];
96
104
  for (const target of stale) {
97
105
  target.lastProbeAt = now;
98
- const prepared = await prepareProbe(target, "-tick");
106
+ const prepared = await prepareSample(target);
99
107
  if (!prepared.ok) {
100
108
  target.storeFails = (target.storeFails ?? 0) + 1;
101
109
  if (target.storeFails >= STORE_FAILS_REAUTH) target.needsReauth = true;
@@ -103,32 +111,15 @@ export async function sampleOldest(cfg: Config): Promise<void> {
103
111
  continue;
104
112
  }
105
113
  target.storeFails = 0;
106
- let token: string | null = null;
107
- if (!seats.has(target.id)) {
108
- const creds = await readStore(target.id).catch(() => null);
109
- if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
110
- }
111
- batch.push({ account: target, dir: prepared.dir, token });
114
+ batch.push({ account: target, token: prepared.token });
112
115
  }
113
116
  saveAccounts(claudePool, idx);
114
117
  return batch;
115
118
  });
116
119
  await Promise.all(
117
- reserved.map(async ({ account, dir, token }) => {
120
+ reserved.map(async ({ account, token }) => {
118
121
  const startedAt = Date.now();
119
- let via = "probe";
120
- let outcome: SampleOutcome;
121
- if (token) {
122
- const usage = await fetchUsageDirect(token);
123
- if (usage) {
124
- via = "get";
125
- outcome = { ok: true, usage };
126
- } else {
127
- outcome = await runProbe(account, dir, { retries: 0 });
128
- }
129
- } else {
130
- outcome = await runProbe(account, dir, { retries: 0 });
131
- }
122
+ const outcome = await runSample(account, token, { retries: 0, suffix: "-tick" });
132
123
  await withLock(claudePool.lockFile, () => {
133
124
  const idx = loadAccounts(claudePool);
134
125
  const stored = idx.accounts.find((a) => a.id === account.id);
@@ -139,7 +130,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
139
130
  }
140
131
  log(outcome.ok ? "sample.ok" : "sample.failed", {
141
132
  account: account.id.slice(0, 8),
142
- ...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
133
+ ...(outcome.ok ? { via: outcome.via } : { reason: outcome.reason.slice(0, 200) }),
143
134
  });
144
135
  });
145
136
  }),
package/src/lib/usage.ts CHANGED
@@ -359,12 +359,28 @@ export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: {
359
359
  const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", "https://api.anthropic.com/api/oauth/usage");
360
360
  const USAGE_DEADLINE_MS = 10_000;
361
361
 
362
- const UsageWindowResponseSchema = z.object({ utilization: z.number(), resets_at: z.number().nullable().optional() });
363
- const UsageResponseSchema = z.object({
364
- rate_limits: z
365
- .object({ five_hour: UsageWindowResponseSchema.nullish(), seven_day: UsageWindowResponseSchema.nullish() })
366
- .nullish(),
362
+ const UsageLimitSchema = z.looseObject({ utilization: z.number(), resets_at: z.unknown() });
363
+ const UsageScopedLimitSchema = z.looseObject({
364
+ kind: z.string(),
365
+ percent: z.number(),
366
+ resets_at: z.unknown(),
367
+ scope: z.looseObject({ model: z.looseObject({ display_name: z.string() }) }),
367
368
  });
369
+ const UsageResponseSchema = z.looseObject({
370
+ five_hour: UsageLimitSchema.nullish(),
371
+ seven_day: UsageLimitSchema.nullish(),
372
+ limits: z.array(z.unknown()).nullish(),
373
+ });
374
+
375
+ function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
376
+ const perModel: Record<string, UsageWindow> = {};
377
+ for (const raw of limits) {
378
+ const row = UsageScopedLimitSchema.safeParse(raw);
379
+ if (!row.success || row.data.kind !== "weekly_scoped") continue;
380
+ perModel[row.data.scope.model.display_name] = { usedPercentage: row.data.percent, resetsAt: normalizeResetsAt(row.data.resets_at) };
381
+ }
382
+ return perModel;
383
+ }
368
384
 
369
385
  export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
370
386
  let res: Response;
@@ -382,15 +398,10 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindow
382
398
  return null;
383
399
  }
384
400
  const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
385
- const five = parsed.success ? parsed.data.rate_limits?.five_hour : null;
386
- const seven = parsed.success ? parsed.data.rate_limits?.seven_day : null;
387
- if (!five || !seven) {
401
+ if (!parsed.success || !parsed.data.five_hour || !parsed.data.seven_day) {
388
402
  log("usage.get_incomplete", { ok: parsed.success });
389
403
  return null;
390
404
  }
391
- const win = (w: z.infer<typeof UsageWindowResponseSchema>): UsageWindow => ({
392
- usedPercentage: w.utilization * 100,
393
- resetsAt: w.resets_at != null ? w.resets_at * 1000 : null,
394
- });
395
- return { fiveHour: win(five), sevenDay: win(seven), perModel: {} };
405
+ const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: normalizeResetsAt(w.resets_at) });
406
+ return { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) };
396
407
  }