tokenmaxxing 1.88.0 → 1.90.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
@@ -36,7 +36,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
36
36
  - `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
37
37
  - `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
38
38
  - `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
39
- - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it), and the launch time the marker was written for.
39
+ - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, the `origin` (the hook or the seat watch that wrote it), the launch time the marker was written for, and after a usage-credits refusal the accounts that have refused the session.
40
40
  - `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
41
41
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
42
42
  - `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
@@ -53,7 +53,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
53
53
  ## 3. How a move happens
54
54
 
55
55
  ### 3.1 Usage feed (free, push-based)
56
- 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/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (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). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family: the direct no-spend usage read with the seat's stored token. The compact child still sets `TOKENMAXXING_PROBE=1` so the hook entries return at once. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it. An expired stored access token stays unmeasured until a session on that store refreshes it.
56
+ 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/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (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). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family, at most once per the account's sample interval (§5): the direct no-spend usage read with the seat's stored token. The compact child still sets `TOKENMAXXING_PROBE=1` so the hook entries return at once. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it. An expired stored access token stays unmeasured until a session on that store refreshes it.
57
57
 
58
58
  ### 3.2 Detect + decide + signal (Stop hook, per turn)
59
59
  1. Resolve the seat: the pooled account whose store the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` names. No seat (a session started outside the supervisor) means nothing to move.
@@ -87,7 +87,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
87
87
  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 that ranks first; nothing usable, queue first come first served onto a reset target (at most four waiters per account, overflow takes the soonest anyway) and wait for that reset. The same ranking places a session at launch.
88
88
 
89
89
  - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
90
- - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it.
90
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval, over the direct no-spend usage read with the account's stored token. The interval is 15 minutes times the unused fraction of the bar closest to being reached, never under `policy.usagePollTtlMs`, and 15 minutes for an account at or over an aggregate bar. The windows are the session and weekly aggregates plus the `policy.switchModels` caps that are still under their bar, and an account with no stored aggregate gets `policy.usagePollTtlMs`. Every account is attempted about once per three ticks per pooled account, or once per its sample interval plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it, and no sampler sends a read with it, because the endpoint answers it with 401 and, after a few, with the same 429.
91
91
  - 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.
92
92
  - Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
93
93
 
package/README.md CHANGED
@@ -84,13 +84,13 @@ claude # use claude as always; each session gets its own accoun
84
84
  Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
85
85
 
86
86
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
87
- - **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 the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
87
+ - **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 the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family. A refusal for usage credits, which the usage read cannot predict, walls nothing: the refused session moves with the Fable cap screened, and a later credits move never returns it to an account that already refused it.
88
88
 
89
89
  The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) 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).
90
90
 
91
91
  Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
92
92
 
93
- - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. An expired stored access token stays unmeasured until a session on that store refreshes it.
93
+ - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval (15 minutes for an empty account, shrinking to `policy.usagePollTtlMs` as it nears a bar, and 15 minutes again at or over an aggregate bar), with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. An expired stored access token stays unmeasured until a session on that store refreshes it.
94
94
  - When no account is usable, the session queues first come first served onto a reset target (at most four waiters per account) and pauses until that reset if it lands within `policy.maxWaitMs`, and otherwise stays put.
95
95
 
96
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.
@@ -112,7 +112,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
112
112
  }
