tokenmaxxing 0.6.1 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DESIGN.md +1 -1
- package/package.json +2 -2
- package/src/cli/doctor.ts +21 -2
- package/src/cli/init.ts +6 -4
- package/src/cli/switch.ts +3 -2
- package/src/entries/supervisor.ts +24 -3
- package/src/lib/claudebin.ts +153 -10
- package/src/lib/install.ts +36 -0
- package/src/lib/picker.ts +30 -9
- package/src/lib/usage.ts +26 -5
package/DESIGN.md
CHANGED
|
@@ -48,7 +48,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
48
48
|
|
|
49
49
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
50
50
|
1. Read `usage.json`; `exit 0` fast if both windows `< 95%` (metered per `organizationUuid`).
|
|
51
|
-
2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited,
|
|
51
|
+
2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
|
|
52
52
|
3. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
|
|
53
53
|
|
|
54
54
|
### 3.3 Respawn (supervisor)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -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/switch.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// `tokenmaxxing switch [selector]`.
|
|
2
|
-
// No selector → greedy: rank EVERY account (current included) by
|
|
3
|
-
//
|
|
2
|
+
// No selector → greedy: rank EVERY account (current included) by pace pressure
|
|
3
|
+
// (furthest behind its own weekly pace first, see picker.ts) among those with
|
|
4
|
+
// session/week under threshold, off the cached windows.
|
|
4
5
|
// When the current account already wins (or ties - swapping between equals buys
|
|
5
6
|
// nothing), do nothing: the command is idempotent, so running it periodically
|
|
6
7
|
// converges on the right account. With a selector → switch to that one. Runs
|
|
@@ -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/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
|
@@ -1,11 +1,14 @@
|
|
|
1
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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// is use-it-or-lose-it
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
2
|
+
// reauth, no window >= threshold that hasn't reset yet), take the one furthest
|
|
3
|
+
// behind its own weekly pace - highest pacePressure, the burn rate its
|
|
4
|
+
// remaining weekly quota demands to be consumed at before the fixed
|
|
5
|
+
// per-account reset forfeits it (weekly allowance is use-it-or-lose-it).
|
|
6
|
+
// This refines the older soonest-expiry policy in both directions: equal
|
|
7
|
+
// remaining reduces to soonest expiry first, equal expiry to most remaining
|
|
8
|
+
// first. Runs entirely off each account's cached windows (absolute UTC
|
|
9
|
+
// epochs, so a stale snapshot still resolves to the correct upcoming reset),
|
|
10
|
+
// which makes the pick deterministic and idempotent: re-running lands on the
|
|
11
|
+
// same account.
|
|
9
12
|
|
|
10
13
|
import { minBy, sortBy } from "es-toolkit";
|
|
11
14
|
import { z } from "zod";
|
|
@@ -75,10 +78,28 @@ export function weeklyExpiry(a: Account, now: number): number {
|
|
|
75
78
|
return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
/**
|
|
79
|
-
*
|
|
81
|
+
/** How far behind its own weekly pace the account is, measured forward: the
|
|
82
|
+
* burn rate (percent per ms) its remaining weekly quota must be consumed at
|
|
83
|
+
* to beat the reset that forfeits it. A backward-looking used/expected ratio
|
|
84
|
+
* blows up right after a reset (expected ~0) and ignores how much quota is
|
|
85
|
+
* at risk; the required forward rate has neither problem. A window past its
|
|
86
|
+
* cached reset counts as empty (the account is fresh again); an account with
|
|
87
|
+
* no sampled reset anchor has nothing to forfeit on any known clock and
|
|
88
|
+
* ranks last (0). */
|
|
89
|
+
export function pacePressure(a: Account, now: number): number {
|
|
90
|
+
const cached = a.lastUsage?.sevenDay;
|
|
91
|
+
const reset = nextWeeklyReset(cached?.resetsAt ?? null, now);
|
|
92
|
+
if (cached == null || reset == null) return 0;
|
|
93
|
+
const used = cached.resetsAt != null && cached.resetsAt <= now ? 0 : cached.usedPercentage;
|
|
94
|
+
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The switch preference: furthest behind its own weekly pace first (highest
|
|
98
|
+
* pacePressure), tiebreak soonest weekly expiry then lowest 7-day usage.
|
|
99
|
+
* Shared with the statusLine pool ordering so the display order IS the
|
|
80
100
|
* swap order. */
|
|
81
101
|
export const swapPreference = (now: number) => [
|
|
102
|
+
(a: Account) => -pacePressure(a, now),
|
|
82
103
|
(a: Account) => weeklyExpiry(a, now),
|
|
83
104
|
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
84
105
|
];
|
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
|
|
|
@@ -212,6 +212,8 @@ const CRED_ENV_OVERRIDES = [
|
|
|
212
212
|
* Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
|
|
213
213
|
* answers in seconds. */
|
|
214
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;
|
|
215
217
|
|
|
216
218
|
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
217
219
|
let out: string;
|
|
@@ -221,15 +223,30 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
221
223
|
stdout: "pipe",
|
|
222
224
|
stderr: "pipe",
|
|
223
225
|
});
|
|
224
|
-
|
|
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);
|
|
225
229
|
try {
|
|
226
|
-
|
|
227
|
-
|
|
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;
|
|
228
244
|
await p.exited;
|
|
229
245
|
if (p.exitCode !== 0) {
|
|
230
246
|
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
231
247
|
return null;
|
|
232
248
|
}
|
|
249
|
+
out = text;
|
|
233
250
|
} finally {
|
|
234
251
|
clearTimeout(killer);
|
|
235
252
|
}
|
|
@@ -269,7 +286,11 @@ const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
|
269
286
|
* transient, so retry with backoff. Returns null if it never yields data.
|
|
270
287
|
*/
|
|
271
288
|
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
272
|
-
|
|
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) };
|
|
273
294
|
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
274
295
|
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
275
296
|
|