tokenmaxxing 0.5.0 → 0.6.1

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
@@ -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
 
package/README.md CHANGED
@@ -48,10 +48,12 @@ claude # use claude as always
48
48
  Switching triggers at **95%** (configurable) on any of:
49
49
 
50
50
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
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.
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.5.0",
3
+ "version": "0.6.1",
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
@@ -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/render.ts CHANGED
@@ -40,6 +40,19 @@ export function fmtReset(epochMs: number | null | undefined, now = Date.now()):
40
40
  return `resets in ${m}m`;
41
41
  }
42
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
+
43
56
  /** Compact time-until-reset for the statusLine: the largest unit only ("6d",
44
57
  * "2h", "45m"), floored to "1m" so a live window never reads as zero, and ""
45
58
  * once the reset has passed (the window is simply empty again). Non-empty
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
 
@@ -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;
@@ -82,18 +92,18 @@ export async function cmdStatus(): Promise<number> {
82
92
  const badges: string[] = [];
83
93
  if (active) badges.push(c.green("active"));
84
94
  if (a.needsReauth) badges.push(c.red("needs-reauth"));
85
- if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid }))
95
+ if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
86
96
  badges.push(c.yellow("exhausted"));
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,69 @@ 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
+ // No session context here, so every configured per-model family gates.
62
+ const everyone: PickCtx = { now, threshold: cfg.threshold, currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
63
+ const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
64
+ const best = pickBest(idx.accounts, everyone);
65
+ const currentWins =
66
+ best != null && active != null && !active.needsReauth && !isExhausted(active, everyone) &&
67
+ (best.accountUuid === active.accountUuid || swapPreference(now).every((k) => k(active) === k(best)));
68
+ if (currentWins) {
69
+ if (drifted) return swapTo(active);
70
+ const expiry = weeklyExpiry(active, now);
71
+ const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
72
+ console.log(`already on the best account: ${c.bold(active.label)}${why}`);
46
73
  return 0;
47
74
  }
75
+ if (best) {
76
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies: cfg.policy.switchModels });
77
+ if (landed) {
78
+ console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
79
+ return 0;
80
+ }
81
+ }
48
82
 
49
- // everything is depleted switch to whichever recovers soonest
50
- const earliest = pickEarliestReset(idx.accounts, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid });
51
- 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;
83
+ // No usable target swapped in: everything is depleted, or the remaining
84
+ // candidates' refresh tokens just died (chooseAndSwap marks needs-reauth,
85
+ // hence the reload). Stay on / switch to whichever recovers soonest.
86
+ const fresh = loadAccounts();
87
+ const earliest = pickEarliestReset(fresh.accounts, everyone);
88
+ if (!earliest) {
89
+ // Either every account needs re-auth, or every account is blocked with no
90
+ // recoverable bound (unparsed reset clocks AND no sample time - see log).
91
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
92
+ if (reauth.length > 0) { console.error(c.yellow(`no switchable account - re-auth needed: ${reauth.join(", ")}`)); return 1; }
93
+ // never freeze a label drift behind a no-op (see header).
94
+ if (drifted && active) return swapTo(active);
95
+ console.log(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"));
96
+ return 0;
97
+ }
98
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
99
+ const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
100
+ if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
101
+ // availableAt in the past means the current account is fine and the
102
+ // others are simply unusable - do not claim a limit that is not there.
103
+ const msg = earliest.availableAt <= now
104
+ ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
105
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})${reauthNote}`;
106
+ console.log(c.yellow(msg));
107
+ return 0;
108
+ }
109
+ const code = await swapTo(earliest.account);
110
+ if (code === 0 && earliest.availableAt > now) {
111
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(earliest.availableAt, now)})${reauthNote}`));
57
112
  }
58
- console.log(`${c.yellow("↻")} all accounts at limit - switched to ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})`);
59
- return 0;
113
+ return code;
60
114
  });
61
115
  }
@@ -1,8 +1,8 @@
1
1
  // SessionStart hook. A launch/resume backstop: if the active account is already
2
2
  // over threshold with FRESH usage (e.g. a prior session left it exhausted), swap
3
3
  // the credential before this session's first turn so it starts on a good account.
4
- // Right after a respawn, usage.json is stale for the new org, so the org guard in
5
- // evaluateAndMaybeSwap makes this correctly no-op.
4
+ // Right after a respawn, the post-swap cooldown in evaluateAndMaybeSwap makes
5
+ // this correctly no-op.
6
6
 
7
7
  import { z } from "zod";
8
8
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
@@ -17,7 +17,7 @@ import { sortBy } from "es-toolkit";
17
17
  import { z } from "zod";
18
18
  import { readOAuthAccount } from "../lib/claudejson.ts";
19
19
  import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
20
- import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
20
+ import { familyTokens, gatedFamilies, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
21
21
  import { isExhausted, swapPreference, weeklyExpiry } from "../lib/picker.ts";
22
22
  import { worktreeName } from "../lib/worktree.ts";
23
23
  import { fmtResetShort, makeColors } from "../cli/render.ts";
@@ -105,7 +105,12 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
105
105
  : "";
106
106
 
107
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 };
108
+ const pickCtx = {
109
+ now: ctx.now,
110
+ threshold: ctx.threshold,
111
+ currentAccountUuid: ctx.accounts.activeAccountUuid,
112
+ switchFamilies: gatedFamilies(parseStatusLineModel(stdinObj), ctx.switchModels),
113
+ };
109
114
  const parked = sortBy(
110
115
  ctx.accounts.accounts.filter((a) => a.accountUuid !== ctx.accounts.activeAccountUuid),
111
116
  [(a) => (a.needsReauth || isExhausted(a, pickCtx) ? 1 : 0), ...swapPreference(ctx.now)],
@@ -30,7 +30,10 @@ export async function runStopHook(): Promise<number> {
30
30
  (parsed.success ? parsed.data.session_id : undefined) ?? process.env.TOKENMAXXING_SESSION_ID;
31
31
 
32
32
  try {
33
- const decision = await evaluateAndMaybeSwap();
33
+ // Anticipatory depleted swaps are only sane when the respawn marker below
34
+ // will actually pause the session until the reset.
35
+ const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId != null;
36
+ const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
34
37
  // Respawn on a swap, or on a depleted-pool wait (relaunch after the reset).
35
38
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
36
39
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
@@ -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
@@ -1,11 +1,16 @@
1
- // Shared switch decision used by both the Stop hook and the SessionStart hook.
2
- // Cheap pre-check off the lock; the authoritative re-check + swap under the flock.
1
+ // Shared switch decision used by the Stop/SessionStart hooks and the periodic
2
+ // `check` timer. Cheap pre-check off the lock; the authoritative re-check + swap
3
+ // under the flock.
3
4
  //
4
5
  // Two limit families are checked, both metered against the CURRENTLY-active org:
5
- // 1. AGGREGATE windows (session=five_hour, week-all=seven_day) from statusLine.
6
- // 2. PER-MODEL weekly cap (e.g. "week (Fable)") - only when the active model is
7
- // capacity-constrained (config policy.switchModels). This isn't in statusLine,
8
- // so we read it from `claude -p '/usage'`, TTL-cached (not every turn).
6
+ // 1. AGGREGATE windows (session=five_hour, week-all=seven_day). A rendering
7
+ // statusLine tees them fresh every turn; when nothing renders (headless
8
+ // boxes, idle TUIs) they come from `claude -p '/usage'`, re-probed once the
9
+ // snapshot ages past the poll TTL so they can never freeze at a stale value.
10
+ // 2. PER-MODEL weekly cap (e.g. "week (Fable)") - when the active model is
11
+ // capacity-constrained (config policy.switchModels), or for EVERY configured
12
+ // family when the model is unknown (nothing rendered within the TTL, so the
13
+ // session that stamped the last model may be gone).
9
14
  //
10
15
  // The org guard is load-bearing: right after a respawn, usage.json still reflects
11
16
  // the OLD account, so org != activeOrg → we correctly do nothing until fresh usage
@@ -15,14 +20,14 @@ import { maxBy } from "es-toolkit";
15
20
  import { z } from "zod";
16
21
  import { withLock } from "./lock.ts";
17
22
  import { paths } from "./paths.ts";
18
- import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, writeUsage } from "./state.ts";
23
+ import { loadAccounts, loadConfig, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
19
24
  import { readOAuthAccount } from "./claudejson.ts";
20
25
  import { chooseAndSwap, performSwap } from "./swap.ts";
21
26
  import { pickEarliestReset, usableAt } from "./picker.ts";
22
27
  import { InvalidGrantError } from "./oauth.ts";
23
- import { familyTokens, matchedFamily, probeUsage } from "./usage.ts";
28
+ import { familyTokens, gatedFamilies, probeUsage } from "./usage.ts";
24
29
  import { log } from "./log.ts";
25
- import { AccountSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
30
+ import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
26
31
 
27
32
  const SwapDecisionSchema = z.object({
28
33
  swapped: z.boolean(),
@@ -33,75 +38,130 @@ const SwapDecisionSchema = z.object({
33
38
  });
34
39
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
35
40
 
36
- /** Cold-start aggregate fallback when statusLine hasn't written usage.json yet. */
37
- async function probeAggregate(org: string | null): Promise<UsageState | null> {
38
- const full = await probeUsage();
39
- if (!full) return null;
40
- return { fiveHour: full.session, sevenDay: full.weekAll, org, ts: Date.now(), model: null };
41
- }
42
-
43
- /** Ensure a fresh-enough per-model cache for the active org; poll `/usage` if stale. */
44
- async function ensurePerModel(cfg: Config, org: string | null): Promise<ModelUsageState | null> {
45
- const cached = loadModelUsage();
46
- const fresh = cached && cached.org === org && Date.now() - cached.ts < cfg.policy.usagePollTtlMs;
47
- if (fresh) return cached;
48
- const full = await probeUsage();
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
- }
56
- const state: ModelUsageState = { perModel: full.perModel, org, ts: Date.now() };
57
- saveModelUsage(state);
58
- return state;
41
+ /** A window's usable-against percentage NOW: one whose cached reset has passed
42
+ * is empty again, never a switch reason. */
43
+ function liveUsed(w: UsageWindow, now: number): number {
44
+ return w.resetsAt != null && w.resetsAt <= now ? 0 : w.usedPercentage;
59
45
  }