113
113
  ```
114
114
 
115
- `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 the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a usage sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a 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. `claudeBin`, `codexBin`, `grokBin`, `opencodeBin`, and `piBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, `TOKENMAXXING_OPENCODE_BIN`, and `TOKENMAXXING_PI_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
115
+ `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 the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is the shortest sample interval, the one an account near a bar gets, for a seat's own sample, for `status`, and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a 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. `claudeBin`, `codexBin`, `grokBin`, `opencodeBin`, and `piBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, `TOKENMAXXING_OPENCODE_BIN`, and `TOKENMAXXING_PI_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
116
116
 
117
117
  State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
118
118
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.88.0",
3
+ "version": "1.90.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/status.ts CHANGED
@@ -27,7 +27,7 @@ type StatusAccount = {
27
27
  usage: UsageReport | null;
28
28
  usageAt: number | null;
29
29
  limitsAt: number | null;
30
- sample: SampleReport | { ok: true; source: "cached" };
30
+ sample: SampleReport;
31
31
  };
32
32
 
33
33
  type PoolReport = {
@@ -109,6 +109,10 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
109
109
  stored.usageRetryAt = a.usageRetryAt;
110
110
  dirty = true;
111
111
  }
112
+ if ((a.lastProbeAt ?? 0) > (stored.lastProbeAt ?? 0)) {
113
+ stored.lastProbeAt = a.lastProbeAt;
114
+ dirty = true;
115
+ }
112
116
  if (a.lastUsageAt != null && (stored.lastUsageAt == null || a.lastUsageAt > stored.lastUsageAt)) {
113
117
  landWindows(stored, p.mergeWindows(a.windows, stored.windows), a.lastUsageAt, bars);
114
118
  if (a.email != null) stored.email = a.email;
@@ -2,7 +2,7 @@ import { z } from "zod";
2
2
  import { claude } from "../lib/claude.ts";
3
3
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
4
4
  import { readStdin } from "../lib/proc.ts";
5
- import { adoptLiveSession, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
5
+ import { adoptLiveSession, refusedAccounts, supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
6
6
  import { loadConfig } from "../lib/state.ts";
7
7
  import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
8
8
  import { paths } from "../lib/paths.ts";
@@ -57,7 +57,7 @@ export async function runStopFailureHook(): Promise<number> {
57
57
 
58
58
  let enforced: EnforcedLimit | null = null;
59
59
  if (limit && account) {
60
- enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
60
+ enforced = { account, kind: limit.kind, family: limit.kind === "model" || limit.kind === "credits" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
61
61
  log("stopfailure.enforced", { kind: limit.kind, family: enforced.family ?? undefined, resetsAt: limit.resetsAt, subagent: !mainLoop });
62
62
  } else {
63
63
  log("stopfailure.unclassified", {
@@ -80,9 +80,10 @@ export async function runStopFailureHook(): Promise<number> {
80
80
  );
81
81
  }
82
82
 
83
- const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined });
83
+ const refused = enforced?.kind === "credits" ? [...new Set([...refusedAccounts(), enforced.account])] : undefined;
84
+ const decision = await evaluateAndMaybeSwap(claude, now, canRespawn, enforced, { waiterId: canRespawn ? session?.sid : undefined, exclude: refused });
84
85
  if (session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
85
- writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure" });
86
+ writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false, origin: "stopfailure", refused });
86
87
  log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
87
88
  } else {
88
89
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
@@ -378,6 +378,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
378
378
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
379
379
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
380
380
  delete passthroughEnv.TOKENMAXXING_MODEL;
381
+ delete passthroughEnv.TOKENMAXXING_REFUSED;
381
382
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
382
383
  }
383
384
 
@@ -427,6 +428,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
427
428
  let respawns = 0;
428
429
  let overriddenUntil = 0;
429
430
  let wanted: string | null = null;
431
+ let refused: string[] = [];
430
432
  try {
431
433
  while (true) {
432
434
  if (existsSync(marker)) rmSync(marker, { force: true });
@@ -446,6 +448,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
446
448
  TOKENMAXXING_SESSION_ID: sid,
447
449
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
448
450
  TOKENMAXXING_MODEL: effective.model ?? "",
451
+ TOKENMAXXING_REFUSED: refused.join(","),
449
452
  ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
450
453
  },
451
454
  });
@@ -512,6 +515,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
512
515
  delete compactEnv.TOKENMAXXING_SESSION_ID;
513
516
  delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
514
517
  delete compactEnv.TOKENMAXXING_MODEL;
518
+ delete compactEnv.TOKENMAXXING_REFUSED;
515
519
  const outcome = await compactClaudeSession({ real, sid: m.sessionId, transcript, env: compactEnv, onSpawn: (p) => { child = p; } });
516
520
  child = null;
517
521
  if (terminating) {
@@ -526,6 +530,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
526
530
  if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
527
531
  } else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
528
532
  wanted = m.accountId;
533
+ refused = m.refused ?? refused;
529
534
  saveSessionFlags(m.sessionId, persistable, process.cwd());
530
535
  const prompt = resumable ? resumePrompt({ compacted, origin: m.origin }) : null;
531
536
  firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
package/src/lib/claude.ts CHANGED
@@ -13,7 +13,7 @@ import { landWindows, pickBest, pickEarliestReset, thresholdBars, type PickCtx }
13
13
  import { deletePiStore } from "./piauth.ts";
14
14
  import { livingPresences } from "./presence.ts";
15
15
  import { StoreUnusableError, type Observation, type Provider, type SampleReport } from "./provider.ts";
16
- import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
16
+ import { foldTee, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
17
17
  import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
19
  import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
@@ -33,11 +33,10 @@ function presence(): Map<string, number> {
33
33
  const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
34
34
 
35
35
  async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
36
- const ttl = cfg.policy.usagePollTtlMs;
37
- const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
36
+ const interval = Math.min(sampleIntervalMs(account, cfg, now) * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
38
37
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
39
38
  const snap = loadUsageSnapshot(account.id);
40
- const fresh = snap != null && now - snap.at <= ttl;
39
+ const fresh = snap != null && now - snap.at <= cfg.policy.usagePollTtlMs;
41
40
  if (opts.probe && !probeAttempted && usageBlockedUntil(account, now) == null && (!fresh || opts.perModel)) {
42
41
  const startedAt = Date.now();
43
42
  const outcome = await sampleAccountUsage(account);
@@ -64,9 +63,10 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
64
63
  return teeObservation(current);
65
64
  }
66
65
 
67
- async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
66
+ async function samplePool(accounts: Account[], _liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
68
67
  const reports = new Map<string, SampleReport>();
69
- const bars = thresholdBars(loadConfig());
68
+ const cfg = loadConfig();
69
+ const bars = thresholdBars(cfg);
70
70
  await Promise.all(
71
71
  accounts.map(async (a) => {
72
72
  const tee = loadUsageSnapshot(a.id);
@@ -76,7 +76,17 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
76
76
  reports.set(a.id, { ok: true, source: "statusline" });
77
77
  return;
78
78
  }
79
- const outcome = await sampleAccountUsage(a);
79
+ const ready = await readyToSample(a, now);
80
+ if (!ready.ok) {
81
+ reports.set(a.id, { ok: false, reason: ready.reason });
82
+ return;
83
+ }
84
+ if (!sampleDue(a, cfg, now)) {
85
+ reports.set(a.id, { ok: true, source: "cached" });
86
+ return;
87
+ }
88
+ a.lastProbeAt = now;
89
+ const outcome = await runSample(a, ready);
80
90
  if (!outcome.ok) {
81
91
  if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
82
92
  reports.set(a.id, { ok: false, reason: outcome.reason });
package/src/lib/decide.ts CHANGED
@@ -14,6 +14,7 @@ export type EvalOpts = {
14
14
  seatId?: string | null;
15
15
  sessionFamilies?: string[];
16
16
  waiterId?: string;
17
+ exclude?: string[];
17
18
  };
18
19
 
19
20
  const MAX_WAITERS_PER_ACCOUNT = 4;
@@ -82,7 +83,8 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
82
83
  const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
83
84
  const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
84
85
  let stamped = false;
85
- if (enforced && origin) {
86
+ const credits = enforced?.kind === "credits";
87
+ if (enforced && origin && !credits) {
86
88
  const covered = modelKindCovered(enforced, origin, now, bars.weekly);
87
89
  if (!covered || prior) {
88
90
  origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
@@ -92,7 +94,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
92
94
  saveAccounts(p.pool, idx);
93
95
  log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active, covered: covered && !prior });
94
96
  }
95
- const enforced2 = stamped && origin === active ? enforced : null;
97
+ const enforced2 = (stamped || credits) && origin === active ? enforced : null;
96
98
 
97
99
  if (id2 != null && !active) {
98
100
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
@@ -125,7 +127,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
125
127
 
126
128
  const seatOf = (cur: { accounts: Account[] }): Account | null => cur.accounts.find((a) => a.id === id2) ?? null;
127
129
 
128
- const rejected = new Set<string>();
130
+ const rejected = new Set<string>(opts.exclude);
129
131
  const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (p.seats === "shared" || a.id === id2 || !present.has(a.id)));
130
132
  const skipOrThrow = (e: unknown, candidate: Account): void => {
131
133
  if (p.classifySwapError(e) === "fatal") throw e;
package/src/lib/picker.ts CHANGED
@@ -49,7 +49,7 @@ export function liveUsed(w: Window, now: number): number {
49
49
  return w.usedPercentage;
50
50
  }
51
51
 
52
- function barFor(w: Window, thresholds: Thresholds): number {
52
+ export function barFor(w: Window, thresholds: Thresholds): number {
53
53
  return isSessionWindow(w) ? thresholds.session : thresholds.weekly;
54
54
  }
55
55
 
@@ -4,7 +4,7 @@ import type { Account, Config, Window } from "./types.ts";
4
4
 
5
5
  export type Observation = { windows: Window[]; at: number };
6
6
 
7
- export type SampleReport = { ok: true; source: "statusline" | "probe" } | { ok: false; reason: string };
7
+ export type SampleReport = { ok: true; source: "statusline" | "probe" | "cached" } | { ok: false; reason: string };
8
8
 
9
9
  export type SwapFailure = "dead-grant" | "skip" | "fatal";
10
10
 
package/src/lib/sample.ts CHANGED
@@ -3,16 +3,36 @@ import { withLock } from "./lock.ts";
3
3
  import { errorMessage, log } from "./log.ts";
4
4
  import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
5
5
  import { claudePool, paths } from "./paths.ts";
6
- import { landWindows, thresholdBars } from "./picker.ts";
6
+ import { barFor, gatedWindows, landWindows, liveUsed, thresholdBars } from "./picker.ts";
7
7
  import { seatCounts } from "./presence.ts";
8
8
  import type { Observation } from "./provider.ts";
9
9
  import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
10
10
  import { fetchUsageDirect, mergeWindows, windowsOf } from "./usage.ts";
11
- import type { Account, Config, Thresholds, UsageWindows } from "./types.ts";
11
+ import type { Account, Config, Thresholds, UsageWindows, Window } from "./types.ts";
12
12
 
13
13
  export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string; retryAt?: number };
14
14
 
15
- type PreparedSample = { ok: true; token: string } | { ok: false; reason: string };
15
+ type PreparedSample = { ok: true; token: string; expiresAt: number } | { ok: false; reason: string };
16
+
17
+ const SAMPLE_INTERVAL_MAX_MS = 15 * 60 * 1000;
18
+
19
+ export function sampleIntervalMs(account: Account, cfg: Config, now: number): number {
20
+ const floor = cfg.policy.usagePollTtlMs;
21
+ const bars = thresholdBars(cfg);
22
+ const aggregates = account.windows.filter((w) => w.name == null);
23
+ if (aggregates.length === 0) return floor;
24
+ const blocked = (w: Window) => liveUsed(w, now) >= barFor(w, bars);
25
+ if (aggregates.some(blocked)) return Math.max(floor, SAMPLE_INTERVAL_MAX_MS);
26
+ const open = [...aggregates, ...gatedWindows(account, cfg.policy.switchModels).filter((w) => !blocked(w))];
27
+ const spare = Math.min(...open.map((w) => 1 - liveUsed(w, now) / barFor(w, bars)));
28
+ return Math.max(floor, SAMPLE_INTERVAL_MAX_MS * spare);
29
+ }
30
+
31
+ const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
32
+
33
+ export function sampleDue(account: Account, cfg: Config, now: number): boolean {
34
+ return now - sampledAt(account) > sampleIntervalMs(account, cfg, now);
35
+ }
16
36
 
17
37
  export function teeObservation(account: Account): Observation | null {
18
38
  const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
@@ -45,7 +65,7 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
45
65
  }
46
66
  if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
47
67
  account.tier = claudeTierLabel(creds) ?? account.tier;
48
- return { ok: true, token: creds.accessToken };
68
+ return { ok: true, token: creds.accessToken, expiresAt: creds.expiresAt };
49
69
  }
50
70
 
51
71
  export function usageBlockedUntil(account: Account, now: number): number | null {
@@ -56,19 +76,27 @@ function rateLimitedReason(retryAt: number, now: number): string {
56
76
  return `the usage endpoint rate limited this account, next read in ${Math.ceil((retryAt - now) / 60_000)}m`;
57
77
  }
58
78
 
59
- export async function runSample(account: Account, token: string): Promise<SampleOutcome> {
60
- const read = await fetchUsageDirect(token);
79
+ const EXPIRED_REASON = "the stored access token expired, next read after a session on this account refreshes it";
80
+
81
+ export async function runSample(account: Account, prepared: { token: string; expiresAt: number }): Promise<SampleOutcome> {
82
+ if (prepared.expiresAt <= Date.now()) return { ok: false, reason: EXPIRED_REASON };
83
+ const read = await fetchUsageDirect(prepared.token);
61
84
  if (read.ok) return { ok: true, usage: read.usage, via: "get" };
62
85
  if (read.retryAt == null) return { ok: false, reason: "usage read failed (see log)" };
63
86
  return { ok: false, reason: rateLimitedReason(read.retryAt, Date.now()), retryAt: read.retryAt };
64
87
  }
65
88
 
66
- export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
67
- const now = Date.now();
89
+ export async function readyToSample(account: Account, now: number): Promise<PreparedSample> {
68
90
  const blockedUntil = usageBlockedUntil(account, now);
69
91
  if (blockedUntil != null) return { ok: false, reason: rateLimitedReason(blockedUntil, now) };
70
92
  const prepared = await prepareSample(account);
71
- return prepared.ok ? runSample(account, prepared.token) : prepared;
93
+ if (prepared.ok && prepared.expiresAt <= now) return { ok: false, reason: EXPIRED_REASON };
94
+ return prepared;
95
+ }
96
+
97
+ export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
98
+ const ready = await readyToSample(account, Date.now());
99
+ return ready.ok ? runSample(account, ready) : ready;
72
100
  }
73
101
 
74
102
  const SAMPLE_BATCH = 3;
@@ -80,17 +108,16 @@ export async function sampleOldest(cfg: Config): Promise<void> {
80
108
  const idx = loadAccounts(claudePool);
81
109
  let dirty = false;
82
110
  for (const a of idx.accounts) dirty = foldTee(a, thresholdBars(cfg)) || dirty;
83
- const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
84
111
  const seats = seatCounts(paths.presenceDir);
85
112
  const stale = idx.accounts
86
- .filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
113
+ .filter((a) => a.needsReauth !== true && sampleDue(a, cfg, now))
87
114
  .sort((a, b) => Number(seats.has(b.id)) - Number(seats.has(a.id)) || sampledAt(a) - sampledAt(b))
88
115
  .slice(0, SAMPLE_BATCH);
89
116
  if (stale.length === 0) {
90
117
  if (dirty) saveAccounts(claudePool, idx);
91
118
  return [];
92
119
  }
93
- const batch: { account: Account; token: string }[] = [];
120
+ const batch: { account: Account; prepared: { token: string; expiresAt: number } }[] = [];
94
121
  for (const target of stale) {
95
122
  target.lastProbeAt = now;
96
123
  const prepared = await prepareSample(target);
@@ -101,15 +128,15 @@ export async function sampleOldest(cfg: Config): Promise<void> {
101
128
  continue;
102
129
  }
103
130
  target.storeFails = 0;
104
- if (usageBlockedUntil(target, now) == null) batch.push({ account: target, token: prepared.token });
131
+ if (usageBlockedUntil(target, now) == null) batch.push({ account: target, prepared });
105
132
  }
106
133
  saveAccounts(claudePool, idx);
107
134
  return batch;
108
135
  });
109
136
  await Promise.all(
110
- reserved.map(async ({ account, token }) => {
137
+ reserved.map(async ({ account, prepared }) => {
111
138
  const startedAt = Date.now();
112
- const outcome = await runSample(account, token);
139
+ const outcome = await runSample(account, prepared);
113
140
  await withLock(claudePool.lockFile, () => {
114
141
  const idx = loadAccounts(claudePool);
115
142
  const stored = idx.accounts.find((a) => a.id === account.id);
@@ -104,6 +104,7 @@ export function pruneStaleSessions(now: number): void {
104
104
  export type SupervisedSession = { sid: string; launchedAt: number | null; live: string };
105
105
 
106
106
  const LaunchedAtSchema = z.coerce.number().finite().optional().catch(undefined);
107
+ const RefusedSchema = z.string().optional().transform((ids) => (ids ?? "").split(",").filter((id) => id !== ""));
107
108
 
108
109
  export function supervisedSession(env: Record<string, string | undefined> = process.env): SupervisedSession | null {
109
110
  if (env.TOKENMAXXING_SUPERVISED !== "1") return null;
@@ -112,6 +113,10 @@ export function supervisedSession(env: Record<string, string | undefined> = proc
112
113
  return { sid, launchedAt: LaunchedAtSchema.parse(env.TOKENMAXXING_LAUNCHED_AT) ?? null, live: liveSessionId(sid) };
113
114
  }
114
115
 
116
+ export function refusedAccounts(env: Record<string, string | undefined> = process.env): string[] {
117
+ return RefusedSchema.parse(env.TOKENMAXXING_REFUSED);
118
+ }
119
+
115
120
  export function adoptLiveSession(session: SupervisedSession, stdinSid: string | undefined): SupervisedSession | null {
116
121
  if (stdinSid == null || stdinSid === session.live) return session;
117
122
  const child = presencePid({ dir: paths.presenceDir, id: session.sid });
@@ -124,7 +129,7 @@ export function adoptLiveSession(session: SupervisedSession, stdinSid: string |
124
129
  return { ...session, live: stdinSid };
125
130
  }
126
131
 
127
- export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"] }): void {
132
+ export function writeRespawnMarker(input: { session: SupervisedSession; accountId: string; waitUntil: number; compact: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; refused?: string[] }): void {
128
133
  const payload: z.infer<typeof RespawnMarkerSchema> = {
129
134
  accountId: input.accountId,
130
135
  ts: Date.now(),
@@ -133,6 +138,7 @@ export function writeRespawnMarker(input: { session: SupervisedSession; accountI
133
138
  compact: input.compact,
134
139
  origin: input.origin,
135
140
  ...(input.session.launchedAt != null ? { launchedAt: input.session.launchedAt } : {}),
141
+ ...(input.refused != null ? { refused: input.refused } : {}),
136
142
  };
137
143
  writeFileAtomic(join(paths.respawnDir, input.session.sid), JSON.stringify(payload));
138
144
  }
package/src/lib/types.ts CHANGED
@@ -84,7 +84,7 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
84
84
 
85
85
  export type EnforcedLimit = {
86
86
  account: string;
87
- kind: "session" | "weekly" | "model";
87
+ kind: "session" | "weekly" | "model" | "credits";
88
88
  family: string | null;
89
89
  resetsAt: number | null;
90
90
  blind: boolean;
@@ -150,6 +150,7 @@ export const RespawnMarkerSchema = z.object({
150
150
  compact: z.boolean(),
151
151
  origin: z.enum(["stop", "sessionstart", "stopfailure", "seatwatch"]),
152
152
  launchedAt: z.number().optional(),
153
+ refused: z.array(z.string()).optional(),
153
154
  });
154
155
 
155
156
  export const EpochSecondsSchema = z.number().transform((seconds) => seconds * 1000);
package/src/lib/usage.ts CHANGED
@@ -56,6 +56,7 @@ export const TranscriptRowSchema = z.looseObject({
56
56
  timestamp: z.string().optional(),
57
57
  isApiErrorMessage: z.boolean().optional(),
58
58
  apiErrorIsTransient: z.boolean().optional(),
59
+ apiError: z.string().optional(),
59
60
  error: z.string().optional(),
60
61
  errorDetails: z.string().optional(),
61
62
  quotaLimits: z.looseObject({ rateLimitType: z.string().optional(), resetsAt: EpochSecondsSchema.optional() }).optional(),
@@ -114,13 +115,14 @@ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMes
114
115
  export type EnforcedClass =
115
116
  | { kind: "session"; resetsAt: number | null }
116
117
  | { kind: "weekly"; resetsAt: number | null }
117
- | { kind: "model"; family: string; resetsAt: number | null };
118
+ | { kind: "model"; family: string; resetsAt: number | null }
119
+ | { kind: "credits"; family: string; resetsAt: null };
118
120
 
119
121
  const ErrorBodySchema = z.looseObject({
120
122
  error: z.looseObject({ type: z.string().optional(), details: z.looseObject({ error_code: z.string().optional() }).optional() }).optional(),
121
123
  });
122
124
 
123
- const CREDITS_GATED_FAMILIES = ["fable"];
125
+ const CREDITS_FAMILY = "fable";
124
126
 
125
127
  export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof ErrorBodySchema> | null {
126
128
  if (!errorDetails) return null;
@@ -131,19 +133,15 @@ export function parseErrorBody(errorDetails: string | undefined): z.infer<typeof
131
133
  }
132
134
 
133
135
  export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]): EnforcedClass | null {
136
+ if (row.apiError === "model_requires_usage_credits") return { kind: "credits", family: CREDITS_FAMILY, resetsAt: null };
134
137
  const q = row.quotaLimits;
135
- if (q) {
136
- const resetsAt = q.resetsAt ?? null;
137
- const type = q.rateLimitType ?? "";
138
- if (type === "five_hour") return { kind: "session", resetsAt };
139
- if (type === "seven_day") return { kind: "weekly", resetsAt };
140
- const family = switchModels.find((f) => type.includes(f));
141
- return family ? { kind: "model", family, resetsAt } : null;
142
- }
143
- if (row.apiErrorIsTransient === true) return null;
144
- if (parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
145
- const family = switchModels.find((f) => CREDITS_GATED_FAMILIES.includes(f));
146
- return family ? { kind: "model", family, resetsAt: null } : null;
138
+ if (!q) return null;
139
+ const resetsAt = q.resetsAt ?? null;
140
+ const type = q.rateLimitType ?? "";
141
+ if (type === "five_hour") return { kind: "session", resetsAt };
142
+ if (type === "seven_day") return { kind: "weekly", resetsAt };
143
+ const family = switchModels.find((f) => type.includes(f));
144
+ return family ? { kind: "model", family, resetsAt } : null;
147
145
  }
148
146
 
149
147
  const FIVE_HOURS_S = 5 * 3600;