tokenmaxxing 1.19.1 → 1.20.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
@@ -49,8 +49,8 @@ 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`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default 80% of the 5h window) unless a screening bar is already crossed. Engaged-but-under-every-bar runs the GREEDY convergence: stay when the current account wins or ties, else swap onto the strictly better account. A crossed bar forces the hard path (metered per live `accountUuid`; team seats share one organization, so the organization never identifies a seat).
53
- 2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
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).
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)
@@ -79,9 +79,22 @@ 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 >= 80%` (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 automatic greedy path adds **incumbent hysteresis** (`policy.greedySwapMargin`, default 0.15): the seat holds unless a parked account's pace pressure beats it by more than that fraction, so near-tied accounts stop trading the seat back and forth and busting the per-org prompt cache (each such swap forces a full uncached re-read next turn). The margin only gates lateral under-bar swaps, never a crossed bar or the wall; bare `tokenmaxxing switch` sets it to zero and still converges on the strictly best account. The **Layer 1 screening bars** - `five_hour >= <active rung>` (the 5h ladder `thresholds.session`, default `[90]`, a single rung: 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.
82
+ The [switching policy](docs/content/docs/switching.mdx) defines engagement, screening bars, organization preference, incumbent hysteresis and the wall squeeze.
83
83
 
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).
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.
88
+ - Verification attempts at most two candidates with a shared 12-second deadline per candidate for identity reads and CLI execution.
89
+ - Expiring access tokens are not refreshed for verification, and the isolated CLI receives no refresh grant.
90
+ - Failed or budget-limited verification uses the existing cached data, so stale-target handoffs remain possible.
91
+
92
+ The [profile](docs/content/docs/switching-profile.mdx) distinguishes observed cache rewrites from pricing-weighted estimates and does not infer subscription quota savings.
93
+
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.
85
98
 
86
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.
87
100
 
package/README.md CHANGED
@@ -87,7 +87,14 @@ Switching engages (configurable) once the active account's 5-hour session window
87
87
 
88
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.
89
89
 
90
- 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
+ Automatic selection prefers same-organization candidates with measured session and weekly headroom, then uses remaining weekly percentage divided by time to reset.
91
+
92
+ - Lateral moves stay within the current organization, while a crossed bar or enforced limit may trigger a cross-organization handoff.
93
+ - Candidate verification makes at most two bounded `/usage` attempts per evaluation and re-ranks successful samples before switching.
94
+ - Failed verification leaves cached figures in force, so it cannot guarantee a fresh target.
95
+ - Manual `tokenmaxxing switch` retains pure pace-pressure selection.
96
+
97
+ 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.
91
98
 
92
99
  ## Configuration
93
100
 
@@ -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.19.1",
4
+ "version": "1.20.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",
@@ -8,8 +8,13 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
8
8
  ## Vocabulary
9
9
 
10
10
  - **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
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. The automatic path adds incumbent hysteresis (`policy.greedySwapMargin`, default 0.15): hold the seat unless a parked account's pace pressure exceeds the current one's by more than that fraction, so near-tied accounts stop trading the seat and busting the per-org prompt cache. The margin gates lateral under-bar swaps only, never a crossed bar or the wall; bare `tokenmaxxing switch` uses margin 0 and always takes the strictly best account.
12
- - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
11
+ - **GREEDY path** permits lateral moves only within the incumbent organization and retains `greedySwapMargin` hysteresis.
12
+ - **HARD path** starts at a crossed screening bar and may cross organizations, with Layer 2 as its fallback.
13
+ - **Organization preference** requires measured headroom in session, aggregate weekly and gated model windows under the [policy](references/policy.md).
14
+ - **Verification** attempts at most two stale candidates per evaluation, with a 12-second deadline covering identity reads and the CLI for each candidate.
15
+ - **Probe results** are persisted and re-ranked, while screening exhaustion remains eligible for the wall squeeze and reset selection.
16
+ - **Unavailable verification** leaves cached figures in force, and verification never spends a parked refresh grant.
17
+ - **Manual switch** retains pure pace ranking with no organization preference or incumbent margin.
13
18
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
19
  - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: 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, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
15
20
  - **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
@@ -3,6 +3,8 @@
3
3
  - `docs/content/docs/switching.mdx`
4
4
  - `src/lib/decide.ts`, `src/lib/picker.ts`
5
5
  - `src/lib/bankedreset.ts`, `src/lib/codexreset.ts` (banked limit resets)
6
- - `.memory/switch-policy-pace-pressure.md`
6
+ - `.cursor/rules/switch-verification.md`
7
7
 
