tokenmaxxing 1.24.0 → 1.26.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 (the active rung of the 5h session ladder, **90** by default, 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).
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**90%** of the 5h session window 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
  >
@@ -10,9 +10,9 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
10
10
 
11
11
  ## 1. Why there is a thin supervisor (and why that's the whole trick)
12
12
 
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.
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 over its bar 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 screens on a ladder, a single rung at 90 by default; a configured ladder like 50 then 80 then 95 has each rung take over once every pooled account is past the one below (a 5h reset is cheap to sit out, so a multi-rung pool drains level by level), 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 its real limit.** The session window swaps at 90 (a 5h reset is cheap to sit out), 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
 
@@ -49,12 +49,12 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
49
49
  The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
50
50
 
51
51
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
52
- 1. Read `usage.json` and exit below the engagement floor unless a screening bar is crossed, then follow the greedy or hard path defined in the [switching policy](docs/content/docs/switching.mdx).
52
+ 1. Read `usage.json` and exit while the live account is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
53
53
  2. Take the pool lock, re-check state, select a candidate under the [switching policy](docs/content/docs/switching.mdx), and attempt bounded verification of stale usage before swapping the credential.
54
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
55
55
 
56
56
  ### 3.3 Depleted-pool pause (supervisor)
57
- The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
57
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop. A marker that fails to parse is never deleted and treated as absent. The supervisor stops the child, restores the terminal, and exits naming the marker path.
58
58
 
59
59
  ### 3.4 Swap sequence (under the lock)
60
60
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), NOT the `accounts.json` active label and NOT the organization (team seats share one). The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
@@ -79,24 +79,18 @@ 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 [switching policy](docs/content/docs/switching.mdx) defines engagement, screening bars, organization preference, incumbent hysteresis and the wall squeeze.
82
+ The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account with the highest pace pressure; nothing usable, wait for the soonest reset.
83
83
 
84
- - Automatic lateral moves stay within the incumbent organization, and a crossed bar can trigger a cross-organization handoff.
85
- - Organization preference requires measured headroom in every applicable window and is an optimization heuristic, not a cache-hit guarantee.
86
- - Manual switching retains pure pace-pressure ranking.
87
- - Successful candidate probes persist their results and restart selection, preserving candidates that fail a screening bar but still clear the wall.
84
+ - Automatic and manual switching rank by pace pressure alone; organization membership is not an input.
85
+ - Successful candidate probes persist their results and restart selection.
88
86
  - Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
89
87
  - Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
90
88
  - Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
89
+ - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
91
90
 
92
- The [profile](docs/content/docs/switching-profile.mdx) distinguishes observed cache rewrites from pricing-weighted estimates and does not infer subscription quota savings.
91
+ The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
93
92
 
