tokenmaxxing 1.17.0 → 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -58,7 +58,7 @@ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed
58
58
 
59
59
  ### 3.4 Swap sequence (under the lock)
60
60
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), NOT the `accounts.json` active label and NOT the organization (team seats share one). The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
61
- 2. **Refresh B** - OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account.
61
+ 2. **Refresh B** - a blob Claude Code dead-cleared (empty tokens) is dead without a request. Resolve the owner of B's parked token through the profile endpoint first; a token that belongs to another account is never refreshed, because a refresh rotation revokes that account's live sessions, and B is marked `needs_reauth` instead. Then OAuth refresh-grant with B's parked refresh token → fresh access token; persist the rotated refresh token. On `invalid_grant`, mark B `needs_reauth`, notify, try the next account. On any other 4xx, skip B for this evaluation without marking it and try the next account.
62
62
  3. **Install B** - `security add-generic-password -U ... 'Claude Code-credentials' ...` with B's fresh (non-expired) `claudeAiOauth` JSON.
63
63
  4. **Swap identity + mark B active** - atomically rewrite only the `oauthAccount` object in `~/.claude.json` (temp+rename) to B's, and write `activeAccountUuid = B` in the SAME critical section, so a crash can't leave the installed credential and the active label pointing at different accounts.
64
64
  5. Do steps 1, 3, 4 inside Claude's own refresh locks - the primary `<credDir>/.oauth_refresh.lock` plus the legacy sibling `<realpath(credDir)>.lock` (`~/.claude.lock` by default), both mkdir-based proper-lockfile locks, binary-verified against 2.1.214 - so the writes can't collide with a token refresh. Contention fails the swap fast (claude itself gives up with `lock_timeout` rather than refreshing unlocked); the next check retries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.17.0",
4
+ "version": "1.18.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/init.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import { isApiKeyMode, readOAuthAccount } from "../lib/claudejson.ts";
3
3
  import { readItem, writeItem, liveTarget, parkedTarget, mergeIntoLive } from "../lib/credstore.ts";
4
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
4
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
5
5
  import { withClaudeRefreshLock } from "../lib/claudelock.ts";
6
6
  import { loadAccounts, saveAccounts, loadConfig, pinBinOverride } from "../lib/state.ts";
7
7
  import { withLock } from "../lib/lock.ts";
@@ -83,6 +83,11 @@ export async function cmdInit(): Promise<number> {
83
83
  }
84
84
 
85
85
  let creds = blob.claudeAiOauth;
