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