tokenmaxxing 0.6.0 → 0.6.2
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/package.json +2 -2
- package/src/cli/doctor.ts +21 -2
- package/src/cli/init.ts +6 -4
- package/src/cli/status.ts +1 -1
- package/src/cli/switch.ts +13 -3
- package/src/entries/sessionstart.ts +2 -2
- package/src/entries/statusline.ts +7 -2
- package/src/entries/stophook.ts +4 -1
- package/src/entries/supervisor.ts +24 -3
- package/src/lib/claudebin.ts +153 -10
- package/src/lib/decide.ts +129 -60
- package/src/lib/install.ts +36 -0
- package/src/lib/picker.ts +49 -19
- package/src/lib/state.ts +26 -3
- package/src/lib/usage.ts +39 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
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",
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
"DESIGN.md"
|
|
20
20
|
],
|
|
21
21
|
"engines": {
|
|
22
|
-
"bun": ">=1.
|
|
22
|
+
"bun": ">=1.2.6"
|
|
23
23
|
},
|
|
24
24
|
"os": ["darwin", "linux"],
|
|
25
25
|
"scripts": {
|
package/src/cli/doctor.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// `tokenmaxxing doctor` - verify the supervisor + three settings entries survived
|
|
2
2
|
// and the pool is healthy.
|
|
3
3
|
|
|
4
|
-
import { existsSync } from "node:fs";
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { verifyRealClaude } from "../lib/claudebin.ts";
|
|
5
6
|
import { checkSettings, installedBin } from "../lib/settings.ts";
|
|
6
|
-
import { checkTimerHealthy, isBinDirAhead, timerActivationHint } from "../lib/install.ts";
|
|
7
|
+
import { checkTimerHealthy, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
|
|
7
8
|
import { paths } from "../lib/paths.ts";
|
|
8
9
|
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
9
10
|
import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
|
|
@@ -74,6 +75,24 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
74
75
|
|
|
75
76
|
const cfg = loadConfig();
|
|
76
77
|
check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
|
|
78
|
+
if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
|
|
79
|
+
// Behavioral: the pin must answer --version without re-entering the wrapper.
|
|
80
|
+
// Catches a poisoned pin (a shim that resolves `claude` back to us) that
|
|
81
|
+
// existence checks cannot - the 2026-07-12 recursive-spawn incident.
|
|
82
|
+
const fail = verifyRealClaude(cfg.claudeBin);
|
|
83
|
+
check(fail === null, "claudeBin launches the real claude", fail ?? undefined);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Warnings only: an interactive alias/function can shadow or bypass the
|
|
87
|
+
// wrapper in ways PATH checks cannot see (`alias claude=...`, or a `cc`-style
|
|
88
|
+
// alias hardcoding an absolute path to the real binary).
|
|
89
|
+
const rc = shellRcPath();
|
|
90
|
+
if (rc && existsSync(rc)) {
|
|
91
|
+
for (const s of findClaudeShadowers(readFileSync(rc, "utf8"))) {
|
|
92
|
+
if (s.kind === "shadow") console.log(c.yellow(`⚠ ${rc}: \`${s.line}\` shadows the supervised claude wrapper - launches through it skip tokenmaxxing`));
|
|
93
|
+
else console.log(c.yellow(`⚠ ${rc}: alias \`${s.name}\` hardcodes a claude path and bypasses the supervisor - use plain \`claude\` in its body instead`));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
77
96
|
|
|
78
97
|
console.log();
|
|
79
98
|
console.log(ok ? c.green("all good ✓") : c.yellow("issues found - see above"));
|
package/src/cli/init.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "..
|
|
|
7
7
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
|
|
8
8
|
import { loadAccounts, saveAccounts, loadConfig, saveConfig } from "../lib/state.ts";
|
|
9
9
|
import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
|
|
10
|
-
import {
|
|
10
|
+
import { resolveVerifiedClaude } from "../lib/claudebin.ts";
|
|
11
11
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
12
12
|
import { CredentialBlobSchema, type Account } from "../lib/types.ts";
|
|
13
13
|
import { c } from "./render.ts";
|
|
@@ -40,9 +40,11 @@ export async function cmdInit(): Promise<number> {
|
|
|
40
40
|
if (existingIdx.accounts.length > 0) {
|
|
41
41
|
const out = installSupervisor();
|
|
42
42
|
// repair the claudeBin pin too - hooks run with claude's PATH and must
|
|
43
|
-
// never have to guess which binary is the real claude.
|
|
43
|
+
// never have to guess which binary is the real claude. Verified pinning:
|
|
44
|
+
// a pin that fails --version (or loops back into the wrapper) is replaced
|
|
45
|
+
// by a fresh PATH scan instead of being re-saved.
|
|
44
46
|
const cfg = loadConfig();
|
|
45
|
-
cfg.claudeBin =
|
|
47
|
+
cfg.claudeBin = resolveVerifiedClaude();
|
|
46
48
|
saveConfig(cfg);
|
|
47
49
|
const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
|
|
48
50
|
console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
|
|
@@ -112,7 +114,7 @@ export async function cmdInit(): Promise<number> {
|
|
|
112
114
|
saveAccounts(idx);
|
|
113
115
|
|
|
114
116
|
const cfg = loadConfig();
|
|
115
|
-
cfg.claudeBin =
|
|
117
|
+
cfg.claudeBin = resolveVerifiedClaude();
|
|
116
118
|
saveConfig(cfg);
|
|
117
119
|
|
|
118
120
|
const out = installSupervisor();
|
package/src/cli/status.ts
CHANGED
|
@@ -92,7 +92,7 @@ export async function cmdStatus(): Promise<number> {
|
|
|
92
92
|
const badges: string[] = [];
|
|
93
93
|
if (active) badges.push(c.green("active"));
|
|
94
94
|
if (a.needsReauth) badges.push(c.red("needs-reauth"));
|
|
95
|
-
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 }))
|
|
96
96
|
badges.push(c.yellow("exhausted"));
|
|
97
97
|
|
|
98
98
|
console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
|
package/src/cli/switch.ts
CHANGED
|
@@ -58,7 +58,8 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
// auto: greedy over everyone, current included - a no-op when current wins.
|
|
61
|
-
|
|
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 };
|
|
62
63
|
const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
|
|
63
64
|
const best = pickBest(idx.accounts, everyone);
|
|
64
65
|
const currentWins =
|
|
@@ -72,7 +73,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
72
73
|
return 0;
|
|
73
74
|
}
|
|
74
75
|
if (best) {
|
|
75
|
-
const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
|
|
76
|
+
const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies: cfg.policy.switchModels });
|
|
76
77
|
if (landed) {
|
|
77
78
|
console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
|
|
78
79
|
return 0;
|
|
@@ -84,7 +85,16 @@ export async function cmdSwitch(selector?: string): Promise<number> {
|
|
|
84
85
|
// hence the reload). Stay on / switch to whichever recovers soonest.
|
|
85
86
|
const fresh = loadAccounts();
|
|
86
87
|
const earliest = pickEarliestReset(fresh.accounts, everyone);
|
|
87
|
-
if (!earliest) {
|
|
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
|
+
}
|
|
88
98
|
const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
|
|
89
99
|
const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
|
|
90
100
|
if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
|
|
@@ -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 });
|
|
@@ -11,7 +11,7 @@ import { join } from "node:path";
|
|
|
11
11
|
import { maxBy } from "es-toolkit";
|
|
12
12
|
import { z } from "zod";
|
|
13
13
|
import { paths } from "../lib/paths.ts";
|
|
14
|
-
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
14
|
+
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
15
15
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
16
16
|
import { loadSessionFlags, saveSessionFlags } from "../lib/sessions.ts";
|
|
17
17
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
@@ -111,12 +111,33 @@ async function countdownWait(acct: string, until: number): Promise<void> {
|
|
|
111
111
|
|
|
112
112
|
/** Entry point: `claude ...args`. */
|
|
113
113
|
export async function runSupervisor(argv: string[]): Promise<number> {
|
|
114
|
+
// Depth cap: every spawn below tags its child, so a claudeBin that leads back
|
|
115
|
+
// here (pinned shim re-execing `claude` from PATH) dies at a handful of
|
|
116
|
+
// processes instead of fork-bombing the machine (2026-07-12 incident).
|
|
117
|
+
const depth = wrapDepth();
|
|
118
|
+
if (depth >= MAX_WRAP_DEPTH) {
|
|
119
|
+
console.error(
|
|
120
|
+
`tokenmaxxing: ${LOOP_DIAGNOSIS} (depth ${depth}) - claudeBin in ${paths.configJson} does not launch the real Claude binary. Fix claudeBin, then run \`tokenmaxxing doctor\`.`,
|
|
121
|
+
);
|
|
122
|
+
log("supervisor.loop_abort", { depth });
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
// Rate backstop: an env-sanitizing shim in the loop strips the sentinel, but
|
|
126
|
+
// it cannot erase the on-disk entry counter.
|
|
127
|
+
if (wrapperEntryRateTripped(Date.now())) {
|
|
128
|
+
console.error(
|
|
129
|
+
`tokenmaxxing: ${LOOP_DIAGNOSIS} (over ${WRAP_RATE_MAX} wrapper entries in ${WRAP_RATE_WINDOW_MS / 1000}s) - claudeBin in ${paths.configJson} does not launch the real Claude binary. Fix claudeBin, then run \`tokenmaxxing doctor\`.`,
|
|
130
|
+
);
|
|
131
|
+
log("supervisor.rate_abort", { max: WRAP_RATE_MAX });
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
114
134
|
const real = resolveRealClaude();
|
|
115
135
|
const info = analyzeArgs(argv);
|
|
136
|
+
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
116
137
|
|
|
117
138
|
// Pass-through: no session management, no respawn - exact stock behavior.
|
|
118
139
|
if (!info.manage) {
|
|
119
|
-
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
140
|
+
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: childEnv });
|
|
120
141
|
await p.exited;
|
|
121
142
|
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
122
143
|
}
|
|
@@ -165,7 +186,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
165
186
|
stdin: "inherit",
|
|
166
187
|
stdout: "inherit",
|
|
167
188
|
stderr: "inherit",
|
|
168
|
-
env: { ...
|
|
189
|
+
env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid },
|
|
169
190
|
});
|
|
170
191
|
|
|
171
192
|
// Race the child's own exit against the appearance of a respawn marker.
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -1,26 +1,169 @@
|
|
|
1
1
|
// Resolve the REAL claude binary (never our shim on PATH).
|
|
2
2
|
|
|
3
|
-
import { existsSync, statSync } from "node:fs";
|
|
4
|
-
import { join } from "node:path";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { uniq } from "es-toolkit";
|
|
6
|
+
import { z } from "zod";
|
|
5
7
|
import { paths } from "./paths.ts";
|
|
6
8
|
import { loadConfig } from "./state.ts";
|
|
9
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
10
|
+
|
|
11
|
+
/** How many tokenmaxxing wrappers sit above this process. Every supervisor
|
|
12
|
+
* spawn increments it in the child env; the wrapper refuses to run at the cap,
|
|
13
|
+
* so ANY claudeBin indirection that leads back to the wrapper (a pinned shim
|
|
14
|
+
* that re-execs `claude` from PATH) dies in a handful of processes instead of
|
|
15
|
+
* fork-bombing the machine (2026-07-12: ~1800 runaway bun processes). */
|
|
16
|
+
export const WRAP_DEPTH_ENV = "TOKENMAXXING_WRAP_DEPTH";
|
|
17
|
+
export const MAX_WRAP_DEPTH = 5;
|
|
18
|
+
/** Stable fragment of the loop-abort diagnostic; verifyRealClaude greps a
|
|
19
|
+
* child's stderr for it to name the failure precisely. */
|
|
20
|
+
export const LOOP_DIAGNOSIS = "wrapper re-entered without reaching the real claude";
|
|
21
|
+
|
|
22
|
+
export function wrapDepth(env: Record<string, string | undefined> = process.env): number {
|
|
23
|
+
const n = Number(env[WRAP_DEPTH_ENV] ?? "");
|
|
24
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Non-env backstop for the depth sentinel: an env-sanitizing shim in the loop
|
|
28
|
+
* (env -i / corporate launchers) strips the sentinel every cycle, so the
|
|
29
|
+
* wrapper ALSO counts its own entries in an on-disk sliding window that no
|
|
30
|
+
* child environment can erase. A self-spawn loop sustains several entries per
|
|
31
|
+
* second indefinitely; legitimate bursts (a tmux session restore launching
|
|
32
|
+
* dozens of panes) land once and go quiet, staying far under the cap. */
|
|
33
|
+
export const WRAP_RATE_MAX = 60;
|
|
34
|
+
export const WRAP_RATE_WINDOW_MS = 30_000;
|
|
35
|
+
const SpawnRateSchema = z.object({ entries: z.array(z.number()) });
|
|
36
|
+
|
|
37
|
+
export function wrapperEntryRateTripped(now: number): boolean {
|
|
38
|
+
const file = join(paths.home, "spawnrate.json");
|
|
39
|
+
let entries: number[] = [];
|
|
40
|
+
try {
|
|
41
|
+
entries = SpawnRateSchema.parse(JSON.parse(readFileSync(file, "utf8"))).entries;
|
|
42
|
+
} catch { /* absent or corrupt - start a fresh window */ }
|
|
43
|
+
entries = entries.filter((t) => now - t < WRAP_RATE_WINDOW_MS);
|
|
44
|
+
entries.push(now);
|
|
45
|
+
try {
|
|
46
|
+
mkdirSync(paths.home, { recursive: true });
|
|
47
|
+
writeFileAtomic(file, JSON.stringify({ entries }));
|
|
48
|
+
} catch { /* an unwritable home must never block launching claude */ }
|
|
49
|
+
return entries.length > WRAP_RATE_MAX;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function realpathOrNull(p: string): string | null {
|
|
53
|
+
try {
|
|
54
|
+
return realpathSync(p);
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when `bin` IS tokenmaxxing (the `claude` wrapper, the `xx` alias, or the
|
|
61
|
+
* installed binary, via any symlink): spawning it as claude recurses through
|
|
62
|
+
* the supervisor. Realpath-based - a trailing slash, a symlinked dir, or a
|
|
63
|
+
* symlink to the wrapper must not defeat it the way the old exact-string
|
|
64
|
+
* binDir compare could. */
|
|
65
|
+
export function pointsBackAtUs(bin: string): boolean {
|
|
66
|
+
const resolved = realpathOrNull(bin);
|
|
67
|
+
const binDir = realpathOrNull(paths.binDir);
|
|
68
|
+
return resolved != null && binDir != null && dirname(resolved) === binDir;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Every PATH `claude` that is not us, in PATH order, deduped by realpath.
|
|
72
|
+
* All of them, not just the first: a user-made wrapper script named claude can
|
|
73
|
+
* sit ahead of the real binary, and verified resolution must be able to walk
|
|
74
|
+
* past it. */
|
|
75
|
+
export function scanPathForClaudeCandidates(): string[] {
|
|
76
|
+
const seen = new Set<string>();
|
|
77
|
+
const out: string[] = [];
|
|
78
|
+
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
79
|
+
if (!d) continue;
|
|
80
|
+
const cand = join(d, "claude");
|
|
81
|
+
try {
|
|
82
|
+
if (existsSync(cand) && statSync(cand).isFile() && !pointsBackAtUs(cand)) {
|
|
83
|
+
const key = realpathOrNull(cand) ?? cand;
|
|
84
|
+
if (!seen.has(key)) {
|
|
85
|
+
seen.add(key);
|
|
86
|
+
out.push(cand);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} catch { /* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** First PATH entry with a `claude` that is not us. null when PATH has none. */
|
|
95
|
+
export function scanPathForClaude(): string | null {
|
|
96
|
+
return scanPathForClaudeCandidates()[0] ?? null;
|
|
97
|
+
}
|
|
7
98
|
|
|
8
99
|
export function resolveRealClaude(): string {
|
|
9
100
|
const cfg = loadConfig();
|
|
10
101
|
if (cfg.claudeBin) {
|
|
11
|
-
if (existsSync(cfg.claudeBin)) return cfg.claudeBin;
|
|
12
102
|
// A configured-but-vanished binary must not silently degrade to the PATH
|
|
13
103
|
// scan: under a relocated TOKENMAXXING_HOME the scan's binDir guard misses
|
|
14
104
|
// the installed wrapper, which then recurses through the supervisor
|
|
15
105
|
// (observed 2026-07-12 as a forever-hung `/usage` probe).
|
|
16
|
-
|
|
106
|
+
if (!existsSync(cfg.claudeBin)) {
|
|
107
|
+
throw new Error(`configured claudeBin does not exist: ${cfg.claudeBin} - fix config.json`);
|
|
108
|
+
}
|
|
109
|
+
// A pin that leads back to us is the recursion incident, not a claude.
|
|
110
|
+
if (pointsBackAtUs(cfg.claudeBin)) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`configured claudeBin (${cfg.claudeBin}) is tokenmaxxing's own wrapper - spawning it recurses. Point claudeBin at the real claude binary in ${paths.configJson}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return cfg.claudeBin;
|
|
17
116
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
117
|
+
const scanned = scanPathForClaude();
|
|
118
|
+
if (scanned) return scanned;
|
|
119
|
+
throw new Error("could not locate the real `claude` binary (set claudeBin in config.json)");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Behavioral check used by init/doctor before trusting a pin: the binary must
|
|
123
|
+
* answer `--version` without re-entering this wrapper. Depth is preset to the
|
|
124
|
+
* cap so an indirection back into us aborts on its FIRST wrapper entry instead
|
|
125
|
+
* of recursing. Returns null when the binary passes, else the failure detail. */
|
|
126
|
+
export function verifyRealClaude(bin: string): string | null {
|
|
127
|
+
const env = { ...process.env, [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH), TOKENMAXXING_PROBE: "1" };
|
|
128
|
+
let p: ReturnType<typeof Bun.spawnSync>;
|
|
129
|
+
try {
|
|
130
|
+
// spawnSync throws on an unrunnable path (ENOENT/EACCES) - that is a
|
|
131
|
+
// verification failure to report, not a crash. SIGKILL: a TERM-trapping
|
|
132
|
+
// candidate must not hang the very repair commands (init/doctor) that the
|
|
133
|
+
// loop-abort diagnostic points the user at.
|
|
134
|
+
p = Bun.spawnSync([bin, "--version"], { env, stdout: "pipe", stderr: "pipe", timeout: 15_000, killSignal: "SIGKILL" });
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return String((e as Error).message ?? e);
|
|
137
|
+
}
|
|
138
|
+
const outText = (p.stdout?.toString() ?? "").trim();
|
|
139
|
+
const err = (p.stderr?.toString() ?? "").trim();
|
|
140
|
+
if (p.exitCode === 0) {
|
|
141
|
+
// exit 0 only proves something ran; the output must identify as claude
|
|
142
|
+
// ("2.1.207 (Claude Code)" on 2.1.207) or the pin is some other program.
|
|
143
|
+
if (/claude/i.test(outText)) return null;
|
|
144
|
+
return `--version output does not identify claude: "${outText.slice(0, 80)}"`;
|
|
145
|
+
}
|
|
146
|
+
if (err.includes(LOOP_DIAGNOSIS)) return "it leads back into the tokenmaxxing wrapper (recursion)";
|
|
147
|
+
return `--version exited ${p.exitCode ?? "on signal/timeout"}: ${(err || outText).slice(0, 160)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Resolution for `init`'s pin: resolve, then behaviorally verify. A configured
|
|
151
|
+
* bin that fails verification (e.g. a shim pinned by an old version) falls back
|
|
152
|
+
* to the PATH scan, and the scan walks past failing candidates (a user-made
|
|
153
|
+
* claude wrapper ahead of the real binary) instead of giving up on the first. */
|
|
154
|
+
export function resolveVerifiedClaude(): string {
|
|
155
|
+
const candidates: string[] = [];
|
|
156
|
+
try {
|
|
157
|
+
candidates.push(resolveRealClaude());
|
|
158
|
+
} catch { /* broken config - the scan below is init's repair path */ }
|
|
159
|
+
candidates.push(...scanPathForClaudeCandidates());
|
|
160
|
+
|
|
161
|
+
const failures: string[] = [];
|
|
162
|
+
for (const cand of uniq(candidates)) {
|
|
163
|
+
const fail = verifyRealClaude(cand);
|
|
164
|
+
if (fail === null) return cand;
|
|
165
|
+
failures.push(`${cand}: ${fail}`);
|
|
24
166
|
}
|
|
167
|
+
if (failures.length > 0) throw new Error(`no usable claude binary found:\n ${failures.join("\n ")}`);
|
|
25
168
|
throw new Error("could not locate the real `claude` binary (set claudeBin in config.json)");
|
|
26
169
|
}
|
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
|
|
|
@@ -118,24 +178,29 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
118
178
|
active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
|
|
119
179
|
active.lastUsageAt = u2.ts;
|
|
120
180
|
// Snapshot per-model caps too, so they still show after we switch away.
|
|
121
|
-
|
|
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;
|
|
122
184
|
saveAccounts(idx);
|
|
123
185
|
}
|
|
124
186
|
}
|
|
125
187
|
|
|
126
|
-
if (!isOver(u2, mu2, org2, cfg, floor)) {
|
|
188
|
+
if (!isOver(u2, mu2, org2, cfg, floor, now)) {
|
|
127
189
|
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
128
190
|
}
|
|
129
191
|
|
|
130
|
-
|
|
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 });
|
|
131
196
|
if (landed) return { swapped: true, account: landed, reason: "swapped" };
|
|
132
197
|
|
|
133
198
|
// Every account is depleted. Wait for whichever recovers soonest (including the
|
|
134
199
|
// current one), if that reset is within the auto-wait window.
|
|
135
200
|
const fresh = loadAccounts();
|
|
136
|
-
const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
|
|
201
|
+
const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
|
|
137
202
|
const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
|
|
138
|
-
const currentAt = current ? usableAt(current,
|
|
203
|
+
const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
|
|
139
204
|
const other = pickEarliestReset(fresh.accounts, ctx);
|
|
140
205
|
|
|
141
206
|
let target: Account | null = null;
|
|
@@ -150,6 +215,10 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
150
215
|
}
|
|
151
216
|
|
|
152
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
|
+
}
|
|
153
222
|
if (!isCurrent) {
|
|
154
223
|
try {
|
|
155
224
|
await performSwap(target);
|
package/src/lib/install.ts
CHANGED
|
@@ -204,6 +204,42 @@ export function ensurePathInRc(rc: string): "added" | "present" {
|
|
|
204
204
|
return "added";
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
const ShellShadowerSchema = z.object({
|
|
208
|
+
/** shadow: a `claude` alias/function hides the wrapper entirely.
|
|
209
|
+
* bypass: another alias (e.g. `cc`, `cco`) hardcodes an absolute path to a
|
|
210
|
+
* claude binary, so launches through it skip supervision. */
|
|
211
|
+
kind: z.enum(["shadow", "bypass"]),
|
|
212
|
+
name: z.string(),
|
|
213
|
+
line: z.string(),
|
|
214
|
+
});
|
|
215
|
+
export type ShellShadower = z.infer<typeof ShellShadowerSchema>;
|
|
216
|
+
|
|
217
|
+
/** Scan shell-rc text for aliases/functions that shadow `claude` or hardcode a
|
|
218
|
+
* path to a claude binary. Aliases whose body starts with plain `claude` are
|
|
219
|
+
* fine (they expand through PATH into the wrapper); an absolute path is not.
|
|
220
|
+
* Lines referencing the wrapper itself are deliberate and skipped. */
|
|
221
|
+
export function findClaudeShadowers(rcText: string): ShellShadower[] {
|
|
222
|
+
const out: ShellShadower[] = [];
|
|
223
|
+
const absClaude = /(?:^|[\s"'=])(\/[^\s"']*\/claude)(?:[\s"']|$)/;
|
|
224
|
+
for (const rawLine of rcText.split("\n")) {
|
|
225
|
+
const line = rawLine.trim();
|
|
226
|
+
if (line.startsWith("#") || line.includes(paths.supervisorLink)) continue;
|
|
227
|
+
const alias = line.match(/^alias\s+([A-Za-z0-9_-]+)=(.*)$/);
|
|
228
|
+
if (alias) {
|
|
229
|
+
if (alias[1] === "claude") {
|
|
230
|
+
out.push(ShellShadowerSchema.parse({ kind: "shadow", name: "claude", line }));
|
|
231
|
+
} else if (absClaude.test(alias[2]!)) {
|
|
232
|
+
out.push(ShellShadowerSchema.parse({ kind: "bypass", name: alias[1]!, line }));
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (/^(?:function\s+)?claude\s*\(\)/.test(line)) {
|
|
237
|
+
out.push(ShellShadowerSchema.parse({ kind: "shadow", name: "claude", line }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
|
|
207
243
|
export function uninstallSupervisor(): void {
|
|
208
244
|
uninstallSettings();
|
|
209
245
|
uninstallCheckTimer();
|
package/src/lib/picker.ts
CHANGED
|
@@ -9,26 +9,57 @@
|
|
|
9
9
|
|
|
10
10
|
import { minBy, sortBy } from "es-toolkit";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
-
import {
|
|
12
|
+
import { familyTokens } from "./usage.ts";
|
|
13
|
+
import { AccountSchema, type Account, type UsageWindow } from "./types.ts";
|
|
13
14
|
|
|
14
15
|
const PickCtxSchema = z.object({
|
|
15
16
|
now: z.number(),
|
|
16
17
|
threshold: z.number(),
|
|
17
18
|
/** account to exclude (hooks switch AWAY from it); null ranks everyone. */
|
|
18
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()),
|
|
19
24
|
});
|
|
20
25
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
21
26
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
|
|
28
|
-
return blocked(u.fiveHour) || blocked(u.sevenDay);
|
|
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);
|
|
29
32
|
}
|
|
30
33
|
|
|
31
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
|
+
|
|
59
|
+
/** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
|
|
60
|
+
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
61
|
+
return blockingUntil(a, ctx).some((t) => t > ctx.now);
|
|
62
|
+
}
|
|
32
63
|
|
|
33
64
|
/** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
|
|
34
65
|
* anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
|
|
@@ -59,25 +90,24 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
59
90
|
return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
|
|
60
91
|
}
|
|
61
92
|
|
|
62
|
-
/** When an account becomes usable again: the latest
|
|
63
|
-
* windows (all must
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
.filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
|
|
69
|
-
.map((w) => w.resetsAt as number);
|
|
70
|
-
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;
|
|
71
99
|
}
|
|
72
100
|
|
|
73
101
|
const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
|
|
74
102
|
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
75
103
|
|
|
76
104
|
/** For the all-depleted case: the account (not current, not reauth) that becomes
|
|
77
|
-
* usable soonest.
|
|
105
|
+
* usable soonest. Accounts blocked with no known reset are unknowable, never
|
|
106
|
+
* a wait target. */
|
|
78
107
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
79
108
|
const mapped = accounts
|
|
80
109
|
.filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
|
|
81
|
-
.map((a) => ({ account: a, availableAt: usableAt(a, ctx
|
|
110
|
+
.map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
|
|
111
|
+
.filter((x) => Number.isFinite(x.availableAt));
|
|
82
112
|
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
83
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";
|
|
@@ -131,14 +131,37 @@ export function saveLastSwapAt(ts: number): void {
|
|
|
131
131
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
132
132
|
|
|
133
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.
|
|
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. */
|
|
135
138
|
export function writeUsage(next: UsageState): boolean {
|
|
136
139
|
const prev = loadUsage();
|
|
137
|
-
if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS)
|
|
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
|
+
}
|
|
138
150
|
writeFileAtomic(paths.usageJson, JSON.stringify(next));
|
|
139
151
|
return true;
|
|
140
152
|
}
|
|
141
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
|
+
|
|
142
165
|
// ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
|
|
143
166
|
|
|
144
167
|
export function loadModelUsage(): ModelUsageState | null {
|
package/src/lib/usage.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { delay } from "es-toolkit";
|
|
8
8
|
import { z } from "zod";
|
|
9
|
-
import { resolveRealClaude } from "./claudebin.ts";
|
|
9
|
+
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
10
10
|
import { log } from "./log.ts";
|
|
11
11
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
12
12
|
|
|
@@ -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,
|
|
@@ -199,6 +212,8 @@ const CRED_ENV_OVERRIDES = [
|
|
|
199
212
|
* Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
|
|
200
213
|
* answers in seconds. */
|
|
201
214
|
const PROBE_KILL_MS = 60_000;
|
|
215
|
+
/** How long after the child's death to keep waiting for pipe EOF. */
|
|
216
|
+
const PIPE_GRACE_MS = 2_000;
|
|
202
217
|
|
|
203
218
|
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
204
219
|
let out: string;
|
|
@@ -208,15 +223,30 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
208
223
|
stdout: "pipe",
|
|
209
224
|
stderr: "pipe",
|
|
210
225
|
});
|
|
211
|
-
|
|
226
|
+
// SIGKILL: claude traps SIGTERM, and a wedged probe child that survives the
|
|
227
|
+
// kill would keep p.exited pending and re-wedge the read race below.
|
|
228
|
+
const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
|
|
212
229
|
try {
|
|
213
|
-
|
|
214
|
-
|
|
230
|
+
// Descendants inherit the output pipes, so EOF can lag the child's death
|
|
231
|
+
// or never arrive at all - a leaked grandchild holding the pipe wedged
|
|
232
|
+
// the 2026-07-12 probes forever, defeating the kill guard above. Bound
|
|
233
|
+
// the reads by child-exit + grace instead of awaiting EOF unconditionally.
|
|
234
|
+
const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
|
|
235
|
+
const settled = await Promise.race([
|
|
236
|
+
reads,
|
|
237
|
+
p.exited.then(() => delay(PIPE_GRACE_MS)).then(() => null),
|
|
238
|
+
]);
|
|
239
|
+
if (settled === null) {
|
|
240
|
+
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
const [text, errText] = settled;
|
|
215
244
|
await p.exited;
|
|
216
245
|
if (p.exitCode !== 0) {
|
|
217
246
|
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
218
247
|
return null;
|
|
219
248
|
}
|
|
249
|
+
out = text;
|
|
220
250
|
} finally {
|
|
221
251
|
clearTimeout(killer);
|
|
222
252
|
}
|
|
@@ -256,7 +286,11 @@ const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
|
256
286
|
* transient, so retry with backoff. Returns null if it never yields data.
|
|
257
287
|
*/
|
|
258
288
|
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
259
|
-
|
|
289
|
+
// The probe spawns the real claude DIRECTLY - it never legitimately passes
|
|
290
|
+
// through the wrapper again. Preset the depth to the cap so a poisoned pin
|
|
291
|
+
// that leads back to the wrapper aborts on its first entry (the 2026-07-12
|
|
292
|
+
// ~1800-process recursion started as exactly this probe).
|
|
293
|
+
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
|
|
260
294
|
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
261
295
|
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
262
296
|
|