tokenmaxxing 0.4.0 → 0.6.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
@@ -4,7 +4,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
4
4
 
5
5
  > Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
6
6
  >
7
- > Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun → single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-…`, JSON body). What the acceptance gate actually shows is in §9.
7
+ > Status: **implemented** (v0.1.0, 2026-07-09). TypeScript on Bun → single binary; Zod validates every external-boundary payload, JSON config, es-toolkit for utilities, `flock(2)` via `bun:ffi`. All load-bearing external facts were adversarially verified against the `2.1.204` binary + docs (OAuth token endpoint is `platform.claude.com/v1/oauth/token`, client_id `9d1c250a-...`, JSON body). What the acceptance gate actually shows is in §9.
8
8
 
9
9
  ---
10
10
 
@@ -57,7 +57,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
57
57
  ### 3.4 Swap sequence (under the lock)
58
58
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
59
59
  2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
60
- 3. **Install B** - `security add-generic-password -U 'Claude Code-credentials' …` with B's fresh (non-expired) `claudeAiOauth` JSON.
60
+ 3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
61
61
  4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
62
62
  5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
63
63
 
@@ -78,7 +78,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
78
78
  ## 5. Rotation policy
79
79
  Trigger at `five_hour >= 95%` OR `seven_day >= 95%`, per org. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`95 − EMA(per-turn Δ%)`) so a single large turn can't blow past 100% before the next Stop hook.
80
80
 
81
- **Model-aware trigger.** Claude subscriptions also enforce a **per-model weekly cap** - the capable models (Fable, Opus) have a tighter weekly limit that binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate.
81
+ **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate.
82
82
 
83
83
  ---
84
84
 
@@ -114,7 +114,7 @@ TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/u
114
114
 
115
115
  Unit suite: **32 pass**. Hermetic swap+concurrency+model-aware E2E: **all pass**. CLI init/doctor/uninstall: **pass** (re-verified init/doctor 2026-07-09 through the npm-installed bun shim on linux-arm64 after the switch to source packaging; full suite 47 pass / 0 fail there).
116
116
 
117
- 1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p …` committed a clean 12-line transcript; `claude --resume X -p …` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
117
+ 1. **Transcript continuity across a process boundary - ✅ proven on real claude/real account.** `claude --session-id X -p ...` committed a clean 12-line transcript; `claude --resume X -p ...` recalled the earlier turn's codeword. The supervisor's kill→restore-termios→respawn→`--resume` loop is proven with a mock claude. The one step not run live is the abrupt SIGTERM of an *idle interactive* real claude (structurally safe - the transcript is fully committed+fsynced before idle and nothing writes while idle).
118
118
  2. **Terminal restoration - ✅ mechanism proven.** The supervisor saves `stty -g` and restores it between kill and respawn; the path executes in the mock-supervisor run. Visual raw-mode/​resize confirmation wants a live interactive terminal.
119
119
  3. **SessionStart swap - ✅ swap logic proven; ⚠️ "adopted by first turn" needs a 2nd real account.** The hook's decision+swap path is covered by the E2E; the claude-internal timing (SessionStart before first `getToken`) needs a second subscription to observe end-to-end.
120
120
  4. **Keychain writes from a headless hook - ✅ proven.** The swap E2E's 4 concurrent subprocesses each wrote the live item via `security -i` (secret on stdin, never argv) with no prompt/hang; `init` parked backups headlessly too. (Residual: the live `Claude Code-credentials` ACL for the *fresh resumed claude* is mitigated by onboarding touching `security` interactively.)
package/README.md CHANGED
@@ -6,9 +6,9 @@
6
6
 
7
7
  ```
8
8
  $ claude
9
- you work normally
10
- ↻ tokenmaxxing: switched to work@acme.com - resuming
11
- same conversation, fresh quota
9
+ ...you work normally...
10
+ ↻ tokenmaxxing: switched to work@acme.com - resuming...
11
+ ...same conversation, fresh quota...
12
12
  ```
13
13
 
14
14
  ## Why
@@ -24,7 +24,7 @@ bun add -g tokenmaxxing
24
24
  tokenmaxxing init