8
- Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick). Banked resets off by default (`policy.preferToUseBankedReset: []`); Claude claims once a week per account at the wall, Codex consumes one credit at the wall.
8
+ Automatic organization preference requires session usage below `greedySessionFloor` and more than `max(1, session bar - greedySessionFloor)` points below every applicable bar.
9
+
10
+ The exact policy, defaults, verification limits and manual-switch behavior live in `docs/content/docs/switching.mdx`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.19.1",
3
+ "version": "1.20.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/switch.ts CHANGED
@@ -7,6 +7,7 @@ import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry,
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
9
9
  import { findAccount } from "./rename.ts";
10
+ import { log } from "../lib/log.ts";
10
11
  import { c, emitError, emitJson, fmtReset } from "./render.ts";
11
12
  import type { Account } from "../lib/types.ts";
12
13
 
@@ -44,6 +45,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
44
45
  }
45
46
  throw e;
46
47
  }
48
+ log("switch.manual", { account: target.accountUuid.slice(0, 8), reason });
47
49
  emit(`${c.green("↻")} switched to ${c.bold(target.label)}`, { switched: true, account: target.label, reason, ...extra });
48
50
  return 0;
49
51
  };
@@ -69,12 +71,12 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
69
71
  const rejected = new Set<string>();
70
72
  while (true) {
71
73
  const cur = loadAccounts();
72
- const everyone = everyoneIn(cur.accounts);
73
74
  const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
74
75
  const active =
75
76
  (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
76
77
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
77
78
  null;
79
+ const everyone = everyoneIn(cur.accounts);
78
80
  if (active != null && currentWins(active, pool, everyone)) {
79
81
  if (drifted) return swapTo(active, "drift-reconciled");
80
82
  const expiry = weeklyExpiry(active, now);
@@ -104,6 +106,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
104
106
  }
105
107
  throw e;
106
108
  }
109
+ log("switch.manual", { account: best.accountUuid.slice(0, 8), reason: "best" });
107
110
  emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
108
111
  return 0;
109
112
  }
@@ -159,6 +162,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
159
162
  }
160
163
  throw e;
161
164
  }
165
+ log("switch.manual", { account: earliest.account.accountUuid.slice(0, 8), reason: "earliest-reset" });
162
166
  emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
163
167
  if (availableAt != null && !json) {
164
168
  console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
package/src/lib/decide.ts CHANGED
@@ -5,8 +5,9 @@ import { paths } from "./paths.ts";
5
5
  import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, measureFamilyWindow, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { bankedResetBelievedAvailable, claimBankedReset } from "./bankedreset.ts";
8
- import { chooseAndSwap, isSkippableSwapError, performSwap } from "./swap.ts";
9
- import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
8
+ import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
9
+ import { probeParkedUsage } from "./sample.ts";
10
+ import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
10
11
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
12
  import { log } from "./log.ts";
12
13
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -22,6 +23,8 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
22
23
 
23
24
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
24
25
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
26
+ const VERIFY_PROBE_BUDGET = 2;
27
+ const VERIFY_PROBE_KILL_MS = 12_000;
25
28
 
26
29
  function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
27
30
  const { window: w, windowMs, sampledAt, now } = input;
@@ -253,18 +256,60 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
253
256
  rejected.add(candidate.accountUuid);
254
257
  log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
255
258
  };
259
+ let probeBudget = VERIFY_PROBE_BUDGET;
260
+ const verified = async (candidate: Account, ctx: PickCtx): Promise<VerifyVerdict> => {
261
+ if (candidate.lastUsageAt != null && now - candidate.lastUsageAt <= cfg.policy.usagePollTtlMs) return "go";
262
+ const short = candidate.accountUuid.slice(0, 8);
263
+ const idx2 = loadAccounts();
264
+ const stored = idx2.accounts.find((a) => a.accountUuid === candidate.accountUuid);
265
+ if (!stored) return "go";
266
+ if (probeBudget <= 0) {
267
+ log("decide.verify_budget_spent", { account: short });
268
+ return "go";
269
+ }
270
+ probeBudget -= 1;
271
+ const sample = await probeParkedUsage(stored, { retries: 0, refreshParked: false, killMs: VERIFY_PROBE_KILL_MS, signal: AbortSignal.timeout(VERIFY_PROBE_KILL_MS) });
272
+ if (sample.ok) {
273
+ const at = Date.now();
274
+ stored.lastUsage = { fiveHour: sample.usage.session, sevenDay: sample.usage.weekAll };
275
+ stored.lastUsageAt = at;
276
+ if (Object.keys(sample.usage.perModel).length > 0) {
277
+ stored.lastPerModel = sample.usage.perModel;
278
+ stored.lastPerModelAt = at;
279
+ }
280
+ }
281
+ if (sample.ok || stored.needsReauth === true) saveAccounts(idx2);
282
+ if (stored.needsReauth === true) {
283
+ rejected.add(candidate.accountUuid);
284
+ log("decide.candidate_rejected", { account: short, error: "needs reauth" });
285
+ return "skip";
286
+ }
287
+ if (!sample.ok) {
288
+ log("decide.candidate_unverified", { account: short });
289
+ return "go";
290
+ }
291
+ log(isExhausted(stored, ctx) ? "decide.candidate_walled" : "decide.candidate_verified", {
292
+ account: short,
293
+ session: sample.usage.session.usedPercentage,
294
+ weekly: sample.usage.weekAll.usedPercentage,
295
+ });
296
+ return "rerank";
297
+ };
256
298
 
