tokenmaxxing 1.58.0 → 1.60.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
@@ -90,7 +90,7 @@ The [switching policy](docs/content/docs/switching.mdx) is one rule: under both
90
90
 
91
91
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
92
92
 
93
- **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move, and a launch, whose model is unknown, gates every configured family.
93
+ **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move. A session's model is the `--model` value it was launched with: the supervisor reads it from the launch flags, uses it for launch placement and the seat watch, and hands it to the session's hooks in `TOKENMAXXING_MODEL`, which the hooks read ahead of the per-account tee. With the default `policy.switchModels`, a session launched as Opus, Sonnet, or Haiku gates no per-model cap, so it runs on an account whose Fable cap is spent while the session and weekly windows have headroom. A launch without `--model`, or with a value that names no model family (`best`, `opusplan`), is unknown: launch placement and the seat watch gate every configured family for it, and its hooks fall back to the model in the account's tee. A move off a walled seat, or one that follows a subagent's five-hour or weekly refusal, screens candidates on every configured family whatever the launch model.
94
94
 
95
95
  ---
96
96
 
@@ -115,7 +115,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
115
115
 
116
116
  **Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
117
117
 
118
- **Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor, hooks, or usage source ([Status-only pools](docs/content/docs/status-only-pools.mdx)).
118
+ **Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor or hooks ([Status-only pools](docs/content/docs/status-only-pools.mdx)). grok `status` reads the weekly subscription credit window from the credits billing GET; opencode-go has no usage source.
119
119
 
120
120
  **Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes `.cursor/environment.json` and a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
121
121
 
package/README.md CHANGED
@@ -84,7 +84,7 @@ 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 active 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.
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.
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
 
@@ -142,7 +142,7 @@ A Cursor Cloud Agent can hand substantial work to Claude Code running on your ow
142
142
  - **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
143
143
  - **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
144
144
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
145
- - **Status-only pools carry no usage.** grok and opencode-go accounts render with no bars and never move.
145
+ - **Status-only pools do not switch.** grok `status` shows the weekly subscription credit window; opencode-go accounts still render with no bars. Neither pool moves.
146
146
 
147
147
  ## How it's built
148
148
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.58.0",
3
+ "version": "1.60.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
@@ -88,9 +88,14 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
88
88
  if (!cached && idx.accounts.length > 0) {
89
89
  await withLock(p.pool.lockFile, async () => {
90
90
  idx = loadAccounts(p.pool);
91
- console.error(c.dim(`sampling ${p.name} usage...`));
92
- liveId = p.liveId();
93
- reports = await p.samplePool(idx.accounts, liveId, now);
91
+ const notice = process.stderr.isTTY;
92
+ if (notice) process.stderr.write(c.dim(`sampling ${p.name} usage...`));
93
+ try {
94
+ liveId = p.liveId();
95
+ reports = await p.samplePool(idx.accounts, liveId, now);
96
+ } finally {
97
+ if (notice) process.stderr.write("\r\x1b[2K");
98
+ }
94
99
  saveAccounts(p.pool, idx);
95
100
  });
96
101
  }
