tokenmaxxing 0.3.0 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.3.0",
3
+ "version": "0.4.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/add.ts CHANGED
@@ -11,6 +11,7 @@ import { readItem, writeItem, deleteItem, parkedTarget, isolatedTarget, claudeAi
11
11
  import { resolveRealClaude } from "../lib/claudebin.ts";
12
12
  import { probeUsage } from "../lib/usage.ts";
13
13
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
14
+ import { withLock } from "../lib/lock.ts";
14
15
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
15
16
  import { credItemFor, paths } from "../lib/paths.ts";
16
17
  import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
@@ -101,29 +102,33 @@ export async function cmdAdd(): Promise<number> {
101
102
  const keychainItem = credItemFor(uuid);
102
103
  await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw)); // park a small backup
103
104
 
104
- const idx = loadAccounts();
105
- const existing = idx.accounts.find((a) => a.accountUuid === uuid);
106
- const account: Account = {
107
- accountUuid: uuid,
108
- email: oauthAccount.emailAddress,
109
- organizationUuid: oauthAccount.organizationUuid,
110
- label: existing?.label ?? oauthAccount.emailAddress,
111
- keychainItem,
112
- oauthAccount,
113
- addedAt: existing?.addedAt ?? new Date().toISOString(),
114
- subscriptionType: blob.claudeAiOauth.subscriptionType,
115
- needsReauth: false,
116
- lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
117
- lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
118
- };
119
- if (existing) Object.assign(existing, account);
120
- else idx.accounts.push(account);
121
- saveAccounts(idx);
105
+ // under the flock: a concurrent swap's index write must not be clobbered.
106
+ const { account, poolSize } = await withLock(paths.lockFile, async () => {
107
+ const idx = loadAccounts();
108
+ const existing = idx.accounts.find((a) => a.accountUuid === uuid);
109
+ const fresh: Account = {
110
+ accountUuid: uuid,
111
+ email: oauthAccount.emailAddress,
112
+ organizationUuid: oauthAccount.organizationUuid,
113
+ label: existing?.label ?? oauthAccount.emailAddress,
114
+ keychainItem,
115
+ oauthAccount,
116
+ addedAt: existing?.addedAt ?? new Date().toISOString(),
117
+ subscriptionType: blob.claudeAiOauth.subscriptionType,
118
+ needsReauth: false,
119
+ lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
120
+ lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
121
+ };
122
+ if (existing) Object.assign(existing, fresh);
123
+ else idx.accounts.push(fresh);
124
+ saveAccounts(idx);
125
+ return { account: fresh, poolSize: idx.accounts.length };
126
+ });
122
127
 
123
128
  await cleanup();
124
129
 
125
130
  console.log();
126
131
  const usageNote = sampled ? ` · session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%` : "";
127
- console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${idx.accounts.length} account(s)`);
132
+ console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${poolSize} account(s)`);
128
133
  return 0;
129
134
  }
@@ -0,0 +1,29 @@
1
+ // `tokenmaxxing check` - one auto-switch evaluation, the same decision the
2
+ // Stop/SessionStart hooks make (flocked, idempotent). Installed as a periodic
3
+ // launchd/systemd job so a limit crossed mid-turn, or with no session running,
4
+ // still switches within minutes; the hooks only ever see turn boundaries.
5
+ // Running claude sessions adopt the swapped credential in place (<=30s).
6
+
7
+ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
8
+ import { log } from "../lib/log.ts";
9
+ import { c, fmtReset } from "./render.ts";
10
+
11
+ export async function cmdCheck(): Promise<number> {
12
+ let d;
13
+ try {
14
+ d = await evaluateAndMaybeSwap();
15
+ } catch (e) {
16
+ // unattended under the timer: the log is the only place anyone will look.
17
+ log("check.error", { err: String((e as Error).message ?? e) });
18
+ console.error(c.red(`check failed: ${String((e as Error).message ?? e)}`));
19
+ return 1;
20
+ }
21
+ if (d.swapped && d.account) {
22
+ console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)}`);
23
+ } else if (d.waitUntil !== undefined && d.account) {
24
+ console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`));
25
+ } else {
26
+ console.log(c.dim(`no switch (${d.reason})`));
27
+ }
28
+ return 0;
29
+ }
package/src/cli/doctor.ts CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  import { existsSync } from "node:fs";
5
5
  import { checkSettings, installedBin } from "../lib/settings.ts";