25
25
  ```
26
26
 
27
- `init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (a statusLine shim, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
27
+ `init` imports the account you're already on, installs the `claude` supervisor + three `settings.json` entries (the tokenmaxxing statusLine, a Stop hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
28
28
 
29
29
  ```sh
30
30
  tokenmaxxing add # logs one in, in isolation, and pools it
@@ -47,11 +47,13 @@ claude # use claude as always
47
47
 
48
48
  Switching triggers at **95%** (configurable) on any of:
49
49
 
50
- - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by a statusLine shim.
51
- - **Per-model weekly cap** - the capable models (Fable, Opus) have their own tighter weekly limit that binds *before* the aggregate. tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
50
+ - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
51
+ - **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
52
52
 
53
53
  The 5% headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall.
54
54
 
55
+ The **target** is chosen greedily off each account's cached windows: the usable account (session and week under threshold, or past their reset) whose weekly window **expires soonest** - unused weekly allowance is forfeited at the fixed per-account reset. Cached resets are absolute UTC epochs, so a stale snapshot still resolves correctly: a weekly reset that has passed extrapolates forward in 7-day steps, and a session window past its reset counts as empty. `tokenmaxxing switch` ranks the current account too and does nothing when it already wins, so it is idempotent - running it periodically converges on the right account.
56
+
55
57
  ## Configuration
56
58
 
57
59
  `~/.config/tokenmaxxing/config.json` (every field optional):
@@ -61,7 +63,7 @@ The 5% headroom is deliberate: it's the budget to reach a clean turn boundary an
61
63
  "threshold": 95,
62
64
  "policy": {
63
65
  "projectionMargin": 0,
64
- "switchModels": ["fable", "opus"],
66
+ "switchModels": ["fable"],
65
67
  "usagePollTtlMs": 90000
66
68
  }
67
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.4.0",
3
+ "version": "0.6.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
@@ -94,7 +94,7 @@ export async function cmdAdd(): Promise<number> {
94
94
  }
95
95
 
96
96
  // Sample usage now (#16) so the account isn't "not sampled yet" in status/ls.
97
- console.log(c.dim("sampling usage"));
97
+ console.log(c.dim("sampling usage..."));
98
98
  const sampled = await probeUsage(onboardDir);
99
99
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
100
100
 
@@ -118,6 +118,7 @@ export async function cmdAdd(): Promise<number> {
118
118
  needsReauth: false,
119
119
  lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
120
120
  lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
121
+ lastUsageAt: sampled ? Date.now() : existing?.lastUsageAt,
121
122
  };
122
123
  if (existing) Object.assign(existing, fresh);
123
124
  else idx.accounts.push(fresh);
package/src/cli/init.ts CHANGED
@@ -120,7 +120,6 @@ export async function cmdInit(): Promise<number> {
120
120
  console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
121
121
  console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
122
122
  reportTimer(out);
123
- if (out.priorStatusLine) console.log(`${c.green("✓")} wrapped your existing statusLine (preserved)`);
124
123
  if (!out.pathAhead) {
125
124
  console.log();
126
125
  ensurePathAhead();
package/src/cli/render.ts CHANGED
@@ -2,16 +2,21 @@
2
2
 
3
3
  import { clamp } from "es-toolkit";
4
4
 
5
- const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
5
+ /** ANSI painters, no-ops when disabled. The statusLine renders through a pipe
6
+ * (never a TTY), so it needs its own enable gate; the CLI gates on stdout. */
7
+ export function makeColors(enabled: boolean) {
8
+ const paint = (code: string) => (s: string) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : s);
9
+ return {
10
+ dim: paint("2"),
11
+ bold: paint("1"),
12
+ green: paint("32"),
13
+ yellow: paint("33"),
14
+ red: paint("31"),
15
+ cyan: paint("36"),
16
+ };
17
+ }
6
18
 
7
- export const c = {
8
- dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
9
- bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
10
- green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
11
- yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
12
- red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
13
- cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
14
- };
19
+ export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
15
20
 
16
21
  /** A fixed-width usage bar, colored by fill. */
17
22
  export function bar(pct: number, width = 16): string {
@@ -34,3 +39,33 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
34
39
  if (h > 0) return `resets in ${h}h${m}m`;
35
40
  return `resets in ${m}m`;
36
41
  }
42
+
43
+ /** Age of a cached sample, largest unit only: "just now", "3m ago", "2h ago",
44
+ * "5d ago". */
45
+ export function fmtAgo(epochMs: number, now = Date.now()): string {
46
+ const dsec = Math.max(0, Math.round((now - epochMs) / 1000));
47
+ if (dsec < 60) return "just now";
48
+ const d = Math.floor(dsec / 86400);
49
+ const h = Math.floor((dsec % 86400) / 3600);
50
+ const m = Math.floor((dsec % 3600) / 60);
51
+ if (d > 0) return `${d}d ago`;
52
+ if (h > 0) return `${h}h ago`;
53
+ return `${m}m ago`;
54
+ }
55
+
56
+ /** Compact time-until-reset for the statusLine: the largest unit only ("6d",
57
+ * "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
58
+ * once the reset has passed (the window is simply empty again). Non-empty
59
+ * output always ends in a unit letter, so the digit-leading used-percent glued
60
+ * after it stays parseable. */
61
+ export function fmtResetShort(epochMs: number | null | undefined, now = Date.now()): string {
62
+ if (epochMs == null) return "";
63
+ const dsec = Math.round((epochMs - now) / 1000);
64
+ if (dsec <= 0) return "";
65
+ const d = Math.floor(dsec / 86400);
66
+ const h = Math.floor((dsec % 86400) / 3600);
67
+ const m = Math.floor((dsec % 3600) / 60);
68
+ if (d > 0) return `${d}d`;
69
+ if (h > 0) return `${h}h`;
70
+ return `${Math.max(m, 1)}m`;
71
+ }
package/src/cli/status.ts CHANGED
@@ -10,8 +10,8 @@ import { readOAuthAccount } from "../lib/claudejson.ts";
10
10
  import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
11
11
  import { withLock } from "../lib/lock.ts";
12
12
  import { paths } from "../lib/paths.ts";
13
- import { isExhausted } from "../lib/picker.ts";
14
- import { bar, c, fmtReset } from "./render.ts";
13
+ import { isExhausted, nextWeeklyReset } from "../lib/picker.ts";
14
+ import { bar, c, fmtAgo, fmtReset } from "./render.ts";
15
15
  import type { FullUsage } from "../lib/usage.ts";
16
16
  import type { UsageWindow } from "../lib/types.ts";
17
17
 
@@ -28,7 +28,7 @@ export async function cmdStatus(): Promise<number> {
28
28
  // Load, sample, and save entirely under the flock: parked refreshes must not
29
29
  // collide with an in-flight swap, and a save of an index loaded before a
30
30
  // concurrent swap would clobber the swap's activeAccountUuid.
31
- console.error(c.dim("sampling live usage"));
31
+ console.error(c.dim("sampling live usage..."));
32
32
  const outcomes = new Map<string, SampleOutcome>();
33
33
  await withLock(paths.lockFile, async () => {
34
34
  idx = loadAccounts();
@@ -58,6 +58,9 @@ export async function cmdStatus(): Promise<number> {
58
58
  if (!outcome.ok) return;
59
59
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
60
60
  if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
61
+ // stamp when the figures were actually measured: the statusLine tee's
62
+ // own write time for the push-fed active account, else the probe time.
63
+ a.lastUsageAt = fromStatusLine && live ? live.ts : Date.now();
61
64
  }),
62
65
  );
63
66
  saveAccounts(idx);
@@ -66,8 +69,15 @@ export async function cmdStatus(): Promise<number> {
66
69
  console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
67
70
  console.log();
68
71
 
69
- const row = (name: string, w: UsageWindow) =>
70
- console.log(` ${name.padEnd(5)} ${bar(w.usedPercentage)} ${c.dim(fmtReset(w.resetsAt, now))}`);
72
+ // A window whose cached reset has passed is empty again; weekly windows recur
73
+ // on a fixed per-account anchor, so a stale weekly reset extrapolates forward.
74
+ // Fresh samples pass through unchanged (their resets are in the future).
75
+ const row = (name: string, w: UsageWindow, weekly: boolean) => {
76
+ const passed = w.resetsAt != null && w.resetsAt <= now;
77
+ const pct = passed ? 0 : w.usedPercentage;
78
+ const resetsAt = weekly ? nextWeeklyReset(w.resetsAt, now) : passed ? null : w.resetsAt;
79
+ console.log(` ${name.padEnd(5)} ${bar(pct)} ${c.dim(fmtReset(resetsAt, now))}`);
80
+ };
71
81
 
72
82
  for (const a of idx.accounts) {
73
83
  const active = a.accountUuid === idx.activeAccountUuid;
@@ -87,13 +97,13 @@ export async function cmdStatus(): Promise<number> {
87
97
 
88
98
  console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
89
99
  if (aggregate) {
90
- row("5h", aggregate.fiveHour);
91
- row("week", aggregate.sevenDay);
100
+ row("5h", aggregate.fiveHour, false);
101
+ row("week", aggregate.sevenDay, true);
92
102
  }
93
- if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w);
103
+ if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w, true);
94
104
  if (failed && outcome && !outcome.ok) {
95
- const note = aggregate || perModel ? "cached · live sample failed" : "live sample failed";
96
- console.log(` ${c.yellow(note)}: ${c.dim(outcome.reason)}`);
105
+ const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""} · ` : "";
106
+ console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
97
107
  }
