tokenmaxxing 0.15.0 → 0.16.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
@@ -45,7 +45,7 @@ claude # use claude as always
45
45
  | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
46
46
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
47
47
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
48
- | `tokenmaxxing rename <sel> <label>` / `rm <sel>` | manage the pool |
48
+ | `tokenmaxxing rename [--codex] <sel> <label>` / `rm <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
49
49
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
50
50
 
51
51
  ## How switching decides
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/add.ts CHANGED
@@ -15,7 +15,7 @@ import { withLock } from "../lib/lock.ts";
15
15
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
16
16
  import { credItemFor, paths } from "../lib/paths.ts";
17
17
  import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
18
- import { c, count } from "./render.ts";
18
+ import { c, claudeTierLabel, count } from "./render.ts";
19
19
 
20
20
  /** True once `/login` has written a usable identity into the onboard dir. */
21
21
  function identityReady(cjPath: string): boolean {
@@ -115,6 +115,7 @@ export async function cmdAdd(): Promise<number> {
115
115
  oauthAccount,
116
116
  addedAt: existing?.addedAt ?? new Date().toISOString(),
117
117
  subscriptionType: blob.claudeAiOauth.subscriptionType,
118
+ rateLimitTier: blob.claudeAiOauth.rateLimitTier,
118
119
  needsReauth: false,
119
120
  lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
120
121
  lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
@@ -130,6 +131,6 @@ export async function cmdAdd(): Promise<number> {
130
131
 
131
132
  console.log();
132
133
  const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
133
- console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
134
+ console.log(`${c.green("✓")} added ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
134
135
  return 0;
135
136
  }
package/src/cli/init.ts CHANGED
@@ -10,7 +10,7 @@ import { installSupervisor, shellRcPath, ensurePathInRc, timerActivationHint, ty
10
10
  import { resolveVerifiedClaude } from "../lib/claudebin.ts";
11
11
  import { credItemFor, paths } from "../lib/paths.ts";
12
12
  import { CredentialBlobSchema, type Account } from "../lib/types.ts";
13
- import { c } from "./render.ts";
13
+ import { c, claudeTierLabel } from "./render.ts";
14
14
 
15
15
  /** Put the supervisor bin dir on PATH via the user's shell rc (idempotent).
16
16
  * Falls back to the manual instruction when the shell is unknown. */
@@ -121,6 +121,7 @@ export async function cmdInit(): Promise<number> {
121
121
  oauthAccount,
122
122
  addedAt: existing?.addedAt ?? new Date().toISOString(),
123
123
  subscriptionType: blob.claudeAiOauth.subscriptionType,
124
+ rateLimitTier: blob.claudeAiOauth.rateLimitTier,
124
125
  needsReauth: false,
125
126
  };
126
127
  if (existing) Object.assign(existing, account);
@@ -134,7 +135,7 @@ export async function cmdInit(): Promise<number> {
134
135
 
135
136
  const out = installSupervisor();
136
137
 
137
- console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${account.subscriptionType ?? "?"})`);
138
+ console.log(`${c.green("✓")} imported current account → ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})`);
138
139
  console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
139
140
  reportTimer(out);
140
141
  if (!out.pathAhead) {
package/src/cli/ls.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { loadAccounts } from "../lib/state.ts";
4
4
  import { loadCodexAccounts } from "../lib/codexstate.ts";
5
5
  import { liveCodexAccountId } from "../lib/codexsample.ts";
6
- import { c } from "./render.ts";
6
+ import { c, claudeTierLabel } from "./render.ts";
7
7
 
8
8
  export function cmdLs(): number {
9
9
  const idx = loadAccounts();
@@ -19,7 +19,7 @@ export function cmdLs(): number {
19
19
  const tag = flags.length ? ` ${flags.join(" ")}` : "";
20
20
  const label = a.label || a.email;
21
21
  console.log(`${marker} ${c.bold(label)}${tag}`);
22
- console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${a.subscriptionType ?? "?"}, uuid ${a.accountUuid.slice(0, 8)}`)}`);
22
+ console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${claudeTierLabel(a) ?? "?"}, uuid ${a.accountUuid.slice(0, 8)}`)}`);
23
23
  }
24
24
 
25
25
  const codex = loadCodexAccounts();
package/src/cli/rename.ts CHANGED
@@ -1,12 +1,16 @@
1
- // `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
1
+ // `tokenmaxxing rename [--codex] <selector> <new-label>` - relabel a pooled
2
+ // account. The pools are separate namespaces and one email can hold both a
3
+ // claude and a codex account, so the codex pool is targeted explicitly via
4
+ // `--codex` (mirroring `switch --codex`), never by searching both pools.
2
5
 
