tokenmaxxing 1.39.0 → 1.40.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.
@@ -1,12 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
2
2
  import { basename, join } from "node:path";
3
+ import type { Subprocess } from "bun";
3
4
  import { maxBy } from "es-toolkit";
4
5
  import { z } from "zod";
5
- import { paths } from "../lib/paths.ts";
6
+ import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
6
7
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
8
+ import { pickSeat } from "../lib/claude.ts";
9
+ import { withLock } from "../lib/lock.ts";
10
+ import { clearPresence, writePresence } from "../lib/presence.ts";
7
11
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
8
12
  import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
9
- import { RespawnMarkerSchema } from "../lib/types.ts";
13
+ import { loadAccounts } from "../lib/state.ts";
14
+ import { RespawnMarkerSchema, type Account } from "../lib/types.ts";
10
15
  import { log } from "../lib/log.ts";
11
16
 
12
17
  const NONINTERACTIVE_SUBCMDS = new Set([
@@ -187,6 +192,25 @@ async function countdownWait(acct: string, until: number): Promise<boolean> {
187
192
  return aborted;
188
193
  }
189
194
 
195
+ async function recordPresence(child: Subprocess, sid: string, seat: Account, savedTermios: string | null): Promise<void> {
196
+ for (let attempt = 0; ; attempt++) {
197
+ try {
198
+ writePresence({ dir: paths.presenceDir, id: sid, accountId: seat.id, pid: child.pid });
199
+ return;
200
+ } catch (e) {
201
+ if (child.exitCode !== null || child.signalCode !== null) return;
202
+ if (attempt >= 9) {
203
+ log("supervisor.presence_failed", { err: e instanceof Error ? e.message : String(e) });
204
+ child.kill();
205
+ await child.exited;
206
+ restoreTermios(savedTermios);
207
+ throw new Error("could not write the session presence file - refusing to run a session whose seat placement cannot see");
208
+ }
209
+ await Bun.sleep(100);
210
+ }
211
+ }
212
+ }
213
+
190
214
  export async function runSupervisor(argv: string[]): Promise<number> {
191
215
  const depth = wrapDepth();
192
216
  if (depth >= MAX_WRAP_DEPTH) {
@@ -250,16 +274,28 @@ export async function runSupervisor(argv: string[]): Promise<number> {
250
274
 
251
275
  let respawns = 0;
252
276
  let overriddenUntil = 0;
277
+ let wanted: string | null = null;
253
278
  while (true) {
254
279
  if (existsSync(marker)) rmSync(marker, { force: true });
255
- log("supervisor.launch", { sid, respawns, args: launchArgs.join(" ") });
256
280
 
257
281
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
258
- const child = Bun.spawn([real, ...launchArgs], {
259
- stdin: "inherit",
260
- stdout: "inherit",
261
- stderr: "inherit",
262
- env: { ...childEnv, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid, TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt) },
282
+ const child = await withLock(claudePool.lockFile, async () => {
283
+ const seat = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
284
+ log("supervisor.launch", { sid, respawns, seat: seat?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
285
+ const spawned = Bun.spawn([real, ...launchArgs], {
286
+ stdin: "inherit",
287
+ stdout: "inherit",
288
+ stderr: "inherit",
289
+ env: {
290
+ ...childEnv,
291
+ TOKENMAXXING_SUPERVISED: "1",
292
+ TOKENMAXXING_SESSION_ID: sid,
293
+ TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
294
+ ...(seat ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(seat.id) } : {}),
295
+ },
296
+ });
297
+ if (seat) await recordPresence(spawned, sid, seat, savedTermios);
298
+ return spawned;
263
299
  });
264
300
 
265
301
  let done = false;
@@ -276,6 +312,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
276
312
  if (winner === "marker") child.kill();
277
313
  } catch (e) {
278
314
  child.kill();
315
+ clearPresence({ dir: paths.presenceDir, id: sid });
279
316
  throw e;
280
317
  } finally {
281
318
  await child.exited;
@@ -288,13 +325,16 @@ export async function runSupervisor(argv: string[]): Promise<number> {
288
325
  if (m) {
289
326
  rmSync(marker, { force: true });
290
327
  respawns++;
328
+ const label = loadAccounts(claudePool).accounts.find((a) => a.id === m.accountId)?.label ?? m.accountId.slice(0, 8);
291
329
  if (m.waitUntil > Date.now()) {
292
- if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
293
- } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
330
+ if (await countdownWait(label, m.waitUntil)) overriddenUntil = m.waitUntil;
331
+ } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`);
332
+ wanted = m.accountId;
294
333
  saveSessionFlags(m.sessionId, persistable, process.cwd());
295
334
  launchArgs = ["--resume", m.sessionId, ...persistable];
296
335
  continue;
297
336
  }
337
+ clearPresence({ dir: paths.presenceDir, id: sid });
298
338
  log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
299
339
  return child.exitCode ?? (child.signalCode ? 1 : 0);
300
340
  }
package/src/lib/claude.ts CHANGED
@@ -1,126 +1,130 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
4
+ import { readItem, writeItem, deleteItem, isolatedTarget, readStore, storeTarget, claudeAiOauthOnly } from "./credstore.ts";
5
5
  import { resolveRealClaude, resolveVerifiedClaude } from "./claudebin.ts";
6
6
  import { withClaudeRefreshLock } from "./claudelock.ts";
7
- import { isApiKeyMode, readOAuthAccount } from "./claudejson.ts";
8
7
  import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath, timerActivationHint } from "./install.ts";
9
8
  import { withLock } from "./lock.ts";
10
9
  import { log } from "./log.ts";
11
- import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isAccessTokenExpiring, isDeadCredential, refreshCredential, InvalidGrantError } from "./oauth.ts";
12
- import { claudePool, credItemFor, paths } from "./paths.ts";
10
+ import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
11
+ import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
12
+ import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
+ import { seatCounts } from "./presence.ts";
13
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
14
- import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage } from "./sample.ts";
15
- import { loadAccounts, loadUsage, loadUsageSnapshot, pinBinOverride, saveAccounts, writeUsage, type Harvest } from "./state.ts";
16
- import { isSkippableSwapError, performSwap } from "./swap.ts";
15
+ import { foldTee, probeAccountUsage, teeObservation } from "./sample.ts";
16
+ import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
- import { CRED_ENV_OVERRIDES, familyTokens, gatedFamilies, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
20
- import { CredentialBlobSchema, OAuthAccountSchema, type Account, type Config, type UsageState } from "./types.ts";
19
+ import { CRED_ENV_OVERRIDES, gatedFamilies, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
20
+ import { CredentialBlobSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
21
21
  import { c } from "../cli/render.ts";
22
22
 
23
- function teeObservation(account: Account, snap: { state: UsageState; at: number } | null): Observation | null {
24
- if (!snap || snap.state.account !== account.id) return null;
25
- if (account.lastUsageAt != null && snap.at < account.lastUsageAt) return { windows: account.windows, at: account.lastUsageAt };
26
- const at = snap.state.sampledAt ?? snap.at;
27
- const aggregate = windowsOf({ fiveHour: snap.state.fiveHour, sevenDay: snap.state.sevenDay, perModel: {} }, at);
28
- return { windows: mergeWindows(aggregate, account.windows), at };
23
+ class StoreUnusableError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "StoreUnusableError";
27
+ }
28
+ }
29
+
30
+ function liveId(): string | null {
31
+ return seatFromEnv(loadAccounts(claudePool).accounts.map((a) => a.id));
32
+ }
33
+
34
+ function presence(): Map<string, number> {
35
+ return seatCounts(paths.presenceDir);
29
36
  }
30
37
 
31
38
  async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
32
- let snap = loadUsageSnapshot();
33
39
  const ttl = cfg.policy.usagePollTtlMs;
34
40
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= ttl;
35
- const fresh = snap != null && snap.state.account === account.id && now - snap.at <= ttl;
41
+ const snap = loadUsageSnapshot(account.id);
42
+ const fresh = snap != null && now - snap.at <= ttl;
36
43
  const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
37
44
  if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
38
45
  const startedAt = Date.now();
39
- const full = await probeUsage();
40
- const ts = Date.now();
41
- if (readOAuthAccount()?.accountUuid === account.id) {
42
- if (full) {
43
- const teed = loadUsageSnapshot();
44
- if (teed && teed.state.account === account.id && ts - teed.at <= ttl) {
45
- snap = teed;
46
- } else {
47
- const state: UsageState = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account: account.id, ts, model: null };
48
- writeUsage(state);
49
- snap = { state, at: ts };
50
- }
51
- const expected = gatedFamilies(snap.state.model, cfg.policy.switchModels);
52
- const rows = Object.keys(full.perModel);
53
- if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
54
- log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
55
- }
46
+ const outcome = await probeAccountUsage(account);
47
+ await withLock(claudePool.lockFile, () => {
48
+ const idx = loadAccounts(claudePool);
49
+ const a = idx.accounts.find((x) => x.id === account.id);
50
+ if (!a) return;
51
+ a.lastProbeAt = startedAt;
52
+ a.tier = account.tier;
53
+ if (account.needsReauth) a.needsReauth = true;
54
+ if (outcome.ok && (a.lastUsageAt == null || startedAt > a.lastUsageAt)) {
55
+ a.windows = mergeWindows(windowsOf(outcome.usage, startedAt), a.windows);
56
+ a.lastUsageAt = startedAt;
56
57
  }
57
- await withLock(claudePool.lockFile, () => {
58
- const idx = loadAccounts(claudePool);
59
- const a = idx.accounts.find((x) => x.id === account.id);
60
- if (!a) return;
61
- a.lastProbeAt = startedAt;
62
- if (full) {
63
- const probed = windowsOf(full, startedAt);
64
- const aggregate = a.windows.some((w) => w.name == null) ? a.windows.filter((w) => w.name == null) : probed.filter((w) => w.name == null);
65
- if (a.lastUsageAt == null) a.lastUsageAt = startedAt;
66
- a.windows = mergeWindows([...aggregate, ...probed.filter((w) => w.name != null)], a.windows);
67
- }
68
- saveAccounts(claudePool, idx);
69
- });
70
- }
58
+ saveAccounts(claudePool, idx);
59
+ });
71
60
  }
72
61
  const current = loadAccounts(claudePool).accounts.find((a) => a.id === account.id) ?? account;
73
- return teeObservation(current, snap);
62
+ return teeObservation(current);
74
63
  }
75
64
 
76
- async function samplePool(accounts: Account[], liveId: string | null): Promise<Map<string, SampleReport>> {
65
+ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport>> {
77
66
  const reports = new Map<string, SampleReport>();
78
- const tee = loadUsageSnapshot();
79
- const probeOne = async (a: Account): Promise<void> => {
80
- const isActive = liveId != null && liveId === a.id;
81
- const teeCurrent = tee != null && (a.lastUsageAt == null || tee.at >= a.lastUsageAt);
82
- if (isActive && tee && teeCurrent && tee.state.account === a.id) {
83
- const at = tee.state.sampledAt ?? tee.at;
67
+ await Promise.all(
68
+ accounts.map(async (a) => {
69
+ const tee = loadUsageSnapshot(a.id);
70
+ const teeAt = tee == null ? null : (tee.state.sampledAt ?? tee.state.ts);
71
+ if (teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt)) {
72
+ foldTee(a);
73
+ reports.set(a.id, { ok: true, source: "statusline" });
74
+ return;
75
+ }
76
+ const outcome = await probeAccountUsage(a);
77
+ if (!outcome.ok) {
78
+ reports.set(a.id, { ok: false, reason: outcome.reason });
79
+ return;
80
+ }
81
+ const at = Date.now();
84
82
  a.lastUsageAt = at;
85
- a.windows = mergeWindows(windowsOf({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, at), a.windows);
86
- reports.set(a.id, { ok: true, source: "statusline" });
87
- return;
88
- }
89
- const outcome = isActive ? await probeActiveUsage(a) : await probeParkedUsage(a);
90
- if (!outcome.ok) {
91
- reports.set(a.id, { ok: false, reason: outcome.reason });
92
- return;
93
- }
94
- const at = Date.now();
95
- a.lastUsageAt = at;
96
- a.windows = mergeWindows(windowsOf(outcome.usage, at), a.windows);
97
- reports.set(a.id, { ok: true, source: "probe" });
98
- };
99
- const active = accounts.find((a) => liveId != null && liveId === a.id) ?? null;
100
- if (active) await probeOne(active);
83
+ a.windows = mergeWindows(windowsOf(outcome.usage, at), a.windows);
84
+ reports.set(a.id, { ok: true, source: "probe" });
85
+ }),
86
+ );
87
+ return reports;
88
+ }
89
+
90
+ async function prepareMove(target: Account): Promise<void> {
91
+ let creds;
101
92
  try {
102
- await ensureLiveTokenFresh();
103
- } catch {
93
+ creds = await readStore(target.id);
94
+ } catch (e) {
95
+ throw new StoreUnusableError(`${target.label}'s store is unreadable (${e instanceof Error ? e.message : String(e)}) - re-auth with \`tokenmaxxing auth ${target.label}\``);
104
96
  }
105
- await Promise.all(accounts.filter((a) => a !== active).map(probeOne));
106
- return reports;
97
+ if (!creds) throw new StoreUnusableError(`${target.label} has no credential in its store - re-auth with \`tokenmaxxing auth ${target.label}\``);
98
+ if (isDeadCredential(creds)) {
99
+ const idx = loadAccounts(claudePool);
100
+ const t = idx.accounts.find((a) => a.id === target.id);
101
+ if (t) {
102
+ t.needsReauth = true;
103
+ saveAccounts(claudePool, idx);
104
+ }
105
+ log("move.invalid_grant", { account: target.id.slice(0, 8) });
106
+ throw new InvalidGrantError(`${target.label}'s store was cleared after a failed refresh - re-auth with \`tokenmaxxing auth ${target.label}\``);
107
+ }
108
+ log("move.prepared", { account: target.id.slice(0, 8), label: target.label });
107
109
  }
108
110
 
109
- async function liveOwner(): Promise<string | null> {
110
- const live = await readItem(liveTarget());
111
- if (live == null) return null;
112
- const creds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
113
- return (await fetchTokenIdentity(creds.accessToken)).accountUuid;
111
+ export function pickSeat(now: number): Account | null {
112
+ const cfg = loadConfig();
113
+ const idx = loadAccounts(claudePool);
114
+ let dirty = false;
115
+ for (const a of idx.accounts) dirty = foldTee(a) || dirty;
116
+ if (dirty) saveAccounts(claudePool, idx);
117
+ const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
118
+ return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
114
119
  }
115
120
 
116
121
  async function removeCredentials(a: Account): Promise<void> {
122
+ await deleteItem(storeTarget(a.id));
123
+ rmSync(storeDirFor(a.id), { recursive: true, force: true });
124
+ rmSync(`${storeDirFor(a.id)}.lock`, { recursive: true, force: true });
125
+ for (const dir of [sampleDirFor(a.id), sampleDirFor(a.id, "-tick")]) rmSync(dir, { recursive: true, force: true });
126
+ clearUsageSnapshot(a.id);
117
127
  const setupTokens = loadSetupTokens();
118
- const item = credItemFor(a.id);
119
- await deleteItem(parkedTarget(item));
120
- for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
121
- await deleteItem(isolatedTarget(sampleDir));
122
- rmSync(sampleDir, { recursive: true, force: true });
123
- }
124
128
  if (setupTokens.tokens.some((t) => t.accountUuid === a.id)) {
125
129
  saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.id) });
126
130
  }
@@ -169,7 +173,7 @@ async function login(): Promise<Harvest | null> {
169
173
  await onExit;
170
174
  restoreTermios(savedTermios);
171
175
 
172
- const blobRaw = await readItem(iso);
176
+ const blobRaw = await withClaudeRefreshLock(onboardDir, () => readItem(iso));
173
177
  if (!blobRaw || !identityReady(cjPath)) {
174
178
  console.error(c.red("no login detected in the isolated session - nothing changed."));
175
179
  return null;
@@ -183,9 +187,20 @@ async function login(): Promise<Harvest | null> {
183
187
  console.error(c.red("could not parse the onboarded account's credential/identity."));
184
188
  return null;
185
189
  }
190
+ let identity;
191
+ try {
192
+ identity = await fetchTokenIdentity(blob.claudeAiOauth.accessToken);
193
+ } catch (e) {
194
+ console.error(c.red(`could not verify which account the login belongs to (${e instanceof Error ? e.message : String(e)}) - nothing changed.`));
195
+ return null;
196
+ }
197
+ if (identity.accountUuid !== oauthAccount.accountUuid) {
198
+ console.error(c.red(`the login's credential belongs to ${describeIdentity(identity)}, but its identity file names ${oauthAccount.emailAddress} - nothing changed.`));
199
+ return null;
200
+ }
186
201
 
187
202
  console.log(c.dim("sampling usage..."));
188
- const sampled = await probeUsage(onboardDir);
203
+ const sampled = await probeUsage({ configDir: onboardDir });
189
204
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
190
205
  const at = Date.now();
191
206
  const id = oauthAccount.accountUuid;
@@ -195,7 +210,7 @@ async function login(): Promise<Harvest | null> {
195
210
  tier: claudeTierLabel(blob.claudeAiOauth),
196
211
  oauthAccount,
197
212
  sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
198
- park: () => writeItem(parkedTarget(credItemFor(id)), claudeAiOauthOnly(blobRaw)),
213
+ park: () => writeItem(storeTarget(id), claudeAiOauthOnly(blobRaw)),
199
214
  };
200
215
  } finally {
201
216
  if (p.exitCode === null) {
@@ -208,63 +223,13 @@ async function login(): Promise<Harvest | null> {
208
223
  }
209
224
  }
210
225
 
211
- async function importLive(): Promise<Harvest | null> {
212
- if (isApiKeyMode()) {
213
- console.error(c.yellow("tokenmaxxing pools subscription accounts, but you're authed via API key / apiKeyHelper."));
214
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} with a Pro/Max account first, then re-run ${c.cyan("tokenmaxxing init")}.`);
215
- return null;
216
- }
217
-
218
- const oauthAccount = readOAuthAccount();
219
- const liveRaw = await readItem(liveTarget());
220
- if (!oauthAccount || !liveRaw) {
221
- console.error(c.red("no active Claude subscription login found (missing oauthAccount or credential)."));
222
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
223
- return null;
224
- }
225
-
226
- let blob;
227
- try {
228
- blob = CredentialBlobSchema.parse(JSON.parse(liveRaw));
229
- } catch {
230
- console.error(c.red("the live credential is not a recognizable Claude OAuth blob."));
231
- return null;
232
- }
233
-
234
- let creds = blob.claudeAiOauth;
235
- if (isDeadCredential(creds)) {
236
- console.error(c.red("the live credential was cleared after a failed refresh."));
237
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
238
- return null;
239
- }
240
- if (isAccessTokenExpiring(creds)) {
241
- await withClaudeRefreshLock(async (lock) => {
242
- const raw2 = await readItem(liveTarget());
243
- if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
244
- const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
245
- creds = isAccessTokenExpiring(current) ? await refreshCredential(current) : current;
246
- if (creds === current) return;
247
- if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
248
- await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
249
- });
250
- }
251
- const identity = await fetchTokenIdentity(creds.accessToken);
252
- if (identity.accountUuid !== oauthAccount.accountUuid) {
253
- console.error(c.red(`the live credential belongs to ${describeIdentity(identity)}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
254
- console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
255
- return null;
256
- }
226
+ const loginStep = (who: string) => `In the session that opens, run ${c.bold("/login")} with ${who}. It closes itself once you're in.`;
257
227
 
258
- const id = oauthAccount.accountUuid;
259
- const parked = JSON.stringify({ claudeAiOauth: creds });
260
- return {
261
- id,
262
- email: oauthAccount.emailAddress,
263
- tier: claudeTierLabel(creds),
264
- oauthAccount,
265
- sample: null,
266
- park: () => writeItem(parkedTarget(credItemFor(id)), parked),
267
- };
228
+ async function importLive(): Promise<Harvest | null> {
229
+ console.log(c.cyan("Opening an isolated login for your first pooled account - the login you already have stays as it is for sessions started outside the supervisor."));
230
+ console.log(c.dim(loginStep("the first account to pool")));
231
+ console.log();
232
+ return login();
268
233
  }
269
234
 
270
235
  function ensurePathAhead(): void {
@@ -285,7 +250,7 @@ function ensurePathAhead(): void {
285
250
 
286
251
  function install(): void {
287
252
  const out = installSupervisor();
288
- console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
253
+ console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/StopFailure/SessionStart hooks`);
289
254
  if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
290
255
  else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
291
256
  if (!out.pathAhead) {
@@ -298,23 +263,24 @@ export const claude: Provider = {
298
263
  name: "claude",
299
264
  flag: "",
300
265
  pool: claudePool,
266
+ seats: "shared",
301
267
  waitsWhenDepleted: true,
302
- switchMargin: 1,
303
- switchNote: "",
304
- liveId: () => readOAuthAccount()?.accountUuid ?? null,
305
- liveOwner,
306
- presentIds: () => new Set(),
307
- gatedFamilies: (cfg) => gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels),
268
+ liveId,
269
+ presence,
270
+ gatedFamilies: (cfg) => {
271
+ const seat = liveId();
272
+ return gatedFamilies(seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null), cfg.policy.switchModels);
273
+ },
308
274
  observeLive,
309
275
  samplePool,
310
276
  mergeWindows,
311
- swap: performSwap,
312
- classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : isSkippableSwapError(e) ? "skip" : "fatal"),
277
+ swap: prepareMove,
278
+ classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
313
279
  removeCredentials,
314
280
  login,
315
281
  importLive,
316
282
  preflight: () => pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() }),
317
283
  install,
318
- loginStep: (who) => `In the session that opens, run ${c.bold("/login")} with ${who}. It closes itself once you're in.`,
284
+ loginStep,
319
285
  windowLabel: (name) => name.toLowerCase(),
320
286
  };
@@ -1,7 +1,6 @@
1
1
  import { mkdirSync, realpathSync, rmdirSync, statSync, utimesSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { delay } from "es-toolkit";
4
- import { credDir } from "./paths.ts";
5
4
  import { log } from "./log.ts";
6
5
  import { ErrnoSchema } from "./types.ts";
7
6
 
@@ -28,12 +27,12 @@ function tryAcquire(lockDir: string): boolean {
28
27
  }
29
28
 
30
29
  export async function withClaudeRefreshLock<T>(
30
+ dir: string,
31
31
  fn: (lock: { compromised: () => boolean }) => Promise<T> | T,
32
32
  opts: { attempts?: number; retryMs?: number; heartbeatMs?: number } = {},
33
33
  ): Promise<T> {
34
34
  const attempts = opts.attempts ?? ATTEMPTS;
35
35
  const retryMs = opts.retryMs ?? RETRY_MS;
36
- const dir = credDir();
37
36
  mkdirSync(dir, { recursive: true });
38
37
  const primary = join(dir, ".oauth_refresh.lock");
39
38
  let legacyRoot = dir;
@@ -58,7 +57,7 @@ export async function withClaudeRefreshLock<T>(
58
57
  if (attempt >= attempts) {
59
58
  log("claudelock.contested", { attempts: attempt });
60
59
  throw new Error(
61
- "claude's credential-refresh lock is contested (a token refresh is likely mid-flight) - not touching the live credential store; retry shortly",
60
+ "claude's credential-refresh lock is contested (a token refresh is likely mid-flight) - not touching the credential store; retry shortly",
62
61
  );
63
62
  }
64
63
  await delay(retryMs + Math.random() * retryMs);
package/src/lib/codex.ts CHANGED
@@ -5,7 +5,7 @@ import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "./claudebin.ts";
5
5
  import { codexIdentityOf, deleteParkedCodexAuth, isCodexAccessExpiring, liveCodexAccountId, readCodexAuthAt, readLiveCodexAuth, readParkedCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
6
6
  import { resolveRealCodex, verifyRealCodex } from "./codexbin.ts";
7
7
  import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
8
- import { livingCodexPresences, presentCodexAccountIds } from "./codexpresence.ts";
8
+ import { livingPresences, seatCounts } from "./presence.ts";
9
9
  import { CodexUsageReadError, codexLimitLabel, fetchCodexUsage } from "./codexusage.ts";
10
10
  import { codexSupervisorLink, ensurePathInRc, installCodexSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
11
11
  import { withLock } from "./lock.ts";
@@ -33,7 +33,7 @@ async function sampleLiveOntoOwner(now: number): Promise<void> {
33
33
  const owner = idx.accounts.find((a) => a.id === identity.accountId);
34
34
  if (!owner) return;
35
35
 
36
- if (isCodexAccessExpiring({ auth: live, now }) && !presentCodexAccountIds().has(identity.accountId)) {
36
+ if (isCodexAccessExpiring({ auth: live, now }) && !seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
37
37
  try {
38
38
  live = await refreshCodexAuth({ auth: live, now });
39
39
  } catch (e) {
@@ -69,7 +69,7 @@ async function sampleAccount(account: Account, liveId: string | null, now: numbe
69
69
  let auth = isLive ? readLiveCodexAuth() : readParkedCodexAuth({ credFile });
70
70
  if (!auth) return { ok: false, reason: isLive ? "live auth.json vanished" : "no parked credential", deadGrant: false };
71
71
  if (isCodexAccessExpiring({ auth, now })) {
72
- const running = presentCodexAccountIds().has(account.id);
72
+ const running = seatCounts(codexPaths.presenceDir).has(account.id);
73
73
  if (running && !isLive) {
74
74
  return { ok: false, reason: "running in a live codex session (parked token refresh unsafe)", deadGrant: false };
75
75
  }
@@ -232,7 +232,7 @@ async function importLive(): Promise<Harvest | null> {
232
232
  const identity = codexIdentityOf({ auth: live });
233
233
  const usage = await sampleLogin(live);
234
234
 
235
- if (presentCodexAccountIds().has(identity.accountId)) {
235
+ if (seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
236
236
  console.error(c.red("a live supervised codex session is running this account - its token rotates under us, so parking a snapshot now could poison the backup."));
237
237
  console.error(c.dim("close that codex session (or let it exit) and re-run `tokenmaxxing init --codex`."));
238
238
  return null;
@@ -244,7 +244,7 @@ async function importLive(): Promise<Harvest | null> {
244
244
  tier: usage?.usage.planType ?? identity.planType,
245
245
  sample: usage ? { windows: usage.usage.windows, at: usage.at } : null,
246
246
  park: async () => {
247
- if (presentCodexAccountIds().has(identity.accountId)) {
247
+ if (seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
248
248
  throw new Error("a live supervised codex session started running this account mid-init - close it and re-run `tokenmaxxing init --codex`");
249
249
  }
250
250
  const fresh2 = readLiveCodexAuth();
@@ -292,7 +292,7 @@ function install(): void {
292
292
  }
293
293
 
294
294
  export function codexPickCtx(now: number, currentId: string | null): PickCtx {
295
- return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null };
295
+ return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
296
296
  }
297
297
 
298
298
  export function reconcileSiblings(now: number): void {
@@ -301,9 +301,9 @@ export function reconcileSiblings(now: number): void {
301
301
  const idx = loadAccounts(codexPool);
302
302
  const liveAccount = idx.accounts.find((a) => a.id === liveId);
303
303
  if (!liveAccount || liveAccount.needsReauth === true || isExhausted(liveAccount, codexPickCtx(now, liveId))) return;
304
- const living = livingCodexPresences();
304
+ const living = livingPresences(codexPaths.presenceDir);
305
305
  if (existsSync(codexPaths.reconcileDir)) {
306
- const alive = new Set(living.map((presence) => presence.supervisorId));
306
+ const alive = new Set(living.map((presence) => presence.id));
307
307
  for (const name of readdirSync(codexPaths.reconcileDir)) {
308
308
  if (!alive.has(name)) rmSync(join(codexPaths.reconcileDir, name), { force: true });
309
309
  }
@@ -311,11 +311,11 @@ export function reconcileSiblings(now: number): void {
311
311
  for (const presence of living) {
312
312
  if (presence.accountId === liveId) continue;
313
313
  if (!idx.accounts.some((a) => a.id === presence.accountId)) continue;
314
- const markerPath = join(codexPaths.reconcileDir, presence.supervisorId);
314
+ const markerPath = join(codexPaths.reconcileDir, presence.id);
315
315
  if (existsSync(markerPath)) continue;
316
316
  mkdirSync(codexPaths.reconcileDir, { recursive: true });
317
317
  writeFileAtomic(markerPath, JSON.stringify(CodexReconcileMarkerSchema.parse({ accountId: presence.accountId, ts: now })));
318
- log("codex.reconcile_signal", { supervisorId: presence.supervisorId.slice(0, 8), account: presence.accountId.slice(0, 8) });
318
+ log("codex.reconcile_signal", { supervisorId: presence.id.slice(0, 8), account: presence.accountId.slice(0, 8) });
319
319
  }
320
320
  }
321
321
 
@@ -323,12 +323,10 @@ export const codex: Provider = {
323
323
  name: "codex",
324
324
  flag: " --codex",
325
325
  pool: codexPool,
326
+ seats: "live",
326
327
  waitsWhenDepleted: false,
327
- switchMargin: 1.2,
328
- switchNote: " (takes effect on the next codex start)",
329
328
  liveId: liveCodexAccountId,
330
- liveOwner: async () => liveCodexAccountId(),
331
- presentIds: presentCodexAccountIds,
329
+ presence: () => seatCounts(codexPaths.presenceDir),
332
330
  gatedFamilies: () => null,
333
331
  observeLive,
334
332
  samplePool,