tokenmaxxing 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.6.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/status.ts CHANGED
@@ -92,7 +92,7 @@ export async function cmdStatus(): Promise<number> {
92
92
  const badges: string[] = [];
93
93
  if (active) badges.push(c.green("active"));
94
94
  if (a.needsReauth) badges.push(c.red("needs-reauth"));
95
- 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 }))
96
96
  badges.push(c.yellow("exhausted"));
97
97
 
98
98
  console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
package/src/cli/switch.ts CHANGED
@@ -58,7 +58,8 @@ export async function cmdSwitch(selector?: string): Promise<number> {
58
58
  }
59
59
 
60
60
  // auto: greedy over everyone, current included - a no-op when current wins.
61
- const everyone: PickCtx = { now, threshold: cfg.threshold, currentAccountUuid: null };
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 };
62
63
  const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid) ?? null;
63
64
  const best = pickBest(idx.accounts, everyone);
64
65
  const currentWins =
@@ -72,7 +73,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
72
73
  return 0;
73
74
  }
74
75
  if (best) {
75
- const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
76
+ const landed = await chooseAndSwap({ now, threshold: cfg.threshold, switchFamilies: cfg.policy.switchModels });
76
77
  if (landed) {
77
78
  console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
78
79
  return 0;
@@ -84,7 +85,16 @@ export async function cmdSwitch(selector?: string): Promise<number> {
84
85
  // hence the reload). Stay on / switch to whichever recovers soonest.
85
86
  const fresh = loadAccounts();
86
87
  const earliest = pickEarliestReset(fresh.accounts, everyone);
87
- if (!earliest) { console.error(c.yellow("no switchable account (all need re-auth?)")); return 1; }
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
+ }
88
98
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
89
99
  const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
90
100
  if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
@@ -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 });
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
 
@@ -118,24 +178,29 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
118
178
  active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
119
179
  active.lastUsageAt = u2.ts;
120
180
  // Snapshot per-model caps too, so they still show after we switch away.
121
- 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;
122
184
  saveAccounts(idx);
123
185
  }
124
186
  }
125
187
 
126
- if (!isOver(u2, mu2, org2, cfg, floor)) {
188
+ if (!isOver(u2, mu2, org2, cfg, floor, now)) {
127
189
  return { swapped: false, account: null, reason: "raced-already-swapped" };
128
190
  }
129
191
 
130
- 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 });
131
196
  if (landed) return { swapped: true, account: landed, reason: "swapped" };
132
197
 
133
198
  // Every account is depleted. Wait for whichever recovers soonest (including the
134
199
  // current one), if that reset is within the auto-wait window.
135
200
  const fresh = loadAccounts();
136
- const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid };
201
+ const ctx = { now, threshold: cfg.threshold, currentAccountUuid: fresh.activeAccountUuid, switchFamilies };
137
202
  const current = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid);
138
- const currentAt = current ? usableAt(current, cfg.threshold, now) : Number.POSITIVE_INFINITY;
203
+ const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
139
204
  const other = pickEarliestReset(fresh.accounts, ctx);
140
205
 
141
206
  let target: Account | null = null;
@@ -150,6 +215,10 @@ export async function evaluateAndMaybeSwap(now = Date.now()): Promise<SwapDecisi
150
215
  }
151
216
 
152
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
+ }
153
222
  if (!isCurrent) {
154
223
  try {
155
224
  await performSwap(target);
package/src/lib/picker.ts CHANGED
@@ -9,26 +9,57 @@
9
9
 
10
10
  import { minBy, sortBy } from "es-toolkit";
11
11
  import { z } from "zod";
12
- import { AccountSchema, type Account } from "./types.ts";
12
+ import { familyTokens } from "./usage.ts";
13
+ import { AccountSchema, type Account, type UsageWindow } from "./types.ts";
13
14
 
14
15
  const PickCtxSchema = z.object({
15
16
  now: z.number(),
16
17
  threshold: z.number(),
17
18
  /** account to exclude (hooks switch AWAY from it); null ranks everyone. */
18
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()),
19
24
  });
20
25
  export type PickCtx = z.infer<typeof PickCtxSchema>;
21
26
 
22
- /** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
23
- export function isExhausted(a: Account, ctx: PickCtx): boolean {
24
- const u = a.lastUsage;
25
- if (!u) return false;
26
- const blocked = (w: { usedPercentage: number; resetsAt: number | null }) =>
27
- w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
28
- return blocked(u.fiveHour) || blocked(u.sevenDay);
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);
29
32
  }
30
33
 
31
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
+
59
+ /** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
60
+ export function isExhausted(a: Account, ctx: PickCtx): boolean {
61
+ return blockingUntil(a, ctx).some((t) => t > ctx.now);
62
+ }
32
63
 
33
64
  /** Next occurrence of a weekly reset. The weekly reset is a fixed per-account
34
65
  * anchor, so a cached (past) resetsAt extrapolates forward in 7-day steps -
@@ -59,25 +90,24 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
59
90
  return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
60
91
  }
61
92
 
62
- /** When an account becomes usable again: the latest reset among its over-threshold
63
- * windows (all must reset), or `now` if nothing is over. */
64
- export function usableAt(a: Account, threshold: number, now: number): number {
65
- const u = a.lastUsage;
66
- if (!u) return now;
67
- const blocking = [u.fiveHour, u.sevenDay]
68
- .filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
69
- .map((w) => w.resetsAt as number);
70
- 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;
71
99
  }
72
100
 
73
101
  const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
74
102
  export type EarliestReset = z.infer<typeof EarliestResetSchema>;
75
103
 
76
104
  /** For the all-depleted case: the account (not current, not reauth) that becomes
77
- * usable soonest. */
105
+ * usable soonest. Accounts blocked with no known reset are unknowable, never
106
+ * a wait target. */
78
107
  export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
79
108
  const mapped = accounts
80
109
  .filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
81
- .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));
82
112
  return minBy(mapped, (x) => x.availableAt) ?? null;
83
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";
@@ -131,14 +131,37 @@ export function saveLastSwapAt(ts: number): void {
131
131
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
132
132
 
133
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. */
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. */
135
138
  export function writeUsage(next: UsageState): boolean {
136
139
  const prev = loadUsage();
137
- if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) 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
+ }
138
150
  writeFileAtomic(paths.usageJson, JSON.stringify(next));
139
151
  return true;
140
152
  }
141
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
+
142
165
  // ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
143
166
 
144
167
  export function loadModelUsage(): ModelUsageState | null {
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,