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 +3 -3
- package/package.json +1 -1
- package/src/cli/doctor.ts +28 -3
- package/src/cli/status.ts +2 -2
- package/src/entries/musesupervisor.ts +335 -0
- package/src/lib/claudebin.ts +1 -0
- package/src/lib/grok.ts +3 -1
- package/src/lib/install.ts +10 -1
- package/src/lib/muse.ts +235 -30
- package/src/lib/paths.ts +28 -4
- package/src/lib/sessions.ts +4 -1
- package/src/lib/statusonly.ts +2 -4
- package/src/lib/supervise.ts +2 -1
- package/src/main.ts +8 -4
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
|
|
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` |
|
|
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
|
-
- **
|
|
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.
|
|
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
|
|
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" })}
|
|
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
|
+
}
|
package/src/lib/claudebin.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
});
|
package/src/lib/install.ts
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import
|
|
7
|
-
import {
|
|
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
|
-
.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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
|
|
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:
|
|
128
|
-
binKey:
|
|
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: () =>
|
|
137
|
-
presence
|
|
138
|
-
versionOk:
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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 {
|
package/src/lib/sessions.ts
CHANGED
|
@@ -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
|
|
package/src/lib/statusonly.ts
CHANGED
|
@@ -28,7 +28,7 @@ export type StatusOnlySpec = {
|
|
|
28
28
|
versionOk: (versionOutput: string) => boolean;
|
|
29
29
|
importIntro: string;
|
|
30
30
|
foundLive: (count: number) => string;
|
|
31
|
-
|
|
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
|
};
|
package/src/lib/supervise.ts
CHANGED
|
@@ -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
|
|
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")}
|
|
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
|
|
143
|
-
emitError({ json, message: `${provider.flag.trim()} applies to ${[...
|
|
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
|
|