94
- The [wall policy](docs/content/docs/switching.mdx#the-wall) defines Layer 2 recovery and its conditional headroom preference, with pace-only selection under default bars.
95
-
96
- - Layer 2 is Claude-only because its running sessions can adopt a credential change.
97
- - Codex keeps its current account until the wall because a running sibling cannot adopt another account without restarting.
98
-
99
- **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. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
93
+ **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. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a switch.
100
94
 
101
95
  ---
102
96
 
package/README.md CHANGED
@@ -66,7 +66,7 @@ claude # use claude as always
66
66
  | `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
67
67
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
68
68
  | `tokenmaxxing auth [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one |
69
- | `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account greedily (no-op when the current one wins), a selector targets one |
69
+ | `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account (no-op when the current one wins), a selector targets one |
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
71
71
  | `tokenmaxxing ls` | list pooled accounts |
72
72
  | `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
@@ -80,19 +80,18 @@ claude # use claude as always
80
80
 
81
81
  ## How switching decides
82
82
 
83
- Switching engages (configurable) once the active account's 5-hour session window is **80% 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 (a single rung at **90** by default; with more rungs configured, each takes 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
+ One rule: while the active account is under its bars, nothing happens; once it is at or over a bar, the decision moves to the usable account **furthest behind its own weekly pace**. The session bar is **90%** of the 5-hour window and the weekly bar is **98%**. The bars also screen candidates on any of:
84
84
 
85
85
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
86
86
  - **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.
87
87
 
88
- 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 (90) because a 5-hour reset is cheap to sit out, and any lower rungs you add 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 (80) sits below both: weekly allowance is forfeited at each account's fixed reset, so once most of a session window is spent, quota is best burned on whichever account has the most at risk.
88
+ 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 account's real limit. The session bar sits lower (90) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
89
89
 
90
- Automatic selection prefers same-organization candidates with measured session and weekly headroom, then uses remaining weekly percentage divided by time to reset.
90
+ Selection ranks usable candidates by remaining weekly percentage divided by time to reset, highest first; organization membership is not an input, and manual `tokenmaxxing switch` uses the same ranking.
91
91
 
92
- - Lateral moves stay within the current organization, while a crossed bar or enforced limit may trigger a cross-organization handoff.
93
92
  - Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
94
93
  - Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
95
- - Manual `tokenmaxxing switch` retains pure pace-pressure selection.
94
+ - When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
96
95
 
97
96
  See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
98
97
 
@@ -102,10 +101,9 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
102
101
 
103
102
  ```json
104
103
  {
105
- "thresholds": { "session": [90], "weekly": 98 },
104
+ "thresholds": { "session": 90, "weekly": 98 },
106
105
  "policy": {
107
106
  "projectionMargin": 0,
108
- "greedySessionFloor": 80,
109
107
  "switchModels": ["fable"],
110
108
  "usagePollTtlMs": 90000,
111
109
  "checkIntervalMs": 60000
@@ -113,7 +111,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
113
111
  }
114
112
  ```
115
113
 
116
- `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` 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); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
114
+ `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `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; `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); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
117
115
 
118
116
  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`).
119
117
 
@@ -127,7 +125,7 @@ tokenmaxxing add --codex # log in another account, isolated - your primary lo
127
125
  codex # use codex as always
128
126
  ```
129
127
 
130
- Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same greedy pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
128
+ Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
131
129
 
132
130
  Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
133
131
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.24.0",
3
+ "version": "1.26.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",
@@ -7,7 +7,7 @@ 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 { terminalBars } from "../lib/picker.ts";
10
+ import { thresholdBars } from "../lib/picker.ts";
11
11
  import { c, emitError, emitJson } from "./render.ts";
12
12
 
13
13
  export async function cmdCodexSwitch(sel?: string, json = false): Promise<number> {
@@ -23,7 +23,7 @@ export async function cmdCodexSwitch(sel?: string, json = false): Promise<number
23
23
  };
24
24
  const deadGrantMessage = (label: string) => `${label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``;
25
25
  const cfg = loadConfig();
26
- const bars = terminalBars(cfg);
26
+ const bars = thresholdBars(cfg);
27
27
  const now = Date.now();
28
28
 
29
29
  return withLock(codexPaths.lockFile, async () => {
package/src/cli/config.ts CHANGED
@@ -10,13 +10,9 @@ import { c, emitError, emitJson } from "./render.ts";
10
10
  export const KNOWN_KEYS = [
11
11
  "thresholds.session",
12
12
  "thresholds.weekly",
13
- "hardThresholds.session",
14
- "hardThresholds.weekly",
15
13
  "claudeBin",
16
14
  "codexBin",
17
15
  "policy.projectionMargin",
18
- "policy.greedySessionFloor",
19
- "policy.greedySwapMargin",
20
16
  "policy.switchModels",
21
17
  "policy.usagePollTtlMs",
22
18
  "policy.maxWaitMs",
package/src/cli/status.ts CHANGED
@@ -5,7 +5,7 @@ import { readOAuthAccount } from "../lib/claudejson.ts";
5
5
  import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
6
6
  import { withLock } from "../lib/lock.ts";
7
7
  import { codexPaths, paths } from "../lib/paths.ts";
8
- import { earliestReset, effectiveBars, isExhausted, nextWeeklyReset, terminalBars } from "../lib/picker.ts";
8
+ import { earliestReset, isExhausted, nextWeeklyReset, thresholdBars } from "../lib/picker.ts";
9
9
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
10
10
  import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
11
11
  import { isCodexExhausted } from "../lib/codexpick.ts";
@@ -62,7 +62,7 @@ type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
62
62
  const StatusReportSchema = z.object({
63
63
  now: z.number(),
64
64
  claude: z.object({
65
- thresholds: z.object({ session: z.array(z.number()), weekly: z.number() }),
65
+ thresholds: ThresholdsSchema,
66
66
  bars: ThresholdsSchema,
67
67
  projectionMargin: z.number(),
68
68
  accounts: z.array(ClaudeStatusAccountSchema),
@@ -171,7 +171,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
171
171
  }
172
172
 
173
173
  const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
174
- const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
174
+ const bars = thresholdBars(cfg);
175
175
  const liveAccount = readOAuthAccount()?.accountUuid ?? null;
176
176
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
177
177
  const accounts = ordered.map((a): ClaudeStatusAccount => {
@@ -207,7 +207,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
207
207
 
208
208
  async function collectCodex(input: { cfg: Config; now: number }): Promise<StatusReport["codex"]> {
209
209
  const { cfg, now } = input;
210
- const bars = terminalBars(cfg);
210
+ const bars = thresholdBars(cfg);
211
211
  let index = loadCodexAccounts();
212
212
  if (index.accounts.length === 0) return { bars, accounts: [] };
213
213
 
@@ -410,7 +410,7 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
410
410
  return;
411
411
  }
412
412
 
413
- console.log(c.dim(`thresholds 5h ${claude.thresholds.session.join("/")}% (at ${claude.bars.session + claude.projectionMargin}%) weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
413
+ console.log(c.dim(`thresholds 5h ${claude.thresholds.session}% weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
414
414
  console.log();
415
415
  renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
416
416
  }
package/src/cli/switch.ts CHANGED
@@ -3,7 +3,7 @@ import { paths } from "../lib/paths.ts";
3
3
  import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
5
  import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
6
- import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
6
+ import { currentWins, pickBest, pickEarliestReset, thresholdBars, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
9
9
  import { findAccount } from "./rename.ts";
@@ -62,12 +62,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
62
62
  }
63
63
 
64
64
  const switchFamilies = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
65
- const everyoneIn = (accounts: Account[]): PickCtx => ({
66
- now,
67
- thresholds: effectiveBars(cfg, { accounts, now, switchFamilies }),
68
- currentAccountUuid: null,
69
- switchFamilies,
70
- });
65
+ const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentAccountUuid: null, switchFamilies };
71
66
  const rejected = new Set<string>();
72
67
  while (true) {
73
68
  const cur = loadAccounts();
@@ -76,7 +71,6 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
76
71
  (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
77
72
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
78
73
  null;
79
- const everyone = everyoneIn(cur.accounts);
80
74
  if (active != null && currentWins(active, pool, everyone)) {
81
75
  if (drifted) return swapTo(active, "drift-reconciled");
82
76
  const expiry = weeklyExpiry(active, now);
@@ -114,7 +108,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
114
108
  while (true) {
115
109
  const fresh = loadAccounts();
116
110
  const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
117
- const earliest = pickEarliestReset(pool, everyoneIn(fresh.accounts));
111
+ const earliest = pickEarliestReset(pool, everyone);
118
112
  const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
119
113
  if (!earliest) {
120
114
  if (reauth.length > 0) {
@@ -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 { terminalBars } from "../lib/picker.ts";
13
+ import { thresholdBars } 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 = terminalBars(loadConfig());
54
+ const bars = thresholdBars(loadConfig());
55
55
  const index = loadCodexAccounts();
56
56
  const unusable = (account: CodexAccount): boolean =>
57
57
  account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
@@ -21,13 +21,14 @@ const NONINTERACTIVE_SUBCMDS = new Set([
21
21
 
22
22
  const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
23
23
 
24
- function consumableCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> | null {
24
+ function readCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> {
25
25
  try {
26
26
  return CodexRespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
27
27
  } catch (e) {
28
- rmSync(marker, { force: true });
29
28
  log("codexsupervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
30
- return null;
29
+ throw new Error(
30
+ `${marker} is corrupt (unparsable JSON or off-schema) - auth.json may already hold another account, so the codex session was stopped instead of resumed on stale credentials; inspect and remove the marker, then run \`codex resume\``,
31
+ );
31
32
  }
32
33
  }
33
34
 
@@ -126,7 +127,10 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
126
127
  let done = false;
127
128
  const markerWatch = (async () => {
128
129
  while (!done) {
129
- if (existsSync(marker) && consumableCodexMarker(marker) != null) return true;
130
+ if (existsSync(marker)) {
131
+ readCodexMarker(marker);
132
+ return true;
133
+ }
130
134
  await Bun.sleep(150);
131
135
  }
132
136
  return false;
@@ -135,17 +139,20 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
135
139
  done = true;
136
140
  return "exit";
137
141
  });
138
- const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
139
-
140
- if (winner === "marker") {
142
+ try {
143
+ const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
144
+ if (winner === "marker") child.kill();
145
+ } catch (e) {
141
146
  child.kill();
147
+ throw e;
148
+ } finally {
149
+ await child.exited;
150
+ done = true;
151
+ await markerWatch.catch(() => false);
152
+ restoreTermios(savedTermios);
142
153
  }
143
- await child.exited;
144
- done = true;
145
- await markerWatch.catch(() => false);
146
- restoreTermios(savedTermios);
147
154
 
148
- const payload = existsSync(marker) ? consumableCodexMarker(marker) : null;
155
+ const payload = existsSync(marker) ? readCodexMarker(marker) : null;
149
156
  if (payload) {
150
157
  rmSync(marker, { force: true });
151
158
  respawns++;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { maxBy } from "es-toolkit";
4
4
  import { z } from "zod";
5
5
  import { paths } from "../lib/paths.ts";
@@ -152,9 +152,10 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
152
152
  try {
153
153
  m = RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
154
154
  } catch (e) {
155
- rmSync(marker, { force: true });
156
155
  log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
157
- return null;
156
+ throw new Error(
157
+ `${marker} is corrupt (unparsable JSON or off-schema) - the session was stopped instead of guessing whether the pool is depleted; inspect the marker, then run \`claude --resume ${basename(marker)}\` (a fresh launch clears it)`,
158
+ );
158
159
  }
159
160
  if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
160
161
  rmSync(marker, { force: true });
@@ -270,15 +271,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
270
271
  return false;
271
272
  })();
272
273
  const exited = child.exited.then(() => { done = true; return "exit" as const; });
273
- const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
274
-
275
- if (winner === "marker") {
274
+ try {
275
+ const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
276
+ if (winner === "marker") child.kill();
277
+ } catch (e) {
276
278
  child.kill();
279
+ throw e;
280
+ } finally {
281
+ await child.exited;
282
+ done = true;
283
+ await markerWatch.catch(() => {});
284
+ restoreTermios(savedTermios);
277
285
  }
278
- await child.exited;
279
- done = true;
280
- await markerWatch.catch(() => {});
281
- restoreTermios(savedTermios);
282
286
 
283
287
  const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
284
288
  if (m) {
@@ -6,14 +6,14 @@ import { writeFileAtomic } from "./atomic.ts";
6
6
  import { codexPaths } from "./paths.ts";
7
7
  import { loadConfig } from "./state.ts";
8
8
  import { loadCodexAccounts, loadCodexLastSwapAt, saveCodexAccounts } from "./codexstate.ts";
9
- import { codexCurrentWins, isCodexEngaged, isCodexExhausted, pickBestCodex } from "./codexpick.ts";
9
+ import { isCodexExhausted, pickBestCodex } from "./codexpick.ts";
10
10
  import { performCodexSwap } from "./codexswap.ts";
11
11
  import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
12
12
  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 { terminalBars } from "./picker.ts";
16
+ import { thresholdBars } 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 = terminalBars(cfg);
103
+ const bars = thresholdBars(cfg);
104
104
 
105
105
  return withLock(codexPaths.lockFile, async () => {
106
106
  let index = loadCodexAccounts();
@@ -131,34 +131,9 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
131
131
  const active = index.accounts.find((account) => account.accountId === activeId) ?? null;
132
132
  if (!active) return { swapped: false, account: null, reason: "no-active-account" };
133
133
 
134
- const engaged =
135
- active.needsReauth === true ||
136
- isCodexEngaged({ account: active, floor: cfg.policy.greedySessionFloor, now }) ||
137
- isCodexExhausted({ account: active, thresholds: bars, now });
134
+ const engaged = active.needsReauth === true || isCodexExhausted({ account: active, thresholds: bars, now });
138
135
  if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
139
136
 
140
- if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
141
- while (true) {
142
- const current = loadCodexAccounts();
143
- const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: activeId });
144
- const cur = candidates.find((account) => account.accountId === activeId) ?? null;
145
- if (codexCurrentWins({ active: cur, accounts: candidates, thresholds: bars, now })) {
146
- return { swapped: false, account: null, reason: "current-best" };
147
- }
148
- const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: activeId });
149
- if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
150
- try {
151
- await performCodexSwap({ target: best });
152
- } catch (e) {
153
- if (e instanceof CodexInvalidGrantError) continue;
154
- throw e;
155
- }
156
- log("codexdecide.greedy_swap", { account: best.accountId.slice(0, 8) });
157
- postSwapResweep({ liveAccountId: best.accountId, bars, now });
158
- return { swapped: true, account: best, reason: "swapped" };
159
- }
160
- }
161
-
162
137
  const tried = new Set<string>();