257
299
  const greedy = async (holdMargin: number): Promise<SwapDecision> => {
258
300
  while (true) {
259
301
  const cur = loadAccounts();
260
302
  const active = seatOf(cur);
261
- const pool = usable(cur.accounts);
262
- const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
303
+ const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, currentOrganizationUuid: active?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies, holdMargin };
304
+ const seatOrg = active != null && !active.needsReauth && !isExhausted(active, ctxAll) ? active.organizationUuid : null;
305
+ const pool = usable(cur.accounts).filter((a) => seatOrg == null || a.organizationUuid === seatOrg);
263
306
  if (currentWins(active, pool, ctxAll)) {
264
307
  return { swapped: false, account: null, reason: "current-best" };
265
308
  }
266
- const best = pickBest(pool, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
309
+ const ctx = { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null };
310
+ const best = pickBest(pool, ctx);
267
311
  if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
312
+ if ((await verified(best, ctx)) !== "go") continue;
268
313
  try {
269
314
  await performSwap(best);
270
315
  } catch (e) {
@@ -297,14 +342,17 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
297
342
  if (claim === "reset") return { swapped: false, account: seat, reason: "banked-reset", reset: true };
298
343
  }
299
344
  }
300
- const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
345
+ const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
346
+ const best = pickBest(pool, ctx);
301
347
  if (!best) break;
348
+ if ((await verified(best, ctx)) !== "go") continue;
302
349
  try {
303
350
  await performSwap(best);
304
351
  } catch (e) {
305
352
  skipOrThrow(e, best);
306
353
  continue;
307
354
  }
355
+ log("decide.hard_swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
308
356
  return { swapped: true, account: best, reason: "swapped" };
309
357
  }
310
358
 
@@ -314,7 +362,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
314
362
  log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
315
363
  return { swapped: false, account: null, reason: "last-drop-hold" };
316
364
  }
317
- const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null }, rejected);
365
+ const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor }, rejected, verified);
318
366
  if (squeezed) {
319
367
  log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
320
368
  return { swapped: true, account: squeezed, reason: "last-drop-swap" };
package/src/lib/oauth.ts CHANGED
@@ -94,11 +94,12 @@ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now =
94
94
  return !creds.expiresAt || creds.expiresAt - now <= skewMs;
95
95
  }
96
96
 
