tokenmaxxing 1.9.1 → 1.10.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
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing - design
2
2
 
3
- Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (the active rung of the 5h session ladder, **50, then 80, then 95** as the pool fills, or **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
4
4
 
5
5
  > Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state, decision engine, and supervisor; its verified internals live in AGENTS.md's Codex sections.
6
6
  >
@@ -12,7 +12,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
12
12
 
13
13
  A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. A plain swap therefore needs no process management at all (since 0.15.0, 2026-07-16; earlier versions respawned on every swap): the Stop hook swaps the credential and the session keeps running. What adoption cannot give you is the depleted case: when every account is at the wall the session must be PAUSED until something resets, and a live `claude` cannot pause itself.
14
14
 
15
- So the supervisor's job is narrow: on a depleted pool it **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits the wall.** The session window swaps at 95 (a 5h reset is cheap to sit out) while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
15
+ So the supervisor's job is narrow: on a depleted pool it **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits the wall.** The session window climbs a ladder, 50 then 80 then 95, each rung taking over once every pooled account is past the one below (a 5h reset is cheap to sit out, so the pool drains level by level), while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
16
16
 
17
17
  A hook can't do the pause-and-relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
18
18
 
@@ -79,7 +79,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
79
79
  ---
80
80
 
81
81
  ## 5. Rotation policy
82
- The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= 95%` OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
82
+ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= <active rung>` (the 5h ladder `thresholds.session`, default 50/80/95: the lowest rung some pooled account still clears, the current one included) OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
83
83
 
84
84
  **Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
85
85
 
package/README.md CHANGED
@@ -33,12 +33,11 @@ tokenmaxxing init
33
33
  nix-darwin:
34
34
 
35
35
  ```nix
36
- # flake inputs: tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
36
+ inputs.tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
37
37
  modules = [
38
38
  inputs.tokenmaxxing.darwinModules.withOverlay
39
39
  { programs.tokenmaxxing.enable = true; }
40
40
  ];
41
- # then: tokenmaxxing init
42
41
  ```
43
42
 
44
43
  Home Manager:
@@ -47,9 +46,10 @@ Home Manager:
47
46
  imports = [ inputs.tokenmaxxing.homeManagerModules.default ];
48
47
  programs.tokenmaxxing.enable = true;
49
48
  programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.default;
50
- # then: tokenmaxxing init
51
49
  ```
52
50
 
51
+ With either module, run `tokenmaxxing init` afterwards.
52
+
53
53
  `init` imports the account you're already on, installs the `claude` supervisor + five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure 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:
54
54
 
55
55
  ```sh
@@ -79,12 +79,12 @@ claude # use claude as always
79
79
 
80
80
  ## How switching decides
81
81
 
82
- Switching engages (configurable) once the active account's 5-hour session window is **50% used** - from there, every evaluation greedily converges on the usable account **furthest behind its own weekly pace**, and does nothing when the current account already wins. Independent of that, crossing a hard screening bar - **95%** session or **98%** weekly - always forces a switch. The bars also screen candidates on any of:
82
+ Switching engages (configurable) once the active account's 5-hour session window is **50% used** - from there, every evaluation greedily converges on the usable account **furthest behind its own weekly pace**, and does nothing when the current account already wins. Independent of that, crossing a screening bar always forces a switch: the session bar is the active rung of a ladder (**50**, then **80**, then **95**, each rung taking over once every pooled account is past the one below) and the weekly bar is **98%**. The bars also screen candidates on any of:
83
83
 
84
84
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
85
85
  - **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.
86
86
 
87
- The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session bar sits lower (95) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
87
+ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session ladder tops out lower (95) because a 5-hour reset is cheap to sit out, and its lower rungs keep the pool draining level by level; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
88
88
 
89
89
  The **target** is chosen greedily off each account's cached windows: among usable accounts (every window under its bar, or past its reset), the one **furthest behind its own weekly pace** - highest remaining% divided by time to its weekly reset - because 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. Both `tokenmaxxing switch` and the automatic path rank the current account too and do nothing when it already wins, so they are idempotent - evaluating periodically converges on the right account.
90
90
 
@@ -94,7 +94,7 @@ The **target** is chosen greedily off each account's cached windows: among usabl
94
94
 
95
95
  ```json
96
96
  {
97
- "thresholds": { "session": 95, "weekly": 98 },
97
+ "thresholds": { "session": [50, 80, 95], "weekly": 98 },
98
98
  "policy": {
99
99
  "projectionMargin": 0,
100
100
  "greedySessionFloor": 50,
@@ -104,7 +104,7 @@ The **target** is chosen greedily off each account's cached windows: among usabl
104
104
  }
105
105
  ```
106
106
 
107
- `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers).
107
+ `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers).
108
108
 
