tokenmaxxing 1.67.0 → 1.69.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing - design
2
2
 
3
- Automatic Claude Code account switching. You run `claude` exactly as always; 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).
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 queues sessions first come first served onto reset targets (at most four waiters per account, overflow takes the soonest anyway), pauses each one with a countdown, and auto-resumes at its assigned reset when that 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 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).
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 reset the wait queue assigned (at most four waiters per account, first come first served, overflow on the soonest). **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
 
@@ -37,8 +37,9 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
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
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
+ - `wait-queue.json` - the depleted-pool wait claims (`{sessionId, accountId, at, waitUntil}`), so at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
40
41
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points.
41
- - `sessions/<sid>.json`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `hub-key`, `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).
42
+ - `sessions/<sid>.json`, `onboard/`, `hub-key`, `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).
42
43
  - 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.
43
44
 
44
45
  - 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 sample attempt is oldest (the direct no-spend usage GET), deletes leftover dead state files at the state root, and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
@@ -57,7 +58,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
57
58
  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
59
  2. Read the seat's tee and exit while it is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
59
60
  3. Take the pool lock, re-check, fold every account's tee into the index, select a candidate under the switching policy from the cached figures, and verify that the target's store holds a usable credential (a store Claude Code dead-cleared flags the account for re-auth and the next candidate is tried).
60
- 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.
61
+ 4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the assigned reset when the pool is depleted. No credential is written.
61
62
 
62
63
  ### 3.3 Move and depleted-pool pause (supervisor)
63
64
  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 and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <live-id> /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 <live-id>` (`--session-id <live-id>` when that id has no transcript yet, because `--resume` exits with `No conversation found` for it) with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The marker file is keyed by the launch id `<sid>`; the live id inside it is the session id the supervised process runs now, which `/clear` and an in-session `/resume` change. A hook adopts a differing stdin id as the live id (`current` in `sessions/<sid>.json`) only when its own process descends, through shell processes alone, from the presence record's pid; any other differing id is a nested session and writes nothing. The resumed process reads that store cold → runs on the target account, same conversation. The `--session-id` path is the exception: the cleared conversation has no prior turn, so it starts empty with no compaction and no first prompt. On the `--resume` path 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. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. 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. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. 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. In a stream-json session that fatal, the wrap-depth abort, the wrap-rate abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
@@ -75,14 +76,14 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
75
76
  ## 4. Onboarding (no `adopt`)
76
77
 
77
78
  - **`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.
78
- - **`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 and the `setup-token/` mint homes; a session's store is always set through `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
79
+ - **`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; a session's store is always set through `CLAUDE_SECURESTORAGE_CONFIG_DIR`.
79
80
  - **`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.
80
81
  - 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.
81
82
 
82
83
  ---
83
84
 
84
85
  ## 5. Placement and rotation policy
85
- 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.
86
+ 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, queue first come first served onto a reset target (at most four waiters per account, overflow takes the soonest anyway) and wait for that reset. The same ranking places a session at launch.
86
87
 
87
88
  - 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.
88
89
  - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, 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 GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it.
@@ -118,7 +119,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
118
119
 
119
120
  **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 or hooks ([Status-only pools](docs/content/docs/status-only-pools.mdx)). grok `status` reads the weekly subscription credit window from the credits billing GET; opencode-go has no usage source.
120
121
 
121
- **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).
122
+ **Removed (issue #173):** the Cursor Cloud relay and its setup tokens: `tokenmaxxing setup-token`, `tokenmaxxing cursor init`, `tokenmaxxing cloud run`, `setup-tokens.json`, `TOKENMAXXING_TOKENS`, and the `cloud/` state.
122
123
 
123
124
  **Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the read-only identity check at onboarding; Slack as a message bus; soft concurrency caps.
124
125
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
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, 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).
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 queues first come first served for a reset (at most four sessions wait on one account) and pauses with a countdown until its assigned 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
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
 
@@ -71,13 +71,10 @@ claude # use claude as always; each session gets its own accoun
71
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 |
72
72
  | `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
73
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 hub service, 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` |
74
+ | `tokenmaxxing doctor` | verify the install: PATH order, `claudeBin`, the five settings entries, the timer, the hub service, 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, and shell aliases that shadow `claude` |
75
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
76
  | `tokenmaxxing serve` | serve the CLIProxyAPI-compatible usage API on `http://localhost:8317` (`hub.port`) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows the quota of every pooled Claude and Codex account; the management key is the contents of `hub-key` in the state directory, minted on the first run, and `init` runs it as a background service beside the check timer. Details in [Usage hub](docs/content/docs/hub.mdx) |
77
77
  | `tokenmaxxing uninstall [--yes]` | print the targets, then remove the shims, settings entries, codex Stop hook, check timer, hub service, and rc PATH line (accounts/stores kept); refused without `--yes` when `HOME` is the login home |
78
- | `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 |
79
- | `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
80
- | `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 |
81
78
  | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
82
79
 
83
80
  ## How switching decides
@@ -92,7 +89,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
92
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 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.
93
90
 
94
91
  - Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. An expired stored access token stays unmeasured until a session on that store refreshes it.
95
- - When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
92
+ - When no account is usable, the session queues first come first served onto a reset target (at most four waiters per account) and pauses until that reset if it lands within `policy.maxWaitMs`, and otherwise stays put.
96
93
 
97
94
  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.
98
95
 
@@ -113,7 +110,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
113
110
  }
114
111
  ```
115
112
 
116
- `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 sample stays fresh, for a seat's own sample 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
+ `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 sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a 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`.
117
114
 
118
115
  State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, 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`).
119
116
 
@@ -131,10 +128,6 @@ Codex mechanics differ from Claude Code in one hard way: a running codex process
131
128
 
132
129
  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`.
133
130
 
134
- ## Cursor Cloud
135
-
136
- A Cursor Cloud Agent can hand substantial work to Claude Code running on your own accounts. The VM has no keychain and no supervisor, so this path uses setup tokens instead. `tokenmaxxing setup-token` mints one `claude setup-token` per pooled account on your machine and prints the value for a user-scoped Runtime Secret named `TOKENMAXXING_TOKENS`; `tokenmaxxing cursor init` writes a `claude` project subagent and an `environment.json` install line into the repo; on the VM, the subagent forwards each task to `tokenmaxxing cloud run`, which runs `claude -p` on one token per thread and moves the thread to the next token when a run ends on a usage limit. Setup tokens are inference-only and report no usage percentages, so cloud rotation is reactive and the local switching engine never reads one. Details and limits in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
137
-
138
131
  ## Honest limitations
139
132
 
140
133
  - **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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.67.0",
3
+ "version": "1.69.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/doctor.ts CHANGED
@@ -8,8 +8,7 @@ import { loadAccounts, loadConfig } from "../lib/state.ts";
8
8
  import { readStore } from "../lib/credstore.ts";
9
9
  import { codexIdentityOf, readCodexStoreAuth } from "../lib/codexauth.ts";
10
10
  import { isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
11
- import { SETUP_TOKEN_STALE_MS, loadSetupTokens } from "../lib/setuptokens.ts";
12
- import { c, fmtAgo } from "./render.ts";
11
+ import { c } from "./render.ts";
13
12
 
14
13
  export async function cmdDoctor(): Promise<number> {
15
14
  let failed = 0;
@@ -93,15 +92,6 @@ export async function cmdDoctor(): Promise<number> {
93
92
  else if (trust === "unknown") note(`${a.label} (codex): hook trust unknown (no hooks.json or config.toml yet - launch a supervised session once, then trust via /hooks)`);
94
93
  }
95
94
 
96
- if (existsSync(paths.setupTokensJson)) {
97
- const setupTokens = loadSetupTokens().tokens;
98
- for (const a of idx.accounts) {
99
- const token = setupTokens.find((t) => t.accountUuid === a.id);
100
- if (!token) warn(`no setup token stored for ${a.label} - run \`tokenmaxxing setup-token\` to mint one for Cursor Cloud`);
101
- else if (Date.now() - token.mintedAt > SETUP_TOKEN_STALE_MS) warn(`the setup token for ${a.label} was minted ${fmtAgo(token.mintedAt)} and expires a year after minting - \`tokenmaxxing setup-token rm ${a.label}\` then \`tokenmaxxing setup-token\` re-mints it`);
102
- }
103
- }
104
-
105
95
  const cfg = loadConfig();
