tokenmaxxing 1.2.1 → 1.3.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 -2
- package/package.json +1 -1
- package/src/cli/config.ts +2 -0
- package/src/cli/switch.ts +3 -1
- package/src/lib/codexdecide.ts +7 -1
- package/src/lib/decide.ts +36 -7
- package/src/lib/picker.ts +17 -0
- package/src/lib/state.ts +16 -3
- package/src/lib/types.ts +20 -0
package/DESIGN.md
CHANGED
|
@@ -79,9 +79,11 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The
|
|
82
|
+
The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= 95%` OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
|
|
83
83
|
|
|
84
|
-
**
|
|
84
|
+
**Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. The serve/SDK path additionally stamps an account walled the instant an *errored* turn result reports a limit (`recordObservedLimit`, gated on `is_error`), because it has no statusLine tee. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
|
|
85
|
+
|
|
86
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
|
|
85
87
|
|
|
86
88
|
---
|
|
87
89
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/config.ts
CHANGED
package/src/cli/switch.ts
CHANGED
|
@@ -113,7 +113,9 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
113
113
|
// No usable target swapped in: everything is depleted, or the remaining
|
|
114
114
|
// candidates' refresh tokens just died (performSwap persists needs-reauth
|
|
115
115
|
// before throwing, hence the reload). Stay on / switch to whichever
|
|
116
|
-
// recovers soonest.
|
|
116
|
+
// recovers soonest. (Layer 2 - the wall squeeze - is deliberately confined
|
|
117
|
+
// to the automatic decision path in decide.ts, which decides off the live
|
|
118
|
+
// statusLine tee; bare `xx switch` stays cache-only and simply parks here.)
|
|
117
119
|
const fresh = loadAccounts();
|
|
118
120
|
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
119
121
|
if (!earliest) {
|
package/src/lib/codexdecide.ts
CHANGED
|
@@ -260,7 +260,13 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
|
|
|
260
260
|
|
|
261
261
|
// Hard path: a bar is crossed. Land on the best usable candidate, walking
|
|
262
262
|
// past dead grants; a fully depleted pool stays put (no pre-park: nothing
|
|
263
|
-
// can pause a codex session for a countdown yet).
|
|
263
|
+
// can pause a codex session for a countdown yet). Layer 2 (the wall) is
|
|
264
|
+
// deliberately claude-only: a running codex cannot hot-adopt a swapped
|
|
265
|
+
// credential (restart IS the switch), so a last-drop-swap onto a still-
|
|
266
|
+
// under-wall account would strand any concurrent sibling on the departed
|
|
267
|
+
// account (the reconcile can only signal siblings onto a Layer-1-usable
|
|
268
|
+
// seat), and a hold-only Layer 2 is identical to codex already staying put
|
|
269
|
+
// here - so codex just rides the current account to its wall.
|
|
264
270
|
const tried = new Set<string>();
|
|
265
271
|
while (true) {
|
|
266
272
|
const current = loadCodexAccounts();
|
package/src/lib/decide.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { paths } from "./paths.ts";
|
|
|
30
30
|
import { loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
31
31
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
32
32
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
33
|
-
import { currentWins, effectiveBars, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
33
|
+
import { currentWins, effectiveBars, hardBars, isExhausted, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
|
|
34
34
|
import { InvalidGrantError } from "./oauth.ts";
|
|
35
35
|
import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
|
|
36
36
|
import { log } from "./log.ts";
|
|
@@ -307,15 +307,44 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
307
307
|
const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
|
|
308
308
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
309
309
|
|
|
310
|
-
// Every account is
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
310
|
+
// ── LAYER 2 (the wall). Every account is exhausted at the Layer 1
|
|
311
|
+
// screening bars, so Layer 1 alone would park the pool right here with
|
|
312
|
+
// quota still unspent on every account. Before parking, pump the last drops
|
|
313
|
+
// against the hard wall bars (default the server's own 100% limit, the same
|
|
314
|
+
// figure /rate-limit-options reads): hold the seat while it is still under
|
|
315
|
+
// its wall, else move onto the best still-under-wall account (chooseAndSwap
|
|
316
|
+
// keeps the usual pace-pressure ranking - squeeze the account whose weekly
|
|
317
|
+
// quota is most about to be forfeited first). Only when EVERY account has
|
|
318
|
+
// truly walled do we fall through to the depleted-wait park below. The wall
|
|
319
|
+
// reading is the statusLine's authoritative rate_limits tee (the same data
|
|
320
|
+
// /rate-limit-options renders); a single-turn overshoot is caught one
|
|
321
|
+
// boundary later by the check timer or the next Stop hook, and the serve/SDK
|
|
322
|
+
// path additionally stamps observed limits on errored results.
|
|
323
|
+
const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
|
|
324
|
+
const seat = seatOf(loadAccounts());
|
|
325
|
+
if (seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
|
|
326
|
+
log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
|
|
327
|
+
return { swapped: false, account: null, reason: "last-drop-hold" };
|
|
328
|
+
}
|
|
329
|
+
const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null });
|
|
330
|
+
if (squeezed) {
|
|
331
|
+
log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
|
|
332
|
+
return { swapped: true, account: squeezed, reason: "last-drop-swap" };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Every account is walled. Wait for whichever drops below its wall soonest
|
|
336
|
+
// (including the current one), if that reset is within the auto-wait window.
|
|
337
|
+
// Recovery is measured against the WALL, not the screening bars: an account
|
|
338
|
+
// whose session window resets below 100 is squeezable again even while its
|
|
339
|
+
// weekly window still sits above the Layer 1 bar, so waiting on the Layer 1
|
|
340
|
+
// reset would over-park. A dead grant on the chosen pre-park target must not
|
|
341
|
+
// abort the wait: performSwap persists needs-reauth before throwing, so each
|
|
342
|
+
// retry re-ranks without the dead account and the loop terminates (mirrors
|
|
343
|
+
// the greedy loop above).
|
|
315
344
|
while (true) {
|
|
316
345
|
const fresh = loadAccounts();
|
|
317
346
|
const current = seatOf(fresh);
|
|
318
|
-
const ctx = { now, thresholds:
|
|
347
|
+
const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
|
|
319
348
|
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
320
349
|
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
321
350
|
|
package/src/lib/picker.ts
CHANGED
|
@@ -27,6 +27,23 @@ export function effectiveBars(cfg: Config): Thresholds {
|
|
|
27
27
|
};
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/** LAYER 2 - the wall bars. The projection margin is subtracted the SAME way
|
|
31
|
+
* effectiveBars does it, for two reasons: (1) it keeps the documented disable
|
|
32
|
+
* contract honest - `hardThresholds == thresholds` then yields hardBars ==
|
|
33
|
+
* effectiveBars, so Layer 2 has no band to act in and is truly off (without
|
|
34
|
+
* the margin here a nonzero margin left a live band between the two, review
|
|
35
|
+
* catch PR #47); (2) at the default margin 0 the wall is still the literal 100
|
|
36
|
+
* (the server's own figure /rate-limit-options reads). Used only in the
|
|
37
|
+
* all-Layer-1-exhausted fallback, where an account under its wall is still
|
|
38
|
+
* worth squeezing. Config's refine (hardThresholds >= thresholds) guarantees
|
|
39
|
+
* hardBars >= effectiveBars, so Layer 2 is never stricter than Layer 1. */
|
|
40
|
+
export function hardBars(cfg: Config): Thresholds {
|
|
41
|
+
return {
|
|
42
|
+
session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
|
|
43
|
+
weekly: cfg.hardThresholds.weekly - cfg.policy.projectionMargin,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
const PickCtxSchema = z.object({
|
|
31
48
|
now: z.number(),
|
|
32
49
|
thresholds: ThresholdsSchema,
|
package/src/lib/state.ts
CHANGED
|
@@ -20,9 +20,14 @@ import {
|
|
|
20
20
|
// ---- config.json (minimal, fixed schema) ---------------------------------
|
|
21
21
|
|
|
22
22
|
const DEFAULT_CONFIG: Config = {
|
|
23
|
-
//
|
|
24
|
-
// cheap to sit out, weekly quota is use-it-or-lose-it so it drains
|
|
23
|
+
// LAYER 1 - screening bars, split per window (user 2026-07-16): a session
|
|
24
|
+
// reset is cheap to sit out, weekly quota is use-it-or-lose-it so it drains
|
|
25
|
+
// to 98. These drive normal account-to-account switching with headroom.
|
|
25
26
|
thresholds: { session: 95, weekly: 98 },
|
|
27
|
+
// LAYER 2 - the wall bars (default the server's own 100% limit). Reached only
|
|
28
|
+
// once every account is over its Layer 1 bar: from there a session pumps the
|
|
29
|
+
// last drops up to the wall instead of parking with quota unspent.
|
|
30
|
+
hardThresholds: { session: 100, weekly: 100 },
|
|
26
31
|
claudeBin: "",
|
|
27
32
|
codexBin: "",
|
|
28
33
|
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
@@ -42,6 +47,7 @@ const PercentSchema = z.number().min(0).max(100);
|
|
|
42
47
|
export const ConfigFileSchema = z
|
|
43
48
|
.object({
|
|
44
49
|
thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
50
|
+
hardThresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
|
|
45
51
|
claudeBin: z.string(),
|
|
46
52
|
codexBin: z.string(),
|
|
47
53
|
policy: z
|
|
@@ -69,9 +75,16 @@ export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
|
|
|
69
75
|
* throw, silently disabling status/switch/hooks/statusline until the file is
|
|
70
76
|
* hand-repaired (closing-review catch). */
|
|
71
77
|
export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
|
|
72
|
-
const cfg: Config = {
|
|
78
|
+
const cfg: Config = {
|
|
79
|
+
...DEFAULT_CONFIG,
|
|
80
|
+
thresholds: { ...DEFAULT_CONFIG.thresholds },
|
|
81
|
+
hardThresholds: { ...DEFAULT_CONFIG.hardThresholds },
|
|
82
|
+
policy: { ...DEFAULT_CONFIG.policy },
|
|
83
|
+
};
|
|
73
84
|
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
74
85
|
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
86
|
+
cfg.hardThresholds.session = p.hardThresholds?.session ?? cfg.hardThresholds.session;
|
|
87
|
+
cfg.hardThresholds.weekly = p.hardThresholds?.weekly ?? cfg.hardThresholds.weekly;
|
|
75
88
|
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
76
89
|
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
77
90
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
package/src/lib/types.ts
CHANGED
|
@@ -142,6 +142,18 @@ export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
|
142
142
|
export const ConfigSchema = z
|
|
143
143
|
.object({
|
|
144
144
|
thresholds: ThresholdsSchema,
|
|
145
|
+
/** LAYER 2 - the wall bars. `thresholds` (Layer 1) screen normal
|
|
146
|
+
* account-to-account switching and deliberately leave headroom; these are
|
|
147
|
+
* the true-wall bars the decision falls back to ONLY once every account is
|
|
148
|
+
* exhausted at Layer 1. Below the wall a session holds its seat and pumps
|
|
149
|
+
* the last drops; a window at/over the wall (default 100 = the server's own
|
|
150
|
+
* limit, the same figure /rate-limit-options reads) is genuinely spent and
|
|
151
|
+
* the pool moves to the next account, parking only when all are walled.
|
|
152
|
+
* Set equal to `thresholds` to disable Layer 2 (hardBars subtracts the same
|
|
153
|
+
* projectionMargin as effectiveBars, so equal thresholds collapse to one
|
|
154
|
+
* effective bar and Layer 2 has no band to act in). At the default margin 0
|
|
155
|
+
* the wall is the literal 100. */
|
|
156
|
+
hardThresholds: ThresholdsSchema,
|
|
145
157
|
claudeBin: z.string(),
|
|
146
158
|
/** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
|
|
147
159
|
codexBin: z.string(),
|
|
@@ -168,6 +180,14 @@ export const ConfigSchema = z
|
|
|
168
180
|
// switch path churns. Per-field bounds alone cannot see this.
|
|
169
181
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
|
|
170
182
|
message: "policy.projectionMargin must be strictly below both thresholds (effectiveBars would hit zero and every account would read as exhausted)",
|
|
183
|
+
})
|
|
184
|
+
// The wall must sit at or above each screening bar. A wall BELOW its
|
|
185
|
+
// screening bar would make Layer 2 "usable" a stricter test than Layer 1
|
|
186
|
+
// screening - the pool could reach the wall fallback and find every account
|
|
187
|
+
// already over the (lower) wall, parking earlier than Layer 1 alone would.
|
|
188
|
+
// Equal is allowed and simply disables Layer 2 for that window.
|
|
189
|
+
.refine((cfg) => cfg.hardThresholds.session >= cfg.thresholds.session && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
|
|
190
|
+
message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars) for both windows",
|
|
171
191
|
});
|
|
172
192
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
173
193
|
|