tokenmaxxing 1.23.0 → 1.24.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/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/check.ts +6 -22
- package/src/lib/decide.ts +2 -37
- package/src/lib/install.ts +2 -2
- package/src/lib/paths.ts +0 -1
- package/src/lib/state.ts +0 -26
- package/src/lib/swap.ts +1 -2
- package/src/lib/types.ts +0 -2
- package/src/main.ts +7 -1
package/DESIGN.md
CHANGED
|
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
37
37
|
- `bin/claude` - the supervisor.
|
|
38
38
|
- Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform.
|
|
39
39
|
|
|
40
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check
|
|
40
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
|
|
41
41
|
|
|
42
42
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary and `~/.claude` layout are untouched.
|
|
43
43
|
|
package/README.md
CHANGED
|
@@ -113,7 +113,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
113
113
|
}
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer
|
|
116
|
+
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
117
117
|
|
|
118
118
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
119
119
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.24.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/check.ts
CHANGED
|
@@ -1,49 +1,33 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
1
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
3
2
|
import { log } from "../lib/log.ts";
|
|
4
|
-
import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
|
|
5
3
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
6
4
|
|
|
7
|
-
export async function cmdCheck(
|
|
8
|
-
const now = Date.now();
|
|
9
|
-
let cfg;
|
|
5
|
+
export async function cmdCheck(json = false): Promise<number> {
|
|
10
6
|
let d;
|
|
11
7
|
try {
|
|
12
|
-
|
|
13
|
-
const dueAt = args.includes("--if-due") ? loadNextCheckDueAt({ now, cfg }) : null;
|
|
14
|
-
if (dueAt != null && now + cfg.policy.checkIntervalMs / 2 < dueAt) {
|
|
15
|
-
if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
|
|
16
|
-
else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
17
|
-
return 0;
|
|
18
|
-
}
|
|
19
|
-
d = await evaluateAndMaybeSwap(now);
|
|
8
|
+
d = await evaluateAndMaybeSwap(Date.now());
|
|
20
9
|
} catch (e) {
|
|
21
10
|
const detail = e instanceof Error ? e.message : String(e);
|
|
22
11
|
log("check.error", { err: detail });
|
|
23
12
|
emitError({ json, message: `check failed: ${detail}` });
|
|
24
13
|
return 1;
|
|
25
14
|
}
|
|
26
|
-
const delayMs = checkDelayMs({ cfg, account: readOAuthAccount()?.accountUuid ?? null, now, decision: d });
|
|
27
|
-
saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
|
|
28
15
|
if (json) {
|
|
29
16
|
emitJson({
|
|
30
17
|
ok: true,
|
|
31
|
-
due: true,
|
|
32
18
|
swapped: d.swapped,
|
|
33
19
|
account: d.account?.label ?? null,
|
|
34
20
|
reason: d.reason,
|
|
35
21
|
waitUntil: d.waitUntil ?? null,
|
|
36
|
-
nextCheckAt: now + delayMs,
|
|
37
22
|
});
|
|
38
23
|
return 0;
|
|
39
24
|
}
|
|
40
|
-
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
41
25
|
if (d.swapped && d.account) {
|
|
42
|
-
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}
|
|
26
|
+
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
|
|
43
27
|
} else if (d.waitUntil !== undefined && d.account) {
|
|
44
|
-
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`)
|
|
28
|
+
console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
|
|
45
29
|
} else {
|
|
46
|
-
console.log(c.dim(`no switch (${d.reason})
|
|
30
|
+
console.log(c.dim(`no switch (${d.reason})`));
|
|
47
31
|
}
|
|
48
32
|
return 0;
|
|
49
33
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -2,11 +2,11 @@ import { maxBy } from "es-toolkit";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { withLock } from "./lock.ts";
|
|
4
4
|
import { paths } from "./paths.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
7
|
import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
|
|
8
8
|
import { probeParkedUsage } from "./sample.ts";
|
|
9
|
-
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset,
|
|
9
|
+
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, usableAt, type PickCtx } from "./picker.ts";
|
|
10
10
|
import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
|
|
11
11
|
import { log } from "./log.ts";
|
|
12
12
|
import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
|
|
@@ -413,38 +413,3 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
|
|
|
413
413
|
return { outcome: "stamped", resetsAt: limit.resetsAt };
|
|
414
414
|
});
|
|
415
415
|
}
|
|
416
|
-
|
|
417
|
-
const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
|
|
418
|
-
|
|
419
|
-
export function checkDelayMs(input: { cfg: Config; account: string | null; now: number; decision: SwapDecision }): number {
|
|
420
|
-
const { cfg, account, now, decision } = input;
|
|
421
|
-
const tick = cfg.policy.checkIntervalMs;
|
|
422
|
-
const max = maxCheckDelayMs(cfg);
|
|
423
|
-
if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
|
|
424
|
-
const swapAt = loadLastSwapAt();
|
|
425
|
-
if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
|
|
426
|
-
const snap = loadUsageSnapshot();
|
|
427
|
-
if (!account || !snap || !usageFresh(snap.state, snap.at, account, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
|
|
428
|
-
const accounts = loadAccounts().accounts;
|
|
429
|
-
const u = freshest(snap.state, snap.at, accounts.find((a) => a.accountUuid === account)) ?? { ...snap.state, ts: snap.at };
|
|
430
|
-
const mu = loadModelUsage();
|
|
431
|
-
const muSame = mu && mu.account === account ? mu : null;
|
|
432
|
-
const families = gatedFamilies(u.model, cfg.policy.switchModels);
|
|
433
|
-
const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, account), now, switchFamilies: families });
|
|
434
|
-
const heads = [
|
|
435
|
-
bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
|
|
436
|
-
bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
|
|
437
|
-
];
|
|
438
|
-
let capMissing = false;
|
|
439
|
-
const capFresh = muSame != null && now - (muSame.sampledAt ?? muSame.ts) <= cfg.policy.usagePollTtlMs;
|
|
440
|
-
for (const family of families) {
|
|
441
|
-
const cap = muSame && capFresh ? capForFamily(muSame, family, now) : undefined;
|
|
442
|
-
if (cap && muSame) heads.push(bars.weekly - liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now }));
|
|
443
|
-
else capMissing = true;
|
|
444
|
-
}
|
|
445
|
-
const headroom = Math.min(...heads);
|
|
446
|
-
const banded = headroom >= 40 ? max : headroom >= 20 ? 3 * tick : headroom >= 8 ? 2 * tick : tick;
|
|
447
|
-
const stage = sessionLadder(cfg).indexOf(bars.session);
|
|
448
|
-
const staged = Math.min(banded, (STAGE_CEILING_TICKS[stage] ?? 1) * tick);
|
|
449
|
-
return capMissing ? Math.min(staged, 2 * tick) : staged;
|
|
450
|
-
}
|
package/src/lib/install.ts
CHANGED
|
@@ -214,7 +214,7 @@ function installCheckTimer(intervalS: number): boolean {
|
|
|
214
214
|
<plist version="1.0">
|
|
215
215
|
<dict>
|
|
216
216
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
217
|
-
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string
|
|
217
|
+
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
|
|
218
218
|
<key>StartInterval</key><integer>${intervalS}</integer>
|
|
219
219
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
220
220
|
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
@@ -229,7 +229,7 @@ function installCheckTimer(intervalS: number): boolean {
|
|
|
229
229
|
return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
const exec = `"${installedBin().replaceAll("%", "%%")}" check
|
|
232
|
+
const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
|
|
233
233
|
writeFileAtomic(
|
|
234
234
|
join(paths.systemdUserDir, "tokenmaxxing-check.service"),
|
|
235
235
|
`[Unit]
|
package/src/lib/paths.ts
CHANGED
|
@@ -20,7 +20,6 @@ export const paths = {
|
|
|
20
20
|
modelUsageJson: join(TM_HOME, "model-usage.json"),
|
|
21
21
|
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
22
22
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
23
|
-
nextCheckJson: join(TM_HOME, "nextcheck.json"),
|
|
24
23
|
respawnDir: join(TM_HOME, "respawn"),
|
|
25
24
|
binDir: join(TM_HOME, "bin"),
|
|
26
25
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
package/src/lib/state.ts
CHANGED
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
LastSwapSchema,
|
|
10
10
|
ModelUsageStateSchema,
|
|
11
|
-
NextCheckSchema,
|
|
12
11
|
SessionLadderSchema,
|
|
13
12
|
UsageStateSchema,
|
|
14
13
|
type AccountsIndex,
|
|
@@ -209,33 +208,8 @@ export function clearDepletedWait(): void {
|
|
|
209
208
|
rmSync(paths.depletedJson, { force: true });
|
|
210
209
|
}
|
|
211
210
|
|
|
212
|
-
export const MAX_CHECK_DELAY_TICKS = 5;
|
|
213
211
|
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
214
212
|
|
|
215
|
-
export function maxCheckDelayMs(cfg: Config): number {
|
|
216
|
-
return MAX_CHECK_DELAY_TICKS * cfg.policy.checkIntervalMs;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export function loadNextCheckDueAt(input: { now: number; cfg: Config }): number | null {
|
|
220
|
-
if (!existsSync(paths.nextCheckJson)) return null;
|
|
221
|
-
let parsed;
|
|
222
|
-
try {
|
|
223
|
-
parsed = NextCheckSchema.safeParse(JSON.parse(readFileSync(paths.nextCheckJson, "utf8")));
|
|
224
|
-
} catch {
|
|
225
|
-
return null;
|
|
226
|
-
}
|
|
227
|
-
if (!parsed.success) return null;
|
|
228
|
-
return parsed.data.dueAt - input.now > Math.max(maxCheckDelayMs(input.cfg), POST_SWAP_COOLDOWN_MS) ? null : parsed.data.dueAt;
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
|
|
232
|
-
writeFileAtomic(paths.nextCheckJson, JSON.stringify(NextCheckSchema.parse(input)));
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
export function clearNextCheck(): void {
|
|
236
|
-
rmSync(paths.nextCheckJson, { force: true });
|
|
237
|
-
}
|
|
238
|
-
|
|
239
213
|
const USAGE_TS_REFRESH_MS = 10 * 60_000;
|
|
240
214
|
const SAMPLED_AT_REFRESH_MS = 30_000;
|
|
241
215
|
|
package/src/lib/swap.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { clearDepletedWait,
|
|
2
|
+
import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
|
|
3
3
|
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
4
4
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
5
5
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
@@ -155,7 +155,6 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
155
155
|
saveAccounts(idx);
|
|
156
156
|
clearUsageSnapshots();
|
|
157
157
|
clearDepletedWait();
|
|
158
|
-
clearNextCheck();
|
|
159
158
|
saveLastSwapAt(Date.now());
|
|
160
159
|
});
|
|
161
160
|
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
package/src/lib/types.ts
CHANGED
|
@@ -85,8 +85,6 @@ export const AccountsIndexSchema = z.object({
|
|
|
85
85
|
export const LastSwapSchema = z.object({ ts: z.number() });
|
|
86
86
|
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
87
87
|
|
|
88
|
-
export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
|
|
89
|
-
|
|
90
88
|
export const EnforcedLimitSchema = z.object({
|
|
91
89
|
account: z.string(),
|
|
92
90
|
family: z.string().nullable(),
|
package/src/main.ts
CHANGED
|
@@ -140,7 +140,13 @@ async function main(): Promise<number> {
|
|
|
140
140
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|
|
141
141
|
return args.includes("--codex") ? cmdCodexSwitch(rest[0], json) : cmdSwitch(rest[0], json);
|
|
142
142
|
}
|
|
143
|
-
case "check":
|
|
143
|
+
case "check": {
|
|
144
|
+
if (args.length > 1) {
|
|
145
|
+
emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
|
|
146
|
+
return 2;
|
|
147
|
+
}
|
|
148
|
+
return cmdCheck(json);
|
|
149
|
+
}
|
|
144
150
|
case "config": return cmdConfig(args.slice(1), json);
|
|
145
151
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
146
152
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|