tokenmaxxing 1.12.0 → 1.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -79,7 +79,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
79
79
  ---
80
80
 
81
81
  ## 5. Rotation policy
82
- The decision engages at `five_hour >= 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 **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 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.
83
83
 
84
84
  **Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
85
85
 
@@ -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.12.0",
4
+ "version": "1.13.1",
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,7 +8,7 @@ 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.
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
12
  - **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
13
13
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
14
14
  - **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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.12.0",
3
+ "version": "1.13.1",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/config.ts CHANGED
@@ -16,6 +16,7 @@ export const KNOWN_KEYS = [
16
16
  "codexBin",
17
17
  "policy.projectionMargin",
18
18
  "policy.greedySessionFloor",
19
+ "policy.greedySwapMargin",
19
20
  "policy.switchModels",
20
21
  "policy.usagePollTtlMs",
21
22
  "policy.maxWaitMs",
package/src/cli/status.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
- import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
3
+ import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
4
4
  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";
@@ -33,6 +33,7 @@ const ClaudeStatusAccountSchema = z.object({
33
33
  usageAt: z.number().nullable(),
34
34
  sample: SampleReportSchema,
35
35
  pingError: z.string().nullable(),
36
+ pingRejected: z.boolean(),
36
37
  pinged: z.boolean(),
37
38
  });
38
39
  type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
@@ -93,15 +94,18 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
93
94
  console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
94
95
  await withLock(paths.lockFile, async () => {
95
96
  idx = loadAccounts();
96
- const live = loadUsage();
97
+ const tee = loadUsageSnapshot();
98
+ const live = tee?.state ?? null;
99
+ const teeAt = tee?.at ?? null;
97
100
  const modelUsage = loadModelUsage();
98
101
  const liveOAuth = readOAuthAccount();
99
102
  const activeOrg = liveOAuth?.organizationUuid ?? null;
100
103
  const probeOne = async (a: Account) => {
101
104
  const isActive = activeOrg != null && activeOrg === a.organizationUuid;
102
105
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
106
+ const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
103
107
  const fromStatusLine: FullUsage | null =
104
- isActive && live && live.org === a.organizationUuid
108
+ isActive && live && teeCurrent && live.org === a.organizationUuid
105
109
  ? {
106
110
  session: live.fiveHour,
107
111
  weekAll: live.sevenDay,
@@ -115,7 +119,10 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
115
119
  if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
116
120
  const failed = outcome;
117
121
  outcome = { ok: true, usage: fromStatusLine };
118
- if (failed.pingError != null) outcome.pingError = failed.pingError;
122
+ if (failed.pingError != null) {
123
+ outcome.pingError = failed.pingError;
124
+ outcome.pingRejected = failed.pingRejected;
125
+ }
119
126
  viaTee = true;
120
127
  }
121
128
  } else {
@@ -129,7 +136,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
129
136
  samples.set(a.accountUuid, { outcome, viaTee });
130
137
  if (!outcome.ok) return;
131
138
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
132
- a.lastUsageAt = viaTee && live ? live.ts : Date.now();
139
+ a.lastUsageAt = viaTee && teeAt != null ? teeAt : Date.now();
133
140
  if (Object.keys(outcome.usage.perModel).length > 0) {
134
141
  a.lastPerModel = outcome.usage.perModel;
135
142
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
@@ -169,6 +176,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
169
176
  usageAt: a.lastUsageAt ?? null,
170
177
  sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
171
178
  pingError: sampled.outcome.pingError ?? null,
179
+ pingRejected: sampled.outcome.pingRejected === true,
172
180
  pinged: force && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
173
181
  };
174
182
  });
@@ -277,8 +285,14 @@ function rowPrinter(now: number): (name: string, w: UsageWindow) => void {
277
285
  };
278
286
  }
279
287
 
280
- function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: boolean; now: number; row: (name: string, w: UsageWindow) => void }): void {
281
- const { claude, codexPooled, now, row } = input;
288
+ function renderClaude(input: {
289
+ claude: StatusReport["claude"];
290
+ codexPooled: boolean;
291
+ now: number;
292
+ staleAfterMs: number;
293
+ row: (name: string, w: UsageWindow) => void;
294
+ }): void {
295
+ const { claude, codexPooled, now, staleAfterMs, row } = input;
282
296
  if (claude.accounts.length === 0) {
283
297
  if (!codexPooled) {
284
298
  console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
@@ -303,11 +317,21 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
303
317
  row("week", a.usage.week);
304
318
  }
305
319
  for (const [name, w] of Object.entries(a.perModel)) row(name.toLowerCase(), w);
320
+ if (a.sample.ok && a.sample.source === "statusline") {
321
+ const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
322
+ const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
323
+ console.log(` ${(stale ? c.yellow : c.dim)(`statusline tee ${age}${stale ? " (stale)" : ""}`)}`);
324
+ }
306
325
  if (!a.sample.ok) {
307
326
  const cached = a.usage || Object.keys(a.perModel).length > 0 ? `cached${a.usageAt != null ? ` ${fmtAgo(a.usageAt, now)}` : ""}, ` : "";
308
327
  console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(a.sample.reason)}`);
