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.
@@ -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}…\x1b[0m\n`);
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…\x1b[0m\n`);
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) 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
+ /** 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 display = u.model?.display;
65
- if (display && cfg.policy.switchModels.includes(display.toLowerCase()) && mu && mu.org === org) {
66
- const cap = capForModel(mu, display);
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
- const display = u?.model?.display;
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) usage = await probeAggregate(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;
@@ -1,9 +1,10 @@
1
1
  // Install/uninstall the on-PATH `claude` supervisor wrapper + settings entries.
2
- // The wrapper is a 2-line `exec __supervise "$@"` shim so dispatch never
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
- const { priorStatusLine } = installSettings();
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
  }
@@ -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 -w <secret>` line into `security -i`
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)}(${m.length})`);
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
@@ -1,6 +1,7 @@
1
1
  // Idempotent merge of tokenmaxxing's three entries into the user-owned
2
- // ~/.claude/settings.json: a statusLine shim, a Stop hook, a SessionStart hook.
3
- // We APPEND to existing hook arrays and WRAP the existing statusLine - never clobber.
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
- const InstallResultSchema = z.object({ priorStatusLine: z.string().nullable() });
75
- export type InstallResult = z.infer<typeof InstallResultSchema>;
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 and restore the prior statusLine if we have it. */
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 → weekAll (7d aggregate)
126
- * Current week (<Model>): N% used · resets → perModel[<Model>]
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 it a couple of times. Returns null if it never yields data.
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 || attempt >= 2) return full;
232
- await delay(1500);
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();