60
46
 
61
47
  /** 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 {
48
+ * family, the most-used LIVE one wins (switching early beats metering a depleted
49
+ * cap, but a row whose reset passed must not mask a still-burning sibling). */
50
+ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWindow | undefined {
64
51
  const rows = Object.entries(mu.perModel)
65
52
  .filter(([k]) => familyTokens(k).includes(family))
66
53
  .map(([, w]) => w);
67
- return maxBy(rows, (w) => w.usedPercentage);
54
+ return maxBy(rows, (w) => liveUsed(w, now));
68
55
  }
69
56
 
70
57
  /** True if the active account is over the floor on ANY applicable limit. */
71
- function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number): boolean {
58
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, floor: number, now: number): boolean {
72
59
  if (!u || !org || u.org !== org) return false;
73
- if (u.fiveHour.usedPercentage >= floor || u.sevenDay.usedPercentage >= floor) return true;
74
- const family = matchedFamily(u.model, cfg.policy.switchModels);
75
- if (family && mu && mu.org === org) {
76
- const cap = capForFamily(mu, family);
77
- if (cap && cap.usedPercentage >= floor) return true;
60
+ if (liveUsed(u.fiveHour, now) >= floor || liveUsed(u.sevenDay, now) >= floor) return true;
61
+ if (mu && mu.org === org) {
62
+ for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
63
+ const cap = capForFamily(mu, family, now);
64
+ if (cap && liveUsed(cap, now) >= floor) return true;
65
+ }
78
66
  }
79
67
  return false;
80
68
  }
