tokenmaxxing 1.173.0 → 1.174.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 as a status-only pool. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
7
7
  ```
8
8
  $ claude
@@ -40,7 +40,7 @@ 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 |
43
+ | `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 |
44
44
  | `tokenmaxxing add [--codex \| --grok \| --muse]` | register an additional account in that pool (isolated login, harvested once into its own store) |
45
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 |
46
46
  | `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 |
@@ -126,7 +126,7 @@ pi keeps its own credential and refreshes it itself, so each pooled account gets
126
126
  - **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
127
  - **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; a store follows the same model.
128
128
  - **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.
129
+ - **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
130
 
131
131
  ## How it's built
132
132
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.173.0",
3
+ "version": "1.174.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/doctor.ts CHANGED
@@ -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/status.ts CHANGED
@@ -289,7 +289,7 @@ function card(p: Provider, a: StatusAccount, now: number, staleAfterMs: number):
289
289
 
290
290
  function renderPool(p: Provider, pool: PoolReport, header: string, now: number, staleAfterMs: number): void {
291
291
  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"));
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 the provider reports"));
293
293
  console.log();
294
294
  renderGrid(pool.accounts.map((a) => card(p, a, now, staleAfterMs)));
295
295
  if (pool.gatedNote) {
@@ -336,7 +336,7 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
336
336
  renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
337
337
  }
338
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);
339
+ renderPool(muse, museReport, `muse (${count({ n: museReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
340
340
  }
341
341
  return 0;
342
342
  }
@@ -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;
package/src/lib/grok.ts CHANGED
@@ -134,7 +134,9 @@ export const grok = statusOnlyProvider({
134
134
  versionOk: (out) => out.toLowerCase().includes("grok"),
135
135
  importIntro: "Opening an isolated grok login for your first pooled account - the login you already have stays as it is.",
136
136
  foundLive: (count) => `found ${count} grok login(s) in ${grokPaths.home} - pooling the first; use \`tokenmaxxing add --grok\` for the rest.`,
137
- installNotice: "grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts.",
137
+ install: () => {
138
+ console.log(c.dim("grok pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled grok accounts."));
139
+ },
138
140
  loginStep: () => `Sign in in the browser session that opens (or run ${c.bold("grok login --device-auth")} on headless hosts first).`,
139
141
  samplePool,
140
142
  });
@@ -180,6 +180,14 @@ export function installPiSupervisor(): void {
180
180
  writeFileAtomic(piSupervisorLink(), `#!/bin/sh\nexec ${shellQuote(installEntryPoints())} __supervise-pi "$@"\n`, 0o755);
181
181
  }
182
182
 
183
+ export function museSupervisorLink(): string {
184
+ return join(paths.binDir, "muse");
185
+ }
186
+
187
+ export function installMuseSupervisor(): void {
188
+ writeFileAtomic(museSupervisorLink(), `#!/bin/sh\nexec ${shellQuote(installEntryPoints())} __supervise-muse "$@"\n`, 0o755);
189
+ }
190
+
183
191
  export type UnitJob = {
184
192
  label: string;
185
193
  arg: string;
@@ -484,6 +492,7 @@ export function uninstallTargets(live: boolean): string[] {
484
492
  paths.supervisorLink,
485
493
  codexSupervisorLink(),
486
494
  piSupervisorLink(),
495
+ museSupervisorLink(),
487
496
  join(paths.binDir, "xx"),
488
497
  installedBin(),
489
498
  ...(rc == null ? [] : [`${rc}: the ${PATH_LINE_MARK} line`]),
@@ -495,7 +504,7 @@ export function uninstallSupervisor(input: { live: boolean }): UninstallOutcome
495
504
  const timer = uninstallJob(CHECK_JOB, input.live);
496
505
  const hub = uninstallJob(HUB_JOB, input.live);
497
506
  uninstallCodexSupervisor();
498
- for (const f of [paths.supervisorLink, piSupervisorLink(), join(paths.binDir, "xx"), installedBin()]) {
507
+ for (const f of [paths.supervisorLink, piSupervisorLink(), museSupervisorLink(), join(paths.binDir, "xx"), installedBin()]) {
499
508
  rmSync(f, { force: true });
500
509
  }
501
510
  const rc = shellRcPath();
package/src/lib/muse.ts CHANGED
@@ -1,12 +1,20 @@
1
+ import { lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, rmSync, symlinkSync } from "node:fs";
2
+ import { join, sep } from "node:path";
1
3
  import { z } from "zod";
2
4
  import { writeFileAtomic } from "./atomic.ts";
5
+ import { ensurePathAhead } from "./claude.ts";
6
+ import { MUSE_BIN } from "./claudebin.ts";
3
7
  import { http, safeErrorDetail } from "./http.ts";
4
- import { errorMessage } from "./log.ts";
5
- import { env, museAuthJsonFor, musePaths, musePool, museStoreDirFor } from "./paths.ts";
6
- import type { SampleReport } from "./provider.ts";
7
- import { readJsonFile, type Harvest } from "./state.ts";
8
+ import { isBinDirAhead, installMuseSupervisor, museSupervisorLink } from "./install.ts";
9
+ import { withLock } from "./lock.ts";
10
+ import { errorMessage, log } from "./log.ts";
11
+ import { env, museAuthJsonFor, musePaths, musePool, museSeatFromEnv, museStoreDirFor } from "./paths.ts";
12
+ import { isExhausted, landWindows, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
+ import { seatCounts } from "./presence.ts";
14
+ import { StoreUnusableError, type Observation, type Provider, type SampleReport } from "./provider.ts";
15
+ import { loadAccounts, loadConfig, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
8
16
  import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
9
- import { EpochSecondsSchema, ErrnoSchema, JsonTextSchema, type Account } from "./types.ts";
17
+ import { EpochSecondsSchema, ErrnoSchema, JsonTextSchema, type Account, type Config, type Window } from "./types.ts";
10
18
  import { c } from "../cli/render.ts";
11
19
 
12
20
  const MuseProviderSchema = z.looseObject({
@@ -54,6 +62,108 @@ function readAuth(path: string): AuthEntry[] {
54
62
  return out;
55
63
  }
56
64
 
65
+ export function museStoreUsable(accountId: string): boolean {
66
+ try {
67
+ return readAuth(museAuthJsonFor(accountId)).some((e) => e.id === accountId && e.usable);
68
+ } catch {
69
+ return false;
70
+ }
71
+ }
72
+
73
+ export function museStoreIdentity(accountId: string): string[] {
74
+ return readAuth(museAuthJsonFor(accountId)).map((e) => e.id);
75
+ }
76
+
77
+ function namesIn(dir: string): string[] {
78
+ try {
79
+ return readdirSync(dir);
80
+ } catch (e) {
81
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return [];
82
+ throw e;
83
+ }
84
+ }
85
+
86
+ // A mirror link is one this code made: a symlink to the real config home's entry of the same name.
87
+ function isMirrorLink(from: string, to: string, name: string): boolean {
88
+ if (lstatSync(join(to, name), { throwIfNoEntry: false })?.isSymbolicLink() !== true) return false;
89
+ return readlinkSync(join(to, name)) === join(from, name);
90
+ }
91
+
92
+ function linkEntries(from: string, to: string, keep: (name: string) => boolean): void {
93
+ for (const name of namesIn(from)) {
94
+ if (!keep(name)) continue;
95
+ try {
96
+ symlinkSync(join(from, name), join(to, name));
97
+ } catch (e) {
98
+ if (ErrnoSchema.safeParse(e).data?.code !== "EEXIST") throw e;
99
+ }
100
+ }
101
+ }
102
+
103
+ function sharedTop(name: string): boolean {
104
+ return name !== "muse" && !`${musePaths.storesDir}${sep}`.startsWith(join(musePaths.configHome, name) + sep);
105
+ }
106
+
107
+ function sharedMuse(name: string): boolean {
108
+ return !name.startsWith("auth.json") && !name.endsWith(".lock");
109
+ }
110
+
111
+ // The store doubles as the seated session's XDG_CONFIG_HOME, which every tool the session runs inherits, so it
112
+ // mirrors the real config home: a link to each entry but `muse`, and in `muse/` a link to each entry but the login.
113
+ function configHomeMarker(accountId: string): string {
114
+ return `${museStoreDirFor(accountId)}.config-home`;
115
+ }
116
+
117
+ // A store mirrors one config home, recorded beside it. Relinking it to another while a session may run on it could
118
+ // redirect that session, so a launch from another config home is refused instead.
119
+ function recordedConfigHome(accountId: string): string | null {
120
+ try {
121
+ return readFileSync(configHomeMarker(accountId), "utf8");
122
+ } catch (e) {
123
+ if (ErrnoSchema.safeParse(e).data?.code !== "ENOENT") throw e;
124
+ return null;
125
+ }
126
+ }
127
+
128
+ // Why this process cannot seat the account's store, or null when it can.
129
+ export function museStoreMismatch(accountId: string): string | null {
130
+ const recorded = recordedConfigHome(accountId);
131
+ if (recorded == null || recorded === musePaths.configHome) return null;
132
+ return `${museStoreDirFor(accountId)} mirrors the config home ${recorded}, not ${musePaths.configHome} - launch with XDG_CONFIG_HOME=${recorded}, or run \`tokenmaxxing rm --muse\` and add the account again`;
133
+ }
134
+
135
+ export function ensureMuseStoreHome(accountId: string): string {
136
+ const store = museStoreDirFor(accountId);
137
+ const config = join(store, "muse");
138
+ const mismatch = museStoreMismatch(accountId);
139
+ if (mismatch != null) throw new Error(mismatch);
140
+ mkdirSync(config, { recursive: true, mode: 0o700 });
141
+ if (recordedConfigHome(accountId) == null) writeFileAtomic(configHomeMarker(accountId), musePaths.configHome);
142
+ linkEntries(musePaths.configHome, store, sharedTop);
143
+ linkEntries(join(musePaths.configHome, "muse"), config, sharedMuse);
144
+ return store;
145
+ }
146
+
147
+ // Deletes the login. Anything in the store besides the login and the mirror links (an entry a seated tool or Muse
148
+ // Code made there, such as a settings.json rewritten in place of its link) keeps the store whole, mirror links
149
+ // included, since a kept entry may point through one; the command names each kept entry.
150
+ function removeMuseStore(accountId: string): void {
151
+ const store = museStoreDirFor(accountId);
152
+ const config = join(store, "muse");
153
+ for (const name of namesIn(config)) if (name.startsWith("auth.json")) rmSync(join(config, name), { force: true });
154
+ const home = recordedConfigHome(accountId) ?? musePaths.configHome;
155
+ const kept = [
156
+ ...namesIn(config).filter((n) => !isMirrorLink(join(home, "muse"), config, n)).map((n) => join(config, n)),
157
+ ...namesIn(store).filter((n) => n !== "muse" && !isMirrorLink(home, store, n)).map((n) => join(store, n)),
158
+ ];
159
+ if (kept.length > 0) {
160
+ console.log(c.yellow(`kept ${store} without its login: it holds ${kept.join(", ")}, which a seated session made there - move what you need, then delete the directory`));
161
+ return;
162
+ }
163
+ rmSync(store, { recursive: true, force: true });
164
+ rmSync(configHomeMarker(accountId), { force: true });
165
+ }
166
+
57
167
  const SubscriptionWindowSchema = z.looseObject({ used_percent: z.number(), resets_at: EpochSecondsSchema });
58
168
 
59
169
  const MintSchema = z.looseObject({
@@ -62,7 +172,7 @@ const MintSchema = z.looseObject({
62
172
  window: SubscriptionWindowSchema.extend({ window_duration_mins: z.number() }),
63
173
  weekly: SubscriptionWindowSchema,
64
174
  })
65
- .nullable(),
175
+ .nullish(),
66
176
  });
67
177
 
68
178
  const WEEK_S = 7 * 86400;
@@ -81,51 +191,136 @@ function storedAccessToken(id: string): string | null {
81
191
  return null;
82
192
  }
83
193
 
84
- async function readMuseUsage(account: Account, now: number): Promise<SampleReport> {
194
+ // windows null: the mint carried no subs_usage, which it leaves out while no 5-hour window is open.
195
+ type MuseRead = { ok: true; windows: Window[] | null; at: number } | { ok: false; reason: string; rejected: boolean };
196
+
197
+ // With no window open, a stored window that has not reset (the weekly one at its limit, say) still binds.
198
+ function landedWindows(read: { windows: Window[] | null }, stored: Window[], now: number): Window[] {
199
+ return read.windows ?? stored.filter((w) => w.resetsAt != null && w.resetsAt > now);
200
+ }
201
+
202
+ async function readMuseUsage(accountId: string, now: number): Promise<MuseRead> {
85
203
  let token: string | null;
86
204
  try {
87
- token = storedAccessToken(account.id);
205
+ token = storedAccessToken(accountId);
88
206
  } catch (e) {
89
- return { ok: false, reason: `this account's store is unreadable (${errorMessage(e)}) - run \`tokenmaxxing auth --muse\`` };
207
+ return { ok: false, rejected: false, reason: `this account's store is unreadable (${errorMessage(e)}) - run \`tokenmaxxing auth --muse\`` };
90
208
  }
91
- if (token == null) return { ok: false, reason: "no access_token in this account's store - run `tokenmaxxing auth --muse`" };
209
+ if (token == null) return { ok: false, rejected: false, reason: "no access_token in this account's store - run `tokenmaxxing auth --muse`" };
92
210
  let res: Response;
93
211
  let text: string;
94
212
  try {
95
213
  res = await http.post(usageUrl(), { headers: { Authorization: `Bearer ${token}` }, json: {} });
96
214
  text = await res.text();
97
215
  } catch (e) {
98
- return { ok: false, reason: `Muse Code usage read failed: endpoint unreachable: ${errorMessage(e)}` };
216
+ return { ok: false, rejected: false, reason: `Muse Code usage read failed: endpoint unreachable: ${errorMessage(e)}` };
99
217
  }
100
218
  if (res.status === 401 || res.status === 403) {
101
- account.needsReauth = true;
102
- return { ok: false, reason: `Muse Code rejected the stored login (HTTP ${res.status}) - run \`tokenmaxxing auth --muse\`` };
219
+ return { ok: false, rejected: true, reason: `Muse Code rejected the stored login (HTTP ${res.status}) - run \`tokenmaxxing auth --muse\`` };
103
220
  }
104
- if (!res.ok) return { ok: false, reason: `Muse Code usage read failed: HTTP ${res.status}: ${safeErrorDetail({ text })}` };
221
+ if (!res.ok) return { ok: false, rejected: false, reason: `Muse Code usage read failed: HTTP ${res.status}: ${safeErrorDetail({ text })}` };
105
222
  const parsed = MintSchema.safeParse(JsonTextSchema.safeParse(text).data);
106
- if (!parsed.success) return { ok: false, reason: "Muse Code usage read failed: endpoint returned an unexpected body shape (withheld)" };
107
- if (parsed.data.subs_usage == null) return { ok: false, reason: "Muse Code reports no usage until a model request opens the 5-hour window" };
223
+ if (!parsed.success) return { ok: false, rejected: false, reason: "Muse Code usage read failed: endpoint returned an unexpected body shape (withheld)" };
224
+ if (parsed.data.subs_usage == null) return { ok: true, windows: null, at: now };
108
225
  const { window, weekly } = parsed.data.subs_usage;
109
- account.windows = [
110
- { name: null, usedPercentage: window.used_percent, resetsAt: window.resets_at, windowSeconds: window.window_duration_mins * 60, sampledAt: now },
111
- { name: null, usedPercentage: weekly.used_percent, resetsAt: weekly.resets_at, windowSeconds: WEEK_S, sampledAt: now },
112
- ];
113
- account.lastUsageAt = now;
114
- return { ok: true, source: "probe" };
226
+ return {
227
+ ok: true,
228
+ at: now,
229
+ windows: [
230
+ { name: null, usedPercentage: window.used_percent, resetsAt: window.resets_at, windowSeconds: window.window_duration_mins * 60, sampledAt: now },
231
+ { name: null, usedPercentage: weekly.used_percent, resetsAt: weekly.resets_at, windowSeconds: WEEK_S, sampledAt: now },
232
+ ],
233
+ };
115
234
  }
116
235
 
117
236
  async function samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>> {
118
237
  const reports = new Map<string, SampleReport>();
119
- await Promise.all(accounts.map(async (account) => reports.set(account.id, await readMuseUsage(account, now))));
238
+ await Promise.all(
239
+ accounts.map(async (account) => {
240
+ const read = await readMuseUsage(account.id, now);
241
+ if (read.ok) {
242
+ account.windows = landedWindows(read, account.windows, read.at);
243
+ account.lastUsageAt = read.at;
244
+ reports.set(account.id, { ok: true, source: "probe" });
245
+ return;
246
+ }
247
+ if (read.rejected) account.needsReauth = true;
248
+ reports.set(account.id, { ok: false, reason: read.reason });
249
+ }),
250
+ );
120
251
  return reports;
121
252
  }
122
253
 
123
- export const muse = statusOnlyProvider({
254
+ export async function observeMuse(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
255
+ if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
256
+ const read = await readMuseUsage(account.id, Date.now());
257
+ if (!read.ok) log("muse.usage_failed", { account: account.id.slice(0, 8), reason: read.reason.slice(0, 200) });
258
+ await withLock(musePool.lockFile, () => {
259
+ const idx = loadAccounts(musePool);
260
+ const a = idx.accounts.find((x) => x.id === account.id);
261
+ if (!a) return;
262
+ if (read.ok) {
263
+ if (a.lastUsageAt == null || read.at > a.lastUsageAt) landWindows(a, landedWindows(read, a.windows, read.at), read.at, thresholdBars(cfg));
264
+ } else if (read.rejected) {
265
+ a.needsReauth = true;
266
+ }
267
+ saveAccounts(musePool, idx);
268
+ });
269
+ }
270
+ const current = loadAccounts(musePool).accounts.find((a) => a.id === account.id) ?? account;
271
+ return current.lastUsageAt != null ? { windows: current.windows, at: current.lastUsageAt } : null;
272
+ }
273
+
274
+ function presence(): Map<string, number> {
275
+ return seatCounts(musePaths.presenceDir);
276
+ }
277
+
278
+ // A store that mirrors another config home is no seat for this process; moving a session onto it would stop the
279
+ // session and then refuse to start it.
280
+ function seatable(a: Account): boolean {
281
+ return a.needsReauth !== true && museStoreUsable(a.id) && museStoreMismatch(a.id) == null;
282
+ }
283
+
284
+ // Reads the usage of every seatable account whose sample is stale. It takes the pool lock itself, so it runs
285
+ // before the lock a placement holds.
286
+ export async function probeMuseSeats(now: number): Promise<void> {
287
+ const cfg = loadConfig();
288
+ await Promise.all(loadAccounts(musePool).accounts.filter(seatable).map((a) => observeMuse(a, cfg, now, { probe: true })));
289
+ }
290
+
291
+ export function museSeatOpen(a: Account, now: number): boolean {
292
+ return seatable(a) && !isExhausted(a, { now, thresholds: thresholdBars(loadConfig()), currentId: null, families: null, seats: null });
293
+ }
294
+
295
+ // Ranks the stored accounts the way a move does, spreading sessions across accounts with the same headroom; with
296
+ // none under its bars, the one that frees up first. Call it under the pool lock, after probeMuseSeats.
297
+ export function rankMuseSeat(now: number): Account | null {
298
+ const ctx: PickCtx = { now, thresholds: thresholdBars(loadConfig()), currentId: null, families: null, seats: presence() };
299
+ const open = loadAccounts(musePool).accounts.filter(seatable);
300
+ return pickBest(open, ctx) ?? pickEarliestReset(open, ctx)?.account ?? null;
301
+ }
302
+
303
+ async function prepareMove(target: Account): Promise<void> {
304
+ if (!museStoreUsable(target.id)) {
305
+ throw new StoreUnusableError(`${target.label} has no usable login in its store - run \`tokenmaxxing auth --muse ${target.label}\``);
306
+ }
307
+ const mismatch = museStoreMismatch(target.id);
308
+ if (mismatch != null) throw new StoreUnusableError(mismatch);
309
+ log("move.prepared", { account: target.id.slice(0, 8), label: target.label, muse: true });
310
+ }
311
+
312
+ function install(): void {
313
+ installMuseSupervisor();
314
+ console.log(`${c.green("✓")} muse supervisor installed at ${museSupervisorLink()}`);
315
+ if (!isBinDirAhead(MUSE_BIN)) ensurePathAhead();
316
+ }
317
+
318
+ const base = statusOnlyProvider({
124
319
  name: "muse",
125
320
  flag: " --muse",
126
321
  pool: musePool,
127
- binName: "muse",
128
- binKey: "museBin",
322
+ binName: MUSE_BIN.name,
323
+ binKey: MUSE_BIN.key,
129
324
  storeDirFor: museStoreDirFor,
130
325
  authJsonFor: museAuthJsonFor,
131
326
  homeEnv: "XDG_CONFIG_HOME",
@@ -133,12 +328,22 @@ export const muse = statusOnlyProvider({
133
328
  onboardAuthRel: "muse/auth.json",
134
329
  liveAuthPath: () => musePaths.authJson,
135
330
  readAuth,
136
- liveId: () => null,
137
- presence: () => new Map(),
138
- versionOk: (out) => out.toLowerCase().includes("muse code"),
331
+ liveId: () => museSeatFromEnv(loadAccounts(musePool).accounts.map((a) => a.id)),
332
+ presence,
333
+ versionOk: MUSE_BIN.versionOk,
139
334
  importIntro: "Opening an isolated Muse Code login for your first pooled account - the login you already have stays as it is.",
140
335
  foundLive: (count) => `found ${count} Muse Code login(s) in ${musePaths.authJson} - pooling the first; use \`tokenmaxxing add --muse\` for the rest.`,
141
- installNotice: "muse pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled Muse Code accounts.",
336
+ install,
142
337
  loginStep: () => `Approve the code in the browser that ${c.bold("muse login")} opens.`,
143
338
  samplePool,
144
339
  });
340
+
341
+ export const muse: Provider = {
342
+ ...base,
343
+ seats: "shared",
344
+ statusOnly: false,
345
+ observeLive: (account, cfg, now, opts) => observeMuse(account, cfg, now, { probe: opts.probe }),
346
+ swap: prepareMove,
347
+ removeCredentials: async (a) => removeMuseStore(a.id),
348
+ classifySwapError: (e) => (e instanceof StoreUnusableError ? "skip" : "fatal"),
349
+ };
package/src/lib/paths.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { homedir, userInfo } from "node:os";
2
- import { join, resolve } from "node:path";
2
+ import { join, resolve, sep } from "node:path";
3
3
  import { z } from "zod";
4
4
 
5
5
  const HOME = homedir();
@@ -124,17 +124,41 @@ export function grokAuthJsonFor(accountId: string): string {
124
124
  return join(grokStoreDirFor(accountId), "auth.json");
125
125
  }
126
126
 
127
+ const MUSE_STORES_DIR = join(TM_HOME, "muse-stores");
128
+ const XDG_CONFIG = optionalEnv("XDG_CONFIG_HOME");
129
+ export const MUSE_CONFIG_HOME_ENV = "TOKENMAXXING_XDG_CONFIG_HOME";
130
+
131
+ export function inMuseStores(path: string): boolean {
132
+ return path === MUSE_STORES_DIR || path.startsWith(MUSE_STORES_DIR + sep);
133
+ }
134
+
135
+ // Inside a seat XDG_CONFIG_HOME names a store, and the seat carries the real config home it mirrors in
136
+ // MUSE_CONFIG_HOME_ENV; a store named with no such record (one rm --muse kept) falls back to ~/.config.
137
+ const MUSE_CONFIG_HOME =
138
+ XDG_CONFIG == null ? join(HOME, ".config") : !inMuseStores(XDG_CONFIG) ? XDG_CONFIG : (optionalEnv(MUSE_CONFIG_HOME_ENV) ?? join(HOME, ".config"));
139
+
127
140
  export const musePaths = {
128
- authJson: join(env("XDG_CONFIG_HOME", join(HOME, ".config")), "muse", "auth.json"),
129
- storesDir: join(TM_HOME, "muse-stores"),
141
+ configHome: MUSE_CONFIG_HOME,
142
+ authJson: join(MUSE_CONFIG_HOME, "muse", "auth.json"),
143
+ sessionsDir: join(env("XDG_DATA_HOME", join(HOME, ".local", "share")), "muse", "sessions"),
144
+ storesDir: MUSE_STORES_DIR,
145
+ presenceDir: join(TM_HOME, "muse-live"),
130
146
  } as const;
131
147
 
148
+ // A store is the XDG_CONFIG_HOME of the sessions seated on its account: Muse Code reads its login only from
149
+ // $XDG_CONFIG_HOME/muse/auth.json.
132
150
  export function museStoreDirFor(accountId: string): string {
133
151
  return join(musePaths.storesDir, shortId(accountId));
134
152
  }
135
153
 
136
154
  export function museAuthJsonFor(accountId: string): string {
137
- return join(museStoreDirFor(accountId), "auth.json");
155
+ return join(museStoreDirFor(accountId), "muse", "auth.json");
156
+ }
157
+
158
+ export function museSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
159
+ const home = env.XDG_CONFIG_HOME;
160
+ if (home == null || home === "") return null;
161
+ return accountIds.find((id) => museStoreDirFor(id) === home) ?? null;
138
162
  }
139
163
 
140
164
  export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
@@ -59,12 +59,15 @@ function listDir(dir: string, root: string): Dirent[] {
59
59
  }
60
60
 
61
61
  function tmpSweepDirs(root: string): string[] {
62
- const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, paths.apiKeysDir, codexPaths.presenceDir, codexPaths.respawnDir];
62
+ const dirs = [paths.home, paths.usageDir, paths.presenceDir, paths.respawnDir, sessionsDir(), paths.binDir, paths.apiKeysDir, codexPaths.presenceDir, codexPaths.respawnDir, musePaths.presenceDir];
63
63
  for (const parent of SWEEP_PARENTS) {
64
64
  for (const child of listDir(parent, root)) {
65
65
  if (child.isDirectory()) dirs.push(join(parent, child.name));
66
66
  }
67
67
  }
68
+ for (const child of listDir(musePaths.storesDir, root)) {
69
+ if (child.isDirectory()) dirs.push(join(musePaths.storesDir, child.name, "muse"));
70
+ }
68
71
  return dirs;
69
72
  }
70
73
 
@@ -28,7 +28,7 @@ export type StatusOnlySpec = {
28
28
  versionOk: (versionOutput: string) => boolean;
29
29
  importIntro: string;
30
30
  foundLive: (count: number) => string;
31
- installNotice: string;
31
+ install: () => void;
32
32
  loginStep: () => string;
33
33
  samplePool: Provider["samplePool"];
34
34
  };
@@ -110,9 +110,7 @@ export function statusOnlyProvider(spec: StatusOnlySpec): Provider {
110
110
  login,
111
111
  importLive,
112
112
  preflight,
113
- install: () => {
114
- console.log(c.dim(spec.installNotice));
115
- },
113
+ install: spec.install,
116
114
  loginStep: spec.loginStep,
117
115
  windowLabel: (name) => name.toLowerCase(),
118
116
  };
@@ -8,7 +8,7 @@ import { readLines } from "./proc.ts";
8
8
  import { writePresence } from "./presence.ts";
9
9
  import { restoreTermios } from "./tty.ts";
10
10
 
11
- export type WrappedProduct = "claude" | "codex" | "pi";
11
+ export type WrappedProduct = "claude" | "codex" | "pi" | "muse";
12
12
 
13
13
  export const SEAT_POLL_MS = 10_000;
14
14
  export const SEAT_RETRY_MS = 60_000;
@@ -29,6 +29,7 @@ const WRAPPED: Record<WrappedProduct, { setting: string; binary: string; events:
29
29
  claude: { setting: "claudeBin", binary: "Claude", events: "supervisor" },
30
30
  codex: { setting: "codexBin", binary: "codex", events: "codexsupervisor" },
31
31
  pi: { setting: "piBin", binary: "pi", events: "pisupervisor" },
32
+ muse: { setting: "museBin", binary: "muse", events: "musesupervisor" },
32
33
  };
33
34
 
34
35
  export function exitStatus(child: { exitCode: number | null; signalCode: string | null }): number {
package/src/main.ts CHANGED
@@ -10,6 +10,7 @@ import { runStopFailureHook } from "./entries/stopfailurehook.ts";
10
10
  import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
11
11
  import { runCodexStopHook } from "./entries/codexstophook.ts";
12
12
  import { runPiSupervisor } from "./entries/pisupervisor.ts";
13
+ import { runMuseSupervisor } from "./entries/musesupervisor.ts";
13
14
  import { claude } from "./lib/claude.ts";
14
15
  import { codex } from "./lib/codex.ts";
15
16
  import { grok } from "./lib/grok.ts";
@@ -41,7 +42,7 @@ const MUSE_FLAG = "--muse";
41
42
  const PI_FLAG = "--pi";
42
43
  const JSON_COMMANDS = new Set(["status", "config", "check"]);
43
44
  const CODEX_COMMANDS = new Set(["init", "add", "auth", "emails", "rm", "rename", "seat"]);
44
- const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "emails", "rm", "rename"]);
45
+ const POOL_COMMANDS = new Set(["init", "add", "auth", "emails", "rm", "rename"]);
45
46
  const MAX_OPERANDS = new Map([["init", 0], ["add", 0], ["emails", 0], ["rm", 1], ["rename", 2], ["key", 4]]);
46
47
 
47
48
  function printHelp(): void {
@@ -53,7 +54,7 @@ function printHelp(): void {
53
54
  ${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
54
55
  ${c.cyan("tokenmaxxing init --pi")} install the pi supervisor: pi sessions run on pooled Claude or ChatGPT accounts that are logged into pi
55
56
  ${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)
57
+ ${c.cyan("tokenmaxxing init --muse")} same for Muse Code: log in the first account, isolated, install the muse supervisor
57
58
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
58
59
  ${c.cyan("tokenmaxxing add")} --codex | --grok | --muse same for the codex, grok, or muse pool
59
60
  ${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)
@@ -99,6 +100,9 @@ async function main(): Promise<number> {
99
100
  if (argv0 === "pi" || argv[0] === "__supervise-pi") {
100
101
  return runPiSupervisor(argv[0] === "__supervise-pi" ? argv.slice(1) : argv);
101
102
  }
103
+ if (argv0 === "muse" || argv[0] === "__supervise-muse") {
104
+ return runMuseSupervisor(argv[0] === "__supervise-muse" ? argv.slice(1) : argv);
105
+ }
102
106
 
103
107
  jsonMode = argv.includes(JSON_FLAG);
104
108
  const json = jsonMode;
@@ -139,8 +143,8 @@ async function main(): Promise<number> {
139
143
  return 2;
140
144
  }
141
145
 
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"}` });
146
+ if ((provider === grok || provider === muse) && (sub == null || !POOL_COMMANDS.has(sub))) {
147
+ emitError({ json, message: `${provider.flag.trim()} applies to ${[...POOL_COMMANDS].join(", ")}, not ${sub ?? "status"}` });
144
148
  return 2;
145
149
  }
146
150