tokenmaxxing 1.172.0 → 1.173.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/README.md +2 -2
- package/package.json +1 -1
- package/src/lib/claude.ts +9 -2
- package/src/lib/codex.ts +25 -1
- package/src/lib/decide.ts +2 -2
- package/src/lib/picker.ts +35 -5
- package/src/lib/provider.ts +1 -0
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
|
|
3
|
+
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with room for it whose weekly quota is closest to expiring unused, 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 Muse Code as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
|
|
6
6
|
|
|
@@ -62,7 +62,7 @@ Each session runs on its own seat. One rule: while the seat is under its bars, n
|
|
|
62
62
|
|
|
63
63
|
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.
|
|
64
64
|
|
|
65
|
-
Launches and moves rank usable accounts
|
|
65
|
+
Launches and moves rank usable accounts in groups first: plan headroom before usage credits, a credential store that read cleanly, room for a whole session (session-window headroom per running session, `(bar - used) / (sessions + 1)`, scaled by plan size, at least 15 unless the session window resets within the hour), a weekly reset within 4 days, and a usage figure under 6 hours old. Within a group, the highest pace pressure scaled by plan size wins (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), so weekly quota closest to expiring unused is spent first; ties break by soonest weekly reset, then headroom per session, then lowest weekly use, then fewest sessions. Plan size reads the account's tier: a Claude multiplier divided by 5 (`max 20x` is 4, `pro` 0.2), Codex relative to Plus (`prolite` 5, `pro` 10, `promax` 20). Organization membership is not an input.
|
|
66
66
|
|
|
67
67
|
- 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.
|
|
68
68
|
- 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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.173.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
|
@@ -140,6 +140,12 @@ async function storeUsable(a: Account): Promise<boolean> {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
export function claudePlanWeight(a: Account): number {
|
|
144
|
+
const multiplier = a.tier?.match(/(\d+)x\b/)?.[1];
|
|
145
|
+
if (multiplier != null) return Number(multiplier) / 5;
|
|
146
|
+
return a.tier?.startsWith("pro") ? 0.2 : 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
143
149
|
export type Seat = { account: Account; depletedUntil?: number };
|
|
144
150
|
|
|
145
151
|
export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Seat | null {
|
|
@@ -150,7 +156,7 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
|
|
|
150
156
|
for (const a of idx.accounts) dirty = foldTee(a, bars) || dirty;
|
|
151
157
|
if (dirty) saveAccounts(claudePool, idx);
|
|
152
158
|
const seats = presence();
|
|
153
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
|
|
159
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats, planWeight: claudePlanWeight };
|
|
154
160
|
const candidates = idx.accounts.filter(eligible);
|
|
155
161
|
const best = pickBest(candidates, ctx);
|
|
156
162
|
if (best) return { account: best };
|
|
@@ -204,7 +210,7 @@ export async function borrowClaudeSeat(pid: number, named?: string): Promise<Sea
|
|
|
204
210
|
}
|
|
205
211
|
const open = idx.accounts.filter((a) => a.needsReauth !== true);
|
|
206
212
|
const usable = await Promise.all(open.map(storeUsable));
|
|
207
|
-
const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living) };
|
|
213
|
+
const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living), planWeight: claudePlanWeight };
|
|
208
214
|
const picked = pickBest(open.filter((_, i) => usable[i]), ctx);
|
|
209
215
|
if (!picked) return null;
|
|
210
216
|
writePresence({ dir: paths.presenceDir, id: seatId, accountId: picked.id, pid });
|
|
@@ -361,6 +367,7 @@ export const claude: Provider = {
|
|
|
361
367
|
apiKeys: claudeApiKeys,
|
|
362
368
|
liveId,
|
|
363
369
|
presence,
|
|
370
|
+
planWeight: claudePlanWeight,
|
|
364
371
|
gatedFamilies: (cfg) => {
|
|
365
372
|
const seat = liveId();
|
|
366
373
|
const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
|
package/src/lib/codex.ts
CHANGED
|
@@ -276,8 +276,31 @@ function install(): void {
|
|
|
276
276
|
console.log(c.yellow("`tokenmaxxing doctor` lists seats still needing trust."));
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// Relative to Plus. prolite (Pro 100) is 5x per OpenAI's launch; pro (Pro 200) was 20x and is about 10x for new
|
|
280
|
+
// subscribers; promax (Pro 500) is unpublished beyond "the most". Business, Enterprise, and Edu seats match Plus.
|
|
281
|
+
const CODEX_PLAN_WEIGHTS: Record<string, number> = {
|
|
282
|
+
free: 0.2,
|
|
283
|
+
go: 0.5,
|
|
284
|
+
plus: 1,
|
|
285
|
+
team: 1,
|
|
286
|
+
business: 1,
|
|
287
|
+
enterprise: 1,
|
|
288
|
+
ent26: 1,
|
|
289
|
+
edu: 1,
|
|
290
|
+
edu_plus: 1,
|
|
291
|
+
prolite: 5,
|
|
292
|
+
self_serve_business_prolite: 5,
|
|
293
|
+
edu_pro: 5,
|
|
294
|
+
pro: 10,
|
|
295
|
+
promax: 20,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export function codexPlanWeight(a: Account): number {
|
|
299
|
+
return (a.tier == null ? undefined : CODEX_PLAN_WEIGHTS[a.tier.toLowerCase()]) ?? 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
279
302
|
export function codexPickCtx(now: number, currentId: string | null): PickCtx {
|
|
280
|
-
return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
|
|
303
|
+
return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null, planWeight: codexPlanWeight };
|
|
281
304
|
}
|
|
282
305
|
|
|
283
306
|
export function pickCodexSeat(now: number, eligible: (a: Account) => boolean = (a) => codexStoreUsable(a.id)): Account | null {
|
|
@@ -343,6 +366,7 @@ export const codex: Provider = {
|
|
|
343
366
|
statusOnly: false,
|
|
344
367
|
liveId,
|
|
345
368
|
presence,
|
|
369
|
+
planWeight: codexPlanWeight,
|
|
346
370
|
gatedFamilies: () => null,
|
|
347
371
|
observeLive: (account, cfg, now, opts) => observeCodex(account, cfg, now, { probe: opts.probe, refresh: true }),
|
|
348
372
|
samplePool,
|
package/src/lib/decide.ts
CHANGED
|
@@ -199,7 +199,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
199
199
|
while (true) {
|
|
200
200
|
const cur = loadAccounts(p.pool);
|
|
201
201
|
const seat = seatOf(cur);
|
|
202
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
|
|
202
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
|
|
203
203
|
const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
|
|
204
204
|
const best = pickBest(usable(cur.accounts).filter((a) => !((credited || keyId != null) && opts.refused?.includes(a.id))), ctx);
|
|
205
205
|
if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
|
|
@@ -232,7 +232,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
232
232
|
while (true) {
|
|
233
233
|
const fresh = loadAccounts(p.pool);
|
|
234
234
|
const current = seatOf(fresh);
|
|
235
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
|
|
235
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
|
|
236
236
|
const others = liveWaitClaims(now).filter((c) => c.sessionId !== waiterId);
|
|
237
237
|
const waiters = new Map(Object.entries(countBy(others, (c) => c.accountId)));
|
|
238
238
|
const waitable = usable(fresh.accounts).filter((a) => keyId == null || !opts.refused?.includes(a.id));
|
package/src/lib/picker.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type PickCtx = {
|
|
|
17
17
|
currentId: string | null;
|
|
18
18
|
families: string[] | null;
|
|
19
19
|
seats: Map<string, number> | null;
|
|
20
|
+
planWeight?: (a: Account) => number;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -166,29 +167,58 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
|
|
|
166
167
|
|
|
167
168
|
const MIN_SEAT_HEADROOM = 5;
|
|
168
169
|
|
|
170
|
+
const planWeight = (a: Account, ctx: PickCtx): number => ctx.planWeight?.(a) ?? 1;
|
|
171
|
+
|
|
169
172
|
function seatFull(a: Account, ctx: PickCtx): boolean {
|
|
170
173
|
const session = sessionWindow(a);
|
|
171
174
|
if (ctx.seats == null || session == null || spendsCredits(a, ctx.thresholds)) return false;
|
|
172
|
-
return seatHeadroom(a, ctx) < MIN_SEAT_HEADROOM;
|
|
175
|
+
return seatHeadroom(a, ctx) * planWeight(a, ctx) < MIN_SEAT_HEADROOM;
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
export function onCredits(a: Account, ctx: PickCtx): boolean {
|
|
176
179
|
return spendsCredits(a, ctx.thresholds) && isExhausted({ ...a, hasCredits: false }, ctx);
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
183
|
+
const WEEKLY_SOON_MS = 4 * DAY_MS;
|
|
184
|
+
const THREAD_HEADROOM = 15;
|
|
185
|
+
const SESSION_SOON_MS = 60 * 60 * 1000;
|
|
186
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
187
|
+
|
|
188
|
+
function cramped(a: Account, ctx: PickCtx): boolean {
|
|
189
|
+
const session = sessionWindow(a);
|
|
190
|
+
if (session == null || spendsCredits(a, ctx.thresholds)) return false;
|
|
191
|
+
const end = windowEnd(session);
|
|
192
|
+
const resetsSoon = end > ctx.now && end - ctx.now <= SESSION_SOON_MS;
|
|
193
|
+
return !resetsSoon && seatHeadroom(a, ctx) * planWeight(a, ctx) < THREAD_HEADROOM;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Judged per window, because a fresh statusline sample refreshes the session and weekly figures but carries an old per-model cap forward.
|
|
197
|
+
function stale(a: Account, ctx: PickCtx): boolean {
|
|
198
|
+
const read = [sessionWindow(a), weeklyWindow(a), ...gatedWindows(a, ctx.families)];
|
|
199
|
+
return read.some((w) => w != null && windowEnd(w) > ctx.now && ctx.now - w.sampledAt > STALE_MS);
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
const swapPreference = (ctx: PickCtx) => [
|
|
180
203
|
(a: Account) => (onCredits(a, ctx) ? 1 : 0),
|
|
181
|
-
|
|
182
|
-
(a: Account) =>
|
|
204
|
+
(a: Account) => ((a.storeFails ?? 0) > 0 ? 1 : 0),
|
|
205
|
+
(a: Account) => (a.windows.length === 0 ? 1 : 0),
|
|
206
|
+
// A full account ranks after every open one, and among full accounts the most room per session wins.
|
|
207
|
+
(a: Account) => (seatFull(a, ctx) ? 1 : 0),
|
|
208
|
+
(a: Account) => (seatFull(a, ctx) ? -seatHeadroom(a, ctx) * planWeight(a, ctx) : 0),
|
|
209
|
+
(a: Account) => (cramped(a, ctx) ? 1 : 0),
|
|
210
|
+
(a: Account) => (weeklyExpiry(a, ctx.now) - ctx.now <= WEEKLY_SOON_MS ? 0 : 1),
|
|
211
|
+
(a: Account) => (stale(a, ctx) ? 1 : 0),
|
|
212
|
+
(a: Account) => -pacePressure(a, ctx) * planWeight(a, ctx),
|
|
183
213
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
214
|
+
...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx) * planWeight(a, ctx)]),
|
|
184
215
|
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
185
216
|
...(ctx.seats == null ? [] : [(a: Account) => ctx.seats?.get(a.id) ?? 0]),
|
|
186
217
|
];
|
|
187
218
|
|
|
188
219
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
189
220
|
const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
|
|
190
|
-
|
|
191
|
-
return sortBy(open.length > 0 ? open : usable, swapPreference(ctx))[0] ?? null;
|
|
221
|
+
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
package/src/lib/provider.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type Provider = {
|
|
|
36
36
|
liveId(): string | null;
|
|
37
37
|
presence(): Map<string, number>;
|
|
38
38
|
gatedFamilies(cfg: Config): string[] | null;
|
|
39
|
+
planWeight?(account: Account): number;
|
|
39
40
|
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
|
|
40
41
|
samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>>;
|
|
41
42
|
mergeWindows(next: Window[], prev: Window[]): Window[];
|