98
108
  console.log();
99
109
  }
package/src/cli/switch.ts CHANGED
@@ -1,16 +1,28 @@
1
- // `tokenmaxxing switch [selector]` (also the bare `tokenmaxxing` / `xx`).
2
- // No selector → auto-pick the best available account. With a selector switch to
3
- // that one. Manual/recovery tool: no threshold gate, runs under the flock. When
4
- // everything is depleted it still switches to the soonest-resetting account.
1
+ // `tokenmaxxing switch [selector]`.
2
+ // No selector → greedy: rank EVERY account (current included) by soonest weekly
3
+ // expiry among those with session/week under threshold, off the cached windows.
4
+ // When the current account already wins (or ties - swapping between equals buys
5
+ // nothing), do nothing: the command is idempotent, so running it periodically
6
+ // converges on the right account. With a selector → switch to that one. Runs
7
+ // under the flock. When everything is depleted it stays on / switches to
8
+ // whichever account recovers soonest.
9
+ //
10
+ // The active LABEL can drift from the live login (manual /login is the
11
+ // surviving drift source). A no-op would freeze that drift forever, so when
12
+ // ~/.claude.json names a different account than accounts.json we swap for real
13
+ // instead of trusting the label - performSwap resolves the live credential's
14
+ // true owner, harvesting the drifted login correctly.
5
15
 
6
16
  import { withLock } from "../lib/lock.ts";
7
17
  import { paths } from "../lib/paths.ts";
8
18
  import { loadAccounts, loadConfig } from "../lib/state.ts";
19
+ import { readOAuthAccount } from "../lib/claudejson.ts";
9
20
  import { performSwap, chooseAndSwap } from "../lib/swap.ts";
