tokenmaxxing 1.49.0 → 1.51.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 +25 -24
- package/README.md +29 -25
- package/package.json +1 -1
- package/src/cli/rename.ts +1 -1
- package/src/entries/supervisor.ts +118 -19
- package/src/main.ts +3 -3
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; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**90%** of the 5h session window or **98%** of a weekly window), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool pauses a session with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**90%** of the 5h session window minus the 3-point projection margin, so 87 by default, or **98%** of a weekly window), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool pauses a session with a countdown and auto-resumes at the soonest reset when it lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state and supervisor; since 1.35.0 both run on one engine. Codex's verified internals live in AGENTS.md's Codex sections.
|
|
6
6
|
>
|
|
@@ -12,7 +12,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; eve
|
|
|
12
12
|
|
|
13
13
|
Claude Code namespaces its credential by `CLAUDE_SECURESTORAGE_CONFIG_DIR` (verified in the 2.1.269 binary, `xSe`/`y_`): the variable moves only the credential store (a `.credentials.json` inside that directory on Linux, a keychain item named `Claude Code-credentials-<sha256(dir)[0:8]>` on macOS), while `~/.claude` (settings, transcripts, plugins) and `~/.claude.json` stay shared, so `--resume` works across stores. An empty namespaced store never falls back to the default login. Hooks, the statusline, and MCP servers inherit the variable. That is the whole mechanism: give every pooled account its own store, set the variable per session, and each session runs on its own account with no credential ever moving.
|
|
14
14
|
|
|
15
|
-
What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **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 - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the soonest reset first. **Thresholds sit below 100: the headroom is the budget to reach a clean turn boundary before the account actually hits its real limit.** The session window moves at 90 (a 5h reset is cheap to sit out), while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
|
|
15
|
+
What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **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 - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the soonest reset first. **Thresholds sit below 100: the headroom is the budget to reach a clean turn boundary before the account actually hits its real limit.** The session window moves at 90 minus the margin (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
17
|
A hook can't do the placement or the relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
|
|
18
18
|
|
|
@@ -21,35 +21,36 @@ supervisor (you type `claude`) → real claude, CLAUDE_SECURESTORAGE_CONFIG_DI
|
|
|
21
21
|
▲______________ relaunch --resume <sid> under another store ______________|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
It is a process manager only - spawn with inherited stdio plus saved `stty -g` termios (not a PTY copy), wait, restore the terminal, relaunch. It never proxies API traffic and never handles tokens. Everything else about `claude` is unchanged.
|
|
24
|
+
It is a process manager only - spawn with inherited stdio (stdin through a relay pipe for a stream-json session, see §3.3) plus saved `stty -g` termios (not a PTY copy), wait, restore the terminal, relaunch. It never proxies API traffic and never handles tokens. Everything else about `claude` is unchanged.
|
|
25
25
|
|
|
26
26
|
---
|
|
27
27
|
|
|
28
28
|
## 2. What tokenmaxxing installs
|
|
29
29
|
|
|
30
|
-
- A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`)
|
|
30
|
+
- A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`) - you invoke it identically; a `claude` shell function or alias would shadow it, and `doctor` warns about one.
|
|
31
31
|
- Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
|
|
32
32
|
- **`~/.config/tokenmaxxing/`** - the single home for config and state:
|
|
33
|
-
- `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
|
|
34
|
-
- `accounts.json` - non-secret index `{id, label, email, tier, windows, lastUsageAt, lastProbeAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). `activeId` is `null` for both pools: there is no pool-wide active account.
|
|
33
|
+
- `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin/grokBin/opencodeBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
|
|
34
|
+
- `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). `activeId` is `null` for both pools: there is no pool-wide active account.
|
|
35
35
|
- `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
|
|
36
36
|
- `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
|
|
37
37
|
- `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
|
|
38
38
|
- `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
|
|
39
|
-
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account
|
|
40
|
-
- `bin/claude` - the
|
|
39
|
+
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, and the launch time the marker was written for.
|
|
40
|
+
- `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points.
|
|
41
|
+
- `sessions/<sid>.json`, `sample/`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
|
41
42
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
42
43
|
|
|
43
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it folds fresh tees into the index
|
|
44
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last `/usage` attempt is oldest (the direct no-spend usage GET for an idle account with a fresh token, the `/usage` probe under the store otherwise), and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
|
|
44
45
|
|
|
45
|
-
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude
|
|
46
|
+
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
|
|
46
47
|
|
|
47
48
|
---
|
|
48
49
|
|
|
49
50
|
## 3. How a move happens
|
|
50
51
|
|
|
51
52
|
### 3.1 Usage feed (free, push-based)
|
|
52
|
-
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (write-on-change, O(ms)), and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). A session with no pooled seat writes no tee.
|
|
53
|
+
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (write-on-change, O(ms)), and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). A session with no pooled seat writes no tee. Fallback when the seat's tee is older than `policy.usagePollTtlMs`, and the per-model read when the active model is a gated family: `claude -p '/usage'` under the seat's store, with a throwaway `CLAUDE_CONFIG_DIR` so no hooks load and `TOKENMAXXING_PROBE=1` in the environment so the hook entries return at once should any load (hooks fire in `-p` too; the compact child sets it as well). The probe scrubs every ambient credential override claude reads before the store (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) and then sets the store it means to read. It takes one attempt per interval; a silent result (claude prints local stats with no percentages when its own usage fetch throttles) doubles the interval up to 30 minutes, and a success resets it.
|
|
53
54
|
|
|
54
55
|
### 3.2 Detect + decide + signal (Stop hook, per turn)
|
|
55
56
|
1. Resolve the seat: the pooled account whose store the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` names. No seat (a session started outside the supervisor) means nothing to move.
|
|
@@ -58,23 +59,23 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
58
59
|
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
|
|
59
60
|
|
|
60
61
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
61
|
-
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The `SessionStart` hook (source
|
|
62
|
+
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
|
|
62
63
|
|
|
63
64
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
64
65
|
1. Fold every account's tee into the index.
|
|
65
|
-
2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (
|
|
66
|
+
2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (still supervised, on whatever login the environment names). Print mode, the non-interactive subcommands, the bare resume picker, an invalid `--session-id`, and `--fork-session` with a resume are passed through unmanaged.
|
|
66
67
|
3. Spawn claude with the store env set, then write `live/<sid>` with the child's pid and start time. The lock covers the pick and the presence write, so concurrent launches see each other.
|
|
67
68
|
|
|
68
69
|
### 3.5 Multiple concurrent sessions
|
|
69
|
-
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, its own seat, and its own presence file. Sessions spread over the pool at launch (the second launch never lands on the first's account while another fresh account exists), sessions on one account share its store and coordinate refreshes through Claude Code's own store-rooted refresh lock, and each session moves on its own seat's figures. A move never touches another session. Only a shared state read is serialized: the pool lock covers decisions, placement, and the sampler's reservation
|
|
70
|
+
Each terminal ran the supervisor, so each has its own child `claude`, its own `--session-id`, its own seat, and its own presence file. Sessions spread over the pool at launch (the second launch never lands on the first's account while another fresh account exists), sessions on one account share its store and coordinate refreshes through Claude Code's own store-rooted refresh lock, and each session moves on its own seat's figures. A move never touches another session. Only a shared state read is serialized: the pool lock covers decisions, placement, and the sampler's reservation; the tick's `/usage` child runs outside it, while a live `status` probes under it.
|
|
70
71
|
|
|
71
72
|
---
|
|
72
73
|
|
|
73
74
|
## 4. Onboarding (no `adopt`)
|
|
74
75
|
|
|
75
76
|
- **`tokenmaxxing init` logs your first pooled account in through an isolated browser sign-in, even if you are already signed in.** The login runs in a throwaway `CLAUDE_CONFIG_DIR=~/.config/tokenmaxxing/onboard`, its token's owner is verified through the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), and the credential is harvested once into that account's store; the throwaway home and its credential item are deleted. The login you already had keeps its own grant in Claude Code's default store for sessions started outside the supervisor; it is never copied, because a copy of one grant in two stores dies at the first refresh. `init` also installs the supervisor + the five settings entries.
|
|
76
|
-
- **`tokenmaxxing add`** - registers *additional* accounts the same way, one store each. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway homes like this one
|
|
77
|
-
- **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose store died (a needs-reauth account can never heal on its own: the dead grant is exactly what Claude Code's refresh would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (verified `accountUuid` must match, else nothing changes). Bare `auth` lists the pool with emails and asks which; `--all` walks every flagged
|
|
77
|
+
- **`tokenmaxxing add`** - registers *additional* accounts the same way, one store each. `CLAUDE_CONFIG_DIR` is SET by tokenmaxxing only for throwaway homes like this one, the probe homes under `sample/`, and the `setup-token/` mint homes; a session's store is always set through `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
|
|
78
|
+
- **`tokenmaxxing auth [sel | --all]`** - reauthenticates an *existing* pool member whose store died (a needs-reauth account can never heal on its own: the dead grant is exactly what Claude Code's refresh would need). Same isolated-login harvest as `add`, but it states which email to sign in with and **requires the login to land on the target account** (verified `accountUuid` must match, else nothing changes). Bare `auth` lists the pool with emails and asks which; `--all` walks every account that is flagged or whose store holds no usable credential, one by one.
|
|
78
79
|
- Both commands exercise `security` reads/writes interactively (where a macOS keychain ACL prompt is acceptable), so the first access never happens cold inside a headless hook.
|
|
79
80
|
|
|
80
81
|
---
|
|
@@ -82,31 +83,31 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
82
83
|
## 5. Placement and rotation policy
|
|
83
84
|
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
|
|
84
85
|
|
|
85
|
-
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest
|
|
86
|
+
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
|
|
86
87
|
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under each account's store, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the `/usage` child.
|
|
87
88
|
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
88
89
|
- Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
|
|
89
90
|
|
|
90
91
|
The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
|
|
91
92
|
|
|
92
|
-
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels
|
|
93
|
+
**Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from `claude -p '/usage'` under the seat's store (free, 0 tokens, one attempt with backoff) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move, and a launch, whose model is unknown, gates every configured family.
|
|
93
94
|
|
|
94
95
|
---
|
|
95
96
|
|
|
96
97
|
## 6. Honest papercuts
|
|
97
98
|
- **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
|
|
98
|
-
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the
|
|
99
|
+
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the relaunch continues the conversation on the fresh account from the supervisor's resume prompt. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
|
|
99
100
|
- **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
|
|
100
101
|
- **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
|
|
101
102
|
- **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
|
|
102
103
|
- **statusLine fragility.** The native statusline is the most visible surface - a bug flickers or blanks the line for every session (and install takes the slot outright, replacing any custom statusline you had). Keep it O(ms), write-on-change.
|
|
103
|
-
- **Keychain blob size & ps-safety.** A store's keychain item also holds per-MCP-server OAuth state once Claude Code writes it, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing's one write, the onboarding harvest, stores the **`claudeAiOauth`-only** object (small
|
|
104
|
+
- **Keychain blob size & ps-safety.** A store's keychain item also holds per-MCP-server OAuth state once Claude Code writes it, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing's one write, the onboarding harvest, stores the **`claudeAiOauth`-only** object (small, so it takes the ps-safe stdin write; a blob past the 4000-byte line falls back to an argv write) and never rewrites a store afterwards.
|
|
104
105
|
- **settings.json is user-owned.** Install by merge; ship `tokenmaxxing doctor` to verify the supervisor + 5 entries survive a `/config` edit or update.
|
|
105
106
|
|
|
106
107
|
---
|
|
107
108
|
|
|
108
109
|
## 7. Scope
|
|
109
|
-
**v1:** `tokenmaxxing init` / `add` / `ls`
|
|
110
|
+
**v1:** `tokenmaxxing init` / `add` / `auth` / `status` (bare default; `ls` removed in 1.30.0, `status --cached` covers it) / `check` / `config` / `doctor` / `rm` / `rename` / `uninstall`; the supervisor; statusLine shim + Stop/StopFailure/SessionStart hooks; per-session placement and moves by respawn with `flock` + reset-aware picker; platform credential stores (macOS keychain / Linux 0600 files, one facade). macOS + Linux.
|
|
110
111
|
|
|
111
112
|
**v2:** projected-threshold pre-emption; a `UserPromptSubmit` guard that respawns *before* a turn starts when already over; Windows.
|
|
112
113
|
|
|
@@ -114,16 +115,16 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
|
|
|
114
115
|
|
|
115
116
|
**Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
|
|
116
117
|
|
|
117
|
-
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`).
|
|
118
|
+
**Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`). Since 1.44.0 two status-only pools, grok and opencode-go, share the engine's account commands and state layout with `statusOnly: true` and no supervisor, hooks, or usage source ([Status-only pools](docs/content/docs/status-only-pools.mdx)).
|
|
118
119
|
|
|
119
|
-
**Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
|
|
120
|
+
**Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes `.cursor/environment.json` and a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
|
|
120
121
|
|
|
121
122
|
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the read-only identity check at onboarding; Slack as a message bus; soft concurrency caps.
|
|
122
123
|
|
|
123
124
|
---
|
|
124
125
|
|
|
125
126
|
## 8. Stack
|
|
126
|
-
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude`
|
|
127
|
+
TypeScript on Bun, shipped as source: one multi-call entry (`src/main.ts`, `#!/usr/bin/env bun`) serves the CLI, the `claude` and `codex` supervisors, the statusLine and subagent statusLine shims, and the hooks; `init` installs a 2-line shim that `exec`s bun on the installed package's entry (a longer `/bin/sh` shim under Nix that drops its own directory from PATH; the Stop path runs every turn, and bun's start-up stays low-millisecond). A Bun global install refreshes itself from npm on the check tick, once a day. Published to npm as `tokenmaxxing` (source, platform-independent - a compiled binary was tried and shipped one architecture's Mach-O to every platform). Shipping is PR-based since 2026-07-18: work reaches main only through a pull request (branch, PR, CI green, a 10-minute review wait, every review handled, merge, teardown), and a release is a PR-landed version bump followed by `gh release create` (details in AGENTS.md "Release and CI"). The supervisor spawns claude with inherited stdio (a relay pipe on stdin for a stream-json session) and restores saved `stty -g` termios between runs - no PTY layer; resize and signals flow through the shared foreground process group.
|
|
127
128
|
|
|
128
129
|
---
|
|
129
130
|
|
package/README.md
CHANGED
|
@@ -1,19 +1,20 @@
|
|
|
1
1
|
# tokenmaxxing
|
|
2
2
|
|
|
3
|
-
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, restarted under another account's credential store. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session pauses with a countdown and auto-resumes at the soonest reset, when that reset falls within `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
|
|
3
|
+
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session pauses with a countdown and auto-resumes at the soonest reset, when that reset falls within `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
|
|
4
4
|
|
|
5
|
-
> **Scope:** Claude Code
|
|
5
|
+
> **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok and opencode-go as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys; the opencode-go pool, which holds Zen API keys, is the one exception.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ claude
|
|
9
9
|
...you work normally; the statusline's ◆ marks this session's account...
|
|
10
|
+
↻ tokenmaxxing: compacting the conversation on me@example.com before the move...
|
|
10
11
|
↻ tokenmaxxing: moving to work@acme.com - resuming...
|
|
11
12
|
⏳ tokenmaxxing: all accounts at their limit. Resuming on work@acme.com when it resets (Ctrl-C to resume now).
|
|
12
13
|
```
|
|
13
14
|
|
|
14
15
|
## Why
|
|
15
16
|
|
|
16
|
-
Every pooled account owns one Claude Code credential store under `~/.config/tokenmaxxing/stores/`. A thin `claude` supervisor on your PATH picks an account for each session and points `CLAUDE_SECURESTORAGE_CONFIG_DIR` at its store, so sessions on different accounts never share a credential, while sessions on the same account share its store and Claude Code's own refresh keeps them in step. A session moves only by respawn: the supervisor stops it at a committed turn boundary (the transcript is already on disk, nothing is lost) and runs `claude --resume <id>` under another store. tokenmaxxing writes a store once at onboarding and never again; it never copies a credential between stores, because a refresh rotation revokes the previous access token and two copies of one grant break each other. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
|
|
17
|
+
Every pooled account owns one Claude Code credential store under `~/.config/tokenmaxxing/stores/`. A thin `claude` supervisor on your PATH picks an account for each session and points `CLAUDE_SECURESTORAGE_CONFIG_DIR` at its store, so sessions on different accounts never share a credential, while sessions on the same account share its store and Claude Code's own refresh keeps them in step. A session moves only by respawn: the supervisor stops it at a committed turn boundary (the transcript is already on disk, nothing is lost) and runs `claude --resume <id>` under another store. tokenmaxxing writes a store once at onboarding and never again; it never copies a credential between stores, because a refresh rotation revokes the previous access token and two copies of one grant break each other. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through. Print mode and the non-interactive subcommands run without a seat, on whatever login the environment names.
|
|
17
18
|
|
|
18
19
|
## Install
|
|
19
20
|
|
|
@@ -51,7 +52,7 @@ programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.defa
|
|
|
51
52
|
|
|
52
53
|
With either module, run `tokenmaxxing init` afterwards.
|
|
53
54
|
|
|
54
|
-
`init` opens
|
|
55
|
+
`init` verifies and pins the real `claude` binary, opens an isolated `claude` session for your first pooled account (run `/login` there, even if you are already signed in; the login you already have stays for sessions started outside the supervisor), installs the `claude` supervisor and the `xx` entry point, five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure hook, a SessionStart hook), and the periodic check timer, and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
|
|
55
56
|
|
|
56
57
|
```sh
|
|
57
58
|
tokenmaxxing add # logs one in, in isolation, into its own store
|
|
@@ -62,16 +63,17 @@ claude # use claude as always; each session gets its own accoun
|
|
|
62
63
|
|
|
63
64
|
| command | what it does |
|
|
64
65
|
|---|---|
|
|
65
|
-
| `tokenmaxxing init` | log the first account in (isolated) + install supervisor
|
|
66
|
+
| `tokenmaxxing init` | verify and pin the real `claude`, log the first account in (isolated) + install supervisor, hooks, PATH line, and check timer |
|
|
66
67
|
| `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
|
|
67
|
-
| `tokenmaxxing
|
|
68
|
-
| `tokenmaxxing add --codex` | register an additional
|
|
69
|
-
| `tokenmaxxing auth [--codex] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one
|
|
70
|
-
| `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
68
|
+
| `tokenmaxxing init --grok` / `init --opencode-go` | pool the first grok login or opencode-go key, status-only: no supervisor, no switching |
|
|
69
|
+
| `tokenmaxxing add [--codex \| --grok \| --opencode-go]` | register an additional account in that pool (isolated login, harvested once into its own store) |
|
|
70
|
+
| `tokenmaxxing auth [--codex \| --grok \| --opencode-go] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one |
|
|
71
|
+
| `tokenmaxxing status [--cached]` | every pool: accounts with 5h / weekly / per-model usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
|
|
71
72
|
| `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
|
|
72
|
-
| `tokenmaxxing
|
|
73
|
-
| `tokenmaxxing
|
|
74
|
-
| `tokenmaxxing
|
|
73
|
+
| `tokenmaxxing check` | fold fresh tees, sample up to three stale accounts, and self-update a Bun global install once a day; the periodic timer runs this every tick |
|
|
74
|
+
| `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the credential identity of every Codex store and of every Claude store whose access token is still fresh (an expiring Claude token is reported as unverifiable, not as a failure), codex hook trust, setup-token age, and shell aliases that shadow `claude` |
|
|
75
|
+
| `tokenmaxxing rename [--codex \| --grok \| --opencode-go] <sel> <label>` / `rm [...] <sel>` | manage a pool (one email can hold both a claude and a codex account); `rm` is refused for an account with a running supervised session |
|
|
76
|
+
| `tokenmaxxing uninstall` | remove the shims, settings entries, codex Stop hook, check timer, and rc PATH line (accounts/stores kept) |
|
|
75
77
|
| `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
|
|
76
78
|
| `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
|
|
77
79
|
| `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
|
|
@@ -79,16 +81,16 @@ claude # use claude as always; each session gets its own accoun
|
|
|
79
81
|
|
|
80
82
|
## How switching decides
|
|
81
83
|
|
|
82
|
-
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is **
|
|
84
|
+
Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
83
85
|
|
|
84
86
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
|
|
85
|
-
- **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'` under the seat's store (free, 0 tokens,
|
|
87
|
+
- **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'` under the seat's store (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the active model is one of `policy.switchModels`, backing off after silent probes, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate.
|
|
86
88
|
|
|
87
|
-
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
89
|
+
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90, minus the margin) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98).
|
|
88
90
|
|
|
89
|
-
Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by
|
|
91
|
+
Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
|
|
90
92
|
|
|
91
|
-
- Every periodic check tick folds fresh statusline tees into the index and samples
|
|
93
|
+
- Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`: an idle account with a fresh token through the direct no-spend usage read, the rest through `claude -p '/usage'` under the account's store; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
|
|
92
94
|
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
93
95
|
|
|
94
96
|
See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
|
|
@@ -104,14 +106,15 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
104
106
|
"projectionMargin": 3,
|
|
105
107
|
"switchModels": ["fable"],
|
|
106
108
|
"usagePollTtlMs": 90000,
|
|
109
|
+
"maxWaitMs": 3600000,
|
|
107
110
|
"checkIntervalMs": 60000
|
|
108
111
|
}
|
|
109
112
|
}
|
|
110
113
|
```
|
|
111
114
|
|
|
112
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's
|
|
115
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's probe and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, and `opencodeBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, and `TOKENMAXXING_OPENCODE_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
113
116
|
|
|
114
|
-
State lives
|
|
117
|
+
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer unit, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
115
118
|
|
|
116
119
|
## Codex support
|
|
117
120
|
|
|
@@ -123,9 +126,9 @@ tokenmaxxing add --codex # log in another account, isolated - your primary lo
|
|
|
123
126
|
codex # use codex as always
|
|
124
127
|
```
|
|
125
128
|
|
|
126
|
-
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. Each codex session runs on its own account's store (`codex-stores/<uuid8>/`, only `auth.json` per account, everything else shared with `~/.codex` so resume works across stores). The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and
|
|
129
|
+
Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. Each codex session runs on its own account's store (`codex-stores/<uuid8>/`, only `auth.json` per account, everything else shared with `~/.codex` so resume works across stores). The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, the weekly aggregate and every named additional limit alike); when it moves, the supervisor relaunches `codex resume <session-id>` under the target's store with the transcript intact, after compacting the thread on the old account. `status` (and `status --cached`) shows every pool.
|
|
127
130
|
|
|
128
|
-
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex
|
|
131
|
+
Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and each codex session runs on its own store, so no live `auth.json` is ever shared: tokenmaxxing refreshes a parked account's token only when it reads usage for it, never one a session is running on, and a move is a restart at an idle turn boundary. `init --codex` refuses a `~/.codex/config.toml` that pins `cli_auth_credentials_store` away from `file`.
|
|
129
132
|
|
|
130
133
|
## Cursor Cloud
|
|
131
134
|
|
|
@@ -133,16 +136,17 @@ A Cursor Cloud Agent can hand substantial work to Claude Code running on your ow
|
|
|
133
136
|
|
|
134
137
|
## Honest limitations
|
|
135
138
|
|
|
136
|
-
- **A move restarts the process.** The session resumes the same transcript under the new account, but the process restarts
|
|
139
|
+
- **A move restarts the process.** The session resumes the same transcript under the new account and continues on its own from a first prompt the supervisor submits, but the process restarts; a bar-triggered move compacts the conversation on the old account first, so the first turn on the new account uploads the summary, while a move after a refusal re-uploads the full context once (prompt cache is org-scoped).
|
|
137
140
|
- **Depleted-pause hiccup.** When the whole pool is at its limit, `claude` stops for the countdown; anything typed in that split second is lost.
|
|
138
|
-
- **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on Claude Code's own login, which tokenmaxxing never writes.
|
|
141
|
+
- **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on Claude Code's own login, which tokenmaxxing never writes; when a hard limit lands there, the StopFailure hook prints the shim command that resumes the session under the supervisor.
|
|
139
142
|
- **One shared identity file.** `~/.claude.json` holds one `oauthAccount` that Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email; the statusline's `◆` marks the account a session actually uses.
|
|
140
|
-
- **Keychain
|
|
143
|
+
- **Keychain (macOS).** The first keychain access for a new store happens inside the interactive `init`, `add`, or `auth` run, where a prompt can be answered, not inside a headless hook.
|
|
141
144
|
- **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
|
|
145
|
+
- **Status-only pools carry no usage.** grok and opencode-go accounts render with no bars and never move.
|
|
142
146
|
|
|
143
147
|
## How it's built
|
|
144
148
|
|
|
145
|
-
TypeScript on Bun. One multi-call entry (`src/main.ts`) runs the CLI, the `claude`
|
|
149
|
+
TypeScript on Bun. One multi-call entry (`src/main.ts`) runs the CLI, the `claude` and `codex` supervisors, and the hook/statusLine shims. [Zod](https://zod.dev) validates every external-boundary payload (credential blobs, hook/statusLine stdin, identity responses, config). [es-toolkit](https://es-toolkit.dev) for utilities and [ky](https://github.com/sindresorhus/ky) for the few HTTP calls (the identity check, the no-spend usage read, codex's usage and refresh, the npm version read). The supervisor is process/terminal-only. It never proxies API traffic or touches tokens in flight. Cross-process coordination uses `flock(2)` via `bun:ffi` (macOS has no `flock(1)`; one codepath for both platforms). Credential I/O goes through one platform-selected store: `security(1)` generic-passwords on macOS, atomic 0600 file writes on Linux.
|
|
146
150
|
|
|
147
151
|
## License
|
|
148
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.51.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/rename.ts
CHANGED
|
@@ -16,7 +16,7 @@ export function findAccount(accounts: Account[], selector: string): Account | un
|
|
|
16
16
|
export async function cmdRename(p: Provider, argv: string[]): Promise<number> {
|
|
17
17
|
const [selector, newLabel] = argv;
|
|
18
18
|
if (!selector || !newLabel) {
|
|
19
|
-
emitError({ message: "usage: tokenmaxxing rename [--codex] <email|label|id> <new-label>", paint: plain });
|
|
19
|
+
emitError({ message: "usage: tokenmaxxing rename [--codex | --grok | --opencode-go] <email|label|id> <new-label>", paint: plain });
|
|
20
20
|
return 2;
|
|
21
21
|
}
|
|
22
22
|
return withLock(p.pool.lockFile, async () => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
2
2
|
import { basename, join } from "node:path";
|
|
3
|
-
import type { Subprocess } from "bun";
|
|
3
|
+
import type { FileSink, Subprocess } from "bun";
|
|
4
4
|
import { maxBy } from "es-toolkit";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
|
|
@@ -22,18 +22,21 @@ const NONINTERACTIVE_SUBCMDS = new Set([
|
|
|
22
22
|
|
|
23
23
|
const VALUE_TAKING_ROOT_FLAGS = new Set([
|
|
24
24
|
"--agent", "--agents", "--append-system-prompt", "--append-system-prompt-file",
|
|
25
|
-
"--debug-file", "--effort", "--
|
|
26
|
-
"--
|
|
27
|
-
"--
|
|
28
|
-
"--
|
|
29
|
-
"--
|
|
25
|
+
"--autocompact", "--debug-file", "--effort", "--environment", "--fallback-model",
|
|
26
|
+
"--input-format", "--json-schema", "--managed-settings", "--max-budget-usd",
|
|
27
|
+
"--max-thinking-tokens", "--max-turns", "--model", "-n", "--name",
|
|
28
|
+
"--output-format", "--permission-mode", "--permission-prompt-tool", "--permission-prompts",
|
|
29
|
+
"--plugin-dir", "--plugin-dir-no-mcp", "--plugin-url", "--remote-control-session-name-prefix",
|
|
30
|
+
"--setting-sources", "--settings", "--system-prompt", "--system-prompt-snapshot",
|
|
31
|
+
"--task-budget", "--thinking", "--thinking-display",
|
|
30
32
|
]);
|
|
31
33
|
const VARIADIC_ROOT_FLAGS = new Set([
|
|
32
34
|
"--add-dir", "--allowedTools", "--allowed-tools", "--betas",
|
|
33
35
|
"--disallowedTools", "--disallowed-tools", "--file", "--mcp-config", "--tools",
|
|
34
36
|
]);
|
|
35
37
|
const OPTIONAL_VALUE_ROOT_FLAGS = new Set([
|
|
36
|
-
"-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control",
|
|
38
|
+
"--cloud", "-d", "--debug", "--from-pr", "--prompt-suggestions", "--remote-control",
|
|
39
|
+
"--teleport", "-w", "--worktree",
|
|
37
40
|
]);
|
|
38
41
|
|
|
39
42
|
const isUuid = (s: string) => z.uuid().safeParse(s).success;
|
|
@@ -43,6 +46,7 @@ const AnalysisSchema = z.object({
|
|
|
43
46
|
sessionId: z.string().nullable(),
|
|
44
47
|
resumeId: z.string().nullable(),
|
|
45
48
|
continueLatest: z.boolean(),
|
|
49
|
+
streamInput: z.boolean(),
|
|
46
50
|
});
|
|
47
51
|
type Analysis = z.infer<typeof AnalysisSchema>;
|
|
48
52
|
|
|
@@ -50,6 +54,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
50
54
|
let sessionId: string | null = null;
|
|
51
55
|
let resumeId: string | null = null;
|
|
52
56
|
let continueLatest = false;
|
|
57
|
+
let streamInput = false;
|
|
53
58
|
let printMode = false;
|
|
54
59
|
let invalidSessionArg = false;
|
|
55
60
|
let pickerResume = false;
|
|
@@ -82,6 +87,8 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
82
87
|
if (isUuid(value)) resumeId = value;
|
|
83
88
|
else pickerResume = true;
|
|
84
89
|
}
|
|
90
|
+
else if (a === "--input-format") streamInput = argv[++i] === "stream-json";
|
|
91
|
+
else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
|
|
85
92
|
else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
|
|
86
93
|
else if (VARIADIC_ROOT_FLAGS.has(a)) {
|
|
87
94
|
while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
|
|
@@ -97,7 +104,7 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
97
104
|
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
98
105
|
const forkResume = forkSession && (resumeId !== null || continueLatest);
|
|
99
106
|
const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
|
|
100
|
-
return { manage, sessionId, resumeId, continueLatest };
|
|
107
|
+
return { manage, sessionId, resumeId, continueLatest, streamInput };
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
@@ -187,19 +194,97 @@ async function countdownWait(acct: string, until: number): Promise<boolean> {
|
|
|
187
194
|
let aborted = false;
|
|
188
195
|
const onInt = () => { aborted = true; };
|
|
189
196
|
process.on("SIGINT", onInt);
|
|
190
|
-
process.
|
|
197
|
+
process.stderr.write(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`);
|
|
191
198
|
while (!aborted && Date.now() < until) {
|
|
192
199
|
const left = until - Date.now();
|
|
193
200
|
const m = Math.floor(left / 60000);
|
|
194
201
|
const s = Math.floor((left % 60000) / 1000);
|
|
195
|
-
process.
|
|
202
|
+
process.stderr.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
|
|
196
203
|
await Bun.sleep(1000);
|
|
197
204
|
}
|
|
198
205
|
process.removeListener("SIGINT", onInt);
|
|
199
|
-
process.
|
|
206
|
+
process.stderr.write(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`);
|
|
200
207
|
return aborted;
|
|
201
208
|
}
|
|
202
209
|
|
|
210
|
+
function resumePrompt(compacted: boolean): string {
|
|
211
|
+
const moved = compacted
|
|
212
|
+
? "tokenmaxxing compacted this conversation and resumed the session on an account with quota headroom."
|
|
213
|
+
: "tokenmaxxing resumed this session on an account with quota headroom.";
|
|
214
|
+
return `${moved} Continue the task from where the previous turn left off. If the previous turn ended waiting on the user, restate what you need and wait.`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function userLine(text: string): string {
|
|
218
|
+
return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
class StdinRelay {
|
|
222
|
+
private sink: FileSink | null = null;
|
|
223
|
+
private queue: string[] = [];
|
|
224
|
+
private ended = false;
|
|
225
|
+
|
|
226
|
+
constructor() {
|
|
227
|
+
this.pump()
|
|
228
|
+
.catch((e: unknown) => log("supervisor.relay_read_failed", { err: e instanceof Error ? e.message : String(e) }))
|
|
229
|
+
.finally(() => {
|
|
230
|
+
this.ended = true;
|
|
231
|
+
this.close();
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
attach(sink: FileSink, first: string | null): void {
|
|
236
|
+
this.sink = sink;
|
|
237
|
+
const lines = first === null ? this.queue : [first, ...this.queue];
|
|
238
|
+
this.queue = [];
|
|
239
|
+
for (const line of lines) this.forward(line);
|
|
240
|
+
if (this.ended) this.close();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
detach(): void {
|
|
244
|
+
this.sink = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private async pump(): Promise<void> {
|
|
248
|
+
const decoder = new TextDecoder();
|
|
249
|
+
let pending = "";
|
|
250
|
+
for await (const chunk of Bun.stdin.stream()) {
|
|
251
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
252
|
+
let nl = pending.indexOf("\n");
|
|
253
|
+
while (nl !== -1) {
|
|
254
|
+
this.forward(pending.slice(0, nl + 1));
|
|
255
|
+
pending = pending.slice(nl + 1);
|
|
256
|
+
nl = pending.indexOf("\n");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
pending += decoder.decode();
|
|
260
|
+
if (pending.length > 0) this.forward(`${pending}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private forward(line: string): void {
|
|
264
|
+
if (this.sink !== null) {
|
|
265
|
+
try {
|
|
266
|
+
this.sink.write(line);
|
|
267
|
+
this.sink.flush();
|
|
268
|
+
return;
|
|
269
|
+
} catch (e) {
|
|
270
|
+
log("supervisor.relay_write_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
271
|
+
this.sink = null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
this.queue.push(line);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private close(): void {
|
|
278
|
+
if (this.sink === null) return;
|
|
279
|
+
try {
|
|
280
|
+
this.sink.end();
|
|
281
|
+
} catch (e) {
|
|
282
|
+
log("supervisor.relay_end_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
283
|
+
}
|
|
284
|
+
this.sink = null;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
203
288
|
async function recordPresence(child: Subprocess, sid: string, seat: Account, savedTermios: string | null): Promise<void> {
|
|
204
289
|
for (let attempt = 0; ; attempt++) {
|
|
205
290
|
try {
|
|
@@ -280,6 +365,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
280
365
|
process.on("SIGINT", () => {});
|
|
281
366
|
process.on("SIGHUP", () => {});
|
|
282
367
|
|
|
368
|
+
const relay = analyzeArgs(base).streamInput ? new StdinRelay() : null;
|
|
369
|
+
let firstLine: string | null = null;
|
|
283
370
|
let respawns = 0;
|
|
284
371
|
let overriddenUntil = 0;
|
|
285
372
|
let wanted: string | null = null;
|
|
@@ -289,9 +376,9 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
289
376
|
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
290
377
|
const { child, seat } = await withLock(claudePool.lockFile, async () => {
|
|
291
378
|
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
|
|
292
|
-
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
|
|
379
|
+
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
|
|
293
380
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
294
|
-
stdin: "inherit",
|
|
381
|
+
stdin: relay === null ? "inherit" : "pipe",
|
|
295
382
|
stdout: "inherit",
|
|
296
383
|
stderr: "inherit",
|
|
297
384
|
env: {
|
|
@@ -302,6 +389,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
302
389
|
...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
|
|
303
390
|
},
|
|
304
391
|
});
|
|
392
|
+
if (relay !== null) relay.attach(spawned.stdin!, firstLine);
|
|
393
|
+
firstLine = null;
|
|
305
394
|
if (picked) await recordPresence(spawned, sid, picked, savedTermios);
|
|
306
395
|
return { child: spawned, seat: picked };
|
|
307
396
|
});
|
|
@@ -317,13 +406,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
317
406
|
const exited = child.exited.then(() => { done = true; return "exit" as const; });
|
|
318
407
|
try {
|
|
319
408
|
const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
|
|
320
|
-
if (winner === "marker")
|
|
409
|
+
if (winner === "marker") {
|
|
410
|
+
relay?.detach();
|
|
411
|
+
child.kill();
|
|
412
|
+
}
|
|
321
413
|
} catch (e) {
|
|
414
|
+
relay?.detach();
|
|
322
415
|
child.kill();
|
|
323
416
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
|
324
417
|
throw e;
|
|
325
418
|
} finally {
|
|
326
419
|
await child.exited;
|
|
420
|
+
relay?.detach();
|
|
327
421
|
done = true;
|
|
328
422
|
await markerWatch.catch(() => {});
|
|
329
423
|
restoreTermios(savedTermios);
|
|
@@ -334,22 +428,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
334
428
|
rmSync(marker, { force: true });
|
|
335
429
|
respawns++;
|
|
336
430
|
const label = loadAccounts(claudePool).accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
|
|
337
|
-
|
|
338
|
-
|
|
431
|
+
const resumable = existsSync(transcriptPath(m.sessionId));
|
|
432
|
+
let compacted = false;
|
|
433
|
+
if (m.compact && seat && resumable) {
|
|
434
|
+
process.stderr.write(`\n\x1b[36m↻ tokenmaxxing: compacting the conversation on ${seat.label} before the move...\x1b[0m\n`);
|
|
339
435
|
const compactEnv: Record<string, string | undefined> = { ...childEnv, TOKENMAXXING_PROBE: "1", CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
|
|
340
436
|
delete compactEnv.TOKENMAXXING_SUPERVISED;
|
|
341
437
|
delete compactEnv.TOKENMAXXING_SESSION_ID;
|
|
342
438
|
delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
|
|
343
439
|
const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
|
|
344
440
|
log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
|
|
345
|
-
if (!outcome.ok) process.
|
|
441
|
+
if (!outcome.ok) process.stderr.write(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`);
|
|
442
|
+
compacted = outcome.ok;
|
|
346
443
|
}
|
|
347
444
|
if (m.waitUntil > Date.now()) {
|
|
348
445
|
if (await countdownWait(label, m.waitUntil)) overriddenUntil = m.waitUntil;
|
|
349
|
-
} else process.
|
|
446
|
+
} else process.stderr.write(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`);
|
|
350
447
|
wanted = m.accountId;
|
|
351
448
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
352
|
-
|
|
449
|
+
const prompt = resumable ? resumePrompt(compacted) : null;
|
|
450
|
+
firstLine = relay !== null && prompt !== null ? userLine(prompt) : null;
|
|
451
|
+
launchArgs = ["--resume", m.sessionId, ...(relay === null && prompt !== null ? [prompt] : []), ...persistable];
|
|
353
452
|
continue;
|
|
354
453
|
}
|
|
355
454
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
package/src/main.ts
CHANGED
|
@@ -41,13 +41,13 @@ function printHelp(): void {
|
|
|
41
41
|
console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
|
|
42
42
|
|
|
43
43
|
${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
|
|
44
|
-
${c.cyan("tokenmaxxing check")} sample
|
|
44
|
+
${c.cyan("tokenmaxxing check")} sample up to three accounts whose last usage attempts are oldest (run by the periodic timer)
|
|
45
45
|
${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
|
|
46
46
|
${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
|
|
47
47
|
${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
|
|
48
48
|
${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
|
|
49
49
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
50
|
-
${c.cyan("tokenmaxxing add --codex
|
|
50
|
+
${c.cyan("tokenmaxxing add")} --codex | --grok | --opencode-go same for the codex, grok, or opencode-go pool
|
|
51
51
|
${c.cyan("tokenmaxxing auth")} [--codex | --grok | --opencode-go] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
|
|
52
52
|
${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
|
|
53
53
|
${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
|
|
@@ -165,7 +165,7 @@ async function main(): Promise<number> {
|
|
|
165
165
|
console.log(`removed ${removed.join(", ")}`);
|
|
166
166
|
if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
|
|
167
167
|
if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
|
|
168
|
-
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
168
|
+
console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; grok: grok-stores/; opencode-go: opencode-go-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
|
|
169
169
|
return 0;
|
|
170
170
|
}
|
|
171
171
|
case "help":
|