tokenmaxxing 0.14.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/DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing - design
2
2
 
3
- Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps to a fresh account and - at the next safe turn boundary - **restarts your session resumed on it, automatically**. Works across many concurrent sessions at once.
3
+ Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset.
4
4
 
5
5
  > Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
6
6
  >
@@ -10,11 +10,11 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
10
10
 
11
11
  ## 1. Why there is a thin supervisor (and why that's the whole trick)
12
12
 
13
- A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. What a swap alone cannot give you is a *clean cutover*: a mid-flight 429 retry keeps its already-snapshotted token, and an account already at the wall still needs the session paused until something resets.
13
+ A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. A plain swap therefore needs no process management at all (since 0.15.0, 2026-07-16; earlier versions respawned on every swap): the Stop hook swaps the credential and the session keeps running. What adoption cannot give you is the depleted case: when every account is at the wall the session must be PAUSED until something resets, and a live `claude` cannot pause itself.
14
14
 
15
- So the supervisor's job is to **replace the process at a salvageable moment**: after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt - killing it there loses nothing, and a `claude --resume <session-id>` comes back cold on the new account and continues exactly where it left off. **This is why we swap below 100%: the headroom is the budget to reach a clean turn boundary and respawn before the account actually hits the wall.** The session window swaps at 95 (a 5h reset is cheap to sit out) while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
15
+ So the supervisor's job is narrow: on a depleted pool it **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits the wall.** The session window swaps at 95 (a 5h reset is cheap to sit out) while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
16
16
 
17
- A hook can't do the respawn - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
17
+ A hook can't do the pause-and-relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
18
18
 
19
19
  ```
20
20
  supervisor (you type `claude`) → real claude (in a PTY) → Stop hook
@@ -33,7 +33,7 @@ It is a process/PTY manager only - spawn, forward the terminal, wait, restore te
33
33
  - `config.json` - threshold, account order/policy.
34
34
  - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
35
35
  - `usage.json` - live usage, written by the statusLine shim.
36
- - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal).
36
+ - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
37
37
  - `bin/claude` - the supervisor.
38
38
  - Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (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.
39
39
 
@@ -49,10 +49,10 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
49
49
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
50
50
  1. Read `usage.json`; `exit 0` fast if every window is under its threshold (metered per `organizationUuid`).
51
51
  2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
52
- 3. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
52
+ 3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
53
53
 
54
- ### 3.3 Respawn (supervisor)
55
- The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, resets the terminal, prints `↻ switched to <account>`, and relaunches `claude --resume <sid>`. The resumed process reads the keychain cold → runs on the new account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
54
+ ### 3.3 Depleted-pool pause (supervisor)
55
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
56
56
 
57
57
  ### 3.4 Swap sequence (under the lock)
58
58
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
@@ -62,7 +62,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
62
62
  5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
63
63
 
64
64
  ### 3.5 Multiple concurrent sessions
65
- Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, and its own respawn marker. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; **every** session's Stop hook writes its own respawn marker and SIGTERMs its own `claude`; **every** supervisor independently relaunches `claude --resume <its-own-sid>`. All of them come back on the new account, each continuing its own conversation. (They share one credential, so they always move together - consistent with "one current account, many windows.")
65
+ Each terminal ran the supervisor, so each has its own child `claude` and its own `--session-id`. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; every running session then adopts the new credential in place - no restarts. (They share one credential, so they always move together - consistent with "one current account, many windows.") Only a depleted pool fans out: each supervised session's Stop hook writes its own `respawn/<sid>` marker, and each supervisor independently pauses and later relaunches `claude --resume <its-own-sid>`.
66
66
 
67
67
  ---
68
68
 
@@ -83,10 +83,11 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
83
83
  ---
84
84
 
85
85
  ## 6. Honest papercuts
86
- - **Respawn hiccup.** At the swap turn you see `claude` restart (~1–2s) and resume. Anything you typed in the split second before respawn is lost - mitigate by respawning fast and showing a clear "switching" state; the supervisor resets terminal mode so nothing is left garbled.
87
- - **One cold turn.** Prompt cache is org-scoped: the first turn after resuming on B re-uploads context once (bigger on long transcripts).
88
- - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn can end rate-limited before the Stop hook swaps; the respawn then still recovers it. Projected threshold reduces this.
89
- - **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each via its own respawn). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
86
+ - **Respawn hiccup (depleted pause only).** Plain swaps never restart the session. When the whole pool is depleted you see `claude` stop, a countdown, and a resume; anything typed in the split second before the SIGTERM is lost, and the supervisor resets terminal mode so nothing is left garbled.
87
+ - **Adoption lag.** macOS reads the keychain through a raw 30s cache, so at most the first turn after a swap can still meter the old account. The bars' headroom absorbs it.
88
+ - **One cold turn.** Prompt cache is org-scoped: the first turn on B re-uploads context once (bigger on long transcripts).
89
+ - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn can end rate-limited before the Stop hook swaps; the swap then still recovers the session (its next turn adopts the fresh account). Projected threshold reduces this.
90
+ - **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each adopts in place). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
90
91
  - **Refresh-token rotation / parked-token rot.** Step 1 re-harvest is mandatory; a parked refresh token can be invalidated by logging in elsewhere → picker must catch `invalid_grant`, mark `needs_reauth`, fall through.
91
92
  - **statusLine fragility.** The shim is the most visible surface - a bug flickers or breaks your real status line. Keep it O(ms), write-on-change.
92
93
  - **Keychain blob size & ps-safety.** The live `Claude Code-credentials` item also holds per-MCP-server OAuth state, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing therefore stores parked backups as **`claudeAiOauth`-only** (small → always the ps-safe stdin write) and, on a swap, **merges** the fresh `claudeAiOauth` into the *current* live blob so MCP tokens survive the switch - using the argv write path (secret briefly visible in `ps` on your own machine) only for that one oversized live write.
package/README.md CHANGED
@@ -1,19 +1,18 @@
1
1
  # tokenmaxxing
2
2
 
3
- **Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account crosses its usage limit, tokenmaxxing swaps to a fresh account and - at the next safe turn boundary - restarts your session *resumed on it*, automatically. Works across many concurrent sessions.
3
+ **Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account nears its usage limit, tokenmaxxing swaps the credential to a fresher account at a safe turn boundary and your session keeps running on it - no restart, same conversation. Works across many concurrent sessions. Only when the whole pool is at its limit does anything visible happen: a countdown that auto-resumes at the soonest reset.
4
4
 
5
5
  > **Scope:** Claude Code only, macOS and Linux. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
9
- ...you work normally...
10
- tokenmaxxing: switched to work@acme.com - resuming...
11
- ...same conversation, fresh quota...
9
+ ...you work normally; swaps are invisible (watch the statusline account flip)...
10
+ tokenmaxxing: all accounts at their limit. Resuming on work@acme.com when it resets (Ctrl-C to resume now).
12
11
  ```
