tokenmaxxing 1.117.0 → 1.118.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
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing - design
2
2
 
3
- Automatic Claude Code account switching. You run `claude` exactly as always; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**98%** of the 5h session window or of a weekly window by default), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool queues sessions first come first served onto reset targets (at most four waiters per account, overflow takes the soonest anyway), pauses each one with a countdown, and auto-resumes at its assigned reset when that lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**98%** of the 5h session window or of a weekly window by default), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool queues sessions first come first served onto reset targets (at most eight waiters per account; overflow waits for the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset only when the horizon is full), pauses each one with a countdown, and auto-resumes at its assigned reset (further out than twice `policy.maxWaitMs`, the session stays put rather than parking for hours).
4
4
 
5
5
  > Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state and supervisor; since 1.35.0 both run on one engine. Codex's verified internals live in AGENTS.md's Codex sections.
6
6
  >
@@ -12,7 +12,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; eve
12
12
 
13
13
  Claude Code namespaces its credential by `CLAUDE_SECURESTORAGE_CONFIG_DIR` (verified in the 2.1.269 binary, `xSe`/`y_`): the variable moves only the credential store (a `.credentials.json` inside that directory on Linux, a keychain item named `Claude Code-credentials-<sha256(dir)[0:8]>` on macOS), while `~/.claude` (settings, transcripts, plugins) and `~/.claude.json` stay shared, so `--resume` works across stores. An empty namespaced store never falls back to the default login. Hooks, the statusline, and MCP servers inherit the variable. That is the whole mechanism: give every pooled account its own store, set the variable per session, and each session runs on its own account with no credential ever moving.
14
14
 
15
- What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the reset the wait queue assigned (at most four waiters per account, first come first served, overflow on the soonest). **Thresholds sit below 100: the headroom is the budget to reach a clean turn boundary before the account actually hits its real limit.** Both windows move at 98 (weekly allowance is use-it-or-lose-it, so it drains close to the limit, and the session bar matches it, so the configured thresholds and the effective bars read the same).
15
+ What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the reset the wait queue assigned (at most eight waiters per account, first come first served, overflow on the next reset with room). **Thresholds sit below 100: the headroom is the budget to reach a clean turn boundary before the account actually hits its real limit.** Both windows move at 98 (weekly allowance is use-it-or-lose-it, so it drains close to the limit, and the session bar matches it, so the configured thresholds and the effective bars read the same).
16
16
 
17
17
  A hook can't do the placement or the relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
18
18
 
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
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, the `origin` (the hook or the seat watch that wrote it), the launch time the marker was written for, and after a usage-credits refusal the accounts that have refused the session.
40
- - `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
40
+ - `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most eight sessions wait on one account; a session that arrives when every reset within `policy.maxWaitMs` is full waits for the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset when the horizon is full.
41
41
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
42
42
  - `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
43
43
  - `sessions/<sid>.json`, `onboard/`, `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).
@@ -84,7 +84,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
84
84
  ---
85
85
 
86
86
  ## 5. Placement and rotation policy
87
- 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, queue first come first served onto a reset target (at most four waiters per account, overflow takes the soonest anyway) and wait for that reset. The same ranking places a session at launch.
87
+ 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, queue first come first served onto a reset target (at most eight waiters per account; overflow waits for the next reset with room) and wait for that reset. The same ranking places a session at launch; a launch onto a depleted pool takes the same target selection, counting running sessions as well as queued waiters, and starts immediately.
88
88
 
89
89
  - 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.
90
90
  - 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 the account's sample interval, over the direct no-spend usage read with the account's stored token. The interval is 15 minutes times the unused fraction of the bar closest to being reached, never under `policy.usagePollTtlMs`, and 15 minutes for an account at or over an aggregate bar or with no session or borrower on this host. Every sampler claims a read under the pool lock before it sends it, so concurrent hooks and `status` runs send an account one read. The windows are the session and weekly aggregates plus the `policy.switchModels` caps that are still under their bar, and an account with no stored aggregate gets `policy.usagePollTtlMs`. Every account is attempted about once per three ticks per pooled account, or once per its sample interval 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. No sampler sends a read with an expired stored access token, because the endpoint answers it with 401 and, after a few, with the same 429. A sample that finds the token expired first runs `claude -p /usage --no-session-persistence --safe-mode` with the account's store set and a throwaway `CLAUDE_CONFIG_DIR`, so Claude Code refreshes the token under its own lock, and then reads with the new token; the child's output is not read. A refresh that leaves the token expired fails the sample with no read, and one that clears the store flags the account `needsReauth`.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing
2
2
 
3
- **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most four sessions wait on one account) and pauses with a countdown until its assigned reset, when that reset falls within `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
3
+ **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
4
4
 
5
5
  > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok and opencode-go as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys; the opencode-go pool, which holds Zen API keys, is the one exception.
6
6
 
@@ -91,7 +91,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
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
93
  - Every periodic check tick folds fresh session tees (the statusline of a terminal session, the `rate_limit_event` stream of a T3 Code or Agent SDK session) into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval (15 minutes for an empty account or one with no session on this host, shrinking to `policy.usagePollTtlMs` as a live account nears a bar, and 15 minutes again at or over an aggregate bar), with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. A sample that finds the stored access token expired has Claude Code refresh it first (`claude -p /usage` under that store) and reads with the new token.
94
- - When no account is usable, the session queues first come first served onto a reset target (at most four waiters per account) and pauses until that reset if it lands within `policy.maxWaitMs`, and otherwise stays put.
94
+ - When no account is usable, the session queues first come first served onto a reset target (at most eight waiters per account; overflow waits for the next reset with room) and pauses until that reset if it lands within twice `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.
97
97
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.117.0",
3
+ "version": "1.118.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
@@ -9,7 +9,7 @@ import { withLock } from "./lock.ts";
9
9
  import { errorMessage, log } from "./log.ts";
