tokenmaxxing 1.163.0 → 1.164.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 or **98%** of a weekly window by default), 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 eight waiters per account; overflow waits for the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset only when the horizon is full), pauses each one with a countdown, and auto-resumes at its assigned reset (further out than twice `policy.maxWaitMs`, 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 or **98%** of a weekly window by default), 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 assigns sessions first come first served to reset targets (at most eight sessions per account; overflow takes the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset only when the horizon is full), stops each session with its assigned reset named, and holds no process open until that reset, so a harness closes the run and the session resumes when it is started again after the reset (further out than twice `policy.maxWaitMs`, a running session stays put).
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 `.claude/rules/codex.md`.
6
6
  >
@@ -30,7 +30,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
30
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/accounts, claudeBin/codexBin/grokBin/piBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
33
+ - `config.json` - SPARSE overrides only (thresholds.session/weekly/accounts, claudeBin/codexBin/grokBin/museBin/piBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
34
  - `accounts.json` - non-secret index `{id, label, email, tier, addedAt, windows, lastUsageAt, lastProbeAt, probeFails, storeFails, usageRetryAt, 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`). Neither index names an active account: the schema is `{version, accounts}`, and an older file that still carries `activeId` loads and drops the key on the next save.
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.
@@ -40,7 +40,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
40
40
  - `wait-queue.json` - the claims of supervised sessions that are moving or waiting (`{sessionId, accountId, at, waitUntil}`), naming the account each relaunches on: placement counts a claimed session there, and at most eight sessions wait on one account; a session that arrives when every reset within `policy.maxWaitMs` is full waits for the next reset with room up to twice `policy.maxWaitMs` out, joining the least-crowded reset when the horizon is full.
41
41
  - `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points; `bin/pi` after `init --pi`.
42
42
  - `pi-stores/claude/<uuid8>/` and `pi-stores/codex/<uuid8>/` - one pi store per pooled account that has a pi login: the account's own pi grant in `auth.json`, everything else symlinked to the shared `~/.pi/agent` ([pi support](docs/content/docs/pi.mdx)).
43
- - `sessions/<sid>.json`, `onboard/`, `hub-key`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` state of the status-only grok pool: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
43
+ - `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 `muse-*` state of the status-only grok and Muse Code pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
44
44
  - 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.
45
45
 
46
46
  - 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 atomic-write temp files under 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.
@@ -61,8 +61,8 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
61
61
  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).
62
62
  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.
63
63
 
64
- ### 3.3 Move and depleted-pool pause (supervisor)
65
- 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, the old seat's record carries no live `enforcedUntil`, and `waitUntil` is not in the future (`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; the compaction counts only when a `compact_boundary` row lands in the transcript, because a refused compaction still exits 0, and a failure only prints why), and (when `waitUntil` is in the future, which means no pooled account has quota) skips the compaction and hands the session to the launch gate, which every launch goes through: with no account that has quota and no API key, it starts no child, claims the reset in `wait-queue.json`, shows an interruptible countdown to it, and places the session again; 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 conversation has no prior turn (a `/clear` with no turn since, or a move before the first turn), so it starts with no compaction and no first prompt, and in a stream-json session the relay replays the harness lines the killed child received: `user` lines and the `initialize` and session-state control requests in `REPLAYED_REQUESTS`; every other control request gets an error `control_response` on stdout, because a repeat could run its effect twice, and the `control_response` answers to the killed child's own requests are dropped. A first launch with `--resume <id>` takes the same path when the supervisor holds `sessions/<id>.json` for that id and no project directory holds its transcript. On the `--resume` path the relaunch submits a first prompt that names the move (and the compaction, when it landed). After a Stop move the previous turn had finished, so the prompt asks Claude to end the turn without restating it, and to relaunch any background Bash task, Monitor, or Workflow run that turn waited on, because the move ended it. After any other move the prompt asks Claude to continue the task, says the restart ended every background Bash task, Monitor, and Workflow run the session started, and asks Claude to relaunch what the task still needs; a SessionStart move is in that group because a prompt sent inside the marker poll window can already have started on the old seat. 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, sends the new process the latest `initialize` and session-state control requests the harness sent, because the Agent SDK sends `initialize` once per query and it carries the appended system prompt, the hooks, and the SDK MCP servers, and then writes the prompt as the first `user` line. 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. Before the first notice of a move it writes a `task_notification` line with `status: "stopped"` and `reason: "worker_restart"` for each task the old child started on stdout (`task_started`) and did not end there (a `task_notification`, or a `task_updated` to `completed`, `failed`, or `killed`), the line Claude Code writes when a restarted process finds a task orphaned, because the move ended those tasks and a harness that tracks them from the stream, as T3 Code does, otherwise shows them running until the session exits. 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 wrapper-chain abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
64
+ ### 3.3 Move and depleted-pool stop (supervisor)
65
+ 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, the old seat's record carries no live `enforcedUntil`, and `waitUntil` is not in the future (`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; the compaction counts only when a `compact_boundary` row lands in the transcript, because a refused compaction still exits 0, and a failure only prints why), and (when `waitUntil` is in the future, which means no pooled account has quota) skips the compaction and hands the session to the launch gate, which every launch goes through: with no account that has quota and no API key, it starts no child, writes one notice naming the assigned reset, releases the session's claim, and exits, and a later resume places the session again; with an account that has quota 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 conversation has no prior turn (a `/clear` with no turn since, or a move before the first turn), so it starts with no compaction and no first prompt, and in a stream-json session the relay replays the harness lines the killed child received: `user` lines and the `initialize` and session-state control requests in `REPLAYED_REQUESTS`; every other control request gets an error `control_response` on stdout, because a repeat could run its effect twice, and the `control_response` answers to the killed child's own requests are dropped. A first launch with `--resume <id>` takes the same path when the supervisor holds `sessions/<id>.json` for that id and no project directory holds its transcript. On the `--resume` path the relaunch submits a first prompt that names the move (and the compaction, when it landed). After a Stop move the previous turn had finished, so the prompt asks Claude to end the turn without restating it, and to relaunch any background Bash task, Monitor, or Workflow run that turn waited on, because the move ended it. After any other move the prompt asks Claude to continue the task, says the restart ended every background Bash task, Monitor, and Workflow run the session started, and asks Claude to relaunch what the task still needs; a SessionStart move is in that group because a prompt sent inside the marker poll window can already have started on the old seat. 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, sends the new process the latest `initialize` and session-state control requests the harness sent, because the Agent SDK sends `initialize` once per query and it carries the appended system prompt, the hooks, and the SDK MCP servers, and then writes the prompt as the first `user` line. 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 stop with its reset 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 supervisor writes only while no child is running, except a fatal that ends the session. Before the first notice of a move it writes a `task_notification` line with `status: "stopped"` and `reason: "worker_restart"` for each task the old child started on stdout (`task_started`) and did not end there (a `task_notification`, or a `task_updated` to `completed`, `failed`, or `killed`), the line Claude Code writes when a restarted process finds a task orphaned, because the move ended those tasks and a harness that tracks them from the stream, as T3 Code does, otherwise shows them running until the session exits. 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. 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 wrapper-chain abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
66
66
 
