tokenmaxxing 1.18.0 → 1.18.1

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,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.18.0",
4
+ "version": "1.18.1",
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.18.0",
3
+ "version": "1.18.1",
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/switch.ts CHANGED
@@ -2,7 +2,7 @@ import { withLock } from "../lib/lock.ts";
2
2
  import { paths } from "../lib/paths.ts";
3
3
  import { loadAccounts, loadConfig, loadUsage } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
- import { performSwap } from "../lib/swap.ts";
5
+ import { isSkippableSwapError, performSwap } from "../lib/swap.ts";
6
6
  import { currentWins, effectiveBars, pickBest, pickEarliestReset, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
7
7
  import { InvalidGrantError } from "../lib/oauth.ts";
8
8
  import { gatedFamilies } from "../lib/usage.ts";
@@ -66,14 +66,16 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
66
66
  currentAccountUuid: null,
67
67
  switchFamilies,
68
68
  });
69
+ const rejected = new Set<string>();
69
70
  while (true) {
70
71
  const cur = loadAccounts();
71
72
  const everyone = everyoneIn(cur.accounts);
73
+ const pool = cur.accounts.filter((a) => !rejected.has(a.accountUuid));
72
74
  const active =
73
75
  (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
74
76
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
75
77
  null;
76
- if (active != null && currentWins(active, cur.accounts, everyone)) {
78
+ if (active != null && currentWins(active, pool, everyone)) {
77
79
  if (drifted) return swapTo(active, "drift-reconciled");
78
80
  const expiry = weeklyExpiry(active, now);
79
81
  const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
@@ -85,60 +87,83 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
85
87
  });
86
88
  return 0;
87
89
  }
88
- const best = pickBest(cur.accounts, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
90
+ const best = pickBest(pool, { ...everyone, currentAccountUuid: active?.accountUuid ?? null });
89
91
  if (!best) break;
90
92
  try {
91
93
  await performSwap(best);
92
94
  } catch (e) {
95
+ rejected.add(best.accountUuid);
93
96
  if (e instanceof InvalidGrantError) {
94
97
  deadGrants.push(best.label);
95
98
  if (!json) console.error(c.red(deadGrantMessage(best)));
96
99
  continue;
97
100
  }
101
+ if (isSkippableSwapError(e)) {
102
+ if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
103
+ continue;
104
+ }
98
105
  throw e;
99
106
  }
100
107
  emit(`${c.green("↻")} switched to ${c.bold(best.label)}`, { switched: true, account: best.label, reason: "best" });
101
108
  return 0;
102
109
  }
103
110
 
104
- const fresh = loadAccounts();
105
- const earliest = pickEarliestReset(fresh.accounts, everyoneIn(fresh.accounts));
106
- const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
107
- if (!earliest) {
108
- if (reauth.length > 0) {
109
- return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
110
- paint: c.yellow,
111
- extra: { reauthNeeded: reauth },
111
+ while (true) {
112
+ const fresh = loadAccounts();
113
+ const pool = fresh.accounts.filter((a) => !rejected.has(a.accountUuid));
114
+ const earliest = pickEarliestReset(pool, everyoneIn(fresh.accounts));
115
+ const reauth = fresh.accounts.filter((a) => a.needsReauth).map((a) => a.label);
116
+ if (!earliest) {
117
+ if (reauth.length > 0) {
118
+ return fail(`no switchable account - reauth needed (run \`tokenmaxxing auth --all\`): ${reauth.join(", ")}`, {
119
+ paint: c.yellow,
120
+ extra: { reauthNeeded: reauth },
121
+ });
122
+ }
123
+ const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
124
+ if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
125
+ emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
126
+ switched: false,
127
+ account: freshActive?.label ?? null,
128
+ reason: "unknown-resets",
129
+ });
130
+ return 0;
131
+ }
132
+ const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
133
+ const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
134
+ if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
135
+ const msg = availableAt == null
136
+ ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
137
+ : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
138
+ emit(c.yellow(msg), {
139
+ switched: false,
140
+ account: earliest.account.label,
141
+ reason: availableAt == null ? "no-target" : "all-at-limit",
142
+ availableAt,
143
+ reauthNeeded: reauth,
112
144
  });
145
+ return 0;
146
+ }
147
+ try {
148
+ await performSwap(earliest.account);
149
+ } catch (e) {
150
+ rejected.add(earliest.account.accountUuid);
151
+ if (e instanceof InvalidGrantError) {
152
+ deadGrants.push(earliest.account.label);
153
+ if (!json) console.error(c.red(deadGrantMessage(earliest.account)));
154
+ continue;
155
+ }
156
+ if (isSkippableSwapError(e)) {
157
+ if (!json) console.error(c.yellow(`${earliest.account.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
158
+ continue;
159
+ }
160
+ throw e;
161
+ }
162
+ emit(`${c.green("↻")} switched to ${c.bold(earliest.account.label)}`, { switched: true, account: earliest.account.label, reason: "earliest-reset", availableAt, reauthNeeded: reauth });
163
+ if (availableAt != null && !json) {
164
+ console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
113
165
  }
114
- const freshActive = fresh.accounts.find((a) => a.accountUuid === fresh.activeAccountUuid) ?? null;
115
- if (drifted && freshActive) return swapTo(freshActive, "drift-reconciled");
116
- emit(c.yellow("all accounts at their limit with unknown reset times (unparsed reset clocks? see tokenmaxxing.log) - staying put"), {
117
- switched: false,
118
- account: freshActive?.label ?? null,
119
- reason: "unknown-resets",
120
- });
121
- return 0;
122
- }
123
- const reauthNote = reauth.length ? ` - re-auth needed: ${reauth.join(", ")}` : "";
124
- const availableAt = earliest.availableAt > now ? earliest.availableAt : null;
125
- if (earliest.account.accountUuid === fresh.activeAccountUuid && !drifted) {
126
- const msg = availableAt == null
127
- ? `staying on ${c.bold(earliest.account.label)} - no usable switch target${reauthNote}`
128
- : `all accounts at limit - staying on ${c.bold(earliest.account.label)} (${fmtReset(availableAt, now)})${reauthNote}`;
129
- emit(c.yellow(msg), {
130
- switched: false,
131
- account: earliest.account.label,
132
- reason: availableAt == null ? "no-target" : "all-at-limit",
133
- availableAt,
134
- reauthNeeded: reauth,
135
- });
136
166
  return 0;
137
167
  }
138
- const code = await swapTo(earliest.account, "earliest-reset", { availableAt, reauthNeeded: reauth });
139
- if (code === 0 && availableAt != null && !json) {
140
- console.log(c.yellow(`all accounts at limit - ${c.bold(earliest.account.label)} recovers soonest (${fmtReset(availableAt, now)})${reauthNote}`));
141
- }
142
- return code;
143
168
  });
144
169
  }
package/src/lib/decide.ts CHANGED
@@ -209,7 +209,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
209
209
  const cur = loadAccounts();
210
210
  const active = seatOf(cur);
211
211
  const pool = usable(cur.accounts);
212
- const ctxAll = { now, thresholds: barsOf(pool), currentAccountUuid: null, switchFamilies, holdMargin };
212
+ const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
213
213
  if (currentWins(active, pool, ctxAll)) {
214
214
  return { swapped: false, account: null, reason: "current-best" };
215
215
  }
@@ -231,7 +231,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
231
231
  const cur = loadAccounts();
232
232
  const seat = seatOf(cur);
233
233
  const pool = usable(cur.accounts);
234
- const thresholds = barsOf(pool);
234
+ const thresholds = barsOf(cur.accounts);
235
235
  if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
236
236
  const best = pickBest(pool, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
237
237
  if (!best) break;
package/src/lib/oauth.ts CHANGED
@@ -29,11 +29,19 @@ export class RefreshRejectedError extends Error {
29
29
  }
30
30
  }
31
31
 
32
+ export class IdentityUnavailableError extends Error {
33
+ constructor(public readonly status: number | null, public readonly detail: string) {
34
+ super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
35
+ this.name = "IdentityUnavailableError";
36
+ }
37
+ }
38
+
32
39
  export function isDeadCredential(creds: OAuthCreds): boolean {
33
40
  return creds.refreshToken === "" || creds.accessToken === "";
34
41
  }
35
42
 
36
43
  export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
44
+ if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
37
45
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
38
46
  const body = {
39
47
  grant_type: "refresh_token",
@@ -93,14 +101,19 @@ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIden
93
101
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
94
102
  });
95
103
  } catch (e) {
96
- throw new Error(`profile endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
104
+ throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
97
105
  }
98
- const text = await res.text();
99
- if (!res.ok) throw new Error(`identity check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
106
+ let text: string;
107
+ try {
108
+ text = await res.text();
109
+ } catch (e) {
110
+ throw new IdentityUnavailableError(res.status, `profile response body unreadable: ${e instanceof Error ? e.message : String(e)}`);
111
+ }
112
+ if (!res.ok) throw new IdentityUnavailableError(res.status, safeErrorDetail({ text }));
100
113
  const parsed = ProfileResponseSchema.safeParse((() => {
101
114
  try { return JSON.parse(text); } catch { return null; }
102
115
  })());
103
- if (!parsed.success) throw new Error(`profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
116
+ if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
104
117
  return TokenIdentitySchema.parse({
105
118
  accountUuid: parsed.data.account.uuid,
106
119
  email: parsed.data.account.email ?? null,
package/src/lib/sample.ts CHANGED
@@ -4,9 +4,12 @@ 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, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
7
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
8
8
  import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
- import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
+ import { loadAccounts } from "./state.ts";
10
+ import { keepRotatedPair } from "./swap.ts";
11
+ import { log } from "./log.ts";
12
+ import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
13
 
11
14
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
15
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
@@ -16,8 +19,8 @@ export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
16
19
 
17
20
  const IdentityCheckSchema = z.discriminatedUnion("status", [
18
21
  z.object({ status: z.literal("match") }),
19
- z.object({ status: z.literal("mismatch"), reason: z.string() }),
20
- z.object({ status: z.literal("unavailable"), reason: z.string() }),
22
+ z.object({ status: z.literal("mismatch"), reason: z.string(), owner: TokenIdentitySchema }),
23
+ z.object({ status: z.literal("unavailable"), reason: z.string(), stale: z.boolean() }),
21
24
  ]);
22
25
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
23
26
 
@@ -26,10 +29,14 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
26
29
  try {
27
30
  identity = await fetchTokenIdentity(creds.accessToken);
28
31
  } catch (e) {
29
- return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
32
+ return {
33
+ status: "unavailable",
34
+ reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}`,
35
+ stale: e instanceof IdentityUnavailableError && e.status === 401,
36
+ };
30
37
  }
31
38
  if (identity.accountUuid === account.accountUuid) return { status: "match" };
32
- return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}` };
39
+ return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
33
40
  }
34
41
 
35
42
  function refreshPlanFields(account: Account, creds: OAuthCreds): void {
@@ -54,6 +61,8 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
54
61
  }
55
62
 
56
63
  const liveRaw = await readItem(liveTarget());
64
+ let liveAccount: string | null = null;
65
+ let liveToken: string | null = null;
57
66
  if (liveRaw != null) {
58
67
  let liveCreds: OAuthCreds;
59
68
  try {
@@ -62,7 +71,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
62
71
  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` };
63
72
  }
64
73
  if (!isDeadCredential(liveCreds)) {
65
- let liveAccount: string;
74
+ liveToken = liveCreds.accessToken;
66
75
  try {
67
76
  liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
68
77
  } catch (e) {
@@ -74,28 +83,64 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
74
83
  }
75
84
  }
76
85
 
86
+ const relocate = async (owner: TokenIdentity): Promise<string> => {
87
+ const pooled = loadAccounts().accounts.find((a) => a.accountUuid === owner.accountUuid) ?? null;
88
+ if (pooled == null || pooled.accountUuid === liveAccount) return "was left in place";
89
+ await writeItem(parkedTarget(pooled.keychainItem), JSON.stringify({ claudeAiOauth: creds }));
90
+ log("swap.parked_relocated", { account: pooled.accountUuid.slice(0, 8) });
91
+ return "was copied to that account's slot";
92
+ };
93
+
77
94
  if (isAccessTokenExpiring(creds, 300_000)) {
78
95
  const owner = await checkIdentity(creds, account);
79
96
  if (owner.status === "mismatch") {
97
+ const kept = await relocate(owner.owner);
80
98
  account.needsReauth = true;
81
- return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
99
+ return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
82
100
  }
83
- try {
84
- creds = await refreshCredential(creds);
85
- await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
86
- } catch (e) {
87
- if (e instanceof InvalidGrantError) {
88
- account.needsReauth = true;
89
- return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
101
+ if (owner.status === "unavailable" && !owner.stale) {
102
+ return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
103
+ }
104
+ const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
105
+ try {
106
+ return { fresh: await refreshCredential(creds) };
107
+ } catch (e) {
108
+ if (e instanceof InvalidGrantError) {
109
+ account.needsReauth = true;
110
+ return { failed: { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" } };
111
+ }
112
+ return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
113
+ }
114
+ };
115
+ let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
116
+ if (owner.status === "match") {
117
+ result = await rotate();
118
+ } else {
119
+ try {
120
+ result = await withClaudeRefreshLock(async (lock) => {
121
+ const rotated = await rotate();
122
+ if ("failed" in rotated) return rotated;
123
+ const verified = await checkIdentity(rotated.fresh, account);
124
+ if (verified.status !== "mismatch") return rotated;
125
+ const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
126
+ const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
127
+ account.needsReauth = true;
128
+ return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
129
+ });
130
+ } catch (e) {
131
+ return { ok: false, reason: `cannot arbitrate a stale parked credential: ${e instanceof Error ? e.message : String(e)}` };
90
132
  }
91
- return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
92
133
  }
134
+ if ("failed" in result) return result.failed;
135
+ creds = result.fresh;
136
+ await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
93
137
  }
94
138
 
95
139
  const identity = await checkIdentity(creds, account);
96
140
  if (identity.status === "mismatch") {
141
+ const kept = await relocate(identity.owner);
97
142
  account.needsReauth = true;
98
- return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
143
+ return { ok: false, reason: `${identity.reason} - this account's own credential is gone; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
99
144
  }
100
145
  if (identity.status === "unavailable") {
101
146
  return { ok: false, reason: identity.reason };
package/src/lib/swap.ts CHANGED
@@ -1,11 +1,11 @@
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, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
3
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, 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";
7
7
  import { pickBest, type PickCtx } from "./picker.ts";
8
- import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
8
+ import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
9
 
10
10
  function parseBlob(raw: string) {
11
11
  return CredentialBlobSchema.parse(JSON.parse(raw));
@@ -44,7 +44,12 @@ export async function performSwap(target: Account): Promise<void> {
44
44
  }
45
45
  }
46
46
  if (liveCreds != null) {
47
- const identity = await fetchTokenIdentity(liveCreds.accessToken);
47
+ let identity: TokenIdentity;
48
+ try {
49
+ identity = await fetchTokenIdentity(liveCreds.accessToken);
50
+ } catch (e) {
51
+ 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`);
52
+ }
48
53
  liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
49
54
  if (!liveOwner) {
50
55
  throw new Error(
@@ -73,16 +78,51 @@ export async function performSwap(target: Account): Promise<void> {
73
78
  return new InvalidGrantError(detail);
74
79
  };
75
80
  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
- }
81
+ let parkedOwner: TokenIdentity | null = null;
80
82
  try {
81
- fresh = await refreshCredential(parked);
83
+ parkedOwner = await fetchTokenIdentity(parked.accessToken);
82
84
  } catch (e) {
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 });
85
- throw e;
85
+ if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
86
+ log("swap.parked_token_stale", { account: target.accountUuid.slice(0, 8) });
87
+ }
88
+ if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
89
+ const owner = idx.accounts.find((a) => a.accountUuid === parkedOwner.accountUuid) ?? null;
90
+ let kept = "was left in place";
91
+ if (owner != null && owner.accountUuid !== liveOwner?.accountUuid) {
92
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: parked }));
93
+ log("swap.parked_relocated", { account: owner.accountUuid.slice(0, 8) });
94
+ kept = "was copied to that account's slot";
95
+ }
96
+ 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}\``);
97
+ }
98
+ const refreshParked = async (): Promise<OAuthCreds> => {
99
+ try {
100
+ return await refreshCredential(parked);
101
+ } catch (e) {
102
+ if (e instanceof InvalidGrantError) throw markDead(e.detail);
103
+ if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
104
+ throw e;
105
+ }
106
+ };
107
+ if (parkedOwner != null) {
108
+ fresh = await refreshParked();
109
+ } else {
110
+ fresh = await withClaudeRefreshLock(async (lock) => {
111
+ const rotated = await refreshParked();
112
+ let freshOwner: TokenIdentity;
113
+ try {
114
+ freshOwner = await fetchTokenIdentity(rotated.accessToken);
115
+ } catch (e) {
116
+ await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: rotated }));
117
+ 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`);
118
+ }
119
+ if (freshOwner.accountUuid !== target.accountUuid) {
120
+ const owner = idx.accounts.find((a) => a.accountUuid === freshOwner.accountUuid) ?? null;
121
+ const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerUuid: liveOwner?.accountUuid ?? null, expectedLiveToken, lock });
122
+ throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
123
+ }
124
+ return rotated;
125
+ });
86
126
  }
87
127
  await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
88
128
  }
@@ -117,8 +157,43 @@ export async function performSwap(target: Account): Promise<void> {
117
157
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
118
158
  }
119
159
 
160
+ export async function keepRotatedPair(input: {
161
+ fresh: OAuthCreds;
162
+ owner: Account | null;
163
+ fallback: Account;
164
+ liveOwnerUuid: string | null;
165
+ expectedLiveToken: string | null;
166
+ lock: { compromised: () => boolean };
167
+ }): Promise<string> {
168
+ const { fresh, owner } = input;
169
+ if (owner == null) {
170
+ await writeItem(parkedTarget(input.fallback.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
171
+ log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.accountUuid.slice(0, 8) });
172
+ return "was kept in this account's stamped slot because its owner is not in the pool";
173
+ }
174
+ const park = async (): Promise<void> => {
175
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
176
+ log("swap.rotated_parked_rescued", { account: owner.accountUuid.slice(0, 8) });
177
+ };
178
+ if (input.liveOwnerUuid !== owner.accountUuid) {
179
+ await park();
180
+ return "was parked under that account";
181
+ }
182
+ try {
183
+ const currentLive = await readItem(liveTarget());
184
+ if (input.lock.compromised()) throw new Error("refresh lock compromised");
185
+ if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
186
+ await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
187
+ log("swap.rotated_live_rescued", { account: owner.accountUuid.slice(0, 8) });
188
+ return "was installed into the live store so that account's sessions continue";
189
+ } catch (e) {
190
+ await park();
191
+ return `was parked under that account (${e instanceof Error ? e.message : String(e)})`;
192
+ }
193
+ }
194
+
120
195
  export function isSkippableSwapError(e: unknown): boolean {
121
- return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
196
+ return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
122
197
  }
123
198
 
124
199
  export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {