tokenmaxxing 1.173.0 → 1.175.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with room for it whose weekly quota is closest to expiring unused, 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 and Muse Code as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
5
+ > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI and Muse Code as further switching pools; grok and Cursor as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
@@ -40,15 +40,16 @@ claude # use claude as always; each session gets its own accoun
40
40
  | `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
41
41
  | `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 |
42
42
  | `tokenmaxxing init --grok` | pool the first grok login, status-only: no supervisor, no switching |
43
- | `tokenmaxxing init --muse` | pool the first Muse Code login, status-only: no supervisor, no switching |
44
- | `tokenmaxxing add [--codex \| --grok \| --muse]` | register an additional account in that pool (isolated login, harvested once into its own store) |
45
- | `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 |
43
+ | `tokenmaxxing init --cursor` | pool the Cursor CLI login, status-only: no supervisor, no switching |
44
+ | `tokenmaxxing init --muse` | same for Muse Code: log in the first account (isolated), install the `muse` supervisor, which starts each session on the freshest account and resumes it on another near quota |
45
+ | `tokenmaxxing add [--codex \| --grok \| --muse \| --cursor]` | register an additional account in that pool (isolated login, harvested once into its own store) |
46
+ | `tokenmaxxing auth [--codex \| --grok \| --muse \| --cursor] [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 |
46
47
  | `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 |
47
48
  | `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 |
48
49
  | `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 |
49
50
  | `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 |
50
51
  | `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` |
51
- | `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 |
52
+ | `tokenmaxxing rename [--codex \| --grok \| --muse \| --cursor] <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 |
52
53
  | `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) |
53
54
  | `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 |
54
55
  | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
@@ -126,7 +127,8 @@ pi keeps its own credential and refreshes it itself, so each pooled account gets
126
127
  - **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.
127
128
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
128
129
  - **The status-only grok pool does not switch.** grok `status` shows the weekly subscription credit window, and no grok session moves.
129
- - **The status-only Muse Code pool does not switch, and shows no usage before the first model request of a window.** Its usage read is the key mint, which reports no usage until a model request opens the 5-hour window.
130
+ - **The status-only Cursor pool does not switch, and reads only a file login.** Cursor `status` shows the included usage of the monthly billing cycle. The Cursor CLI keeps its login in the keychain on macOS, which tokenmaxxing does not read, so the pool works where the login is `auth.json` (Linux).
131
+ - **A Muse Code move stops the running turn.** `muse resume` takes no prompt, so a turn that was running when the move landed has to be sent again; `muse exec` and `muse serve` keep the account they started on.
130
132
 
131
133
  ## How it's built
132
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.173.0",
3
+ "version": "1.175.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 | --muse] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok | --muse] --emails <email>...\n tokenmaxxing auth --pi [--codex] [<email|label|id> | --all]";
16
+ "usage: tokenmaxxing auth [--codex | --grok | --muse | --cursor] [<email|label|id> | --all]\n tokenmaxxing auth [--codex | --grok | --muse | --cursor] --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/doctor.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { CLAUDE_BIN, PI_BIN, verifyRealBin } from "../lib/claudebin.ts";
2
+ import { CLAUDE_BIN, MUSE_BIN, PI_BIN, verifyRealBin } from "../lib/claudebin.ts";
3
3
  import { checkSettings, installedBin } from "../lib/settings.ts";
4
- import { activationHint, CHECK_JOB, codexStoreHookTrust, findClaudeShadowers, HUB_JOB, isBinDirAhead, jobHealthy, piSupervisorLink, shellRcPath } from "../lib/install.ts";
4
+ import { activationHint, CHECK_JOB, codexStoreHookTrust, findClaudeShadowers, HUB_JOB, isBinDirAhead, jobHealthy, museSupervisorLink, piSupervisorLink, shellRcPath } from "../lib/install.ts";
5
+ import { museStoreIdentity } from "../lib/muse.ts";
5
6
  import { readPiStore } from "../lib/piauth.ts";
6
7
  import { errorMessage } from "../lib/log.ts";
7
- import { claudePool, codexPool, paths } from "../lib/paths.ts";
8
+ import { claudePool, codexPool, musePool, paths } from "../lib/paths.ts";
8
9
  import { loadAccounts, loadConfig } from "../lib/state.ts";
9
10
  import { readStore } from "../lib/credstore.ts";
10
11
  import { codexIdentityOf, codexSeatIdOf, readCodexStoreAuth } from "../lib/codexauth.ts";
@@ -138,6 +139,30 @@ export async function cmdDoctor(): Promise<number> {
138
139
  }
139
140
  }
140
141
 
142
+ const midx = loadAccounts(musePool);
143
+ if (existsSync(museSupervisorLink())) {
144
+ check(!!cfg.museBin && existsSync(cfg.museBin), "real muse binary resolved", "run `tokenmaxxing init --muse`");
145
+ if (cfg.museBin && existsSync(cfg.museBin)) {
146
+ const fail = verifyRealBin({ ...MUSE_BIN, bin: cfg.museBin });
147
+ check(fail === null, "museBin launches the real muse", fail ?? undefined);
148
+ check(isBinDirAhead(MUSE_BIN), `${paths.binDir} is ahead of the real muse on PATH`, `export PATH="${paths.binDir}:$PATH"`);
149
+ }
150
+ } else if (midx.accounts.length > 0) {
151
+ warn("Muse Code accounts are pooled but the muse supervisor is not installed - run `tokenmaxxing init --muse`");
152
+ }
153
+ for (const a of midx.accounts) {
154
+ let ids: string[] = [];
155
+ let readErr: string | null = null;
156
+ try {
157
+ ids = museStoreIdentity(a.id);
158
+ } catch (e) {
159
+ readErr = errorMessage(e).slice(0, 100);
160
+ }
161
+ check(ids.length > 0, `Muse Code store login present for ${a.label}`, readErr ?? `run \`tokenmaxxing auth --muse ${a.label}\``);
162
+ if (ids.length > 0) check(ids.includes(a.id), `Muse Code store login identity matches ${a.label}`, `the store holds another account's login - run \`tokenmaxxing auth --muse ${a.label}\``);
163
+ if (a.needsReauth) check(false, `${a.label} (muse) needs re-auth`, `run \`tokenmaxxing auth --muse ${a.label}\` to re-login`);
164
+ }
165
+
141
166
  const rc = shellRcPath();