67
67
  ### 3.4 Placement (supervisor, under the pool lock)
68
68
  1. Fold every account's tee into the index.
@@ -84,11 +84,11 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
84
84
  ---
85
85
 
86
86
  ## 5. Placement and rotation policy
87
- 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 eight waiters per account; overflow waits for the next reset with room) and wait for that reset. The same ranking places a session at launch; a launch onto a depleted pool takes the same target selection, counting running sessions as well as queued waiters, and waits for that reset before it starts a `claude` process.
87
+ 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, assign first come first served to a reset target (at most eight sessions per account; overflow takes the next reset with room), stop the session, and name that reset. The same ranking places a session at launch; a launch onto a depleted pool takes the same target selection, counting running sessions as well as assigned ones, and stops before it starts a `claude` process.
88
88
 
89
89
  - 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.
90
90
  - 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 the account's sample interval, over the direct no-spend usage read with the account's stored token. The interval is 15 minutes times the unused fraction of the bar closest to being reached, never under `policy.usagePollTtlMs`, and 15 minutes for an account at or over an aggregate bar or with no session or borrower on this host. Every sampler claims a read under the pool lock before it sends it, so concurrent hooks and `status` runs send an account one read. The windows are the session and weekly aggregates plus the `policy.switchModels` caps that are still under their bar, and an account with no stored aggregate gets `policy.usagePollTtlMs`. Every account is attempted about once per three ticks per pooled account, or once per its sample interval 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. No sampler sends a read with an expired stored access token, because the endpoint answers it with 401 and, after a few, with the same 429. A sample that finds the token expired first runs `claude -p /usage --no-session-persistence --safe-mode` with the account's store set and a throwaway `CLAUDE_CONFIG_DIR`, so Claude Code refreshes the token under its own lock, and then reads with the new token; the child's output is not read. A refresh that leaves the token expired fails the sample with no read, and one that clears the store flags the account `needsReauth`.
91
- - 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.
91
+ - The depleted stop happens at the bar, not at 100; Codex has no stop and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
92
92
  - 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.
93
93
 
94
94
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
package/README.md CHANGED
@@ -2,14 +2,14 @@
2
2
 
3
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 eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
4
4
 
5
- > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok as a status-only pool. It pools **subscription** accounts (Pro/Max), not API keys.
5
+ > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok and Muse Code as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
9
9
  ...you work normally; the statusline's ◆ marks this session's account...
10
10
  ↻ tokenmaxxing: compacting the conversation on me@example.com before the move...
11
11
  ↻ tokenmaxxing: moving to work@acme.com - resuming...
12
- ⏳ tokenmaxxing: all accounts at their limit. Resuming on work@acme.com when it resets (Ctrl-C to resume now).
12
+ ⏳ tokenmaxxing: all accounts at their limit, so this session stopped. The next reset is at 18:42 KST on work@acme.com. Run `claude --resume <id>` after that to continue.
13
13
  ```
14
14
 
15
15
  ## Why
@@ -67,14 +67,15 @@ claude # use claude as always; each session gets its own accoun
67
67
  | `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
68
68
  | `tokenmaxxing init --pi` | verify and pin the real `pi` and install the `pi` supervisor, which runs pi sessions on pooled Claude and ChatGPT accounts that have a pi login |
69
69
  | `tokenmaxxing init --grok` | pool the first grok login, status-only: no supervisor, no switching |
70
- | `tokenmaxxing add [--codex \| --grok]` | register an additional account in that pool (isolated login, harvested once into its own store) |
71
- | `tokenmaxxing auth [--codex \| --grok] [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 init --muse` | pool the first Muse Code login, status-only: no supervisor, no switching, no usage read |
71
+ | `tokenmaxxing add [--codex \| --grok \| --muse]` | register an additional account in that pool (isolated login, harvested once into its own store) |
72
+ | `tokenmaxxing auth [--codex \| --grok \| --muse] [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 |
72
73
  | `tokenmaxxing auth --pi [--codex] [sel \| --all]` | log a pooled Claude account (or a codex account with `--codex`) into pi, isolated, and keep that login as the account's pi credential after an identity check; `--all` walks every account without a pi login |
73
74
  | `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 |
74
75
  | `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 |
75
76
  | `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 |
76
77
  | `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` |
77
- | `tokenmaxxing rename [--codex \| --grok] <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 |
78
+ | `tokenmaxxing rename [--codex \| --grok \| --muse] <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 |
78
79
  | `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) |
79
80
  | `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 |
80
81
  | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
@@ -146,12 +147,13 @@ pi keeps its own credential and refreshes it itself, so each pooled account gets
146
147
  ## Honest limitations
147
148
 
148
149
  - **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, which ends every background Bash task, Monitor, and Workflow run the session started (the first prompt asks Claude to relaunch what the task still needs); a bar-triggered move compacts the conversation on the old account first, so the first turn on the new account uploads the summary (the full context when the compaction does not land), while a move after a refusal re-uploads the full context once (prompt cache is org-scoped).
149
- - **Depleted-pause hiccup.** When the whole pool is at its limit, `claude` stops for the countdown; anything typed in that split second is lost.
150
+ - **Depleted-stop hiccup.** When the whole pool is at its limit, `claude` stops and names the next reset; anything typed in that split second is lost, and the session continues only when you resume it after that reset.
150
151
  - **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.