10
- import { pickEarliestReset } from "../lib/picker.ts";
21
+ import { isExhausted, pickBest, pickEarliestReset, swapPreference, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
11
22
  import { InvalidGrantError } from "../lib/oauth.ts";
12
23
  import { findAccount } from "./rename.ts";
13
24
  import { c, fmtReset } from "./render.ts";
25
+ import type { Account } from "../lib/types.ts";
14
26
 
15
27
  export async function cmdSwitch(selector?: string): Promise<number> {
16
28
  const idx0 = loadAccounts();
@@ -23,12 +35,10 @@ export async function cmdSwitch(selector?: string): Promise<number> {
23
35
 
24
36
  return withLock(paths.lockFile, async () => {
25
37
  const idx = loadAccounts();
38
+ const claimed = readOAuthAccount()?.accountUuid ?? null;
39
+ const drifted = claimed != null && claimed !== idx.activeAccountUuid;
26
40
 
27
- if (selector) {
28
- const target = findAccount(idx.accounts, selector);
29
- if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
30
- if (target.accountUuid === idx.activeAccountUuid) { console.log(`already on ${c.bold(target.label)}`); return 0; }
31
- if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing add\``)); return 1; }
41
+ const swapTo = async (target: Account): Promise<number> => {
32
42
  try {
33
43
  await performSwap(target);
34
44
  } catch (e) {
@@ -37,25 +47,59 @@ export async function cmdSwitch(selector?: string): Promise<number> {
37
47
  }
38
48
  console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
39
49
  return 0;
50
+ };
51
+
52
+ if (selector) {
53
+ const target = findAccount(idx.accounts, selector);
54
+ if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
55
+ if (target.accountUuid === idx.activeAccountUuid && !drifted) { console.log(`already on ${c.bold(target.label)}`); return 0; }
56
+ if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing add\``)); return 1; }
57
+ return swapTo(target);
40
58
  }
41
59
 
42
- // auto: best account under threshold
43
- const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
44
- if (landed) {
45
- console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
60
+ // auto: greedy over everyone, current included - a no-op when current wins.
61
+ const everyone: PickCtx = { now, threshold: cfg.threshold, currentAccountUuid: null };
62
+ const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
63
+ const best = pickBest(idx.accounts, everyone);
64
+ const currentWins =
65
+ best != null && active != null && !active.needsReauth && !isExhausted(active, everyone) &&
66
+ (best.accountUuid === active.accountUuid || swapPreference(now).every((k) => k(active) === k(best)));
67
+ if (currentWins) {
68
+ if (drifted) return swapTo(active);
69
+ const expiry = weeklyExpiry(active, now);
70
+ const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
71
+ console.log(`already on the best account: ${c.bold(active.label)}${why}`);
46
72
  return 0;
47
73
  }
74
+ if (best) {
75
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
76
+ if (landed) {
77
+ console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
78
+ return 0;
79
+ }
80
+ }
48
81
 
49
- // everything is depleted switch to whichever recovers soonest
50
- const earliest = pickEarliestReset(idx.accounts, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid });
82
+ // No usable target swapped in: everything is depleted, or the remaining
83
+ // candidates' refresh tokens just died (chooseAndSwap marks needs-reauth,
84
+ // hence the reload). Stay on / switch to whichever recovers soonest.
85
+ const fresh = loadAccounts();
86
+ const earliest = pickEarliestReset(fresh.accounts, everyone);
51
87
  if (!earliest) { console.error(c.yellow("no switchable account (all need re-auth?)")); return 1; }
52
- try {
53
- await performSwap(earliest.account);
54
- } catch (e) {
55
- if (e instanceof InvalidGrantError) { console.error(c.red(`${earliest.account.label}'s refresh token is dead`)); return 1; }
56
- throw e;
88
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
89
+ const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
90
+ if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
91
+ // availableAt in the past means the current account is fine and the
92
+ // others are simply unusable - do not claim a limit that is not there.
93
+ const msg = earliest.availableAt <= now
94
+ ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
95
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
96
+ console.log(c.yellow(msg));
97
+ return 0;
98
+ }
99
+ const code = await swapTo(earliest.account);
100
+ if (code === 0 && earliest.availableAt > now) {
101
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(earliest.availableAt, now)})${reauthNote}`));
57
102
  }
58
- console.log(`${c.yellow("↻")} all accounts at limit - switched to ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})`);
59
- return 0;
103
+ return code;
60
104
  });
61
105
  }
@@ -1,58 +1,186 @@
1
- // statusLine shim. Reads Claude's statusLine stdin, tees the rate-limit data to
2
- // usage.json (write-on-change, O(ms)), then transparently delegates to the user's
3
- // prior statusLine command (same stdin) and passes its stdout through unchanged.
4
- // Must NEVER break the user's status line: every step is best-effort.
1
+ // Native statusLine. Reads Claude's statusLine stdin, tees the rate-limit data
2
+ // to usage.json (write-on-change, O(ms)), then renders ONE line:
3
+ // worktree name (linked worktrees only), model (effort), ctx used,
4
+ // +added/-removed, then quota as USED percent (bold, severity-colored)
5
+ // glued after its time-to-reset: "◆ F26 2h5 1d38 ◇ F67 2d40 ◇ 2 full"
6
+ // ("2h5" = resets in 2h, 5 used) - the active account's windows after a
7
+ // green ◆ (per-model by initial first, "F?" when the cap applies but is
8
+ // unmeasured, then session/5h, then week), then each parked account's week
9
+ // after a cyan ◇, in swap-preference order so the first usable ◇ is where
10
+ // the next swap lands. Adjacent untouched (or unsampled) parked accounts
11
+ // collapse into one counted token. Blocks are joined by TWO spaces, tokens
12
+ // within a block by one. Per-model resets are omitted (they match the
13
+ // weekly reset).
14
+ // Must NEVER break the status line: render what parses, skip what doesn't.
5
15
 
16
+ import { sortBy } from "es-toolkit";
17
+ import { z } from "zod";
6
18
  import { readOAuthAccount } from "../lib/claudejson.ts";
7
- import { readPriorStatusLine } from "../lib/settings.ts";
8
- import { loadLastSwapAt, writeUsage } from "../lib/state.ts";
9
- import { parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
10
- import type { UsageState } from "../lib/types.ts";
19
+ import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
20
+ import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
21
+ import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
22
+ import { worktreeName } from "../lib/worktree.ts";
23
+ import { fmtResetShort, makeColors } from "../cli/render.ts";
24
+ import {
25
+ AccountsIndexSchema,
26
+ StatusLineStdinSchema,
27
+ UsageWindowSchema,
28
+ type Account,
29
+ type UsageState,
30
+ type UsageWindow,
31
+ } from "../lib/types.ts";
11
32
 
12
33
  /** A session that hasn't adopted a fresh swap yet (<=30s keychain cache) pushes
13
34
  * the OLD account's windows while ~/.claude.json already names the NEW org.
14
35
  * Suppress the tee for this long after a swap so that mislabel never lands. */
15
36
  const ADOPTION_GRACE_MS = 45_000;
16
37
 
38
+ const RenderCtxSchema = z.object({
39
+ accounts: AccountsIndexSchema,
40
+ /** active account's per-model weekly windows ({} unless fresh for the live org). */
41
+ perModel: z.record(z.string(), UsageWindowSchema),
42
+ /** families (lowercased) whose per-model weekly cap gates a switch. */
43
+ switchModels: z.array(z.string()),
44
+ threshold: z.number(),
45
+ /** linked-worktree basename, null in a main checkout. */
46
+ worktree: z.string().nullable(),
47
+ now: z.number(),
48
+ color: z.boolean(),
49
+ });
50
+ export type RenderCtx = z.infer<typeof RenderCtxSchema>;
51
+
17
52
  async function readStdin(): Promise<string> {
18
53
  const chunks: Uint8Array[] = [];
19
54
  for await (const c of Bun.stdin.stream()) chunks.push(c);
20
55
  return Buffer.concat(chunks).toString("utf8");
21
56
  }
22
57
 
58
+ /** Pure renderer: statusLine stdin + tokenmaxxing state → the emitted line. */
59
+ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
60
+ const col = makeColors(ctx.color);
61
+ const severity = (u: number) => (u >= 95 ? col.red : u >= 75 ? col.yellow : col.green);
62
+ const gauge = (u: number) => col.bold(severity(u)(`${Math.round(u)}`));
63
+ // Used quota in a window; one whose reset has passed is empty again.
64
+ const used = (w: UsageWindow) => (w.resetsAt != null && w.resetsAt <= ctx.now ? 0 : w.usedPercentage);
65
+ const reset = (epochMs: number | null) => fmtResetShort(epochMs, ctx.now);
66
+
67
+ const parsed = StatusLineStdinSchema.safeParse(stdinObj);
68
+ const d = parsed.success ? parsed.data : null;
69
+
70
+ // ---- info block: worktree, model, ctx, diff
71
+ const info: string[] = [];
72
+ if (ctx.worktree != null) info.push(ctx.worktree);
73
+ const modelName = d?.model?.display_name ?? d?.model?.id;
74
+ if (modelName) {
75
+ const effort = d?.effort?.level;
76
+ info.push(col.bold(modelName) + (effort ? ` (${effort})` : ""));
77
+ }
78
+ const ctxUsed = d?.context_window?.used_percentage;
79
+ if (ctxUsed != null) info.push(`ctx ${gauge(ctxUsed)}`);
80
+ const added = d?.cost?.total_lines_added ?? 0;
81
+ const removed = d?.cost?.total_lines_removed ?? 0;
82
+ // -removed stays unpainted: red means quota alarm and nothing else.
83
+ if (added > 0 || removed > 0) info.push(`${col.green(`+${added}`)}/-${removed}`);
84
+
85
+ // ---- active account block
86
+ const seg = (label: string, w: UsageWindow, resetAt: number | null) =>
87
+ `${label}${reset(resetAt)}${gauge(used(w))}`;
88
+ const wins = parseStatusLineStdin(stdinObj);
89
+ const windows: string[] = [];
90
+ // A capacity-constrained model whose cap is unmeasured must not look safe.
91
+ const family = matchedFamily(parseStatusLineModel(stdinObj), ctx.switchModels);
92
+ if (family && !Object.keys(ctx.perModel).some((k) => familyTokens(k).includes(family))) {
93
+ windows.push(`${family[0]!.toUpperCase()}?`);
94
+ }
95
+ for (const [name, w] of Object.entries(ctx.perModel)) windows.push(seg(name.slice(0, 1), w, null));
96
+ if (wins) {
97
+ windows.push(seg("", wins.fiveHour, wins.fiveHour.resetsAt));
98
+ windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
99
+ }
100
+ const active =
101
+ windows.length > 0
102
+ ? `${col.green("◆")} ${windows.join(" ")}`
103
+ : ctx.accounts.activeAccountUuid != null
104
+ ? `${col.green("◆")} ?`
105
+ : "";
106
+
107
+ // ---- parked accounts, in swap order: the first usable ◇ is the next target
108
+ const pickCtx = { now: ctx.now, threshold: ctx.threshold, currentAccountUuid: ctx.accounts.activeAccountUuid };
109
+ const parked = sortBy(
110
+ ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
111
+ [(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
112
+ );
113
+ const poolSeg = (a: Account): { kind: "full" | "unknown" | "other"; text: string } => {
114
+ const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
115
+ const mergeable = !a.needsReauth;
116
+
117
+ const week = a.lastUsage?.sevenDay;
118
+ if (week == null) return { kind: mergeable ? "unknown" : "other", text: `${marker} ?` };
119
+ const weekUsed = used(week);
120
+ if (Math.round(weekUsed) <= 0) return { kind: mergeable ? "full" : "other", text: `${marker} ${col.green("full")}` };
121
+
122
+ const parts: string[] = [];
123
+ // A per-model weekly cap with more used than the aggregate is the binding constraint - surface it.
124
+ for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
125
+ if (used(w) > weekUsed) parts.push(seg(name.slice(0, 1), w, null));
126
+ }
127
+ const expiry = weeklyExpiry(a, ctx.now);
128
+ parts.push(seg("", week, Number.isFinite(expiry) ? expiry : null));
129
+ return { kind: "other", text: `${marker} ${parts.join(" ")}` };
130
+ };
131
+
132
+ // Adjacent identical bare tokens collapse into one counted token ("◇ 3 full").
133
+ const pool: string[] = [];
134
+ const segs = parked.map(poolSeg);
135
+ for (let i = 0; i < segs.length; ) {
136
+ const s = segs[i]!;
137
+ let j = i + 1;
138
+ while (s.kind !== "other" && j < segs.length && segs[j]!.kind === s.kind) j++;
139
+ if (j - i >= 2) pool.push(`${col.cyan("◇")} ${j - i} ${s.kind === "full" ? col.green("full") : "?"}`);
140
+ else pool.push(s.text);
141
+ i = j;
142
+ }
143
+
144
+ return [info.join(" "), active, ...pool].filter((l) => l !== "").join(" ");
145
+ }
146
+
23
147
  export async function runStatusline(): Promise<number> {
24
148
  const raw = await readStdin();
149
+ let obj: unknown = null;
150
+ try {
151
+ obj = JSON.parse(raw);
152
+ } catch {
153
+ // malformed stdin - render from state alone
154
+ }
155
+ const now = Date.now();
25
156
 
26
- // 1) tee usage - best effort, never throws out.
157
+ // tee usage for the Stop hook / status - best effort, never blocks rendering.
158
+ let org: string | null = null;
27
159
  try {
28
- const obj = JSON.parse(raw);
29
- const windows = parseStatusLineStdin(obj);
160
+ org = readOAuthAccount()?.organizationUuid ?? null;
161
+ const windows = obj == null ? null : parseStatusLineStdin(obj);
30
162
  const lastSwapAt = loadLastSwapAt();
31
- if (windows && (lastSwapAt == null || Date.now() - lastSwapAt >= ADOPTION_GRACE_MS)) {
32
- const org = readOAuthAccount()?.organizationUuid ?? null;
33
- const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
163
+ if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
164
+ const state: UsageState = { ...windows, org, ts: now, model: parseStatusLineModel(obj) };
34
165
  writeUsage(state);
35
166
  }
36
167
  } catch {
37
- // malformed stdin - skip usage, still delegate below
168
+ // skip the tee, still render below
38
169
  }
39
170
 
40
- // 2) delegate to the prior statusLine, feeding it the same stdin.
41
- const prior = readPriorStatusLine();
42
- if (prior) {
43
- try {
44
- const p = Bun.spawn(["/bin/sh", "-c", prior], {
45
- stdin: new TextEncoder().encode(raw),
46
- stdout: "pipe",
47
- stderr: "inherit",
48
- });
49
- const out = await new Response(p.stdout).text();
50
- await p.exited;
51
- if (out) process.stdout.write(out);
52
- return p.exitCode ?? 0;
53
- } catch {
54
- // fall through to no-op
55
- }
56
- }
171
+ const cfg = loadConfig();
172
+ const modelUsage = loadModelUsage();
173
+ const stdin = StatusLineStdinSchema.safeParse(obj);
174
+ const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
175
+ const ctx: RenderCtx = {
176
+ accounts: loadAccounts(),
177
+ perModel: modelUsage && modelUsage.org === org ? modelUsage.perModel : {},
178
+ switchModels: cfg.policy.switchModels,
179
+ threshold: cfg.threshold,
180
+ worktree: dir == null ? null : worktreeName(dir),
181
+ now,
182
+ color: !process.env.NO_COLOR,
183
+ };
184
+ process.stdout.write(renderStatusline(obj, ctx) + "\n");
57
185
  return 0;
58
186
  }
@@ -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,7 +193,7 @@ 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
  }
@@ -7,9 +7,14 @@ import { loadConfig } from "./state.ts";
7
7
 
8
8
  export function resolveRealClaude(): string {
9
9
  const cfg = loadConfig();
10
- if (cfg.claudeBin && existsSync(cfg.claudeBin)) return cfg.claudeBin;
11
- if (process.env.TOKENMAXXING_CLAUDE_BIN && existsSync(process.env.TOKENMAXXING_CLAUDE_BIN))
12
- return process.env.TOKENMAXXING_CLAUDE_BIN;
10
+ if (cfg.claudeBin) {
11
+ if (existsSync(cfg.claudeBin)) return cfg.claudeBin;
12
+ // A configured-but-vanished binary must not silently degrade to the PATH
13
+ // scan: under a relocated TOKENMAXXING_HOME the scan's binDir guard misses
14
+ // the installed wrapper, which then recurses through the supervisor
15
+ // (observed 2026-07-12 as a forever-hung `/usage` probe).
16
+ throw new Error(`configured claudeBin does not exist: ${cfg.claudeBin} - fix config.json`);
17
+ }
13
18
  for (const d of (process.env.PATH ?? "").split(":")) {
14
19
  if (!d || d === paths.binDir) continue;
15
20
  const cand = join(d, "claude");
package/src/lib/decide.ts CHANGED
@@ -20,9 +20,9 @@ import { readOAuthAccount } from "./claudejson.ts";
20
20
  import { chooseAndSwap, performSwap } from "./swap.ts";
21
21
  import { pickEarliestReset, usableAt } from "./picker.ts";
22
22
  import { InvalidGrantError } from "./oauth.ts";
23
- import { probeUsage } from "./usage.ts";
23
+ import { familyTokens, matchedFamily, probeUsage } from "./usage.ts";
24
24
  import { log } from "./log.ts";
25
- import { AccountSchema, type Account, type Config, type ModelInfo, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
25
+ import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
26
26
 
27
27
  const SwapDecisionSchema = z.object({
28
28
  swapped: z.boolean(),
@@ -58,24 +58,6 @@ async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsa
58
58
  return state;
59
59
  }
60
60
 
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
61
  /** The family's weekly cap among the `/usage` rows; when several rows match the
80
62
  * family, the most-used one wins (switching early beats metering a depleted cap). */
81
63
  function capForFamily(mu: ModelUsageState, family: string): UsageWindow | undefined {
@@ -134,6 +116,7 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
134
116
  const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
135
117
  if (active) {
136
118
  active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
119
+ active.lastUsageAt = u2.ts;
137
120
  // Snapshot per-model caps too, so they still show after we switch away.
138
121
  if (mu2 && mu2.org === org2) active.lastPerModel = mu2.perModel;
139
122
  saveAccounts(idx);
@@ -1,5 +1,5 @@
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";
@@ -14,7 +14,6 @@ import { resolveRealClaude } from "./claudebin.ts";
14
14
  const InstallOutcomeSchema = z.object({
15
15
  claudeWrapper: z.string(),
16
16
  installedBin: z.string(),
17
- priorStatusLine: z.string().nullable(),
18
17
  pathAhead: z.boolean(),
19
18
  timerLoaded: z.boolean(),
20
19
  });
@@ -48,11 +47,10 @@ export function installSupervisor(): InstallOutcome {
48
47
  // the `xx` short alias → tokenmaxxing
49
48
  writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
50
49
 
51
- const { priorStatusLine } = installSettings();
50
+ installSettings();
52
51
  return {
53
52
  claudeWrapper: paths.supervisorLink,
54
53
  installedBin: target,
55
- priorStatusLine,
56
54
  pathAhead: isBinDirAhead(),
57
55
  timerLoaded: installCheckTimer(),
58
56
  };
@@ -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/picker.ts CHANGED
@@ -1,10 +1,11 @@
1
- // Choose the best account to switch TO when the active one crosses threshold.
2
- // Policy: exclude the current account and any that need reauth or are still
3
- // rate-limited (usage >= threshold and not yet past resets_at). Among the rest,
4
- // prefer the account whose weekly window expires soonest: weekly limits reset
5
- // at a fixed per-account time and unused allowance is forfeited at reset, so
6
- // quota nearest its reset is use-it-or-lose-it and should be drained first.
7
- // Tiebreak on lowest 7-day usage, then soonest 5h reset.
1
+ // Choose the account to switch TO. Greedy policy: among usable accounts (no
2
+ // reauth, no window >= threshold that hasn't reset yet), take the one whose
3
+ // weekly window expires soonest - weekly limits reset at a fixed per-account
4
+ // time and unused allowance is forfeited at reset, so quota nearest its reset
5
+ // is use-it-or-lose-it and should be drained first. Runs entirely off each
6
+ // account's cached windows (absolute UTC epochs, so a stale snapshot still
7
+ // resolves to the correct upcoming reset), which makes the pick deterministic
8
+ // and idempotent: re-running lands on the same account.
8
9
 
9
10
  import { minBy, sortBy } from "es-toolkit";
10
11
  import { z } from "zod";
@@ -13,6 +14,7 @@ import { AccountSchema, type Account } from "./types.ts";
13
14
  const PickCtxSchema = z.object({
14
15
  now: z.number(),
15
16
  threshold: z.number(),
17
+ /** account to exclude (hooks switch AWAY from it); null ranks everyone. */
16
18
  currentAccountUuid: z.string().nullable(),
17
19
  });
18
20
  export type PickCtx = z.infer<typeof PickCtxSchema>;
@@ -28,31 +30,33 @@ export function isExhausted(a: Account, ctx: PickCtx): boolean {
28
30
 
29
31
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
30
32
 
31
- /** Epoch ms when the account's weekly quota is next forfeited. The weekly reset
32
- * is a fixed per-account anchor, so a stale (past) resetsAt extrapolates
33
- * forward in 7-day steps; an account with no sampled reset sorts last. */
33
+ /** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
34
+ * anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
35
+ * an old snapshot still yields the correct upcoming reset. */
36
+ export function nextWeeklyReset(resetsAt: number | null, now: number): number | null {
37
+ if (resetsAt == null || resetsAt > now) return resetsAt;
38
+ return resetsAt + (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
39
+ }
40
+
41
+ /** Epoch ms when the account's weekly quota is next forfeited; an account with
42
+ * no sampled reset sorts last. */
34
43
  export function weeklyExpiry(a: Account, now: number): number {
35
- const r = a.lastUsage?.sevenDay.resetsAt;
36
- if (r == null) return Number.POSITIVE_INFINITY;
37
- if (r > now) return r;
38
- return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
44
+ return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
39
45
  }
40
46
 
47
+ /** The switch preference: soonest weekly expiry first, tiebreak lowest 7-day
48
+ * usage. Shared with the statusLine pool ordering so the display order IS the
49
+ * swap order. */
50
+ export const swapPreference = (now: number) => [
51
+ (a: Account) => weeklyExpiry(a, now),
52
+ (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
53
+ ];
54
+
41
55
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
42
- const candidates = accounts.filter(
43
- (a) =>
44
- a.accountUuid !== ctx.currentAccountUuid &&
45
- !a.needsReauth &&
46
- !isExhausted(a, ctx),
56
+ const usable = accounts.filter(
57
+ (a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
47
58
  );
48
- 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]!;
59
+ return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
56
60
  }
57
61
 
58
62
  /** 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
@@ -22,7 +22,9 @@ import {
22
22
  const DEFAULT_CONFIG: Config = {
23
23
  threshold: 95,
24
24
  claudeBin: "",
25
- policy: { projectionMargin: 0, switchModels: ["fable", "opus"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
25
+ // per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
26
+ // per the user 2026-07-12), and only Fable's is worth switching on.
27
+ policy: { projectionMargin: 0, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
26
28
  };
27
29
 
28
30
  /** On-disk shape (all optional); validated via Zod, merged over defaults. */
@@ -124,10 +126,15 @@ export function saveLastSwapAt(ts: number): void {
124
126
  writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
125
127
  }
126
128
 
127
- /** Write-on-change: skip the write (and its fsync) when only `ts` would differ. */
129
+ /** An alive feed re-proving unchanged figures still refreshes `ts` this often,
130
+ * so cache-age displays stay honest without a write+fsync per tick. */
131
+ const USAGE_TS_REFRESH_MS = 10 * 60_000;
132
+
133
+ /** Write-on-change: skip the write (and its fsync) when only `ts` would differ,
134
+ * unless the stored `ts` has aged past the refresh window. */
128
135
  export function writeUsage(next: UsageState): boolean {
129
136
  const prev = loadUsage();
130
- if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 })) return false;
137
+ if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) return false;
131
138
  writeFileAtomic(paths.usageJson, JSON.stringify(next));
132
139
  return true;
133
140
  }
package/src/lib/types.ts CHANGED
@@ -84,6 +84,10 @@ export const AccountSchema = z.object({
84
84
  addedAt: z.string(),
85
85
  lastUsage: UsageWindowsSchema.optional(),
86
86
  lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
87
+ /** epoch ms of the sample behind lastUsage/lastPerModel. Their resetsAt
88
+ * values are absolute epochs (UTC-anchored), so even an old snapshot still
89
+ * resolves to correct resets - display it as a dated cache, never discard. */
90
+ lastUsageAt: z.number().optional(),
87
91
  needsReauth: z.boolean().optional(),
88
92
  subscriptionType: z.string().optional(),
89
93
  });
@@ -137,6 +141,32 @@ export const RateLimitsStdinSchema = z.looseObject({
137
141
  organizationUuid: z.string().optional(),
138
142
  });
139
143
 
144
+ /** The statusLine stdin fields the native renderer consumes, on top of the
145
+ * rate-limit tee's needs. Loose + optional throughout: fields are null before
146
+ * the first API response and claude adds new ones freely. Each sub-object also
147
+ * `.catch(undefined)`es so a field that drifts to a wrong shape degrades to
148
+ * absent instead of failing the whole parse and erasing the info block. */
149
+ export const StatusLineStdinSchema = RateLimitsStdinSchema.extend({
150
+ workspace: z
151
+ .looseObject({
152
+ current_dir: z.string().nullable().optional(),
153
+ project_dir: z.string().nullable().optional(),
154
+ })
155
+ .nullable()
156
+ .optional()
157
+ .catch(undefined),
158
+ context_window: z.looseObject({ used_percentage: z.number().nullable().optional() }).nullable().optional().catch(undefined),
159
+ cost: z
160
+ .looseObject({
161
+ total_lines_added: z.number().nullable().optional(),
162
+ total_lines_removed: z.number().nullable().optional(),
163
+ })
164
+ .nullable()
165
+ .optional()
166
+ .catch(undefined),
167
+ effort: z.looseObject({ level: z.string().optional() }).nullable().optional().catch(undefined),
168
+ });
169
+
140
170
  /** Success body of the OAuth refresh grant. */
141
171
  export const RefreshResponseSchema = z.looseObject({
142
172
  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;
@@ -177,6 +195,11 @@ const CRED_ENV_OVERRIDES = [
177
195
  /** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
178
196
  * limit lines (claude prints only a local-stats footer when its own usage fetch
179
197
  * errors/throttles) or failed to run. */
198
+ /** A probe child that wedges (auth prompt, dead endpoint) must never block the
199
+ * Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
200
+ * answers in seconds. */
201
+ const PROBE_KILL_MS = 60_000;
202
+
180
203
  async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
181
204
  let out: string;
182
205
  try {
@@ -185,12 +208,17 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
185
208
  stdout: "pipe",
186
209
  stderr: "pipe",
187
210
  });
188
- out = await new Response(p.stdout).text();
189
- const errText = await new Response(p.stderr).text();
190
- await p.exited;
191
- if (p.exitCode !== 0) {
192
- log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
193
- return null;
211
+ const killer = setTimeout(() => p.kill(), PROBE_KILL_MS);
212
+ try {
213
+ out = await new Response(p.stdout).text();
214
+ const errText = await new Response(p.stderr).text();
215
+ await p.exited;
216
+ if (p.exitCode !== 0) {
217
+ log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
218
+ return null;
219
+ }
220
+ } finally {
221
+ clearTimeout(killer);
194
222
  }
195
223
  } catch (e) {
196
224
  log("usage.probe_failed", { err: String((e as Error).message ?? e) });
@@ -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
@@ -24,7 +24,7 @@ function printHelp(): void {
24
24
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
25
25
 
26
26
  ${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
27
- ${c.cyan("tokenmaxxing switch")} [sel] switch now to the best (or a specific) account
27
+ ${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
28
28
  ${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
29
29
  ${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
30
30
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)