tokenmaxxing 1.45.0 → 1.47.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
@@ -40,7 +40,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
40
40
  - `bin/claude` - the supervisor.
41
41
  - Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
42
42
 
43
- - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the account whose `/usage` figure is oldest, so the cached figures every placement reads stay fresh.
43
+ - A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index and samples the accounts whose `/usage` figures are oldest (up to three per tick), so the cached figures every placement reads stay fresh.
44
44
 
45
45
  The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude`, `~/.claude.json`, and Claude Code's default credential store are untouched.
46
46
 
@@ -83,7 +83,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
83
83
  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.
84
84
 
85
85
  - 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 weekly pace pressure (remaining weekly percent over time to the weekly reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex keeps the pure pace ordering and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
86
- - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples the account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under that account's store, so every account is attempted about once per tick 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` child.
86
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under each account's store, 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` child.
87
87
  - 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.
88
88
  - 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.
89
89
 
@@ -95,7 +95,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
95
95
 
96
96
  ## 6. Honest papercuts
97
97
  - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
98
- - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. Projected threshold reduces this.
98
+ - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
99
99
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
100
100
  - **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
101
101
  - **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
package/README.md CHANGED
@@ -101,7 +101,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
101
101
  {
102
102
  "thresholds": { "session": 90, "weekly": 98 },
103
103
  "policy": {
104
- "projectionMargin": 0,
104
+ "projectionMargin": 3,
105
105
  "switchModels": ["fable"],
106
106
  "usagePollTtlMs": 90000,
107
107
  "checkIntervalMs": 60000
@@ -109,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
109
109
  }
110
110
  ```
111
111
 
112
- `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 each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest 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.
112
+ `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` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest 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.
113
113
 
114
114
  State lives entirely in `~/.config/tokenmaxxing/`. 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`).
115
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.45.0",
3
+ "version": "1.47.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
@@ -35,15 +35,18 @@ function presence(): Map<string, number> {
35
35
  return seatCounts(paths.presenceDir);
36
36
  }
37
37
 
38
+ const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
39
+
38
40
  async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
39
41
  const ttl = cfg.policy.usagePollTtlMs;
40
- const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= ttl;
42
+ const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
43
+ const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
41
44
  const snap = loadUsageSnapshot(account.id);
42
45
  const fresh = snap != null && now - snap.at <= ttl;
43
46
  const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
44
47
  if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
45
48
  const startedAt = Date.now();
46
- const outcome = await probeAccountUsage(account);
49
+ const outcome = await probeAccountUsage(account, { retries: 0 });
47
50
  await withLock(claudePool.lockFile, () => {
48
51
  const idx = loadAccounts(claudePool);
49
52
  const a = idx.accounts.find((x) => x.id === account.id);
@@ -51,9 +54,14 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
51
54
  a.lastProbeAt = startedAt;
52
55
  a.tier = account.tier;
53
56
  if (account.needsReauth) a.needsReauth = true;
54
- if (outcome.ok && (a.lastUsageAt == null || startedAt > a.lastUsageAt)) {
55
- a.windows = mergeWindows(windowsOf(outcome.usage, startedAt), a.windows);
56
- a.lastUsageAt = startedAt;
57
+ if (outcome.ok) {
58
+ a.probeFails = 0;
59
+ if (a.lastUsageAt == null || startedAt > a.lastUsageAt) {
60
+ a.windows = mergeWindows(windowsOf(outcome.usage, startedAt), a.windows);
61
+ a.lastUsageAt = startedAt;
62
+ }
63
+ } else {
64
+ a.probeFails = (a.probeFails ?? 0) + 1;
57
65
  }
58
66
  saveAccounts(claudePool, idx);
59
67
  });
package/src/lib/picker.ts CHANGED
@@ -6,7 +6,7 @@ import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresh
6
6
  export function thresholdBars(cfg: Config): Thresholds {
7
7
  return {
8
8
  session: cfg.thresholds.session - cfg.policy.projectionMargin,
9
- weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
9
+ weekly: cfg.thresholds.weekly,
10
10
  };
11
11
  }
12
12
 
@@ -87,11 +87,22 @@ export function earliestReset(a: Account, now: number): number {
87
87
  return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
88
88
  }
89
89
 
90
- export function pacePressure(a: Account, now: number): number {
90
+ function windowResetAt(w: Window, now: number): number | null {
91
+ if (w.resetsAt != null) return nextWeeklyReset(w.resetsAt, now);
92
+ if (w.windowSeconds != null) return w.sampledAt + w.windowSeconds * 1000;
93
+ return null;
94
+ }
95
+
96
+ export function pacePressure(a: Account, ctx: PickCtx): number {
97
+ const binding = minBy(gatedWindows(a, ctx.families), (w) => Math.max(0, 100 - liveUsed(w, ctx.now)));
98
+ if (binding != null) {
99
+ const reset = windowResetAt(binding, ctx.now);
100
+ if (reset != null) return Math.max(0, 100 - liveUsed(binding, ctx.now)) / Math.max(1, reset - ctx.now);
101
+ }
91
102
  const weekly = weeklyWindow(a);
92
- const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
103
+ const reset = nextWeeklyReset(weekly?.resetsAt ?? null, ctx.now);
93
104
  if (weekly == null || reset == null) return 0;
94
- return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
105
+ return Math.max(0, 100 - liveUsed(weekly, ctx.now)) / Math.max(1, reset - ctx.now);
95
106
  }
96
107
 
97
108
  export function seatHeadroom(a: Account, ctx: PickCtx): number {
@@ -102,7 +113,7 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
102
113
 
103
114
  const swapPreference = (ctx: PickCtx) => [
104
115
  ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
105
- (a: Account) => -pacePressure(a, ctx.now),
116
+ (a: Account) => -pacePressure(a, ctx),
106
117
  (a: Account) => weeklyExpiry(a, ctx.now),
107
118
  (a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
108
119
  ];
@@ -116,7 +127,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
116
127
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
117
128
  const best = pickBest(accounts, { ...ctx, currentId: null });
118
129
  if (best == null || best.id === active.id) return true;
119
- if (margin > 1) return pacePressure(best, ctx.now) <= pacePressure(active, ctx.now) * margin;
130
+ if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
120
131
  return swapPreference(ctx).every((k) => k(active) === k(best));
121
132
  }
122
133
 
package/src/lib/sample.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { minBy } from "es-toolkit";
4
3
  import { z } from "zod";
5
4
  import { writeFileAtomic } from "./atomic.ts";
6
5
  import { readStore } from "./credstore.ts";
@@ -69,11 +68,14 @@ export async function runProbe(account: Account, dir: string, opts: { retries?:
69
68
  return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
70
69
  }
71
70
 
72
- export async function probeAccountUsage(account: Account): Promise<SampleOutcome> {
71
+ export async function probeAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
73
72
  const prepared = await prepareProbe(account);
74
- return prepared.ok ? runProbe(account, prepared.dir, {}) : prepared;
73
+ return prepared.ok ? runProbe(account, prepared.dir, opts) : prepared;
75
74
  }
76
75
 
76
+ const SAMPLE_BATCH = 3;
77
+ const STORE_FAILS_REAUTH = 5;
78
+
77
79
  export async function sampleOldest(cfg: Config): Promise<void> {
78
80
  const reserved = await withLock(claudePool.lockFile, async () => {
79
81
  const now = Date.now();
@@ -81,52 +83,64 @@ export async function sampleOldest(cfg: Config): Promise<void> {
81
83
  let dirty = false;
82
84
  for (const a of idx.accounts) dirty = foldTee(a) || dirty;
83
85
  const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
84
- const stale = idx.accounts.filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
85
- const target = minBy(stale, sampledAt);
86
- if (!target) {
86
+ const stale = idx.accounts
87
+ .filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
88
+ .sort((a, b) => sampledAt(a) - sampledAt(b))
89
+ .slice(0, SAMPLE_BATCH);
90
+ if (stale.length === 0) {
87
91
  if (dirty) saveAccounts(claudePool, idx);
88
- return null;
89
- }
90
- target.lastProbeAt = now;
91
- const prepared = await prepareProbe(target, "-tick");
92
- saveAccounts(claudePool, idx);
93
- if (!prepared.ok) {
94
- log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
95
- return null;
92
+ return [];
96
93
  }
97
- let token: string | null = null;
98
- if (!seatCounts(paths.presenceDir).has(target.id)) {
99
- const creds = await readStore(target.id).catch(() => null);
100
- if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
101
- }
102
- return { account: target, dir: prepared.dir, token };
103
- });
104
- if (!reserved) return;
105
- const startedAt = Date.now();
106
- let via = "probe";
107
- let outcome: SampleOutcome;
108
- if (reserved.token) {
109
- const usage = await fetchUsageDirect(reserved.token);
110
- if (usage) {
111
- via = "get";
112
- outcome = { ok: true, usage };
113
- } else {
114
- outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
94
+ const batch: { account: Account; dir: string; token: string | null }[] = [];
95
+ for (const target of stale) {
96
+ target.lastProbeAt = now;
97
+ const prepared = await prepareProbe(target, "-tick");
98
+ if (!prepared.ok) {
99
+ target.storeFails = (target.storeFails ?? 0) + 1;
100
+ if (target.storeFails >= STORE_FAILS_REAUTH) target.needsReauth = true;
101
+ log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
102
+ continue;
103
+ }
104
+ target.storeFails = 0;
105
+ let token: string | null = null;
106
+ if (!seatCounts(paths.presenceDir).has(target.id)) {
107
+ const creds = await readStore(target.id).catch(() => null);
108
+ if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
109
+ }
110
+ batch.push({ account: target, dir: prepared.dir, token });
115
111
  }
116
- } else {
117
- outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
118
- }
119
- await withLock(claudePool.lockFile, () => {
120
- const idx = loadAccounts(claudePool);
121
- const stored = idx.accounts.find((a) => a.id === reserved.account.id);
122
- if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
123
- stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
124
- stored.lastUsageAt = startedAt;
125
- saveAccounts(claudePool, idx);
126
- }
127
- log(outcome.ok ? "sample.ok" : "sample.failed", {
128
- account: reserved.account.id.slice(0, 8),
129
- ...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
130
- });
112
+ saveAccounts(claudePool, idx);
113
+ return batch;
131
114
  });
115
+ await Promise.all(
116
+ reserved.map(async ({ account, dir, token }) => {
117
+ const startedAt = Date.now();
118
+ let via = "probe";
119
+ let outcome: SampleOutcome;
120
+ if (token) {
121
+ const usage = await fetchUsageDirect(token);
122
+ if (usage) {
123
+ via = "get";
124
+ outcome = { ok: true, usage };
125
+ } else {
126
+ outcome = await runProbe(account, dir, { retries: 0 });
127
+ }
128
+ } else {
129
+ outcome = await runProbe(account, dir, { retries: 0 });
130
+ }
131
+ await withLock(claudePool.lockFile, () => {
132
+ const idx = loadAccounts(claudePool);
133
+ const stored = idx.accounts.find((a) => a.id === account.id);
134
+ if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
135
+ stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
136
+ stored.lastUsageAt = startedAt;
137
+ saveAccounts(claudePool, idx);
138
+ }
139
+ log(outcome.ok ? "sample.ok" : "sample.failed", {
140
+ account: account.id.slice(0, 8),
141
+ ...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
142
+ });
143
+ });
144
+ }),
145
+ );
132
146
  }
package/src/lib/types.ts CHANGED
@@ -70,6 +70,8 @@ export const AccountSchema = z.object({
70
70
  windows: z.array(WindowSchema).default([]),
71
71
  lastUsageAt: z.number().optional(),
72
72
  lastProbeAt: z.number().optional(),
73
+ probeFails: z.number().optional(),
74
+ storeFails: z.number().optional(),
73
75
  enforcedUntil: z.number().optional(),
74
76
  needsReauth: z.boolean().optional(),
75
77
  oauthAccount: OAuthAccountSchema.optional(),
@@ -114,7 +116,7 @@ export const ConfigSchema = z
114
116
  opencodeBin: z.string().default(""),
115
117
  policy: z
116
118
  .object({
117
- projectionMargin: z.number().min(0).max(100).default(0),
119
+ projectionMargin: z.number().min(0).max(100).default(3),
118
120
  switchModels: z
119
121
  .array(z.string())
120
122
  .default(["fable"])
@@ -125,9 +127,9 @@ export const ConfigSchema = z
125
127
  })
126
128
  .prefault({}),
127
129
  })
128
- .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
130
+ .refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
129
131
  path: ["policy", "projectionMargin"],
130
- message: "must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
132
+ message: "must be strictly below the session threshold (the session bar would hit zero and every account would read as exhausted)",
131
133
  });
132
134
  export type Config = z.infer<typeof ConfigSchema>;
133
135