6
- import { isBinDirAhead } from "../lib/install.ts";
6
+ import { checkTimerHealthy, isBinDirAhead, timerActivationHint } from "../lib/install.ts";
7
7
  import { paths } from "../lib/paths.ts";
8
8
  import { loadAccounts, loadConfig } from "../lib/state.ts";
9
9
  import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
@@ -34,6 +34,7 @@ export async function cmdDoctor(): Promise<number> {
34
34
  check(s.statusLineOk, "statusLine shim installed in settings.json", "run `tokenmaxxing init`");
35
35
  check(s.stopOk, "Stop hook installed in settings.json", "run `tokenmaxxing init`");
36
36
  check(s.sessionStartOk, "SessionStart hook installed in settings.json", "run `tokenmaxxing init`");
37
+ check(checkTimerHealthy(), "periodic check timer active", timerActivationHint());
37
38
 
38
39
  const idx = loadAccounts();
39
40
  check(idx.accounts.length > 0, "at least one account in the pool", "run `tokenmaxxing init`");
package/src/cli/init.ts CHANGED
@@ -6,7 +6,7 @@ import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
6
6
  import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
7
7
  import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
8
8
  import { loadAccounts, saveAccounts, loadConfig, saveConfig } from "../lib/state.ts";
9
- import { installSupervisor, shellRcPath, ensurePathInRc } from "../lib/install.ts";
9
+ import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, type InstallOutcome } from "../lib/install.ts";
10
10
  import { resolveRealClaude } from "../lib/claudebin.ts";
11
11
  import { credItemFor, paths } from "../lib/paths.ts";
12
12
  import { CredentialBlobSchema, type Account } from "../lib/types.ts";
@@ -25,6 +25,11 @@ function ensurePathAhead(): void {
25
25
  else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
26
26
  }
27
27
 
28
+ function reportTimer(out: InstallOutcome): void {
29
+ if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every 3m)`);
30
+ else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
31
+ }
32
+
28
33
  export async function cmdInit(): Promise<number> {
29
34
  mkdirSync(paths.home, { recursive: true });
30
35
 
@@ -41,6 +46,7 @@ export async function cmdInit(): Promise<number> {
41
46
  saveConfig(cfg);
42
47
  const active = existingIdx.accounts.find((a) => a.accountUuid === existingIdx.activeAccountUuid);
43
48
  console.log(`${c.green("✓")} re-installed supervisor + hooks (pool already has ${existingIdx.accounts.length} account${existingIdx.accounts.length === 1 ? "" : "s"} - not re-importing)`);
49
+ reportTimer(out);
44
50
  if (!out.pathAhead) ensurePathAhead();
45
51
  console.log(` active: ${c.bold(active?.label ?? "unknown")} · run ${c.cyan("tokenmaxxing add")} for more, ${c.cyan("tokenmaxxing status")} to check`);
46
52
  return 0;
@@ -113,6 +119,7 @@ export async function cmdInit(): Promise<number> {
113
119
 
114
120
  console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
115
121
  console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
122
+ reportTimer(out);
116
123
  if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
117
124
  if (!out.pathAhead) {
118
125
  console.log();
package/src/cli/rename.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  // `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
2
2
 
3
+ import { withLock } from "../lib/lock.ts";
4
+ import { paths } from "../lib/paths.ts";
3
5
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
4
6
  import { c } from "./render.ts";
5
7
  import type { Account } from "../lib/types.ts";
@@ -14,20 +16,23 @@ export function findAccount(accounts: Account[], selector: string): Account | un
14
16
  );
15
17
  }
16
18
 
