tokenmaxxing 1.39.0 → 1.41.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,139 @@
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;
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;
92
+ try {
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}\``);
96
+ }
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);
93
104
  }
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);
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 });
109
+ }
110
+
111
+ async function storeUsable(a: Account): Promise<boolean> {
101
112
  try {
102
- await ensureLiveTokenFresh();
113
+ const creds = await readStore(a.id);
114
+ return creds != null && !isDeadCredential(creds);
103
115
  } catch {
116
+ return false;
104
117
  }
105
- await Promise.all(accounts.filter((a) => a !== active).map(probeOne));
106
- return reports;
107
118
  }
108
119
 
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;
120
+ export function pickSeat(now: number): Account | null {
121
+ const cfg = loadConfig();
122
+ const idx = loadAccounts(claudePool);
123
+ let dirty = false;
124
+ for (const a of idx.accounts) dirty = foldTee(a) || dirty;
125
+ if (dirty) saveAccounts(claudePool, idx);
126
+ const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
127
+ return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
114
128
  }
115
129
 
116
130
  async function removeCredentials(a: Account): Promise<void> {
131
+ await deleteItem(storeTarget(a.id));
132
+ rmSync(storeDirFor(a.id), { recursive: true, force: true });
133
+ rmSync(`${storeDirFor(a.id)}.lock`, { recursive: true, force: true });
134
+ for (const dir of [sampleDirFor(a.id), sampleDirFor(a.id, "-tick")]) rmSync(dir, { recursive: true, force: true });
135
+ clearUsageSnapshot(a.id);
117
136
  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
137
  if (setupTokens.tokens.some((t) => t.accountUuid === a.id)) {
125
138
  saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.id) });
126
139
  }
@@ -169,7 +182,7 @@ async function login(): Promise<Harvest | null> {
169
182
  await onExit;
170
183
  restoreTermios(savedTermios);
171
184
 
172
- const blobRaw = await readItem(iso);
185
+ const blobRaw = await withClaudeRefreshLock(onboardDir, () => readItem(iso));
173
186
  if (!blobRaw || !identityReady(cjPath)) {
174
187
  console.error(c.red("no login detected in the isolated session - nothing changed."));
175
188
  return null;
@@ -183,9 +196,20 @@ async function login(): Promise<Harvest | null> {
183
196
  console.error(c.red("could not parse the onboarded account's credential/identity."));
184
197
  return null;
185
198
  }
199
+ let identity;
200
+ try {
201
+ identity = await fetchTokenIdentity(blob.claudeAiOauth.accessToken);
202
+ } catch (e) {
203
+ console.error(c.red(`could not verify which account the login belongs to (${e instanceof Error ? e.message : String(e)}) - nothing changed.`));
204
+ return null;
205
+ }
206
+ if (identity.accountUuid !== oauthAccount.accountUuid) {
207
+ console.error(c.red(`the login's credential belongs to ${describeIdentity(identity)}, but its identity file names ${oauthAccount.emailAddress} - nothing changed.`));
208
+ return null;
209
+ }
186
210
 
187
211
  console.log(c.dim("sampling usage..."));
188
- const sampled = await probeUsage(onboardDir);
212
+ const sampled = await probeUsage({ configDir: onboardDir });
189
213
  if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
190
214
  const at = Date.now();
191
215
  const id = oauthAccount.accountUuid;
@@ -195,7 +219,7 @@ async function login(): Promise<Harvest | null> {
195
219
  tier: claudeTierLabel(blob.claudeAiOauth),
196
220
  oauthAccount,
197
221
  sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
198
- park: () => writeItem(parkedTarget(credItemFor(id)), claudeAiOauthOnly(blobRaw)),
222
+ park: () => writeItem(storeTarget(id), claudeAiOauthOnly(blobRaw)),
199
223
  };
200
224
  } finally {
201
225
  if (p.exitCode === null) {
@@ -208,63 +232,13 @@ async function login(): Promise<Harvest | null> {
208
232
  }
209
233
  }
210
234
 
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
- }
235
+ const loginStep = (who: string) => `In the session that opens, run ${c.bold("/login")} with ${who}. It closes itself once you're in.`;
217
236
 
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
- }
257
-
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
- };
237
+ async function importLive(): Promise<Harvest | null> {
238
+ 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."));
239
+ console.log(c.dim(loginStep("the first account to pool")));
240
+ console.log();
241
+ return login();
268
242
  }
269
243
 
270
244
  function ensurePathAhead(): void {
@@ -285,7 +259,7 @@ function ensurePathAhead(): void {
285
259
 
286
260
  function install(): void {
287
261
  const out = installSupervisor();
288
- console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
262
+ console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/StopFailure/SessionStart hooks`);
289
263
  if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
290
264
  else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
291
265
  if (!out.pathAhead) {
@@ -298,23 +272,25 @@ export const claude: Provider = {
298
272
  name: "claude",
299
273
  flag: "",
300
274
  pool: claudePool,
275
+ seats: "shared",
301
276
  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),
277
+ liveId,
278
+ presence,
279
+ gatedFamilies: (cfg) => {
280
+ const seat = liveId();
281
+ return gatedFamilies(seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null), cfg.policy.switchModels);
282
+ },
308
283
  observeLive,
309
284
  samplePool,
310
285
  mergeWindows,
311
- swap: performSwap,
312
- classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : isSkippableSwapError(e) ? "skip" : "fatal"),
286
+ swap: prepareMove,
287
+ classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
313
288
  removeCredentials,
289
+ storeUsable,
314
290
  login,
315
291
  importLive,
316
292
  preflight: () => pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() }),
317
293
  install,
318
- loginStep: (who) => `In the session that opens, run ${c.bold("/login")} with ${who}. It closes itself once you're in.`,
294
+ loginStep,
319
295
  windowLabel: (name) => name.toLowerCase(),
320
296
  };
@@ -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,
@@ -336,6 +334,13 @@ export const codex: Provider = {
336
334
  swap,
337
335
  classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : "fatal"),
338
336
  removeCredentials: async (a) => deleteParkedCodexAuth({ credFile: codexCredItemFor(a.id) }),
337
+ storeUsable: async (a) => {
338
+ try {
339
+ return readParkedCodexAuth({ credFile: codexCredItemFor(a.id) }) != null;
340
+ } catch {
341
+ return false;
342
+ }
343
+ },
339
344
  login,
340
345
  importLive,
341
346
  preflight,