tokenmaxxing 0.5.0 → 0.6.1
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 +1 -1
- package/README.md +4 -2
- package/package.json +1 -1
- package/src/cli/add.ts +1 -0
- package/src/cli/render.ts +13 -0
- package/src/cli/status.ts +20 -10
- package/src/cli/switch.ts +78 -24
- package/src/entries/sessionstart.ts +2 -2
- package/src/entries/statusline.ts +7 -2
- package/src/entries/stophook.ts +4 -1
- package/src/lib/claudebin.ts +8 -3
- package/src/lib/decide.ts +130 -60
- package/src/lib/picker.ts +74 -43
- package/src/lib/state.ts +34 -4
- package/src/lib/types.ts +4 -0
- package/src/lib/usage.ts +29 -6
- package/src/main.ts +1 -1
package/DESIGN.md
CHANGED
|
@@ -78,7 +78,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
78
78
|
## 5. Rotation policy
|
|
79
79
|
Trigger at `five_hour >= 95%` OR `seven_day >= 95%`, per org. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`95 − EMA(per-turn Δ%)`) so a single large turn can't blow past 100% before the next Stop hook.
|
|
80
80
|
|
|
81
|
-
**Model-aware trigger.** Claude subscriptions also enforce
|
|
81
|
+
**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.
|
|
82
82
|
|
|
83
83
|
---
|
|
84
84
|
|
package/README.md
CHANGED
|
@@ -48,10 +48,12 @@ claude # use claude as always
|
|
|
48
48
|
Switching triggers at **95%** (configurable) on any of:
|
|
49
49
|
|
|
50
50
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
51
|
-
- **Per-model weekly cap** - the capable
|
|
51
|
+
- **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 `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
|
|
52
52
|
|
|
53
53
|
The 5% headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall.
|
|
54
54
|
|
|
55
|
+
The **target** is chosen greedily off each account's cached windows: the usable account (session and week under threshold, or past their reset) whose weekly window **expires soonest** - unused weekly allowance is forfeited at the fixed per-account reset. Cached resets are absolute UTC epochs, so a stale snapshot still resolves correctly: a weekly reset that has passed extrapolates forward in 7-day steps, and a session window past its reset counts as empty. `tokenmaxxing switch` ranks the current account too and does nothing when it already wins, so it is idempotent - running it periodically converges on the right account.
|
|
56
|
+
|
|
55
57
|
## Configuration
|
|
56
58
|
|
|
57
59
|
`~/.config/tokenmaxxing/config.json` (every field optional):
|
|
@@ -61,7 +63,7 @@ The 5% headroom is deliberate: it's the budget to reach a clean turn boundary an
|
|
|
61
63
|
"threshold": 95,
|
|
62
64
|
"policy": {
|
|
63
65
|
"projectionMargin": 0,
|
|
64
|
-
"switchModels": ["fable"
|
|
66
|
+
"switchModels": ["fable"],
|
|
65
67
|
"usagePollTtlMs": 90000
|
|
66
68
|
}
|
|
67
69
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
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/add.ts
CHANGED
|
@@ -118,6 +118,7 @@ export async function cmdAdd(): Promise<number> {
|
|
|
118
118
|
needsReauth: false,
|
|
119
119
|
lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
|
|
120
120
|
lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
|
|
121
|
+
lastUsageAt: sampled ? Date.now() : existing?.lastUsageAt,
|
|
121
122
|
};
|
|
122
123
|
if (existing) Object.assign(existing, fresh);
|
|
123
124
|
else idx.accounts.push(fresh);
|
package/src/cli/render.ts
CHANGED
|
@@ -40,6 +40,19 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
|
|
|
40
40
|
return `resets in ${m}m`;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Age of a cached sample, largest unit only: "just now", "3m ago", "2h ago",
|
|
44
|
+
* "5d ago". */
|
|
45
|
+
export function fmtAgo(epochMs: number, now = Date.now()): string {
|
|
46
|
+
const dsec = Math.max(0, Math.round((now - epochMs) / 1000));
|
|
47
|
+
if (dsec < 60) return "just now";
|
|
48
|
+
const d = Math.floor(dsec / 86400);
|
|
49
|
+
const h = Math.floor((dsec % 86400) / 3600);
|
|
50
|
+
const m = Math.floor((dsec % 3600) / 60);
|
|
51
|
+
if (d > 0) return `${d}d ago`;
|
|
52
|
+
if (h > 0) return `${h}h ago`;
|
|
53
|
+
return `${m}m ago`;
|
|
54
|
+
}
|
|
55
|
+
|
|
43
56
|
/** Compact time-until-reset for the statusLine: the largest unit only ("6d",
|
|
44
57
|
* "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
|
|
45
58
|
* once the reset has passed (the window is simply empty again). Non-empty
|
package/src/cli/status.ts
CHANGED
|
@@ -10,8 +10,8 @@ import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
|
10
10
|
import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
11
11
|
import { withLock } from "../lib/lock.ts";
|
|
12
12
|
import { paths } from "../lib/paths.ts";
|
|
13
|
-
import { isExhausted } from "../lib/picker.ts";
|
|
14
|
-
import { bar, c, fmtReset } from "./render.ts";
|
|
13
|
+
import { isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
14
|
+
import { bar, c, fmtAgo, fmtReset } from "./render.ts";
|
|
15
15
|
import type { FullUsage } from "../lib/usage.ts";
|
|
16
16
|
import type { UsageWindow } from "../lib/types.ts";
|
|
17
17
|
|
|
@@ -58,6 +58,9 @@ export async function cmdStatus(): Promise<number> {
|
|
|
58
58
|
if (!outcome.ok) return;
|
|
59
59
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
60
60
|
if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
|
|
61
|
+
// stamp when the figures were actually measured: the statusLine tee's
|
|
62
|
+
// own write time for the push-fed active account, else the probe time.
|
|
63
|
+
a.lastUsageAt = fromStatusLine && live ? live.ts : Date.now();
|
|
61
64
|
}),
|
|
62
65
|
);
|
|
63
66
|
saveAccounts(idx);
|
|
@@ -66,8 +69,15 @@ export async function cmdStatus(): Promise<number> {
|
|
|
66
69
|
console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
|
|
67
70
|
console.log();
|
|
68
71
|
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
73
|
+
// on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
|
|
74
|
+
// Fresh samples pass through unchanged (their resets are in the future).
|
|
75
|
+
const row = (name: string, w: UsageWindow, weekly: boolean) => {
|
|
76
|
+
const passed = w.resetsAt != null && w.resetsAt <= now;
|
|
77
|
+
const pct = passed ? 0 : w.usedPercentage;
|
|
78
|
+
const resetsAt = weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt;
|
|
79
|
+
console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
|
|
80
|
+
};
|
|
71
81
|
|
|
72
82
|
for (const a of idx.accounts) {
|
|
73
83
|
const active = a.accountUuid === idx.activeAccountUuid;
|
|
@@ -82,18 +92,18 @@ export async function cmdStatus(): Promise<number> {
|
|
|
82
92
|
const badges: string[] = [];
|
|
83
93
|
if (active) badges.push(c.green("active"));
|
|
84
94
|
if (a.needsReauth) badges.push(c.red("needs-reauth"));
|
|
85
|
-
if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid }))
|
|
95
|
+
if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
|
|
86
96
|
badges.push(c.yellow("exhausted"));
|
|
87
97
|
|
|
88
98
|
console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
|
|
89
99
|
if (aggregate) {
|
|
90
|
-
row("5h", aggregate.fiveHour);
|
|
91
|
-
row("week", aggregate.sevenDay);
|
|
100
|
+
row("5h", aggregate.fiveHour, false);
|
|
101
|
+
row("week", aggregate.sevenDay, true);
|
|
92
102
|
}
|
|
93
|
-
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w);
|
|
103
|
+
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w, true);
|
|
94
104
|
if (failed && outcome && !outcome.ok) {
|
|
95
|
-
const
|
|
96
|
-
console.log(` ${c.yellow(
|
|
105
|
+
const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""} · ` : "";
|
|
106
|
+
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
|
|
97
107
|
}
|
|
98
108
|
console.log();
|
|
99
109
|
}
|
package/src/cli/switch.ts
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
|
-
// `tokenmaxxing switch [selector]
|
|
2
|
-
// No selector →
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `tokenmaxxing switch [selector]`.
|
|
2
|
+
// No selector → greedy: rank EVERY account (current included) by soonest weekly
|
|
3
|
+
// expiry among those with session/week under threshold, off the cached windows.
|
|
4
|
+
// When the current account already wins (or ties - swapping between equals buys
|
|
5
|
+
// nothing), do nothing: the command is idempotent, so running it periodically
|
|
6
|
+
// converges on the right account. With a selector → switch to that one. Runs
|
|
7
|
+
// under the flock. When everything is depleted it stays on / switches to
|
|
8
|
+
// whichever account recovers soonest.
|
|
9
|
+
//
|
|
10
|
+
// The active LABEL can drift from the live login (manual /login is the
|
|
11
|
+
// surviving drift source). A no-op would freeze that drift forever, so when
|
|
12
|
+
// ~/.claude.json names a different account than accounts.json we swap for real
|
|
13
|
+
// instead of trusting the label - performSwap resolves the live credential's
|
|
14
|
+
// true owner, harvesting the drifted login correctly.
|
|
5
15
|
|
|
6
16
|
import { withLock } from "../lib/lock.ts";
|
|
7
17
|
import { paths } from "../lib/paths.ts";
|
|
8
18
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
19
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
9
20
|
import { performSwap, chooseAndSwap } from "../lib/swap.ts";
|
|
10
|
-
import { pickEarliestReset } from "../lib/picker.ts";
|
|
21
|
+
import { isExhausted, pickBest, pickEarliestReset, swapPreference, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
|
|
11
22
|
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
12
23
|
import { findAccount } from "./rename.ts";
|
|
13
24
|
import { c, fmtReset } from "./render.ts";
|
|
25
|
+
import type { Account } from "../lib/types.ts";
|
|
14
26
|
|
|
15
27
|
export async function cmdSwitch(selector?: string): Promise<number> {
|
|
16
28
|
const idx0 = loadAccounts();
|
|
@@ -23,12 +35,10 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
23
35
|
|
|
24
36
|
return withLock(paths.lockFile, async () => {
|
|
25
37
|
const idx = loadAccounts();
|
|
38
|
+
const claimed = readOAuthAccount()?.accountUuid ?? null;
|
|
39
|
+
const drifted = claimed != null && claimed !== idx.activeAccountUuid;
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
const target = findAccount(idx.accounts, selector);
|
|
29
|
-
if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
|
|
30
|
-
if (target.accountUuid === idx.activeAccountUuid) { console.log(`already on ${c.bold(target.label)}`); return 0; }
|
|
31
|
-
if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing add\``)); return 1; }
|
|
41
|
+
const swapTo = async (target: Account): Promise<number> => {
|
|
32
42
|
try {
|
|
33
43
|
await performSwap(target);
|
|
34
44
|
} catch (e) {
|
|
@@ -37,25 +47,69 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
37
47
|
}
|
|
38
48
|
console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
|
|
39
49
|
return 0;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
if (selector) {
|
|
53
|
+
const target = findAccount(idx.accounts, selector);
|
|
54
|
+
if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
|
|
55
|
+
if (target.accountUuid === idx.activeAccountUuid && !drifted) { console.log(`already on ${c.bold(target.label)}`); return 0; }
|
|
56
|
+
if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing add\``)); return 1; }
|
|
57
|
+
return swapTo(target);
|
|
40
58
|
}
|
|
41
59
|
|
|
42
|
-
// auto:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
// auto: greedy over everyone, current included - a no-op when current wins.
|
|
61
|
+
// No session context here, so every configured per-model family gates.
|
|
62
|
+
const everyone: PickCtx = { now, threshold: cfg.threshold, currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
|
|
63
|
+
const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
|
|
64
|
+
const best = pickBest(idx.accounts, everyone);
|
|
65
|
+
const currentWins =
|
|
66
|
+
best != null && active != null && !active.needsReauth && !isExhausted(active, everyone) &&
|
|
67
|
+
(best.accountUuid === active.accountUuid || swapPreference(now).every((k) => k(active) === k(best)));
|
|
68
|
+
if (currentWins) {
|
|
69
|
+
if (drifted) return swapTo(active);
|
|
70
|
+
const expiry = weeklyExpiry(active, now);
|
|
71
|
+
const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
|
|
72
|
+
console.log(`already on the best account: ${c.bold(active.label)}${why}`);
|
|
46
73
|
return 0;
|
|
47
74
|
}
|
|
75
|
+
if (best) {
|
|
76
|
+
const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies: cfg.policy.switchModels });
|
|
77
|
+
if (landed) {
|
|
78
|
+
console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
48
82
|
|
|
49
|
-
// everything is depleted
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
83
|
+
// No usable target swapped in: everything is depleted, or the remaining
|
|
84
|
+
// candidates' refresh tokens just died (chooseAndSwap marks needs-reauth,
|
|
85
|
+
// hence the reload). Stay on / switch to whichever recovers soonest.
|
|
86
|
+
const fresh = loadAccounts();
|
|
87
|
+
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
88
|
+
if (!earliest) {
|
|
89
|
+
// Either every account needs re-auth, or every account is blocked with no
|
|
90
|
+
// recoverable bound (unparsed reset clocks AND no sample time - see log).
|
|
91
|
+
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
92
|
+
if (reauth.length > 0) { console.error(c.yellow(`no switchable account - re-auth needed: ${reauth.join(", ")}`)); return 1; }
|
|
93
|
+
// never freeze a label drift behind a no-op (see header).
|
|
94
|
+
if (drifted && active) return swapTo(active);
|
|
95
|
+
console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
99
|
+
const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
|
|
100
|
+
if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
|
|
101
|
+
// availableAt in the past means the current account is fine and the
|
|
102
|
+
// others are simply unusable - do not claim a limit that is not there.
|
|
103
|
+
const msg = earliest.availableAt <= now
|
|
104
|
+
? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
|
|
105
|
+
: `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
|
|
106
|
+
console.log(c.yellow(msg));
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const code = await swapTo(earliest.account);
|
|
110
|
+
if (code === 0 && earliest.availableAt > now) {
|
|
111
|
+
console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(earliest.availableAt, now)})${reauthNote}`));
|
|
57
112
|
}
|
|
58
|
-
|
|
59
|
-
return 0;
|
|
113
|
+
return code;
|
|
60
114
|
});
|
|
61
115
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// SessionStart hook. A launch/resume backstop: if the active account is already
|
|
2
2
|
// over threshold with FRESH usage (e.g. a prior session left it exhausted), swap
|
|
3
3
|
// the credential before this session's first turn so it starts on a good account.
|
|
4
|
-
// Right after a respawn,
|
|
5
|
-
//
|
|
4
|
+
// Right after a respawn, the post-swap cooldown in evaluateAndMaybeSwap makes
|
|
5
|
+
// this correctly no-op.
|
|
6
6
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
@@ -17,7 +17,7 @@ import { sortBy } from "es-toolkit";
|
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
19
19
|
import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
|
|
20
|
-
import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
20
|
+
import { familyTokens, gatedFamilies, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
21
21
|
import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
|
|
22
22
|
import { worktreeName } from "../lib/worktree.ts";
|
|
23
23
|
import { fmtResetShort, makeColors } from "../cli/render.ts";
|
|
@@ -105,7 +105,12 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
105
105
|
: "";
|
|
106
106
|
|
|
107
107
|
// ---- parked accounts, in swap order: the first usable ◇ is the next target
|
|
108
|
-
const pickCtx = {
|
|
108
|
+
const pickCtx = {
|
|
109
|
+
now: ctx.now,
|
|
110
|
+
threshold: ctx.threshold,
|
|
111
|
+
currentAccountUuid: ctx.accounts.activeAccountUuid,
|
|
112
|
+
switchFamilies: gatedFamilies(parseStatusLineModel(stdinObj), ctx.switchModels),
|
|
113
|
+
};
|
|
109
114
|
const parked = sortBy(
|
|
110
115
|
ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
|
|
111
116
|
[(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
|
package/src/entries/stophook.ts
CHANGED
|
@@ -30,7 +30,10 @@ export async function runStopHook(): Promise<number> {
|
|
|
30
30
|
(parsed.success ? parsed.data.session_id : undefined) ?? process.env.TOKENMAXXING_SESSION_ID;
|
|
31
31
|
|
|
32
32
|
try {
|
|
33
|
-
|
|
33
|
+
// Anticipatory depleted swaps are only sane when the respawn marker below
|
|
34
|
+
// will actually pause the session until the reset.
|
|
35
|
+
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId != null;
|
|
36
|
+
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
34
37
|
// Respawn on a swap, or on a depleted-pool wait (relaunch after the reset).
|
|
35
38
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
36
39
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -7,9 +7,14 @@ import { loadConfig } from "./state.ts";
|
|
|
7
7
|
|
|
8
8
|
export function resolveRealClaude(): string {
|
|
9
9
|
const cfg = loadConfig();
|
|
10
|
-
if (cfg.claudeBin
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
if (cfg.claudeBin) {
|
|
11
|
+
if (existsSync(cfg.claudeBin)) return cfg.claudeBin;
|
|
12
|
+
// A configured-but-vanished binary must not silently degrade to the PATH
|
|
13
|
+
// scan: under a relocated TOKENMAXXING_HOME the scan's binDir guard misses
|
|
14
|
+
// the installed wrapper, which then recurses through the supervisor
|
|
15
|
+
// (observed 2026-07-12 as a forever-hung `/usage` probe).
|
|
16
|
+
throw new Error(`configured claudeBin does not exist: ${cfg.claudeBin} - fix config.json`);
|
|
17
|
+
}
|
|
13
18
|
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
14
19
|
if (!d || d === paths.binDir) continue;
|
|
15
20
|
const cand = join(d, "claude");
|
package/src/lib/decide.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
|
-
// Shared switch decision used by
|
|
2
|
-
// Cheap pre-check off the lock; the authoritative re-check + swap
|
|
1
|
+
// Shared switch decision used by the Stop/SessionStart hooks and the periodic
|
|
2
|
+
// `check` timer. Cheap pre-check off the lock; the authoritative re-check + swap
|
|
3
|
+
// under the flock.
|
|
3
4
|
//
|
|
4
5
|
// Two limit families are checked, both metered against the CURRENTLY-active org:
|
|
5
|
-
// 1. AGGREGATE windows (session=five_hour, week-all=seven_day)
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// 1. AGGREGATE windows (session=five_hour, week-all=seven_day). A rendering
|
|
7
|
+
// statusLine tees them fresh every turn; when nothing renders (headless
|
|
8
|
+
// boxes, idle TUIs) they come from `claude -p '/usage'`, re-probed once the
|
|
9
|
+
// snapshot ages past the poll TTL so they can never freeze at a stale value.
|
|
10
|
+
// 2. PER-MODEL weekly cap (e.g. "week (Fable)") - when the active model is
|
|
11
|
+
// capacity-constrained (config policy.switchModels), or for EVERY configured
|
|
12
|
+
// family when the model is unknown (nothing rendered within the TTL, so the
|
|
13
|
+
// session that stamped the last model may be gone).
|
|
9
14
|
//
|
|
10
15
|
// The org guard is load-bearing: right after a respawn, usage.json still reflects
|
|
11
16
|
// the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
|
|
@@ -15,14 +20,14 @@ import { maxBy } from "es-toolkit";
|
|
|
15
20
|
import { z } from "zod";
|
|
16
21
|
import { withLock } from "./lock.ts";
|
|
17
22
|
import { paths } from "./paths.ts";
|
|
18
|
-
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, writeUsage } from "./state.ts";
|
|
23
|
+
import { loadAccounts, loadConfig, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
|
|
19
24
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
20
25
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
21
26
|
import { pickEarliestReset, usableAt } from "./picker.ts";
|
|
22
27
|
import { InvalidGrantError } from "./oauth.ts";
|
|
23
|
-
import { familyTokens,
|
|
28
|
+
import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
|
|
24
29
|
import { log } from "./log.ts";
|
|
25
|
-
import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
30
|
+
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
26
31
|
|
|
27
32
|
const SwapDecisionSchema = z.object({
|
|
28
33
|
swapped: z.boolean(),
|
|
@@ -33,75 +38,130 @@ const SwapDecisionSchema = z.object({
|
|
|
33
38
|
});
|
|
34
39
|
export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
|
|
35
40
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return { fiveHour: full.session, sevenDay: full.weekAll, org, ts: Date.now(), model: null };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** Ensure a fresh-enough per-model cache for the active org; poll `/usage` if stale. */
|
|
44
|
-
async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsageState | null> {
|
|
45
|
-
const cached = loadModelUsage();
|
|
46
|
-
const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
|
|
47
|
-
if (fresh) return cached;
|
|
48
|
-
const full = await probeUsage();
|
|
49
|
-
if (!full) {
|
|
50
|
-
// Probing the live token fail-silently while it's busy is expected; stamp the
|
|
51
|
-
// cache so the next probe waits out the TTL instead of every turn re-paying
|
|
52
|
-
// the full backoff (the 2026-07-10 post-swap probe storm).
|
|
53
|
-
saveModelUsage({ perModel: cached?.org === org ? cached.perModel : {}, org, ts: Date.now() });
|
|
54
|
-
return cached;
|
|
55
|
-
}
|
|
56
|
-
const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
|
|
57
|
-
saveModelUsage(state);
|
|
58
|
-
return state;
|
|
41
|
+
/** A window's usable-against percentage NOW: one whose cached reset has passed
|
|
42
|
+
* is empty again, never a switch reason. */
|
|
43
|
+
function liveUsed(w: UsageWindow, now: number): number {
|
|
44
|
+
return w.resetsAt != null && w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
59
45
|
}
|
|
60
46
|
|
|
61
47
|
/** The family's weekly cap among the `/usage` rows; when several rows match the
|
|
62
|
-
* family, the most-used one wins (switching early beats metering a depleted
|
|
63
|
-
|
|
48
|
+
* family, the most-used LIVE one wins (switching early beats metering a depleted
|
|
49
|
+
* cap, but a row whose reset passed must not mask a still-burning sibling). */
|
|
50
|
+
function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWindow | undefined {
|
|
64
51
|
const rows = Object.entries(mu.perModel)
|
|
65
52
|
.filter(([k]) => familyTokens(k).includes(family))
|
|
66
53
|
.map(([, w]) => w);
|
|
67
|
-
return maxBy(rows, (w) => w
|
|
54
|
+
return maxBy(rows, (w) => liveUsed(w, now));
|
|
68
55
|
}
|
|
69
56
|
|
|
70
57
|
/** True if the active account is over the floor on ANY applicable limit. */
|
|
71
|
-
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
|
|
58
|
+
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number, now: number): boolean {
|
|
72
59
|
if (!u || !org || u.org !== org) return false;
|
|
73
|
-
if (u.fiveHour
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
60
|
+
if (liveUsed(u.fiveHour, now) >= floor || liveUsed(u.sevenDay, now) >= floor) return true;
|
|
61
|
+
if (mu && mu.org === org) {
|
|
62
|
+
for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
|
|
63
|
+
const cap = capForFamily(mu, family, now);
|
|
64
|
+
if (cap && liveUsed(cap, now) >= floor) return true;
|
|
65
|
+
}
|
|
78
66
|
}
|
|
79
67
|
return false;
|
|
80
68
|
}
|
|
81
69
|
|
|
82
|
-
/** Does
|
|
70
|
+
/** Does a per-model cap gate the decision for this usage snapshot? */
|
|
83
71
|
function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
84
|
-
return
|
|
72
|
+
return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const SnapshotsSchema = z.object({
|
|
76
|
+
u: UsageStateSchema.nullable(),
|
|
77
|
+
mu: ModelUsageStateSchema.nullable(),
|
|
78
|
+
});
|
|
79
|
+
type Snapshots = z.infer<typeof SnapshotsSchema>;
|
|
80
|
+
|
|
81
|
+
/** usage.json is trusted while the tee proved itself alive (mtime, NOT the
|
|
82
|
+
* embedded ts - write-on-change lets ts age under an alive feed) within the
|
|
83
|
+
* TTL for the live org. */
|
|
84
|
+
function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
|
|
85
|
+
if (u == null || u.org !== org) return false;
|
|
86
|
+
const teeAt = usageTeeAt();
|
|
87
|
+
return teeAt != null && now - teeAt <= ttl;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Load the two usage snapshots, re-probing `/usage` (free, 0 tokens) when they
|
|
92
|
+
* are absent, org-drifted, or older than the poll TTL. ONE probe carries all
|
|
93
|
+
* three limit kinds, so a success refreshes BOTH files; anything less leaves a
|
|
94
|
+
* headless box (no rendering statusLine to tee) evaluating frozen or
|
|
95
|
+
* org-mismatched values forever - the 2026-07-12 stella blindness. The
|
|
96
|
+
* refreshed usage carries model: null (whatever session stamped the old model
|
|
97
|
+
* may be gone), which gates every configured family. model-usage.json's ts also
|
|
98
|
+
* stamps FAILED attempts, so a busy live token cannot cause a probe storm
|
|
99
|
+
* (2026-07-10): the next hook waits out the TTL instead of re-probing.
|
|
100
|
+
*/
|
|
101
|
+
async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
|
|
102
|
+
let u = loadUsage();
|
|
103
|
+
let mu = loadModelUsage();
|
|
104
|
+
const ttl = cfg.policy.usagePollTtlMs;
|
|
105
|
+
const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
|
|
106
|
+
if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
|
|
107
|
+
const full = await probeUsage();
|
|
108
|
+
const ts = Date.now();
|
|
109
|
+
// A swap can complete while the probe runs (no lock is held here). Its
|
|
110
|
+
// result would then be stamped under the pre-swap org over the files the
|
|
111
|
+
// swap just cleared - discard it; the locked re-check below rejects this
|
|
112
|
+
// evaluation anyway and the next one re-probes the new org.
|
|
113
|
+
if (readOAuthAccount()?.organizationUuid === org) {
|
|
114
|
+
if (full) {
|
|
115
|
+
// A probe takes seconds; a rendering session's tee may have landed
|
|
116
|
+
// while it ran. The tee is fresher AND model-aware, so it wins.
|
|
117
|
+
const teed = loadUsage();
|
|
118
|
+
if (usageFresh(teed, org, ttl, ts)) {
|
|
119
|
+
u = teed;
|
|
120
|
+
} else {
|
|
121
|
+
u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
|
|
122
|
+
writeUsage(u);
|
|
123
|
+
}
|
|
124
|
+
mu = { perModel: full.perModel, org, ts };
|
|
125
|
+
saveModelUsage(mu);
|
|
126
|
+
} else {
|
|
127
|
+
mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts };
|
|
128
|
+
saveModelUsage(mu);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { u, mu };
|
|
85
133
|
}
|
|
86
134
|
|
|
87
|
-
|
|
135
|
+
/** How long after a swap the auto paths hold still. The statusLine tee is
|
|
136
|
+
* suppressed this long (sessions adopt the swap in <=30s), so a decision made
|
|
137
|
+
* sooner runs model-blind on data the swap itself invalidated - that is how a
|
|
138
|
+
* model-aware swap got immediately undone into an A<->B respawn loop. Manual
|
|
139
|
+
* `switch` is unaffected. */
|
|
140
|
+
const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* `anticipatory` allows the depleted path to swap onto an account that is still
|
|
144
|
+
* blocked but recovers soonest. Only a caller that can PAUSE until the reset
|
|
145
|
+
* (the supervised Stop hook, which writes a respawn marker the supervisor
|
|
146
|
+
* honors with a countdown) should pass true: from the check timer or an
|
|
147
|
+
* unsupervised hook, pre-parking silently yanks a live session onto a
|
|
148
|
+
* known-over-limit account, and buys nothing - the normal pick path adopts the
|
|
149
|
+
* recovering account the moment its reset passes.
|
|
150
|
+
*/
|
|
151
|
+
export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
|
|
152
|
+
const lastSwapAt = loadLastSwapAt();
|
|
153
|
+
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
154
|
+
return { swapped: false, account: null, reason: "post-swap-cooldown" };
|
|
155
|
+
}
|
|
156
|
+
|
|
88
157
|
const cfg = loadConfig();
|
|
89
158
|
const floor = cfg.threshold - cfg.policy.projectionMargin;
|
|
90
159
|
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
91
160
|
|
|
92
|
-
|
|
93
|
-
if (!usage && activeOrg) {
|
|
94
|
-
usage = await probeAggregate(activeOrg);
|
|
95
|
-
// Persist: post-swap the snapshots are cleared and the statusLine tee is in
|
|
96
|
-
// its grace window, so without this every turn boundary would re-probe.
|
|
97
|
-
if (usage) writeUsage(usage);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// per-model poll (TTL-cached) only when on a capacity-constrained model
|
|
101
|
-
const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
|
|
161
|
+
const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
102
162
|
|
|
103
163
|
// cheap pre-check off the lock - the common case exits here.
|
|
104
|
-
if (!isOver(usage, mu, activeOrg, cfg, floor)) {
|
|
164
|
+
if (!isOver(usage, mu, activeOrg, cfg, floor, now)) {
|
|
105
165
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
106
166
|
}
|
|
107
167
|
|
|
@@ -116,25 +176,31 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
116
176
|
const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
|
|
117
177
|
if (active) {
|
|
118
178
|
active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
|
|
179
|
+
active.lastUsageAt = u2.ts;
|
|
119
180
|
// Snapshot per-model caps too, so they still show after we switch away.
|
|
120
|
-
|
|
181
|
+
// An empty map is a failed probe's anti-storm stamp, not a measurement -
|
|
182
|
+
// it must not erase the burnt-cap snapshot the picker screens on.
|
|
183
|
+
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) active.lastPerModel = mu2.perModel;
|
|
121
184
|
saveAccounts(idx);
|
|
122
185
|
}
|
|
123
186
|
}
|
|
124
187
|
|
|
125
|
-
if (!isOver(u2, mu2, org2, cfg, floor)) {
|
|
188
|
+
if (!isOver(u2, mu2, org2, cfg, floor, now)) {
|
|
126
189
|
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
127
190
|
}
|
|
128
191
|
|
|
129
|
-
|
|
192
|
+
// Candidates are screened by the same families that drove this decision, so
|
|
193
|
+
// the pool cannot ping-pong onto an account the gate would immediately flag.
|
|
194
|
+
const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
|
|
195
|
+
const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies });
|
|
130
196
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
131
197
|
|
|
132
198
|
// Every account is depleted. Wait for whichever recovers soonest (including the
|
|
133
199
|
// current one), if that reset is within the auto-wait window.
|
|
134
200
|
const fresh = loadAccounts();
|
|
135
|
-
const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
|
|
201
|
+
const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
|
|
136
202
|
const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
|
|
137
|
-
const currentAt = current ? usableAt(current,
|
|
203
|
+
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
138
204
|
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
139
205
|
|
|
140
206
|
let target: Account | null = null;
|
|
@@ -149,6 +215,10 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
149
215
|
}
|
|
150
216
|
|
|
151
217
|
const isCurrent = target.accountUuid === fresh.activeAccountUuid;
|
|
218
|
+
if (!isCurrent && !anticipatory) {
|
|
219
|
+
log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
|
|
220
|
+
return { swapped: false, account: null, reason: "all-depleted" };
|
|
221
|
+
}
|
|
152
222
|
if (!isCurrent) {
|
|
153
223
|
try {
|
|
154
224
|
await performSwap(target);
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,82 +1,113 @@
|
|
|
1
|
-
// Choose the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
1
|
+
// Choose the account to switch TO. Greedy policy: among usable accounts (no
|
|
2
|
+
// reauth, no window >= threshold that hasn't reset yet), take the one whose
|
|
3
|
+
// weekly window expires soonest - weekly limits reset at a fixed per-account
|
|
4
|
+
// time and unused allowance is forfeited at reset, so quota nearest its reset
|
|
5
|
+
// is use-it-or-lose-it and should be drained first. Runs entirely off each
|
|
6
|
+
// account's cached windows (absolute UTC epochs, so a stale snapshot still
|
|
7
|
+
// resolves to the correct upcoming reset), which makes the pick deterministic
|
|
8
|
+
// and idempotent: re-running lands on the same account.
|
|
8
9
|
|
|
9
10
|
import { minBy, sortBy } from "es-toolkit";
|
|
10
11
|
import { z } from "zod";
|
|
11
|
-
import {
|
|
12
|
+
import { familyTokens } from "./usage.ts";
|
|
13
|
+
import { AccountSchema, type Account, type UsageWindow } from "./types.ts";
|
|
12
14
|
|
|
13
15
|
const PickCtxSchema = z.object({
|
|
14
16
|
now: z.number(),
|
|
15
17
|
threshold: z.number(),
|
|
18
|
+
/** account to exclude (hooks switch AWAY from it); null ranks everyone. */
|
|
16
19
|
currentAccountUuid: z.string().nullable(),
|
|
20
|
+
/** families whose per-model weekly cap counts toward exhaustion (from
|
|
21
|
+
* gatedFamilies): a candidate with a burnt gated cap is no switch target -
|
|
22
|
+
* landing on it would re-trigger the same gate and ping-pong the pool. */
|
|
23
|
+
switchFamilies: z.array(z.string()),
|
|
17
24
|
});
|
|
18
25
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
19
26
|
|
|
27
|
+
/** The account's cached per-model windows that belong to a gated family. */
|
|
28
|
+
function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
|
|
29
|
+
return Object.entries(a.lastPerModel ?? {})
|
|
30
|
+
.filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
|
|
31
|
+
.map(([, w]) => w);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
35
|
+
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/** Epoch until which `w` blocks the account; anything <= now means it does not
|
|
38
|
+
* block. A window with no known reset (unparsed clock) is still bounded by
|
|
39
|
+
* its own duration past the sample time - a 5h window sampled 6h ago has
|
|
40
|
+
* certainly reset - so a benched account always recovers by itself. With no
|
|
41
|
+
* sample time either, it blocks indefinitely: guessing "usable now" would
|
|
42
|
+
* swap onto it with waitUntil=now and churn kill/respawn. */
|
|
43
|
+
function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined, ctx: PickCtx): number {
|
|
44
|
+
if (w.usedPercentage < ctx.threshold) return 0;
|
|
45
|
+
if (w.resetsAt != null) return w.resetsAt;
|
|
46
|
+
return sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** When each of the account's windows stops blocking: the two aggregates plus
|
|
50
|
+
* the gated per-model caps. */
|
|
51
|
+
function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
52
|
+
const u = a.lastUsage;
|
|
53
|
+
return [
|
|
54
|
+
...(u ? [blockedUntil(u.fiveHour, FIVE_HOURS_MS, a.lastUsageAt, ctx), blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx)] : []),
|
|
55
|
+
...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastUsageAt, ctx)),
|
|
56
|
+
];
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
/** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
|
|
21
60
|
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
22
|
-
|
|
23
|
-
if (!u) return false;
|
|
24
|
-
const blocked = (w: { usedPercentage: number; resetsAt: number | null }) =>
|
|
25
|
-
w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
|
|
26
|
-
return blocked(u.fiveHour) || blocked(u.sevenDay);
|
|
61
|
+
return blockingUntil(a, ctx).some((t) => t > ctx.now);
|
|
27
62
|
}
|
|
28
63
|
|
|
29
|
-
|
|
64
|
+
/** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
|
|
65
|
+
* anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
|
|
66
|
+
* an old snapshot still yields the correct upcoming reset. */
|
|
67
|
+
export function nextWeeklyReset(resetsAt: number | null, now: number): number | null {
|
|
68
|
+
if (resetsAt == null || resetsAt > now) return resetsAt;
|
|
69
|
+
return resetsAt + (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
|
|
70
|
+
}
|
|
30
71
|
|
|
31
|
-
/** Epoch ms when the account's weekly quota is next forfeited
|
|
32
|
-
*
|
|
33
|
-
* forward in 7-day steps; an account with no sampled reset sorts last. */
|
|
72
|
+
/** Epoch ms when the account's weekly quota is next forfeited; an account with
|
|
73
|
+
* no sampled reset sorts last. */
|
|
34
74
|
export function weeklyExpiry(a: Account, now: number): number {
|
|
35
|
-
|
|
36
|
-
if (r == null) return Number.POSITIVE_INFINITY;
|
|
37
|
-
if (r > now) return r;
|
|
38
|
-
return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
|
|
75
|
+
return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
39
76
|
}
|
|
40
77
|
|
|
41
|
-
/** The switch preference: soonest weekly expiry first
|
|
42
|
-
* usage
|
|
43
|
-
*
|
|
78
|
+
/** The switch preference: soonest weekly expiry first, tiebreak lowest 7-day
|
|
79
|
+
* usage. Shared with the statusLine pool ordering so the display order IS the
|
|
80
|
+
* swap order. */
|
|
44
81
|
export const swapPreference = (now: number) => [
|
|
45
82
|
(a: Account) => weeklyExpiry(a, now),
|
|
46
83
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
47
|
-
(a: Account) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
48
84
|
];
|
|
49
85
|
|
|
50
86
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
51
|
-
const
|
|
52
|
-
(a) =>
|
|
53
|
-
a.accountUuid !== ctx.currentAccountUuid &&
|
|
54
|
-
!a.needsReauth &&
|
|
55
|
-
!isExhausted(a, ctx),
|
|
87
|
+
const usable = accounts.filter(
|
|
88
|
+
(a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
|
|
56
89
|
);
|
|
57
|
-
|
|
58
|
-
return sortBy(candidates, swapPreference(ctx.now))[0]!;
|
|
90
|
+
return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
|
|
59
91
|
}
|
|
60
92
|
|
|
61
|
-
/** When an account becomes usable again: the latest
|
|
62
|
-
* windows (all must
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
|
|
68
|
-
.map((w) => w.resetsAt as number);
|
|
69
|
-
return blocking.length ? Math.max(...blocking) : now;
|
|
93
|
+
/** When an account becomes usable again: the latest blocking bound among its
|
|
94
|
+
* windows (all must clear), or `now` if nothing blocks. Consistent with
|
|
95
|
+
* isExhausted by construction (same blockingUntil). */
|
|
96
|
+
export function usableAt(a: Account, ctx: PickCtx): number {
|
|
97
|
+
const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
|
|
98
|
+
return blocking.length ? Math.max(...blocking) : ctx.now;
|
|
70
99
|
}
|
|
71
100
|
|
|
72
101
|
const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
|
|
73
102
|
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
74
103
|
|
|
75
104
|
/** For the all-depleted case: the account (not current, not reauth) that becomes
|
|
76
|
-
* usable soonest.
|
|
105
|
+
* usable soonest. Accounts blocked with no known reset are unknowable, never
|
|
106
|
+
* a wait target. */
|
|
77
107
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
78
108
|
const mapped = accounts
|
|
79
109
|
.filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
|
|
80
|
-
.map((a) => ({ account: a, availableAt: usableAt(a, ctx
|
|
110
|
+
.map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
|
|
111
|
+
.filter((x) => Number.isFinite(x.availableAt));
|
|
81
112
|
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
82
113
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Config + accounts index + usage snapshot persistence. All writes atomic.
|
|
2
2
|
|
|
3
|
-
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
4
4
|
import { isEqual } from "es-toolkit";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { paths, realClaudeBinFromEnv } from "./paths.ts";
|
|
@@ -22,7 +22,9 @@ import {
|
|
|
22
22
|
const DEFAULT_CONFIG: Config = {
|
|
23
23
|
threshold: 95,
|
|
24
24
|
claudeBin: "",
|
|
25
|
-
|
|
25
|
+
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
26
|
+
// per the user 2026-07-12), and only Fable's is worth switching on.
|
|
27
|
+
policy: { projectionMargin: 0, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/** On-disk shape (all optional); validated via Zod, merged over defaults. */
|
|
@@ -124,14 +126,42 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
124
126
|
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
125
127
|
}
|
|
126
128
|
|
|
127
|
-
/**
|
|
129
|
+
/** An alive feed re-proving unchanged figures still refreshes `ts` this often,
|
|
130
|
+
* so cache-age displays stay honest without a write+fsync per tick. */
|
|
131
|
+
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
132
|
+
|
|
133
|
+
/** Write-on-change: skip the write (and its fsync) when only `ts` would differ,
|
|
134
|
+
* unless the stored `ts` has aged past the refresh window. A suppressed write
|
|
135
|
+
* still bumps the file's mtime (metadata only, no fsync): mtime is the feed's
|
|
136
|
+
* liveness heartbeat, and without the bump an alive tee re-proving unchanged
|
|
137
|
+
* figures reads as a dead feed and the decision path goes model-blind. */
|
|
128
138
|
export function writeUsage(next: UsageState): boolean {
|
|
129
139
|
const prev = loadUsage();
|
|
130
|
-
if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }))
|
|
140
|
+
if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) {
|
|
141
|
+
try {
|
|
142
|
+
utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
|
|
143
|
+
} catch (e) {
|
|
144
|
+
// The file vanished mid-race: a concurrent swap just invalidated these
|
|
145
|
+
// figures. Suppressing stays correct; a write would resurrect them.
|
|
146
|
+
if ((e as { code?: string }).code !== "ENOENT") throw e;
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
131
150
|
writeFileAtomic(paths.usageJson, JSON.stringify(next));
|
|
132
151
|
return true;
|
|
133
152
|
}
|
|
134
153
|
|
|
154
|
+
/** When the usage feed last proved itself alive (usage.json mtime), null if the
|
|
155
|
+
* snapshot is absent. Fresher than the embedded `ts`, which write-on-change
|
|
156
|
+
* deliberately lets age while figures hold still. */
|
|
157
|
+
export function usageTeeAt(): number | null {
|
|
158
|
+
try {
|
|
159
|
+
return statSync(paths.usageJson).mtimeMs;
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
135
165
|
// ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
|
|
136
166
|
|
|
137
167
|
export function loadModelUsage(): ModelUsageState | null {
|
package/src/lib/types.ts
CHANGED
|
@@ -84,6 +84,10 @@ export const AccountSchema = z.object({
|
|
|
84
84
|
addedAt: z.string(),
|
|
85
85
|
lastUsage: UsageWindowsSchema.optional(),
|
|
86
86
|
lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
|
|
87
|
+
/** epoch ms of the sample behind lastUsage/lastPerModel. Their resetsAt
|
|
88
|
+
* values are absolute epochs (UTC-anchored), so even an old snapshot still
|
|
89
|
+
* resolves to correct resets - display it as a dated cache, never discard. */
|
|
90
|
+
lastUsageAt: z.number().optional(),
|
|
87
91
|
needsReauth: z.boolean().optional(),
|
|
88
92
|
subscriptionType: z.string().optional(),
|
|
89
93
|
});
|
package/src/lib/usage.ts
CHANGED
|
@@ -67,6 +67,19 @@ export function matchedFamily(model: ModelInfo | null, families: string[]): stri
|
|
|
67
67
|
return families.find((f) => tokens.has(f)) ?? null;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** Families whose per-model weekly cap gates a switch, given the active model:
|
|
71
|
+
* the model's own family when it is capacity-constrained, none when it is a
|
|
72
|
+
* known-unconstrained model, and EVERY configured family when the model is
|
|
73
|
+
* unknown. The unknown case matters on headless boxes: a swap clears the
|
|
74
|
+
* snapshots and only an actively-rendering statusLine restores the model, so
|
|
75
|
+
* the periodic check ran model-blind for hours while the active account sat at
|
|
76
|
+
* its Fable cap (the 2026-07-12 stella incident). */
|
|
77
|
+
export function gatedFamilies(model: ModelInfo | null, families: string[]): string[] {
|
|
78
|
+
if (!model) return families;
|
|
79
|
+
const family = matchedFamily(model, families);
|
|
80
|
+
return family ? [family] : [];
|
|
81
|
+
}
|
|
82
|
+
|
|
70
83
|
export const FullUsageSchema = z.object({
|
|
71
84
|
session: UsageWindowSchema,
|
|
72
85
|
weekAll: UsageWindowSchema,
|
|
@@ -195,6 +208,11 @@ const CRED_ENV_OVERRIDES = [
|
|
|
195
208
|
/** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
|
|
196
209
|
* limit lines (claude prints only a local-stats footer when its own usage fetch
|
|
197
210
|
* errors/throttles) or failed to run. */
|
|
211
|
+
/** A probe child that wedges (auth prompt, dead endpoint) must never block the
|
|
212
|
+
* Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
|
|
213
|
+
* answers in seconds. */
|
|
214
|
+
const PROBE_KILL_MS = 60_000;
|
|
215
|
+
|
|
198
216
|
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
199
217
|
let out: string;
|
|
200
218
|
try {
|
|
@@ -203,12 +221,17 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
203
221
|
stdout: "pipe",
|
|
204
222
|
stderr: "pipe",
|
|
205
223
|
});
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
const killer = setTimeout(() => p.kill(), PROBE_KILL_MS);
|
|
225
|
+
try {
|
|
226
|
+
out = await new Response(p.stdout).text();
|
|
227
|
+
const errText = await new Response(p.stderr).text();
|
|
228
|
+
await p.exited;
|
|
229
|
+
if (p.exitCode !== 0) {
|
|
230
|
+
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
} finally {
|
|
234
|
+
clearTimeout(killer);
|
|
212
235
|
}
|
|
213
236
|
} catch (e) {
|
|
214
237
|
log("usage.probe_failed", { err: String((e as Error).message ?? e) });
|
package/src/main.ts
CHANGED
|
@@ -24,7 +24,7 @@ function printHelp(): void {
|
|
|
24
24
|
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
25
25
|
|
|
26
26
|
${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
|
|
27
|
-
${c.cyan("tokenmaxxing switch")} [sel] switch
|
|
27
|
+
${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
|
|
28
28
|
${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
|
|
29
29
|
${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
|
|
30
30
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|