17
- export function cmdRename(selector?: string, newLabel?: string): number {
19
+ export async function cmdRename(selector?: string, newLabel?: string): Promise<number> {
18
20
  if (!selector || !newLabel) {
19
21
  console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
20
22
  return 2;
21
23
  }
22
- const idx = loadAccounts();
23
- const a = findAccount(idx.accounts, selector);
24
- if (!a) {
25
- console.error(c.red(`no account matches "${selector}"`));
26
- return 1;
27
- }
28
- const old = a.label;
29
- a.label = newLabel;
30
- saveAccounts(idx);
31
- console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
32
- return 0;
24
+ // under the flock: a concurrent swap's index write must not be clobbered.
25
+ return withLock(paths.lockFile, async () => {
26
+ const idx = loadAccounts();
27
+ const a = findAccount(idx.accounts, selector);
28
+ if (!a) {
29
+ console.error(c.red(`no account matches "${selector}"`));
30
+ return 1;
31
+ }
32
+ const old = a.label;
33
+ a.label = newLabel;
34
+ saveAccounts(idx);
35
+ console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
36
+ return 0;
37
+ });
33
38
  }
package/src/cli/rm.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  // `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
2
2
 
3
3
  import { deleteItem, parkedTarget } from "../lib/credstore.ts";
4
+ import { withLock } from "../lib/lock.ts";
5
+ import { paths } from "../lib/paths.ts";
4
6
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
5
7
  import { findAccount } from "./rename.ts";
6
8
  import { c } from "./render.ts";
@@ -10,19 +12,22 @@ export async function cmdRm(selector?: string): Promise<number> {
10
12
  console.error("usage: tokenmaxxing rm <email|label|uuid>");
11
13
  return 2;
12
14
  }
13
- const idx = loadAccounts();
14
- const a = findAccount(idx.accounts, selector);
15
- if (!a) {
16
- console.error(c.red(`no account matches "${selector}"`));
17
- return 1;
18
- }
19
- if (a.accountUuid === idx.activeAccountUuid) {
20
- console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
21
- return 1;
22
- }
23
- await deleteItem(parkedTarget(a.keychainItem));
24
- idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
25
- saveAccounts(idx);
26
- console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
27
- return 0;
15
+ // under the flock: a concurrent swap's index write must not be clobbered.
16
+ return withLock(paths.lockFile, async () => {
17
+ const idx = loadAccounts();
18
+ const a = findAccount(idx.accounts, selector);
19
+ if (!a) {
20
+ console.error(c.red(`no account matches "${selector}"`));
21
+ return 1;
22
+ }
23
+ if (a.accountUuid === idx.activeAccountUuid) {
24
+ console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
25
+ return 1;
26
+ }
27
+ await deleteItem(parkedTarget(a.keychainItem));
28
+ idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
29
+ saveAccounts(idx);
30
+ console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
31
+ return 0;
32
+ });
28
33
  }
package/src/cli/status.ts CHANGED
@@ -16,10 +16,8 @@ import type { FullUsage } from "../lib/usage.ts";
16
16
  import type { UsageWindow } from "../lib/types.ts";
17
17
 