109
109
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
110
110
 
@@ -116,19 +116,20 @@ For agents you build on the [Claude Agent SDK](https://docs.claude.com/en/api/ag
116
116
  import { query } from "@anthropic-ai/claude-agent-sdk";
117
117
  import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
118
118
 
119
- await ensureBestAccount(); // run the switch decision before the spawn (swaps once it engages - see below)
119
+ await ensureBestAccount();
120
120
 
121
121
  for await (const message of query({
122
122
  prompt: "...",
123
123
  options: {
124
- ...pooledOptions(), // pinned real claude + scrubbed env -> the pooled live credential
125
- hooks: { Stop: [{ hooks: [stopHookCheck] }] }, // re-decide at every turn boundary
124
+ ...pooledOptions(),
125
+ hooks: { Stop: [{ hooks: [stopHookCheck] }] },
126
126
  },
127
127
  })) {
128
- // capture the session id from the init message if you want `resume` across swaps
129
128
  }
130
129
  ```
131
130
 
131
+ The loop body receives the message stream; keep the session id from the init message if you want `resume` across swaps.
132
+
132
133
  The SDK reads credentials when it spawns the claude subprocess and has no statusLine, so none of the CLI-side supervisor machinery applies; the integration is boundary-driven instead. `ensureBestAccount()` runs the exact greedy decision the CLI hooks and timer run (screening bars, pace-pressure target, post-swap cooldown - all shared code); like them, it deliberately does nothing until the decision engages (the active session past `policy.greedySessionFloor`, or a bar crossed), so a fresh account rides instead of churning. `pooledOptions()` pins `pathToClaudeCodeExecutable` to the real claude binary and supplies a full replacement `env` with every ambient credential override (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, ...) scrubbed, so the subprocess resolves the pool's live credential and nothing else. The pooled surface requires the default Claude Code credential store: it fails fast if `CLAUDE_CONFIG_DIR` or `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set in your app's environment, because a swap would write the live credential where those point while the spawned subprocess reads the default store. `stopHookCheck` re-runs the decision at turn boundaries; a swap it lands takes effect on the next subprocess spawn (it never yanks a mid-query token). If your app loads user settings (see the SDK's `settingSources`), the Stop hook `tokenmaxxing init` installed may already fire in SDK sessions too - `stopHookCheck` makes the check explicit and works when settings are restricted.
133
134
 
134
135
  This is for pooling **your own** subscription accounts in agents you run yourself - the same personal-use posture as the CLI. Anthropic does not allow third-party products to offer claude.ai login or rate limits, including agents built on the Agent SDK; don't ship this surface to third parties.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.9.1",
4
+ "version": "1.10.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -1,15 +1,14 @@
1
1
  ---
2
2
  name: safe-contribution
3
- description: Safely change and ship tokenmaxxing (tests, PR window, Mac vs Linux skew, public-repo hygiene). Use when editing this repo, opening PRs, or verifying decision-path changes.
3
+ description: Safely change and ship tokenmaxxing (verification without a test suite, PR window, Mac vs Linux skew, public-repo hygiene). Use when editing this repo, opening PRs, or verifying decision-path changes.
4
4
  ---
5
5
 
6
6
  # Safe contribution
7
7
 
8
8
  ## Verify
9
9
 
10
- - Default suite: `bun test`.
11
- - After decision-path changes, also run `bun test/e2e/swap-concurrency.ts` by hand (not part of `bun test`).
12
- - Hermetic CLI: `TOKENMAXXING_HOME=/tmp/xx-test bun run src/main.ts ...`.
10
+ - The repo carries no test code and no comments (owner ruling 2026-09-02). Never add tests, a test script, or comments.
11
+ - Verify with `bun run typecheck`, then hermetic CLI runs: `TOKENMAXXING_HOME=/tmp/xx-test bun run src/main.ts ...` (for decision-path changes, write accounts.json, usage.json, config.json, and a claude.json under that root and run `check`; point `TOKENMAXXING_KEYCHAIN_SERVICE` and `TOKENMAXXING_KEYCHAIN_ACCOUNT` at throwaway names and `TOKENMAXXING_CLAUDE_JSON` at that claude.json, because the CLI resolves claude.json from HOME, not from `TOKENMAXXING_HOME`).
13
12
  - Ask before any run that meters real quota (`status --force`, live-pool inference). Free `/usage` / plain status is fine.