81
69
 
82
- /** Does the active model warrant a per-model `/usage` poll? */
70
+ /** Does a per-model cap gate the decision for this usage snapshot? */
83
71
  function needsPerModel(u: UsageState | null, cfg: Config): boolean {
84
- return matchedFamily(u?.model ?? null, cfg.policy.switchModels) !== null;
72
+ return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
73
+ }
74
+
75
+ const SnapshotsSchema = z.object({
76
+ u: UsageStateSchema.nullable(),
77
+ mu: ModelUsageStateSchema.nullable(),
78
+ });
79
+ type Snapshots = z.infer<typeof SnapshotsSchema>;
80
+
81
+ /** usage.json is trusted while the tee proved itself alive (mtime, NOT the
82
+ * embedded ts - write-on-change lets ts age under an alive feed) within the
83
+ * TTL for the live org. */
84
+ function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
85
+ if (u == null || u.org !== org) return false;
86
+ const teeAt = usageTeeAt();
87
+ return teeAt != null && now - teeAt <= ttl;
88
+ }
89
+
90
+ /**
91
+ * Load the two usage snapshots, re-probing `/usage` (free, 0 tokens) when they
92
+ * are absent, org-drifted, or older than the poll TTL. ONE probe carries all
93
+ * three limit kinds, so a success refreshes BOTH files; anything less leaves a
94
+ * headless box (no rendering statusLine to tee) evaluating frozen or
95
+ * org-mismatched values forever - the 2026-07-12 stella blindness. The
96
+ * refreshed usage carries model: null (whatever session stamped the old model
97
+ * may be gone), which gates every configured family. model-usage.json's ts also
98
+ * stamps FAILED attempts, so a busy live token cannot cause a probe storm
99
+ * (2026-07-10): the next hook waits out the TTL instead of re-probing.
100
+ */
101
+ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
102
+ let u = loadUsage();
103
+ let mu = loadModelUsage();
104
+ const ttl = cfg.policy.usagePollTtlMs;
105
+ const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
106
+ if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
107
+ const full = await probeUsage();
108
+ const ts = Date.now();
109
+ // A swap can complete while the probe runs (no lock is held here). Its
110
+ // result would then be stamped under the pre-swap org over the files the
111
+ // swap just cleared - discard it; the locked re-check below rejects this
112
+ // evaluation anyway and the next one re-probes the new org.
113
+ if (readOAuthAccount()?.organizationUuid === org) {
114
+ if (full) {
115
+ // A probe takes seconds; a rendering session's tee may have landed
116
+ // while it ran. The tee is fresher AND model-aware, so it wins.
117
+ const teed = loadUsage();
118
+ if (usageFresh(teed, org, ttl, ts)) {
119
+ u = teed;
120
+ } else {
121
+ u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
122
+ writeUsage(u);
123
+ }
124
+ mu = { perModel: full.perModel, org, ts };
125
+ saveModelUsage(mu);
126
+ } else {
127
+ mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts };
128
+ saveModelUsage(mu);
129
+ }
130
+ }
131
+ }
132
+ return { u, mu };
85
133
  }
