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 +14 -13
- package/README.md +10 -10
- package/package.json +1 -1
- package/src/cli/add.ts +3 -2
- package/src/cli/init.ts +3 -2
- package/src/cli/ls.ts +2 -2
- package/src/cli/rename.ts +41 -7
- package/src/cli/render.ts +13 -0
- package/src/cli/status.ts +15 -7
- package/src/entries/stophook.ts +9 -6
- package/src/entries/supervisor.ts +6 -5
- package/src/lib/codexusage.ts +10 -0
- package/src/lib/sample.ts +10 -0
- package/src/lib/types.ts +11 -3
- package/src/main.ts +2 -2
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
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
55
|
-
The supervisor
|
|
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
|
|
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.**
|
|
87
|
-
- **
|
|
88
|
-
- **
|
|
89
|
-
- **
|
|
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
|
|
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
|
-
|
|
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)
|
|
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>`
|
|
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
|
|
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
|
|
125
|
-
- **
|
|
126
|
-
- **
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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|
|
|
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
|
|
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
|
-
|
|
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)}
|
|
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);
|
package/src/entries/stophook.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Stop hook. Fires when claude finishes a turn (transcript already committed).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// only - it never
|
|
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
|
|
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;
|
package/src/lib/codexusage.ts
CHANGED
|
@@ -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
|
-
/**
|
|
149
|
-
waitUntil: z.number()
|
|
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
|
|
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)");
|