tokenmaxxing 1.172.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 +5 -5
- 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/claude.ts +9 -2
- package/src/lib/claudebin.ts +1 -0
- package/src/lib/codex.ts +25 -1
- package/src/lib/decide.ts +2 -2
- 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/picker.ts +35 -5
- package/src/lib/provider.ts +1 -0
- 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
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# tokenmaxxing
|
|
2
2
|
|
|
3
|
-
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with
|
|
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 |
|
|
@@ -62,7 +62,7 @@ Each session runs on its own seat. One rule: while the seat is under its bars, n
|
|
|
62
62
|
|
|
63
63
|
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98). The default margin is 0, so the configured thresholds and the effective bars read the same.
|
|
64
64
|
|
|
65
|
-
Launches and moves rank usable accounts
|
|
65
|
+
Launches and moves rank usable accounts in groups first: plan headroom before usage credits, a credential store that read cleanly, room for a whole session (session-window headroom per running session, `(bar - used) / (sessions + 1)`, scaled by plan size, at least 15 unless the session window resets within the hour), a weekly reset within 4 days, and a usage figure under 6 hours old. Within a group, the highest pace pressure scaled by plan size wins (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset), so weekly quota closest to expiring unused is spent first; ties break by soonest weekly reset, then headroom per session, then lowest weekly use, then fewest sessions. Plan size reads the account's tier: a Claude multiplier divided by 5 (`max 20x` is 4, `pro` 0.2), Codex relative to Plus (`prolite` 5, `pro` 10, `promax` 20). Organization membership is not an input.
|
|
66
66
|
|
|
67
67
|
- Every periodic check tick folds fresh session tees (the statusline of a terminal session, the `rate_limit_event` stream of a T3 Code or Agent SDK session) into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval (15 minutes for an empty account or one with no session on this host, shrinking to `policy.usagePollTtlMs` as a live account nears a bar, and 15 minutes again at or over an aggregate bar), with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. A sample that finds the stored access token expired has Claude Code refresh it first (`claude -p /usage` under that store) and reads with the new token.
|
|
68
68
|
- When no account is usable, the session queues first come first served onto a reset target (at most eight waiters per account; overflow waits for the next reset with room) and pauses until that reset if it lands within twice `policy.maxWaitMs`, and otherwise stays put.
|
|
@@ -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/claude.ts
CHANGED
|
@@ -140,6 +140,12 @@ async function storeUsable(a: Account): Promise<boolean> {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
export function claudePlanWeight(a: Account): number {
|
|
144
|
+
const multiplier = a.tier?.match(/(\d+)x\b/)?.[1];
|
|
145
|
+
if (multiplier != null) return Number(multiplier) / 5;
|
|
146
|
+
return a.tier?.startsWith("pro") ? 0.2 : 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
143
149
|
export type Seat = { account: Account; depletedUntil?: number };
|
|
144
150
|
|
|
145
151
|
export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Seat | null {
|
|
@@ -150,7 +156,7 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
|
|
|
150
156
|
for (const a of idx.accounts) dirty = foldTee(a, bars) || dirty;
|
|
151
157
|
if (dirty) saveAccounts(claudePool, idx);
|
|
152
158
|
const seats = presence();
|
|
153
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
|
|
159
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats, planWeight: claudePlanWeight };
|
|
154
160
|
const candidates = idx.accounts.filter(eligible);
|
|
155
161
|
const best = pickBest(candidates, ctx);
|
|
156
162
|
if (best) return { account: best };
|
|
@@ -204,7 +210,7 @@ export async function borrowClaudeSeat(pid: number, named?: string): Promise<Sea
|
|
|
204
210
|
}
|
|
205
211
|
const open = idx.accounts.filter((a) => a.needsReauth !== true);
|
|
206
212
|
const usable = await Promise.all(open.map(storeUsable));
|
|
207
|
-
const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living) };
|
|
213
|
+
const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living), planWeight: claudePlanWeight };
|
|
208
214
|
const picked = pickBest(open.filter((_, i) => usable[i]), ctx);
|
|
209
215
|
if (!picked) return null;
|
|
210
216
|
writePresence({ dir: paths.presenceDir, id: seatId, accountId: picked.id, pid });
|
|
@@ -361,6 +367,7 @@ export const claude: Provider = {
|
|
|
361
367
|
apiKeys: claudeApiKeys,
|
|
362
368
|
liveId,
|
|
363
369
|
presence,
|
|
370
|
+
planWeight: claudePlanWeight,
|
|
364
371
|
gatedFamilies: (cfg) => {
|
|
365
372
|
const seat = liveId();
|
|
366
373
|
const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
|
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/codex.ts
CHANGED
|
@@ -276,8 +276,31 @@ function install(): void {
|
|
|
276
276
|
console.log(c.yellow("`tokenmaxxing doctor` lists seats still needing trust."));
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// Relative to Plus. prolite (Pro 100) is 5x per OpenAI's launch; pro (Pro 200) was 20x and is about 10x for new
|
|
280
|
+
// subscribers; promax (Pro 500) is unpublished beyond "the most". Business, Enterprise, and Edu seats match Plus.
|
|
281
|
+
const CODEX_PLAN_WEIGHTS: Record<string, number> = {
|
|
282
|
+
free: 0.2,
|
|
283
|
+
go: 0.5,
|
|
284
|
+
plus: 1,
|
|
285
|
+
team: 1,
|
|
286
|
+
business: 1,
|
|
287
|
+
enterprise: 1,
|
|
288
|
+
ent26: 1,
|
|
289
|
+
edu: 1,
|
|
290
|
+
edu_plus: 1,
|
|
291
|
+
prolite: 5,
|
|
292
|
+
self_serve_business_prolite: 5,
|
|
293
|
+
edu_pro: 5,
|
|
294
|
+
pro: 10,
|
|
295
|
+
promax: 20,
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export function codexPlanWeight(a: Account): number {
|
|
299
|
+
return (a.tier == null ? undefined : CODEX_PLAN_WEIGHTS[a.tier.toLowerCase()]) ?? 1;
|
|
300
|
+
}
|
|
301
|
+
|
|
279
302
|
export function codexPickCtx(now: number, currentId: string | null): PickCtx {
|
|
280
|
-
return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
|
|
303
|
+
return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null, planWeight: codexPlanWeight };
|
|
281
304
|
}
|
|
282
305
|
|
|
283
306
|
export function pickCodexSeat(now: number, eligible: (a: Account) => boolean = (a) => codexStoreUsable(a.id)): Account | null {
|
|
@@ -343,6 +366,7 @@ export const codex: Provider = {
|
|
|
343
366
|
statusOnly: false,
|
|
344
367
|
liveId,
|
|
345
368
|
presence,
|
|
369
|
+
planWeight: codexPlanWeight,
|
|
346
370
|
gatedFamilies: () => null,
|
|
347
371
|
observeLive: (account, cfg, now, opts) => observeCodex(account, cfg, now, { probe: opts.probe, refresh: true }),
|
|
348
372
|
samplePool,
|
package/src/lib/decide.ts
CHANGED
|
@@ -199,7 +199,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
199
199
|
while (true) {
|
|
200
200
|
const cur = loadAccounts(p.pool);
|
|
201
201
|
const seat = seatOf(cur);
|
|
202
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
|
|
202
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
|
|
203
203
|
const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
|
|
204
204
|
const best = pickBest(usable(cur.accounts).filter((a) => !((credited || keyId != null) && opts.refused?.includes(a.id))), ctx);
|
|
205
205
|
if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
|
|
@@ -232,7 +232,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
232
232
|
while (true) {
|
|
233
233
|
const fresh = loadAccounts(p.pool);
|
|
234
234
|
const current = seatOf(fresh);
|
|
235
|
-
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
|
|
235
|
+
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
|
|
236
236
|
const others = liveWaitClaims(now).filter((c) => c.sessionId !== waiterId);
|
|
237
237
|
const waiters = new Map(Object.entries(countBy(others, (c) => c.accountId)));
|
|
238
238
|
const waitable = usable(fresh.accounts).filter((a) => keyId == null || !opts.refused?.includes(a.id));
|
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/picker.ts
CHANGED
|
@@ -17,6 +17,7 @@ export type PickCtx = {
|
|
|
17
17
|
currentId: string | null;
|
|
18
18
|
families: string[] | null;
|
|
19
19
|
seats: Map<string, number> | null;
|
|
20
|
+
planWeight?: (a: Account) => number;
|
|
20
21
|
};
|
|
21
22
|
|
|
22
23
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
@@ -166,29 +167,58 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
|
|
|
166
167
|
|
|
167
168
|
const MIN_SEAT_HEADROOM = 5;
|
|
168
169
|
|
|
170
|
+
const planWeight = (a: Account, ctx: PickCtx): number => ctx.planWeight?.(a) ?? 1;
|
|
171
|
+
|
|
169
172
|
function seatFull(a: Account, ctx: PickCtx): boolean {
|
|
170
173
|
const session = sessionWindow(a);
|
|
171
174
|
if (ctx.seats == null || session == null || spendsCredits(a, ctx.thresholds)) return false;
|
|
172
|
-
return seatHeadroom(a, ctx) < MIN_SEAT_HEADROOM;
|
|
175
|
+
return seatHeadroom(a, ctx) * planWeight(a, ctx) < MIN_SEAT_HEADROOM;
|
|
173
176
|
}
|
|
174
177
|
|
|
175
178
|
export function onCredits(a: Account, ctx: PickCtx): boolean {
|
|
176
179
|
return spendsCredits(a, ctx.thresholds) && isExhausted({ ...a, hasCredits: false }, ctx);
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
183
|
+
const WEEKLY_SOON_MS = 4 * DAY_MS;
|
|
184
|
+
const THREAD_HEADROOM = 15;
|
|
185
|
+
const SESSION_SOON_MS = 60 * 60 * 1000;
|
|
186
|
+
const STALE_MS = 6 * 60 * 60 * 1000;
|
|
187
|
+
|
|
188
|
+
function cramped(a: Account, ctx: PickCtx): boolean {
|
|
189
|
+
const session = sessionWindow(a);
|
|
190
|
+
if (session == null || spendsCredits(a, ctx.thresholds)) return false;
|
|
191
|
+
const end = windowEnd(session);
|
|
192
|
+
const resetsSoon = end > ctx.now && end - ctx.now <= SESSION_SOON_MS;
|
|
193
|
+
return !resetsSoon && seatHeadroom(a, ctx) * planWeight(a, ctx) < THREAD_HEADROOM;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Judged per window, because a fresh statusline sample refreshes the session and weekly figures but carries an old per-model cap forward.
|
|
197
|
+
function stale(a: Account, ctx: PickCtx): boolean {
|
|
198
|
+
const read = [sessionWindow(a), weeklyWindow(a), ...gatedWindows(a, ctx.families)];
|
|
199
|
+
return read.some((w) => w != null && windowEnd(w) > ctx.now && ctx.now - w.sampledAt > STALE_MS);
|
|
200
|
+
}
|
|
201
|
+
|
|
179
202
|
const swapPreference = (ctx: PickCtx) => [
|
|
180
203
|
(a: Account) => (onCredits(a, ctx) ? 1 : 0),
|
|
181
|
-
|
|
182
|
-
(a: Account) =>
|
|
204
|
+
(a: Account) => ((a.storeFails ?? 0) > 0 ? 1 : 0),
|
|
205
|
+
(a: Account) => (a.windows.length === 0 ? 1 : 0),
|
|
206
|
+
// A full account ranks after every open one, and among full accounts the most room per session wins.
|
|
207
|
+
(a: Account) => (seatFull(a, ctx) ? 1 : 0),
|
|
208
|
+
(a: Account) => (seatFull(a, ctx) ? -seatHeadroom(a, ctx) * planWeight(a, ctx) : 0),
|
|
209
|
+
(a: Account) => (cramped(a, ctx) ? 1 : 0),
|
|
210
|
+
(a: Account) => (weeklyExpiry(a, ctx.now) - ctx.now <= WEEKLY_SOON_MS ? 0 : 1),
|
|
211
|
+
(a: Account) => (stale(a, ctx) ? 1 : 0),
|
|
212
|
+
(a: Account) => -pacePressure(a, ctx) * planWeight(a, ctx),
|
|
183
213
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
214
|
+
...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx) * planWeight(a, ctx)]),
|
|
184
215
|
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
185
216
|
...(ctx.seats == null ? [] : [(a: Account) => ctx.seats?.get(a.id) ?? 0]),
|
|
186
217
|
];
|
|
187
218
|
|
|
188
219
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
189
220
|
const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
|
|
190
|
-
|
|
191
|
-
return sortBy(open.length > 0 ? open : usable, swapPreference(ctx))[0] ?? null;
|
|
221
|
+
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
192
222
|
}
|
|
193
223
|
|
|
194
224
|
export function usableAt(a: Account, ctx: PickCtx): number {
|
package/src/lib/provider.ts
CHANGED
|
@@ -36,6 +36,7 @@ export type Provider = {
|
|
|
36
36
|
liveId(): string | null;
|
|
37
37
|
presence(): Map<string, number>;
|
|
38
38
|
gatedFamilies(cfg: Config): string[] | null;
|
|
39
|
+
planWeight?(account: Account): number;
|
|
39
40
|
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
|
|
40
41
|
samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>>;
|
|
41
42
|
mergeWindows(next: Window[], prev: Window[]): Window[];
|
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
|
|