@@ -21,7 +21,7 @@ async function compactBeforeMove(input: { sessionId: string | null; now: number
21
21
  const liveId = codex.liveId();
22
22
  const live = liveId == null ? undefined : loadAccounts(codex.pool).accounts.find((a) => a.id === liveId);
23
23
  if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
24
- const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
24
+ const observed = await codex.observeLive(live, loadConfig(), now, { probe: true, perModel: false });
25
25
  if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
26
26
  const env: Record<string, string | undefined> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
27
27
  delete env[CODEX_SUPERVISOR_ID_ENV];
@@ -17,7 +17,8 @@ import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, r
17
17
  import { saveTermios } from "../lib/tty.ts";
18
18
  import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
19
19
  import { loadAccounts, loadConfig } from "../lib/state.ts";
20
- import { RespawnMarkerSchema, type Account } from "../lib/types.ts";
20
+ import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
21
+ import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
21
22
  import { errorMessage, log } from "../lib/log.ts";
22
23
 
23
24
  const NONINTERACTIVE_SUBCMDS = new Set([
@@ -53,6 +54,7 @@ type Analysis = {
53
54
  continueLatest: boolean;
54
55
  streamInput: boolean;
55
56
  streamOutput: boolean;
57
+ model: string | null;
56
58
  };
57
59
 
58
60
  export function analyzeArgs(argv: string[]): Analysis {
@@ -61,6 +63,7 @@ export function analyzeArgs(argv: string[]): Analysis {
61
63
  let continueLatest = false;
62
64
  let streamInput = false;
63
65
  let streamOutput = false;
66
+ let model: string | null = null;
64
67
  let printMode = false;
65
68
  let invalidSessionArg = false;
66
69
  let pickerResume = false;
@@ -98,6 +101,8 @@ export function analyzeArgs(argv: string[]): Analysis {
98
101
  else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
99
102
  else if (a === "--output-format") streamOutput = argv[++i] === "stream-json";
100
103
  else if (a.startsWith("--output-format=")) streamOutput = a.slice("--output-format=".length) === "stream-json";
104
+ else if (a === "--model") model = argv[++i] ?? null;
105
+ else if (a.startsWith("--model=")) model = a.slice("--model=".length);
101
106
  else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
102
107
  else if (VARIADIC_ROOT_FLAGS.has(a)) {
103
108
  while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
@@ -113,7 +118,7 @@ export function analyzeArgs(argv: string[]): Analysis {
113
118
  const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
114
119
  const forkResume = forkSession && (resumeId !== null || continueLatest);
115
120
  const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
116
- return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput };
121
+ return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
117
122
  }
118
123
 
119
124
  export function stripSessionFlags(argv: string[]): string[] {
@@ -201,22 +206,23 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
201
206
  const SEAT_POLL_MS = 10_000;
202
207
  const SEAT_RETRY_MS = 60_000;
203
208
 
204
- function seatBlockedUntil(seatId: string, now: number): number | null {
209
+ function seatBlockedUntil(seatId: string, now: number, families: string[], cfg: Config): number | null {
205
210
  const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
206
211
  if (!account) return null;
207
- const cfg = loadConfig();
208
212
  const observed = teeObservation(account);
209
213
  const current = observed ? { ...account, windows: observed.windows } : account;
210
- const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families: cfg.policy.switchModels, seats: null });
214
+ const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
211
215
  return until > now ? until : null;
212
216
  }
213
217
 
214
- async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
218
+ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null): Promise<boolean> {
215
219
  try {
216
220
  const now = Date.now();
217
- const until = seatBlockedUntil(seat.id, now);
221
+ const cfg = loadConfig();
222
+ const families = gatedFamilies(model, cfg.policy.switchModels);
223
+ const until = seatBlockedUntil(seat.id, now, families, cfg);
218
224
  if (until == null || until <= gate.overriddenUntil) return false;
219
- const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
225
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
220
226
  log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
221
227
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
222
228
  writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
@@ -342,6 +348,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
342
348
  const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
343
349
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
344
350
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
351
+ delete passthroughEnv.TOKENMAXXING_MODEL;
345
352
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
346
353
  }
347
354
 
@@ -380,6 +387,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
380
387
  const effective = analyzeArgs(base);
381
388
  const relay = effective.streamInput ? new StdinRelay() : null;
382
389
  const stream = effective.streamOutput;
390
+ const model = modelFromFlag(effective.model);
383
391
  let noticeSid = sid;
384
392
  const say: Say = (terminal, text) => {
385
393
  if (stream) process.stdout.write(systemLine(noticeSid, text));
@@ -394,7 +402,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
394
402
 
395
403
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
396
404
  const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
397
- const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
405
+ const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
398
406
  log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
399
407
  const spawned = Bun.spawn([real, ...launchArgs], {
400
408
  stdin: relay === null ? "inherit" : "pipe",
@@ -405,6 +413,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
405
413
  TOKENMAXXING_SUPERVISED: "1",
406
414
  TOKENMAXXING_SESSION_ID: sid,
407
415
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
416
+ TOKENMAXXING_MODEL: effective.model ?? "",
408
417
  ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
409
418
  },
410
419
  });
@@ -431,7 +440,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
431
440
  tick: async () => {
432
441
  if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
433
442
  if (seat && !terminating && Date.now() >= seatCheckAt) {
434
- const decided = await moveExhaustedSeat(seat, sid, gate);
443
+ const decided = await moveExhaustedSeat(seat, sid, gate, model);
435
444
  seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
436
445
  }
437
446
  return false;
@@ -459,6 +468,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
459
468
  delete compactEnv.TOKENMAXXING_SUPERVISED;
460
469
  delete compactEnv.TOKENMAXXING_SESSION_ID;
461
470
  delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
471
+ delete compactEnv.TOKENMAXXING_MODEL;
462
472
  const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
463
473
  log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
464
474
  if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
package/src/lib/claude.ts CHANGED
@@ -8,7 +8,7 @@ import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath
8
8
  import { withLock } from "./lock.ts";
9
9
  import { errorMessage, log } from "./log.ts";
10
10
  import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
11
- import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
11
+ import { claudePool, env, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
12
12
  import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { seatCounts } from "./presence.ts";
14
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
@@ -16,8 +16,8 @@ import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
16
16
  import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
- import { fetchUsageDirect, gatedFamilies, mergeWindows, scrubCredentialEnv, windowsOf } from "./usage.ts";
20
- import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
19
+ import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
20
+ import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
21
21
  import { c } from "../cli/render.ts";
22
22
 
23
23
  class StoreUnusableError extends Error {
@@ -37,14 +37,13 @@ function presence(): Map<string, number> {
37
37
 
38
38
  const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
39
39
 
40
- async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
40
+ async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
41
41
  const ttl = cfg.policy.usagePollTtlMs;
42
42
  const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
43
43
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
44
44
  const snap = loadUsageSnapshot(account.id);
45
45
  const fresh = snap != null && now - snap.at <= ttl;
46
- const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
47
- if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
46
+ if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
48
47
  const startedAt = Date.now();
49
48
  const outcome = await sampleAccountUsage(account);
50
49
  await withLock(claudePool.lockFile, () => {
@@ -125,13 +124,13 @@ async function storeUsable(a: Account): Promise<boolean> {
125
124
  }
126
125
  }
127
126
 
128
- export function pickSeat(now: number): Account | null {
127
+ export function pickSeat(now: number, model: ModelInfo | null): Account | null {
129
128
  const cfg = loadConfig();
130
129
  const idx = loadAccounts(claudePool);
131
130
  let dirty = false;
132
131
  for (const a of idx.accounts) dirty = foldTee(a) || dirty;
133
132
  if (dirty) saveAccounts(claudePool, idx);
134
- const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
133
+ const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats: presence() };
135
134
  return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
136
135
  }
137
136
 
@@ -286,7 +285,8 @@ export const claude: Provider = {
286
285
  presence,
287
286
  gatedFamilies: (cfg) => {
288
287
  const seat = liveId();
289
- return gatedFamilies(seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null), cfg.policy.switchModels);
288
+ const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
289
+ return gatedFamilies(model, cfg.policy.switchModels);
290
290
  },
291
291
  observeLive,
292
292
  samplePool,
package/src/lib/decide.ts CHANGED
@@ -36,16 +36,17 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
36
36
  return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
37
37
  }
38
38
 
39
- export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
39
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId(), sessionFamilies?: string[]): Promise<SwapDecision> {
40
40
  const activeId = seatId;
41
41
  const cfg = loadConfig();
42
42
  const bars = thresholdBars(cfg);
43
43
  const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
44
44
  const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
45
- const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
45
+ const gated = sessionFamilies ?? p.gatedFamilies(cfg);
46
+ const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
46
47
  const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
47
48
 
48
- if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg), seats: null })) {
49
+ if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
49
50
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
50
51
  }
51
52
 
@@ -70,7 +71,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
70
71
 
71
72
  let obs2: Observation | null = null;
72
73
  for (const a of idx.accounts) {
73
- const obs = await p.observeLive(a, cfg, now, { probe: false });
74
+ const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
74
75
  if (a === active) obs2 = obs;
75
76
  if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
76
77
  a.windows = p.mergeWindows(obs.windows, a.windows);
@@ -79,7 +80,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
79
80
  }
80
81
  saveAccounts(p.pool, idx);
81
82
 
82
- const families = p.gatedFamilies(cfg);
83
+ const families = sessionFamilies ?? p.gatedFamilies(cfg);
83
84
  const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
84
85
  const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
85
86
  const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
package/src/lib/grok.ts CHANGED
@@ -2,10 +2,13 @@ import { mkdirSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
+ import { fetchGrokUsage, GrokUsageReadError } from "./grokusage.ts";
6
+ import { errorMessage } from "./log.ts";
5
7
  import { grokAuthJsonFor, grokPaths, grokPool, grokSeatFromEnv, grokStoreDirFor } from "./paths.ts";
6
- import type { Provider } from "./provider.ts";
8
+ import type { Provider, SampleReport } from "./provider.ts";
7
9
  import { loadAccounts, type Harvest } from "./state.ts";
8
10
  import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
11
+ import type { Account } from "./types.ts";
9
12
  import { c } from "../cli/render.ts";
10
13
 
11
14
  const GrokAuthEntrySchema = z.looseObject({
@@ -13,11 +16,14 @@ const GrokAuthEntrySchema = z.looseObject({
13
16
  principal_id: z.string().optional(),
14
17
  email: z.string().nullish(),
15
18
  first_name: z.string().nullish(),
19
+ key: z.string().optional(),
16
20
  refresh_token: z.string().optional(),
17
21
  expires_at: z.string().nullish(),
18
22
  });
19
23
  type GrokAuthEntry = z.infer<typeof GrokAuthEntrySchema>;
20
24
 
25
+ type GrokCred = { id: string; token: string; expiresAt: number | null };
26
+
21
27
  function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
22
28
  return {
23
29
  id,
@@ -31,7 +37,7 @@ function harvestOf(id: string, key: string, entry: GrokAuthEntry): Harvest {
31
37
  };
32
38
  }
33
39
 
34
- function readAuth(path: string): AuthEntry[] {
40
+ function readMap(path: string): [string, GrokAuthEntry][] {
35
41
  let raw: unknown;
36
42
  try {
37
43
  raw = JSON.parse(readFileSync(path, "utf8"));
@@ -40,18 +46,87 @@ function readAuth(path: string): AuthEntry[] {
40
46
  }
41
47
  const map = z.record(z.string(), z.unknown()).safeParse(raw);
42
48
  if (!map.success) return [];
43
- const out: AuthEntry[] = [];
49
+ const out: [string, GrokAuthEntry][] = [];
44
50
  for (const [key, value] of Object.entries(map.data)) {
45
51
  const parsed = GrokAuthEntrySchema.safeParse(value);
46
52
  if (!parsed.success) continue;
47
- const entry = parsed.data;
53
+ out.push([key, parsed.data]);
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function readAuth(path: string): AuthEntry[] {
59
+ const out: AuthEntry[] = [];
60
+ for (const [key, entry] of readMap(path)) {
48
61
  const id = entry.user_id ?? entry.principal_id ?? key;
49
62
  out.push({ id, usable: (entry.refresh_token ?? "") !== "", harvest: id === "" ? null : harvestOf(id, key, entry) });
50
63
  }
51
64
  return out;
52
65
  }
53
66
 
54
- export const grok: Provider = statusOnlyProvider({
67
+ function credsIn(path: string): GrokCred[] {
68
+ const out: GrokCred[] = [];
69
+ for (const [key, entry] of readMap(path)) {
70
+ const id = entry.user_id ?? entry.principal_id ?? key;
71
+ const token = entry.key ?? "";
72
+ if (id === "" || token === "") continue;
73
+ const expiresAt = entry.expires_at != null ? Date.parse(entry.expires_at) : null;
74
+ out.push({ id, token, expiresAt: expiresAt != null && Number.isFinite(expiresAt) ? expiresAt : null });
75
+ }
76
+ return out;
77
+ }
78
+
79
+ function liveCred(accountId: string): GrokCred | null {
80
+ return credsIn(join(grokPaths.home, "auth.json")).find((c) => c.id === accountId) ?? null;
81
+ }
82
+
83
+ function bearerFor(accountId: string, now: number): { token: string; from: "store" | "live" } | null {
84
+ const store = credsIn(grokAuthJsonFor(accountId)).find((c) => c.id === accountId) ?? null;
85
+ const live = liveCred(accountId);
86
+ if (store != null && (store.expiresAt == null || store.expiresAt > now + 60_000)) return { token: store.token, from: "store" };
87
+ if (live != null) return { token: live.token, from: "live" };
88
+ if (store != null) return { token: store.token, from: "store" };
89
+ return null;
90
+ }
91
+
92
+ async function readGrokUsage(account: Account, now: number): Promise<{ ok: true; at: number } | { ok: false; reason: string }> {
93
+ const first = bearerFor(account.id, now);
94
+ if (first == null) return { ok: false, reason: "no usable credential in this account's store - run `tokenmaxxing auth --grok`" };
95
+ try {
96
+ const usage = await fetchGrokUsage({ token: first.token, at: now });
97
+ account.windows = usage.windows;
98
+ account.lastUsageAt = usage.at;
99
+ return { ok: true, at: usage.at };
100
+ } catch (e) {
101
+ if (e instanceof GrokUsageReadError && e.status === 401 && first.from === "store") {
102
+ const live = liveCred(account.id);
103
+ if (live != null && live.token !== first.token) {
104
+ try {
105
+ const usage = await fetchGrokUsage({ token: live.token, at: now });
106
+ account.windows = usage.windows;
107
+ account.lastUsageAt = usage.at;
108
+ return { ok: true, at: usage.at };
109
+ } catch (retry) {
110
+ return { ok: false, reason: retry instanceof GrokUsageReadError ? retry.message : errorMessage(retry) };
111
+ }
112
+ }
113
+ }
114
+ return { ok: false, reason: e instanceof GrokUsageReadError ? e.message : errorMessage(e) };
115
+ }
116
+ }
117
+
118
+ async function samplePool(accounts: Account[], _liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
119
+ const reports = new Map<string, SampleReport>();
120
+ await Promise.all(
121
+ accounts.map(async (account) => {
122
+ const outcome = await readGrokUsage(account, now);
123
+ reports.set(account.id, outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason });
124
+ }),
125
+ );
126
+ return reports;
127
+ }
128
+
129
+ const base = statusOnlyProvider({
55
130
  name: "grok",
56
131
  flag: " --grok",
57
132
  pool: grokPool,
@@ -72,3 +147,8 @@ export const grok: Provider = statusOnlyProvider({
72
147
  installNotice: "grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts.",
73
148
  loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
74
149
  });
150
+
151
+ export const grok: Provider = {
152
+ ...base,
153
+ samplePool,
154
+ };
@@ -0,0 +1,75 @@
1
+ import { z } from "zod";
2
+ import { http, safeErrorDetail } from "./http.ts";
3
+ import { errorMessage } from "./log.ts";
4
+ import { env } from "./paths.ts";
5
+ import { JsonTextSchema, type Window } from "./types.ts";
6
+
7
+ export class GrokUsageReadError extends Error {
8
+ status: number | null;
9
+ constructor(detail: string, status: number | null = null) {
10
+ super(`grok usage read failed: ${detail}`);
11
+ this.name = "GrokUsageReadError";
12
+ this.status = status;
13
+ }
14
+ }
15
+
16
+ const InstantSchema = z.iso.datetime({ offset: true });
17
+
18
+ const CreditsPeriodSchema = z.looseObject({
19
+ start: InstantSchema,
20
+ end: InstantSchema,
21
+ });
22
+
23
+ const CreditsConfigSchema = z.looseObject({
24
+ currentPeriod: CreditsPeriodSchema,
25
+ creditUsagePercent: z.number(),
26
+ });
27
+
28
+ const CreditsBillingSchema = z.looseObject({
29
+ config: CreditsConfigSchema,
30
+ });
31
+
32
+ export type GrokUsage = { windows: Window[]; at: number };
33
+
34
+ function usageUrl(): string {
35
+ return env("TOKENMAXXING_GROK_USAGE_URL", "https://cli-chat-proxy.grok.com/v1/billing?format=credits");
36
+ }
37
+
38
+ export async function fetchGrokUsage(input: { token: string; at: number }): Promise<GrokUsage> {
39
+ const { token, at } = input;
40
+ let res: Response;
41
+ try {
42
+ res = await http.get(usageUrl(), {
43
+ headers: {
44
+ Authorization: `Bearer ${token}`,
45
+ Accept: "application/json",
46
+ "User-Agent": "grok",
47
+ },
48
+ });
49
+ } catch (e) {
50
+ throw new GrokUsageReadError(`endpoint unreachable: ${errorMessage(e)}`);
51
+ }
52
+ const text = await res.text();
53
+ if (!res.ok) {
54
+ throw new GrokUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`, res.status);
55
+ }
56
+ const parsed = CreditsBillingSchema.safeParse(JsonTextSchema.safeParse(text).data);
57
+ if (!parsed.success) {
58
+ throw new GrokUsageReadError("endpoint returned an unexpected body shape (withheld)");
59
+ }
60
+ const period = parsed.data.config.currentPeriod;
61
+ const start = Date.parse(period.start);
62
+ const end = Date.parse(period.end);
63
+ return {
64
+ at,
65
+ windows: [
66
+ {
67
+ name: null,
68
+ usedPercentage: parsed.data.config.creditUsagePercent,
69
+ resetsAt: Number.isFinite(end) ? end : null,
70
+ windowSeconds: Number.isFinite(start) && Number.isFinite(end) && end > start ? Math.round((end - start) / 1000) : null,
71
+ sampledAt: at,
72
+ },
73
+ ],
74
+ };
75
+ }
@@ -18,7 +18,7 @@ export type Provider = {
18
18
  liveId(): string | null;
19
19
  presence(): Map<string, number>;
20
20
  gatedFamilies(cfg: Config): string[] | null;
21
- observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
21
+ observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
22
22
  samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
23
23
  mergeWindows(next: Window[], prev: Window[]): Window[];
24
24
  swap(target: Account): Promise<void>;
package/src/lib/usage.ts CHANGED
@@ -44,6 +44,14 @@ export function gatedFamilies(model: ModelInfo | null, families: string[]): stri
44
44
  return family ? [family] : [];
45
45
  }
46
46
 
47
+ const MODEL_FAMILIES = ["sonnet", "opus", "haiku", "fable"];
48
+
49
+ export function modelFromFlag(value: string | null): ModelInfo | null {
50
+ const name = value?.split("[")[0]?.trim() ?? "";
51
+ const tokens = familyTokens(name);
52
+ return MODEL_FAMILIES.some((f) => tokens.includes(f)) ? { id: name, display: name } : null;
53
+ }
54
+
47
55
  const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
48
56
  export const TranscriptRowSchema = z.looseObject({
49
57
  type: z.string().optional(),