151
152
  - **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.
152
153
  - **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.
153
154
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
154
155
  - **The status-only grok pool does not switch.** grok `status` shows the weekly subscription credit window, and no grok session moves.
156
+ - **The status-only Muse Code pool does not switch and shows no usage.** Muse Code exposes no read-only quota endpoint, so every Muse Code account stays unmeasured.
155
157
 
156
158
  ## How it's built
157
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.163.0",
3
+ "version": "1.164.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/auth.ts CHANGED
@@ -13,7 +13,7 @@ import { c, count } from "./render.ts";
13
13
  import type { Account, AccountsIndex } from "../lib/types.ts";
14
14
 
15
15
  const AUTH_USAGE =
16
- "usage: tokenmaxxing auth [--codex | --grok] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok] --emails <email>...\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
16
+ "usage: tokenmaxxing auth [--codex | --grok | --muse] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok | --muse] --emails <email>...\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
17
17
  const EMAILS_FLAG = "--emails";
18
18
 
19
19
  export type AuthPlan = { kind: "usage" } | { kind: "error"; message: string } | { kind: "pick" } | { kind: "targets"; ids: string[] };
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 | --grok] <email|label|id> <new-label>", paint: plain });
19
+ emitError({ message: "usage: tokenmaxxing rename [--codex | --grok | --muse] <email|label|id> <new-label>", paint: plain });
20
20
  return 2;
21
21
  }
22
22
  return withLock(p.pool.lockFile, async () => {
package/src/cli/status.ts CHANGED
@@ -3,9 +3,10 @@ import { creditLeft, keySessions, loadApiKeys } from "../lib/apikeys.ts";
3
3
  import { claude } from "../lib/claude.ts";
4
4
  import { codex } from "../lib/codex.ts";
5
5
  import { grok } from "../lib/grok.ts";
6
+ import { muse } from "../lib/muse.ts";
6
7
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
7
8
  import { withLock } from "../lib/lock.ts";
8
- import { codexPool, grokPool } from "../lib/paths.ts";
9
+ import { codexPool, grokPool, musePool } from "../lib/paths.ts";
9
10
  import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
10
11
  import type { Provider, SampleReport } from "../lib/provider.ts";
11
12
  import { usd } from "./key.ts";
@@ -52,7 +53,7 @@ type StatusApiKey = {
52
53
  sessions: number;
53
54
  };
54
55
 
55
- export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; apiKeys?: StatusApiKey[] };
56
+ export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; muse: PoolReport; apiKeys?: StatusApiKey[] };
56
57
 
57
58
  function apiKeyReport(): StatusApiKey[] {
58
59
  const sessions = keySessions();
@@ -306,9 +307,10 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
306
307
  if (!json) {
307
308
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
308
309
  const grokPooled = loadAccounts(grokPool).accounts.length > 0;
310
+ const musePooled = loadAccounts(musePool).accounts.length > 0;
309
311
  if (claudeReport.accounts.length === 0) {
310
- if (!codexPooled && !grokPooled && apiKeys.length === 0) {
311
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`)"));
312
+ if (!codexPooled && !grokPooled && !musePooled && apiKeys.length === 0) {
313
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--muse`)"));
312
314
  return 0;
313
315
  }
314
316
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
@@ -321,8 +323,9 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
321
323
  }
322
324
  const codexReport = await collect(codex, cfg, now, cached);
323
325
  const grokReport = await collect(grok, cfg, now, cached);
326
+ const museReport = await collect(muse, cfg, now, cached);
324
327
  if (json) {
325
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
328
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, muse: museReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
326
329
  emitJson({ ok: true, ...report });
327
330
  return 0;
328
331
  }
@@ -332,5 +335,8 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
332
335
  if (grokReport.accounts.length > 0) {
333
336
  renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
334
337
  }
338
+ if (museReport.accounts.length > 0) {
339
+ renderPool(muse, museReport, `muse (${count({ n: museReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
340
+ }
335
341
  return 0;
336
342
  }
@@ -14,7 +14,7 @@ import { ensurePiStoreHome, piStoreUsable } from "../lib/piauth.ts";
14
14
  import { thresholdBars, usableAt } from "../lib/picker.ts";
15
15
  import { PI_PRESENCE_PREFIX, clearPresence, livingPresences } from "../lib/presence.ts";
16
16
  import { teeObservation } from "../lib/sample.ts";
17
- import { countdownWait, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS } from "../lib/supervise.ts";
17
+ import { clockFmt, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS } from "../lib/supervise.ts";
18
18
  import { saveTermios } from "../lib/tty.ts";
19
19
  import { loadAccounts, loadConfig, releaseWaitClaim } from "../lib/state.ts";
20
20
  import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
@@ -364,7 +364,6 @@ export async function runPiSupervisor(argv: string[]): Promise<number> {
364
364
 
365
365
  let respawns = 0;
366
366
  let wanted: string | null = null;
367
- let overriddenUntil = 0;
368
367
  try {
369
368
  while (true) {
370
369
  const launchedAt = Date.now();
@@ -400,7 +399,7 @@ export async function runPiSupervisor(argv: string[]): Promise<number> {
400
399
  log("pisupervisor.claim_release_failed", { err: errorMessage(e) });
401
400
  }
402
401
  }
403
- return { proc: spawned, seat: picked, overriddenUntil: Math.max(overriddenUntil, target?.depletedUntil ?? 0) };
402
+ return { proc: spawned, seat: picked, overriddenUntil: target?.depletedUntil ?? 0 };
404
403
  });
405
404
  if (launched == null) {
406
405
  await releaseClaim();
@@ -434,8 +433,13 @@ export async function runPiSupervisor(argv: string[]): Promise<number> {
434
433
  respawns++;
435
434
  const waitUntil = pending.move?.waitUntil ?? 0;
436
435
  if (waitUntil > Date.now()) {
437
- if (await countdownWait(target.label, waitUntil, { stream: false, say })) overriddenUntil = waitUntil;
438
- } else say(`\n\x1b[36m↻ tokenmaxxing: moving pi to ${target.label} - resuming...\x1b[0m\n`);
436
+ log("pisupervisor.depleted_exit", { id, pool, account: target.id.slice(0, 8), waitUntil });
437
+ say(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit, so this session stopped. The next reset is at ${clockFmt.format(waitUntil)} on ${target.label}. Start pi again after that to continue.\x1b[0m\n`);
438
+ clearPresence({ dir: presenceDir, id });
439
+ await releaseClaim();
440
+ return 1;
441
+ }
442
+ say(`\n\x1b[36m↻ tokenmaxxing: moving pi to ${target.label} - resuming...\x1b[0m\n`);
439
443
  wanted = target.id;
440
444
  launchArgs = sessionExists(dir, sid) ? ["--session-id", sid, ...args.flags, "--", RESUME_PROMPT] : firstArgs;
441
445
  continue;
@@ -14,10 +14,10 @@ import { limitWindows, liveUsed, sessionWindow, thresholdBars, usableAt, weeklyW
14
14
  import { clearPresence } from "../lib/presence.ts";
15
15
  import { readLines } from "../lib/proc.ts";
16
16
  import { teeObservation } from "../lib/sample.ts";
17
- import { childStdout, countdownWait, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, relayStdout, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS, type Say } from "../lib/supervise.ts";
17
+ import { childStdout, clockFmt, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, relayStdout, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS, type Say } from "../lib/supervise.ts";
18
18
  import { saveTermios } from "../lib/tty.ts";
19
19
  import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
20
- import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, replaceWaitClaim, writeUsage } from "../lib/state.ts";
20
+ import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
21
21
  import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
22
22
  import { CostLineSchema, CostStateRowSchema, JsonTextSchema, RespawnMarkerSchema, type Account, type ApiKey, type Config, type ModelInfo, type SessionCost, type UsageWindows, type Window } from "../lib/types.ts";
23
23
  import { errorMessage, log } from "../lib/log.ts";
@@ -247,7 +247,6 @@ function latestSessionForCwd(): string | null {
247
247
 
248
248
  type MarkerGate = {
249
249
  launchedAt: number;
250
- overriddenUntil: number;
251
250
  };
252
251
 
253
252
  function releaseClaimLocked(sid: string): void {
@@ -280,9 +279,6 @@ async function consumableMarker(marker: string, gate: MarkerGate): Promise<z.inf
280
279
  if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
281
280
  return discardMarker(marker, "supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
282
281
  }
283
- if (m.waitUntil > Date.now() && m.waitUntil <= gate.overriddenUntil) {
284
- return discardMarker(marker, "supervisor.marker_overridden", { waitUntil: m.waitUntil });
285
- }
286
282
  return m;
287
283
  }
288
284
 
@@ -301,7 +297,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
301
297
  const cfg = loadConfig();
302
298
  const families = gatedFamilies(model, cfg.policy.switchModels);
303
299
  const until = seatBlockedUntil(seat.id, now, families, cfg);
304
- if (until == null || until <= gate.overriddenUntil) return false;
300
+ if (until == null) return false;
305
301
  const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused, apiKeys: true });
306
302
  log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), key: decision.apiKey?.id, waitUntil: decision.waitUntil });