18
18
  export async function cmdStatus(): Promise<number> {
19
- const idx = loadAccounts();
19
+ let idx = loadAccounts();
20
20
  const cfg = loadConfig();
21
- const live = loadUsage();
22
- const modelUsage = loadModelUsage();
23
21
  const now = Date.now();
24
22
 
25
23
  if (idx.accounts.length === 0) {
@@ -27,13 +25,17 @@ export async function cmdStatus(): Promise<number> {
27
25
  return 0;
28
26
  }
29
27
 
30
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
31
-
32
- // Sample under the flock so parked refreshes can't collide with an in-flight swap.
28
+ // Load, sample, and save entirely under the flock: parked refreshes must not
29
+ // collide with an in-flight swap, and a save of an index loaded before a
30
+ // concurrent swap would clobber the swap's activeAccountUuid.
33
31
  console.error(c.dim("sampling live usage…"));
34
32
  const outcomes = new Map<string, SampleOutcome>();
35
- await withLock(paths.lockFile, () =>
36
- Promise.all(
33
+ await withLock(paths.lockFile, async () => {
34
+ idx = loadAccounts();
35
+ const live = loadUsage();
36
+ const modelUsage = loadModelUsage();
37
+ const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
38
+ await Promise.all(
37
39
  idx.accounts.map(async (a) => {
38
40
  const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
39
41
  // Active account: prefer the free statusLine push (usage.json) so we never
@@ -57,9 +59,9 @@ export async function cmdStatus(): Promise<number> {
57
59
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
58
60
  if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
59
61
  }),
60
- ),
61
- );
62
- saveAccounts(idx);
62
+ );
63
+ saveAccounts(idx);
64
+ });
63
65
 
64
66
  console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
65
67
  console.log();
@@ -5,10 +5,15 @@
5
5
 
6
6
  import { readOAuthAccount } from "../lib/claudejson.ts";
7
7
  import { readPriorStatusLine } from "../lib/settings.ts";
8
- import { writeUsage } from "../lib/state.ts";
8
+ import { loadLastSwapAt, writeUsage } from "../lib/state.ts";
9
9
  import { parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
10
10
  import type { UsageState } from "../lib/types.ts";
11
11
 
12
+ /** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
13
+ * the OLD account's windows while ~/.claude.json already names the NEW org.
14
+ * Suppress the tee for this long after a swap so that mislabel never lands. */
15
+ const ADOPTION_GRACE_MS = 45_000;
16
+
12
17
  async function readStdin(): Promise<string> {
13
18
  const chunks: Uint8Array[] = [];
14
19
  for await (const c of Bun.stdin.stream()) chunks.push(c);
@@ -22,7 +27,8 @@ export async function runStatusline(): Promise<number> {
22
27
  try {
23
28
  const obj = JSON.parse(raw);
24
29
  const windows = parseStatusLineStdin(obj);
25
- if (windows) {
30
+ const lastSwapAt = loadLastSwapAt();
31
+ if (windows && (lastSwapAt == null || Date.now() - lastSwapAt >= ADOPTION_GRACE_MS)) {
26
32
  const org = readOAuthAccount()?.organizationUuid ?? null;
27
33
  const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
28
34
  writeUsage(state);
@@ -197,6 +197,9 @@ export async function runSupervisor(argv: string[]): Promise<number> {
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,17 +11,18 @@
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
23
  import { probeUsage } from "./usage.ts";
23
24
  import { log } from "./log.ts";
24
- import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
25
+ import { AccountSchema, type Account, type Config, type ModelInfo, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
25
26
 
26
27
  const SwapDecisionSchema = z.object({
27
28
  swapped: z.boolean(),
@@ -45,25 +46,52 @@ 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) return cached; // keep stale on poll failure
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
- function capForModel(mu: ModelUsageState | null, display: string): UsageWindow | undefined {
55
- if (!mu) return undefined;
56
- const key = Object.keys(mu.perModel).find((k) => k.toLowerCase() === display.toLowerCase());
57
- return key ? mu.perModel[key] : undefined;
61
+ /** Lowercased word tokens of a model id or display string: "claude-opus-4-8" /
62
+ * "Opus 4.8" -> ["claude","opus","4","8"] / ["opus","4","8"]. Model naming
63
+ * drifts per release ("Fable" became "Fable 5" in 2.1.206, and id grammar has
64
+ * historically flipped between family-first and version-first), so gates match
65
+ * a family token anywhere instead of an exact string - an exact-string gate
66
+ * silently disabled the per-model check in the 2026-07-09/10 incidents. */
67
+ export function familyTokens(s: string): string[] {
68
+ return s.trim().toLowerCase().split(/[\s.-]+/).filter((t) => t.length > 0);
69
+ }
70
+
71
+ /** The switchModels family the active model belongs to, from its id OR display
72
+ * tokens; null when the model is not capacity-constrained. */
73
+ export function matchedFamily(model: ModelInfo | null, families: string[]): string | null {
74
+ if (!model) return null;
75
+ const tokens = new Set([...familyTokens(model.id), ...familyTokens(model.display)]);
76
+ return families.find((f) => tokens.has(f)) ?? null;
77
+ }
78
+
79
+ /** The family's weekly cap among the `/usage` rows; when several rows match the
80
+ * family, the most-used one wins (switching early beats metering a depleted cap). */
81
+ function capForFamily(mu: ModelUsageState, family: string): UsageWindow | undefined {
82
+ const rows = Object.entries(mu.perModel)
83
+ .filter(([k]) => familyTokens(k).includes(family))
84
+ .map(([, w]) => w);
85
+ return maxBy(rows, (w) => w.usedPercentage);
58
86
  }
59
87
 
60
88
  /** True if the active account is over the floor on ANY applicable limit. */
61
89
  function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
62
90
  if (!u || !org || u.org !== org) return false;
63
91
  if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
64
- const display = u.model?.display;
65
- if (display && cfg.policy.switchModels.includes(display.toLowerCase()) && mu && mu.org === org) {
66
- const cap = capForModel(mu, display);
92
+ const family = matchedFamily(u.model, cfg.policy.switchModels);
93
+ if (family && mu && mu.org === org) {
94
+ const cap = capForFamily(mu, family);
67
95
  if (cap && cap.usedPercentage >= floor) return true;
68
96
  }
69
97
  return false;
@@ -71,8 +99,7 @@ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string |
71
99
 
72
100
  /** Does the active model warrant a per-model `/usage` poll? */
73
101
  function needsPerModel(u: UsageState | null, cfg: Config): boolean {
74
- const display = u?.model?.display;
75
- return !!display && cfg.policy.switchModels.includes(display.toLowerCase());
102
+ return matchedFamily(u?.model ?? null, cfg.policy.switchModels) !== null;
76
103
  }
77
104
 
78
105
  export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
@@ -81,7 +108,12 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
81
108
  const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
82
109
 
83
110
  let usage = loadUsage();
84
- if (!usage && activeOrg) usage = await probeAggregate(activeOrg);
111
+ if (!usage && activeOrg) {
112
+ usage = await probeAggregate(activeOrg);
113
+ // Persist: post-swap the snapshots are cleared and the statusLine tee is in
114
+ // its grace window, so without this every turn boundary would re-probe.
115
+ if (usage) writeUsage(usage);
116
+ }
85
117
 
86
118
  // per-model poll (TTL-cached) only when on a capacity-constrained model
87
119
  const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
@@ -4,6 +4,7 @@
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";
@@ -15,6 +16,7 @@ const InstallOutcomeSchema = z.object({
15
16
  installedBin: z.string(),
16
17
  priorStatusLine: z.string().nullable(),
17
18
  pathAhead: z.boolean(),
19
+ timerLoaded: z.boolean(),
18
20
  });
19
21
  export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
20
22
 
@@ -52,9 +54,134 @@ export function installSupervisor(): InstallOutcome {
52
54
  installedBin: target,
53
55
  priorStatusLine,
54
56
  pathAhead: isBinDirAhead(),
57
+ timerLoaded: installCheckTimer(),
55
58
  };
56
59
  }
57
60
 
61
+ // ---- periodic `check` timer ------------------------------------------------
62
+ // The hooks evaluate only at turn boundaries; one long agentic turn can burn a
63
+ // window from healthy to depleted with zero boundaries (2026-07-10 incident).
64
+ // A timer closes that gap: launchd on macOS, a systemd user timer on Linux.
65
+
66
+ const CHECK_INTERVAL_S = 180;
67
+ const LAUNCHD_LABEL = "com.tokenmaxxing.check";
68
+
69
+ function launchdPlist(): string {
70
+ return join(paths.launchdAgentsDir, `${LAUNCHD_LABEL}.plist`);
71
+ }
72
+
73
+ /** `gui/<uid>` launchd domain, or null when the platform has no getuid. */
74
+ function launchdDomain(): string | null {
75
+ const uid = process.getuid?.();
76
+ return uid == null ? null : `gui/${uid}`;
77
+ }
78
+
79
+ /** launchctl/systemctl may be absent (spawnSync throws ENOENT) or hang on a
80
+ * dead session bus (ssh without lingering) - degrade, never crash or block. */
81
+ function run(cmd: string[]): boolean {
82
+ try {
83
+ return Bun.spawnSync(cmd, { stdout: "ignore", stderr: "ignore", timeout: 10_000 }).exitCode === 0;
84
+ } catch {
85
+ return false;
86
+ }
87
+ }
88
+
89
+ /** Install + activate the periodic check job. False means the unit files are in
90
+ * place but activation failed (e.g. systemd user session absent over ssh) -
91
+ * the caller prints the manual activation step. */
92
+ export function installCheckTimer(): boolean {
93
+ if (process.platform === "darwin") {
94
+ const plist = launchdPlist();
95
+ writeFileAtomic(
96
+ plist,
97
+ `<?xml version="1.0" encoding="UTF-8"?>
98
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
99
+ <plist version="1.0">
100
+ <dict>
101
+ <key>Label</key><string>${LAUNCHD_LABEL}</string>
102
+ <key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string></array>
103
+ <key>StartInterval</key><integer>${CHECK_INTERVAL_S}</integer>
104
+ <key>StandardOutPath</key><string>/dev/null</string>
105
+ <key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
106
+ </dict>
107
+ </plist>
108
+ `,
109
+ 0o644,
110
+ );
111
+ const domain = launchdDomain();
112
+ if (domain == null) return false;
113
+ run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]); // reload a changed plist
114
+ // bootstrap can lose a benign race with an in-flight bootout; loaded is loaded.
115
+ return run(["launchctl", "bootstrap", domain, plist]) || checkTimerHealthy();
116
+ }
117
+
118
+ // systemd: quote the path and escape `%` (unit-file specifier character).
119
+ const exec = `"${installedBin().replaceAll("%", "%%")}" check`;
120
+ writeFileAtomic(
121
+ join(paths.systemdUserDir, "tokenmaxxing-check.service"),
122
+ `[Unit]
123
+ Description=tokenmaxxing account-switch check
124
+
125
+ [Service]
126
+ Type=oneshot
127
+ ExecStart=${exec}
128
+ `,
129
+ 0o644,
130
+ );
131
+ writeFileAtomic(
132
+ join(paths.systemdUserDir, "tokenmaxxing-check.timer"),
133
+ `[Unit]
134
+ Description=tokenmaxxing periodic account-switch check
135
+
136
+ [Timer]
137
+ OnBootSec=60
138
+ OnUnitActiveSec=${CHECK_INTERVAL_S}
139
+ AccuracySec=30
140
+
141
+ [Install]
142
+ WantedBy=timers.target
143
+ `,
144
+ 0o644,
145
+ );
146
+ return (
147
+ run(["systemctl", "--user", "daemon-reload"]) &&
148
+ run(["systemctl", "--user", "enable", "--now", "tokenmaxxing-check.timer"])
149
+ );
150
+ }
151
+
152
+ /** The manual activation command for an unloaded timer, per platform. */
153
+ export function timerActivationHint(): string {
154
+ if (process.platform === "darwin") {
155
+ return `launchctl bootstrap gui/$(id -u) ${launchdPlist()}`;
156
+ }
157
+ return "systemctl --user daemon-reload && systemctl --user enable --now tokenmaxxing-check.timer";
158
+ }
159
+
160
+ /** True when the timer unit exists AND the service manager reports it loaded. */
161
+ export function checkTimerHealthy(): boolean {
162
+ if (process.platform === "darwin") {
163
+ const domain = launchdDomain();
164
+ return existsSync(launchdPlist()) && domain != null && run(["launchctl", "print", `${domain}/${LAUNCHD_LABEL}`]);
165
+ }
166
+ return (
167
+ existsSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer")) &&
168
+ run(["systemctl", "--user", "is-active", "--quiet", "tokenmaxxing-check.timer"])
169
+ );
170
+ }
171
+
172
+ export function uninstallCheckTimer(): void {
173
+ if (process.platform === "darwin") {
174
+ const domain = launchdDomain();
175
+ if (domain != null) run(["launchctl", "bootout", `${domain}/${LAUNCHD_LABEL}`]);
176
+ rmSync(launchdPlist(), { force: true });
177
+ return;
178
+ }
179
+ run(["systemctl", "--user", "disable", "--now", "tokenmaxxing-check.timer"]);
180
+ rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.timer"), { force: true });
181
+ rmSync(join(paths.systemdUserDir, "tokenmaxxing-check.service"), { force: true });
182
+ run(["systemctl", "--user", "daemon-reload"]);
183
+ }
184
+
58
185
  /** The rc file of the user's login shell, or null when the shell is unknown.
59
186
  * Overridable for hermetic tests. */
60
187
  export function shellRcPath(): string | null {
@@ -81,6 +208,7 @@ export function ensurePathInRc(rc: string): "added" | "present" {
81
208
 
82
209
  export function uninstallSupervisor(): void {
83
210
  uninstallSettings();
211
+ uninstallCheckTimer();
84
212
  for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
85
213
  if (existsSync(f)) rmSync(f, { force: true });
86
214
  }
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/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({
package/src/lib/usage.ts CHANGED
@@ -86,12 +86,16 @@ function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min
86
86
 
87
87
  /**
88
88
  * Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
89
- * `Jul 9 at 11:20pm (Asia/Seoul)` to epoch ms. The text carries no year, so we
90
- * pick the year whose resulting instant is nearest `now` (resets are always days
91
- * away, so the correct year wins by ~360 days). Returns null if unparseable.
89
+ * `Jul 10, 3:30pm (Asia/Seoul)` to epoch ms. The day-time glue is not stable
90
+ * across claude installs (observed on 2.1.206: ` at ` on macOS, `, ` on Linux);
91
+ * exactly those two glues are accepted, same-line only, so a third drift shows
92
+ * up as an unparsed clock (and the usage.reset_clock_unparsed log) instead of a
93
+ * guessed instant. The text carries no year, so we pick the year whose resulting
94
+ * instant is nearest `now` (resets are always days away, so the correct year
95
+ * wins by ~360 days). Returns null if unparseable.
92
96
  */
93
97
  export function parseResetClock(clock: string, now = Date.now()): number | null {
94
- const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
98
+ const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
95
99
  if (!m) return null;
96
100
  const mon = MONTHS[m[1]!.slice(0, 3).toLowerCase()];
97
101
  if (mon === undefined) return null;
@@ -123,11 +127,15 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
123
127
  */
124
128
  export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
125
129
  if (!text) return null;
126
- // The reset clock is required in full (month day at h[:mm]am/pm (tz)) inside its
127
- // optional group, so the lazy bridge is forced to find it when present yet the
128
- // group cleanly skips a line that has no clock - and it never swallows a
129
- // following "Current" entry on a single line.
130
- const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:[^\n]*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}\s+at\s+\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
130
+ // The reset clock is required in full (month day[, | at ]h[:mm]am/pm (tz))
131
+ // inside its optional group, so the lazy bridge is forced to find it when
132
+ // present yet the group cleanly skips a line that has no clock. The bridge
133
+ // refuses to cross another "Current" so a dateless clock ("resets Jul 8.")
134
+ // can never steal the NEXT entry's clock or swallow that entry. The day-time
135
+ // glue is not stable across installs (observed on 2.1.206: ` at ` on macOS,
136
+ // `, ` on Linux); exactly those two are accepted, same-line only, so a third
137
+ // drift surfaces in the usage.reset_clock_unparsed log instead of misparsing.
138
+ const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:(?:(?!current)[^\n])*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
131
139
  let session: UsageWindow | null = null;
132
140
  let weekAll: UsageWindow | null = null;
133
141
  const perModel: Record<string, UsageWindow> = {};
@@ -190,18 +198,34 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
190
198
  }
191
199
 
192
200
  const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
193
- const full = parseUsageTextFull(j.success ? j.data.result : out, now);
194
- if (!full) log("usage.probe_unparsed", { sample: out.trim().slice(0, 120) });
201
+ const text = j.success ? j.data.result : out;
202
+ const full = parseUsageTextFull(text, now);
203
+ if (!full) {
204
+ log("usage.probe_unparsed", { sample: text.trim().slice(0, 200) });
205
+ } else {
206
+ const clockLine = text.split("\n").find((l) => /^current /i.test(l) && /\bresets\b/i.test(l));
207
+ if (clockLine && [full.session, full.weekAll, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
208
+ // Percentages parsed but every reset clock was dropped: the clock format
209
+ // drifted again. Log the line so the next drift is visible in the log.
210
+ log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
211
+ }
212
+ }
195
213
  return full;
196
214
  }
197
215
 
216
+ /** Escalating retry delays for the empty-footer case (usage endpoint throttled
217
+ * or the sampled token busy). Capped at ~7s of sleep: probeUsage runs inside
218
+ * Stop/SessionStart hooks and under the status flock, and the periodic `check`
219
+ * timer re-runs every 3 minutes anyway, owning the long-tail retry. */
220
+ const PROBE_RETRY_DELAYS_MS = [2000, 5000];
221
+
198
222
  /**
199
223
  * Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
200
224
  * Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
201
225
  * sample the live account. All ambient credential overrides are scrubbed so the
202
226
  * probe meters exactly the OAuth credential in the (possibly namespaced)
203
227
  * keychain item. The empty-footer case (claude's own usage call throttled) is
204
- * transient, so retry it a couple of times. Returns null if it never yields data.
228
+ * transient, so retry with backoff. Returns null if it never yields data.
205
229
  */
206
230
  export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
207
231
  const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1" };
@@ -210,7 +234,11 @@ export async function probeUsage(configDir?: string, now = Date.now()): Promise<
210
234
 
211
235
  for (let attempt = 0; ; attempt++) {
212
236
  const full = await probeUsageOnce(env, now);
213
- if (full || attempt >= 2) return full;
214
- await delay(1500);
237
+ if (full) return full;
238
+ if (attempt >= PROBE_RETRY_DELAYS_MS.length) {
239
+ log("usage.probe_gave_up", { attempts: attempt + 1 });
240
+ return null;
241
+ }
242
+ await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
215
243
  }
216
244
  }
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();