86
+ if (isDeadCredential(creds)) {
87
+ console.error(c.red("the live credential was cleared after a failed refresh."));
88
+ console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
89
+ return 1;
90
+ }
86
91
  if (isAccessTokenExpiring(creds)) {
87
92
  await withClaudeRefreshLock(async (lock) => {
88
93
  const raw2 = await readItem(liveTarget());
package/src/lib/decide.ts CHANGED
@@ -4,9 +4,8 @@ import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
5
  import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { chooseAndSwap, performSwap } from "./swap.ts";
7
+ import { chooseAndSwap, isSkippableSwapError, performSwap } from "./swap.ts";
8
8
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
9
- import { InvalidGrantError } from "./oauth.ts";
10
9
  import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
11
10
  import { log } from "./log.ts";
12
11
  import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
@@ -197,21 +196,30 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
197
196
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
198
197
  null;
199
198
 
199
+ const rejected = new Set<string>();
200
+ const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.accountUuid));
201
+ const skipOrThrow = (e: unknown, candidate: Account): void => {
202
+ if (!isSkippableSwapError(e)) throw e;
203
+ rejected.add(candidate.accountUuid);
204
+ log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
205
+ };
206
+
200
207
  const greedy = async (holdMargin: number): Promise<SwapDecision> => {
201
208
  while (true) {
202
209
  const cur = loadAccounts();
203
210
  const active = seatOf(cur);
204
- const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
205
- if (currentWins(active, cur.accounts, ctxAll)) {
211
+ const pool = usable(cur.accounts);
212
+ const ctxAll = { now, thresholds: barsOf(pool), currentAccountUuid: null, switchFamilies, holdMargin };
213
+ if (currentWins(active, pool, ctxAll)) {
206
214
  return { swapped: false, account: null, reason: "current-best" };
207
215
  }
208
- const best = pickBest(cur.accounts, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
216
+ const best = pickBest(pool, { ...ctxAll, currentAccountUuid: active?.accountUuid ?? null });
209
217
  if (!best) return { swapped: false, account: null, reason: "no-usable-target" };
210
218
  try {
211
219
  await performSwap(best);
212
220
  } catch (e) {
213
- if (e instanceof InvalidGrantError) continue;
214
- throw e;
221
+ skipOrThrow(e, best);
222
+ continue;
215
223
  }
216
224
  log("decide.greedy_swap", { account: best.accountUuid.slice(0, 8) });
217
225
  return { swapped: true, account: best, reason: "swapped" };
@@ -222,15 +230,16 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
222
230
  while (true) {
223
231
  const cur = loadAccounts();
224
232
  const seat = seatOf(cur);
225
- const thresholds = barsOf(cur.accounts);
233
+ const pool = usable(cur.accounts);
234
+ const thresholds = barsOf(pool);
226
235
  if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
227
- const best = pickBest(cur.accounts, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
236
+ const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
228
237
  if (!best) break;
229
238
  try {
230
239
  await performSwap(best);
231
240
  } catch (e) {
232
- if (e instanceof InvalidGrantError) continue;
233
- throw e;
241
+ skipOrThrow(e, best);
242
+ continue;
234
243
  }
235
244
  return { swapped: true, account: best, reason: "swapped" };
236
245
  }
@@ -241,7 +250,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
241
250
  log("decide.last_drop_hold", { account: seat.accountUuid.slice(0, 8) });
242
251
  return { swapped: false, account: null, reason: "last-drop-hold" };
243
252
  }
244
- const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null });
253
+ const squeezed = await chooseAndSwap({ ...hardCtx, currentAccountUuid: seat?.accountUuid ?? null }, rejected);
245
254
  if (squeezed) {
246
255
  log("decide.last_drop_swap", { account: squeezed.accountUuid.slice(0, 8) });
247
256
  return { swapped: true, account: squeezed, reason: "last-drop-swap" };
@@ -253,7 +262,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
253
262
  const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
254
263
  const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
255
264
  const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
256
- const other = pickEarliestReset(fresh.accounts, ctx);
265
+ const other = pickEarliestReset(usable(fresh.accounts), ctx);
257
266
 
258
267
  let target: Account | null = null;
259
268
  let waitUntil = Number.POSITIVE_INFINITY;
@@ -275,8 +284,8 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
275
284
  try {
276
285
  await performSwap(target);
277
286
  } catch (e) {
278
- if (e instanceof InvalidGrantError) continue;
279
- throw e;
287
+ skipOrThrow(e, target);
288
+ continue;
280
289
  }
281
290
  }
282
291
  saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
package/src/lib/http.ts CHANGED
@@ -1,25 +1,52 @@
1
1
  import ky from "ky";
2
2
  import { z } from "zod";
3
3
 
4
- const ErrorBodySchema = z.looseObject({
5
- error: z.string().optional(),
4
+ const OAuthErrorBodySchema = z.looseObject({
5
+ error: z.string(),
6
6
  error_description: z.string().optional(),
7
+ });
8
+
9
+ const NestedErrorBodySchema = z.looseObject({
10
+ error: z.looseObject({ type: z.string().optional(), message: z.string().optional() }),
11
+ request_id: z.string().nullish(),
12
+ });
13
+
14
+ const PlainErrorBodySchema = z.looseObject({
7
15
  detail: z.string().optional(),
8
16
  message: z.string().optional(),
9
17
  });
10
18
 
19
+ const ErrorDetailSchema = z.object({ code: z.string().nullable(), fields: z.array(z.string()) });
20
+ type ErrorDetail = z.infer<typeof ErrorDetailSchema>;
21
+
22
+ function parseErrorBody(text: string): ErrorDetail | null {
23
+ let json: unknown;
24
+ try {
25
+ json = JSON.parse(text);
26
+ } catch {
27
+ return null;
28
+ }
29
+ const oauth = OAuthErrorBodySchema.safeParse(json);
30
+ if (oauth.success) return { code: oauth.data.error, fields: [oauth.data.error, oauth.data.error_description ?? ""] };
31
+ const nested = NestedErrorBodySchema.safeParse(json);
32
+ if (nested.success) {
33
+ const requestId = nested.data.request_id ? ` (${nested.data.request_id})` : "";
34
+ return { code: nested.data.error.type ?? null, fields: [nested.data.error.type ?? "", (nested.data.error.message ?? "") + requestId] };
35
+ }
36
+ const plain = PlainErrorBodySchema.safeParse(json);
37
+ if (plain.success) return { code: null, fields: [plain.data.detail ?? "", plain.data.message ?? ""] };
38
+ return null;
39
+ }
40
+
41
+ export function oauthErrorCode(input: { text: string }): string | null {
42
+ return parseErrorBody(input.text)?.code ?? null;
43
+ }
44
+
11
45
  export function safeErrorDetail(input: { text: string }): string {
12
- const parsed = ErrorBodySchema.safeParse((() => {
13
- try {
14
- return JSON.parse(input.text);
15
- } catch {
16
- return null;
17
- }
18
- })());
19
- if (!parsed.success) return "(unparsable error body withheld)";
20
- const fields = [parsed.data.error, parsed.data.error_description, parsed.data.detail, parsed.data.message];
21
- const detail = fields.filter((field): field is string => field != null && field.length > 0).join(": ");
22
- return detail.length > 0 ? detail.slice(0, 200) : "(no error detail)";
46
+ const body = parseErrorBody(input.text);
47
+ if (!body) return `(unrecognized error body, ${input.text.length} bytes, content withheld)`;
48
+ const detail = body.fields.filter((field) => field.length > 0).join(": ");
49
+ return detail.length > 0 ? detail.slice(0, 240) : "(no error detail)";
23
50
  }
24
51
 
25
52
  export const http = ky.create({
package/src/lib/log.ts CHANGED
@@ -21,6 +21,7 @@ export function log(event: string, fields: Record<string, unknown> = {}): void {
21
21
  let line = "";
22
22
  try {
23
23
  line = Object.entries(fields)
24
+ .filter(([, v]) => v !== undefined)
24
25
  .map(([k, v]) => {
25
26
  const str = z.string().safeParse(v);
26
27
  return `${k}=${redact(str.success ? str.data : JSON.stringify(v))}`;
package/src/lib/oauth.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { http, safeErrorDetail } from "./http.ts";
2
+ import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
3
3
  import { ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
4
4
 
5
5
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
@@ -22,6 +22,17 @@ export class InvalidGrantError extends Error {
22
22
  }
23
23
  }
24
24
 
25
+ export class RefreshRejectedError extends Error {
26
+ constructor(public readonly status: number, public readonly detail: string) {
27
+ super(`token refresh rejected (HTTP ${status}): ${detail}`);
28
+ this.name = "RefreshRejectedError";
29
+ }
30
+ }
31
+
32
+ export function isDeadCredential(creds: OAuthCreds): boolean {
33
+ return creds.refreshToken === "" || creds.accessToken === "";
34
+ }
35
+
25
36
  export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
26
37
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
27
38
  const body = {
@@ -43,10 +54,10 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Pr
43
54
 
44
55
  const text = await res.text();
45
56
  if (!res.ok) {
46
- if (res.status === 400 && /invalid_grant/.test(text)) {
47
- throw new InvalidGrantError(safeErrorDetail({ text }));
48
- }
49
- throw new Error(`token refresh failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
57
+ const detail = safeErrorDetail({ text });
58
+ if (res.status === 400 && oauthErrorCode({ text }) === "invalid_grant") throw new InvalidGrantError(detail);
59
+ if (res.status >= 400 && res.status < 500) throw new RefreshRejectedError(res.status, detail);
60
+ throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
50
61
  }
51
62
 
52
63
  const parsed = RefreshResponseSchema.safeParse((() => {
package/src/lib/sample.ts CHANGED
@@ -4,7 +4,7 @@ import { z } from "zod";
4
4
  import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
5
5
  import { credItemFor, paths } from "./paths.ts";
6
6
  import { withClaudeRefreshLock } from "./claudelock.ts";
7
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
7
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
8
8
  import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
9
  import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
10
 
@@ -48,22 +48,38 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
48
48
  } catch (e) {
49
49
  return { ok: false, reason: `parked credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - run \`tokenmaxxing auth\`` };
50
50
  }
51
+ if (isDeadCredential(creds)) {
52
+ account.needsReauth = true;
53
+ return { ok: false, reason: "parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`" };
54
+ }
51
55
 
52
56
  const liveRaw = await readItem(liveTarget());
53
57
  if (liveRaw != null) {
54
- let liveAccount: string;
58
+ let liveCreds: OAuthCreds;
55
59
  try {
56
- const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
57
- liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
60
+ liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
58
61
  } catch (e) {
59
- return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
62
+ return { ok: false, reason: `cannot read the live credential (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
60
63
  }
61
- if (liveAccount === account.accountUuid) {
62
- return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
64
+ if (!isDeadCredential(liveCreds)) {
65
+ let liveAccount: string;
66
+ try {
67
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
68
+ } catch (e) {
69
+ return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
70
+ }
71
+ if (liveAccount === account.accountUuid) {
72
+ return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
73
+ }
63
74
  }
64
75
  }
65
76
 
66
77
  if (isAccessTokenExpiring(creds, 300_000)) {
78
+ const owner = await checkIdentity(creds, account);
79
+ if (owner.status === "mismatch") {
80
+ account.needsReauth = true;
81
+ return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
82
+ }
67
83
  try {
68
84
  creds = await refreshCredential(creds);
69
85
  await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
@@ -122,11 +138,13 @@ export async function ensureLiveTokenFresh(): Promise<void> {
122
138
  } catch {
123
139
  return;
124
140
  }
141
+ if (isDeadCredential(creds)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
125
142
  if (!isAccessTokenExpiring(creds, 300_000)) return;
126
143
  await withClaudeRefreshLock(async (lock) => {
127
144
  const raw2 = await readItem(liveTarget());
128
145
  if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
129
146
  const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
147
+ if (isDeadCredential(current)) throw new InvalidGrantError("live credential was cleared after a failed refresh");
130
148
  const next = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
131
149
  if (next === current) return;
132
150
  if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
package/src/lib/swap.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { clearDepletedWait, clearNextCheck, clearUsageSnapshots, loadAccounts, saveAccounts, saveLastSwapAt } from "./state.ts";
2
2
  import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
3
- import { refreshCredential, isAccessTokenExpiring, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
3
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
4
4
  import { swapOAuthAccount } from "./claudejson.ts";
5
5
  import { withClaudeRefreshLock } from "./claudelock.ts";
6
6
  import { log } from "./log.ts";
@@ -21,7 +21,10 @@ export async function performSwap(target: Account): Promise<void> {
21
21
  if (preLive) {
22
22
  liveCreds = parseBlob(preLive).claudeAiOauth;
23
23
  expectedLiveToken = liveCreds.accessToken;
24
- if (isAccessTokenExpiring(liveCreds, 60_000)) {
24
+ if (isDeadCredential(liveCreds)) {
25
+ liveCreds = null;
26
+ log("swap.live_dead", {});
27
+ } else if (isAccessTokenExpiring(liveCreds, 60_000)) {
25
28
  try {
26
29
  await withClaudeRefreshLock(async (lock) => {
27
30
  const raw2 = await readItem(liveTarget());
@@ -62,14 +65,23 @@ export async function performSwap(target: Account): Promise<void> {
62
65
  if (!selfSwap) {
63
66
  const parkedRaw = await readItem(parkedTarget(target.keychainItem));
64
67
  if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
68
+ const parked = parseBlob(parkedRaw).claudeAiOauth;
69
+ const markDead = (detail: string): InvalidGrantError => {
70
+ const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
71
+ if (t) { t.needsReauth = true; saveAccounts(idx); }
72
+ log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8), detail });
73
+ return new InvalidGrantError(detail);
74
+ };
75
+ if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
76
+ const parkedOwner = await fetchTokenIdentity(parked.accessToken).catch(() => null);
77
+ if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
78
+ throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth ${target.label}\``);
79
+ }
65
80
  try {
66
- fresh = await refreshCredential(parseBlob(parkedRaw).claudeAiOauth);
81
+ fresh = await refreshCredential(parked);
67
82
  } catch (e) {
68
- if (e instanceof InvalidGrantError) {
69
- const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
70
- if (t) { t.needsReauth = true; saveAccounts(idx); }
71
- log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
72
- }
83
+ if (e instanceof InvalidGrantError) throw markDead(e.detail);
84
+ if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
73
85
  throw e;
74
86
  }
75
87
  await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
@@ -105,8 +117,12 @@ export async function performSwap(target: Account): Promise<void> {
105
117
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
106
118
  }
107
119
 
108
- export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
109
- const tried = new Set<string>();
120
+ export function isSkippableSwapError(e: unknown): boolean {
121
+ return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
122
+ }
123
+
124
+ export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
125
+ const tried = new Set<string>(exclude);
110
126
  while (true) {
111
127
  const idx = loadAccounts();
112
128
  const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
@@ -117,7 +133,7 @@ export async function chooseAndSwap(ctx: PickCtx): Promise<Account | null> {
117
133
  await performSwap(best);
118
134
  return best;
119
135
  } catch (e) {
120
- if (e instanceof InvalidGrantError) continue;
136
+ if (isSkippableSwapError(e)) continue;
121
137
  throw e;
122
138
  }
123
139
  }