tokenmaxxing 1.40.0 → 1.42.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/README.md CHANGED
@@ -66,7 +66,7 @@ claude # use claude as always; each session gets its own accoun
66
66
  | `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
67
67
  | `tokenmaxxing add` | register an additional account (isolated login, harvested once into its own store) |
68
68
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
69
- | `tokenmaxxing auth [--codex] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every needs-reauth account one by one; `--codex` does the same for the codex pool |
69
+ | `tokenmaxxing auth [--codex] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one; `--codex` does the same for the codex pool |
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start); the claude pool has no manual switch, each session is placed at launch |
71
71
  | `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
72
72
  | `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.40.0",
3
+ "version": "1.42.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/auth.ts CHANGED
@@ -18,14 +18,13 @@ const AuthPlanSchema = z.discriminatedUnion("kind", [
18
18
  ]);
19
19
  export type AuthPlan = z.infer<typeof AuthPlanSchema>;
20
20
 
21
- export function planAuth(input: { p: Provider; accounts: Account[]; argv: string[] }): AuthPlan {
21
+ export function planAuth(input: { p: Provider; accounts: Account[]; argv: string[]; needsAuth: Set<string> }): AuthPlan {
22
22
  const all = input.argv.includes("--all");
23
23
  const rest = input.argv.filter((a) => a !== "--all");
24
24
  if ((all && rest.length > 0) || rest.length > 1) return { kind: "usage" };
25
25
  if (input.accounts.length === 0) return { kind: "error", message: `no accounts in the pool - run \`tokenmaxxing init${input.p.flag}\` first` };
26
26
  if (all) {
27
- const flagged = input.accounts.filter((a) => a.needsReauth === true);
28
- return { kind: "targets", ids: flagged.map((a) => a.id) };
27
+ return { kind: "targets", ids: input.accounts.filter((a) => input.needsAuth.has(a.id)).map((a) => a.id) };
29
28
  }
30
29
  const selector = rest[0];