309
328
  }
310
- if (a.pingError != null) {
329
+ if (a.pingError != null && a.pingRejected) {
330
+ const at = a.pingError.indexOf(": ");
331
+ const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
332
+ const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
333
+ console.log(` ${c.yellow(`ping ${head}`)}${tail ? `: ${c.dim(tail)}` : ""}`);
334
+ } else if (a.pingError != null) {
311
335
  console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(a.pingError)}`);
312
336
  }
313
337
  if (a.pinged) {
@@ -321,11 +345,11 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
321
345
  const { force = false, json = false } = opts;
322
346
  const cfg = loadConfig();
323
347
  const now = Date.now();
324
- const row = rowPrinter(now);
325
348
  const claude = await collectClaude({ cfg, force, now });
326
349
  if (!json) {
327
350
  opts.preRender?.();
328
- renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now, row });
351
+ const at = Date.now();
352
+ renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs, row: rowPrinter(at) });
329
353
  }
330
354
  const codex = await collectCodex({ cfg, now });
331
355
  if (json) {
@@ -333,6 +357,7 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
333
357
  emitJson({ ok: true, ...report });
334
358
  return 0;
335
359
  }
336
- renderCodex({ codex, now, row });
360
+ const at = Date.now();
361
+ renderCodex({ codex, now: at, row: rowPrinter(at) });
337
362
  return 0;
338
363
  }
package/src/lib/decide.ts CHANGED
@@ -2,7 +2,7 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, usageTeeAt, writeUsage } from "./state.ts";
5
+ import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { chooseAndSwap, performSwap } from "./swap.ts";
8
8
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
@@ -72,31 +72,43 @@ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string
72
72
  const SnapshotsSchema = z.object({
73
73
  u: UsageStateSchema.nullable(),
74
74
  mu: ModelUsageStateSchema.nullable(),
75
+ uAt: z.number().nullable(),
75
76
  });
76
77
  type Snapshots = z.infer<typeof SnapshotsSchema>;
77
78
 
78
- function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
79
- if (u == null || u.org !== org) return false;
80
- const teeAt = usageTeeAt();
81
- return teeAt != null && now - teeAt <= ttl;
79
+ function usageFresh(u: UsageState | null, uAt: number | null, org: string | null, ttl: number, now: number): boolean {
80
+ return u != null && u.org === org && uAt != null && now - uAt <= ttl;
81
+ }
82
+
83
+ function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
84
+ if (!u || uAt == null) return u;
85
+ const stored = account?.lastUsage;
86
+ if (stored && account.lastUsageAt != null && u.org === account.organizationUuid && uAt < account.lastUsageAt) {
87
+ return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
88
+ }
89
+ return { ...u, ts: uAt };
82
90
  }
83
91
 
84
92
  async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
85
- let u = loadUsage();
93
+ const snap = loadUsageSnapshot();
94
+ let u = snap?.state ?? null;
95
+ let uAt = snap?.at ?? null;
86
96
  let mu = loadModelUsage();
87
97
  const ttl = cfg.policy.usagePollTtlMs;
88
98
  const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
89
- if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
99
+ if (org && !probeAttempted && (!usageFresh(u, uAt, org, ttl, now) || needsPerModel(u, cfg))) {
90
100
  const full = await probeUsage();
91
101
  const ts = Date.now();
92
102
  if (readOAuthAccount()?.organizationUuid === org) {
93
103
  if (full) {
94
- const teed = loadUsage();
95
- if (usageFresh(teed, org, ttl, ts)) {
96
- u = teed;
104
+ const teed = loadUsageSnapshot();
105
+ if (teed && usageFresh(teed.state, teed.at, org, ttl, ts)) {
106
+ u = teed.state;
107
+ uAt = teed.at;
97
108
  } else {
98
109
  u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
99
110
  writeUsage(u);
111
+ uAt = ts;
100
112
  }
101
113
  mu = { perModel: full.perModel, org, ts, sampledAt: ts };
102
114
  saveModelUsage(mu);
@@ -111,7 +123,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
111
123
  }
112
124
  }
113
125
  }
114
- return { u, mu };
126
+ return { u, mu, uAt };
115
127
  }
116
128
 
117
129
  export const POST_SWAP_COOLDOWN_MS = 45_000;
@@ -127,9 +139,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
127
139
 
128
140
  const cfg = loadConfig();
129
141
 
130
- const { u: usage, mu } = await loadFreshSnapshots(cfg, activeOrg, now);
142
+ const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
143
+ const pool = loadAccounts();
144
+ const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
131
145
  const bars0 = effectiveBars(cfg, {
132
- accounts: overlayLive(loadAccounts().accounts, usage, mu, activeOrg),
146
+ accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
133
147
  now,
134
148
  switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
135
149
  });
@@ -147,19 +161,20 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
147
161
  const idx = loadAccounts();
148
162
  const org2 = readOAuthAccount()?.organizationUuid ?? null;
149
163
  const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
150
- const u2 = loadUsage() ?? usage;
164
+ const tee = loadUsageSnapshot();
165
+ const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
166
+ const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
151
167
  const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
152
168
 
153
- if (org2 != null && !idx.accounts.some((a) => a.organizationUuid === org2)) {
169
+ if (org2 != null && !active) {
154
170
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
155
171
  }
156
172
 
157
- const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
158
173
  if (active) {
159
174
  let sampled = false;
160
- if (u2 && u2.org === org2) {
161
- active.lastUsage = { fiveHour: u2.fiveHour, sevenDay: u2.sevenDay };
162
- active.lastUsageAt = u2.ts;
175
+ if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
176
+ active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
177
+ active.lastUsageAt = tee.at;
163
178
  sampled = true;
164
179
  }
165
180
  if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
@@ -184,11 +199,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
184
199
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
185
200
  null;
186
201
 
187
- const greedy = async (): Promise<SwapDecision> => {
202
+ const greedy = async (holdMargin: number): Promise<SwapDecision> => {
188
203
  while (true) {
189
204
  const cur = loadAccounts();
190
205
  const active = seatOf(cur);
191
- const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies };
206
+ const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
192
207
  if (currentWins(active, cur.accounts, ctxAll)) {
193
208
  return { swapped: false, account: null, reason: "current-best" };
194
209
  }
@@ -204,13 +219,13 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
204
219
  return { swapped: true, account: best, reason: "swapped" };
205
220
  }
206
221
  };
207
- if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy();
222
+ if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
208
223
 
209
224
  while (true) {
210
225
  const cur = loadAccounts();
211
226
  const seat = seatOf(cur);
212
227
  const thresholds = barsOf(cur.accounts);
213
- if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy();
228
+ if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
214
229
  const best = pickBest(cur.accounts, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
215
230
  if (!best) break;
216
231
  try {
@@ -351,12 +366,14 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
351
366
  if (decision.waitUntil !== undefined) return Math.min(MAX_CHECK_DELAY_MS, Math.max(CHECK_DELAY_FLOOR_MS, decision.waitUntil - now));
352
367
  const swapAt = loadLastSwapAt();
353
368
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
354
- const u = loadUsage();
355
- if (!org || !u || !usageFresh(u, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
369
+ const snap = loadUsageSnapshot();
370
+ if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
371
+ const accounts = loadAccounts().accounts;
372
+ const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
356
373
  const mu = loadModelUsage();
357
374
  const muSame = mu && mu.org === org ? mu : null;
358
375
  const families = gatedFamilies(u.model, cfg.policy.switchModels);
359
- const bars = effectiveBars(cfg, { accounts: overlayLive(loadAccounts().accounts, u, muSame, org), now, switchFamilies: families });
376
+ const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
360
377
  const heads = [
361
378
  bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
362
379
  bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
package/src/lib/picker.ts CHANGED
@@ -35,6 +35,7 @@ const PickCtxSchema = z.object({
35
35
  thresholds: ThresholdsSchema,
36
36
  currentAccountUuid: z.string().nullable(),
37
37
  switchFamilies: z.array(z.string()),
38
+ holdMargin: z.number().min(0).optional(),
38
39
  });
39
40
  export type PickCtx = z.infer<typeof PickCtxSchema>;
40
41
 
@@ -110,6 +111,9 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
110
111
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
111
112
  const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
112
113
  if (best == null || best.accountUuid === active.accountUuid) return true;
114
+ const margin = ctx.holdMargin ?? 0;
115
+ const bestPace = pacePressure(best, ctx.now);
116
+ if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
113
117
  return swapPreference(ctx.now).every((k) => k(active) === k(best));
114
118
  }
115
119
 
package/src/lib/sample.ts CHANGED
@@ -9,8 +9,8 @@ import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
9
  import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
10
10
 
11
11
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
- z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
13
- z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
12
+ z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
13
+ z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
14
14
  ]);
15
15
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
16
16
 
@@ -95,12 +95,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
95
95
  try {
96
96
  await writeItem(isoTarget, installed);
97
97
  writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
98
- const pingError = opts.ping ? await pingSession(dir) : null;
98
+ const ping = opts.ping ? await pingSession(dir) : null;
99
99
  const usage = await probeUsage(dir);
100
100
  const outcome: SampleOutcome = usage
101
101
  ? { ok: true, usage }
102
102
  : { ok: false, reason: "`/usage` returned no limit data (see log)" };
103
- if (pingError != null) outcome.pingError = pingError;
103
+ if (ping != null) {
104
+ outcome.pingError = ping.reason;
105
+ outcome.pingRejected = ping.rejected;
106
+ }
104
107
  return outcome;
105
108
  } finally {
106
109
  const afterIso = await readItem(isoTarget);
@@ -152,9 +155,12 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
152
155
  if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
153
156
  refreshPlanFields(account, creds);
154
157
 
155
- const pingError = opts.ping ? await pingSession() : null;
158
+ const ping = opts.ping ? await pingSession() : null;
156
159
  const usage = await probeUsage();
157
160
  const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
158
- if (pingError != null) outcome.pingError = pingError;
161
+ if (ping != null) {
162
+ outcome.pingError = ping.reason;
163
+ outcome.pingRejected = ping.rejected;
164
+ }
159
165
  return outcome;
160
166
  }
package/src/lib/state.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
2
  import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
@@ -22,7 +22,7 @@ const DEFAULT_CONFIG: Config = {
22
22
  hardThresholds: { session: 100, weekly: 100 },
23
23
  claudeBin: "",
24
24
  codexBin: "",
25
- policy: { projectionMargin: 0, greedySessionFloor: 80, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
25
+ policy: { projectionMargin: 0, greedySessionFloor: 80, greedySwapMargin: 0.15, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
26
26
  };
27
27
 
28
28
  const PercentSchema = z.number().min(0).max(100);
@@ -37,6 +37,7 @@ export const ConfigFileSchema = z
37
37
  .object({
38
38
  projectionMargin: PercentSchema,
39
39
  greedySessionFloor: PercentSchema,
40
+ greedySwapMargin: z.number().min(0).max(1),
40
41
  switchModels: z.array(z.string()),
41
42
  usagePollTtlMs: z.number().int().positive(),
42
43
  maxWaitMs: z.number().int().positive(),
@@ -66,6 +67,7 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
66
67
  cfg.codexBin = p.codexBin ?? cfg.codexBin;
67
68
  cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
68
69
  cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
70
+ cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
69
71
  cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
70
72
  cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
71
73
  if (p.policy?.switchModels) {
@@ -133,16 +135,28 @@ export function saveAccounts(idx: AccountsIndex): void {
133
135
  writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
134
136
  }
135
137
 
136
- export function loadUsage(): UsageState | null {
137
- if (!existsSync(paths.usageJson)) return null;
138
+ export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
139
+ let fd: number;
138
140
  try {
139
- const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(paths.usageJson, "utf8")));
140
- return parsed.success ? parsed.data : null;
141
+ fd = openSync(paths.usageJson, "r");
142
+ } catch {
143
+ return null;
144
+ }
145
+ try {
146
+ const at = fstatSync(fd).mtimeMs;
147
+ const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(fd, "utf8")));
148
+ return parsed.success ? { state: parsed.data, at } : null;
141
149
  } catch {
142
150
  return null;
151
+ } finally {
152
+ closeSync(fd);
143
153
  }
144
154
  }
145
155
 
156
+ export function loadUsage(): UsageState | null {
157
+ return loadUsageSnapshot()?.state ?? null;
158
+ }
159
+
146
160
  export function clearUsageSnapshots(): void {
147
161
  rmSync(paths.usageJson, { force: true });
148
162
  rmSync(paths.modelUsageJson, { force: true });
@@ -224,14 +238,6 @@ export function writeUsage(next: UsageState): boolean {
224
238
  return true;
225
239
  }
226
240
 
227
- export function usageTeeAt(): number | null {
228
- try {
229
- return statSync(paths.usageJson).mtimeMs;
230
- } catch {
231
- return null;
232
- }
233
- }
234
-
235
241
  export function loadModelUsage(): ModelUsageState | null {
236
242
  if (!existsSync(paths.modelUsageJson)) return null;
237
243
  try {
package/src/lib/types.ts CHANGED
@@ -121,6 +121,7 @@ export const ConfigSchema = z
121
121
  policy: z.object({
122
122
  projectionMargin: z.number().min(0).max(100),
123
123
  greedySessionFloor: z.number().min(0).max(100),
124
+ greedySwapMargin: z.number().min(0).max(1),
124
125
  switchModels: z.array(z.string()),
125
126
  usagePollTtlMs: z.number().int().positive(),
126
127
  maxWaitMs: z.number().int().positive(),
package/src/lib/usage.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { delay } from "es-toolkit";
4
4
  import { z } from "zod";
@@ -366,13 +366,36 @@ const PING_ARGS = [
366
366
  "--output-format", "json",
367
367
  ];
368
368
 
369
- const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
369
+ const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
370
370
 
371
- export async function pingSession(configDir?: string): Promise<string | null> {
371
+ export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
372
+ export type PingFailure = z.infer<typeof PingFailureSchema>;
373
+
374
+ export function transcriptSlug(cwd: string): string {
375
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
376
+ }
377
+
378
+ export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
379
+ return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
380
+ }
381
+
382
+ const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
383
+
384
+ export function pingRejection(rows: TranscriptRow[]): string | null {
385
+ const row = rows.findLast((r) => r.isApiErrorMessage === true);
386
+ if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
387
+ const type = row.quotaLimits?.rateLimitType ?? "";
388
+ if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
389
+ const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
390
+ const text = transcriptRowText(row);
391
+ return `${label} limit${text ? `: ${text}` : ""}`;
392
+ }
393
+
394
+ export async function pingSession(configDir?: string): Promise<PingFailure | null> {
372
395
  const cwd = join(paths.sampleDir, "ping-cwd");
373
- const fail = (reason: string): string => {
374
- log("usage.ping_failed", { dir: configDir ?? "live", reason: reason.slice(0, 200) });
375
- return reason;
396
+ const fail = (reason: string, rejected = false): PingFailure => {
397
+ log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
398
+ return { reason, rejected };
376
399
  };
377
400
  let r: SpawnResult | null;
378
401
  try {
@@ -382,10 +405,21 @@ export async function pingSession(configDir?: string): Promise<string | null> {
382
405
  return fail(e instanceof Error ? e.message : String(e));
383
406
  }
384
407
  if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
385
- if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
386
408
  const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
387
- if (!parsed.success) return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
388
- if (parsed.data.is_error) return fail((parsed.data.result?.trim() || "request errored").slice(0, 160));
389
- log("usage.ping_ok", { dir: configDir ?? "live" });
390
- return null;
409
+ const result = parsed.success ? parsed.data : null;
410
+ if (r.exitCode === 0 && result?.is_error === false) {
411
+ log("usage.ping_ok", { dir: configDir ?? "live" });
412
+ return null;
413
+ }
414
+ if (result?.session_id) {
415
+ const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
416
+ const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
417
+ if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
418
+ }
419
+ if (r.exitCode !== 0) {
420
+ const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
421
+ return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
422
+ }
423
+ if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
424
+ return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
391
425
  }