14
13
 
15
14
  ## Ship
@@ -19,10 +19,11 @@ for await (const message of query({
19
19
  hooks: { Stop: [{ hooks: [stopHookCheck] }] },
20
20
  },
21
21
  })) {
22
- // capture session id from init for resume across swaps
23
22
  }
24
23
  ```
25
24
 
25
+ The loop body receives the message stream; keep the session id from the init message for `resume` across swaps.
26
+
26
27
  ## Rules
27
28
 
28
29
  - Call `ensureBestAccount()` before each `query()` spawn (no mid-query hot-swap).
@@ -7,11 +7,11 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
7
7
 
8
8
  ## Vocabulary
9
9
 
10
- - **Engaged**: session used >= `policy.greedySessionFloor` (default 50) or any hard/screening bar crossed.
10
+ - **Engaged**: session used >= `policy.greedySessionFloor` (default 50) or any screening bar crossed.
11
11
  - **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks.
12
12
  - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
13
13
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
- - **Effective bars**: `effectiveBars(cfg)` = thresholds minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong.
14
+ - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default 50/80/95: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed.
15
15
 
16
16
  ## Layer 2 (Claude only)
17
17
 
@@ -1,7 +1,7 @@
1
1
  # Policy sources
2
2
 
3
3
  - `docs/content/docs/switching.mdx`
4
- - `src/lib/decide.ts`, `src/lib/picker.ts` (inline rationale)
4
+ - `src/lib/decide.ts`, `src/lib/picker.ts`
5
5
  - `.memory/switch-policy-pace-pressure.md`
6
6
 
7
- Default screening bars: session 95, weekly 98. Wall defaults 100. Cooldown 45s on the automatic path after a swap.
7
+ Default screening bars: session ladder [50, 80, 95] with the active rung resolved per pool, weekly 98. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check cadence ceiling per rung: 300s, 180s, 120s, then the 60s floor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.9.1",
3
+ "version": "1.10.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",
@@ -32,9 +32,8 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "dev": "bun run src/main.ts",
35
- "test": "bun test",
36
35
  "typecheck": "tsc --noEmit",
37
- "prepublishOnly": "bun run typecheck && bun run test",
36
+ "prepublishOnly": "bun run typecheck",
38
37
  "nix:bun": "bunx bun2nix@2.1.2 -o bun.nix"
39
38
  },
40
39
  "devDependencies": {
@@ -7,12 +7,12 @@ import { performCodexSwap } from "../lib/codexswap.ts";
7
7
  import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
8
8
  import { liveCodexAccountId } from "../lib/codexsample.ts";
9
9
  import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
10
- import { effectiveBars } from "../lib/picker.ts";
10
+ import { terminalBars } from "../lib/picker.ts";
11
11
  import { c } from "./render.ts";
12
12
 
13
13
  export async function cmdCodexSwitch(sel?: string): Promise<number> {
14
14
  const cfg = loadConfig();
15
- const bars = effectiveBars(cfg);
15
+ const bars = terminalBars(cfg);
16
16
  const now = Date.now();
17
17
 
18
18
  return withLock(codexPaths.lockFile, async () => {
package/src/cli/status.ts CHANGED
@@ -4,13 +4,13 @@ import { readOAuthAccount } from "../lib/claudejson.ts";
4
4
  import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { codexPaths, paths } from "../lib/paths.ts";
7
- import { earliestReset, effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
7
+ import { earliestReset, effectiveBars, isExhausted, nextWeeklyReset, terminalBars } from "../lib/picker.ts";
8
8
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
9
9
  import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
10
10
  import { isCodexExhausted } from "../lib/codexpick.ts";
11
11
  import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
12
12
  import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
13
- import type { FullUsage } from "../lib/usage.ts";
13
+ import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
14
14
  import type { Account, Config, CodexWindow, UsageWindow } from "../lib/types.ts";
15
15
 
16
16
  export async function cmdStatus(force = false, preRender?: () => void): Promise<number> {
@@ -93,7 +93,9 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
93
93
  });
94
94
 
95
95
  preRender?.();
96
- console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
96
+ const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
97
+ const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
98
+ console.log(c.dim(`thresholds 5h ${cfg.thresholds.session.join("/")}% (at ${bars.session + cfg.policy.projectionMargin}%) weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
97
99
  console.log();