106
96
  check(!!cfg.claudeBin && existsSync(cfg.claudeBin), "real claude binary resolved", "set claudeBin in config.json");
107
97
  if (cfg.claudeBin && existsSync(cfg.claudeBin)) {
@@ -80,7 +80,7 @@ export async function runStopFailureHook(): Promise<number> {
80
80
  );
81
81
  }
82
82
 
83
- const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced);
83
+ const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced, { waiterId: canRespawn ? session?.sid : undefined });
84
84
  if (enforced && session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
85
85
  writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
86
86
  log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
@@ -24,7 +24,7 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
24
24
  log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
25
25
  return 0;
26
26
  }
27
- const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
27
+ const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid });
28
28
  if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
29
29
  writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
30
30
  log(`${event}.${decision.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
@@ -16,7 +16,7 @@ import { teeObservation } from "../lib/sample.ts";
16
16
  import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough } from "../lib/supervise.ts";
17
17
  import { saveTermios } from "../lib/tty.ts";
18
18
  import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
19
- import { loadAccounts, loadConfig } from "../lib/state.ts";
19
+ import { loadAccounts, loadConfig, releaseWaitClaim } from "../lib/state.ts";
20
20
  import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
21
21
  import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
22
22
  import { errorMessage, log } from "../lib/log.ts";
@@ -222,7 +222,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
222
222
  const families = gatedFamilies(model, cfg.policy.switchModels);
223
223
  const until = seatBlockedUntil(seat.id, now, families, cfg);
224
224
  if (until == null || until <= gate.overriddenUntil) return false;
225
- const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
225
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid });
226
226
  log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
227
227
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
228
228
  writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
@@ -364,10 +364,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
364
364
 
365
365
  let child: Subprocess | null = null;
366
366
  let terminating = false;
367
+ let claimSid: string | null = null;
368
+ const releaseClaim = async (): Promise<void> => {
369
+ const claimed = claimSid;
370
+ if (claimed == null) return;
371
+ try {
372
+ await withLock(claudePool.lockFile, () => {
373
+ releaseWaitClaim(claimed);
374
+ });
375
+ } catch (e) {
376
+ log("supervisor.claim_release_failed", { err: errorMessage(e) });
377
+ }
378
+ };
367
379
  process.on("SIGTERM", () => {
368
380
  terminating = true;
369
- if (child) child.kill("SIGTERM");
370
- else process.exit(143);
381
+ const released = releaseClaim();
382
+ if (child) {
383
+ child.kill("SIGTERM");
384
+ void released;
385
+ } else {
386
+ released.finally(() => process.exit(143));
387
+ }
371
388
  });
372
389
 
373
390
  if (!info.manage || process.env[UNMANAGED_ENV]) {
@@ -392,6 +409,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
392
409
  } else {
393
410
  sid = crypto.randomUUID();
394
411
  }
412
+ claimSid = sid;
395
413
 
396
414
  if (resuming && base.length === 0) {
397
415
  const persisted = loadSessionFlags(sid);
@@ -504,6 +522,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
504
522
  if (m.waitUntil > Date.now()) {
505
523
  if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
506
524
  } else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
525
+ await releaseClaim();
507
526
  wanted = m.accountId;
508
527
  saveSessionFlags(m.sessionId, persistable, process.cwd());
509
528
  const prompt = resumable ? resumePrompt(compacted) : null;
@@ -512,6 +531,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
512
531
  continue;
513
532
  }
514
533
  clearPresence({ dir: paths.presenceDir, id: sid });
534
+ await releaseClaim();
515
535
  log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
516
536
  return exitStatus(proc);
517
537
  }
@@ -520,6 +540,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
520
540
  const text = msg.startsWith("tokenmaxxing:") ? msg : `tokenmaxxing: ${msg}`;
521
541
  say(`\n\x1b[31m${text}\x1b[0m\n`, text);
522
542
  log("supervisor.fatal", { err: msg });
543
+ await releaseClaim();
523
544
  return 1;
524
545
  }
525
546
  }
package/src/lib/claude.ts CHANGED
@@ -14,7 +14,6 @@ import { seatCounts } from "./presence.ts";
14
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
15
15
  import { foldTee, sampleAccountUsage, teeObservation, usageBlockedUntil } from "./sample.ts";
16
16
  import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
- import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
17
  import { saveTermios, restoreTermios } from "./tty.ts";
19
18
  import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
20
19
  import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
@@ -141,10 +140,6 @@ async function removeCredentials(a: Account): Promise<void> {
141
140
  rmSync(storeDirFor(a.id), { recursive: true, force: true });
142
141
  rmSync(`${storeDirFor(a.id)}.lock`, { recursive: true, force: true });
143
142
  clearUsageSnapshot(a.id);
144
- const setupTokens = loadSetupTokens();
145
- if (setupTokens.tokens.some((t) => t.accountUuid === a.id)) {
146
- saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.id) });
147
- }
148
143
  }
149
144
 
150
145
  const IdentityReadySchema = z.looseObject({ oauthAccount: z.looseObject({ accountUuid: z.string().min(1) }) });
package/src/lib/decide.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { withLock } from "./lock.ts";
2
- import { loadAccounts, loadConfig, saveAccounts } from "./state.ts";
3
- import { isExhausted, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
2
+ import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
3
+ import { isExhausted, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
4
4
  import { familyTokens } from "./usage.ts";
5
5
  import { errorMessage, log } from "./log.ts";
6
6
  import type { Observation, Provider } from "./provider.ts";
@@ -8,6 +8,14 @@ import type { Account, EnforcedLimit } from "./types.ts";
8
8
 
9
9
  export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
10
10
 
11
+ export type EvalOpts = {
12
+ seatId?: string | null;
13
+ sessionFamilies?: string[];
14
+ waiterId?: string;
15
+ };
16
+
17
+ const MAX_WAITERS_PER_ACCOUNT = 4;
18
+
11
19
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
12
20
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
13
21
 
@@ -42,23 +50,30 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
42
50
  return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
43
51
  }
44
52
 
45
- export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId(), sessionFamilies?: string[]): Promise<SwapDecision> {
46
- const activeId = seatId;
53
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
54
+ const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
55
+ const waiterId = canRespawn ? opts.waiterId : undefined;
47
56
  const cfg = loadConfig();
48
57
  const bars = thresholdBars(cfg);
49
58
  const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
50
59
  const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
51
- const gated = sessionFamilies ?? p.gatedFamilies(cfg);
60
+ const gated = opts.sessionFamilies ?? p.gatedFamilies(cfg);
52
61
  const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
53
62
  const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
54
63
 
55
64
  if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
65
+ if (waiterId != null && liveWaitClaims(now).some((c) => c.sessionId === waiterId)) {
66
+ await withLock(p.pool.lockFile, () => {
67
+ releaseWaitClaim(waiterId);
68
+ });
69
+ }
56
70
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
57
71
  }
58
72
 
59
73
  return withLock(p.pool.lockFile, async () => {
74
+ if (waiterId != null) releaseWaitClaim(waiterId);
60
75
  const idx = loadAccounts(p.pool);
61
- const id2 = seatId;
76
+ const id2 = activeId;
62
77
  const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
63
78
 
64
79
  const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
@@ -91,7 +106,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
91
106
  }
92
107
  saveAccounts(p.pool, idx);
93
108
 
94
- const families = sessionFamilies ?? p.gatedFamilies(cfg);
109
+ const families = opts.sessionFamilies ?? p.gatedFamilies(cfg);
95
110
  const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
96
111
  const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
97
112
  const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
@@ -142,31 +157,35 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
142
157
  const fresh = loadAccounts(p.pool);
143
158
  const current = seatOf(fresh);
144
159
  const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
145
- const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
146
- const other = pickEarliestReset(usable(fresh.accounts), ctx);
147
-
148
- let target: Account | null = null;
149
- let waitUntil = Number.POSITIVE_INFINITY;
150
- if (other && other.availableAt < currentAt) { target = other.account; waitUntil = other.availableAt; }
151
- else if (current) { target = current; waitUntil = currentAt; }
152
- else if (other) { target = other.account; waitUntil = other.availableAt; }
160
+ const waiters = new Map<string, number>();
161
+ for (const claim of liveWaitClaims(now)) {
162
+ if (claim.sessionId === waiterId) continue;
163
+ waiters.set(claim.accountId, (waiters.get(claim.accountId) ?? 0) + 1);
164
+ }
165
+ const target = pickWaitTarget(usable(fresh.accounts), ctx, waiters, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs);
153
166
 
154
- if (!target || waitUntil - now > cfg.policy.maxWaitMs) {
167
+ if (!target) {
168
+ const soonest = pickWaitTarget(usable(fresh.accounts), ctx, new Map(), MAX_WAITERS_PER_ACCOUNT, Number.POSITIVE_INFINITY);
169
+ const waitUntil = soonest?.availableAt ?? Number.POSITIVE_INFINITY;
155
170
  log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
156
171
  return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
157
172
  }
158
173
 
159
- const isCurrent = target.id === (current?.id ?? null);
174
+ const waitUntil = target.availableAt;
175
+ const isCurrent = target.account.id === (current?.id ?? null);
160
176
  if (!isCurrent) {
161
177
  try {
162
- await p.swap(target);
178
+ await p.swap(target.account);
163
179
  } catch (e) {
164
- skipOrThrow(e, target);
180
+ skipOrThrow(e, target.account);
165
181
  continue;
166
182
  }
167
183
  }
168
- log("decide.depleted_wait", { account: target.id.slice(0, 8), waitUntil });
169
- return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
184
+ if (waiterId != null) {
185
+ replaceWaitClaim({ sessionId: waiterId, accountId: target.account.id, at: now, waitUntil });
186
+ }
187
+ log("decide.depleted_wait", { account: target.account.id.slice(0, 8), waitUntil });
188
+ return { swapped: !isCurrent, account: target.account, reason: "depleted-wait", waitUntil };
170
189
  }
171
190
  });
172
191
  }
package/src/lib/paths.ts CHANGED
@@ -27,12 +27,6 @@ export const paths = {
27
27
  logFile: join(TM_HOME, "tokenmaxxing.log"),
28
28
  onboardDir: join(TM_HOME, "onboard"),
29
29
  storesDir: join(TM_HOME, "stores"),
30
- setupTokensJson: join(TM_HOME, "setup-tokens.json"),
31
- setupTokenDir: join(TM_HOME, "setup-token"),
32
- cloudDir: join(TM_HOME, "cloud"),
33
- cloudSessionsJson: join(TM_HOME, "cloud", "sessions.json"),
34
- cloudWalledJson: join(TM_HOME, "cloud", "walled.json"),
35
- cloudLockFile: join(TM_HOME, "cloud", "lock"),
36
30
  hubKeyFile: join(TM_HOME, "hub-key"),
37
31
 
38
32
  claudeSettings: env(
@@ -48,6 +42,7 @@ export const paths = {
48
42
  export const claudePool = {
49
43
  accountsJson: join(TM_HOME, "accounts.json"),
50
44
  lockFile: join(TM_HOME, "lock"),
45
+ waitQueueJson: join(TM_HOME, "wait-queue.json"),
51
46
  } as const;
52
47
 
53
48
  export const codexPool = {
package/src/lib/picker.ts CHANGED
@@ -135,3 +135,12 @@ export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestRe
135
135
  .filter((x) => Number.isFinite(x.availableAt));
136
136
  return minBy(mapped, (x) => x.availableAt) ?? null;
137
137
  }
138
+
139
+ export function pickWaitTarget(accounts: Account[], ctx: PickCtx, waiters: Map<string, number>, cap: number, deadline: number): EarliestReset | null {
140
+ const mapped = accounts
141
+ .filter((a) => !a.needsReauth)
142
+ .map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
143
+ .filter((x) => Number.isFinite(x.availableAt) && x.availableAt <= deadline)
144
+ .sort((x, y) => x.availableAt - y.availableAt || (x.account.id === ctx.currentId ? -1 : y.account.id === ctx.currentId ? 1 : 0));
145
+ return mapped.find((x) => (waiters.get(x.account.id) ?? 0) < cap) ?? mapped[0] ?? null;
146
+ }
@@ -56,6 +56,9 @@ const DEAD_STATE_ENTRIES = [
56
56
  "codex-creds",
57
57
  "codex-reconcile",
58
58
  "sample",
59
+ "setup-tokens.json",
60
+ "setup-token",
61
+ "cloud",
59
62
  ];
60
63
 
61
64
  const TMP_MARKER = ".tmp.";
@@ -76,7 +79,7 @@ function listDir(dir: string, root: string): Dirent[] {
76
79
  }
77
80
 
78
81
  function tmpSweepDirs(root: string): string[] {
79
- const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, paths.cloudDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
82
+ const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, codexPaths.presenceDir, codexPaths.respawnDir, codexPaths.onboardDir];
80
83
  for (const parent of STORE_PARENTS) {
81
84
  for (const child of listDir(parent, root)) {
82
85
  if (child.isDirectory()) dirs.push(join(parent, child.name));
package/src/lib/state.ts CHANGED
@@ -1,17 +1,19 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
2
  import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
- import { optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
4
+ import { claudePool, optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
8
8
  ConfigSchema,
9
9
  ErrnoSchema,
10
10
  UsageStateSchema,
11
+ WaitQueueSchema,
11
12
  type Account,
12
13
  type AccountsIndex,
13
14
  type Config,
14
15
  type UsageState,
16
+ type WaitClaim,
15
17
  type Window,
16
18
  } from "./types.ts";
17
19
 
@@ -67,6 +69,41 @@ export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
67
69
  writeFileAtomic(pool.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
68
70
  }
69
71
 
72
+ function loadWaitQueue(): WaitClaim[] {
73
+ if (!existsSync(claudePool.waitQueueJson)) return [];
74
+ let json: unknown;
75
+ try {
76
+ json = JSON.parse(readFileSync(claudePool.waitQueueJson, "utf8"));
77
+ } catch {
78
+ throw new Error(`${claudePool.waitQueueJson} is corrupt (unparsable JSON) - refusing to treat a damaged wait queue as empty; repair or remove the file`);
79
+ }
80
+ const parsed = WaitQueueSchema.safeParse(json);
81
+ if (!parsed.success) {
82
+ throw new Error(`${claudePool.waitQueueJson} does not match the wait-queue schema - refusing to treat a damaged wait queue as empty; repair or remove the file`);
83
+ }
84
+ return parsed.data.claims;
85
+ }
86
+
87
+ function saveWaitQueue(claims: WaitClaim[]): void {
88
+ writeFileAtomic(claudePool.waitQueueJson, JSON.stringify(WaitQueueSchema.parse({ version: 1, claims }), null, 2) + "\n");
89
+ }
90
+
91
+ export function liveWaitClaims(now: number): WaitClaim[] {
92
+ return loadWaitQueue().filter((c) => c.waitUntil > now);
93
+ }
94
+
95
+ export function replaceWaitClaim(claim: WaitClaim): void {
96
+ saveWaitQueue([...liveWaitClaims(Date.now()).filter((c) => c.sessionId !== claim.sessionId), claim]);
97
+ }
98
+
99
+ export function releaseWaitClaim(sessionId: string): void {
100
+ const now = Date.now();
101
+ const raw = loadWaitQueue();
102
+ const next = raw.filter((c) => c.waitUntil > now && c.sessionId !== sessionId);
103
+ if (next.length === raw.length) return;
104
+ saveWaitQueue(next);
105
+ }
106
+
70
107
  export type Harvest = {
71
108
  id: string;
72
109
  email: string | null;
package/src/lib/types.ts CHANGED
@@ -128,6 +128,19 @@ export const ConfigSchema = z
128
128
  });
129
129
  export type Config = z.infer<typeof ConfigSchema>;
130
130
 
131
+ export const WaitClaimSchema = z.object({
132
+ sessionId: z.string(),
133
+ accountId: z.string(),
134
+ at: z.number(),
135
+ waitUntil: z.number(),
136
+ });
137
+ export type WaitClaim = z.infer<typeof WaitClaimSchema>;
138
+
139
+ export const WaitQueueSchema = z.object({
140
+ version: z.literal(1),
141
+ claims: z.array(WaitClaimSchema).default([]),
142
+ });
143
+
131
144
  export const RespawnMarkerSchema = z.object({
132
145
  accountId: z.string(),
133
146
  ts: z.number(),
package/src/main.ts CHANGED
@@ -22,9 +22,6 @@ import { cmdRename } from "./cli/rename.ts";
22
22
  import { cmdCheck } from "./cli/check.ts";
23
23
  import { cmdConfig } from "./cli/config.ts";
24
24
  import { cmdSeat } from "./cli/seat.ts";
25
- import { cmdSetupToken } from "./cli/setuptoken.ts";
26
- import { cmdCloudRun } from "./cli/cloudrun.ts";
27
- import { cmdCursorInit } from "./cli/cursorinit.ts";
28
25
  import { cmdServe } from "./cli/serve.ts";
29
26
  import { hubDeactivationHint, onLoginHome, timerDeactivationHint, uninstallSupervisor, uninstallTargets } from "./lib/install.ts";
30
27
  import { errorMessage } from "./lib/log.ts";
@@ -61,9 +58,6 @@ function printHelp(): void {
61
58
  ${c.cyan("tokenmaxxing seat --codex")} <pid> borrow one pooled codex account for an unattended consumer (plugin, script): prints the CODEX_HOME to set, reserved until <pid> exits; exit 1 = none usable, fall back to the ambient login
62
59
  ${c.cyan("tokenmaxxing serve")} serve the CLIProxyAPI-compatible usage API on http://localhost:<hub.port> (default 8317) so a dashboard such as T3 Code's "Add a CLIProxyAPI hub" shows every pooled Claude and Codex account's quota; the management key is the contents of hub-key in the state directory
63
60
  ${c.cyan("tokenmaxxing uninstall")} [--yes] print the targets, then remove supervisor + settings entries (refused without ${c.cyan("--yes")} when HOME is the login home)
64
- ${c.cyan("tokenmaxxing setup-token")} [--print | rm <label|uuid>] Cursor Cloud only: mint one \`claude setup-token\` per pooled account (browser sign-in each) and print the TOKENMAXXING_TOKENS secret value; ${c.cyan("--print")} prints the stored set, ${c.cyan("rm")} drops one
65
- ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
66
- ${c.cyan("tokenmaxxing cloud run")} [--session <id>] [--max-turns <n>] "<prompt>" on a Cursor Cloud VM: run claude -p on a setup token from TOKENMAXXING_TOKENS, rotate to the next token on a usage limit
67
61
 
68
62
  ${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, check); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
69
63
 
@@ -162,18 +156,7 @@ async function main(): Promise<number> {
162
156
  }
163
157
  return cmdSeat(args[1], args.slice(2));
164
158
  }
165
- case "setup-token": return cmdSetupToken(args.slice(1));
166
159
  case "serve": return cmdServe(args.slice(1));
167
- case "cursor": {
168
- if (args[1] === "init") return cmdCursorInit(args.slice(2));
169
- emitError({ message: "usage: tokenmaxxing cursor init [dir]" });
170
- return 2;
171
- }
172
- case "cloud": {
173
- if (args[1] === "run") return cmdCloudRun(args.slice(2));
174
- emitError({ message: 'usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"' });
175
- return 2;
176
- }
177
160
  case "uninstall": {
178
161
  if (args.length > 1) {
179
162
  emitError({ message: `unknown uninstall option: ${args[1]} (uninstall takes only ${YES_FLAG})` });
@@ -1,100 +0,0 @@
1
- import { parseArgs } from "node:util";
2
- import { withLock } from "../lib/lock.ts";
3
- import { errorMessage } from "../lib/log.ts";
4
- import { paths } from "../lib/paths.ts";
5
- import {
6
- limitWallUntil,
7
- loadCloudSessions,
8
- loadCloudWalled,
9
- newReservationKey,
10
- pickToken,
11
- readCloudTokens,
12
- saveCloudSessions,
13
- saveCloudWalled,
14
- spawnCloudClaude,
15
- transcriptBytes,
16
- } from "../lib/cloud.ts";
17
- import { c, emitError, fmtReset } from "./render.ts";
18
-
19
- const USAGE = "usage: tokenmaxxing cloud run [--session <id>] [--max-turns <n>] \"<prompt>\" (the prompt is also read from stdin)";
20
-
21
- function parseFlags(args: string[]): { session: string | null; maxTurns: number | null; prompt: string | null } | { error: string } {
22
- let parsed;
23
- try {
24
- parsed = parseArgs({ args, options: { session: { type: "string" }, "max-turns": { type: "string" } }, allowPositionals: true });
25
- } catch (e) {
26
- return { error: errorMessage(e) };
27
- }
28
- const { session, "max-turns": rawTurns } = parsed.values;
29
- if (session === "") return { error: "--session needs a session id" };
30
- const maxTurns = rawTurns == null ? null : Number(rawTurns);
31
- if (maxTurns != null && (!Number.isInteger(maxTurns) || maxTurns < 1)) return { error: `--max-turns needs a positive whole number, got: ${rawTurns}` };
32
- return { session: session ?? null, maxTurns, prompt: parsed.positionals.length > 0 ? parsed.positionals.join(" ") : null };
33
- }
34
-
35
- export async function cmdCloudRun(args: string[]): Promise<number> {
36
- const flags = parseFlags(args);
37
- if ("error" in flags) {
38
- emitError({ message: `${flags.error} - ${USAGE}` });
39
- return 2;
40
- }
41
- const prompt = flags.prompt ?? (process.stdin.isTTY ? null : await Bun.stdin.text());
42
- if (prompt == null || prompt.trim() === "") {
43
- emitError({ message: USAGE });
44
- return 2;
45
- }
46
- const tokens = readCloudTokens();
47
- let sessionId = flags.session;
48
- while (true) {
49
- const reservation = newReservationKey();
50
- const pick = await withLock(paths.cloudLockFile, () => {
51
- const sessions = loadCloudSessions();
52
- const picked = pickToken({ tokens, sessions, walled: loadCloudWalled(), sessionId, now: Date.now() });
53
- if (picked.ok) saveCloudSessions({ ...sessions, [reservation]: picked.token.label });
54
- return picked;
55
- });
56
- if (!pick.ok) {
57
- emitError({ message: `every token is at its limit - earliest ${fmtReset(pick.earliestReset)}` });
58
- return 1;
59
- }
60
- const label = pick.token.label;
61
- console.error(c.dim(`cloud run: ${label}${sessionId ? ` (resuming ${sessionId})` : ""}`));
62
- const transcriptBefore = transcriptBytes(sessionId);
63
- let spawned: Awaited<ReturnType<typeof spawnCloudClaude>>;
64
- try {
65
- spawned = await spawnCloudClaude({ token: pick.token.token, prompt, resume: sessionId, maxTurns: flags.maxTurns });
66
- } catch (e) {
67
- await withLock(paths.cloudLockFile, () => {
68
- const sessions = loadCloudSessions();
69
- delete sessions[reservation];
70
- saveCloudSessions(sessions);
71
- });
72
- throw e;
73
- }
74
- const { exitCode, result } = spawned;
75
- sessionId = result.session_id;
76
- const failed = result.is_error || result.subtype !== "success";
77
- const until = failed ? limitWallUntil({ sessionId: result.session_id, sinceBytes: transcriptBefore, now: Date.now() }) : null;
78
- await withLock(paths.cloudLockFile, () => {
79
- const sessions = loadCloudSessions();
80
- delete sessions[reservation];
81
- saveCloudSessions({ ...sessions, [result.session_id]: label });
82
- if (until != null) saveCloudWalled({ ...loadCloudWalled(), [label]: until });
83
- });
84
- if (failed) {
85
- if (until != null) {
86
- console.error(c.yellow(`cloud run: ${label} hit its limit (${fmtReset(until)}) - resuming the session on the next token`));
87
- continue;
88
- }
89
- const errors = result.subtype === "success" ? [result.result] : result.errors;
90
- emitError({
91
- message: `claude exited ${exitCode ?? "on signal"} (${result.subtype}): ${errors.join("; ")}`,
92
- notes: [`session ${result.session_id}`],
93
- });
94
- return 1;
95
- }
96
- console.log(result.result);
97
- console.log(`session ${result.session_id}`);
98
- return 0;
99
- }
100
- }
@@ -1,77 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { join, resolve } from "node:path";
3
- import { z } from "zod";
4
- import { writeFileAtomic } from "../lib/atomic.ts";
5
- import { c, emitError } from "./render.ts";
6
-
7
- export const CLOUD_INSTALL_LINE =
8
- 'curl -fsSL https://bun.sh/install | bash && curl -fsSL https://claude.ai/install.sh | bash && "$HOME/.bun/bin/bun" add -g tokenmaxxing && test -x "$HOME/.bun/bin/bun" && test -x "$HOME/.local/bin/claude" && test -e "$HOME/.bun/bin/tokenmaxxing"';
9
-
10
- export const CLOUD_RUN_COMMAND = 'TOKENMAXXING_CLAUDE_BIN="$HOME/.local/bin/claude" "$HOME/.bun/bin/bun" "$HOME/.bun/bin/tokenmaxxing" cloud run';
11
-
12
- export const RELAY_AGENT = `---
13
- name: claude
14
- description: Runs a task in Claude Code on the owner's subscription accounts. Use proactively for substantial implementation, debugging, or research work that should not consume the parent's context.
15
- model: inherit
16
- readonly: false
17
- ---
18
-
19
- You are a thin forwarding wrapper around Claude Code.
20
-
21
- Your only job is to forward the task to the runner and return its output. Do not do anything else.
22
-
23
- Rules:
24
-
25
- - Use exactly one shell call. Pass the task on stdin through a quoted heredoc. Pick the delimiter fresh for every call as \`TASK_\` followed by eight random letters and digits, and check that no line of the task equals it; a quoted heredoc ends only on a line that matches the delimiter exactly, so the task text can never become shell commands.
26
-
27
- \`\`\`sh
28
- ${CLOUD_RUN_COMMAND} <<'TASK_k3v9qx2m'
29
- <task>
30
- TASK_k3v9qx2m
31
- \`\`\`
32
-
33
- - When the task continues earlier Claude Code work, add \`--session <id>\` after \`cloud run\` with the session id from the previous output.
34
- - Preserve the task text as given. Do not inspect the repository, read files, run other commands, or summarize.
35
- - Return the command's stdout exactly as-is, including the trailing \`session <id>\` line.
36
- - If the command fails, return its stderr as-is.
37
- `;
38
-
39
- const ENVIRONMENT_JSON = JSON.stringify({ install: CLOUD_INSTALL_LINE }, null, 2) + "\n";
40
- const ExistingEnvironmentSchema = z.looseObject({ user: z.string().optional() });
41
-
42
- const REPO_FILE_MODE = 0o644;
43
- const USAGE = "usage: tokenmaxxing cursor init [dir]";
44
-
45
- export function cmdCursorInit(args: string[]): number {
46
- if (args.length > 1) {
47
- emitError({ message: USAGE });
48
- return 2;
49
- }
50
- const dir = resolve(args[0] ?? ".");
51
- if (!existsSync(dir)) {
52
- emitError({ message: `no such directory: ${dir}` });
53
- return 1;
54
- }
55
- const agentFile = join(dir, ".cursor", "agents", "claude.md");
56
- const environmentFile = join(dir, ".cursor", "environment.json");
57
- const existing = existsSync(environmentFile) ? ExistingEnvironmentSchema.parse(Bun.JSONC.parse(readFileSync(environmentFile, "utf8"))) : null;
58
- const agent = existsSync(agentFile) && readFileSync(agentFile, "utf8") === RELAY_AGENT ? "unchanged" : "written";
59
- if (agent === "written") writeFileAtomic(agentFile, RELAY_AGENT, REPO_FILE_MODE);
60
- const environment = existing == null ? "written" : "exists";
61
- const warnings: string[] = [];
62
- if (existing == null) {
63
- writeFileAtomic(environmentFile, ENVIRONMENT_JSON, REPO_FILE_MODE);
64
- } else if (existing.user === "root" || existing.user === "0") {
65
- warnings.push(".cursor/environment.json sets user to root; Claude Code refuses --dangerously-skip-permissions under root, so every relay call fails until user is a non-root account");
66
- }
67
- console.log(`${c.green("✓")} .cursor/agents/claude.md ${agent}`);
68
- if (environment === "written") {
69
- console.log(`${c.green("✓")} .cursor/environment.json written`);
70
- } else {
71
- console.log(c.yellow("⚠ .cursor/environment.json exists - left untouched; add this to its install command:"));
72
- console.log(` ${CLOUD_INSTALL_LINE}`);
73
- }
74
- for (const warning of warnings) console.log(c.yellow(`⚠ ${warning}`));
75
- console.log(c.dim("next: commit both files, add the TOKENMAXXING_TOKENS user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents, and start a cloud agent on the repo"));
76
- return 0;
77
- }
@@ -1,147 +0,0 @@
1
- import { mkdirSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { resolveRealClaude } from "../lib/claudebin.ts";
4
- import { withLock } from "../lib/lock.ts";
5
- import { claudePool, paths, shortId } from "../lib/paths.ts";
6
- import { loadAccounts } from "../lib/state.ts";
7
- import { scrubCredentialEnv } from "../lib/usage.ts";
8
- import { CURSOR_SECRET_VALUE_CAP_BYTES, cursorSecretValue, loadSetupTokens, saveSetupTokens, type SetupToken } from "../lib/setuptokens.ts";
9
- import type { Account } from "../lib/types.ts";
10
- import { c, emitError } from "./render.ts";
11
-
12
- const TOKEN_BEGIN_MARKER = "Your OAuth token (valid for";
13
- const TOKEN_END_MARKER = "Store this token securely";
14
- const USAGE = "usage: tokenmaxxing setup-token [--print] | tokenmaxxing setup-token rm <label|uuid>";
15
-
16
- export function extractSetupToken(output: string): string | null {
17
- const lines = Bun.stripANSI(output).split("\n").map((line) => line.trim());
18
- const end = lines.findLastIndex((line) => line.includes(TOKEN_END_MARKER));
19
- if (end < 0) return null;
20
- const begin = lines.slice(0, end).findLastIndex((line) => line.includes(TOKEN_BEGIN_MARKER));
21
- if (begin < 0) return null;
22
- const token = lines.slice(begin + 1, end).join("");
23
- return token === "" ? null : token;
24
- }
25
-
26
- async function mint(input: { real: string; item: string }): Promise<string | null> {
27
- const configDir = join(paths.setupTokenDir, input.item);
28
- rmSync(configDir, { recursive: true, force: true });
29
- mkdirSync(configDir, { recursive: true });
30
- const env = scrubCredentialEnv({ ...process.env, CLAUDE_CONFIG_DIR: configDir });
31
- const child = Bun.spawn([input.real, "setup-token"], { env, stdin: "inherit", stdout: "pipe", stderr: "inherit" });
32
- const decoder = new TextDecoder();
33
- let captured = "";
34
- for await (const chunk of child.stdout) {
35
- process.stdout.write(chunk);
36
- captured += decoder.decode(chunk, { stream: true });
37
- }
38
- await child.exited;
39
- rmSync(configDir, { recursive: true, force: true });
40
- if (child.exitCode !== 0) {
41
- console.error(c.red(`claude setup-token exited ${child.exitCode ?? "on signal"} - nothing stored`));
42
- return null;
43
- }
44
- const captureToken = extractSetupToken(captured);
45
- if (captureToken) return captureToken;
46
- const pasted = prompt("the token line was not captured from the output above - paste the token here:")?.trim() ?? "";
47
- if (pasted === "") {
48
- console.error(c.red("no token pasted - nothing stored"));
49
- return null;
50
- }
51
- return pasted;
52
- }
53
-
54
- function currentTokens(tokens: SetupToken[], accounts: Account[]): { label: string; token: string }[] {
55
- return accounts.flatMap((a) => {
56
- const stored = tokens.find((t) => t.accountUuid === a.id);
57
- return stored ? [{ label: a.label, token: stored.token }] : [];
58
- });
59
- }
60
-
61
- async function rmToken(selector: string): Promise<number> {
62
- const outcome = await withLock(claudePool.lockFile, async (): Promise<{ error: string } | { kept: SetupToken[] }> => {
63
- const store = loadSetupTokens();
64
- const accounts = loadAccounts(claudePool).accounts;
65
- const pooled = accounts.find((a) => a.label === selector);
66
- const orphan = (t: SetupToken) => !accounts.some((a) => a.id === t.accountUuid);
67
- const targets = store.tokens.filter((t) =>
68
- t.accountUuid === selector || (pooled ? t.accountUuid === pooled.id : t.label === selector && orphan(t)),
69
- );
70
- if (targets.length === 0) return { error: `no setup token stored for "${selector}"` };
71
- if (targets.length > 1) return { error: `"${selector}" matches ${targets.length} stored tokens (${targets.map((t) => `${t.label} ${t.accountUuid}`).join(", ")}) - nothing removed` };
72
- const kept = store.tokens.filter((t) => t !== targets[0]);
73
- saveSetupTokens({ ...store, tokens: kept });
74
- return { kept };
75
- });
76
- if ("error" in outcome) {
77
- emitError({ message: outcome.error });
78
- return 1;
79
- }
80
- console.log(`removed the setup token for ${selector} (local only: Claude Code documents no revocation for setup tokens)`);
81
- return 0;
82
- }
83
-
84
- export async function cmdSetupToken(args: string[]): Promise<number> {
85
- const [sub, label] = args;
86
- if (sub === "rm" && label !== undefined && args.length === 2) return rmToken(label);
87
- const print = sub === "--print" && args.length === 1;
88
- if (args.length > 0 && !print) {
89
- emitError({ message: USAGE });
90
- return 2;
91
- }
92
- let store = loadSetupTokens();
93
- let idx = loadAccounts(claudePool);
94
- if (!print) {
95
- if (idx.accounts.length === 0) {
96
- emitError({ message: "no accounts in the pool - run `tokenmaxxing init` first" });
97
- return 1;
98
- }
99
- const real = resolveRealClaude();
100
- const missing = idx.accounts.filter((a) => !store.tokens.some((t) => t.accountUuid === a.id));
101
- if (missing.length === 0) console.log(c.dim("every pooled account already has a stored setup token (`setup-token rm <label>` drops one so it can be re-minted)"));
102
- for (const a of missing) {
103
- console.log();
104
- console.log(`${c.bold(a.label)} - sign into this account in the browser (${a.email ?? a.label})`);
105
- const token = await mint({ real, item: shortId(a.id) });
106
- if (!token) return 1;
107
- const stored = await withLock(claudePool.lockFile, async () => {
108
- if (!loadAccounts(claudePool).accounts.some((x) => x.id === a.id)) return false;
109
- const fresh = loadSetupTokens();
110
- const tokens = fresh.tokens.filter((t) => t.accountUuid !== a.id);
111
- saveSetupTokens({ ...fresh, tokens: [...tokens, { accountUuid: a.id, label: a.label, token, mintedAt: Date.now() }] });
112
- return true;
113
- });
114
- if (!stored) {
115
- console.log(c.yellow(`${a.label} left the pool during the login - token discarded`));
116
- continue;
117
- }
118
- console.log(`${c.green("✓")} stored the setup token for ${c.bold(a.label)} ${c.dim("(ownership not verified: an inference-only token cannot read the profile endpoint, so the label is the sign-in you chose)")}`);
119
- }
120
- store = loadSetupTokens();
121
- idx = loadAccounts(claudePool);
122
- }
123
- if (store.tokens.length === 0) {
124
- emitError({ message: "no setup tokens stored - run `tokenmaxxing setup-token` to mint them" });
125
- return 1;
126
- }
127
- const tokens = currentTokens(store.tokens, idx.accounts);
128
- const orphaned = store.tokens
129
- .filter((t) => !idx.accounts.some((a) => a.id === t.accountUuid))
130
- .map((t) => ({ label: t.label, accountUuid: t.accountUuid }));
131
- if (tokens.length === 0) {
132
- emitError({ message: "no stored setup token belongs to a pooled account - run `tokenmaxxing setup-token` to mint them" });
133
- return 1;
134
- }
135
- const secret = cursorSecretValue(tokens);
136
- const bytes = Buffer.byteLength(secret);
137
- console.log();
138
- if (orphaned.length > 0) {
139
- console.log(c.dim(`left out of the secret (no longer in the pool): ${orphaned.map((t) => `${t.label} (${t.accountUuid})`).join(", ")}; \`setup-token rm <label|uuid>\` drops one`));
140
- }
141
- console.log(`${c.bold("TOKENMAXXING_TOKENS")} ${c.dim("(user-scoped Runtime Secret at cursor.com/dashboard/cloud-agents)")}`);
142
- console.log(secret);
143
- if (bytes > CURSOR_SECRET_VALUE_CAP_BYTES) {
144
- console.log(c.yellow(`⚠ ${bytes} bytes: over the ${CURSOR_SECRET_VALUE_CAP_BYTES}-byte value cap Cursor documents for API-created environment variables`));
145
- }
146
- return 0;
147
- }
package/src/lib/cloud.ts DELETED
@@ -1,172 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { minBy } from "es-toolkit";
4
- import { z } from "zod";
5
- import { paths } from "./paths.ts";
6
- import { writeFileAtomic } from "./atomic.ts";
7
- import { UNMANAGED_ENV, resolveRealClaude } from "./claudebin.ts";
8
- import { pidExists } from "./proc.ts";
9
- import { CloudTokensSchema, type CloudToken } from "./setuptokens.ts";
10
- import { parseErrorBody, readTranscriptTail, scrubCredentialEnv } from "./usage.ts";
11
-
12
- export const TOKENS_ENV = "TOKENMAXXING_TOKENS";
13
- export const FALLBACK_WALL_MS = 5 * 3_600_000;
14
- const RESERVATION_PREFIX = "pending:";
15
-
16
- export function readCloudTokens(): CloudToken[] {
17
- const raw = process.env[TOKENS_ENV];
18
- if (raw == null || raw === "") {
19
- throw new Error(`${TOKENS_ENV} is not set - add it as a user-scoped Runtime Secret with the value \`tokenmaxxing setup-token --print\` prints`);
20
- }
21
- let json: unknown;
22
- try {
23
- json = JSON.parse(raw);
24
- } catch {
25
- throw new Error(`${TOKENS_ENV} is not valid JSON`);
26
- }
27
- const parsed = CloudTokensSchema.safeParse(json);
28
- if (!parsed.success) throw new Error(`${TOKENS_ENV} must be a non-empty JSON array of {label, token} objects`);
29
- const labels = parsed.data.map((t) => t.label);
30
- if (new Set(labels).size !== labels.length) throw new Error(`${TOKENS_ENV} carries a duplicate label`);
31
- const reserved = labels.find((label) => label in {});
32
- if (reserved != null) throw new Error(`${TOKENS_ENV} carries the label "${reserved}", which is a reserved object property name`);
33
- return parsed.data;
34
- }
35
-
36
- const SessionsSchema = z.record(z.string(), z.string());
37
- const WalledSchema = z.record(z.string(), z.number());
38
-
39
- function loadState<T>(file: string, schema: z.ZodType<T>, empty: T): T {
40
- if (!existsSync(file)) return empty;
41
- let json: unknown;
42
- try {
43
- json = JSON.parse(readFileSync(file, "utf8"));
44
- } catch {
45
- throw new Error(`${file} is corrupt (unparsable JSON) - repair or remove the file`);
46
- }
47
- return schema.parse(json);
48
- }
49
-
50
- export function loadCloudSessions(): Record<string, string> {
51
- const sessions = loadState(paths.cloudSessionsJson, SessionsSchema, {});
52
- for (const key of Object.keys(sessions)) {
53
- if (!key.startsWith(RESERVATION_PREFIX)) continue;
54
- const pid = Number(key.slice(RESERVATION_PREFIX.length).split(":")[0]);
55
- if (!Number.isInteger(pid) || !pidExists(pid)) delete sessions[key];
56
- }
57
- return sessions;
58
- }
59
-
60
- export function saveCloudSessions(sessions: Record<string, string>): void {
61
- writeFileAtomic(paths.cloudSessionsJson, JSON.stringify(SessionsSchema.parse(sessions), null, 2) + "\n");
62
- }
63
-
64
- export function newReservationKey(): string {
65
- return `${RESERVATION_PREFIX}${process.pid}:${crypto.randomUUID().slice(0, 8)}`;
66
- }
67
-
68
- export function loadCloudWalled(): Record<string, number> {
69
- return loadState(paths.cloudWalledJson, WalledSchema, {});
70
- }
71
-
72
- export function saveCloudWalled(walled: Record<string, number>): void {
73
- writeFileAtomic(paths.cloudWalledJson, JSON.stringify(WalledSchema.parse(walled), null, 2) + "\n");
74
- }
75
-
76
- export type Pick = { ok: true; token: CloudToken } | { ok: false; earliestReset: number };
77
-
78
- export function pickToken(input: {
79
- tokens: CloudToken[];
80
- sessions: Record<string, string>;
81
- walled: Record<string, number>;
82
- sessionId: string | null;
83
- now: number;
84
- }): Pick {
85
- const walled = new Map(Object.entries(input.walled));
86
- const sessions = new Map(Object.entries(input.sessions));
87
- const open = (label: string) => (walled.get(label) ?? 0) <= input.now;
88
- if (input.sessionId != null) {
89
- const pinned = sessions.get(input.sessionId);
90
- const token = input.tokens.find((t) => t.label === pinned);
91
- if (token && open(token.label)) return { ok: true, token };
92
- }
93
- const candidates = input.tokens.filter((t) => open(t.label));
94
- if (candidates.length === 0) {
95
- return { ok: false, earliestReset: Math.min(...input.tokens.map((t) => walled.get(t.label) ?? input.now)) };
96
- }
97
- const load = new Map<string, number>();
98
- for (const label of sessions.values()) load.set(label, (load.get(label) ?? 0) + 1);
99
- return { ok: true, token: minBy(candidates, (t) => load.get(t.label) ?? 0) ?? candidates[0]! };
100
- }
101
-
102
- const ResultBase = {
103
- type: z.literal("result"),
104
- session_id: z.string(),
105
- is_error: z.boolean(),
106
- num_turns: z.number(),
107
- total_cost_usd: z.number(),
108
- };
109
- const CloudResultSchema = z.discriminatedUnion("subtype", [
110
- z.looseObject({ ...ResultBase, subtype: z.literal("success"), result: z.string(), api_error_status: z.number().nullish() }),
111
- z.looseObject({
112
- ...ResultBase,
113
- subtype: z.enum(["error_max_turns", "error_during_execution", "error_max_budget_usd", "error_max_structured_output_retries"]),
114
- errors: z.array(z.string()),
115
- }),
116
- ]);
117
- export type CloudResult = z.infer<typeof CloudResultSchema>;
118
-
119
- export async function spawnCloudClaude(input: {
120
- token: string;
121
- prompt: string;
122
- resume: string | null;
123
- maxTurns: number | null;
124
- }): Promise<{ exitCode: number | null; result: CloudResult }> {
125
- const args = [
126
- "-p", "--output-format", "json", "--dangerously-skip-permissions",
127
- ...(input.resume ? ["--resume", input.resume] : []),
128
- ...(input.maxTurns != null ? ["--max-turns", String(input.maxTurns)] : []),
129
- "--", input.prompt,
130
- ];
131
- const env: Record<string, string> = { ...scrubCredentialEnv({ ...process.env, [UNMANAGED_ENV]: "1" }), CLAUDE_CODE_OAUTH_TOKEN: input.token };
132
- delete env[TOKENS_ENV];
133
- const child = Bun.spawn([resolveRealClaude(), ...args], { env, stdin: "ignore", stdout: "pipe", stderr: "inherit" });
134
- const stdout = await new Response(child.stdout).text();
135
- await child.exited;
136
- let json: unknown;
137
- try {
138
- json = JSON.parse(stdout);
139
- } catch {
140
- throw new Error(`claude exited ${child.exitCode ?? "on signal"} without a JSON result: ${stdout.trim().slice(0, 200)}`);
141
- }
142
- const parsed = CloudResultSchema.safeParse(json);
143
- if (!parsed.success) {
144
- throw new Error(`claude exited ${child.exitCode ?? "on signal"} with a result that does not match the documented result message: ${stdout.trim().slice(0, 200)}`);
145
- }
146
- return { exitCode: child.exitCode, result: parsed.data };
147
- }
148
-
149
- export function cloudTranscriptPath(sessionId: string): string | null {
150
- const projects = join(paths.claudeDir, "projects");
151
- if (!existsSync(projects)) return null;
152
- for (const dir of readdirSync(projects)) {
153
- const candidate = join(projects, dir, `${sessionId}.jsonl`);
154
- if (existsSync(candidate)) return candidate;
155
- }
156
- return null;
157
- }
158
-
159
- export function transcriptBytes(sessionId: string | null): number {
160
- const transcript = sessionId ? cloudTranscriptPath(sessionId) : null;
161
- return transcript ? statSync(transcript).size : 0;
162
- }
163
-
164
- export function limitWallUntil(input: { sessionId: string; sinceBytes: number; now: number }): number | null {
165
- const transcript = cloudTranscriptPath(input.sessionId);
166
- const rows = transcript ? readTranscriptTail(transcript, statSync(transcript).size - input.sinceBytes + 1) : [];
167
- const row = rows.findLast((r) => r.isApiErrorMessage === true);
168
- if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
169
- if ((row.quotaLimits?.rateLimitType ?? "") === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
170
- const resetsAt = row.quotaLimits?.resetsAt ?? null;
171
- return resetsAt != null && resetsAt > input.now ? resetsAt : input.now + FALLBACK_WALL_MS;
172
- }
@@ -1,40 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { z } from "zod";
3
- import { paths } from "./paths.ts";
4
- import { writeFileAtomic } from "./atomic.ts";
5
-
6
- export const CURSOR_SECRET_VALUE_CAP_BYTES = 4096;
7
- export const SETUP_TOKEN_STALE_MS = 335 * 86_400_000;
8
-
9
- const SetupTokenSchema = z.object({ accountUuid: z.string().min(1), label: z.string().min(1), token: z.string().min(1), mintedAt: z.number() });
10
- export type SetupToken = z.infer<typeof SetupTokenSchema>;
11
-
12
- const SetupTokensFileSchema = z.object({ version: z.literal(1), tokens: z.array(SetupTokenSchema).default([]) });
13
- export type SetupTokensFile = z.infer<typeof SetupTokensFileSchema>;
14
-
15
- export const CloudTokenSchema = z.object({ label: z.string().min(1), token: z.string().min(1) });
16
- export type CloudToken = z.infer<typeof CloudTokenSchema>;
17
- export const CloudTokensSchema = z.array(CloudTokenSchema).min(1);
18
-
19
- export function loadSetupTokens(): SetupTokensFile {
20
- if (!existsSync(paths.setupTokensJson)) return { version: 1, tokens: [] };
21
- let json: unknown;
22
- try {
23
- json = JSON.parse(readFileSync(paths.setupTokensJson, "utf8"));
24
- } catch {
25
- throw new Error(`${paths.setupTokensJson} is corrupt (unparsable JSON) - refusing to treat a damaged token store as empty; repair or remove the file`);
26
- }
27
- const parsed = SetupTokensFileSchema.safeParse(json);
28
- if (!parsed.success) {
29
- throw new Error(`${paths.setupTokensJson} does not match the setup-token schema - repair or remove the file`);
30
- }
31
- return parsed.data;
32
- }
33
-
34
- export function saveSetupTokens(file: SetupTokensFile): void {
35
- writeFileAtomic(paths.setupTokensJson, JSON.stringify(SetupTokensFileSchema.parse(file), null, 2) + "\n");
36
- }
37
-
38
- export function cursorSecretValue(tokens: CloudToken[]): string {
39
- return JSON.stringify(CloudTokensSchema.parse(tokens));
40
- }