tokenmaxxing 1.38.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.
@@ -0,0 +1,133 @@
1
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { z } from "zod";
4
+ import { writeFileAtomic } from "./atomic.ts";
5
+ import { http } from "./http.ts";
6
+ import { isNixPackaged } from "./install.ts";
7
+ import { withLock } from "./lock.ts";
8
+ import { log } from "./log.ts";
9
+ import { HOME, paths } from "./paths.ts";
10
+
11
+ const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000;
12
+ const INSTALL_DEADLINE_MS = 60_000;
13
+ const REGISTRY_DEADLINE_MS = 15_000;
14
+
15
+ const OverrideSchema = z.string().min(1).optional().catch(undefined);
16
+ const AttemptSchema = z.object({ attemptedAt: z.number() });
17
+ const VersionSchema = z.object({ version: z.string().min(1) });
18
+ const LatestSchema = z.object({ version: z.string().min(1), engines: z.looseObject({ bun: z.string().min(1).optional() }).optional() });
19
+
20
+ const updateJson = join(paths.home, "update.json");
21
+ const updateLock = join(paths.home, "update.lock");
22
+
23
+ function registry(): { base: URL; authorization: string | null } {
24
+ const raw = OverrideSchema.parse(process.env.TOKENMAXXING_NPM_REGISTRY) ?? "https://registry.npmjs.org";
25
+ const base = new URL(raw.endsWith("/") ? raw : `${raw}/`);
26
+ const authorization =
27
+ base.username !== "" || base.password !== "" ? `Basic ${btoa(`${decodeURIComponent(base.username)}:${decodeURIComponent(base.password)}`)}` : null;
28
+ base.username = "";
29
+ base.password = "";
30
+ return { base, authorization };
31
+ }
32
+
33
+ function realOrNull(path: string): string | null {
34
+ try {
35
+ return realpathSync(path);
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ function globalRootCandidates(): string[] {
42
+ const globalDir = OverrideSchema.parse(process.env.BUN_INSTALL_GLOBAL_DIR);
43
+ const bunInstall = OverrideSchema.parse(process.env.BUN_INSTALL);
44
+ return [
45
+ ...(globalDir != null ? [globalDir] : []),
46
+ ...(bunInstall != null ? [join(bunInstall, "install", "global")] : []),
47
+ join(dirname(dirname(process.execPath)), "install", "global"),
48
+ join(HOME, ".bun", "install", "global"),
49
+ ].map((candidate) => resolve(candidate));
50
+ }
51
+
52
+ function packageDir(root: string): string {
53
+ return join(root, "node_modules", "tokenmaxxing");
54
+ }
55
+
56
+ function registryPackageDir(root: string): string | null {
57
+ const dir = packageDir(root);
58
+ try {
59
+ return lstatSync(dir).isSymbolicLink() ? null : dir;
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function detectGlobalRoot(): string | null {
66
+ if (isNixPackaged()) return null;
67
+ const entry = realOrNull(Bun.main);
68
+ if (entry == null) return null;
69
+ return (
70
+ globalRootCandidates().find((root) => {
71
+ const dir = registryPackageDir(root);
72
+ return dir != null && entry === realOrNull(join(dir, "src", "main.ts"));
73
+ }) ?? null
74
+ );
75
+ }
76
+
77
+ function installedVersion(root: string): string {
78
+ return VersionSchema.parse(JSON.parse(readFileSync(join(packageDir(root), "package.json"), "utf8"))).version;
79
+ }
80
+
81
+ function isDue(now: number): boolean {
82
+ if (!existsSync(updateJson)) return true;
83
+ let json: unknown;
84
+ try {
85
+ json = JSON.parse(readFileSync(updateJson, "utf8"));
86
+ } catch {
87
+ throw new Error(`${updateJson} is corrupt (unparsable JSON) - repair or remove the file`);
88
+ }
89
+ return now - AttemptSchema.parse(json).attemptedAt >= UPDATE_INTERVAL_MS;
90
+ }
91
+
92
+ async function updateToLatest(root: string): Promise<void> {
93
+ const current = installedVersion(root);
94
+ const { base, authorization } = registry();
95
+ const latestUrl = new URL("tokenmaxxing/latest", base).href;
96
+ const signal = AbortSignal.timeout(REGISTRY_DEADLINE_MS);
97
+ const response = await http.get(latestUrl, { signal, headers: authorization ? { authorization } : undefined });
98
+ if (!response.ok) throw new Error(`${latestUrl} answered HTTP ${response.status}`);
99
+ const latest = LatestSchema.parse(await response.json());
100
+ if (Bun.semver.order(latest.version, current) <= 0) return;
101
+ const floor = latest.engines?.bun;
102
+ if (floor != null && !Bun.semver.satisfies(Bun.version, floor)) {
103
+ throw new Error(`tokenmaxxing@${latest.version} needs bun ${floor}, this is bun ${Bun.version}`);
104
+ }
105
+ const child = Bun.spawn([process.execPath, "add", "-g", "--registry", base.href, `tokenmaxxing@${latest.version}`], {
106
+ cwd: paths.home,
107
+ env: { ...process.env, BUN_INSTALL_GLOBAL_DIR: root },
108
+ stdout: "ignore",
109
+ stderr: "inherit",
110
+ timeout: INSTALL_DEADLINE_MS,
111
+ killSignal: "SIGKILL",
112
+ });
113
+ await child.exited;
114
+ if (child.exitCode !== 0) throw new Error(`bun add -g tokenmaxxing@${latest.version} exited ${child.signalCode ?? child.exitCode}`);
115
+ const installed = installedVersion(root);
116
+ if (installed !== latest.version) throw new Error(`bun add -g tokenmaxxing@${latest.version} left ${installed} installed`);
117
+ log("update.done", { from: current, to: latest.version });
118
+ }
119
+
120
+ export async function maybeAutoUpdate(): Promise<void> {
121
+ const root = detectGlobalRoot();
122
+ if (root == null) return;
123
+ await withLock(updateLock, async () => {
124
+ const now = Date.now();
125
+ if (!isDue(now)) return;
126
+ writeFileAtomic(updateJson, JSON.stringify({ attemptedAt: now }) + "\n");
127
+ try {
128
+ await updateToLatest(root);
129
+ } catch (e) {
130
+ throw new Error(`self-update from npm failed: ${e instanceof Error ? e.message : String(e)}`);
131
+ }
132
+ });
133
+ }
package/src/lib/usage.ts CHANGED
@@ -331,14 +331,17 @@ export function scrubCredentialEnv(env: Record<string, string>): Record<string,
331
331
  return scrubbed;
332
332
  }
333
333
 
334
- function probeEnv(configDir?: string): Record<string, string> {
334
+ export type ProbeTarget = { configDir: string; store?: string };
335
+
336
+ function probeEnv(target: ProbeTarget): Record<string, string> {
335
337
  const env = scrubCredentialEnv({ ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) });
336
- if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
338
+ env.CLAUDE_CONFIG_DIR = target.configDir;
339
+ if (target.store) env.CLAUDE_SECURESTORAGE_CONFIG_DIR = target.store;
337
340
  return env;
338
341
  }
339
342
 
340
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
341
- const env = probeEnv(configDir);
343
+ export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
344
+ const env = probeEnv(target);
342
345
 
343
346
  for (let attempt = 0; ; attempt++) {
344
347
  const full = await probeUsageOnce(env, now);
package/src/main.ts CHANGED
@@ -37,9 +37,8 @@ function printHelp(): void {
37
37
  console.log(`${c.bold("tokenmaxxing")} - automatic Claude Code account switching
38
38
 
39
39
  ${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
40
- ${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
41
- ${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
42
- ${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
40
+ ${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
41
+ ${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
43
42
  ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
44
43
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
45
44
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
@@ -98,18 +97,6 @@ async function main(): Promise<number> {
98
97
  emitError({ json, message: `${sub} has no ${JSON_FLAG} form (${JSON_FLAG} applies to ${[...JSON_COMMANDS].join(", ")})` });
99
98
  return 2;
100
99
  }
101
- if (!(sub != null && sub.startsWith("__")) && !process.env.TOKENMAXXING_PROBE) {
102
- const nonEmpty = (v: string | undefined) => (v != null && v !== "" ? v : null);
103
- const ambient = nonEmpty(process.env.CLAUDE_SECURESTORAGE_CONFIG_DIR) ?? nonEmpty(process.env.CLAUDE_CONFIG_DIR);
104
- if (ambient != null) {
105
- emitError({
106
- json,
107
- message: `CLAUDE_CONFIG_DIR / CLAUDE_SECURESTORAGE_CONFIG_DIR is set (${ambient}): claude uses a namespaced credential store there that tokenmaxxing does not manage - unset it (or run from a clean shell) and retry.`,
108
- });
109
- return 1;
110
- }
111
- }
112
-
113
100
  switch (sub) {
114
101
  case "__statusline": return runStatusline();
115
102
  case "__subagent-statusline": return runSubagentStatusline();
@@ -126,7 +113,13 @@ async function main(): Promise<number> {
126
113
  }
127
114
  return cmdStatus({ json, cached });
128
115
  }
129
- case "switch": return cmdSwitch(provider, args[1], json);
116
+ case "switch": {
117
+ if (provider !== codex) {
118
+ emitError({ json, message: "Claude account placement is per session at launch; manual switching is available with `tokenmaxxing switch --codex`." });
119
+ return 2;
120
+ }
121
+ return cmdSwitch(args[1], json);
122
+ }
130
123
  case "check": {
131
124
  if (args.length > 1) {
132
125
  emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
@@ -163,7 +156,7 @@ async function main(): Promise<number> {
163
156
  console.log(`removed ${removed.join(", ")}`);
164
157
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
165
158
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
166
- console.log(`kept: accounts.json, config.json, and every parked credential (claude - macOS: keychain items, Linux: creds/; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
159
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
167
160
  return 0;
168
161
  }
169
162
  case "help":
@@ -1,30 +0,0 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { z } from "zod";
3
- import { paths } from "./paths.ts";
4
- import { writeFileAtomic } from "./atomic.ts";
5
- import { OAuthAccountSchema, type OAuthAccount } from "./types.ts";
6
-
7
- const ClaudeJsonSchema = z.record(z.string(), z.unknown());
8
-
9
- function readClaudeJson(): Record<string, unknown> {
10
- if (!existsSync(paths.claudeJson)) return {};
11
- return ClaudeJsonSchema.parse(JSON.parse(readFileSync(paths.claudeJson, "utf8")));
12
- }
13
-
14
- export function readOAuthAccount(): OAuthAccount | null {
15
- const parsed = OAuthAccountSchema.safeParse(readClaudeJson()["oauthAccount"]);
16
- return parsed.success ? parsed.data : null;
17
- }
18
-
19
- export function isApiKeyMode(): boolean {
20
- if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return true;
21
- const j = readClaudeJson();
22
- if (z.string().min(1).safeParse(j["apiKeyHelper"]).success) return true;
23
- return false;
24
- }
25
-
26
- export function swapOAuthAccount(next: OAuthAccount): void {
27
- const j = readClaudeJson();
28
- j["oauthAccount"] = next;
29
- writeFileAtomic(paths.claudeJson, JSON.stringify(j, null, 2) + "\n", 0o600);
30
- }
@@ -1,64 +0,0 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { z } from "zod";
4
- import { codexPaths } from "./paths.ts";
5
- import { writeFileAtomic } from "./atomic.ts";
6
- import { pidExists, pidStartTime } from "./proc.ts";
7
- import { ErrnoSchema, JsonTextSchema } from "./types.ts";
8
-
9
- const PresenceSchema = z.object({
10
- accountId: z.string(),
11
- pid: z.number(),
12
- startedAt: z.string(),
13
- });
14
-
15
- export function writeCodexPresence(input: { supervisorId: string; accountId: string; pid?: number }): void {
16
- const pid = input.pid ?? process.pid;
17
- const startedAt = pidStartTime(pid);
18
- if (startedAt == null) throw new Error(`could not read pid ${pid}'s start time (ps lstart) - refusing to write an unverifiable presence file`);
19
- mkdirSync(codexPaths.presenceDir, { recursive: true });
20
- writeFileAtomic(
21
- join(codexPaths.presenceDir, input.supervisorId),
22
- JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid, startedAt })),
23
- );
24
- }
25
-
26
- export function clearCodexPresence(input: { supervisorId: string }): void {
27
- rmSync(join(codexPaths.presenceDir, input.supervisorId), { force: true });
28
- }
29
-
30
- const LivingPresenceSchema = z.object({ supervisorId: z.string(), accountId: z.string() });
31
- export type LivingPresence = z.infer<typeof LivingPresenceSchema>;
32
-
33
- export function livingCodexPresences(): LivingPresence[] {
34
- const living: LivingPresence[] = [];
35
- if (!existsSync(codexPaths.presenceDir)) return living;
36
- for (const name of readdirSync(codexPaths.presenceDir)) {
37
- const file = join(codexPaths.presenceDir, name);
38
- let raw: string;
39
- try {
40
- raw = readFileSync(file, "utf8");
41
- } catch (e) {
42
- if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") continue;
43
- throw e;
44
- }
45
- const parsed = PresenceSchema.safeParse(JsonTextSchema.safeParse(raw).data);
46
- if (!parsed.success) {
47
- throw new Error(`${file} is not a readable presence record - it may belong to a RUNNING codex session, refusing to treat it as absent; remove the file (or respawn that session) to proceed`);
48
- }
49
- const observed = pidStartTime(parsed.data.pid);
50
- if (observed !== parsed.data.startedAt) {
51
- if (observed == null && pidExists(parsed.data.pid)) {
52
- throw new Error(`ps could not read the start time of live pid ${parsed.data.pid} (${file}) - refusing to clear a presence file that may guard a RUNNING codex session`);
53
- }
54
- rmSync(file, { force: true });
55
- continue;
56
- }
57
- living.push(LivingPresenceSchema.parse({ supervisorId: name, accountId: parsed.data.accountId }));
58
- }
59
- return living;
60
- }
61
-
62
- export function presentCodexAccountIds(): Set<string> {
63
- return new Set(livingCodexPresences().map((presence) => presence.accountId));
64
- }
package/src/lib/swap.ts DELETED
@@ -1,198 +0,0 @@
1
- import { clearDepletedWait, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
2
- import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
3
- import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
4
- import { swapOAuthAccount } from "./claudejson.ts";
5
- import { withClaudeRefreshLock } from "./claudelock.ts";
6
- import { claudePool, credItemFor } from "./paths.ts";
7
- import { log } from "./log.ts";
8
- import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
-
10
- function parseBlob(raw: string) {
11
- return CredentialBlobSchema.parse(JSON.parse(raw));
12
- }
13
-
14
- export async function performSwap(target: Account): Promise<void> {
15
- const idx = loadAccounts(claudePool);
16
- const oauthAccount = target.oauthAccount;
17
- if (!oauthAccount) throw new Error(`${target.label} has no oauthAccount on its record - re-add it with \`tokenmaxxing auth ${target.label}\``);
18
-
19
- const preLive = await readItem(liveTarget());
20
- let liveOwner: Account | null = null;
21
- let liveCreds: OAuthCreds | null = null;
22
- let expectedLiveToken: string | null = null;
23
- if (preLive) {
24
- liveCreds = parseBlob(preLive).claudeAiOauth;
25
- expectedLiveToken = liveCreds.accessToken;
26
- if (isDeadCredential(liveCreds)) {
27
- liveCreds = null;
28
- log("swap.live_dead", {});
29
- } else if (isAccessTokenExpiring(liveCreds, 60_000)) {
30
- try {
31
- await withClaudeRefreshLock(async (lock) => {
32
- const raw2 = await readItem(liveTarget());
33
- if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
34
- const current = parseBlob(raw2).claudeAiOauth;
35
- const next = isAccessTokenExpiring(current, 60_000) ? await refreshCredential(current) : current;
36
- liveCreds = next;
37
- expectedLiveToken = next.accessToken;
38
- if (next === current) return;
39
- if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
40
- await writeItem(liveTarget(), mergeIntoLive(raw2, next));
41
- });
42
- } catch (e) {
43
- if (!(e instanceof InvalidGrantError)) throw e;
44
- liveCreds = null;
45
- log("swap.harvest_skipped_dead_live", {});
46
- }
47
- }
48
- if (liveCreds != null) {
49
- let identity: TokenIdentity;
50
- try {
51
- identity = await fetchTokenIdentity(liveCreds.accessToken);
52
- } catch (e) {
53
- throw new Error(`cannot resolve the live credential's owner (${e instanceof Error ? e.message : String(e)}) - refusing to swap over it; the next check retries`);
54
- }
55
- liveOwner = idx.accounts.find((a) => a.id === identity.accountUuid) ?? null;
56
- if (!liveOwner) {
57
- throw new Error(
58
- `live credential belongs to ${describeIdentity(identity)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
59
- );
60
- }
61
- if (liveOwner.id !== idx.activeId) {
62
- log("swap.harvest_drift", {
63
- labeled: idx.activeId?.slice(0, 8) ?? null,
64
- actual: liveOwner.id.slice(0, 8),
65
- });
66
- }
67
- }
68
- }
69
-
70
- const selfSwap = liveOwner != null && liveOwner.id === target.id;
71
- let fresh: OAuthCreds | null = null;
72
- if (!selfSwap) {
73
- const parkedRaw = await readItem(parkedTarget(credItemFor(target.id)));
74
- if (!parkedRaw) throw new Error(`no parked credential for ${target.label}`);
75
- const parked = parseBlob(parkedRaw).claudeAiOauth;
76
- const markDead = (detail: string): InvalidGrantError => {
77
- const t = idx.accounts.find((a) => a.id === target.id);
78
- if (t) { t.needsReauth = true; saveAccounts(claudePool, idx); }
79
- log("swap.invalid_grant", { account: target.id.slice(0, 8), detail });
80
- return new InvalidGrantError(detail);
81
- };
82
- if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
83
- let parkedOwner: TokenIdentity | null = null;
84
- try {
85
- parkedOwner = await fetchTokenIdentity(parked.accessToken);
86
- } catch (e) {
87
- if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
88
- log("swap.parked_token_stale", { account: target.id.slice(0, 8) });
89
- }
90
- if (parkedOwner != null && parkedOwner.accountUuid !== target.id) {
91
- const owner = idx.accounts.find((a) => a.id === parkedOwner.accountUuid) ?? null;
92
- let kept = "was left in place";
93
- if (owner != null && owner.id !== liveOwner?.id) {
94
- await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: parked }));
95
- log("swap.parked_relocated", { account: owner.id.slice(0, 8) });
96
- kept = "was copied to that account's slot";
97
- }
98
- throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
99
- }
100
- const refreshParked = async (): Promise<OAuthCreds> => {
101
- try {
102
- return await refreshCredential(parked);
103
- } catch (e) {
104
- if (e instanceof InvalidGrantError) throw markDead(e.detail);
105
- if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.id.slice(0, 8), status: e.status, detail: e.detail });
106
- throw e;
107
- }
108
- };
109
- if (parkedOwner != null) {
110
- fresh = await refreshParked();
111
- } else {
112
- fresh = await withClaudeRefreshLock(async (lock) => {
113
- const rotated = await refreshParked();
114
- let freshOwner: TokenIdentity;
115
- try {
116
- freshOwner = await fetchTokenIdentity(rotated.accessToken);
117
- } catch (e) {
118
- await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: rotated }));
119
- throw new Error(`refreshed the parked credential but cannot verify its owner (${e instanceof Error ? e.message : String(e)}) - kept the rotated token parked; the next check retries`);
120
- }
121
- if (freshOwner.accountUuid !== target.id) {
122
- const owner = idx.accounts.find((a) => a.id === freshOwner.accountUuid) ?? null;
123
- const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerId: liveOwner?.id ?? null, expectedLiveToken, lock });
124
- throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
125
- }
126
- return rotated;
127
- });
128
- }
129
- await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: fresh }));
130
- }
131
-
132
- await withClaudeRefreshLock(async (lock) => {
133
- const currentLive = await readItem(liveTarget());
134
- if (lock.compromised()) throw new Error("refresh lock compromised - aborting the swap before any write");
135
-
136
- const currentToken = currentLive == null ? null : parseBlob(currentLive).claudeAiOauth.accessToken;
137
- if (currentToken !== expectedLiveToken) {
138
- throw new Error("live credential changed while unlocked (concurrent /login or refresh) - aborting this swap; the next check re-resolves the owner and retries");
139
- }
140
-
141
- if (liveOwner && currentLive) {
142
- await writeItem(parkedTarget(credItemFor(liveOwner.id)), claudeAiOauthOnly(currentLive));
143
- log("swap.harvest", { account: liveOwner.id.slice(0, 8) });
144
- }
145
-
146
- if (fresh != null) {
147
- await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
148
- }
149
- swapOAuthAccount(oauthAccount);
150
- idx.activeId = target.id;
151
- const t2 = idx.accounts.find((a) => a.id === target.id);
152
- if (t2) { t2.needsReauth = false; }
153
- saveAccounts(claudePool, idx);
154
- clearUsageSnapshots();
155
- clearDepletedWait();
156
- saveLastSwapAt(claudePool, Date.now());
157
- });
158
- log("swap.done", { account: target.id.slice(0, 8), label: target.label });
159
- }
160
-
161
- export async function keepRotatedPair(input: {
162
- fresh: OAuthCreds;
163
- owner: Account | null;
164
- fallback: Account;
165
- liveOwnerId: string | null;
166
- expectedLiveToken: string | null;
167
- lock: { compromised: () => boolean };
168
- }): Promise<string> {
169
- const { fresh, owner } = input;
170
- if (owner == null) {
171
- await writeItem(parkedTarget(credItemFor(input.fallback.id)), JSON.stringify({ claudeAiOauth: fresh }));
172
- log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.id.slice(0, 8) });
173
- return "was kept in this account's stamped slot because its owner is not in the pool";
174
- }
175
- const park = async (): Promise<void> => {
176
- await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: fresh }));
177
- log("swap.rotated_parked_rescued", { account: owner.id.slice(0, 8) });
178
- };
179
- if (input.liveOwnerId !== owner.id) {
180
- await park();
181
- return "was parked under that account";
182
- }
183
- try {
184
- const currentLive = await readItem(liveTarget());
185
- if (input.lock.compromised()) throw new Error("refresh lock compromised");
186
- if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
187
- await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
188
- log("swap.rotated_live_rescued", { account: owner.id.slice(0, 8) });
189
- return "was installed into the live store so that account's sessions continue";
190
- } catch (e) {
191
- await park();
192
- return `was parked under that account (${e instanceof Error ? e.message : String(e)})`;
193
- }
194
- }
195
-
196
- export function isSkippableSwapError(e: unknown): boolean {
197
- return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
198
- }