tokenmaxxing 1.64.0 → 1.65.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
@@ -31,7 +31,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
31
31
  - Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin/grokBin/opencodeBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
34
+ - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, usageRetryAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
35
35
  - `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
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.
@@ -84,7 +84,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
84
84
  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, wait for the soonest reset. The same ranking places a session at launch.
85
85
 
86
86
  - 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.
87
- - 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. An expired stored access token stays unmeasured until a session on that store refreshes it.
87
+ - 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.
88
88
  - 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.
89
89
  - 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.
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.64.0",
3
+ "version": "1.65.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/lib/claude.ts CHANGED
@@ -12,7 +12,7 @@ import { claudePool, env, paths, 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";
15
- import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
15
+ import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } 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";
@@ -43,7 +43,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
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
- if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
46
+ if (opts.probe && !probeAttempted && usageBlockedUntil(account, now) == null && (!fresh || opts.perModel)) {
47
47
  const startedAt = Date.now();
48
48
  const outcome = await sampleAccountUsage(account);
49
49
  await withLock(claudePool.lockFile, () => {
@@ -61,6 +61,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
61
61
  }
62
62
  } else {
63
63
  a.probeFails = (a.probeFails ?? 0) + 1;
64
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
64
65
  }
65
66
  saveAccounts(claudePool, idx);
66
67
  });
@@ -82,6 +83,7 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
82
83
  }
83
84
  const outcome = await sampleAccountUsage(a);
84
85
  if (!outcome.ok) {
86
+ if (outcome.retryAt != null) a.usageRetryAt = outcome.retryAt;
85
87
  reports.set(a.id, { ok: false, reason: outcome.reason });
86
88
  return;
87
89
  }
@@ -215,7 +217,7 @@ async function login(): Promise<Harvest | null> {
215
217
 
216
218
  console.log(c.dim("sampling usage..."));
217
219
  const sampled = await fetchUsageDirect(blob.claudeAiOauth.accessToken);
218
- if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
220
+ if (!sampled.ok) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
219
221
  const at = Date.now();
220
222
  const id = oauthAccount.accountUuid;
221
223
  return {
@@ -223,7 +225,8 @@ async function login(): Promise<Harvest | null> {
223
225
  email: oauthAccount.emailAddress,
224
226
  tier: claudeTierLabel(blob.claudeAiOauth),
225
227
  oauthAccount,
226
- sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
228
+ sample: sampled.ok ? { windows: windowsOf(sampled.usage, at), at } : null,
229
+ ...(!sampled.ok && sampled.retryAt != null ? { usageRetryAt: sampled.retryAt } : {}),
227
230
  park: () => writeItem(storeTarget(id), claudeAiOauthOnly(blobRaw)),
228
231
  };
229
232
  } finally {
package/src/lib/sample.ts CHANGED
@@ -9,7 +9,7 @@ import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
9
9
  import { fetchUsageDirect, mergeWindows, windowsOf } from "./usage.ts";
10
10
  import type { Account, Config, UsageWindows } from "./types.ts";
11
11
 
12
- export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string };
12
+ export type SampleOutcome = { ok: true; usage: UsageWindows; via: "get" } | { ok: false; reason: string; retryAt?: number };
13
13
 
14
14
  type PreparedSample = { ok: true; token: string | null } | { ok: false; reason: string };
15
15
 
@@ -48,13 +48,26 @@ export async function prepareSample(account: Account): Promise<PreparedSample> {
48
48
  return { ok: true, token: creds.accessToken };
49
49
  }
50
50
 
51
+ export function usageBlockedUntil(account: Account, now: number): number | null {
52
+ return account.usageRetryAt != null && account.usageRetryAt > now ? account.usageRetryAt : null;
53
+ }
54
+
55
+ function rateLimitedReason(retryAt: number, now: number): string {
56
+ return `the usage endpoint rate limited this account, next read in ${Math.ceil((retryAt - now) / 60_000)}m`;
57
+ }
58
+
51
59
  export async function runSample(account: Account, token: string | null): Promise<SampleOutcome> {
52
60
  if (!token) return { ok: false, reason: "no usable access token in the store - run `tokenmaxxing auth`" };
53
- const usage = await fetchUsageDirect(token);
54
- return usage ? { ok: true, usage, via: "get" } : { ok: false, reason: "usage read failed (see log)" };
61
+ const read = await fetchUsageDirect(token);
62
+ if (read.ok) return { ok: true, usage: read.usage, via: "get" };
63
+ if (read.retryAt == null) return { ok: false, reason: "usage read failed (see log)" };
64
+ return { ok: false, reason: rateLimitedReason(read.retryAt, Date.now()), retryAt: read.retryAt };
55
65
  }
56
66
 
57
67
  export async function sampleAccountUsage(account: Account): Promise<SampleOutcome> {
68
+ const now = Date.now();
69
+ const blockedUntil = usageBlockedUntil(account, now);
70
+ if (blockedUntil != null) return { ok: false, reason: rateLimitedReason(blockedUntil, now) };
58
71
  const prepared = await prepareSample(account);
59
72
  return prepared.ok ? runSample(account, prepared.token) : prepared;
60
73
  }
@@ -89,7 +102,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
89
102
  continue;
90
103
  }
91
104
  target.storeFails = 0;
92
- batch.push({ account: target, token: prepared.token });
105
+ if (usageBlockedUntil(target, now) == null) batch.push({ account: target, token: prepared.token });
93
106
  }
94
107
  saveAccounts(claudePool, idx);
95
108
  return batch;
@@ -106,6 +119,10 @@ export async function sampleOldest(cfg: Config): Promise<void> {
106
119
  stored.needsReauth = true;
107
120
  dirty = true;
108
121
  }
122
+ if (stored && !outcome.ok && outcome.retryAt != null) {
123
+ stored.usageRetryAt = outcome.retryAt;
124
+ dirty = true;
125
+ }
109
126
  if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
110
127
  stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
111
128
  stored.lastUsageAt = startedAt;
package/src/lib/state.ts CHANGED
@@ -73,6 +73,7 @@ export type Harvest = {
73
73
  tier: string | null;
74
74
  oauthAccount?: Account["oauthAccount"];
75
75
  sample: { windows: Window[]; at: number } | null;
76
+ usageRetryAt?: number;
76
77
  park: () => Promise<void>;
77
78
  };
78
79
 
@@ -92,6 +93,7 @@ export function upsertAccount(
92
93
  lastUsageAt: h.sample ? h.sample.at : existing?.lastUsageAt,
93
94
  needsReauth: false,
94
95
  ...(h.oauthAccount ? { oauthAccount: h.oauthAccount } : {}),
96
+ usageRetryAt: h.sample ? undefined : (h.usageRetryAt ?? existing?.usageRetryAt),
95
97
  };
96
98
  if (existing) Object.assign(existing, fresh);
97
99
  else idx.accounts.push(fresh);
package/src/lib/types.ts CHANGED
@@ -69,6 +69,7 @@ export const AccountSchema = z.object({
69
69
  lastProbeAt: z.number().optional(),
70
70
  probeFails: z.number().optional(),
71
71
  storeFails: z.number().optional(),
72
+ usageRetryAt: z.number().optional(),
72
73
  enforcedUntil: z.number().optional(),
73
74
  needsReauth: z.boolean().optional(),
74
75
  oauthAccount: OAuthAccountSchema.optional(),
package/src/lib/usage.ts CHANGED
@@ -208,7 +208,11 @@ function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
208
208
  return perModel;
209
209
  }
210
210
 
211
- export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
211
+ const RetryAfterSecondsSchema = z.coerce.number().int().positive();
212
+
213
+ export type UsageRead = { ok: true; usage: UsageWindows } | { ok: false; retryAt: number | null };
214
+
215
+ export async function fetchUsageDirect(accessToken: string): Promise<UsageRead> {
212
216
  let res: Response;
213
217
  try {
214
218
  res = await http.get(USAGE_URL, {
@@ -218,17 +222,18 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindow
218
222
  });
219
223
  } catch (e) {
220
224
  log("usage.get_failed", { err: errorMessage(e) });
221
- return null;
225
+ return { ok: false, retryAt: null };
222
226
  }
223
227
  if (!res.ok) {
224
- log("usage.get_failed", { status: res.status });
225
- return null;
228
+ const retryAfter = res.status === 429 ? RetryAfterSecondsSchema.safeParse(res.headers.get("retry-after")).data : undefined;
229
+ log("usage.get_failed", { status: res.status, retryAfter });
230
+ return { ok: false, retryAt: retryAfter != null ? Date.now() + retryAfter * 1000 : null };
226
231
  }
227
232
  const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
228
233
  if (!parsed.success || !parsed.data.five_hour || !parsed.data.seven_day) {
229
234
  log("usage.get_incomplete", { ok: parsed.success, issue: parsed.success ? undefined : z.prettifyError(parsed.error).slice(0, 200) });
230
- return null;
235
+ return { ok: false, retryAt: null };
231
236
  }
232
237
  const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: w.resets_at ?? null });
233
- return { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) };
238
+ return { ok: true, usage: { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) } };
234
239
  }