86
134
 
87
- export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecision> {
135
+ /** How long after a swap the auto paths hold still. The statusLine tee is
136
+ * suppressed this long (sessions adopt the swap in <=30s), so a decision made
137
+ * sooner runs model-blind on data the swap itself invalidated - that is how a
138
+ * model-aware swap got immediately undone into an A<->B respawn loop. Manual
139
+ * `switch` is unaffected. */
140
+ const POST_SWAP_COOLDOWN_MS = 45_000;
141
+
142
+ /**
143
+ * `anticipatory` allows the depleted path to swap onto an account that is still
144
+ * blocked but recovers soonest. Only a caller that can PAUSE until the reset
145
+ * (the supervised Stop hook, which writes a respawn marker the supervisor
146
+ * honors with a countdown) should pass true: from the check timer or an
147
+ * unsupervised hook, pre-parking silently yanks a live session onto a
148
+ * known-over-limit account, and buys nothing - the normal pick path adopts the
149
+ * recovering account the moment its reset passes.
150
+ */
151
+ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false): Promise<SwapDecision> {
152
+ const lastSwapAt = loadLastSwapAt();
153
+ if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
154
+ return { swapped: false, account: null, reason: "post-swap-cooldown" };
155
+ }
156
+
88
157
  const cfg = loadConfig();
89
158
  const floor = cfg.threshold - cfg.policy.projectionMargin;
90
159
  const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
91
160
 
92
- let usage = loadUsage();
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
- }
99
-
100
- // per-model poll (TTL-cached) only when on a capacity-constrained model
101
- const mu = needsPerModel(usage, cfg) ? await ensurePerModel(cfg, activeOrg) : null;
161
+ const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
102
162
 
103
163
  // cheap pre-check off the lock - the common case exits here.