13
12
 
14
13
  ## Why
15
14
 
16
- A running `claude` re-checks the credential store between requests, so a swapped credential is adopted in-place (within ~30s on macOS, the next request on Linux). tokenmaxxing performs the swap at a committed turn boundary and **respawns** `claude --resume <id>` (the transcript is already on disk, so nothing is lost) - the respawn is what gives you a clean cutover and, when the whole pool is depleted, a countdown that auto-resumes at the soonest reset. A thin `claude` supervisor on your PATH owns that respawn; everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
15
+ A running `claude` re-checks the credential store between requests, so a swapped credential is adopted in-place (within ~30s on macOS, the next request on Linux) - a swap never restarts your session. The one case that still needs process management is a fully depleted pool: a session cannot pause itself, so a thin `claude` supervisor on your PATH stops it at a committed turn boundary (the transcript is already on disk, nothing is lost), shows a countdown, and auto-resumes `claude --resume <id>` at the soonest reset. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
17
16
 
18
17
  ## Install
19
18
 
@@ -46,7 +45,7 @@ claude # use claude as always
46
45
  | `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
47
46
  | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
48
47
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
49
- | `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) |
50
49
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
51
50
 
52
51
  ## How switching decides
@@ -56,7 +55,7 @@ Switching engages (configurable) once the active account's 5-hour session window
56
55
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
57
56
  - **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 `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
58
57
 
59
- The bars' headroom is deliberate: it's the budget to reach a clean turn boundary and respawn before the wall. The session bar sits lower (95) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
58
+ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session bar sits lower (95) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
60
59
 
61
60
  The **target** is chosen greedily off each account's cached windows: among usable accounts (every window under its bar, or past its reset), the one **furthest behind its own weekly pace** - highest remaining% divided by time to its weekly reset - because unused weekly allowance is forfeited at the fixed per-account reset. Cached resets are absolute UTC epochs, so a stale snapshot still resolves correctly: a weekly reset that has passed extrapolates forward in 7-day steps, and a session window past its reset counts as empty. Both `tokenmaxxing switch` and the automatic path rank the current account too and do nothing when it already wins, so they are idempotent - evaluating periodically converges on the right account.
62
61
 
@@ -121,9 +120,10 @@ Two codex-specific facts worth knowing: codex does not run hooks it has not been
121
120
 
122
121
  ## Honest limitations
123
122
 
124
- - **One cold turn.** The first turn after resuming on a new account re-uploads context once (prompt cache is org-scoped).
125
- - **Respawn hiccup.** At the swap you see `claude` restart (~1–2s); anything typed in that split second is lost.
126
- - **Shared blast radius.** All default-profile sessions share one live credential, so a swap moves them all together (each respawns its own session). A `flock` + re-check keeps racing hooks from burning two accounts.
123
+ - **One cold turn.** The first turn on a new account re-uploads context once (prompt cache is org-scoped).
124
+ - **Depleted-pause hiccup.** Plain swaps never restart the session. Only when the whole pool is at its limit does `claude` stop for the countdown; anything typed in that split second is lost.
125
+ - **Adoption lag.** On macOS the first turn within ~30s of a swap can still meter the old account; the bars' headroom absorbs it.
126
+ - **Shared blast radius.** All default-profile sessions share one live credential, so a swap moves them all together (each adopts in place). A `flock` + re-check keeps racing hooks from burning two accounts.
127
127
  - **Keychain ACL (macOS).** `init`/`add` touch the keychain interactively so the first `security` access isn't cold inside a headless hook.
128
128
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; tokenmaxxing's parked copies follow the same model.
129
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.14.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);
@@ -1,8 +1,10 @@
1
1
  // Stop hook. Fires when claude finishes a turn (transcript already committed).
2
- // If the active account crossed the threshold, swap the credential and - when
3
- // running under the supervisor - drop a respawn marker keyed by this session id.
4
- // The supervisor watches for the marker and SIGTERMs its child at this clean
5
- // boundary, then relaunches `--resume`. We never kill claude ourselves.
2
+ // A plain swap needs no respawn: the running session adopts the swapped
3
+ // credential on its own (<=30s on macOS, next request on Linux). Only a
4
+ // depleted-pool wait - when running under the supervisor - drops a respawn
5
+ // marker keyed by this session id: the supervisor SIGTERMs its child at this
6
+ // clean boundary, counts down to the reset, then relaunches `--resume`. We
7
+ // never kill claude ourselves.
6
8
 
7
9
  import { join } from "node:path";
8
10
  import { z } from "zod";
@@ -34,10 +36,11 @@ export async function runStopHook(): Promise<number> {
34
36
  // will actually pause the session until the reset.
35
37
  const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId != null;
36
38
  const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
37
- // Respawn on a swap, or on a depleted-pool wait (relaunch after the reset).
38
39
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
39
40
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
40
- if (process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
41
+ // Respawn only for a depleted-pool wait: pausing until the reset requires
42
+ // killing the child. A plain swap leaves the session running to adopt.
43
+ if (decision.waitUntil !== undefined && process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
41
44
  const marker = join(paths.respawnDir, sessionId);
42
45
  const payload = RespawnMarkerSchema.parse({ account: decision.account.label, ts: Date.now(), waitUntil: decision.waitUntil });
43
46
  writeFileAtomic(marker, JSON.stringify(payload));
@@ -1,10 +1,11 @@
1
1
  // The `claude` supervisor. Invoked in place of claude (via ~/.config/tokenmaxxing/
2
2
  // bin/claude on PATH). Runs the REAL claude with inherited stdio (claude owns the
3
3
  // real terminal exactly as if run directly), pins a session id, and watches for a
4
- // respawn marker dropped by the Stop/SessionStart hook. When the marker appears it
5
- // SIGTERMs its own child at the (already-committed) turn boundary and relaunches
6
- // `claude --resume <id>` on the freshly-swapped account. Process/terminal manager
7
- // only - it never reads or proxies tokens.
4
+ // respawn marker dropped by the Stop hook on a depleted-pool wait (plain swaps
5
+ // adopt in place and never respawn). When the marker appears it SIGTERMs its own
6
+ // child at the (already-committed) turn boundary, counts down to the reset, and
7
+ // relaunches `claude --resume <id>`. Process/terminal manager only - it never
8
+ // reads or proxies tokens.
8
9
 
9
10
  import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
10
11
  import { join } from "node:path";
@@ -213,7 +214,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
213
214
  const m = RespawnMarkerSchema.parse(await Bun.file(marker).json());
214
215
  rmSync(marker, { force: true });
215
216
  respawns++;
216
- if (m.waitUntil && m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
217
+ if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
217
218
  else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
218
219
  launchArgs = ["--resume", sid, ...base];
219
220
  continue;
@@ -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
 
@@ -141,12 +148,13 @@ export const ConfigSchema = z.object({
141
148
  });
142
149
  export type Config = z.infer<typeof ConfigSchema>;
143
150
 
144
- /** The hook -> supervisor respawn marker at respawn/<session-id>. */
151
+ /** The hook -> supervisor respawn marker at respawn/<session-id>. Written only
152
+ * for a depleted-pool wait (plain swaps adopt in place, no respawn). */
145
153
  export const RespawnMarkerSchema = z.object({
146
154
  account: z.string(),
147
155
  ts: z.number(),
148
- /** when set, the supervisor waits until this epoch ms before relaunching. */
149
- waitUntil: z.number().optional(),
156
+ /** the supervisor waits until this epoch ms before relaunching. */
157
+ waitUntil: z.number(),
150
158
  });
151
159
  export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
152
160
 
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)");