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