104
- if (!isOver(usage, mu, activeOrg, cfg, floor)) {
164
+ if (!isOver(usage, mu, activeOrg, cfg, floor, now)) {
105
165
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
106
166
  }
107
167
 
@@ -116,25 +176,31 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
116
176
  const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
117
177
  if (active) {
118
178
  active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
179
+ active.lastUsageAt = u2.ts;
119
180
  // Snapshot per-model caps too, so they still show after we switch away.
120
- if (mu2 && mu2.org === org2) active.lastPerModel = mu2.perModel;
181
+ // An empty map is a failed probe's anti-storm stamp, not a measurement -
182
+ // it must not erase the burnt-cap snapshot the picker screens on.
183
+ if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) active.lastPerModel = mu2.perModel;
121
184
  saveAccounts(idx);
122
185
  }
123
186
  }
124
187
 
125
- if (!isOver(u2, mu2, org2, cfg, floor)) {
188
+ if (!isOver(u2, mu2, org2, cfg, floor, now)) {
126
189
  return { swapped: false, account: null, reason: "raced-already-swapped" };
127
190
  }
128
191
 
129
- const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
192
+ // Candidates are screened by the same families that drove this decision, so
193
+ // the pool cannot ping-pong onto an account the gate would immediately flag.
194
+ const switchFamilies = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
195
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies });
130
196
  if (landed) return { swapped: true, account: landed, reason: "swapped" };
131
197
 
132
198
  // Every account is depleted. Wait for whichever recovers soonest (including the
133
199
  // current one), if that reset is within the auto-wait window.
134
200
  const fresh = loadAccounts();
135
- const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
201
+ const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
136
202
  const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
137
- const currentAt = current ? usableAt(current, cfg.threshold, now) : Number.POSITIVE_INFINITY;
203
+ const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
138
204
  const other = pickEarliestReset(fresh.accounts, ctx);
139
205
 
140
206
  let target: Account | null = null;
@@ -149,6 +215,10 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
149
215
  }
150
216
 
151
217
  const isCurrent = target.accountUuid === fresh.activeAccountUuid;
218
+ if (!isCurrent && !anticipatory) {
219
+ log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
220
+ return { swapped: false, account: null, reason: "all-depleted" };
221
+ }
152
222
  if (!isCurrent) {
153
223
  try {
154
224
  await performSwap(target);
package/src/lib/picker.ts CHANGED
@@ -1,82 +1,113 @@
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";
11
- import { AccountSchema, type Account } from "./types.ts";
12
+ import { familyTokens } from "./usage.ts";
13
+ import { AccountSchema, type Account, type UsageWindow } from "./types.ts";
12
14
 
13
15
  const PickCtxSchema = z.object({
14
16
  now: z.number(),
15
17
  threshold: z.number(),
18
+ /** account to exclude (hooks switch AWAY from it); null ranks everyone. */
16
19
  currentAccountUuid: z.string().nullable(),
20
+ /** families whose per-model weekly cap counts toward exhaustion (from
21
+ * gatedFamilies): a candidate with a burnt gated cap is no switch target -
22
+ * landing on it would re-trigger the same gate and ping-pong the pool. */
23
+ switchFamilies: z.array(z.string()),
17
24
  });
18
25
  export type PickCtx = z.infer<typeof PickCtxSchema>;
19
26
 
27
+ /** The account's cached per-model windows that belong to a gated family. */
28
+ function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
29
+ return Object.entries(a.lastPerModel ?? {})
30
+ .filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
31
+ .map(([, w]) => w);
32
+ }
33
+
34
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
35
+ const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
36
+
37
+ /** Epoch until which `w` blocks the account; anything <= now means it does not
38
+ * block. A window with no known reset (unparsed clock) is still bounded by
39
+ * its own duration past the sample time - a 5h window sampled 6h ago has
40
+ * certainly reset - so a benched account always recovers by itself. With no
41
+ * sample time either, it blocks indefinitely: guessing "usable now" would
42
+ * swap onto it with waitUntil=now and churn kill/respawn. */
43
+ function blockedUntil(w: UsageWindow, windowMs: number, sampledAt: number | undefined, ctx: PickCtx): number {
44
+ if (w.usedPercentage < ctx.threshold) return 0;
45
+ if (w.resetsAt != null) return w.resetsAt;
46
+ return sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
47
+ }
48
+
49
+ /** When each of the account's windows stops blocking: the two aggregates plus
50
+ * the gated per-model caps. */
51
+ function blockingUntil(a: Account, ctx: PickCtx): number[] {
52
+ const u = a.lastUsage;
53
+ return [
54
+ ...(u ? [blockedUntil(u.fiveHour, FIVE_HOURS_MS, a.lastUsageAt, ctx), blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx)] : []),
55
+ ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastUsageAt, ctx)),
56
+ ];
57
+ }
58
+
20
59
  /** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
21
60
  export function isExhausted(a: Account, ctx: PickCtx): boolean {
22
- const u = a.lastUsage;
23
- if (!u) return false;
24
- const blocked = (w: { usedPercentage: number; resetsAt: number | null }) =>
25
- w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
26
- return blocked(u.fiveHour) || blocked(u.sevenDay);
61
+ return blockingUntil(a, ctx).some((t) => t > ctx.now);
27
62
  }
28
63
 
29
- const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
64
+ /** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
65
+ * anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
66
+ * an old snapshot still yields the correct upcoming reset. */
67
+ export function nextWeeklyReset(resetsAt: number | null, now: number): number | null {
68
+ if (resetsAt == null || resetsAt > now) return resetsAt;
69
+ return resetsAt + (Math.floor((now - resetsAt) / WEEK_MS) + 1) * WEEK_MS;
70
+ }
30
71
 
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. */
72
+ /** Epoch ms when the account's weekly quota is next forfeited; an account with
73
+ * no sampled reset sorts last. */
34
74
  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;