163
138
  while (true) {
164
139
  const current = loadCodexAccounts();
@@ -174,7 +149,7 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
174
149
  if (e instanceof CodexInvalidGrantError) continue;
175
150
  throw e;
176
151
  }
177
- log("codexdecide.hard_swap", { account: best.accountId.slice(0, 8) });
152
+ log("codexdecide.swap", { account: best.accountId.slice(0, 8) });
178
153
  postSwapResweep({ liveAccountId: best.accountId, bars, now });
179
154
  return { swapped: true, account: best, reason: "swapped" };
180
155
  }
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { http, safeErrorDetail } from "./http.ts";
2
+ import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
3
3
  import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
4
4
 
5
5
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
@@ -26,12 +26,12 @@ const CodexRefreshResponseSchema = z.looseObject({
26
26
  refresh_token: z.string().optional(),
27
27
  });
28
28
 
29
- const DEAD_GRANT_MARKERS = [
29
+ const DEAD_GRANT_CODES = new Set([
30
30
  "invalid_grant",
31
31
  "refresh_token_reused",
32
32
  "refresh_token_expired",
33
33
  "refresh_token_invalidated",
34
- ];
34
+ ]);
35
35
 
36
36
  export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: number }): Promise<CodexAuthJson> {
37
37
  const { auth, now = Date.now() } = input;
@@ -53,7 +53,8 @@ export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: numbe
53
53
  const text = await res.text();
54
54
  if (!res.ok) {
55
55
  const detail = safeErrorDetail({ text });
56
- if (DEAD_GRANT_MARKERS.some((marker) => text.includes(marker))) {
56
+ const code = oauthErrorCode({ text })?.toLowerCase();
57
+ if (code != null && DEAD_GRANT_CODES.has(code)) {
57
58
  throw new CodexInvalidGrantError(detail);
58
59
  }
59
60
  throw new CodexRefreshFailedError(`HTTP ${res.status}: ${detail}`);
@@ -78,9 +78,3 @@ export function codexCurrentWins(input: {
78
78
  if (best == null || best.accountId === active.accountId) return true;
79
79
  return codexPacePressure({ account: best, now }) <= codexPacePressure({ account: active, now }) * CODEX_SWAP_IMPROVEMENT;
80
80
  }
81
-
82
- export function isCodexEngaged(input: { account: CodexAccount; floor: number; now: number }): boolean {
83
- const { account, floor, now } = input;
84
- const sampledAt = account.lastUsageAt ?? null;
85
- return allWindows(account).some((window) => liveUsed({ window, now, sampledAt }) >= floor);
86
- }
package/src/lib/decide.ts CHANGED
@@ -4,9 +4,9 @@ import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
5
  import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
7
+ import { isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
8
8
  import { probeParkedUsage } from "./sample.ts";
9
- import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, usableAt, type PickCtx } from "./picker.ts";
9
+ import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt, type PickCtx } from "./picker.ts";
10
10
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
11
  import { log } from "./log.ts";
12
12
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -38,15 +38,6 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
38
38
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
39
39
  }
40
40
 
41
- function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
42
- return accounts.map((a) => {
43
- if (account == null || a.accountUuid !== account) return a;
44
- const live = u && u.account === account ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
45
- const perModel = mu && mu.account === account && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
46
- return { ...a, ...live, ...perModel };
47
- });
48
- }
49
-
50
41
  function isOver(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
51
42
  if (!u || !account || u.account !== account) return false;
52
43
  if (
@@ -66,11 +57,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
66
57
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
67
58
  }
68
59
 
69
- function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
70
- if (!u || !account || u.account !== account) return false;
71
- return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
72
- }
73
-
74
60
  const SnapshotsSchema = z.object({
75
61
  u: UsageStateSchema.nullable(),
76
62
  mu: ModelUsageStateSchema.nullable(),
@@ -138,17 +124,13 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
138
124
  }
139
125
 
140
126
  const cfg = loadConfig();
127
+ const bars = thresholdBars(cfg);
141
128
 
142
129
  const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
143
130
  const pool = loadAccounts();
144
131
  const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.accountUuid === activeAccount));