98
100
 
99
101
  const displayAccounts = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
@@ -110,7 +112,7 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
110
112
  const badges: string[] = [];
111
113
  if (active) badges.push(c.green("active"));
112
114
  if (a.needsReauth) badges.push(c.red("needs-reauth"));
113
- if (isExhausted(a, { now, thresholds: effectiveBars(cfg), currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
115
+ if (isExhausted(a, { now, thresholds: bars, currentAccountUuid: idx.activeAccountUuid, switchFamilies: families }))
114
116
  badges.push(c.yellow("exhausted"));
115
117
 
116
118
  const tier = claudeTierLabel(a);
@@ -187,7 +189,7 @@ async function renderCodexSection(input: {
187
189
  const badges: string[] = [];
188
190
  if (active) badges.push(c.green("active"));
189
191
  if (account.needsReauth) badges.push(c.red("needs-reauth"));
190
- if (isCodexExhausted({ account, thresholds: effectiveBars(cfg), now })) badges.push(c.yellow("exhausted"));
192
+ if (isCodexExhausted({ account, thresholds: terminalBars(cfg), now })) badges.push(c.yellow("exhausted"));
191
193
  console.log(`${marker} ${c.bold(account.label)}${account.planType ? ` ${c.dim(account.planType)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
192
194
 
193
195
  const usage = account.lastUsage;
package/src/cli/switch.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { withLock } from "../lib/lock.ts";
2
2
  import { paths } from "../lib/paths.ts";
3
- import { loadAccounts, loadConfig } from "../lib/state.ts";
3
+ import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
5
  import { performSwap } from "../lib/swap.ts";
6
6
  import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
+ import { gatedFamilies } from "../lib/usage.ts";
8
9
  import { findAccount } from "./rename.ts";
9
10
  import { c, fmtReset } from "./render.ts";
10
11
  import type { Account } from "../lib/types.ts";
@@ -44,9 +45,16 @@ export async function cmdSwitch(selector?: string): Promise<number> {
44
45
  return swapTo(target);
45
46
  }
46
47
 
47
- const everyone: PickCtx = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies: cfg.policy.switchModels };
48
+ const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
49
+ const everyoneIn = (accounts: Account[]): PickCtx => ({
50
+ now,
51
+ thresholds: effectiveBars(cfg, { accounts, now, switchFamilies }),
52
+ currentAccountUuid: null,
53
+ switchFamilies,
54
+ });
48
55
  while (true) {
49
56
  const cur = loadAccounts();
57
+ const everyone = everyoneIn(cur.accounts);
50
58
  const active =
51
59
  (claimedOrg != null ? cur.accounts.find((a) => a.organizationUuid === claimedOrg) : null) ??
52
60
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
@@ -74,7 +82,7 @@ export async function cmdSwitch(selector?: string): Promise<number> {
74
82
  }
75
83
 
76
84
  const fresh = loadAccounts();
77
- const earliest = pickEarliestReset(fresh.accounts, everyone);
85
+ const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
78
86
  if (!earliest) {
79
87
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
80
88
  if (reauth.length > 0) { console.error(c.yellow(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`)); return 1; }
@@ -10,7 +10,7 @@ import { livingCodexPresences } from "../lib/codexpresence.ts";
10
10
  import { liveCodexAccountId } from "../lib/codexsample.ts";
11
11
  import { loadCodexAccounts } from "../lib/codexstate.ts";
12
12
  import { loadConfig } from "../lib/state.ts";
13
- import { effectiveBars } from "../lib/picker.ts";
13
+ import { terminalBars } from "../lib/picker.ts";
14
14
  import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
15
15
  import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, type CodexAccount } from "../lib/types.ts";
16
16
  import { log } from "../lib/log.ts";
@@ -51,7 +51,7 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
51
51
  return false;
52
52
  }
53
53
  const now = Date.now();
54
- const bars = effectiveBars(loadConfig());
54
+ const bars = terminalBars(loadConfig());
55
55
  const index = loadCodexAccounts();
56
56
  const unusable = (account: CodexAccount): boolean =>
57
57
  account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
@@ -13,7 +13,7 @@ import { fetchCodexUsage } from "./codexusage.ts";
13
13
  import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
14
14
  import { liveCodexAccountId } from "./codexsample.ts";
15
15
  import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
16
- import { effectiveBars } from "./picker.ts";
16
+ import { terminalBars } from "./picker.ts";
17
17
  import { log } from "./log.ts";
18
18
  import { CodexAccountSchema, CodexReconcileMarkerSchema, type CodexAccount } from "./types.ts";
19
19
 
@@ -100,7 +100,7 @@ function postSwapResweep(input: { liveAccountId: string; bars: { session: number
100
100
  export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promise<CodexSwapDecision> {
101
101
  const now = input.now ?? Date.now();
102
102
  const cfg = loadConfig();
103
- const bars = effectiveBars(cfg);
103
+ const bars = terminalBars(cfg);
104
104
 
105
105
  return withLock(codexPaths.lockFile, async () => {
106
106
  let index = loadCodexAccounts();
package/src/lib/decide.ts CHANGED
@@ -5,11 +5,11 @@ import { paths } from "./paths.ts";
5
5
  import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { chooseAndSwap, performSwap } from "./swap.ts";
8
- import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, usableAt } from "./picker.ts";
8
+ import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
9
9
  import { InvalidGrantError } from "./oauth.ts";
10
10
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
11
  import { log } from "./log.ts";
12
- import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type UsageState, type UsageWindow } from "./types.ts";
12
+ import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
13
13
 
14
14
  const SwapDecisionSchema = z.object({
15
15
  swapped: z.boolean(),
@@ -36,9 +36,17 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
36
36
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
37
37
  }
38
38
 
39
- function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
39
+ function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, org: string | null): Account[] {
40
+ return accounts.map((a) => {
41
+ if (org == null || a.organizationUuid !== org) return a;
42
+ const live = u && u.org === org ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
43
+ const perModel = mu && mu.org === org && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
44
+ return { ...a, ...live, ...perModel };
45
+ });
46
+ }
47
+
48
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
40
49
  if (!u || !org || u.org !== org) return false;
41
- const bars = effectiveBars(cfg);
42
50
  if (
43
51
  liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
44
52
  liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
@@ -56,9 +64,9 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
56
64
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
57
65
  }
58
66
 
59
- function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, cfg: Config, now: number): boolean {
67
+ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
60
68
  if (!u || !org || u.org !== org) return false;
61
- return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, cfg, now);
69
+ return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, bars, cfg, now);
62
70
  }
63
71
 
64
72
  const SnapshotsSchema = z.object({
@@ -120,8 +128,13 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
120
128
  const cfg = loadConfig();
121
129
 
122
130
  const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
131
+ const bars0 = effectiveBars(cfg, {
132
+ accounts: overlayLive(loadAccounts().accounts, usage, mu, activeOrg),
133
+ now,
134
+ switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
135
+ });
123
136
 
124
- if (!enforced0 && !isEngaged(usage, mu, activeOrg, cfg, now)) {
137
+ if (!enforced0 && !isEngaged(usage, mu, activeOrg, bars0, cfg, now)) {
125
138
  const measured = usage != null && activeOrg != null && usage.org === activeOrg;
126
139
  if (!measured) {
127
140
  const replay = depletedReplay(now);
@@ -157,7 +170,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
157
170
  if (sampled) saveAccounts(idx);
158
171
  }
159
172
 
160
- if (!enforced2 && !isEngaged(u2, mu2, org2, cfg, now)) {
173
+ const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
174
+ const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
175
+ const barsOf = (accounts: Account[]): Thresholds => effectiveBars(cfg, { accounts, now, switchFamilies });
176
+ const bars = barsOf(idx.accounts);
177
+
178
+ if (!enforced2 && !isEngaged(u2, mu2, org2, bars, cfg, now)) {
161
179
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
162
180
  }
163
181
 
@@ -166,14 +184,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
166
184
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
167
185
  null;
168
186
 
169
- const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
170
- const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
171
-
172
- if (!enforced2 && !isOver(u2, mu2, org2, cfg, now)) {
173
- const ctxAll = { now, thresholds: effectiveBars(cfg), currentAccountUuid: null, switchFamilies };
187
+ const greedy = async (): Promise<SwapDecision> => {
174
188
  while (true) {
175
189
  const cur = loadAccounts();
176
190
  const active = seatOf(cur);
191
+ const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies };
177
192
  if (currentWins(active, cur.accounts, ctxAll)) {
178
193
  return { swapped: false, account: null, reason: "current-best" };
179
194
  }
@@ -188,10 +203,24 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
188
203
  log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
189
204
  return { swapped: true, account: best, reason: "swapped" };
190
205
  }
191
- }
206
+ };
207
+ if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy();
192
208
 
193
- const landed = await chooseAndSwap({ now, thresholds: effectiveBars(cfg), switchFamilies, currentAccountUuid: seatOf(loadAccounts())?.accountUuid ?? null });
194
- if (landed) return { swapped: true, account: landed, reason: "swapped" };
209
+ while (true) {
210
+ const cur = loadAccounts();
211
+ const seat = seatOf(cur);
212
+ const thresholds = barsOf(cur.accounts);
213
+ if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy();
214
+ const best = pickBest(cur.accounts, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
215
+ if (!best) break;
216
+ try {
217
+ await performSwap(best);
218
+ } catch (e) {
219
+ if (e instanceof InvalidGrantError) continue;
220
+ throw e;
221
+ }
222
+ return { swapped: true, account: best, reason: "swapped" };
223
+ }
195
224
 
196
225
  const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
197
226
  const seat = seatOf(loadAccounts());
@@ -315,6 +344,7 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
315
344
 
316
345
  export const CHECK_DELAY_FLOOR_MS = 60_000;
317
346
  const CHECK_DELAY_UNKNOWN_MS = 180_000;
347
+ const STAGE_CEILING_MS = [MAX_CHECK_DELAY_MS, 180_000, 120_000];
318
348
 
319
349
  export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
320
350
  const { cfg, org, now, decision } = input;
@@ -323,21 +353,24 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
323
353
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
324
354
  const u = loadUsage();
325
355
  if (!org || !u || !usageFresh(u, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
326
- const bars = effectiveBars(cfg);
356
+ const mu = loadModelUsage();
357
+ const muSame = mu && mu.org === org ? mu : null;
358
+ const families = gatedFamilies(u.model, cfg.policy.switchModels);
359
+ const bars = effectiveBars(cfg, { accounts: overlayLive(loadAccounts().accounts, u, muSame, org), now, switchFamilies: families });
327
360
  const heads = [
328
361
  bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
329
362
  bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
330
363
  ];
331
- const mu = loadModelUsage();
332
- const muSame = mu && mu.org === org ? mu : null;
333
364
  let capMissing = false;
334
365
  const capFresh = muSame != null && now - (muSame.sampledAt ?? muSame.ts) <= cfg.policy.usagePollTtlMs;
335
- for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
366
+ for (const family of families) {
336
367
  const cap = muSame && capFresh ? capForFamily(muSame, family, now) : undefined;
337
368
  if (cap && muSame) heads.push(bars.weekly - liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now }));
338
369
  else capMissing = true;
339
370
  }
340
371
  const headroom = Math.min(...heads);
341
372
  const banded = headroom >= 40 ? MAX_CHECK_DELAY_MS : headroom >= 20 ? 180_000 : headroom >= 8 ? 120_000 : CHECK_DELAY_FLOOR_MS;
342
- return capMissing ? Math.min(banded, 120_000) : banded;
373
+ const stage = sessionLadder(cfg).indexOf(bars.session);
374
+ const staged = Math.min(banded, STAGE_CEILING_MS[stage] ?? CHECK_DELAY_FLOOR_MS);
375
+ return capMissing ? Math.min(staged, 120_000) : staged;
343
376
  }
package/src/lib/picker.ts CHANGED
@@ -3,13 +3,26 @@ import { z } from "zod";
3
3
  import { familyTokens } from "./usage.ts";
4
4
  import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type UsageWindow } from "./types.ts";
5
5
 
6
- export function effectiveBars(cfg: Config): Thresholds {
6
+ export function sessionLadder(cfg: Config): number[] {
7
+ return cfg.thresholds.session.map((rung) => rung - cfg.policy.projectionMargin);
8
+ }
9
+
10
+ export function terminalBars(cfg: Config): Thresholds {
7
11
  return {
8
- session: cfg.thresholds.session - cfg.policy.projectionMargin,
12
+ session: Math.max(...sessionLadder(cfg)),
9
13
  weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
10
14
  };
11
15
  }
12
16
 
17
+ export function effectiveBars(cfg: Config, pool: { accounts: Account[]; now: number; switchFamilies: string[] }): Thresholds {
18
+ const top = terminalBars(cfg);
19
+ const holdsAt = (session: number) =>
20
+ pool.accounts.some(
21
+ (a) => !a.needsReauth && !isExhausted(a, { now: pool.now, thresholds: { session, weekly: top.weekly }, currentAccountUuid: null, switchFamilies: pool.switchFamilies }),
22
+ );
23
+ return { session: sessionLadder(cfg).find(holdsAt) ?? top.session, weekly: top.weekly };
24
+ }
25
+
13
26
  export function hardBars(cfg: Config): Thresholds {
14
27
  return {
15
28
  session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
package/src/lib/state.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  LastSwapSchema,
10
10
  ModelUsageStateSchema,
11
11
  NextCheckSchema,
12
+ SessionLadderSchema,
12
13
  UsageStateSchema,
13
14
  type AccountsIndex,
14
15
  type Config,
@@ -17,7 +18,7 @@ import {
17
18
  } from "./types.ts";
18
19
 
19
20
  const DEFAULT_CONFIG: Config = {
20
- thresholds: { session: 95, weekly: 98 },
21
+ thresholds: { session: [50, 80, 95], weekly: 98 },
21
22
  hardThresholds: { session: 100, weekly: 100 },
22
23
  claudeBin: "",
23
24
  codexBin: "",
@@ -28,7 +29,7 @@ const PercentSchema = z.number().min(0).max(100);
28
29
 
29
30
  export const ConfigFileSchema = z
30
31
  .object({
31
- thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
32
+ thresholds: z.object({ session: SessionLadderSchema, weekly: PercentSchema }).partial(),
32
33
  hardThresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
33
34
  claudeBin: z.string(),
34
35
  codexBin: z.string(),
package/src/lib/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { isEqual, uniq } from "es-toolkit";
1
2
  import { z } from "zod";
2
3
 
3
4
  const OAuthCredsSchema = z.looseObject({
@@ -99,9 +100,21 @@ export const ThresholdsSchema = z.object({
99
100
  });
100
101
  export type Thresholds = z.infer<typeof ThresholdsSchema>;
101
102
 
103
+ export const SessionLadderSchema = z
104
+ .array(z.number().min(0).max(100))
105
+ .min(1)
106
+ .refine((rungs) => isEqual(rungs, uniq(rungs).toSorted((a, b) => a - b)), {
107
+ message: "thresholds.session rungs must be strictly ascending",
108
+ });
109
+
110
+ export const ScreeningThresholdsSchema = z.object({
111
+ session: SessionLadderSchema,
112
+ weekly: z.number().min(0).max(100),
113
+ });
114
+
102
115
  export const ConfigSchema = z
103
116
  .object({
104
- thresholds: ThresholdsSchema,
117
+ thresholds: ScreeningThresholdsSchema,
105
118
  hardThresholds: ThresholdsSchema,
106
119
  claudeBin: z.string(),
107
120
  codexBin: z.string(),
@@ -113,11 +126,11 @@ export const ConfigSchema = z
113
126
  maxWaitMs: z.number().int().positive(),
114
127
  }),
115
128
  })
116
- .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
117
- message: "policy.projectionMargin must be strictly below both thresholds (effectiveBars would hit zero and every account would read as exhausted)",
129
+ .refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
130
+ message: "policy.projectionMargin must be strictly below every threshold (effectiveBars would hit zero and every account would read as exhausted)",
118
131
  })
119
- .refine((cfg) => cfg.hardThresholds.session >= cfg.thresholds.session && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
120
- message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars) for both windows",
132
+ .refine((cfg) => cfg.hardThresholds.session >= Math.max(...cfg.thresholds.session) && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
133
+ message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars, the top session rung) for both windows",
121
134
  });
122
135
  export type Config = z.infer<typeof ConfigSchema>;
123
136