75
+ return nextWeeklyReset(a.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
39
76
  }
40
77
 
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. */
78
+ /** The switch preference: soonest weekly expiry first, tiebreak lowest 7-day
79
+ * usage. Shared with the statusLine pool ordering so the display order IS the
80
+ * swap order. */
44
81
  export const swapPreference = (now: number) => [
45
82
  (a: Account) => weeklyExpiry(a, now),
46
83
  (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
47
- (a: Account) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
48
84
  ];
49
85
 
50
86
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
51
- const candidates = accounts.filter(
52
- (a) =>
53
- a.accountUuid !== ctx.currentAccountUuid &&
54
- !a.needsReauth &&
55
- !isExhausted(a, ctx),
87
+ const usable = accounts.filter(
88
+ (a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
56
89
  );
57
- if (candidates.length === 0) return null;
58
- return sortBy(candidates, swapPreference(ctx.now))[0]!;
90
+ return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
59
91
  }
60
92
 
61
- /** When an account becomes usable again: the latest reset among its over-threshold
62
- * windows (all must reset), or `now` if nothing is over. */
63
- export function usableAt(a: Account, threshold: number, now: number): number {
64
- const u = a.lastUsage;
65
- if (!u) return now;
66
- const blocking = [u.fiveHour, u.sevenDay]
67
- .filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
68
- .map((w) => w.resetsAt as number);
69
- return blocking.length ? Math.max(...blocking) : now;
93
+ /** When an account becomes usable again: the latest blocking bound among its
94
+ * windows (all must clear), or `now` if nothing blocks. Consistent with
95
+ * isExhausted by construction (same blockingUntil). */
96
+ export function usableAt(a: Account, ctx: PickCtx): number {
97
+ const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
98
+ return blocking.length ? Math.max(...blocking) : ctx.now;
70
99
  }
71
100
 
72
101
  const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
73
102
  export type EarliestReset = z.infer<typeof EarliestResetSchema>;
74
103
 
75
104
  /** For the all-depleted case: the account (not current, not reauth) that becomes
76
- * usable soonest. */
105
+ * usable soonest. Accounts blocked with no known reset are unknowable, never
106
+ * a wait target. */
77
107
  export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
78
108
  const mapped = accounts
79
109
  .filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
80
- .map((a) => ({ account: a, availableAt: usableAt(a, ctx.threshold, ctx.now) }));
110
+ .map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
111
+ .filter((x) => Number.isFinite(x.availableAt));
81
112
  return minBy(mapped, (x) => x.availableAt) ?? null;
82
113
  }
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, rmSync } from "node:fs";
3
+ import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
4
4
  import { isEqual } from "es-toolkit";
5
5
  import { z } from "zod";
6
6
  import { paths, realClaudeBinFromEnv } from "./paths.ts";
@@ -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,14 +126,42 @@ 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. A suppressed write
135
+ * still bumps the file's mtime (metadata only, no fsync): mtime is the feed's
136
+ * liveness heartbeat, and without the bump an alive tee re-proving unchanged
137
+ * figures reads as a dead feed and the decision path goes model-blind. */
128
138
  export function writeUsage(next: UsageState): boolean {
129
139
  const prev = loadUsage();
130
- if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 })) return false;
140
+ if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) {
141
+ try {
142
+ utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
143
+ } catch (e) {
144
+ // The file vanished mid-race: a concurrent swap just invalidated these
145
+ // figures. Suppressing stays correct; a write would resurrect them.
146
+ if ((e as { code?: string }).code !== "ENOENT") throw e;
147
+ }
148
+ return false;
149
+ }
131
150
  writeFileAtomic(paths.usageJson, JSON.stringify(next));
