tokenmaxxing 1.162.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 +8 -8
- package/README.md +8 -6
- package/package.json +1 -1
- package/src/cli/auth.ts +1 -1
- package/src/cli/rename.ts +1 -1
- package/src/cli/status.ts +11 -5
- package/src/entries/pisupervisor.ts +9 -5
- package/src/entries/supervisor.ts +163 -22
- package/src/lib/muse.ts +82 -0
- package/src/lib/paths.ts +19 -0
- package/src/lib/picker.ts +6 -5
- package/src/lib/provider.ts +1 -1
- package/src/lib/sessions.ts +2 -2
- package/src/lib/state.ts +2 -1
- package/src/lib/statusonly.ts +1 -1
- package/src/lib/supervise.ts +1 -36
- package/src/lib/types.ts +1 -0
- package/src/main.ts +15 -12
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
|
|
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
|
|
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,12 +61,12 @@ 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
|
|
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,
|
|
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.
|
|
69
|
-
2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (still supervised, on whatever login the environment names). Print mode, every subcommand, the bare resume picker, an invalid `--session-id`, `--fork-session` with a resume, and `--no-session-persistence` (no transcript, so a move could not resume the run) are passed through unmanaged.
|
|
69
|
+
2. Rank the usable accounts (§5) with the live session counts from `live/`, or the soonest-recovering account when none is usable; with no pooled account or every account needing re-auth, launch without a store (still supervised, on whatever login the environment names). Print mode, every subcommand, the bare resume picker, an invalid `--session-id`, `--fork-session` with a resume, and `--no-session-persistence` (no transcript, so a move could not resume the run) are passed through unmanaged. One exception: an invocation that carries T3 Code's capability probe shape (the stream-json flag set with `--strict-mcp-config`, `--setting-sources=user,project,local`, `--settings {"disableAllHooks":true}`, `--no-session-persistence`, and the probe environment `ENABLE_CLAUDEAI_MCP_SERVERS=false`, `CLAUDE_CODE_AUTO_CONNECT_IDE=0`, and `CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL=1`) is placed on the account placement picks, or the soonest-recovering account when none is usable, and its `get_usage` control request is answered from that account's sampled windows instead of reaching the child, so the probe sends no usage read. It writes no presence file and no session flags, and no other `--no-session-persistence` run is seated.
|
|
70
70
|
3. Spawn claude with the store env set, then write `live/<sid>` with the child's pid and start time. The lock covers the pick and the presence write, so concurrent launches see each other.
|
|
71
71
|
|
|
72
72
|
### 3.5 Multiple concurrent sessions
|
|
@@ -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,
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
71
|
-
| `tokenmaxxing
|
|
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-
|
|
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.
|
|
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 {
|
|
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:
|
|
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
|
-
|
|
438
|
-
|
|
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;
|
|
@@ -6,20 +6,20 @@ import { z } from "zod";
|
|
|
6
6
|
import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
|
|
7
7
|
import { CLAUDE_BIN, UNMANAGED_ENV, WRAP_DEPTH_ENV, resolveRealBin, wrapDepth } from "../lib/claudebin.ts";
|
|
8
8
|
import { apiKeyEnv, clearSessionCost, foldCompactionCost, foldSessionCost, loadApiKeys, readApiKey, sendApiKey, usableApiKey, writeSessionCost } from "../lib/apikeys.ts";
|
|
9
|
-
import { claude, pickLaunch, type LaunchTarget } from "../lib/claude.ts";
|
|
9
|
+
import { claude, pickLaunch, pickSeat, type LaunchTarget } from "../lib/claude.ts";
|
|
10
10
|
import { compactClaudeSession } from "../lib/compact.ts";
|
|
11
11
|
import { evaluateAndMaybeSwap, moveTarget } from "../lib/decide.ts";
|
|
12
12
|
import { withLock } from "../lib/lock.ts";
|
|
13
|
-
import { thresholdBars, usableAt } from "../lib/picker.ts";
|
|
13
|
+
import { limitWindows, liveUsed, sessionWindow, thresholdBars, usableAt, weeklyWindow } from "../lib/picker.ts";
|
|
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,
|
|
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,
|
|
20
|
+
import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
|
|
21
21
|
import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
|
|
22
|
-
import { CostLineSchema, CostStateRowSchema, JsonTextSchema, RespawnMarkerSchema, type Account, type ApiKey, type Config, type ModelInfo, type SessionCost, type UsageWindows } from "../lib/types.ts";
|
|
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";
|
|
24
24
|
|
|
25
25
|
const SUBCOMMANDS = new Set([
|
|
@@ -128,6 +128,37 @@ export function analyzeArgs(argv: string[]): Analysis {
|
|
|
128
128
|
return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
const PROBE_VALUE_FLAGS = new Set(["--output-format", "--input-format", "--setting-sources", "--settings", "--permission-mode", "--debug-file"]);
|
|
132
|
+
const PROBE_BOOLEAN_FLAGS = new Set(["--verbose", "--strict-mcp-config", "--no-session-persistence"]);
|
|
133
|
+
const ProbeSettingsSchema = z.strictObject({ disableAllHooks: z.literal(true) });
|
|
134
|
+
|
|
135
|
+
export function isCapabilitiesProbe(argv: string[], envLike: Record<string, string | undefined>): boolean {
|
|
136
|
+
if (envLike.ENABLE_CLAUDEAI_MCP_SERVERS !== "false" || envLike.CLAUDE_CODE_AUTO_CONNECT_IDE !== "0" || envLike.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL !== "1") return false;
|
|
137
|
+
const values = new Map<string, string>();
|
|
138
|
+
const booleans = new Set<string>();
|
|
139
|
+
for (let i = 0; i < argv.length; i++) {
|
|
140
|
+
const a = argv[i]!;
|
|
141
|
+
const eq = a.indexOf("=");
|
|
142
|
+
const flag = eq > 0 ? a.slice(0, eq) : a;
|
|
143
|
+
if (PROBE_VALUE_FLAGS.has(flag)) {
|
|
144
|
+
if (values.has(flag)) return false;
|
|
145
|
+
const value = eq > 0 ? a.slice(eq + 1) : argv[++i];
|
|
146
|
+
if (value === undefined) return false;
|
|
147
|
+
values.set(flag, value);
|
|
148
|
+
} else if (eq === -1 && PROBE_BOOLEAN_FLAGS.has(a)) {
|
|
149
|
+
if (booleans.has(a)) return false;
|
|
150
|
+
booleans.add(a);
|
|
151
|
+
} else return false;
|
|
152
|
+
}
|
|
153
|
+
if (!booleans.has("--verbose") || !booleans.has("--strict-mcp-config") || !booleans.has("--no-session-persistence")) return false;
|
|
154
|
+
if (values.get("--output-format") !== "stream-json" || values.get("--input-format") !== "stream-json") return false;
|
|
155
|
+
if (values.get("--setting-sources") !== "user,project,local") return false;
|
|
156
|
+
const permissionMode = values.get("--permission-mode");
|
|
157
|
+
if (permissionMode !== undefined && permissionMode !== "default") return false;
|
|
158
|
+
const settings = values.get("--settings");
|
|
159
|
+
return settings !== undefined && ProbeSettingsSchema.safeParse(JsonTextSchema.safeParse(settings).data).success;
|
|
160
|
+
}
|
|
161
|
+
|
|
131
162
|
export function stripSessionFlags(argv: string[]): string[] {
|
|
132
163
|
const out: string[] = [];
|
|
133
164
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -216,7 +247,6 @@ function latestSessionForCwd(): string | null {
|
|
|
216
247
|
|
|
217
248
|
type MarkerGate = {
|
|
218
249
|
launchedAt: number;
|
|
219
|
-
overriddenUntil: number;
|
|
220
250
|
};
|
|
221
251
|
|
|
222
252
|
function releaseClaimLocked(sid: string): void {
|
|
@@ -249,9 +279,6 @@ async function consumableMarker(marker: string, gate: MarkerGate): Promise<z.inf
|
|
|
249
279
|
if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
|
|
250
280
|
return discardMarker(marker, "supervisor.marker_stale", { markerLaunch: m.launchedAt, childLaunch: gate.launchedAt });
|
|
251
281
|
}
|
|
252
|
-
if (m.waitUntil > Date.now() && m.waitUntil <= gate.overriddenUntil) {
|
|
253
|
-
return discardMarker(marker, "supervisor.marker_overridden", { waitUntil: m.waitUntil });
|
|
254
|
-
}
|
|
255
282
|
return m;
|
|
256
283
|
}
|
|
257
284
|
|
|
@@ -270,7 +297,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
|
|
|
270
297
|
const cfg = loadConfig();
|
|
271
298
|
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
272
299
|
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
273
|
-
if (until == null
|
|
300
|
+
if (until == null) return false;
|
|
274
301
|
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid, refused, apiKeys: true });
|
|
275
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 });
|
|
276
303
|
const target = moveTarget(decision);
|
|
@@ -293,7 +320,7 @@ async function moveKeySession(key: ApiKey, sid: string, gate: MarkerGate, model:
|
|
|
293
320
|
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: null, sessionFamilies: families, waiterId: sid, refused, apiKeys: true, apiKeyId: key.id });
|
|
294
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 });
|
|
295
322
|
const target = moveTarget(decision);
|
|
296
|
-
if (target
|
|
323
|
+
if (target) {
|
|
297
324
|
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, ...target, waitUntil: target.waitUntil ?? now, compact: true, origin: "seatwatch" });
|
|
298
325
|
}
|
|
299
326
|
} catch (e) {
|
|
@@ -331,6 +358,14 @@ function systemLine(sid: string, text: string): string {
|
|
|
331
358
|
return `${JSON.stringify({ type: "system", subtype: "informational", content: text, level: "warning", uuid: crypto.randomUUID(), session_id: sid })}\n`;
|
|
332
359
|
}
|
|
333
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
|
+
|
|
334
369
|
function resumePrompt(input: { compacted: boolean; origin: z.infer<typeof RespawnMarkerSchema>["origin"]; onKey: boolean }): string {
|
|
335
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";
|
|
336
371
|
const moved = input.compacted
|
|
@@ -387,8 +422,12 @@ const RelayLineSchema = z.discriminatedUnion("type", [
|
|
|
387
422
|
z.looseObject({ type: z.literal("control_request"), request_id: z.string(), request: z.looseObject({ subtype: z.string() }) }),
|
|
388
423
|
]);
|
|
389
424
|
|
|
390
|
-
|
|
391
|
-
|
|
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`;
|
|
392
431
|
}
|
|
393
432
|
|
|
394
433
|
class StdinRelay {
|
|
@@ -399,12 +438,18 @@ class StdinRelay {
|
|
|
399
438
|
private dropped: string[] = [];
|
|
400
439
|
private pendingTranscript: string | null = null;
|
|
401
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
|
+
});
|
|
402
446
|
|
|
403
447
|
constructor() {
|
|
404
448
|
this.pump()
|
|
405
449
|
.catch((e: unknown) => log("supervisor.relay_read_failed", { err: errorMessage(e) }))
|
|
406
450
|
.finally(() => {
|
|
407
451
|
this.ended = true;
|
|
452
|
+
this.sawUser();
|
|
408
453
|
this.close();
|
|
409
454
|
});
|
|
410
455
|
}
|
|
@@ -424,6 +469,13 @@ class StdinRelay {
|
|
|
424
469
|
this.sink = null;
|
|
425
470
|
}
|
|
426
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
|
+
|
|
427
479
|
replay(): string[] {
|
|
428
480
|
const dropped = this.dropped;
|
|
429
481
|
this.queue = [...this.replayable, ...this.queue];
|
|
@@ -449,8 +501,14 @@ class StdinRelay {
|
|
|
449
501
|
}
|
|
450
502
|
|
|
451
503
|
private forward(line: string): void {
|
|
452
|
-
const
|
|
504
|
+
const value = JsonTextSchema.safeParse(line).data;
|
|
505
|
+
if (UserLineSchema.safeParse(value).success) this.sawUser();
|
|
506
|
+
const parsed = RelayLineSchema.safeParse(value);
|
|
453
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
|
+
}
|
|
454
512
|
const setup = request !== null && REPLAYED_REQUESTS.has(request.request.subtype);
|
|
455
513
|
if (setup) this.state.set(request.request.subtype, line);
|
|
456
514
|
if (this.sink !== null) {
|
|
@@ -478,6 +536,74 @@ class StdinRelay {
|
|
|
478
536
|
}
|
|
479
537
|
}
|
|
480
538
|
|
|
539
|
+
const ProbeCancelLineSchema = z.looseObject({ type: z.literal("control_cancel_request") });
|
|
540
|
+
|
|
541
|
+
function probeUsageLine(requestId: string, account: Account): string {
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
const observed = teeObservation(account);
|
|
544
|
+
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
545
|
+
const win = (w: Window | undefined) => (w == null ? null : { utilization: liveUsed(w, now), resets_at: w.resetsAt != null && w.resetsAt > now ? new Date(w.resetsAt).toISOString() : null });
|
|
546
|
+
const fiveHour = win(sessionWindow(current));
|
|
547
|
+
const sevenDay = win(weeklyWindow(current));
|
|
548
|
+
const scoped = limitWindows(current).map((w) => ({ display_name: w.name, utilization: liveUsed(w, now), resets_at: w.resetsAt != null && w.resetsAt > now ? new Date(w.resetsAt).toISOString() : null }));
|
|
549
|
+
const available = fiveHour != null || sevenDay != null;
|
|
550
|
+
return `${JSON.stringify({
|
|
551
|
+
type: "control_response",
|
|
552
|
+
response: {
|
|
553
|
+
subtype: "success",
|
|
554
|
+
request_id: requestId,
|
|
555
|
+
response: {
|
|
556
|
+
session: { total_cost_usd: 0, total_api_duration_ms: 0, total_duration_ms: 0, total_lines_added: 0, total_lines_removed: 0, model_usage: {} },
|
|
557
|
+
subscription_type: null,
|
|
558
|
+
rate_limits_available: available,
|
|
559
|
+
rate_limits: available ? { five_hour: fiveHour, seven_day: sevenDay, ...(scoped.length > 0 ? { model_scoped: scoped } : {}) } : null,
|
|
560
|
+
behaviors: null,
|
|
561
|
+
},
|
|
562
|
+
},
|
|
563
|
+
})}\n`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function runCapabilitiesProbe(input: { real: string; argv: string[]; env: Record<string, string | undefined>; seat: Account; onSpawn: (child: Subprocess) => void }): Promise<number> {
|
|
567
|
+
const proc = Bun.spawn([input.real, ...input.argv], { stdin: "pipe", stdout: "pipe", stderr: "inherit", env: input.env });
|
|
568
|
+
input.onSpawn(proc);
|
|
569
|
+
log("supervisor.probe_launch", { seat: input.seat.id.slice(0, 8) });
|
|
570
|
+
const stdoutPump = (async () => {
|
|
571
|
+
for await (const line of readLines(proc.stdout as ReadableStream<Uint8Array>)) process.stdout.write(`${line}\n`);
|
|
572
|
+
})().catch((e: unknown) => log("supervisor.probe_stdout_failed", { err: errorMessage(e) }));
|
|
573
|
+
void (async () => {
|
|
574
|
+
for await (const line of readLines(Bun.stdin.stream())) {
|
|
575
|
+
const parsed = RelayLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
|
|
576
|
+
const request = parsed.success && parsed.data.type === "control_request" ? parsed.data : null;
|
|
577
|
+
if (request?.request.subtype === "get_usage") {
|
|
578
|
+
process.stdout.write(probeUsageLine(request.request_id, input.seat));
|
|
579
|
+
log("supervisor.probe_usage", { seat: input.seat.id.slice(0, 8) });
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
const forwarded = (parsed.success && parsed.data.type === "control_response") || request?.request.subtype === "initialize" || ProbeCancelLineSchema.safeParse(JsonTextSchema.safeParse(line).data).success;
|
|
583
|
+
if (!forwarded) {
|
|
584
|
+
log("supervisor.probe_refused", { seat: input.seat.id.slice(0, 8), kind: parsed.success ? parsed.data.type : "unparsed" });
|
|
585
|
+
proc.kill("SIGKILL");
|
|
586
|
+
break;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
proc.stdin.write(`${line}\n`);
|
|
590
|
+
proc.stdin.flush();
|
|
591
|
+
} catch (e) {
|
|
592
|
+
log("supervisor.probe_stdin_failed", { err: errorMessage(e) });
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
proc.stdin.end();
|
|
598
|
+
} catch (e) {
|
|
599
|
+
log("supervisor.probe_stdin_failed", { err: errorMessage(e) });
|
|
600
|
+
}
|
|
601
|
+
})().catch((e: unknown) => log("supervisor.probe_stdin_failed", { err: errorMessage(e) }));
|
|
602
|
+
await proc.exited;
|
|
603
|
+
await Promise.race([stdoutPump, Bun.sleep(1000)]);
|
|
604
|
+
return exitStatus(proc);
|
|
605
|
+
}
|
|
606
|
+
|
|
481
607
|
function bareResumeStream(info: Analysis, argv: string[], sid: string | null): boolean {
|
|
482
608
|
if (!info.manage || process.env[UNMANAGED_ENV] || info.sessionId != null || stripSessionFlags(argv).length !== 0 || sid == null) return false;
|
|
483
609
|
const persisted = loadSessionFlags(sid);
|
|
@@ -532,6 +658,16 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
532
658
|
|
|
533
659
|
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
534
660
|
const passthroughEnv = omit(childEnv, ["TOKENMAXXING_SUPERVISED", "TOKENMAXXING_SESSION_ID", "TOKENMAXXING_MODEL", "TOKENMAXXING_REFUSED", "TOKENMAXXING_API_KEY_ID"]);
|
|
661
|
+
if (!process.env[UNMANAGED_ENV] && !process.env.TOKENMAXXING_PROBE && isCapabilitiesProbe(argv, process.env)) {
|
|
662
|
+
const seat = await withLock(claudePool.lockFile, () => pickSeat(Date.now(), null)?.account ?? null).catch((e: unknown) => {
|
|
663
|
+
log("supervisor.probe_pick_failed", { err: errorMessage(e) });
|
|
664
|
+
return null;
|
|
665
|
+
});
|
|
666
|
+
if (seat != null) {
|
|
667
|
+
const probeEnv = { ...scrubCredEnv(passthroughEnv), CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) };
|
|
668
|
+
return runCapabilitiesProbe({ real, argv, env: probeEnv, seat, onSpawn: (p) => { child = p; } });
|
|
669
|
+
}
|
|
670
|
+
}
|
|
535
671
|
return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
|
|
536
672
|
}
|
|
537
673
|
|
|
@@ -581,7 +717,6 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
581
717
|
};
|
|
582
718
|
let firstLine: string | null = null;
|
|
583
719
|
let respawns = 0;
|
|
584
|
-
let overriddenUntil = 0;
|
|
585
720
|
let wanted: { accountId: string | null; apiKeyId: string | null } | null = null;
|
|
586
721
|
let refused: string[] = [];
|
|
587
722
|
let drainStdout: ((abandoned: () => boolean) => Promise<void>) | null = null;
|
|
@@ -589,13 +724,12 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
589
724
|
while (true) {
|
|
590
725
|
rmSync(marker, { force: true });
|
|
591
726
|
|
|
592
|
-
const gate: MarkerGate = { launchedAt: Date.now()
|
|
727
|
+
const gate: MarkerGate = { launchedAt: Date.now() };
|
|
593
728
|
const launched = await withLock(claudePool.lockFile, async () => {
|
|
594
729
|
if (terminating) return null;
|
|
595
730
|
releaseClaimLocked(sid);
|
|
596
731
|
const target = resolveLaunch(wanted, gate.launchedAt, model, refused);
|
|
597
|
-
if (target?.account && target.depletedUntil !== undefined && target.depletedUntil > gate.
|
|
598
|
-
replaceWaitClaim({ sessionId: sid, accountId: target.account.id, at: gate.launchedAt, waitUntil: target.depletedUntil });
|
|
732
|
+
if (target?.account && target.depletedUntil !== undefined && target.depletedUntil > gate.launchedAt) {
|
|
599
733
|
return { waitOn: target.account, waitUntil: target.depletedUntil };
|
|
600
734
|
}
|
|
601
735
|
const picked = target?.account ?? null;
|
|
@@ -642,9 +776,16 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
642
776
|
return 143;
|
|
643
777
|
}
|
|
644
778
|
if (launched.waitOn !== undefined) {
|
|
645
|
-
|
|
646
|
-
if (
|
|
647
|
-
|
|
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;
|
|
648
789
|
}
|
|
649
790
|
|
|
650
791
|
const { child: proc, seat, key, open } = launched;
|
|
@@ -685,7 +826,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
685
826
|
const transcript = findTranscript(m.sessionId);
|
|
686
827
|
const resumable = transcript !== null;
|
|
687
828
|
if (!resumable && relay !== null) {
|
|
688
|
-
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));
|
|
689
830
|
}
|
|
690
831
|
let compacted = false;
|
|
691
832
|
const compactKey = key != null && usableApiKey(key.id) != null ? key : null;
|
package/src/lib/muse.ts
ADDED
|
@@ -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
|
-
|
|
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)
|
|
191
|
-
|
|
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
|
-
|
|
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 };
|
package/src/lib/provider.ts
CHANGED
package/src/lib/sessions.ts
CHANGED
|
@@ -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())) : {};
|
package/src/lib/statusonly.ts
CHANGED
|
@@ -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;
|
package/src/lib/supervise.ts
CHANGED
|
@@ -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
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
|
|
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
|
|
140
|
-
emitError({ json, message: `${
|
|
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":
|