142
167
  if (rc && existsSync(rc)) {
143
168
  for (const s of findClaudeShadowers(readFileSync(rc, "utf8"))) {
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 | --muse] <email|label|id> <new-label>", paint: plain });
19
+ emitError({ message: "usage: tokenmaxxing rename [--codex | --grok | --muse | --cursor] <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,16 +3,19 @@ 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 { cursor } from "../lib/cursor.ts";
6
7
  import { muse } from "../lib/muse.ts";
7
8
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
8
9
  import { withLock } from "../lib/lock.ts";
9
- import { codexPool, grokPool, musePool } from "../lib/paths.ts";
10
+ import { codexPool, cursorPool, grokPool, musePool } from "../lib/paths.ts";
10
11
  import { barFor, bufferedUsed, earliestReset, gatedWindows, landGrant, landWindows, isExhausted, isSessionWindow, limitWindows, liveUsed, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
11
12
  import type { Provider, SampleReport } from "../lib/provider.ts";
12
13
  import { usd } from "./key.ts";
13
14
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
14
15
  import type { Account, AccountThresholds, Bars, Config, Thresholds, Window } from "../lib/types.ts";
15
16
 
17
+ const WEEK_S = 7 * 86400;
18
+
16
19
  export type WindowReport = { usedPercentage: number; bufferedPercentage: number; resetsAt: number | null; windowSeconds: number | null };
17
20
 
18
21
  export type UsageReport = { fiveHour: WindowReport | null; week: WindowReport | null; limits: (WindowReport & { name: string })[] };
@@ -53,7 +56,7 @@ type StatusApiKey = {
53
56
  sessions: number;
54
57
  };
55
58
 
56
- export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; muse: PoolReport; apiKeys?: StatusApiKey[] };
59
+ export type StatusReport = { now: number; claude: PoolReport; codex: PoolReport; grok: PoolReport; muse: PoolReport; cursor: PoolReport; apiKeys?: StatusApiKey[] };
57
60
 
58
61
  function apiKeyReport(): StatusApiKey[] {
59
62
  const sessions = keySessions();
@@ -89,7 +92,7 @@ function currentWindow(a: Account, w: Window, bars: Bars | null, now: number): W
89
92
  return {
90
93
  usedPercentage: liveUsed(w, now),
91
94
  bufferedPercentage: bars == null ? liveUsed(w, now) : bufferedUsed(a, w, bars, now),
92
- resetsAt: isSessionWindow(w) ? (passed ? null : w.resetsAt) : nextWeeklyReset(w.resetsAt, now),
95
+ resetsAt: isSessionWindow(w) || (w.windowSeconds ?? 0) > WEEK_S ? (passed ? null : w.resetsAt) : nextWeeklyReset(w.resetsAt, now),
93
96
  windowSeconds: w.windowSeconds,
94
97
  };
95
98
  }
@@ -161,6 +164,7 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
161
164
  if (a.lastUsageAt != null && (stored.lastUsageAt == null || a.lastUsageAt > stored.lastUsageAt)) {
162
165
  landWindows(stored, p.mergeWindows(a.windows, stored.windows), a.lastUsageAt, bars);
163
166
  stored.hasCredits = a.hasCredits;
167
+ if (a.email != null && stored.email == null && stored.label === stored.id.slice(0, 8)) stored.label = a.email;
164
168
  if (a.email != null) stored.email = a.email;
165
169
  if (a.tier != null) stored.tier = a.tier;
166
170
  dirty = true;
@@ -267,7 +271,7 @@ function card(p: Provider, a: StatusAccount, now: number, staleAfterMs: number):
267
271
  const lines = [headerLine({ ...a, name: a.label })];
268
272
  if (a.usage) {
269
273
  if (a.usage.fiveHour) lines.push(usageRow(`${Math.round((a.usage.fiveHour.windowSeconds ?? 0) / 3600)}h`, a.usage.fiveHour));
270
- if (a.usage.week) lines.push(usageRow("week", a.usage.week));
274
+ if (a.usage.week) lines.push(usageRow((a.usage.week.windowSeconds ?? 0) > WEEK_S ? "month" : "week", a.usage.week));
271
275
  for (const w of a.usage.limits) lines.push(usageRow(p.windowLabel(w.name), w));
272
276
  }
273
277
  const notes: Note[] = [];
@@ -289,7 +293,7 @@ function card(p: Provider, a: StatusAccount, now: number, staleAfterMs: number):
289
293
 
290
294
  function renderPool(p: Provider, pool: PoolReport, header: string, now: number, staleAfterMs: number): void {
291
295
  console.log(c.dim(header));
292
- if (!p.statusOnly) console.log(c.dim("buffered usage quota: percent of each account's switch threshold, so it reads higher than the usage Claude or Codex reports"));
296
+ if (!p.statusOnly) console.log(c.dim("buffered usage quota: percent of each account's switch threshold, so it reads higher than the usage the provider reports"));
293
297
  console.log();
294
298
  renderGrid(pool.accounts.map((a) => card(p, a, now, staleAfterMs)));
295
299
  if (pool.gatedNote) {
@@ -308,9 +312,10 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
308
312
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
309
313
  const grokPooled = loadAccounts(grokPool).accounts.length > 0;
310
314
  const musePooled = loadAccounts(musePool).accounts.length > 0;
315
+ const cursorPooled = loadAccounts(cursorPool).accounts.length > 0;
311
316
  if (claudeReport.accounts.length === 0) {
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`)"));
317
+ if (!codexPooled && !grokPooled && !musePooled && !cursorPooled && apiKeys.length === 0) {
318
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--muse`, `--cursor`)"));
314
319
  return 0;
315
320
  }
316
321
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
@@ -324,8 +329,9 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
324
329
  const codexReport = await collect(codex, cfg, now, cached);
325
330
  const grokReport = await collect(grok, cfg, now, cached);
326
331
  const museReport = await collect(muse, cfg, now, cached);
332
+ const cursorReport = await collect(cursor, cfg, now, cached);
327
333
  if (json) {
328
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, muse: museReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
334
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, muse: museReport, cursor: cursorReport, ...(apiKeys.length > 0 ? { apiKeys } : {}) };
329
335
  emitJson({ ok: true, ...report });
330
336
  return 0;
331
337
  }
@@ -336,7 +342,10 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
336
342
  renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
337
343
  }
338
344
  if (museReport.accounts.length > 0) {
339
- renderPool(muse, museReport, `muse (${count({ n: museReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
345
+ renderPool(muse, museReport, `muse (${count({ n: museReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
346
+ }
347
+ if (cursorReport.accounts.length > 0) {
348
+ renderPool(cursor, cursorReport, `cursor (${count({ n: cursorReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
340
349
  }
341
350
  return 0;
342
351
  }
@@ -0,0 +1,335 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { omit } from "es-toolkit";
5
+ import type { Subprocess } from "bun";
6
+ import { MUSE_BIN, UNMANAGED_ENV, WRAP_DEPTH_ENV, resolveRealBin, wrapDepth } from "../lib/claudebin.ts";
7
+ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
8
+ import { withLock } from "../lib/lock.ts";
9
+ import { errorMessage, log } from "../lib/log.ts";
10
+ import { ensureMuseStoreHome, muse, museSeatOpen, observeMuse, probeMuseSeats, rankMuseSeat } from "../lib/muse.ts";
11
+ import { MUSE_CONFIG_HOME_ENV, inMuseStores, musePaths, musePool, museSeatFromEnv } from "../lib/paths.ts";
12
+ import { thresholdBars, usableAt } from "../lib/picker.ts";
13
+ import { clearPresence } from "../lib/presence.ts";
14
+ import { childStdout, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, relayStdout, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS } from "../lib/supervise.ts";
15
+ import { saveTermios } from "../lib/tty.ts";
16
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
17
+ import { ErrnoSchema, type Account } from "../lib/types.ts";
18
+
19
+ const SUBCOMMANDS = new Set([
20
+ "resume", "exec", "config", "export", "trace", "model-profile", "skills", "voice", "plugins", "sandbox",
21
+ "schema", "serve", "session-message", "mcp", "auth", "login", "logout", "init", "help",
22
+ ]);
23
+
24
+ // Headless runs take a seat for their whole life: nothing can resume them elsewhere.
25
+ const SEATED_SUBCMDS = new Set(["exec", "serve"]);
26
+
27
+ const PASSTHROUGH_FLAGS = new Set(["-h", "--help", "-V", "--version"]);
28
+
29
+ const VALUE_FLAGS = new Set([
30
+ "--agents", "--provider", "--preset", "--model", "--contributor-mode", "-c", "--config", "--reasoning-effort",
31
+ "--effort", "--base-url", "--image", "--workspace", "--worktree-base", "--worktree-existing", "--approval-mode",
32
+ "--permission-profile", "--approval-judge", "--echo-delay-ms", "--sandbox-network",
33
+ ]);
34
+
35
+ const WORKTREE_MODES = new Set(["off", "create", "existing"]);
36
+
37
+ // Flags that belong to the first launch only: a resumed session keeps its worktree and has had its image.
38
+ const FIRST_LAUNCH_FLAGS = new Set(["--image", "-w", "--worktree", "--worktree-base", "--worktree-existing"]);
39
+
40
+ type MuseArgs = { help: boolean; sub: string | null; provider: string | null; sessionLog: boolean; resumeFlags: string[] };
41
+
42
+ export function analyzeMuseArgs(argv: string[]): MuseArgs {
43
+ const out: MuseArgs = { help: false, sub: null, provider: null, sessionLog: true, resumeFlags: [] };
44
+ let positionals = 0;
45
+ for (let i = 0; i < argv.length; i++) {
46
+ const a = argv[i]!;
47
+ if (a === "--") break;
48
+ if (PASSTHROUGH_FLAGS.has(a)) out.help = true;
49
+ if (a === "--no-session-log") out.sessionLog = false;
50
+ const eq = a.indexOf("=");
51
+ const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
52
+ if (flag === "-w" || flag === "--worktree") {
53
+ if (eq < 0 && WORKTREE_MODES.has(argv[i + 1] ?? "")) i++;
54
+ continue;
55
+ }
56
+ if (VALUE_FLAGS.has(flag)) {
57
+ const value = eq > 0 && flag !== a ? a.slice(eq + 1) : argv[++i];
58
+ if (flag === "--provider") out.provider = value ?? null;
59
+ if (!FIRST_LAUNCH_FLAGS.has(flag)) out.resumeFlags.push(...(flag === a && value !== undefined ? [a, value] : [a]));
60
+ continue;
61
+ }
62
+ if (a.startsWith("-")) {
63
+ if (a !== "--last" && !FIRST_LAUNCH_FLAGS.has(flag)) out.resumeFlags.push(a);
64
+ continue;
65
+ }
66
+ if (positionals++ === 0 && SUBCOMMANDS.has(a)) out.sub = a;
67
+ }
68
+ return out;
69
+ }
70
+
71
+ type LaunchMode = "passthrough" | "seat" | "managed";
72
+
73
+ function launchMode(args: MuseArgs): { mode: LaunchMode; why: string; inherited?: string } {
74
+ if (args.help) return { mode: "passthrough", why: "help" };
75
+ if (process.env.TOKENMAXXING_PROBE || process.env[UNMANAGED_ENV]) return { mode: "passthrough", why: "unmanaged" };
76
+ if (args.provider != null && args.provider !== "meta") return { mode: "passthrough", why: `provider ${args.provider}` };
77
+ if (process.env.META_API_KEY) return { mode: "passthrough", why: "META_API_KEY overrides the account login" };
78
+ const inherited = museSeatFromEnv(loadAccounts(musePool).accounts.map((a) => a.id));
79
+ if (inherited != null) return { mode: "seat", why: "inherits its parent's seat", inherited };
80
+ if (args.sub != null && args.sub !== "resume") return SEATED_SUBCMDS.has(args.sub) ? { mode: "seat", why: args.sub } : { mode: "passthrough", why: args.sub };
81
+ if (!args.sessionLog) return { mode: "seat", why: "no session log to resume" };
82
+ if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) return { mode: "seat", why: "no terminal" };
83
+ return { mode: "managed", why: "interactive" };
84
+ }
85
+
86
+ const LockSchema = { pid: /^pid=(\d+)$/m, host: /^host=(.*)$/m };
87
+
88
+ function namesDescending(dir: string): string[] {
89
+ try {
90
+ return readdirSync(dir).sort().reverse();
91
+ } catch (e) {
92
+ const code = ErrnoSchema.safeParse(e).data?.code;
93
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
94
+ throw e;
95
+ }
96
+ }
97
+
98
+ // Muse Code keeps each session in sessions/<yyyy>/<mm>/<dd>/<session id>/ and writes the pid of the process that
99
+ // holds it open to .session.lock, a resume included. The newest sessions come first.
100
+ function sessionHeldBy(pid: number): string | null {
101
+ const host = hostname();
102
+ for (const year of namesDescending(musePaths.sessionsDir)) {
103
+ for (const month of namesDescending(join(musePaths.sessionsDir, year))) {
104
+ for (const day of namesDescending(join(musePaths.sessionsDir, year, month))) {
105
+ const dayDir = join(musePaths.sessionsDir, year, month, day);
106
+ for (const id of namesDescending(dayDir)) {
107
+ let lock: string;
108
+ try {
109
+ lock = readFileSync(join(dayDir, id, ".session.lock"), "utf8");
110
+ } catch (e) {
111
+ const code = ErrnoSchema.safeParse(e).data?.code;
112
+ if (code === "ENOENT" || code === "ENOTDIR") continue;
113
+ throw e;
114
+ }
115
+ const lockHost = LockSchema.host.exec(lock)?.[1];
116
+ if (Number(LockSchema.pid.exec(lock)?.[1]) === pid && (lockHost == null || lockHost === host)) return id;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ return null;
122
+ }
123
+
124
+ type Watch = { decided: boolean; move: Account | null };
125
+
126
+ async function watchSeat(seatId: string): Promise<Watch> {
127
+ try {
128
+ const now = Date.now();
129
+ const cfg = loadConfig();
130
+ const account = loadAccounts(musePool).accounts.find((a) => a.id === seatId);
131
+ if (!account) return { decided: false, move: null };
132
+ const observed = await observeMuse(account, cfg, now, { probe: true });
133
+ const unmeasured = observed == null || now - observed.at > cfg.policy.usagePollTtlMs;
134
+ if (unmeasured) log("musesupervisor.seat_unmeasured", { seat: account.id.slice(0, 8), usageAt: observed?.at });
135
+ const rejected = loadAccounts(musePool).accounts.find((a) => a.id === seatId)?.needsReauth === true;
136
+ const current = observed ? { ...account, windows: observed.windows } : account;
137
+ const until = usableAt({ ...current, hasCredits: false }, { now, thresholds: thresholdBars(cfg), currentId: account.id, families: null, seats: null });
138
+ if (!rejected && until <= now) return { decided: unmeasured, move: null };
139
+ // The move decision folds the other accounts from storage, so a destination is read fresh first.
140
+ await probeMuseSeats(now);
141
+ const decision = await evaluateAndMaybeSwap(muse, now, true, null, { seatId: account.id });
142
+ log("musesupervisor.seat_exhausted", { seat: account.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8) });
143
+ return { decided: true, move: decision.swapped ? decision.account : null };
144
+ } catch (e) {
145
+ log("musesupervisor.seat_watch_error", { err: errorMessage(e) });
146
+ return { decided: true, move: null };
147
+ }
148
+ }
149
+
150
+ function validWanted(wanted: string | null, now: number): Account | null {
151
+ if (wanted == null) return null;
152
+ const a = loadAccounts(musePool).accounts.find((x) => x.id === wanted);
153
+ return a && museSeatOpen(a, now) ? a : null;
154
+ }
155
+
156
+ const KILL_GRACE_MS = 30_000;
157
+
158
+ // A launch that gets no seat must not run on a store its environment still names (one inherited from a seat whose
159
+ // account was removed): without a presence file nothing would protect it.
160
+ function unseatedEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
161
+ const home = env.XDG_CONFIG_HOME;
162
+ if (home == null || !inMuseStores(home)) return env;
163
+ const rest = omit(env, ["XDG_CONFIG_HOME", MUSE_CONFIG_HOME_ENV]);
164
+ return env[MUSE_CONFIG_HOME_ENV] != null ? { ...rest, XDG_CONFIG_HOME: env[MUSE_CONFIG_HOME_ENV] } : rest;
165
+ }
166
+
167
+ // A seated child runs with the store as XDG_CONFIG_HOME and keeps the real config home it mirrors, so a launch
168
+ // from inside it mirrors the same one.
169
+ function seatedEnv(env: Record<string, string | undefined>, accountId: string): Record<string, string | undefined> {
170
+ return { ...env, XDG_CONFIG_HOME: ensureMuseStoreHome(accountId), [MUSE_CONFIG_HOME_ENV]: musePaths.configHome };
171
+ }
172
+
173
+ function warnUnseated(): void {
174
+ if (loadAccounts(musePool).accounts.length === 0) return;
175
+ process.stderr.write("\x1b[33mtokenmaxxing: no pooled Muse Code account is usable (run `tokenmaxxing doctor`) - this session runs on your own Muse Code login\x1b[0m\n");
176
+ }
177
+
178
+ export async function runMuseSupervisor(argv: string[]): Promise<number> {
179
+ if (loopGuardTripped("muse")) return 1;
180
+ const real = resolveRealBin(MUSE_BIN);
181
+ const childEnv: Record<string, string | undefined> = { ...process.env, [WRAP_DEPTH_ENV]: String(wrapDepth() + 1) };
182
+ const args = analyzeMuseArgs(argv);
183
+ const { mode, why, inherited } = launchMode(args);
184
+
185
+ if (mode !== "managed") {
186
+ let child: Subprocess | null = null;
187
+ process.on("SIGTERM", () => {
188
+ if (child) child.kill("SIGTERM");
189
+ else process.exit(143);
190
+ });
191
+ if (mode === "passthrough") {
192
+ log("musesupervisor.passthrough", { why, seat: null });
193
+ // A store no pooled account owns any longer (rm --muse kept it for its leftovers) is no login to run on.
194
+ const env = museSeatFromEnv(loadAccounts(musePool).accounts.map((a) => a.id)) == null ? unseatedEnv(childEnv) : childEnv;
195
+ return runPassthrough({ real, argv, env, onSpawn: (p) => { child = p; } });
196
+ }
197
+ const id = `seat-${process.pid}`;
198
+ if (inherited == null) await probeMuseSeats(Date.now());
199
+ // Placement, spawn, and presence share one lock hold, so `rm` never removes an account a launch is taking. A
200
+ // launch inside a seat keeps that seat, and records its own presence so the seat outlives its parent.
201
+ const placed = await withLock(musePool.lockFile, async () => {
202
+ const seat = inherited != null ? (loadAccounts(musePool).accounts.find((a) => a.id === inherited) ?? null) : rankMuseSeat(Date.now());
203
+ log("musesupervisor.passthrough", { why, seat: seat?.id.slice(0, 8) ?? null });
204
+ if (!seat) warnUnseated();
205
+ const p = Bun.spawn([real, ...argv], {
206
+ stdin: "inherit",
207
+ stdout: childStdout(),
208
+ stderr: "inherit",
209
+ env: seat ? seatedEnv(childEnv, seat.id) : unseatedEnv(childEnv),
210
+ });
211
+ child = p;
212
+ if (seat) {
213
+ await recordPresenceOrStop({
214
+ child: p,
215
+ dir: musePaths.presenceDir,
216
+ id,
217
+ accountId: seat.id,
218
+ event: "musesupervisor.seat_presence_failed",
219
+ message: "could not write the muse seat's presence file - refusing to run a session that placement cannot see",
220
+ savedTermios: null,
221
+ });
222
+ }
223
+ return p;
224
+ });
225
+ const drain = placed.stdout instanceof ReadableStream ? relayStdout(placed.stdout) : null;
226
+ try {
227
+ await placed.exited;
228
+ await drain?.();
229
+ return exitStatus(placed);
230
+ } finally {
231
+ clearPresence({ dir: musePaths.presenceDir, id });
232
+ }
233
+ }
234
+
235
+ const id = crypto.randomUUID();
236
+ let child: Subprocess | null = null;
237
+ let terminating = false;
238
+ process.on("SIGTERM", () => {
239
+ terminating = true;
240
+ if (child) child.kill("SIGTERM");
241
+ else process.exit(143);
242
+ });
243
+ const savedTermios = saveTermios();
244
+ process.on("SIGINT", () => {});
245
+ process.on("SIGHUP", () => {});
246
+ const say = (text: string) => {
247
+ process.stderr.write(`\n\x1b[36m${text}\x1b[0m\n`);
248
+ };
249
+
250
+ let launchArgs = argv;
251
+ let respawns = 0;
252
+ let wanted: string | null = null;
253
+ try {
254
+ while (true) {
255
+ await probeMuseSeats(Date.now());
256
+ const launched = await withLock(musePool.lockFile, async () => {
257
+ clearPresence({ dir: musePaths.presenceDir, id });
258
+ if (terminating) return null;
259
+ const picked = validWanted(wanted, Date.now()) ?? rankMuseSeat(Date.now());
260
+ log("musesupervisor.launch", { id: id.slice(0, 8), respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
261
+ if (!picked) warnUnseated();
262
+ const spawned = Bun.spawn([real, ...launchArgs], {
263
+ stdin: "inherit",
264
+ stdout: "inherit",
265
+ stderr: "inherit",
266
+ env: picked ? seatedEnv(childEnv, picked.id) : unseatedEnv(childEnv),
267
+ });
268
+ child = spawned;
269
+ if (picked) {
270
+ await recordPresenceOrStop({
271
+ child: spawned,
272
+ dir: musePaths.presenceDir,
273
+ id,
274
+ accountId: picked.id,
275
+ event: "musesupervisor.presence_failed",
276
+ message: "could not write the muse session's presence file - refusing to run a session that placement cannot see",
277
+ savedTermios,
278
+ });
279
+ }
280
+ return { proc: spawned, picked };
281
+ });
282
+ if (launched == null) return 143;
283
+ const { proc, picked } = launched;
284
+
285
+ const pending: { move: Account | null; session: string | null; killed: boolean } = { move: null, session: null, killed: false };
286
+ let seatCheckAt = 0;
287
+ let escalate: Timer | null = null;
288
+ await raceMarkerOrExit({
289
+ child: proc,
290
+ tick: async () => {
291
+ if (!picked || terminating || Date.now() < seatCheckAt) return false;
292
+ const watched = await watchSeat(picked.id);
293
+ seatCheckAt = Date.now() + (watched.decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
294
+ if (watched.move == null) return false;
295
+ const session = sessionHeldBy(proc.pid);
296
+ if (session == null) {
297
+ log("musesupervisor.session_unresolved", { id: id.slice(0, 8), pid: proc.pid, target: watched.move.id.slice(0, 8) });
298
+ return false;
299
+ }
300
+ pending.move = watched.move;
301
+ pending.session = session;
302
+ return true;
303
+ },
304
+ savedTermios,
305
+ onKill: () => {
306
+ pending.killed = true;
307
+ escalate = setTimeout(() => proc.kill("SIGKILL"), KILL_GRACE_MS);
308
+ },
309
+ onError: () => clearPresence({ dir: musePaths.presenceDir, id }),
310
+ onExited: () => {
311
+ child = null;
312
+ if (escalate) clearTimeout(escalate);
313
+ },
314
+ });
315
+
316
+ if (pending.killed && !terminating && pending.move && pending.session) {
317
+ respawns++;
318
+ log("musesupervisor.move", { id: id.slice(0, 8), from: picked?.id.slice(0, 8), to: pending.move.id.slice(0, 8), session: pending.session });
319
+ say(`↻ tokenmaxxing: moving Muse Code to ${pending.move.label} - resuming the session. A turn that was running stopped; send it again to continue.`);
320
+ wanted = pending.move.id;
321
+ launchArgs = [...args.resumeFlags, "resume", pending.session];
322
+ continue;
323
+ }
324
+ clearPresence({ dir: musePaths.presenceDir, id });
325
+ log("musesupervisor.exit", { id: id.slice(0, 8), respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
326
+ return exitStatus(proc);
327
+ }
328
+ } catch (e) {
329
+ const msg = errorMessage(e);
330
+ say(msg.startsWith("tokenmaxxing:") ? msg : `tokenmaxxing: ${msg}`);
331
+ log("musesupervisor.fatal", { err: msg });
332
+ clearPresence({ dir: musePaths.presenceDir, id });
333
+ return 1;
334
+ }
335
+ }
@@ -109,6 +109,7 @@ export function verifyRealBin(input: { bin: string; name: string; versionOk: (ve
109
109
 
110
110
  export const CLAUDE_BIN = { name: "claude", key: "claudeBin", versionOk: (out: string) => out.toLowerCase().includes("claude") } as const;
111
111
  export const CODEX_BIN = { name: "codex", key: "codexBin", versionOk: (out: string) => out.toLowerCase().includes("codex") } as const;
112
+ export const MUSE_BIN = { name: "muse", key: "museBin", versionOk: (out: string) => out.toLowerCase().includes("muse code") } as const;
112
113
  function isBareVersion(out: string): boolean {
113
114
  try {
114
115
  return Bun.semver.order(out, "0.0.0") >= 0;