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 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 --if-due` once per tick (`policy.checkIntervalMs`, 60s by default), self-paced up to five ticks from the live account's headroom: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
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 and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
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.23.0",
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 { checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
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(args: string[] = [], json = false): Promise<number> {
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
- cfg = loadConfig();
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)} ${next}`);
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)})`) + ` ${next}`);
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}) `) + next);
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 { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
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, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
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
- }
@@ -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><string>--if-due</string></array>
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 --if-due`;
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, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
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": return cmdCheck(args.slice(1), json);
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();