132
151
  return true;
133
152
  }
134
153
 
154
+ /** When the usage feed last proved itself alive (usage.json mtime), null if the
155
+ * snapshot is absent. Fresher than the embedded `ts`, which write-on-change
156
+ * deliberately lets age while figures hold still. */
157
+ export function usageTeeAt(): number | null {
158
+ try {
159
+ return statSync(paths.usageJson).mtimeMs;
160
+ } catch {
161
+ return null;
162
+ }
163
+ }
164
+
135
165
  // ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
136
166
 
137
167
  export function loadModelUsage(): ModelUsageState | null {
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
  });
package/src/lib/usage.ts CHANGED
@@ -67,6 +67,19 @@ export function matchedFamily(model: ModelInfo | null, families: string[]): stri
67
67
  return families.find((f) => tokens.has(f)) ?? null;
68
68
  }
69
69
 
70
+ /** Families whose per-model weekly cap gates a switch, given the active model:
71
+ * the model's own family when it is capacity-constrained, none when it is a
72
+ * known-unconstrained model, and EVERY configured family when the model is
73
+ * unknown. The unknown case matters on headless boxes: a swap clears the
74
+ * snapshots and only an actively-rendering statusLine restores the model, so
75
+ * the periodic check ran model-blind for hours while the active account sat at
76
+ * its Fable cap (the 2026-07-12 stella incident). */
77
+ export function gatedFamilies(model: ModelInfo | null, families: string[]): string[] {
78
+ if (!model) return families;
79
+ const family = matchedFamily(model, families);
80
+ return family ? [family] : [];
81
+ }
82
+
70
83
  export const FullUsageSchema = z.object({
71
84
  session: UsageWindowSchema,
72
85
  weekAll: UsageWindowSchema,
@@ -195,6 +208,11 @@ const CRED_ENV_OVERRIDES = [
195
208
  /** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
196
209
  * limit lines (claude prints only a local-stats footer when its own usage fetch
197
210
  * errors/throttles) or failed to run. */
211
+ /** A probe child that wedges (auth prompt, dead endpoint) must never block the
212
+ * Stop/SessionStart hooks or the status flock forever; a healthy `/usage`
213
+ * answers in seconds. */
214
+ const PROBE_KILL_MS = 60_000;
215
+
198
216
  async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
199
217
  let out: string;
200
218
  try {
@@ -203,12 +221,17 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
203
221
  stdout: "pipe",
204
222
  stderr: "pipe",
205
223
  });
206
- out = await new Response(p.stdout).text();
207
- const errText = await new Response(p.stderr).text();
208
- await p.exited;
209
- if (p.exitCode !== 0) {
210
- log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
211
- return null;
224
+ const killer = setTimeout(() => p.kill(), PROBE_KILL_MS);
225
+ try {
226
+ out = await new Response(p.stdout).text();
227
+ const errText = await new Response(p.stderr).text();
228
+ await p.exited;
229
+ if (p.exitCode !== 0) {
230
+ log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
231
+ return null;
232
+ }
233
+ } finally {
234
+ clearTimeout(killer);
212
235
  }
213
236
  } catch (e) {
214
237
  log("usage.probe_failed", { err: String((e as Error).message ?? e) });
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)