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
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
4
|
+
import { http, safeErrorDetail } from "./http.ts";
|
|
5
|
+
import { errorMessage } from "./log.ts";
|
|
6
|
+
import { cursorAuthJsonFor, cursorPaths, cursorPool, cursorStoreDirFor, env } from "./paths.ts";
|
|
7
|
+
import type { SampleReport } from "./provider.ts";
|
|
8
|
+
import { readJsonFile, type Harvest } from "./state.ts";
|
|
9
|
+
import { statusOnlyProvider, type AuthEntry } from "./statusonly.ts";
|
|
10
|
+
import { ErrnoSchema, JsonTextSchema, type Account, type Window } from "./types.ts";
|
|
11
|
+
import { c } from "../cli/render.ts";
|
|
12
|
+
|
|
13
|
+
const CursorAuthSchema = z.looseObject({ accessToken: z.string().optional() });
|
|
14
|
+
type CursorAuth = z.infer<typeof CursorAuthSchema>;
|
|
15
|
+
|
|
16
|
+
const CliConfigSchema = z.looseObject({
|
|
17
|
+
authInfo: z.looseObject({ authId: z.string().optional(), email: z.string().nullish() }).optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const JwtPayloadSchema = z.looseObject({ sub: z.string().min(1) });
|
|
21
|
+
|
|
22
|
+
function readOptional<T>(path: string, schema: z.ZodType<T>): T | null {
|
|
23
|
+
try {
|
|
24
|
+
return readJsonFile(path, schema);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tokenSubject(token: string): string | null {
|
|
32
|
+
const payload = token.split(".")[1] ?? "";
|
|
33
|
+
return JwtPayloadSchema.safeParse(JsonTextSchema.safeParse(Buffer.from(payload, "base64url").toString("utf8")).data).data?.sub ?? null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The CLI caches the signed-in user next to auth.json; it is the only local copy of the email, because the token carries none.
|
|
37
|
+
function cachedEmail(authPath: string, subject: string): string | null {
|
|
38
|
+
let config: z.infer<typeof CliConfigSchema> | null;
|
|
39
|
+
try {
|
|
40
|
+
config = readOptional(join(dirname(authPath), "cli-config.json"), CliConfigSchema);
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
const info = config?.authInfo;
|
|
45
|
+
if (info?.authId !== subject) return null;
|
|
46
|
+
const email = info.email?.trim().toLowerCase() ?? "";
|
|
47
|
+
return email === "" ? null : email;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const harvestTokens = new WeakMap<Harvest, string>();
|
|
51
|
+
|
|
52
|
+
function readAuth(path: string): AuthEntry[] {
|
|
53
|
+
const file: CursorAuth | null = readOptional(path, CursorAuthSchema);
|
|
54
|
+
const token = file?.accessToken ?? "";
|
|
55
|
+
if (file == null || token === "") return [];
|
|
56
|
+
const subject = tokenSubject(token);
|
|
57
|
+
if (subject == null) return [{ id: "", usable: false, harvest: null }];
|
|
58
|
+
const id = new Bun.CryptoHasher("sha256").update(subject).digest("hex");
|
|
59
|
+
const harvest: Harvest = {
|
|
60
|
+
id,
|
|
61
|
+
email: cachedEmail(path, subject),
|
|
62
|
+
tier: null,
|
|
63
|
+
sample: null,
|
|
64
|
+
park: async () => {
|
|
65
|
+
writeFileAtomic(cursorAuthJsonFor(id), JSON.stringify(file, null, 2), 0o600);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
harvestTokens.set(harvest, token);
|
|
69
|
+
return [{ id, usable: true, harvest }];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const EpochMsSchema = z.coerce.number();
|
|
73
|
+
|
|
74
|
+
const PeriodUsageSchema = z.looseObject({
|
|
75
|
+
billingCycleStart: EpochMsSchema,
|
|
76
|
+
billingCycleEnd: EpochMsSchema,
|
|
77
|
+
planUsage: z
|
|
78
|
+
.looseObject({
|
|
79
|
+
includedSpend: z.number().default(0),
|
|
80
|
+
limit: z.number().default(0),
|
|
81
|
+
totalPercentUsed: z.number().optional(),
|
|
82
|
+
apiPercentUsed: z.number().optional(),
|
|
83
|
+
})
|
|
84
|
+
.optional(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const PlanInfoSchema = z.looseObject({ planInfo: z.looseObject({ planName: z.string().optional() }).optional() });
|
|
88
|
+
|
|
89
|
+
const MeSchema = z.looseObject({ email: z.string().optional() });
|
|
90
|
+
|
|
91
|
+
function dashboardUrl(method: string): string {
|
|
92
|
+
return `${env("TOKENMAXXING_CURSOR_API_URL", "https://api2.cursor.sh")}/aiserver.v1.DashboardService/${method}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function dashboard(token: string, method: string): Promise<Response> {
|
|
96
|
+
return http.post(dashboardUrl(method), { headers: { Authorization: `Bearer ${token}`, "Connect-Protocol-Version": "1" }, json: {} });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function dashboardField<T>(token: string, method: string, schema: z.ZodType<T>): Promise<T | null> {
|
|
100
|
+
try {
|
|
101
|
+
const res = await dashboard(token, method);
|
|
102
|
+
return res.ok ? (schema.safeParse(JsonTextSchema.safeParse(await res.text()).data).data ?? null) : null;
|
|
103
|
+
} catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function readCursorUsage(account: Account, now: number): Promise<SampleReport> {
|
|
109
|
+
let token: string;
|
|
110
|
+
try {
|
|
111
|
+
token = readOptional(cursorAuthJsonFor(account.id), CursorAuthSchema)?.accessToken ?? "";
|
|
112
|
+
} catch (e) {
|
|
113
|
+
return { ok: false, reason: `this account's store is unreadable (${errorMessage(e)}) - run \`tokenmaxxing auth --cursor\`` };
|
|
114
|
+
}
|
|
115
|
+
if (token === "") return { ok: false, reason: "no accessToken in this account's store - run `tokenmaxxing auth --cursor`" };
|
|
116
|
+
let res: Response;
|
|
117
|
+
let text: string;
|
|
118
|
+
const plan = dashboardField(token, "GetPlanInfo", PlanInfoSchema);
|
|
119
|
+
const me = account.email == null ? dashboardField(token, "GetMe", MeSchema) : null;
|
|
120
|
+
try {
|
|
121
|
+
res = await dashboard(token, "GetCurrentPeriodUsage");
|
|
122
|
+
text = await res.text();
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, reason: `Cursor usage read failed: endpoint unreachable: ${errorMessage(e)}` };
|
|
125
|
+
}
|
|
126
|
+
if (res.status === 401 || res.status === 403) {
|
|
127
|
+
account.needsReauth = true;
|
|
128
|
+
return { ok: false, reason: `Cursor rejected the stored login (HTTP ${res.status}) - run \`tokenmaxxing auth --cursor\`` };
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return { ok: false, reason: `Cursor usage read failed: HTTP ${res.status}: ${safeErrorDetail({ text })}` };
|
|
131
|
+
const parsed = PeriodUsageSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
132
|
+
if (!parsed.success) return { ok: false, reason: "Cursor usage read failed: endpoint returned an unexpected body shape (withheld)" };
|
|
133
|
+
const { billingCycleStart, billingCycleEnd, planUsage } = parsed.data;
|
|
134
|
+
const total = planUsage?.totalPercentUsed ?? (planUsage != null && planUsage.limit > 0 ? Math.min(100, (planUsage.includedSpend / planUsage.limit) * 100) : null);
|
|
135
|
+
if (total == null) return { ok: false, reason: "Cursor reports no included-usage limit for this account" };
|
|
136
|
+
const cycle = { resetsAt: billingCycleEnd, windowSeconds: billingCycleEnd > billingCycleStart ? Math.round((billingCycleEnd - billingCycleStart) / 1000) : null, sampledAt: now };
|
|
137
|
+
const windows: Window[] = [{ name: null, usedPercentage: total, ...cycle }];
|
|
138
|
+
if (planUsage?.apiPercentUsed != null) windows.push({ name: "API", usedPercentage: planUsage.apiPercentUsed, ...cycle });
|
|
139
|
+
account.windows = windows;
|
|
140
|
+
account.lastUsageAt = now;
|
|
141
|
+
const planName = (await plan)?.planInfo?.planName?.trim().toLowerCase();
|
|
142
|
+
if (planName) account.tier = planName;
|
|
143
|
+
const email = (await me)?.email?.trim().toLowerCase();
|
|
144
|
+
if (email) account.email = email;
|
|
145
|
+
return { ok: true, source: "probe" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function identify(harvest: Harvest): Promise<void> {
|
|
149
|
+
const token = harvestTokens.get(harvest);
|
|
150
|
+
if (harvest.email != null || token == null) return;
|
|
151
|
+
const email = (await dashboardField(token, "GetMe", MeSchema))?.email?.trim().toLowerCase();
|
|
152
|
+
if (email) harvest.email = email;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>> {
|
|
156
|
+
const reports = new Map<string, SampleReport>();
|
|
157
|
+
await Promise.all(accounts.map(async (account) => reports.set(account.id, await readCursorUsage(account, now))));
|
|
158
|
+
return reports;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export const cursor = statusOnlyProvider({
|
|
162
|
+
name: "cursor",
|
|
163
|
+
flag: " --cursor",
|
|
164
|
+
pool: cursorPool,
|
|
165
|
+
binName: "cursor-agent",
|
|
166
|
+
binKey: "cursorBin",
|
|
167
|
+
storeDirFor: cursorStoreDirFor,
|
|
168
|
+
authJsonFor: cursorAuthJsonFor,
|
|
169
|
+
homeEnv: "XDG_CONFIG_HOME",
|
|
170
|
+
loginArgs: ["login"],
|
|
171
|
+
onboardAuthRel: "cursor/auth.json",
|
|
172
|
+
liveAuthPath: () => cursorPaths.authJson,
|
|
173
|
+
readAuth,
|
|
174
|
+
liveId: () => null,
|
|
175
|
+
presence: () => new Map(),
|
|
176
|
+
versionOk: (out) => /^\d{4}\.\d{2}\.\d{2}-[0-9a-f]+$/.test(out.trim()),
|
|
177
|
+
importIntro: "Opening an isolated Cursor login for your first pooled account - the login you already have stays as it is.",
|
|
178
|
+
foundLive: () => `found the Cursor login in ${cursorPaths.authJson} - pooling it; use \`tokenmaxxing add --cursor\` for more.`,
|
|
179
|
+
install: () => {
|
|
180
|
+
console.log(c.dim("cursor pool is status-only: no supervisor or hooks installed. Use `tokenmaxxing status` to view pooled Cursor accounts."));
|
|
181
|
+
},
|
|
182
|
+
loginStep: () => `Approve the login in the browser that ${c.bold("cursor-agent login")} opens.`,
|
|
183
|
+
samplePool,
|
|
184
|
+
identify,
|
|
185
|
+
});
|
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/onboard.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { join } from "node:path";
|
|
|
3
3
|
import { paths } from "./paths.ts";
|
|
4
4
|
import { pidExists } from "./proc.ts";
|
|
5
5
|
|
|
6
|
-
const POOLS = ["claude", "codex", "pi", "grok", "muse"] as const;
|
|
6
|
+
const POOLS = ["claude", "codex", "pi", "grok", "muse", "cursor"] as const;
|
|
7
7
|
|
|
8
8
|
export type OnboardPool = (typeof POOLS)[number];
|
|
9
9
|
|
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();
|
|
@@ -63,6 +63,11 @@ export const musePool = {
|
|
|
63
63
|
lockFile: join(TM_HOME, "muse-lock"),
|
|
64
64
|
} as const;
|
|
65
65
|
|
|
66
|
+
export const cursorPool = {
|
|
67
|
+
accountsJson: join(TM_HOME, "cursor-accounts.json"),
|
|
68
|
+
lockFile: join(TM_HOME, "cursor-lock"),
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
66
71
|
export type PoolPaths = { accountsJson: string; lockFile: string };
|
|
67
72
|
|
|
68
73
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
@@ -124,17 +129,54 @@ export function grokAuthJsonFor(accountId: string): string {
|
|
|
124
129
|
return join(grokStoreDirFor(accountId), "auth.json");
|
|
125
130
|
}
|
|
126
131
|
|
|
132
|
+
const MUSE_STORES_DIR = join(TM_HOME, "muse-stores");
|
|
133
|
+
const XDG_CONFIG = optionalEnv("XDG_CONFIG_HOME");
|
|
134
|
+
export const MUSE_CONFIG_HOME_ENV = "TOKENMAXXING_XDG_CONFIG_HOME";
|
|
135
|
+
|
|
136
|
+
export function inMuseStores(path: string): boolean {
|
|
137
|
+
return path === MUSE_STORES_DIR || path.startsWith(MUSE_STORES_DIR + sep);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Inside a seat XDG_CONFIG_HOME names a store, and the seat carries the real config home it mirrors in
|
|
141
|
+
// MUSE_CONFIG_HOME_ENV; a store named with no such record (one rm --muse kept) falls back to ~/.config.
|
|
142
|
+
const MUSE_CONFIG_HOME =
|
|
143
|
+
XDG_CONFIG == null ? join(HOME, ".config") : !inMuseStores(XDG_CONFIG) ? XDG_CONFIG : (optionalEnv(MUSE_CONFIG_HOME_ENV) ?? join(HOME, ".config"));
|
|
144
|
+
|
|
127
145
|
export const musePaths = {
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
configHome: MUSE_CONFIG_HOME,
|
|
147
|
+
authJson: join(MUSE_CONFIG_HOME, "muse", "auth.json"),
|
|
148
|
+
sessionsDir: join(env("XDG_DATA_HOME", join(HOME, ".local", "share")), "muse", "sessions"),
|
|
149
|
+
storesDir: MUSE_STORES_DIR,
|
|
150
|
+
presenceDir: join(TM_HOME, "muse-live"),
|
|
130
151
|
} as const;
|
|
131
152
|
|
|
153
|
+
// A store is the XDG_CONFIG_HOME of the sessions seated on its account: Muse Code reads its login only from
|
|
154
|
+
// $XDG_CONFIG_HOME/muse/auth.json.
|
|
132
155
|
export function museStoreDirFor(accountId: string): string {
|
|
133
156
|
return join(musePaths.storesDir, shortId(accountId));
|
|
134
157
|
}
|
|
135
158
|
|
|
136
159
|
export function museAuthJsonFor(accountId: string): string {
|
|
137
|
-
return join(museStoreDirFor(accountId), "auth.json");
|
|
160
|
+
return join(museStoreDirFor(accountId), "muse", "auth.json");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function museSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
|
|
164
|
+
const home = env.XDG_CONFIG_HOME;
|
|
165
|
+
if (home == null || home === "") return null;
|
|
166
|
+
return accountIds.find((id) => museStoreDirFor(id) === home) ?? null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export const cursorPaths = {
|
|
170
|
+
authJson: join(env("XDG_CONFIG_HOME", join(HOME, ".config")), "cursor", "auth.json"),
|
|
171
|
+
storesDir: join(TM_HOME, "cursor-stores"),
|
|
172
|
+
} as const;
|
|
173
|
+
|
|
174
|
+
export function cursorStoreDirFor(accountId: string): string {
|
|
175
|
+
return join(cursorPaths.storesDir, shortId(accountId));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function cursorAuthJsonFor(accountId: string): string {
|
|
179
|
+
return join(cursorStoreDirFor(accountId), "auth.json");
|
|
138
180
|
}
|
|
139
181
|
|
|
140
182
|
export function grokSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
|
package/src/lib/provider.ts
CHANGED