97
- export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
97
+ export async function fetchTokenIdentity(accessToken: string, signal?: AbortSignal): Promise<TokenIdentity> {
98
98
  let res: Response;
99
99
  try {
100
100
  res = await http.get(PROFILE_URL, {
101
101
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
102
+ signal,
102
103
  });
103
104
  } catch (e) {
104
105
  throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
package/src/lib/picker.ts CHANGED
@@ -34,6 +34,8 @@ const PickCtxSchema = z.object({
34
34
  now: z.number(),
35
35
  thresholds: ThresholdsSchema,
36
36
  currentAccountUuid: z.string().nullable(),
37
+ currentOrganizationUuid: z.string().nullable().optional(),
38
+ orgAffinityFloor: z.number().optional(),
37
39
  switchFamilies: z.array(z.string()),
38
40
  holdMargin: z.number().min(0).optional(),
39
41
  });
@@ -94,9 +96,27 @@ export function pacePressure(a: Account, now: number): number {
94
96
  return Math.max(0, 100 - used) / Math.max(1, reset - now);
95
97
  }
96
98
 
97
- const swapPreference = (now: number) => [
98
- (a: Account) => -pacePressure(a, now),
99
- (a: Account) => weeklyExpiry(a, now),
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
+ const swapPreference = (ctx: PickCtx) => [
117
+ (a: Account) => orgTier(a, ctx),
118
+ (a: Account) => -pacePressure(a, ctx.now),
119
+ (a: Account) => weeklyExpiry(a, ctx.now),
100
120
  (a: Account) => a.lastUsage?.sevenDay.usedPercentage ?? 101,
101
121
  ];
102
122
 
@@ -104,7 +124,7 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
104
124
  const usable = accounts.filter(
105
125
  (a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
106
126
  );
107
- return sortBy(usable, swapPreference(ctx.now))[0] ?? null;
127
+ return sortBy(usable, swapPreference(ctx))[0] ?? null;
108
128
  }
109
129
 
110
130
  export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
@@ -114,7 +134,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
114
134
  const margin = ctx.holdMargin ?? 0;
115
135
  const bestPace = pacePressure(best, ctx.now);
116
136
  if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
117
- return swapPreference(ctx.now).every((k) => k(active) === k(best));
137
+ return swapPreference(ctx).every((k) => k(active) === k(best));
118
138
  }
119
139
 
120
140
  export function usableAt(a: Account, ctx: PickCtx): number {
package/src/lib/sample.ts CHANGED
@@ -24,10 +24,10 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
24
24
  ]);
25
25
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
26
26
 
27
- async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
27
+ async function checkIdentity(creds: OAuthCreds, account: Account, signal?: AbortSignal): Promise<IdentityCheck> {
28
28
  let identity: TokenIdentity;
29
29
  try {
30
- identity = await fetchTokenIdentity(creds.accessToken);
30
+ identity = await fetchTokenIdentity(creds.accessToken, signal);
31
31
  } catch (e) {
32
32
  return {
33
33
  status: "unavailable",
@@ -44,7 +44,10 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
44
44
  if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
45
45
  }
46
46
 
47
- export async function probeParkedUsage(account: Account, opts: { ping?: boolean } = {}): Promise<SampleOutcome> {
47
+ export async function probeParkedUsage(
48
+ account: Account,
49
+ opts: { ping?: boolean; retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
50
+ ): Promise<SampleOutcome> {
48
51
  const backup = parkedTarget(account.keychainItem);
49
52
  const parkedRaw = await readItem(backup);
50
53
  if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
@@ -59,6 +62,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
59
62
  account.needsReauth = true;
60
63
  return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
61
64
  }
65
+ if (opts.refreshParked === false && isAccessTokenExpiring(creds, 300_000)) {
66
+ return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
67
+ }
62
68
 
63
69
  const liveRaw = await readItem(liveTarget());
64
70
  let liveAccount: string | null = null;
@@ -73,7 +79,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
73
79
  if (!isDeadCredential(liveCreds)) {
74
80
  liveToken = liveCreds.accessToken;
75
81
  try {
76
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
82
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken, opts.signal)).accountUuid;
77
83
  } catch (e) {
78
84
  return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
79
85
  }
@@ -92,6 +98,9 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
92
98
  };
93
99
 
94
100
  if (isAccessTokenExpiring(creds, 300_000)) {
101
+ if (opts.refreshParked === false) {
102
+ return { ok: false, reason: "parked access token is near expiry - verification requires an unexpired token" };
103
+ }
95
104
  const owner = await checkIdentity(creds, account);
96
105
  if (owner.status === "mismatch") {
97
106
  const kept = await relocate(owner.owner);
@@ -136,7 +145,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
136
145
  await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
137
146
  }
138
147
 
139
- const identity = await checkIdentity(creds, account);
148
+ const identity = await checkIdentity(creds, account, opts.signal);
140
149
  if (identity.status === "mismatch") {
141
150
  const kept = await relocate(identity.owner);
142
151
  account.needsReauth = true;
@@ -151,13 +160,13 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
151
160
  rmSync(dir, { recursive: true, force: true });
152
161
  mkdirSync(dir, { recursive: true });
153
162
  const isoTarget = isolatedTarget(dir);
154
- const installed = JSON.stringify({ claudeAiOauth: creds });
163
+ const installed = JSON.stringify({ claudeAiOauth: opts.refreshParked === false ? { ...creds, refreshToken: "" } : creds });
155
164
 
156
165
  try {
157
166
  await writeItem(isoTarget, installed);
158
167
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
159
168
  const ping = opts.ping ? await pingSession(dir) : null;
160
- const usage = await probeUsage(dir);
169
+ const usage = await probeUsage(dir, Date.now(), opts);
161
170
  const outcome: SampleOutcome = usage
162
171
  ? { ok: true, usage }
163
172
  : { ok: false, reason: "`/usage` returned no limit data (see log)" };
@@ -167,10 +176,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
167
176
  }
168
177
  return outcome;
169
178
  } finally {
170
- const afterIso = await readItem(isoTarget);
171
- if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
172
- await deleteItem(isoTarget);
173
- rmSync(dir, { recursive: true, force: true });
179
+ try {
180
+ if (opts.refreshParked !== false) {
181
+ const afterIso = await readItem(isoTarget);
182
+ if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
183
+ }
184
+ } finally {
185
+ await deleteItem(isoTarget);
186
+ rmSync(dir, { recursive: true, force: true });
187
+ }
174
188
  }
175
189
  }
176
190
 
package/src/lib/swap.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { z } from "zod";
1
2
  import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
2
3
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
3
4
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
@@ -11,6 +12,9 @@ function parseBlob(raw: string) {
11
12
  return CredentialBlobSchema.parse(JSON.parse(raw));
12
13
  }
13
14
 
15
+ const VerifyVerdictSchema = z.enum(["go", "rerank", "skip"]);
16
+ export type VerifyVerdict = z.infer<typeof VerifyVerdictSchema>;
17
+
14
18
  export async function performSwap(target: Account): Promise<void> {
15
19
  const idx = loadAccounts();
16
20
 
@@ -196,13 +200,25 @@ export function isSkippableSwapError(e: unknown): boolean {
196
200
  return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
197
201
  }
198
202
 
199
- export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
203
+ export async function chooseAndSwap(
204
+ ctx: PickCtx,
205
+ exclude: ReadonlySet<string> = new Set(),
206
+ verify?: (candidate: Account, ctx: PickCtx) => Promise<VerifyVerdict>,
207
+ ): Promise<Account | null> {
200
208
  const tried = new Set<string>(exclude);
201
209
  while (true) {
202
210
  const idx = loadAccounts();
203
211
  const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
204
212
  const best = pickBest(candidates, ctx);
205
213
  if (!best) return null;
214
+ if (verify) {
215
+ const verdict = await verify(best, ctx);
216
+ if (verdict === "rerank") continue;
217
+ if (verdict === "skip") {
218
+ tried.add(best.accountUuid);
219
+ continue;
220
+ }
221
+ }
206
222
  tried.add(best.accountUuid);
207
223
  try {
208
224
  await performSwap(best);
package/src/lib/usage.ts CHANGED
@@ -286,9 +286,14 @@ async function spawnClaudeBounded(
286
286
  cmd: string[],
287
287
  env: Record<string, string>,
288
288
  cwd?: string,
289
+ killMs = PROBE_KILL_MS,
290
+ signal?: AbortSignal,
289
291
  ): Promise<SpawnResult | null> {
292
+ signal?.throwIfAborted();
290
293
  const p = Bun.spawn(cmd, { env, cwd, stdout: "pipe", stderr: "pipe" });
291
- const killer = setTimeout(() => p.kill("SIGKILL"), PROBE_KILL_MS);
294
+ const abort = () => p.kill("SIGKILL");
295
+ signal?.addEventListener("abort", abort, { once: true });
296
+ const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
292
297
  try {
293
298
  const reads = Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text()]);
294
299
  const settled = await Promise.race([
@@ -301,13 +306,14 @@ async function spawnClaudeBounded(
301
306
  return { exitCode: p.exitCode, stdout, stderr };
302
307
  } finally {
303
308
  clearTimeout(killer);
309
+ signal?.removeEventListener("abort", abort);
304
310
  }
305
311
  }
306
312
 
307
- async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
313
+ async function probeUsageOnce(env: Record<string, string>, now: number, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
308
314
  let out: string;
309
315
  try {
310
- const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
316
+ const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env, undefined, killMs, signal);
311
317
  if (r === null) {
312
318
  log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
313
319
  return null;
@@ -345,17 +351,17 @@ function probeEnv(configDir?: string): Record<string, string> {
345
351
  return env;
346
352
  }
347
353
 
348
- export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
354
+ export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number; killMs?: number; signal?: AbortSignal } = {}): Promise<FullUsage | null> {
349
355
  const env = probeEnv(configDir);
350
356
 
351
357
  for (let attempt = 0; ; attempt++) {
352
- const full = await probeUsageOnce(env, now);
358
+ const full = await probeUsageOnce(env, now, opts.killMs ?? PROBE_KILL_MS, opts.signal);
353
359
  if (full) return full;
354
- if (attempt >= PROBE_RETRY_DELAYS_MS.length) {
360
+ if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length) || opts.signal?.aborted) {
355
361
  log("usage.probe_gave_up", { attempts: attempt + 1 });
356
362
  return null;
357
363
  }
358
- await delay(PROBE_RETRY_DELAYS_MS[attempt]!);
364
+ await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
359
365
  }
360
366
  }
361
367