3
6
  import { withLock } from "../lib/lock.ts";
4
- import { paths } from "../lib/paths.ts";
7
+ import { codexPaths, paths } from "../lib/paths.ts";
5
8
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
9
+ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
6
10
  import { c } from "./render.ts";
7
- import type { Account } from "../lib/types.ts";
11
+ import type { Account, CodexAccount } from "../lib/types.ts";
8
12
 
9
- /** Resolve an account by email, label, or accountUuid prefix. */
13
+ /** Resolve a claude account by email, label, or accountUuid prefix. */
10
14
  export function findAccount(accounts: Account[], selector: string): Account | undefined {
11
15
  const s = selector.toLowerCase();
12
16
  return (
@@ -16,17 +20,47 @@ export function findAccount(accounts: Account[], selector: string): Account | un
16
20
  );
17
21
  }
18
22
 
19
- export async function cmdRename(selector?: string, newLabel?: string): Promise<number> {
23
+ /** Resolve a codex account by email, label, or accountId prefix. */
24
+ export function findCodexAccount(accounts: CodexAccount[], selector: string): CodexAccount | undefined {
25
+ const s = selector.toLowerCase();
26
+ return (
27
+ accounts.find((a) => a.email?.toLowerCase() === s) ??
28
+ accounts.find((a) => a.label.toLowerCase() === s) ??
29
+ accounts.find((a) => a.accountId.toLowerCase().startsWith(s))
30
+ );
31
+ }
32
+
33
+ async function renameCodexAccount(input: { selector: string; newLabel: string }): Promise<number> {
34
+ // under the codex flock: a concurrent codex swap's index write must not be clobbered.
35
+ return withLock(codexPaths.lockFile, async () => {
36
+ const index = loadCodexAccounts();
37
+ const account = findCodexAccount(index.accounts, input.selector);
38
+ if (!account) {
39
+ console.error(c.red(`no codex account matches "${input.selector}"`));
40
+ return 1;
41
+ }
42
+ const old = account.label;
43
+ account.label = input.newLabel;
44
+ saveCodexAccounts({ index });
45
+ console.log(`renamed codex account ${c.dim(old)} → ${c.bold(input.newLabel)}`);
46
+ return 0;
47
+ });
48
+ }
49
+
50
+ export async function cmdRename(argv: string[]): Promise<number> {
51
+ const codex = argv.includes("--codex");
52
+ const [selector, newLabel] = argv.filter((a) => a !== "--codex");
20
53
  if (!selector || !newLabel) {
21
- console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
54
+ console.error("usage: tokenmaxxing rename [--codex] <email|label|id> <new-label>");
22
55
  return 2;
23
56
  }
57
+ if (codex) return renameCodexAccount({ selector, newLabel });
24
58
  // under the flock: a concurrent swap's index write must not be clobbered.
25
59
  return withLock(paths.lockFile, async () => {
26
60
  const idx = loadAccounts();
27
61
  const a = findAccount(idx.accounts, selector);
28
62
  if (!a) {
29
- console.error(c.red(`no account matches "${selector}"`));
63
+ console.error(c.red(`no claude account matches "${selector}" (codex accounts rename via --codex)`));
30
64
  return 1;
31
65
  }
32
66
  const old = a.label;
package/src/cli/render.ts CHANGED
@@ -18,6 +18,19 @@ export function makeColors(enabled: boolean) {
18
18
 
19
19
  export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
20
20
 
21
+ /** Plan label for a claude account, e.g. "max 20x": subscription name plus the
22
+ * multiplier segment of the rate-limit tier id ("default_claude_max_20x").
23
+ * Structural, never exact-string: the multiplier is any <digits>x segment, so
24
+ * tiers without one (e.g. "default_claude_zero") fall back to the bare
25
+ * subscription name, and an absent blob field falls back to null (unmeasured
26
+ * must not look like a tier). */
27
+ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
28
+ const segments = input.rateLimitTier?.split("_") ?? [];
29
+ const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
30
+ if (input.subscriptionType == null) return multiplier ?? null;
31
+ return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
32
+ }
33
+
21
34
  /** "1 account" / "3 accounts": counted nouns always pluralize properly. */
22
35
  export function count(input: { n: number; noun: string }): string {
23
36
  return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
package/src/cli/status.ts CHANGED
@@ -21,8 +21,8 @@ import { effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
21
21
  import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
22
22
  import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
23
23
  import { isCodexExhausted } from "../lib/codexpick.ts";
24
- import { isSessionWindow } from "../lib/codexusage.ts";
25
- import { bar, c, count, fmtAgo, fmtReset } from "./render.ts";
24
+ import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
25
+ import { bar, c, claudeTierLabel, count, fmtAgo, fmtReset } from "./render.ts";
26
26
  import type { FullUsage } from "../lib/usage.ts";
27
27
  import type { Config, CodexWindow, UsageWindow } from "../lib/types.ts";
28
28
 
@@ -48,10 +48,15 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
48
48
  idx = loadAccounts();
49
49
  const live = loadUsage();
50
50
  const modelUsage = loadModelUsage();
51
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
51
+ const liveOAuth = readOAuthAccount();
52
+ const activeOrg = liveOAuth?.organizationUuid ?? null;
52
53
  await Promise.all(
53
54
  idx.accounts.map(async (a) => {
54
55
  const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
56
+ // The tee path never opens the credential blob, so the active account's
57
+ // tier comes from the live oauthAccount instead - it names this very org
58
+ // (uuid-matched above), so the tier is attributed to its own identity.
59
+ if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
55
60
  // Active account: prefer the free statusLine push (usage.json) so we never
56
61
  // poll its own token, which is busy exactly when it matters. per-model
57
62
  // comes from model-usage.json (also statusLine-driven).
@@ -126,12 +131,15 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
126
131
  if (isExhausted(a, { now, thresholds: effectiveBars(cfg), currentAccountUuid: idx.activeAccountUuid, switchFamilies: cfg.policy.switchModels }))
127
132
  badges.push(c.yellow("exhausted"));
128
133
 
129
- console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
134
+ const tier = claudeTierLabel(a);
135
+ console.log(`${marker} ${c.bold(a.label || a.email)}${tier ? ` ${c.dim(tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
136
+ // Chart label convention (user rule 2026-07-17): everything lowercase,
137
+ // model names short ("fable", "spark").
130
138
  if (aggregate) {
131
139
  row("5h", aggregate.fiveHour, false);
132
140
  row("week", aggregate.sevenDay, true);
133
141
  }
134
- if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w, true);
142
+ if (perModel) for (const [name, w] of Object.entries(perModel)) row(name.toLowerCase(), w, true);
135
143
  if (failed && outcome && !outcome.ok) {
136
144
  const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""}, ` : "";
137
145
  console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
@@ -199,13 +207,13 @@ async function renderCodexSection(input: {
199
207
  if (active) badges.push(c.green("active"));
200
208
  if (account.needsReauth) badges.push(c.red("needs-reauth"));
201
209
  if (isCodexExhausted({ account, thresholds: effectiveBars(cfg), now })) badges.push(c.yellow("exhausted"));
202
- console.log(`${marker} ${c.bold(account.label)} ${account.planType ? c.dim(account.planType) : ""} ${badges.join(" ")}`);
210
+ console.log(`${marker} ${c.bold(account.label)}${account.planType ? ` ${c.dim(account.planType)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
203
211
 
204
212
  const usage = account.lastUsage;
205
213
  if (usage) {
206
214
  for (const window of usage.aggregate) row(windowLabel(window), window, !isSessionWindow({ window }));
207
215
  for (const [name, windows] of Object.entries(usage.perLimit)) {
208
- for (const window of windows) row(name, window, !isSessionWindow({ window }));
216
+ for (const window of windows) row(codexLimitLabel({ limitName: name }), window, !isSessionWindow({ window }));
209
217
  }
210
218
  }
211
219
  const outcome = outcomes.get(account.accountId);
@@ -14,6 +14,7 @@ import { z } from "zod";
14
14
  import { http, safeErrorDetail } from "./http.ts";
15
15
  import { CodexUsageSchema, type CodexAuthJson, type CodexUsage, type CodexWindow } from "./types.ts";
16
16
  import { codexIdentityOf } from "./codexauth.ts";
17
+ import { familyTokens } from "./usage.ts";
17
18
 
18
19
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
19
20
  const USAGE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_USAGE_URL) ?? "https://chatgpt.com/backend-api/wham/usage";
@@ -114,6 +115,15 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
114
115
  });
115
116
  }
116
117
 
118
+ /** Quota-chart label for an additional_rate_limits row: the model family,
119
+ * lowercase ("GPT-5.3-Codex-Spark" -> "spark"). Wire names are versioned, so
120
+ * the label is derived structurally (last non-numeric token), never by exact
121
+ * string (user rule 2026-07-17: chart names are lowercase, e.g. "spark"). */
122
+ export function codexLimitLabel(input: { limitName: string }): string {
123
+ const tokens = familyTokens(input.limitName).filter((t) => Number.isNaN(Number(t)));
124
+ return tokens.at(-1) ?? input.limitName.trim().toLowerCase();
125
+ }
126
+
117
127
  const SESSION_WINDOW_MAX_S = 6 * 3600;
118
128
 
119
129
  /** A window's screening bar: short windows (5h-class) screen at the session
package/src/lib/sample.ts CHANGED
@@ -49,6 +49,14 @@ async function identityMismatch(creds: OAuthCreds, account: Account): Promise<st
49
49
  return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})`;
50
50
  }
51
51
 
52
+ /** Stamp the blob's plan fields onto the account (caller persists). Runs only
53
+ * after the identity check passed, so a drifted credential can never write
54
+ * another account's tier. Absent blob fields keep the last-known values. */
55
+ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
56
+ if (creds.subscriptionType != null) account.subscriptionType = creds.subscriptionType;
57
+ if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
58
+ }
59
+
52
60
  /**
53
61
  * Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
54
62
  * mislabeled credential it sets `account.needsReauth` in place (the caller
@@ -89,6 +97,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
89
97
  account.needsReauth = true;
90
98
  return { ok: false, reason: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
91
99
  }
100
+ refreshPlanFields(account, creds);
92
101
 
93
102
  const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
94
103
  rmSync(dir, { recursive: true, force: true });
@@ -149,6 +158,7 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
149
158
 
150
159
  const mismatch = await identityMismatch(creds, account);
151
160
  if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
161
+ refreshPlanFields(account, creds);
152
162
 
153
163
  const pingError = opts.ping ? await pingSession() : null;
154
164
  const usage = await probeUsage();
package/src/lib/types.ts CHANGED
@@ -36,6 +36,9 @@ export const OAuthAccountSchema = z.looseObject({
36
36
  seatTier: z.string().nullish(),
37
37
  billingType: z.string().nullish(),
38
38
  displayName: z.string().nullish(),
39
+ /** rate-limit tier id ("default_claude_max_20x"), same value the credential
40
+ * blob carries; claude fills it in on profile fetch (live-verified 2.1.211). */
41
+ organizationRateLimitTier: z.string().nullish(),
39
42
  });
40
43
  export type OAuthAccount = z.infer<typeof OAuthAccountSchema>;
41
44
 
@@ -90,6 +93,10 @@ export const AccountSchema = z.object({
90
93
  lastUsageAt: z.number().optional(),
91
94
  needsReauth: z.boolean().optional(),
92
95
  subscriptionType: z.string().optional(),
96
+ /** the blob's rate-limit tier id (e.g. "default_claude_max_20x"): the only
97
+ * field that distinguishes max 5x from max 20x (subscriptionType is just
98
+ * "max" for both). Refreshed on every verified sample. */
99
+ rateLimitTier: z.string().optional(),
93
100
  });
94
101
  export type Account = z.infer<typeof AccountSchema>;
95
102
 
package/src/main.ts CHANGED
@@ -44,7 +44,7 @@ function printHelp(): void {
44
44
  ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
45
45
  ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
46
46
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
47
- ${c.cyan("tokenmaxxing rename")} <sel> <label>
47
+ ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
48
48
  ${c.cyan("tokenmaxxing rm")} <sel>
49
49
  ${c.cyan("tokenmaxxing uninstall")} remove supervisor + settings entries
50
50
 
@@ -85,7 +85,7 @@ async function main(): Promise<number> {
85
85
  case "watch": return cmdWatch(args[1]);
86
86
  case "doctor": return cmdDoctor();
87
87
  case "rm": return cmdRm(args[1]);
88
- case "rename": return cmdRename(args[1], args[2]);
88
+ case "rename": return cmdRename(args.slice(1));
89
89
  case "uninstall":
90
90
  uninstallSupervisor();
91
91
  console.log("removed supervisor wrapper + settings entries (accounts/credentials kept)");