145
- const bars0 = effectiveBars(cfg, {
146
- accounts: overlayLive(pool.accounts, usage, mu, activeAccount),
147
- now,
148
- switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
149
- });
150
132
 
151
- if (!enforced0 && !isEngaged(usage, mu, activeAccount, bars0, cfg, now)) {
133
+ if (!enforced0 && !isOver(usage, mu, activeAccount, bars, cfg, now)) {
152
134
  const measured = usage != null && activeAccount != null && usage.account === activeAccount;
153
135
  if (!measured) {
154
136
  const replay = depletedReplay(now);
@@ -187,10 +169,8 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
187
169
 
188
170
  const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
189
171
  const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
190
- const barsOf = (accounts: Account[]): Thresholds => effectiveBars(cfg, { accounts, now, switchFamilies });
191
- const bars = barsOf(idx.accounts);
192
172
 
193
- if (!enforced2 && !isEngaged(u2, mu2, account2, bars, cfg, now)) {
173
+ if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) {
194
174
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
195
175
  }
196
176
 
@@ -246,40 +226,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
246
226
  return "rerank";
247
227
  };
248
228
 
249
- const greedy = async (holdMargin: number): Promise<SwapDecision> => {
250
- while (true) {
251
- const cur = loadAccounts();
252
- const active = seatOf(cur);
253
- const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, currentOrganizationUuid: active?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies, holdMargin };
254
- const seatOrg = active != null && !active.needsReauth && !isExhausted(active, ctxAll) ? active.organizationUuid : null;
255
- const pool = usable(cur.accounts).filter((a) => seatOrg == null || a.organizationUuid === seatOrg);
256
- if (currentWins(active, pool, ctxAll)) {
257
- return { swapped: false, account: null, reason: "current-best" };
258
- }
259
- const ctx = { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null };
260
- const best = pickBest(pool, ctx);
261
- if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
262
- if ((await verified(best, ctx)) !== "go") continue;
263
- try {
264
- await performSwap(best);
265
- } catch (e) {
266
- skipOrThrow(e, best);
267
- continue;
268
- }
269
- log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
270
- return { swapped: true, account: best, reason: "swapped" };
271
- }
272
- };
273
- if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
274
-
275
229
  while (true) {
276
230
  const cur = loadAccounts();
277
231
  const seat = seatOf(cur);
278
- const pool = usable(cur.accounts);
279
- const thresholds = barsOf(cur.accounts);
280
- if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
281
- const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
282
- const best = pickBest(pool, ctx);
232
+ const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
233
+ const best = pickBest(usable(cur.accounts), ctx);
283
234
  if (!best) break;
284
235
  if ((await verified(best, ctx)) !== "go") continue;
285
236
  try {
@@ -288,26 +239,14 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
288
239
  skipOrThrow(e, best);
289
240
  continue;
290
241
  }
291
- log("decide.hard_swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
242
+ log("decide.swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
292
243
  return { swapped: true, account: best, reason: "swapped" };
293
244
  }
294
245
 
295
- const hardCtx = { now, thresholds: hardBars(cfg), currentAccountUuid: null, switchFamilies };
296
- const seat = seatOf(loadAccounts());
297
- if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, hardCtx)) {
298
- log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
299
- return { swapped: false, account: null, reason: "last-drop-hold" };
300
- }
301
- const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor }, rejected, verified);
302
- if (squeezed) {
303
- log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
304
- return { swapped: true, account: squeezed, reason: "last-drop-swap" };
305
- }
306
-
307
246
  while (true) {
308
247
  const fresh = loadAccounts();
309
248
  const current = seatOf(fresh);
310
- const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
249
+ const ctx = { now, thresholds: bars, currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
311
250
  const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
312
251
  const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
313
252
  const other = pickEarliestReset(usable(fresh.accounts), ctx);
package/src/lib/http.ts CHANGED
@@ -7,7 +7,7 @@ const OAuthErrorBodySchema = z.looseObject({
7
7
  });
8
8
 
9
9
  const NestedErrorBodySchema = z.looseObject({
10
- error: z.looseObject({ type: z.string().optional(), message: z.string().optional() }),
10
+ error: z.looseObject({ code: z.string().nullish(), type: z.string().optional(), message: z.string().optional() }),
11
11
  request_id: z.string().nullish(),
12
12
  });
13
13
 
@@ -31,7 +31,8 @@ function parseErrorBody(text: string): ErrorDetail | null {
31
31
  const nested = NestedErrorBodySchema.safeParse(json);
32
32
  if (nested.success) {
33
33
  const requestId = nested.data.request_id ? ` (${nested.data.request_id})` : "";
34
- return { code: nested.data.error.type ?? null, fields: [nested.data.error.type ?? "", (nested.data.error.message ?? "") + requestId] };
34
+ const code = nested.data.error.code ?? nested.data.error.type ?? null;
35
+ return { code, fields: [code ?? "", (nested.data.error.message ?? "") + requestId] };
35
36
  }
36
37
  const plain = PlainErrorBodySchema.safeParse(json);
37
38
  if (plain.success) return { code: null, fields: [plain.data.detail ?? "", plain.data.message ?? ""] };
package/src/lib/picker.ts CHANGED
@@ -3,41 +3,18 @@ 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 sessionLadder(cfg: Config): number[] {
7
- return cfg.thresholds.session.map((rung) => rung - cfg.policy.projectionMargin);
8
- }
9
-
10
- export function terminalBars(cfg: Config): Thresholds {
6
+ export function thresholdBars(cfg: Config): Thresholds {
11
7
  return {
12
- session: Math.max(...sessionLadder(cfg)),
8
+ session: cfg.thresholds.session - cfg.policy.projectionMargin,
13
9
  weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
14
10
  };
15
11
  }
16
12
 
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
-
26
- export function hardBars(cfg: Config): Thresholds {
27
- return {
28
- session: cfg.hardThresholds.session - cfg.policy.projectionMargin,
29
- weekly: cfg.hardThresholds.weekly - cfg.policy.projectionMargin,
30
- };
31
- }
32
-
33
13
  const PickCtxSchema = z.object({
34
14
  now: z.number(),
35
15
  thresholds: ThresholdsSchema,
36
16
  currentAccountUuid: z.string().nullable(),
37
- currentOrganizationUuid: z.string().nullable().optional(),
38
- orgAffinityFloor: z.number().optional(),
39
17
  switchFamilies: z.array(z.string()),
40
- holdMargin: z.number().min(0).optional(),
41
18
  });
42
19
  export type PickCtx = z.infer<typeof PickCtxSchema>;
43
20
 
@@ -96,25 +73,7 @@ export function pacePressure(a: Account, now: number): number {
96
73
  return Math.max(0, 100 - used) / Math.max(1, reset - now);
97
74
  }
98
75
 
99
- function sessionUsed(a: Account, now: number): number {
100
- const w = a.lastUsage?.fiveHour;
101
- if (w == null) return 101;
102
- if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
103
- if (a.lastUsageAt != null && now >= a.lastUsageAt + FIVE_HOURS_MS) return 0;
104
- return w.usedPercentage;
105
- }
106
-
107
- function orgTier(a: Account, ctx: PickCtx): number {
108
- if (ctx.currentOrganizationUuid == null || a.organizationUuid !== ctx.currentOrganizationUuid) return 1;
109
- if (ctx.orgAffinityFloor != null && sessionUsed(a, ctx.now) >= ctx.orgAffinityFloor) return 1;
110
- if (!a.lastUsage || ctx.switchFamilies.some((family) => gatedPerModelWindows(a, [family]).length === 0)) return 1;
111
- const headroom = Math.max(1, ctx.thresholds.session - (ctx.orgAffinityFloor ?? ctx.thresholds.session));
112
- if (isExhausted(a, { ...ctx, thresholds: { session: ctx.thresholds.session - headroom, weekly: ctx.thresholds.weekly - headroom } })) return 1;
113
- return 0;
114
- }
115
-
116
76
  const swapPreference = (ctx: PickCtx) => [
117
- (a: Account) => orgTier(a, ctx),
118
77
  (a: Account) => -pacePressure(a, ctx.now),
119
78
  (a: Account) => weeklyExpiry(a, ctx.now),
120
79
  (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
@@ -131,9 +90,6 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
131
90
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
132
91
  const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
133
92
  if (best == null || best.accountUuid === active.accountUuid) return true;
134
- const margin = ctx.holdMargin ?? 0;
135
- const bestPace = pacePressure(best, ctx.now);
136
- if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
137
93
  return swapPreference(ctx).every((k) => k(active) === k(best));
138
94
  }
139
95
 
package/src/lib/state.ts CHANGED
@@ -8,7 +8,6 @@ import {
8
8
  ConfigSchema,
9
9
  LastSwapSchema,
10
10
  ModelUsageStateSchema,
11
- SessionLadderSchema,
12
11
  UsageStateSchema,
13
12
  type AccountsIndex,
14
13
  type Config,
@@ -17,14 +16,11 @@ import {
17
16
  } from "./types.ts";
18
17
 
19
18
  const DEFAULT_CONFIG: Config = {
20
- thresholds: { session: [90], weekly: 98 },
21
- hardThresholds: { session: 100, weekly: 100 },
19
+ thresholds: { session: 90, weekly: 98 },
22
20
  claudeBin: "",
23
21
  codexBin: "",
24
22
  policy: {
25
23
  projectionMargin: 0,
26
- greedySessionFloor: 80,
27
- greedySwapMargin: 0.15,
28
24
  switchModels: ["fable"],
29
25
  usagePollTtlMs: 90_000,
30
26
  maxWaitMs: 3_600_000,
@@ -36,15 +32,12 @@ const PercentSchema = z.number().min(0).max(100);
36
32
 
37
33
  export const ConfigFileSchema = z
38
34
  .object({
39
- thresholds: z.object({ session: SessionLadderSchema, weekly: PercentSchema }).partial(),
40
- hardThresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
35
+ thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
41
36
  claudeBin: z.string(),
42
37
  codexBin: z.string(),
43
38
  policy: z
44
39
  .object({
45
40
  projectionMargin: PercentSchema,
46
- greedySessionFloor: PercentSchema,
47
- greedySwapMargin: z.number().min(0).max(1),
48
41
  switchModels: z.array(z.string()),
49
42
  usagePollTtlMs: z.number().int().positive(),
50
43
  maxWaitMs: z.number().int().positive(),
@@ -64,18 +57,13 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
64
57
  const cfg: Config = {
65
58
  ...DEFAULT_CONFIG,
66
59
  thresholds: { ...DEFAULT_CONFIG.thresholds },
67
- hardThresholds: { ...DEFAULT_CONFIG.hardThresholds },
68
60
  policy: { ...DEFAULT_CONFIG.policy },
69
61
  };
70
62
  cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
71
63
  cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
72
- cfg.hardThresholds.session = p.hardThresholds?.session ?? cfg.hardThresholds.session;
73
- cfg.hardThresholds.weekly = p.hardThresholds?.weekly ?? cfg.hardThresholds.weekly;
74
64
  cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
75
65
  cfg.codexBin = p.codexBin ?? cfg.codexBin;
76
66
  cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
77
- cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
78
- cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
79
67
  cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
80
68
  cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
81
69
  cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
@@ -104,7 +92,7 @@ export function loadConfig(): Config {
104
92
  }
105
93
  const parsed = ConfigFileSchema.safeParse(raw);
106
94
  if (!parsed.success) {
107
- const fields = parsed.error.issues.map((issue) => issue.path.join(".")).join(", ");
95
+ const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
108
96
  throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
109
97
  }
110
98
  fileData = parsed.data;
package/src/lib/swap.ts CHANGED
@@ -5,7 +5,6 @@ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenI
5
5
  import { swapOAuthAccount } from "./claudejson.ts";
6
6
  import { withClaudeRefreshLock } from "./claudelock.ts";
7
7
  import { log } from "./log.ts";
8
- import { pickBest, type PickCtx } from "./picker.ts";
9
8
  import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
9
 
11
10
  function parseBlob(raw: string) {
@@ -198,34 +197,3 @@ export async function keepRotatedPair(input: {
198
197
  export function isSkippableSwapError(e: unknown): boolean {
199
198
  return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
200
199
  }
201
-
202
- export async function chooseAndSwap(
203
- ctx: PickCtx,
204
- exclude: ReadonlySet<string> = new Set(),
205
- verify?: (candidate: Account, ctx: PickCtx) => Promise<VerifyVerdict>,
206
- ): Promise<Account | null> {
207
- const tried = new Set<string>(exclude);
208
- while (true) {
209
- const idx = loadAccounts();
210
- const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
211
- const best = pickBest(candidates, ctx);
212
- if (!best) return null;
213
- if (verify) {
214
- const verdict = await verify(best, ctx);
215
- if (verdict === "rerank") continue;
216
- if (verdict === "skip") {
217
- tried.add(best.accountUuid);
218
- continue;
219
- }
220
- }
221
- tried.add(best.accountUuid);
222
- try {
223
- await performSwap(best);
224
- return best;
225
- } catch (e) {
226
- if (isSkippableSwapError(e)) continue;
227
- throw e;
228
- }
229
- }
230
- }
231
-
package/src/lib/types.ts CHANGED
@@ -1,4 +1,3 @@
1
- import { isEqual, uniq } from "es-toolkit";
2
1
  import { z } from "zod";
3
2
 
4
3
  const OAuthCredsSchema = z.looseObject({
@@ -99,39 +98,21 @@ export const ThresholdsSchema = z.object({
99
98
  });
100
99
  export type Thresholds = z.infer<typeof ThresholdsSchema>;
101
100
 
102
- export const SessionLadderSchema = z
103
- .array(z.number().min(0).max(100))
104
- .min(1)
105
- .refine((rungs) => isEqual(rungs, uniq(rungs).toSorted((a, b) => a - b)), {
106
- message: "thresholds.session rungs must be strictly ascending",
107
- });
108
-
109
- export const ScreeningThresholdsSchema = z.object({
110
- session: SessionLadderSchema,
111
- weekly: z.number().min(0).max(100),
112
- });
113
-
114
101
  export const ConfigSchema = z
115
102
  .object({
116
- thresholds: ScreeningThresholdsSchema,
117
- hardThresholds: ThresholdsSchema,
103
+ thresholds: ThresholdsSchema,
118
104
  claudeBin: z.string(),
119
105
  codexBin: z.string(),
120
106
  policy: z.object({
121
107
  projectionMargin: z.number().min(0).max(100),
122
- greedySessionFloor: z.number().min(0).max(100),
123
- greedySwapMargin: z.number().min(0).max(1),
124
108
  switchModels: z.array(z.string()),
125
109
  usagePollTtlMs: z.number().int().positive(),
126
110
  maxWaitMs: z.number().int().positive(),
127
111
  checkIntervalMs: z.number().int().min(10_000),
128
112
  }),
129
113
  })
130
- .refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
131
- message: "policy.projectionMargin must be strictly below every threshold (effectiveBars would hit zero and every account would read as exhausted)",
132
- })
133
- .refine((cfg) => cfg.hardThresholds.session >= Math.max(...cfg.thresholds.session) && cfg.hardThresholds.weekly >= cfg.thresholds.weekly, {
134
- message: "hardThresholds (the Layer 2 wall) must be at or above thresholds (the Layer 1 screening bars, the top session rung) for both windows",
114
+ .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
115
+ message: "policy.projectionMargin must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
135
116
  });
136
117
  export type Config = z.infer<typeof ConfigSchema>;
137
118