tokenmaxxing 1.117.0 → 1.119.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 +4 -4
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/lib/claude.ts +9 -3
- package/src/lib/decide.ts +3 -4
- package/src/lib/picker.ts +10 -3
- package/src/lib/types.ts +1 -1
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 (**
|
|
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 (**90%** of the 5h session window or **98%** 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
|
|
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.** The session window moves at 90 (a 5h reset is cheap to sit out), while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it). The default projection margin is 0, 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
|
|
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
|
|
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
|
|
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
|
|
|
@@ -81,17 +81,17 @@ claude # use claude as always; each session gets its own accoun
|
|
|
81
81
|
|
|
82
82
|
## How switching decides
|
|
83
83
|
|
|
84
|
-
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (
|
|
84
|
+
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (0), so **90%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
85
85
|
|
|
86
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
87
87
|
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family. A refusal for usage credits, which the usage read cannot predict, walls nothing: the refused session moves with the Fable cap screened, and a later credits move never returns it to an account that already refused it.
|
|
88
88
|
|
|
89
|
-
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit.
|
|
89
|
+
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98). The default margin is 0, so the configured thresholds and the effective bars read the same.
|
|
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
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
|
|
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
|
|
|
@@ -101,7 +101,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
101
101
|
|
|
102
102
|
```json
|
|
103
103
|
{
|
|
104
|
-
"thresholds": { "session":
|
|
104
|
+
"thresholds": { "session": 90, "weekly": 98 },
|
|
105
105
|
"policy": {
|
|
106
106
|
"projectionMargin": 0,
|
|
107
107
|
"switchModels": ["fable"],
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.119.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
|
|
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
|
|
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
|
|
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 <=
|
|
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
|
|
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
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -103,7 +103,7 @@ export const ConfigSchema = z
|
|
|
103
103
|
.object({
|
|
104
104
|
thresholds: z
|
|
105
105
|
.object({
|
|
106
|
-
session: PercentSchema.default(
|
|
106
|
+
session: PercentSchema.default(90),
|
|
107
107
|
weekly: PercentSchema.default(98),
|
|
108
108
|
accounts: z.record(z.string(), z.object({ session: PercentSchema, weekly: PercentSchema, credits: z.boolean().default(false) })).default({}),
|
|
109
109
|
})
|