tokenmaxxing 0.3.1 → 0.5.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 +3 -3
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/cli/add.ts +25 -20
- package/src/cli/check.ts +29 -0
- package/src/cli/doctor.ts +2 -1
- package/src/cli/init.ts +8 -2
- package/src/cli/rename.ts +17 -12
- package/src/cli/render.ts +31 -9
- package/src/cli/rm.ts +20 -15
- package/src/cli/status.ts +14 -12
- package/src/entries/statusline.ts +166 -32
- package/src/entries/supervisor.ts +5 -2
- package/src/lib/decide.ts +27 -13
- package/src/lib/install.ts +130 -4
- package/src/lib/keychain.ts +1 -1
- package/src/lib/log.ts +1 -1
- package/src/lib/paths.ts +5 -0
- package/src/lib/picker.ts +10 -7
- package/src/lib/settings.ts +7 -38
- package/src/lib/state.ts +25 -1
- package/src/lib/swap.ts +5 -1
- package/src/lib/types.ts +31 -0
- package/src/lib/usage.ts +33 -5
- package/src/lib/worktree.ts +22 -0
- package/src/main.ts +3 -0
|
@@ -106,7 +106,7 @@ async function countdownWait(acct: string, until: number): Promise<void> {
|
|
|
106
106
|
await Bun.sleep(1000);
|
|
107
107
|
}
|
|
108
108
|
process.removeListener("SIGINT", onInt);
|
|
109
|
-
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}
|
|
109
|
+
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/** Entry point: `claude ...args`. */
|
|
@@ -193,10 +193,13 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
193
193
|
rmSync(marker, { force: true });
|
|
194
194
|
respawns++;
|
|
195
195
|
if (m.waitUntil && m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
|
|
196
|
-
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming
|
|
196
|
+
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
197
197
|
launchArgs = ["--resume", sid, ...base];
|
|
198
198
|
continue;
|
|
199
199
|
}
|
|
200
|
+
// No marker: claude exited on its own (quit, crash, resume refused). Log it -
|
|
201
|
+
// "the process just exited" is undiagnosable without the code/signal.
|
|
202
|
+
log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
|
|
200
203
|
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
|
201
204
|
}
|
|
202
205
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -11,15 +11,16 @@
|
|
|
11
11
|
// the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
|
|
12
12
|
// for the new account arrives.
|
|
13
13
|
|
|
14
|
+
import { maxBy } from "es-toolkit";
|
|
14
15
|
import { z } from "zod";
|
|
15
16
|
import { withLock } from "./lock.ts";
|
|
16
17
|
import { paths } from "./paths.ts";
|
|
17
|
-
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage } from "./state.ts";
|
|
18
|
+
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, writeUsage } from "./state.ts";
|
|
18
19
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
19
20
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
20
21
|
import { pickEarliestReset, usableAt } from "./picker.ts";
|
|
21
22
|
import { InvalidGrantError } from "./oauth.ts";
|
|
22
|
-
import { probeUsage } from "./usage.ts";
|
|
23
|
+
import { familyTokens, matchedFamily, probeUsage } from "./usage.ts";
|
|
23
24
|
import { log } from "./log.ts";
|
|
24
25
|
import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
|
|
25
26
|
|
|
@@ -45,25 +46,34 @@ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsa
|
|
|
45
46
|
const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
|
|
46
47
|
if (fresh) return cached;
|
|
47
48
|
const full = await probeUsage();
|
|
48
|
-
if (!full)
|
|
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
|
+
}
|
|
49
56
|
const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
|
|
50
57
|
saveModelUsage(state);
|
|
51
58
|
return state;
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
/** 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 cap). */
|
|
63
|
+
function capForFamily(mu: ModelUsageState, family: string): UsageWindow | undefined {
|
|
64
|
+
const rows = Object.entries(mu.perModel)
|
|
65
|
+
.filter(([k]) => familyTokens(k).includes(family))
|
|
66
|
+
.map(([, w]) => w);
|
|
67
|
+
return maxBy(rows, (w) => w.usedPercentage);
|
|
58
68
|
}
|
|
59
69
|
|
|
60
70
|
/** True if the active account is over the floor on ANY applicable limit. */
|
|
61
71
|
function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
|
|
62
72
|
if (!u || !org || u.org !== org) return false;
|
|
63
73
|
if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
|
|
64
|
-
const
|
|
65
|
-
if (
|
|
66
|
-
const cap =
|
|
74
|
+
const family = matchedFamily(u.model, cfg.policy.switchModels);
|
|
75
|
+
if (family && mu && mu.org === org) {
|
|
76
|
+
const cap = capForFamily(mu, family);
|
|
67
77
|
if (cap && cap.usedPercentage >= floor) return true;
|
|
68
78
|
}
|
|
69
79
|
return false;
|
|
@@ -71,8 +81,7 @@ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string |
|
|
|
71
81
|
|
|
72
82
|
/** Does the active model warrant a per-model `/usage` poll? */
|
|
73
83
|
function needsPerModel(u: UsageState | null, cfg: Config): boolean {
|
|
74
|
-
|
|
75
|
-
return !!display && cfg.policy.switchModels.includes(display.toLowerCase());
|
|
84
|
+
return matchedFamily(u?.model ?? null, cfg.policy.switchModels) !== null;
|
|
76
85
|
}
|
|
77
86
|
|
|
78
87
|
export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
|
|
@@ -81,7 +90,12 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
|
|
|
81
90
|
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
82
91
|
|
|
83
92
|
let usage = loadUsage();
|
|
84
|
-
if (!usage && activeOrg)
|
|
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
|
+
}
|
|
85
99
|
|
|
86
100
|
// per-model poll (TTL-cached) only when on a capacity-constrained model
|
|
87
101
|
const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
|
package/src/lib/install.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
|
|
2
|
-
// The wrapper is a 2-line `exec
|
|
2
|
+
// The wrapper is a 2-line `exec ... __supervise "$@"` shim so dispatch never
|
|
3
3
|
// depends on argv0 semantics.
|
|
4
4
|
|
|
5
5
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
|
|
6
6
|
import { basename, dirname, join } from "node:path";
|
|
7
|
+
import { escape } from "es-toolkit";
|
|
7
8
|
import { z } from "zod";
|
|
8
9
|
import { HOME, paths } from "./paths.ts";
|
|
9
10
|
import { writeFileAtomic } from "./atomic.ts";
|
|
@@ -13,8 +14,8 @@ import { resolveRealClaude } from "./claudebin.ts";
|
|
|
13
14
|
const InstallOutcomeSchema = z.object({
|
|
14
15
|
claudeWrapper: z.string(),
|
|
15
16
|
installedBin: z.string(),
|
|
16
|
-
priorStatusLine: z.string().nullable(),
|
|
17
17
|
pathAhead: z.boolean(),
|
|
18
|
+
timerLoaded: z.boolean(),
|
|
18
19
|
});
|
|
19
20
|
export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
|
|
20
21
|
|
|
@@ -46,15 +47,139 @@ export function installSupervisor(): InstallOutcome {
|
|
|
46
47
|
// the `xx` short alias → tokenmaxxing
|
|
47
48
|
writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
|
|
48
49
|
|
|
49
|
-
|
|
50
|
+
installSettings();
|
|
50
51
|
return {
|
|
51
52
|
claudeWrapper: paths.supervisorLink,
|
|
52
53
|
installedBin: target,
|
|
53
|
-
priorStatusLine,
|
|
54
54
|
pathAhead: isBinDirAhead(),
|
|
55
|
+
timerLoaded: installCheckTimer(),
|
|
55
56
|
};
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
// ---- periodic `check` timer ------------------------------------------------
|
|
60
|
+
// The hooks evaluate only at turn boundaries; one long agentic turn can burn a
|
|
61
|
+
// window from healthy to depleted with zero boundaries (2026-07-10 incident).
|
|
62
|
+
// A timer closes that gap: launchd on macOS, a systemd user timer on Linux.
|
|
63
|
+
|
|
64
|
+
const CHECK_INTERVAL_S = 180;
|
|
65
|
+
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
66
|
+
|
|
67
|
+
function launchdPlist(): string {
|
|
68
|
+
return join(paths.launchdAgentsDir, `${LAUNCHD_LABEL}.plist`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `gui/<uid>` launchd domain, or null when the platform has no getuid. */
|
|
72
|
+
function launchdDomain(): string | null {
|
|
73
|
+
const uid = process.getuid?.();
|
|
74
|
+
return uid == null ? null : `gui/${uid}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** launchctl/systemctl may be absent (spawnSync throws ENOENT) or hang on a
|
|
78
|
+
* dead session bus (ssh without lingering) - degrade, never crash or block. */
|
|
79
|
+
function run(cmd: string[]): boolean {
|
|
80
|
+
try {
|
|
81
|
+
return Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore", timeout: 10_000 }).exitCode === 0;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Install + activate the periodic check job. False means the unit files are in
|
|
88
|
+
* place but activation failed (e.g. systemd user session absent over ssh) -
|
|
89
|
+
* the caller prints the manual activation step. */
|
|
90
|
+
export function installCheckTimer(): boolean {
|
|
91
|
+
if (process.platform === "darwin") {
|
|
92
|
+
const plist = launchdPlist();
|
|
93
|
+
writeFileAtomic(
|
|
94
|
+
plist,
|
|
95
|
+
`<?xml version="1.0" encoding="UTF-8"?>
|
|
96
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
97
|
+
<plist version="1.0">
|
|
98
|
+
<dict>
|
|
99
|
+
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
100
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
101
|
+
<key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
|
|
102
|
+
<key>StandardOutPath</key><string>/dev/null</string>
|
|
103
|
+
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
104
|
+
</dict>
|
|
105
|
+
</plist>
|
|
106
|
+
`,
|
|
107
|
+
0o644,
|
|
108
|
+
);
|
|
109
|
+
const domain = launchdDomain();
|
|
110
|
+
if (domain == null) return false;
|
|
111
|
+
run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]); // reload a changed plist
|
|
112
|
+
// bootstrap can lose a benign race with an in-flight bootout; loaded is loaded.
|
|
113
|
+
return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// systemd: quote the path and escape `%` (unit-file specifier character).
|
|
117
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
|
|
118
|
+
writeFileAtomic(
|
|
119
|
+
join(paths.systemdUserDir, "tokenmaxxing-check.service"),
|
|
120
|
+
`[Unit]
|
|
121
|
+
Description=tokenmaxxing account-switch check
|
|
122
|
+
|
|
123
|
+
[Service]
|
|
124
|
+
Type=oneshot
|
|
125
|
+
ExecStart=${exec}
|
|
126
|
+
`,
|
|
127
|
+
0o644,
|
|
128
|
+
);
|
|
129
|
+
writeFileAtomic(
|
|
130
|
+
join(paths.systemdUserDir, "tokenmaxxing-check.timer"),
|
|
131
|
+
`[Unit]
|
|
132
|
+
Description=tokenmaxxing periodic account-switch check
|
|
133
|
+
|
|
134
|
+
[Timer]
|
|
135
|
+
OnBootSec=60
|
|
136
|
+
OnUnitActiveSec=${CHECK_INTERVAL_S}
|
|
137
|
+
AccuracySec=30
|
|
138
|
+
|
|
139
|
+
[Install]
|
|
140
|
+
WantedBy=timers.target
|
|
141
|
+
`,
|
|
142
|
+
0o644,
|
|
143
|
+
);
|
|
144
|
+
return (
|
|
145
|
+
run(["systemctl", "--user", "daemon-reload"]) &&
|
|
146
|
+
run(["systemctl", "--user", "enable", "--now", "tokenmaxxing-check.timer"])
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** The manual activation command for an unloaded timer, per platform. */
|
|
151
|
+
export function timerActivationHint(): string {
|
|
152
|
+
if (process.platform === "darwin") {
|
|
153
|
+
return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
|
|
154
|
+
}
|
|
155
|
+
return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** True when the timer unit exists AND the service manager reports it loaded. */
|
|
159
|
+
export function checkTimerHealthy(): boolean {
|
|
160
|
+
if (process.platform === "darwin") {
|
|
161
|
+
const domain = launchdDomain();
|
|
162
|
+
return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
|
|
163
|
+
}
|
|
164
|
+
return (
|
|
165
|
+
existsSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer")) &&
|
|
166
|
+
run(["systemctl", "--user", "is-active", "--quiet", "tokenmaxxing-check.timer"])
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function uninstallCheckTimer(): void {
|
|
171
|
+
if (process.platform === "darwin") {
|
|
172
|
+
const domain = launchdDomain();
|
|
173
|
+
if (domain != null) run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
|
|
174
|
+
rmSync(launchdPlist(), { force: true });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]);
|
|
178
|
+
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
|
|
179
|
+
rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
|
|
180
|
+
run(["systemctl", "--user", "daemon-reload"]);
|
|
181
|
+
}
|
|
182
|
+
|
|
58
183
|
/** The rc file of the user's login shell, or null when the shell is unknown.
|
|
59
184
|
* Overridable for hermetic tests. */
|
|
60
185
|
export function shellRcPath(): string | null {
|
|
@@ -81,6 +206,7 @@ export function ensurePathInRc(rc: string): "added" | "present" {
|
|
|
81
206
|
|
|
82
207
|
export function uninstallSupervisor(): void {
|
|
83
208
|
uninstallSettings();
|
|
209
|
+
uninstallCheckTimer();
|
|
84
210
|
for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
|
|
85
211
|
if (existsSync(f)) rmSync(f, { force: true });
|
|
86
212
|
}
|
package/src/lib/keychain.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// macOS login-keychain generic-password I/O, ps-safe. The darwin backend of
|
|
2
2
|
// credstore.ts - construct targets there, not here.
|
|
3
3
|
// READ : `security find-generic-password -w` → secret only ever in stdout.
|
|
4
|
-
// WRITE : pipe an `add-generic-password -U
|
|
4
|
+
// WRITE : pipe an `add-generic-password -U ... -w <secret>` line into `security -i`
|
|
5
5
|
// over STDIN - the secret never appears in any process's argv (verified).
|
|
6
6
|
// The service/account are non-secret and may sit in argv.
|
|
7
7
|
|
package/src/lib/log.ts
CHANGED
|
@@ -11,7 +11,7 @@ export function redact(s: string): string {
|
|
|
11
11
|
return s
|
|
12
12
|
// JWT-ish / long opaque tokens
|
|
13
13
|
.replace(/\b(sk-ant-[A-Za-z0-9._-]{6,})/g, "sk-ant-***")
|
|
14
|
-
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}
|
|
14
|
+
.replace(/\b([A-Za-z0-9_-]{40,})\b/g, (m) => `${m.slice(0, 4)}...(${m.length})`);
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function log(event: string, fields: Record<string, unknown> = {}): void {
|
package/src/lib/paths.ts
CHANGED
|
@@ -21,6 +21,7 @@ export const paths = {
|
|
|
21
21
|
accountsJson: join(TM_HOME, "accounts.json"),
|
|
22
22
|
usageJson: join(TM_HOME, "usage.json"),
|
|
23
23
|
modelUsageJson: join(TM_HOME, "model-usage.json"),
|
|
24
|
+
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
24
25
|
respawnDir: join(TM_HOME, "respawn"),
|
|
25
26
|
binDir: join(TM_HOME, "bin"),
|
|
26
27
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
@@ -40,6 +41,10 @@ export const paths = {
|
|
|
40
41
|
),
|
|
41
42
|
/** ~/.claude - for the credential-refresh lock and projects/ transcripts. */
|
|
42
43
|
claudeDir: env("CLAUDE_CONFIG_DIR", join(HOME, ".claude")),
|
|
44
|
+
|
|
45
|
+
/** where the periodic-check timer units live (launchd / systemd user). */
|
|
46
|
+
launchdAgentsDir: env("TOKENMAXXING_LAUNCHD_DIR", join(HOME, "Library", "LaunchAgents")),
|
|
47
|
+
systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
|
|
43
48
|
} as const;
|
|
44
49
|
|
|
45
50
|
/** Claude's own credential-refresh lock (verified path filled from facts). */
|
package/src/lib/picker.ts
CHANGED
|
@@ -38,6 +38,15 @@ export function weeklyExpiry(a: Account, now: number): number {
|
|
|
38
38
|
return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** The switch preference: soonest weekly expiry first; tiebreak lowest 7-day
|
|
42
|
+
* usage, then soonest 5h reset. Shared with the statusLine pool ordering so
|
|
43
|
+
* the display order IS the swap order. */
|
|
44
|
+
export const swapPreference = (now: number) => [
|
|
45
|
+
(a: Account) => weeklyExpiry(a, now),
|
|
46
|
+
(a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
47
|
+
(a: Account) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
48
|
+
];
|
|
49
|
+
|
|
41
50
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
42
51
|
const candidates = accounts.filter(
|
|
43
52
|
(a) =>
|
|
@@ -46,13 +55,7 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
|
46
55
|
!isExhausted(a, ctx),
|
|
47
56
|
);
|
|
48
57
|
if (candidates.length === 0) return null;
|
|
49
|
-
|
|
50
|
-
// soonest weekly expiry first; tiebreak lowest 7-day usage, then soonest 5h reset.
|
|
51
|
-
return sortBy(candidates, [
|
|
52
|
-
(a) => weeklyExpiry(a, ctx.now),
|
|
53
|
-
(a) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
54
|
-
(a) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
55
|
-
])[0]!;
|
|
58
|
+
return sortBy(candidates, swapPreference(ctx.now))[0]!;
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/** When an account becomes usable again: the latest reset among its over-threshold
|
package/src/lib/settings.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Idempotent merge of tokenmaxxing's three entries into the user-owned
|
|
2
|
-
// ~/.claude/settings.json:
|
|
3
|
-
//
|
|
2
|
+
// ~/.claude/settings.json: our statusLine, a Stop hook, a SessionStart hook.
|
|
3
|
+
// Hooks APPEND to existing arrays; the statusLine slot is ours outright -
|
|
4
|
+
// tokenmaxxing renders it natively, so any other statusLine command is replaced.
|
|
4
5
|
|
|
5
6
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
@@ -23,8 +24,6 @@ const SettingsSchema = z.looseObject({
|
|
|
23
24
|
type Settings = z.infer<typeof SettingsSchema>;
|
|
24
25
|
type HookGroup = z.infer<typeof HookGroupSchema>;
|
|
25
26
|
|
|
26
|
-
const PRIOR_STATUSLINE_FILE = join(paths.home, "prior-statusline.json");
|
|
27
|
-
|
|
28
27
|
const SUBCMD = {
|
|
29
28
|
statusline: "__statusline",
|
|
30
29
|
stop: "__stop-hook",
|
|
@@ -71,57 +70,27 @@ function removeHook(s: Settings, event: string, sub: string): void {
|
|
|
71
70
|
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
72
71
|
}
|
|
73
72
|
|
|
74
|
-
|
|
75
|
-
export
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Install the three entries. Returns the prior statusLine command that was
|
|
79
|
-
* wrapped (stored to disk so the shim can chain to it and uninstall can restore).
|
|
80
|
-
*/
|
|
81
|
-
export function installSettings(): InstallResult {
|
|
73
|
+
/** Install the three entries: take the statusLine slot, append our hooks. */
|
|
74
|
+
export function installSettings(): void {
|
|
82
75
|
const s = readSettings();
|
|
83
|
-
|
|
84
|
-
// ---- statusLine: capture prior (unless it's already ours), then wrap.
|
|
85
|
-
let prior: string | null;
|
|
86
|
-
if (s.statusLine && !isOurCommand(s.statusLine.command)) {
|
|
87
|
-
prior = s.statusLine.command;
|
|
88
|
-
writeFileAtomic(PRIOR_STATUSLINE_FILE, JSON.stringify({ command: prior }) + "\n", 0o644);
|
|
89
|
-
} else {
|
|
90
|
-
prior = readPriorStatusLine();
|
|
91
|
-
}
|
|
92
76
|
s.statusLine = {
|
|
93
77
|
type: "command",
|
|
94
78
|
command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
|
|
95
79
|
};
|
|
96
|
-
|
|
97
|
-
// ---- hooks: append ours if absent.
|
|
98
80
|
appendHook(s, "Stop", SUBCMD.stop);
|
|
99
81
|
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
100
|
-
|
|
101
82
|
writeSettings(s);
|
|
102
|
-
return { priorStatusLine: prior };
|
|
103
83
|
}
|
|
104
84
|
|
|
105
|
-
/** Remove our three entries
|
|
85
|
+
/** Remove our three entries. The statusLine slot is deleted only if it is ours. */
|
|
106
86
|
export function uninstallSettings(): void {
|
|
107
87
|
const s = readSettings();
|
|
108
88
|
removeHook(s, "Stop", SUBCMD.stop);
|
|
109
89
|
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
110
|
-
if (s.statusLine && isOurCommand(s.statusLine.command))
|
|
111
|
-
const prior = readPriorStatusLine();
|
|
112
|
-
if (prior) s.statusLine = { type: "command", command: prior };
|
|
113
|
-
else delete s.statusLine;
|
|
114
|
-
}
|
|
90
|
+
if (s.statusLine && isOurCommand(s.statusLine.command)) delete s.statusLine;
|
|
115
91
|
writeSettings(s);
|
|
116
92
|
}
|
|
117
93
|
|
|
118
|
-
const PriorStatusLineSchema = z.object({ command: z.string() });
|
|
119
|
-
|
|
120
|
-
export function readPriorStatusLine(): string | null {
|
|
121
|
-
if (!existsSync(PRIOR_STATUSLINE_FILE)) return null;
|
|
122
|
-
return PriorStatusLineSchema.parse(JSON.parse(readFileSync(PRIOR_STATUSLINE_FILE, "utf8"))).command;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
94
|
const SettingsCheckSchema = z.object({
|
|
126
95
|
statusLineOk: z.boolean(),
|
|
127
96
|
stopOk: z.boolean(),
|
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 } from "node:fs";
|
|
3
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
4
|
import { isEqual } from "es-toolkit";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { paths, realClaudeBinFromEnv } from "./paths.ts";
|
|
@@ -8,6 +8,7 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
AccountsIndexSchema,
|
|
10
10
|
ConfigSchema,
|
|
11
|
+
LastSwapSchema,
|
|
11
12
|
ModelUsageStateSchema,
|
|
12
13
|
UsageStateSchema,
|
|
13
14
|
type AccountsIndex,
|
|
@@ -100,6 +101,29 @@ export function loadUsage(): UsageState | null {
|
|
|
100
101
|
}
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** Drop the statusLine-fed snapshots after a swap: their windows belong to the
|
|
105
|
+
* pre-swap account and would otherwise be read under the new active org. */
|
|
106
|
+
export function clearUsageSnapshots(): void {
|
|
107
|
+
rmSync(paths.usageJson, { force: true });
|
|
108
|
+
rmSync(paths.modelUsageJson, { force: true });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- lastswap.json (epoch ms of the last swap; absent = never swapped) ----
|
|
112
|
+
|
|
113
|
+
export function loadLastSwapAt(): number | null {
|
|
114
|
+
if (!existsSync(paths.lastSwapJson)) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = LastSwapSchema.safeParse(JSON.parse(readFileSync(paths.lastSwapJson, "utf8")));
|
|
117
|
+
return parsed.success ? parsed.data.ts : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveLastSwapAt(ts: number): void {
|
|
124
|
+
writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
125
|
+
}
|
|
126
|
+
|
|
103
127
|
/** Write-on-change: skip the write (and its fsync) when only `ts` would differ. */
|
|
104
128
|
export function writeUsage(next: UsageState): boolean {
|
|
105
129
|
const prev = loadUsage();
|
package/src/lib/swap.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// mark B active (inside the lock: a crash before this write leaves a stale
|
|
13
13
|
// active label, which is exactly what once made a harvest destroy a backup)
|
|
14
14
|
|
|
15
|
-
import { loadAccounts, saveAccounts } from "./state.ts";
|
|
15
|
+
import { clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
16
16
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
17
17
|
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
18
18
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
@@ -110,6 +110,10 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
110
110
|
const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
111
111
|
if (t2) { t2.needsReauth = false; }
|
|
112
112
|
saveAccounts(idx);
|
|
113
|
+
// the snapshots on disk still describe the pre-swap account; under the new
|
|
114
|
+
// org label they'd trigger a bogus switch off the account just installed.
|
|
115
|
+
clearUsageSnapshots();
|
|
116
|
+
saveLastSwapAt(Date.now());
|
|
113
117
|
});
|
|
114
118
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
115
119
|
}
|
package/src/lib/types.ts
CHANGED
|
@@ -94,6 +94,11 @@ export const AccountsIndexSchema = z.object({
|
|
|
94
94
|
activeAccountUuid: z.string().nullable(),
|
|
95
95
|
accounts: z.array(AccountSchema).default([]),
|
|
96
96
|
});
|
|
97
|
+
|
|
98
|
+
/** lastswap.json - epoch ms of the last credential swap. Its own tiny file (not
|
|
99
|
+
* accounts.json) so the statusLine shim reads a few bytes per tick and no other
|
|
100
|
+
* index writer can clobber it. Absent = no swap has ever run. */
|
|
101
|
+
export const LastSwapSchema = z.object({ ts: z.number() });
|
|
97
102
|
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
98
103
|
|
|
99
104
|
export const ConfigSchema = z.object({
|
|
@@ -132,6 +137,32 @@ export const RateLimitsStdinSchema = z.looseObject({
|
|
|
132
137
|
organizationUuid: z.string().optional(),
|
|
133
138
|
});
|
|
134
139
|
|
|
140
|
+
/** The statusLine stdin fields the native renderer consumes, on top of the
|
|
141
|
+
* rate-limit tee's needs. Loose + optional throughout: fields are null before
|
|
142
|
+
* the first API response and claude adds new ones freely. Each sub-object also
|
|
143
|
+
* `.catch(undefined)`es so a field that drifts to a wrong shape degrades to
|
|
144
|
+
* absent instead of failing the whole parse and erasing the info block. */
|
|
145
|
+
export const StatusLineStdinSchema = RateLimitsStdinSchema.extend({
|
|
146
|
+
workspace: z
|
|
147
|
+
.looseObject({
|
|
148
|
+
current_dir: z.string().nullable().optional(),
|
|
149
|
+
project_dir: z.string().nullable().optional(),
|
|
150
|
+
})
|
|
151
|
+
.nullable()
|
|
152
|
+
.optional()
|
|
153
|
+
.catch(undefined),
|
|
154
|
+
context_window: z.looseObject({ used_percentage: z.number().nullable().optional() }).nullable().optional().catch(undefined),
|
|
155
|
+
cost: z
|
|
156
|
+
.looseObject({
|
|
157
|
+
total_lines_added: z.number().nullable().optional(),
|
|
158
|
+
total_lines_removed: z.number().nullable().optional(),
|
|
159
|
+
})
|
|
160
|
+
.nullable()
|
|
161
|
+
.optional()
|
|
162
|
+
.catch(undefined),
|
|
163
|
+
effort: z.looseObject({ level: z.string().optional() }).nullable().optional().catch(undefined),
|
|
164
|
+
});
|
|
165
|
+
|
|
135
166
|
/** Success body of the OAuth refresh grant. */
|
|
136
167
|
export const RefreshResponseSchema = z.looseObject({
|
|
137
168
|
access_token: z.string(),
|
package/src/lib/usage.ts
CHANGED
|
@@ -49,6 +49,24 @@ export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
|
49
49
|
return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
|
|
53
|
+
* "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
|
|
54
|
+
* drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
|
|
55
|
+
* historically flipped between family-first and version-first), so gates match
|
|
56
|
+
* a family token anywhere instead of an exact string - an exact-string gate
|
|
57
|
+
* silently disabled the per-model check in the 2026-07-09/10 incidents. */
|
|
58
|
+
export function familyTokens(s: string): string[] {
|
|
59
|
+
return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The switchModels family the active model belongs to, from its id OR display
|
|
63
|
+
* tokens; null when the model is not capacity-constrained. */
|
|
64
|
+
export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
|
|
65
|
+
if (!model) return null;
|
|
66
|
+
const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
|
|
67
|
+
return families.find((f) => tokens.has(f)) ?? null;
|
|
68
|
+
}
|
|
69
|
+
|
|
52
70
|
export const FullUsageSchema = z.object({
|
|
53
71
|
session: UsageWindowSchema,
|
|
54
72
|
weekAll: UsageWindowSchema,
|
|
@@ -122,8 +140,8 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
|
|
|
122
140
|
/**
|
|
123
141
|
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
124
142
|
* Current session: N% used · resets <clock> → session (5h)
|
|
125
|
-
* Current week (all models): N% used · resets
|
|
126
|
-
* Current week (<Model>): N% used · resets
|
|
143
|
+
* Current week (all models): N% used · resets ... → weekAll (7d aggregate)
|
|
144
|
+
* Current week (<Model>): N% used · resets ... → perModel[<Model>]
|
|
127
145
|
*/
|
|
128
146
|
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
129
147
|
if (!text) return null;
|
|
@@ -213,13 +231,19 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
|
|
|
213
231
|
return full;
|
|
214
232
|
}
|
|
215
233
|
|
|
234
|
+
/** Escalating retry delays for the empty-footer case (usage endpoint throttled
|
|
235
|
+
* or the sampled token busy). Capped at ~7s of sleep: probeUsage runs inside
|
|
236
|
+
* Stop/SessionStart hooks and under the status flock, and the periodic `check`
|
|
237
|
+
* timer re-runs every 3 minutes anyway, owning the long-tail retry. */
|
|
238
|
+
const PROBE_RETRY_DELAYS_MS = [2000, 5000];
|
|
239
|
+
|
|
216
240
|
/**
|
|
217
241
|
* Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
|
|
218
242
|
* Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
|
|
219
243
|
* sample the live account. All ambient credential overrides are scrubbed so the
|
|
220
244
|
* probe meters exactly the OAuth credential in the (possibly namespaced)
|
|
221
245
|
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
222
|
-
* transient, so retry
|
|
246
|
+
* transient, so retry with backoff. Returns null if it never yields data.
|
|
223
247
|
*/
|
|
224
248
|
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
225
249
|
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1" };
|
|
@@ -228,7 +252,11 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
|
|
|
228
252
|
|
|
229
253
|
for (let attempt = 0; ; attempt++) {
|
|
230
254
|
const full = await probeUsageOnce(env, now);
|
|
231
|
-
if (full
|
|
232
|
-
|
|
255
|
+
if (full) return full;
|
|
256
|
+
if (attempt >= PROBE_RETRY_DELAYS_MS.length) {
|
|
257
|
+
log("usage.probe_gave_up", { attempts: attempt + 1 });
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
|
|
233
261
|
}
|
|
234
262
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Linked-worktree detection for the statusLine. A linked worktree's root holds
|
|
2
|
+
// a .git FILE (a gitdir pointer) where the main checkout has a .git directory.
|
|
3
|
+
// Read the filesystem directly - never a git subprocess.
|
|
4
|
+
|
|
5
|
+
import { statSync } from "node:fs";
|
|
6
|
+
import { basename, dirname, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** The worktree root's basename when `dir` is inside a LINKED git worktree;
|
|
9
|
+
* null in a main checkout or outside any repository. Any fs error while
|
|
10
|
+
* walking (EACCES, ENOTDIR, stale mounts) is also null: the status line must
|
|
11
|
+
* never break over an unreadable path. */
|
|
12
|
+
export function worktreeName(dir: string): string | null {
|
|
13
|
+
try {
|
|
14
|
+
for (let cur = dir; ; cur = dirname(cur)) {
|
|
15
|
+
const st = statSync(join(cur, ".git"), { throwIfNoEntry: false });
|
|
16
|
+
if (st) return st.isFile() ? basename(cur) : null;
|
|
17
|
+
if (dirname(cur) === cur) return null;
|
|
18
|
+
}
|
|
19
|
+
} catch {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { cmdDoctor } from "./cli/doctor.ts";
|
|
|
16
16
|
import { cmdRm } from "./cli/rm.ts";
|
|
17
17
|
import { cmdRename } from "./cli/rename.ts";
|
|
18
18
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
19
|
+
import { cmdCheck } from "./cli/check.ts";
|
|
19
20
|
import { uninstallSupervisor } from "./lib/install.ts";
|
|
20
21
|
import { c } from "./cli/render.ts";
|
|
21
22
|
|
|
@@ -24,6 +25,7 @@ function printHelp(): void {
|
|
|
24
25
|
|
|
25
26
|
${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
|
|
26
27
|
${c.cyan("tokenmaxxing switch")} [sel] switch now to the best (or a specific) account
|
|
28
|
+
${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
|
|
27
29
|
${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
|
|
28
30
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
29
31
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
@@ -56,6 +58,7 @@ async function main(): Promise<number> {
|
|
|
56
58
|
case "__session-start": return runSessionStart();
|
|
57
59
|
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
58
60
|
case "switch": return cmdSwitch(args[1]);
|
|
61
|
+
case "check": return cmdCheck();
|
|
59
62
|
case "init": return cmdInit();
|
|
60
63
|
case "add": return cmdAdd();
|
|
61
64
|
case "ls": return cmdLs();
|