10
10
  import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
11
11
  import { claudePool, env, paths, seatFromEnv, storeDirFor } from "./paths.ts";
12
- import { landWindows, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
12
+ import { MAX_WAITERS_PER_ACCOUNT, landWindows, pickBest, pickEarliestReset, pickWaitTarget, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { deletePiStore } from "./piauth.ts";
14
14
  import { livingPresences, writePresence } from "./presence.ts";
15
15
  import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
@@ -138,9 +138,15 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
138
138
  let dirty = false;
139
139
  for (const a of idx.accounts) dirty = foldTee(a, bars) || dirty;
140
140
  if (dirty) saveAccounts(claudePool, idx);
141
- const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats: presence() };
141
+ const seats = presence();
142
+ const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
142
143
  const candidates = idx.accounts.filter(eligible);
143
- return pickBest(candidates, ctx) ?? pickEarliestReset(candidates, ctx)?.account ?? null;
144
+ return (
145
+ pickBest(candidates, ctx) ??
146
+ pickWaitTarget(candidates, ctx, seats, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs, now + 2 * cfg.policy.maxWaitMs)?.account ??
147
+ pickEarliestReset(candidates, ctx)?.account ??
148
+ null
149
+ );
144
150
  }
145
151
 
146
152
  export async function borrowClaudeSeat(pid: number): Promise<SeatBorrow> {
package/src/lib/decide.ts CHANGED
@@ -2,7 +2,7 @@ import { countBy } from "es-toolkit";
2
2
  import { CLAUDE_COMPACT_KILL_MS } from "./compact.ts";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
5
- import { isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
5
+ import { MAX_WAITERS_PER_ACCOUNT, isExhausted, landWindows, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, screensUntilReset, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
6
6
  import { familyTokens } from "./usage.ts";
7
7
  import { errorMessage, log } from "./log.ts";
8
8
  import type { Observation, Provider } from "./provider.ts";
@@ -17,7 +17,6 @@ export type EvalOpts = {
17
17
  exclude?: string[];
18
18
  };
19
19
 
20
- const MAX_WAITERS_PER_ACCOUNT = 4;
21
20
  const MOVE_CLAIM_MS = 2 * CLAUDE_COMPACT_KILL_MS;
22
21
 
23
22
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -165,10 +164,10 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
165
164
  const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
166
165
  const others = liveWaitClaims(now).filter((c) => c.sessionId !== waiterId);
167
166
  const waiters = new Map(Object.entries(countBy(others, (c) => c.accountId)));
168
- const target = pickWaitTarget(usable(fresh.accounts), ctx, waiters, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs);
167
+ const target = pickWaitTarget(usable(fresh.accounts), ctx, waiters, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs, now + 2 * cfg.policy.maxWaitMs);
169
168
 
170
169
  if (!target) {
171
- const soonest = pickWaitTarget(usable(fresh.accounts), ctx, new Map(), MAX_WAITERS_PER_ACCOUNT, Number.POSITIVE_INFINITY);
170
+ const soonest = pickWaitTarget(usable(fresh.accounts), ctx, new Map(), MAX_WAITERS_PER_ACCOUNT, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
172
171
  const waitUntil = soonest?.availableAt ?? Number.POSITIVE_INFINITY;
173
172
  log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
174
173
  return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
package/src/lib/picker.ts CHANGED
@@ -176,11 +176,18 @@ export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestRe
176
176
  return minBy(mapped, (x) => x.availableAt) ?? null;
177
177
  }
178
178
 
179
- export function pickWaitTarget(accounts: Account[], ctx: PickCtx, waiters: Map<string, number>, cap: number, deadline: number): EarliestReset | null {
179
+ export const MAX_WAITERS_PER_ACCOUNT = 8;
180
+
181
+ export function pickWaitTarget(accounts: Account[], ctx: PickCtx, waiters: Map<string, number>, cap: number, deadline: number, overflowDeadline: number): EarliestReset | null {
180
182
  const mapped = accounts
181
183
  .filter((a) => !a.needsReauth)
182
184
  .map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
183
- .filter((x) => Number.isFinite(x.availableAt) && x.availableAt <= deadline)
185
+ .filter((x) => Number.isFinite(x.availableAt) && x.availableAt <= overflowDeadline)
184
186
  .sort((x, y) => x.availableAt - y.availableAt || (x.account.id === ctx.currentId ? -1 : y.account.id === ctx.currentId ? 1 : 0));
185
- return mapped.find((x) => (waiters.get(x.account.id) ?? 0) < cap) ?? mapped[0] ?? null;
187
+ return (
188
+ mapped.filter((x) => x.availableAt <= deadline).find((x) => (waiters.get(x.account.id) ?? 0) < cap) ??
189
+ mapped.filter((x) => x.availableAt > deadline).find((x) => (waiters.get(x.account.id) ?? 0) < cap) ??
190
+ sortBy(mapped, [(x) => waiters.get(x.account.id) ?? 0, (x) => x.availableAt, (x) => (x.account.id === ctx.currentId ? 0 : 1)])[0] ??
191
+ null
192
+ );
186
193
  }