31
30
  if (selector !== undefined) {
@@ -36,18 +35,19 @@ export function planAuth(input: { p: Provider; accounts: Account[]; argv: string
36
35
  return { kind: "pick" };
37
36
  }
38
37
 
39
- export function pickerOrder(accounts: Account[]): Account[] {
40
- const [flagged, healthy] = partition(accounts, (a) => a.needsReauth === true);
38
+ export function pickerOrder(accounts: Account[], needsAuth: Set<string>): Account[] {
39
+ const [flagged, healthy] = partition(accounts, (a) => needsAuth.has(a.id));
41
40
  return [...flagged, ...healthy];
42
41
  }
43
42
 
44
- function askWhichAccount(idx: AccountsIndex): Account | null {
45
- const ordered = pickerOrder(idx.accounts);
43
+ function askWhichAccount(idx: AccountsIndex, needsAuth: Set<string>): Account | null {
44
+ const ordered = pickerOrder(idx.accounts, needsAuth);
46
45
  console.log("which account do you want to reauthenticate?");
47
46
  for (const [i, a] of ordered.entries()) {
48
47
  const flags: string[] = [];
49
48
  if (a.id === idx.activeId) flags.push(c.green("active"));
50
49
  if (a.needsReauth) flags.push(c.red("needs-reauth"));
50
+ else if (needsAuth.has(a.id)) flags.push(c.red("no-credential"));
51
51
  const labelNote = a.email != null && a.label !== a.email ? ` (${a.label})` : "";
52
52
  const tag = flags.length ? ` ${flags.join(" ")}` : "";
53
53
  console.log(` ${i + 1}. ${c.bold(a.email ?? a.label)}${labelNote}${tag}`);
@@ -107,7 +107,11 @@ async function reauthOne(p: Provider, target: Account): Promise<boolean> {
107
107
 
108
108
  export async function cmdAuth(p: Provider, argv: string[]): Promise<number> {
109
109
  const idx = loadAccounts(p.pool);
110
- const plan = planAuth({ p, accounts: idx.accounts, argv });
110
+ const needsAuth = new Set<string>();
111
+ for (const a of idx.accounts) {
112
+ if (a.needsReauth === true || !(await p.storeUsable(a))) needsAuth.add(a.id);
113
+ }
114
+ const plan = planAuth({ p, accounts: idx.accounts, argv, needsAuth });
111
115
  if (plan.kind === "usage") {
112
116
  console.error(AUTH_USAGE);
113
117
  return 2;
@@ -119,7 +123,7 @@ export async function cmdAuth(p: Provider, argv: string[]): Promise<number> {
119
123
 
120
124
  const targets: Account[] = [];
121
125
  if (plan.kind === "pick") {
122
- const picked = askWhichAccount(idx);
126
+ const picked = askWhichAccount(idx, needsAuth);
123
127
  if (!picked) return 1;
124
128
  targets.push(picked);
125
129
  } else {
@@ -128,7 +132,7 @@ export async function cmdAuth(p: Provider, argv: string[]): Promise<number> {
128
132
  if (account) targets.push(account);
129
133
  }
130
134
  if (targets.length === 0) {
131
- console.log(`${c.green("✓")} no account needs reauth`);
135
+ console.log(`${c.green("✓")} every account has a usable credential`);
132
136
  return 0;
133
137
  }
134
138
  }
package/src/lib/claude.ts CHANGED
@@ -108,6 +108,15 @@ async function prepareMove(target: Account): Promise<void> {
108
108
  log("move.prepared", { account: target.id.slice(0, 8), label: target.label });
109
109
  }
110
110
 
111
+ async function storeUsable(a: Account): Promise<boolean> {
112
+ try {
113
+ const creds = await readStore(a.id);
114
+ return creds != null && !isDeadCredential(creds);
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
111
120
  export function pickSeat(now: number): Account | null {
112
121
  const cfg = loadConfig();
113
122
  const idx = loadAccounts(claudePool);
@@ -277,6 +286,7 @@ export const claude: Provider = {
277
286
  swap: prepareMove,
278
287
  classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
279
288
  removeCredentials,
289
+ storeUsable,
280
290
  login,
281
291
  importLive,
282
292
  preflight: () => pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() }),
package/src/lib/codex.ts CHANGED
@@ -334,6 +334,13 @@ export const codex: Provider = {
334
334
  swap,
335
335
  classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : "fatal"),
336
336
  removeCredentials: async (a) => deleteParkedCodexAuth({ credFile: codexCredItemFor(a.id) }),
337
+ storeUsable: async (a) => {
338
+ try {
339
+ return readParkedCodexAuth({ credFile: codexCredItemFor(a.id) }) != null;
340
+ } catch {
341
+ return false;
342
+ }
343
+ },
337
344
  login,
338
345
  importLive,
339
346
  preflight,
@@ -23,6 +23,7 @@ export type Provider = {
23
23
  swap(target: Account): Promise<void>;
24
24
  classifySwapError(e: unknown): SwapFailure;
25
25
  removeCredentials(account: Account): Promise<void>;
26
+ storeUsable(account: Account): Promise<boolean>;
26
27
  login(): Promise<Harvest | null>;
27
28
  importLive(): Promise<Harvest | null>;
28
29
  preflight(): void;
package/src/lib/sample.ts CHANGED
@@ -6,11 +6,12 @@ import { writeFileAtomic } from "./atomic.ts";
6
6
  import { readStore } from "./credstore.ts";
7
7
  import { withLock } from "./lock.ts";
8
8
  import { log } from "./log.ts";
9
- import { claudeTierLabel, isDeadCredential } from "./oauth.ts";
10
- import { claudePool, sampleDirFor, storeDirFor } from "./paths.ts";
9
+ import { claudeTierLabel, isAccessTokenExpiring, isDeadCredential } from "./oauth.ts";
10
+ import { claudePool, paths, sampleDirFor, storeDirFor } from "./paths.ts";
11
+ import { seatCounts } from "./presence.ts";
11
12
  import type { Observation } from "./provider.ts";
12
13
  import { loadAccounts, loadUsageSnapshot, saveAccounts } from "./state.ts";
13
- import { mergeWindows, probeUsage, windowsOf } from "./usage.ts";
14
+ import { fetchUsageDirect, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
14
15
  import { UsageWindowsSchema, type Account, type Config } from "./types.ts";
15
16
 
16
17
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
@@ -93,11 +94,28 @@ export async function sampleOldest(cfg: Config): Promise<void> {
93
94
  log("sample.failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
94
95
  return null;
95
96
  }
96
- return { account: target, dir: prepared.dir };
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 };
97
103
  });
98
104
  if (!reserved) return;
99
105
  const startedAt = Date.now();
100
- const outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
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 });
115
+ }
116
+ } else {
117
+ outcome = await runProbe(reserved.account, reserved.dir, { retries: 0 });
118
+ }
101
119
  await withLock(claudePool.lockFile, () => {
102
120
  const idx = loadAccounts(claudePool);
103
121
  const stored = idx.accounts.find((a) => a.id === reserved.account.id);
@@ -108,7 +126,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
108
126
  }
109
127
  log(outcome.ok ? "sample.ok" : "sample.failed", {
110
128
  account: reserved.account.id.slice(0, 8),
111
- ...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
129
+ ...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
112
130
  });
113
131
  });
114
132
  }
package/src/lib/usage.ts CHANGED
@@ -2,7 +2,9 @@ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
2
2
  import { delay } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
5
+ import { http } from "./http.ts";
5
6
  import { log } from "./log.ts";
7
+ import { env } from "./paths.ts";
6
8
  import { JsonTextSchema, RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
7
9
 
8
10
  export function normalizeResetsAt(v: unknown): number | null {
@@ -341,10 +343,10 @@ function probeEnv(target: ProbeTarget): Record<string, string> {
341
343
  }
342
344
 
343
345
  export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
344
- const env = probeEnv(target);
346
+ const probe = probeEnv(target);
345
347
 
346
348
  for (let attempt = 0; ; attempt++) {
347
- const full = await probeUsageOnce(env, now);
349
+ const full = await probeUsageOnce(probe, now);
348
350
  if (full) return full;
349
351
  if (attempt >= (opts.retries ?? PROBE_RETRY_DELAYS_MS.length)) {
350
352
  log("usage.probe_gave_up", { attempts: attempt + 1 });
@@ -353,3 +355,42 @@ export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: {
353
355
  await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
354
356
  }
355
357
  }
358
+
359
+ const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", "https://api.anthropic.com/api/oauth/usage");
360
+ const USAGE_DEADLINE_MS = 10_000;
361
+
362
+ const UsageWindowResponseSchema = z.object({ utilization: z.number(), resets_at: z.number().nullable().optional() });
363
+ const UsageResponseSchema = z.object({
364
+ rate_limits: z
365
+ .object({ five_hour: UsageWindowResponseSchema.nullish(), seven_day: UsageWindowResponseSchema.nullish() })
366
+ .nullish(),
367
+ });
368
+
369
+ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
370
+ let res: Response;
371
+ try {
372
+ res = await http.get(`${USAGE_URL}?at_wall=1&skip_spend=1`, {
373
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
374
+ signal: AbortSignal.timeout(USAGE_DEADLINE_MS),
375
+ });
376
+ } catch (e) {
377
+ log("usage.get_failed", { err: e instanceof Error ? e.message : String(e) });
378
+ return null;
379
+ }
380
+ if (!res.ok) {
381
+ log("usage.get_failed", { status: res.status });
382
+ return null;
383
+ }
384
+ const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
385
+ const five = parsed.success ? parsed.data.rate_limits?.five_hour : null;
386
+ const seven = parsed.success ? parsed.data.rate_limits?.seven_day : null;
387
+ if (!five || !seven) {
388
+ log("usage.get_incomplete", { ok: parsed.success });
389
+ return null;
390
+ }
391
+ const win = (w: z.infer<typeof UsageWindowResponseSchema>): UsageWindow => ({
392
+ usedPercentage: w.utilization * 100,
393
+ resetsAt: w.resets_at != null ? w.resets_at * 1000 : null,
394
+ });
395
+ return { fiveHour: win(five), sevenDay: win(seven), perModel: {} };
396
+ }
package/src/main.ts CHANGED
@@ -42,7 +42,7 @@ function printHelp(): void {
42
42
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
43
43
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
44
44
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
45
- ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
45
+ ${c.cyan("tokenmaxxing auth")} [--codex] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
46
46
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
47
47
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
48
48
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)