307
303
  const target = moveTarget(decision);
@@ -324,7 +320,7 @@ async function moveKeySession(key: ApiKey, sid: string, gate: MarkerGate, model:
324
320
  const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: null, sessionFamilies: families, waiterId: sid, refused, apiKeys: true, apiKeyId: key.id });
325
321
  log("supervisor.key_watch", { key: key.id, reason: decision.reason, account: decision.account?.id.slice(0, 8), next: decision.apiKey?.id, waitUntil: decision.waitUntil });
326
322
  const target = moveTarget(decision);
327
- if (target && (target.waitUntil === undefined || target.waitUntil > gate.overriddenUntil)) {
323
+ if (target) {
328
324
  writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
329
325
  }
330
326
  } catch (e) {
@@ -362,6 +358,14 @@ function systemLine(sid: string, text: string): string {
362
358
  return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
363
359
  }
364
360
 
361
+ function depletedLines(sid: string, until: number, text: string): string {
362
+ const line = (fields: Record<string, unknown>): string => `${JSON.stringify({ ...fields, uuid: crypto.randomUUID(), session_id: sid })}\n`;
363
+ return (
364
+ line({ type: "rate_limit_event", rate_limit_info: { status: "rejected", resetsAt: Math.ceil(until / 1000) } }) +
365
+ line({ type: "result", subtype: "error_during_execution", is_error: true, errors: [text], terminal_reason: "blocking_limit", num_turns: 0 })
366
+ );
367
+ }
368
+
365
369
  function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; onKey: boolean }): string {
366
370
  const where = input.onKey ? "on an API key, because every pooled account is at its limit or the previous key ran out" : "on an account with quota headroom";
367
371
  const moved = input.compacted
@@ -418,8 +422,12 @@ const RelayLineSchema = z.discriminatedUnion("type", [
418
422
  z.looseObject({ type: z.literal("control_request"), request_id: z.string(), request: z.looseObject({ subtype: z.string() }) }),
419
423
  ]);
420
424
 
421
- function controlErrorLine(requestId: string): string {
422
- return `${JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: requestId, error: "tokenmaxxing restarted Claude Code for an account move, and this request may or may not have run." } })}\n`;
425
+ const UserLineSchema = z.looseObject({ type: z.literal("user") });
426
+
427
+ const MOVED_REQUEST_ERROR = "tokenmaxxing restarted Claude Code for an account move, and this request may or may not have run.";
428
+
429
+ function controlErrorLine(requestId: string, error: string): string {
430
+ return `${JSON.stringify({ type: "control_response", response: { subtype: "error", request_id: requestId, error } })}\n`;
423
431
  }
424
432
 
425
433
  class StdinRelay {
@@ -430,12 +438,18 @@ class StdinRelay {
430
438
  private dropped: string[] = [];
431
439
  private pendingTranscript: string | null = null;
432
440
  private ended = false;
441
+ private refusal: string | null = null;
442
+ private sawUser!: () => void;
443
+ readonly firstUser = new Promise<void>((resolve) => {
444
+ this.sawUser = resolve;
445
+ });
433
446
 
434
447
  constructor() {
435
448
  this.pump()
436
449
  .catch((e: unknown) => log("supervisor.relay_read_failed", { err: errorMessage(e) }))
437
450
  .finally(() => {
438
451
  this.ended = true;
452
+ this.sawUser();
439
453
  this.close();
440
454
  });
441
455
  }
@@ -455,6 +469,13 @@ class StdinRelay {
455
469
  this.sink = null;
456
470
  }
457
471
 
472
+ refuse(error: string): void {
473
+ this.refusal = error;
474
+ const lines = [...this.state.values(), ...this.queue];
475
+ this.queue = [];
476
+ for (const line of lines) this.forward(line);
477
+ }
478
+
458
479
  replay(): string[] {
459
480
  const dropped = this.dropped;
460
481
  this.queue = [...this.replayable, ...this.queue];
@@ -480,8 +501,14 @@ class StdinRelay {
480
501
  }
481
502
 
482
503
  private forward(line: string): void {
483
- const parsed = RelayLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
504
+ const value = JsonTextSchema.safeParse(line).data;
505
+ if (UserLineSchema.safeParse(value).success) this.sawUser();
506
+ const parsed = RelayLineSchema.safeParse(value);
484
507
  const request = parsed.success && parsed.data.type === "control_request" ? parsed.data : null;
508
+ if (this.refusal !== null) {
509
+ if (request !== null) process.stdout.write(controlErrorLine(request.request_id, this.refusal));
510
+ return;
511
+ }
485
512
  const setup = request !== null && REPLAYED_REQUESTS.has(request.request.subtype);
486
513
  if (setup) this.state.set(request.request.subtype, line);
487
514
  if (this.sink !== null) {
@@ -690,7 +717,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
690
717
  };
691
718
  let firstLine: string | null = null;
692
719
  let respawns = 0;
693
- let overriddenUntil = 0;
694
720
  let wanted: { accountId: string | null; apiKeyId: string | null } | null = null;
695
721
  let refused: string[] = [];
696
722
  let drainStdout: ((abandoned: () => boolean) => Promise<void>) | null = null;
@@ -698,13 +724,12 @@ export async function runSupervisor(argv: string[]): Promise<number> {
698
724
  while (true) {
699
725
  rmSync(marker, { force: true });
700
726
 
701
- const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
727
+ const gate: MarkerGate = { launchedAt: Date.now() };
702
728
  const launched = await withLock(claudePool.lockFile, async () => {
703
729
  if (terminating) return null;
704
730
  releaseClaimLocked(sid);
705
731
  const target = resolveLaunch(wanted, gate.launchedAt, model, refused);
706
- if (target?.account && target.depletedUntil !== undefined && target.depletedUntil > gate.overriddenUntil) {
707
- replaceWaitClaim({ sessionId: sid, accountId: target.account.id, at: gate.launchedAt, waitUntil: target.depletedUntil });
732
+ if (target?.account && target.depletedUntil !== undefined && target.depletedUntil > gate.launchedAt) {
708
733
  return { waitOn: target.account, waitUntil: target.depletedUntil };
709
734
  }
710
735
  const picked = target?.account ?? null;
@@ -751,9 +776,16 @@ export async function runSupervisor(argv: string[]): Promise<number> {
751
776
  return 143;
752
777
  }
753
778
  if (launched.waitOn !== undefined) {
754
- log("supervisor.depleted_wait", { sid: sid.slice(0, 8), account: launched.waitOn.id.slice(0, 8), waitUntil: launched.waitUntil });
755
- if (await countdownWait(launched.waitOn.label, launched.waitUntil, { stream, say, holdSid: noticeSid })) overriddenUntil = launched.waitUntil;
756
- continue;
779
+ const text = `tokenmaxxing: session stopped because every pooled account is at its limit. The next reset is at ${clockFmt.format(launched.waitUntil)} on ${launched.waitOn.label}. Resume the session after that.`;
780
+ if (respawns === 0 && relay !== null) {
781
+ relay.refuse(text);
782
+ await relay.firstUser;
783
+ }
784
+ log("supervisor.depleted_exit", { sid: sid.slice(0, 8), account: launched.waitOn.id.slice(0, 8), waitUntil: launched.waitUntil });
785
+ say(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit, so this session stopped. The next reset is at ${clockFmt.format(launched.waitUntil)} on ${launched.waitOn.label}. Run \`claude --resume ${noticeSid}\` after that to continue.\x1b[0m\n`, text);
786
+ if (stream) process.stdout.write(depletedLines(noticeSid, launched.waitUntil, text));
787
+ await releaseClaim();
788
+ return 1;
757
789
  }
758
790
 
759
791
  const { child: proc, seat, key, open } = launched;
@@ -794,7 +826,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
794
826
  const transcript = findTranscript(m.sessionId);
795
827
  const resumable = transcript !== null;
796
828
  if (!resumable && relay !== null) {
797
- for (const id of relay.replay()) process.stdout.write(controlErrorLine(id));
829
+ for (const id of relay.replay()) process.stdout.write(controlErrorLine(id, MOVED_REQUEST_ERROR));
798
830
  }
799
831
  let compacted = false;
800
832
  const compactKey = key != null && usableApiKey(key.id) != null ? key : null;
@@ -0,0 +1,82 @@
1
+ import { z } from "zod";
2
+ import { writeFileAtomic } from "./atomic.ts";
3
+ import { museAuthJsonFor, musePaths, musePool, museStoreDirFor } from "./paths.ts";
4
+ import type { SampleReport } from "./provider.ts";
5
+ import { readJsonFile, type Harvest } from "./state.ts";
6
+ import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
7
+ import { ErrnoSchema, type Account } from "./types.ts";
8
+ import { c } from "../cli/render.ts";
9
+
10
+ const MuseProviderSchema = z.looseObject({
11
+ access_token: z.string().optional(),
12
+ api_key: z.string().optional(),
13
+ user_email: z.string().nullish(),
14
+ });
15
+
16
+ const MuseAuthSchema = z.looseObject({
17
+ providers: z.record(z.string(), z.unknown()),
18
+ });
19
+ type MuseAuth = z.infer<typeof MuseAuthSchema>;
20
+
21
+ function readFile(path: string): MuseAuth | null {
22
+ try {
23
+ return readJsonFile(path, MuseAuthSchema);
24
+ } catch (e) {
25
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
26
+ throw e;
27
+ }
28
+ }
29
+
30
+ function readAuth(path: string): AuthEntry[] {
31
+ const file = readFile(path);
32
+ if (file == null) return [];
33
+ const out: AuthEntry[] = [];
34
+ for (const [key, value] of Object.entries(file.providers)) {
35
+ const parsed = MuseProviderSchema.safeParse(value);
36
+ if (!parsed.success) continue;
37
+ const email = parsed.data.user_email?.trim().toLowerCase() ?? "";
38
+ const id = email === "" ? "" : new Bun.CryptoHasher("sha256").update(email).digest("hex");
39
+ const harvest: Harvest | null =
40
+ id === ""
41
+ ? null
42
+ : {
43
+ id,
44
+ email,
45
+ tier: null,
46
+ sample: null,
47
+ park: async () => {
48
+ writeFileAtomic(museAuthJsonFor(id), JSON.stringify({ ...file, providers: { [key]: value } }, null, 2), 0o600);
49
+ },
50
+ };
51
+ out.push({ id, usable: (parsed.data.access_token ?? "") !== "" || (parsed.data.api_key ?? "") !== "", harvest });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
57
+ return new Map(accounts.map((a) => [a.id, { ok: false, reason: "Muse Code exposes no read-only quota endpoint" }]));
58
+ }
59
+
60
+ export const muse = statusOnlyProvider({
61
+ name: "muse",
62
+ flag: " --muse",
63
+ pool: musePool,
64
+ binName: "muse",
65
+ binKey: "museBin",
66
+ storeDirFor: museStoreDirFor,
67
+ authJsonFor: museAuthJsonFor,
68
+ onboardDir: musePaths.onboardDir,
69
+ homeEnv: "XDG_CONFIG_HOME",
70
+ loginArgs: ["login"],
71
+ onboardAuthRel: "muse/auth.json",
72
+ liveAuthPath: () => musePaths.authJson,
73
+ readAuth,
74
+ liveId: () => null,
75
+ presence: () => new Map(),
76
+ versionOk: (out) => out.toLowerCase().includes("muse code"),
77
+ importIntro: "Opening an isolated Muse Code login for your first pooled account - the login you already have stays as it is.",
78
+ foundLive: (count) => `found ${count} Muse Code login(s) in ${musePaths.authJson} - pooling the first; use \`tokenmaxxing add --muse\` for the rest.`,
79
+ installNotice: "muse pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled Muse Code accounts.",
80
+ loginStep: () => `Approve the code in the browser that ${c.bold("muse login")} opens.`,
81
+ samplePool,
82
+ });
package/src/lib/paths.ts CHANGED
@@ -58,6 +58,11 @@ export const grokPool = {
58
58
  lockFile: join(TM_HOME, "grok-lock"),
59
59
  } as const;
60
60
 
61
+ export const musePool = {
62
+ accountsJson: join(TM_HOME, "muse-accounts.json"),
63
+ lockFile: join(TM_HOME, "muse-lock"),
64
+ } as const;
65
+
61
66
  export type PoolPaths = { accountsJson: string; lockFile: string };
62
67
 
63
68
  const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
@@ -122,6 +127,20 @@ export function grokAuthJsonFor(accountId: string): string {
122
127
  return join(grokStoreDirFor(accountId), "auth.json");
123
128
  }
124
129
 
130
+ export const musePaths = {
131
+ authJson: join(env("XDG_CONFIG_HOME", join(HOME, ".config")), "muse", "auth.json"),
132
+ storesDir: join(TM_HOME, "muse-stores"),
133
+ onboardDir: join(TM_HOME, "muse-onboard"),
134
+ } as const;
135
+
136
+ export function museStoreDirFor(accountId: string): string {
137
+ return join(musePaths.storesDir, shortId(accountId));
138
+ }
139
+
140
+ export function museAuthJsonFor(accountId: string): string {
141
+ return join(museStoreDirFor(accountId), "auth.json");
142
+ }
143
+
125
144
  export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
126
145
  const home = env.GROK_HOME;
127
146
  if (home == null || home === "") return null;
package/src/lib/picker.ts CHANGED
@@ -169,8 +169,7 @@ const MIN_SEAT_HEADROOM = 5;
169
169
  function seatFull(a: Account, ctx: PickCtx): boolean {
170
170
  const session = sessionWindow(a);
171
171
  if (ctx.seats == null || session == null || spendsCredits(a, ctx.thresholds)) return false;
172
- const fresh = barFor(a, session, ctx.thresholds, windowEnd(session)) / ((ctx.seats.get(a.id) ?? 0) + 1);
173
- return seatHeadroom(a, ctx) < MIN_SEAT_HEADROOM && fresh >= MIN_SEAT_HEADROOM;
172
+ return seatHeadroom(a, ctx) < MIN_SEAT_HEADROOM;
174
173
  }
175
174
 
176
175
  export function onCredits(a: Account, ctx: PickCtx): boolean {
@@ -187,15 +186,17 @@ const swapPreference = (ctx: PickCtx) => [
187
186
  ];
188
187
 
189
188
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
190
- const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx) && !seatFull(a, ctx));
191
- return sortBy(usable, swapPreference(ctx))[0] ?? null;
189
+ const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
190
+ const open = usable.filter((a) => !seatFull(a, ctx));
191
+ return sortBy(open.length > 0 ? open : usable, swapPreference(ctx))[0] ?? null;
192
192
  }
193
193
 
194
194
  export function usableAt(a: Account, ctx: PickCtx): number {
195
195
  const blocking = blockingUntil(a, ctx).filter((t) => t > ctx.now);
196
196
  const at = blocking.length ? Math.max(...blocking) : ctx.now;
197
197
  const session = sessionWindow(a);
198
- return session != null && seatFull(a, { ...ctx, now: at }) ? windowEnd(session) : at;
198
+ const end = session != null ? windowEnd(session) : 0;
199
+ return session != null && end > at && seatFull(a, { ...ctx, now: at }) ? end : at;
199
200
  }
200
201
 
201
202
  export type EarliestReset = { account: Account; availableAt: number };
@@ -26,7 +26,7 @@ export class StoreUnusableError extends Error {
26
26
  }
27
27
 
28
28
  export type Provider = {
29
- name: "claude" | "codex" | "grok";
29
+ name: "claude" | "codex" | "grok" | "muse";
30
30
  flag: string;
31
31
  pool: PoolPaths;
32
32
  seats: "shared" | "live";
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, realpathSync, rmSync, statSync, type Dirent }
2
2
  import { basename, join, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { errorMessage, log } from "./log.ts";
5
- import { codexPaths, grokPaths, paths, piPaths } from "./paths.ts";
5
+ import { codexPaths, grokPaths, musePaths, paths, piPaths } from "./paths.ts";
6
6
  import { stagingTarget, writeFileAtomic } from "./atomic.ts";
7
7
  import { presencePid } from "./presence.ts";
8
8
  import { readJsonFile } from "./state.ts";
@@ -44,7 +44,7 @@ function recordLiveSession(sid: string, current: string): void {
44
44
 
45
45
  const TMP_GRACE_MS = 3600 * 1000;
46
46
 
47
- const SWEEP_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir, join(piPaths.storesDir, "claude"), join(piPaths.storesDir, "codex"), paths.costDir];
47
+ const SWEEP_PARENTS = [paths.storesDir, codexPaths.storesDir, grokPaths.storesDir, musePaths.storesDir, join(piPaths.storesDir, "claude"), join(piPaths.storesDir, "codex"), paths.costDir];
48
48
 
49
49
  function listDir(dir: string, root: string): Dirent[] {
50
50
  if (!existsSync(dir)) return [];
package/src/lib/state.ts CHANGED
@@ -34,11 +34,12 @@ export function loadConfig(): Config {
34
34
  cfg.claudeBin = optionalEnv("TOKENMAXXING_CLAUDE_BIN") ?? cfg.claudeBin;
35
35
  cfg.codexBin = optionalEnv("TOKENMAXXING_CODEX_BIN") ?? cfg.codexBin;
36
36
  cfg.grokBin = optionalEnv("TOKENMAXXING_GROK_BIN") ?? cfg.grokBin;
37
+ cfg.museBin = optionalEnv("TOKENMAXXING_MUSE_BIN") ?? cfg.museBin;
37
38
  cfg.piBin = optionalEnv("TOKENMAXXING_PI_BIN") ?? cfg.piBin;
38
39
  return cfg;
39
40
  }
40
41
 
41
- export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "piBin";
42
+ export type BinKey = "claudeBin" | "codexBin" | "grokBin" | "museBin" | "piBin";
42
43
 
43
44
  export function pinBinOverride(input: { key: BinKey; bin: string }): void {
44
45
  const raw: Record<string, unknown> = existsSync(paths.configJson) ? readJsonFile(paths.configJson, z.record(z.string(), z.unknown())) : {};
@@ -14,7 +14,7 @@ export type StatusOnlySpec = {
14
14
  flag: string;
15
15
  pool: PoolPaths;
16
16
  binName: string;
17
- binKey: "grokBin";
17
+ binKey: "grokBin" | "museBin";
18
18
  storeDirFor: (accountId: string) => string;
19
19
  authJsonFor: (accountId: string) => string;
20
20
  onboardDir: string;
@@ -15,42 +15,7 @@ export const SEAT_RETRY_MS = 60_000;
15
15
 
16
16
  export type Say = (terminal: string, text: string) => void;
17
17
 
18
- const clockFmt = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" });
19
-
20
- function waitHoldLines(sid: string, until: number): { start: string; stop: string } {
21
- const line = (fields: Record<string, unknown>): string =>
22
- `${JSON.stringify({ type: "system", task_id: `tokenmaxxing-wait-${sid}`, uuid: crypto.randomUUID(), session_id: sid, ...fields })}\n`;
23
- return {
24
- start: line({ subtype: "task_started", task_type: "local_bash", description: `Waiting until ${clockFmt.format(until)} for a Claude account to reset` }),
25
- stop: line({ subtype: "task_notification", status: "completed", output_file: "", summary: "The wait for a Claude account to reset ended" }),
26
- };
27
- }
28
-
29
- export async function countdownWait(acct: string, until: number, out: { stream: boolean; say: Say; holdSid?: string }): Promise<boolean> {
30
- let aborted = false;
31
- const onInt = () => { aborted = true; };
32
- process.on("SIGINT", onInt);
33
- const minutes = Math.max(1, Math.ceil((until - Date.now()) / 60000));
34
- out.say(
35
- `\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`,
36
- `tokenmaxxing: all accounts at their limit. Resuming at ${clockFmt.format(until)} (in ${minutes} min) on ${acct}.`,
37
- );
38
- const hold = out.stream && out.holdSid !== undefined ? waitHoldLines(out.holdSid, until) : null;
39
- if (hold) process.stdout.write(hold.start);
40
- while (!aborted && Date.now() < until) {
41
- if (!out.stream) {
42
- const left = until - Date.now();
43
- const m = Math.floor(left / 60000);
44
- const s = Math.floor((left % 60000) / 1000);
45
- process.stderr.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
46
- }
47
- await Bun.sleep(1000);
48
- }
49
- process.removeListener("SIGINT", onInt);
50
- if (hold) process.stdout.write(hold.stop);
51
- out.say(`\n\x1b[36m↻ resuming on ${acct}...\x1b[0m\n`, `tokenmaxxing: resuming on ${acct}.`);
52
- return aborted;
53
- }
18
+ export const clockFmt = new Intl.DateTimeFormat("en-US", { hour: "2-digit", minute: "2-digit", hourCycle: "h23", timeZoneName: "short" });
54
19
 
55
20
  type ChildHandle = {
56
21
  pid: number;
package/src/lib/types.ts CHANGED
@@ -116,6 +116,7 @@ export const ConfigSchema = z
116
116
  claudeBin: z.string().default(""),
117
117
  codexBin: z.string().default(""),
118
118
  grokBin: z.string().default(""),
119
+ museBin: z.string().default(""),
119
120
  piBin: z.string().default(""),
120
121
  policy: z
121
122
  .object({
package/src/main.ts CHANGED
@@ -13,6 +13,7 @@ import { runPiSupervisor } from "./entries/pisupervisor.ts";
13
13
  import { claude } from "./lib/claude.ts";
14
14
  import { codex } from "./lib/codex.ts";
15
15
  import { grok } from "./lib/grok.ts";
16
+ import { muse } from "./lib/muse.ts";
16
17
  import { cmdInit, cmdInitPi } from "./cli/init.ts";
17
18
  import { cmdAdd } from "./cli/add.ts";
18
19
  import { cmdAuth } from "./cli/auth.ts";
@@ -36,6 +37,7 @@ const CACHED_FLAG = "--cached";
36
37
  const YES_FLAG = "--yes";
37
38
  const CODEX_FLAG = "--codex";
38
39
  const GROK_FLAG = "--grok";
40
+ const MUSE_FLAG = "--muse";
39
41
  const PI_FLAG = "--pi";
40
42
  const JSON_COMMANDS = new Set(["status", "config", "check"]);
41
43
  const CODEX_COMMANDS = new Set(["init", "add", "auth", "emails", "rm", "rename", "seat"]);
@@ -51,17 +53,18 @@ function printHelp(): void {
51
53
  ${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
52
54
  ${c.cyan("tokenmaxxing init --pi")} install the pi supervisor: pi sessions run on pooled Claude or ChatGPT accounts that are logged into pi
53
55
  ${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
56
+ ${c.cyan("tokenmaxxing init --muse")} pool Muse Code logins (status-only: no supervisor and no usage read)
54
57
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
55
- ${c.cyan("tokenmaxxing add")} --codex | --grok same for the codex or grok pool
56
- ${c.cyan("tokenmaxxing auth")} [--codex | --grok] [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)
57
- ${c.cyan("tokenmaxxing auth")} [--codex | --grok] --emails <email>... log in and pool each listed email, skipping the ones already pooled (for moving to a new host: paste the output of ${c.cyan("emails")} from the old one)
58
- ${c.cyan("tokenmaxxing emails")} [--codex | --grok] print every pooled account's email, one per line, to copy into ${c.cyan("auth --emails")} on another host
58
+ ${c.cyan("tokenmaxxing add")} --codex | --grok | --muse same for the codex, grok, or muse pool
59
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse] [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)
60
+ ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --muse] --emails <email>... log in and pool each listed email, skipping the ones already pooled (for moving to a new host: paste the output of ${c.cyan("emails")} from the old one)
61
+ ${c.cyan("tokenmaxxing emails")} [--codex | --grok | --muse] print every pooled account's email, one per line, to copy into ${c.cyan("auth --emails")} on another host
59
62
  ${c.cyan("tokenmaxxing auth --pi")} [--codex] [sel | --all] log a pooled Claude account (or a codex account with --codex) into pi, isolated (bare = pick from a list; --all = every account without a pi login)
60
63
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
61
64
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
62
65
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
63
- ${c.cyan("tokenmaxxing rename")} [--codex | --grok] <sel> <label>
64
- ${c.cyan("tokenmaxxing rm")} [--codex | --grok] <sel>
66
+ ${c.cyan("tokenmaxxing rename")} [--codex | --grok | --muse] <sel> <label>
67
+ ${c.cyan("tokenmaxxing rm")} [--codex | --grok | --muse] <sel>
65
68
  ${c.cyan("tokenmaxxing key add")} <label> [credit-usd] [workspace-id] store an Anthropic or OpenRouter (sk-or-...) API key read from stdin or the prompt; Claude sessions run on it only while every pooled Claude account is at its limit (workspace-id: for a key that spans several workspaces; an OpenRouter key takes neither argument and reads its credit from OpenRouter)
66
69
  ${c.cyan("tokenmaxxing key credit")} <label> <usd> set the key's balance as the Console shows it and restart its spend count (an OpenRouter key takes no amount: key credit <label> clears its refusal)
67
70
  ${c.cyan("tokenmaxxing key rm")} <label> delete the key and its stored secret (refused while a session runs on it)
@@ -101,14 +104,14 @@ async function main(): Promise<number> {
101
104
  const json = jsonMode;
102
105
  const cached = argv.includes(CACHED_FLAG);
103
106
  const yes = argv.includes(YES_FLAG);
104
- const providerFlags = [CODEX_FLAG, GROK_FLAG].filter((f) => argv.includes(f));
107
+ const providerFlags = [CODEX_FLAG, GROK_FLAG, MUSE_FLAG].filter((f) => argv.includes(f));
105
108
  if (providerFlags.length > 1) {
106
109
  emitError({ json, message: `${providerFlags.join(" and ")} are mutually exclusive - pick one pool` });
107
110
  return 2;
108
111
  }
109
- const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : claude;
112
+ const provider = argv.includes(CODEX_FLAG) ? codex : argv.includes(GROK_FLAG) ? grok : argv.includes(MUSE_FLAG) ? muse : claude;
110
113
  const pi = argv.includes(PI_FLAG);
111
- const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== PI_FLAG);
114
+ const args = argv.filter((a) => a !== JSON_FLAG && a !== CACHED_FLAG && a !== YES_FLAG && a !== CODEX_FLAG && a !== GROK_FLAG && a !== MUSE_FLAG && a !== PI_FLAG);
112
115
  const sub = args[0];
113
116
 
114
117
  if (pi && sub !== "init" && sub !== "auth") {
@@ -136,8 +139,8 @@ async function main(): Promise<number> {
136
139
  return 2;
137
140
  }
138
141
 
139
- if (provider === grok && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
140
- emitError({ json, message: `${GROK_FLAG} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
142
+ if (provider.statusOnly && (sub == null || !STATUS_ONLY_COMMANDS.has(sub))) {
143
+ emitError({ json, message: `${provider.flag.trim()} applies to ${[...STATUS_ONLY_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
141
144
  return 2;
142
145
  }
143
146
 
@@ -208,7 +211,7 @@ async function main(): Promise<number> {
208
211
  if (out.timer === "still-loaded") console.log(c.yellow(`⚠ the check job may still be loaded - run: ${deactivationHint(CHECK_JOB)}`));
209
212
  if (out.hub === "still-loaded") console.log(c.yellow(`⚠ the usage hub job may still be loaded - run: ${deactivationHint(HUB_JOB)}`));
210
213
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
211
- console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
214
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/; pi: pi-stores/; grok: grok-stores/; muse: muse-stores/), plus api-keys.json and the API keys in api-keys/ - remove accounts with \`xx rm\` and keys with \`xx key rm\` to delete their credentials`);
212
215
  return 0;
213
216
  }
214
217
  case "help":