tokenmaxxing 1.16.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
@@ -49,7 +49,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
49
49
  The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
50
50
 
51
51
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
52
- 1. Read `usage.json`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default 80% of the 5h window) unless a screening bar is already crossed. Engaged-but-under-every-bar runs the GREEDY convergence: stay when the current account wins or ties, else swap onto the strictly better account. A crossed bar forces the hard path (metered per `organizationUuid`).
52
+ 1. Read `usage.json`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default 80% of the 5h window) unless a screening bar is already crossed. Engaged-but-under-every-bar runs the GREEDY convergence: stay when the current account wins or ties, else swap onto the strictly better account. A crossed bar forces the hard path (metered per live `accountUuid`; team seats share one organization, so the organization never identifies a seat).
53
53
  2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
54
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
55
55
 
@@ -57,8 +57,8 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
57
57
  The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
58
58
 
59
59
  ### 3.4 Swap sequence (under the lock)
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 roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. 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.
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** - 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.16.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",
@@ -1,15 +1,16 @@
1
1
  ---
2
2
  name: credentials-hygiene
3
- description: Credential and identity rules for tokenmaxxing (fetchTokenOrg identity, flock, no blob compare, never print secrets). Use before any auth, swap, or credential-touching change.
3
+ description: Credential and identity rules for tokenmaxxing (fetchTokenIdentity account-uuid identity, flock, no blob compare, never print secrets). Use before any auth, swap, or credential-touching change.
4
4
  ---
5
5
 
6
6
  # Credentials hygiene
7
7
 
8
8
  ## Identity
9
9
 
10
- - Identity is whatever `fetchTokenOrg` reports, never a stored label and never a blob byte compare. Two rotations of one account differ byte-for-byte.
10
+ - Identity is the `accountUuid` that `fetchTokenIdentity` reports for a token (`GET /api/oauth/profile`), never the organization, never a stored label, and never a blob byte compare. Every seat of a Team plan shares one `organizationUuid`, so an org-keyed lookup collapses the seats onto one account. Two rotations of one account differ byte-for-byte.
11
11
  - Park a credential under its token's real owner. Commit the active label in the same critical section as credential writes.
12
12
  - Every live-store write goes through `withClaudeRefreshLock` (Claude). Near-expiry sessions can rotate into the live store at any moment.
13
+ - A refresh rotation revokes the previous access token at once, so refreshing any copy of a credential that is live elsewhere kills those sessions' token.
13
14
 
14
15
  ## Isolation
15
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.16.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/check.ts CHANGED
@@ -23,7 +23,7 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
23
23
  emitError({ json, message: `check failed: ${detail}` });
24
24
  return 1;
25
25
  }
26
- const delayMs = checkDelayMs({ cfg, org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
26
+ const delayMs = checkDelayMs({ cfg, account: readOAuthAccount()?.accountUuid ?? null, now, decision: d });
27
27
  saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
28
28
  if (json) {
29
29
  emitJson({
package/src/cli/doctor.ts CHANGED
@@ -5,14 +5,14 @@ import { checkTimerHealthy, findClaudeShadowers, isBinDirAhead, shellRcPath, tim
5
5
  import { paths } from "../lib/paths.ts";
6
6
  import { loadAccounts, loadConfig } from "../lib/state.ts";
7
7
  import { readItem, liveTarget, parkedTarget } from "../lib/credstore.ts";
8
- import { isAccessTokenExpiring, fetchTokenOrg } from "../lib/oauth.ts";
9
- import { CredentialBlobSchema, type RolesResponse } from "../lib/types.ts";
8
+ import { isAccessTokenExpiring, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
9
+ import { CredentialBlobSchema, type TokenIdentity } from "../lib/types.ts";
10
10
  import { c, emitJson } from "./render.ts";
11
11
 
12
- async function blobOrg(raw: string): Promise<RolesResponse | null> {
12
+ async function blobIdentity(raw: string): Promise<TokenIdentity | null> {
13
13
  const creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
14
14
  if (isAccessTokenExpiring(creds)) return null;
15
- return fetchTokenOrg(creds.accessToken);
15
+ return fetchTokenIdentity(creds.accessToken);
16
16
  }
17
17
 
18
18
  export async function cmdDoctor(json = false): Promise<number> {
@@ -54,8 +54,8 @@ export async function cmdDoctor(json = false): Promise<number> {
54
54
  const active = idx.accounts.find((a) => a.accountUuid === idx.activeAccountUuid);
55
55
  if (live && active) {
56
56
  try {
57
- const org = await blobOrg(live);
58
- if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
57
+ const identity = await blobIdentity(live);
58
+ if (identity) check(identity.accountUuid === active.accountUuid, `live credential identity matches active (${active.email})`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing switch\``);
59
59
  else note("live credential identity unverifiable (access token expired)");
60
60
  } catch (e) {
61
61
  check(false, `live credential identity matches active (${active.email})`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
@@ -67,8 +67,8 @@ export async function cmdDoctor(json = false): Promise<number> {
67
67
  check(!!parked, `parked credential present for ${a.email}`, `run \`tokenmaxxing auth ${a.label}\``);
68
68
  if (parked) {
69
69
  try {
70
- const org = await blobOrg(parked);
71
- if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - run \`tokenmaxxing auth ${a.label}\``);
70
+ const identity = await blobIdentity(parked);
71
+ if (identity) check(identity.accountUuid === a.accountUuid, `parked credential identity matches ${a.email}`, `token belongs to ${describeIdentity(identity)} - run \`tokenmaxxing auth ${a.label}\``);
72
72
  else note(`${a.email} identity unverifiable (access token expired)`);
73
73
  } catch (e) {
74
74
  check(false, `parked credential identity matches ${a.email}`, (e instanceof Error ? e.message : String(e)).slice(0, 100));
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, fetchTokenOrg } 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());
@@ -94,9 +99,9 @@ export async function cmdInit(): Promise<number> {
94
99
  await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
95
100
  });
96
101
  }
97
- const org = await fetchTokenOrg(creds.accessToken);
98
- if (org.organization_uuid !== oauthAccount.organizationUuid) {
99
- console.error(c.red(`the live credential belongs to ${org.organization_name}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
102
+ const identity = await fetchTokenIdentity(creds.accessToken);
103
+ if (identity.accountUuid !== oauthAccount.accountUuid) {
104
+ console.error(c.red(`the live credential belongs to ${describeIdentity(identity)}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
100
105
  console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
101
106
  return 1;
102
107
  }
package/src/cli/rm.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { deleteItem, isolatedTarget, liveTarget, parkedTarget, readItem } from "../lib/credstore.ts";
4
- import { fetchTokenOrg } from "../lib/oauth.ts";
4
+ import { fetchTokenIdentity } from "../lib/oauth.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { credItemFor, paths } from "../lib/paths.ts";
7
7
  import { loadAccounts, saveAccounts } from "../lib/state.ts";
@@ -27,18 +27,18 @@ export async function cmdRm(selector?: string, json = false): Promise<number> {
27
27
  }
28
28
  const live = await readItem(liveTarget());
29
29
  if (live != null) {
30
- let liveOrg: string;
30
+ let liveAccount: string;
31
31
  try {
32
32
  const liveCreds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
33
- liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
33
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
34
34
  } catch (e) {
35
35
  emitError({
36
36
  json,
37
- message: `cannot verify which account the LIVE credential belongs to (${e instanceof Error ? e.message : String(e)}) - refusing to remove while the live owner is unknown; repair the live credential or retry once the roles endpoint is reachable.`,
37
+ message: `cannot verify which account the LIVE credential belongs to (${e instanceof Error ? e.message : String(e)}) - refusing to remove while the live owner is unknown; repair the live credential or retry once the profile endpoint is reachable.`,
38
38
  });
39
39
  return 1;
40
40
  }
41
- if (liveOrg === a.organizationUuid) {
41
+ if (liveAccount === a.accountUuid) {
42
42
  emitError({ json, message: `${a.email}'s credential is currently LIVE (the active label is stale - a manual /login drifted it); run \`tokenmaxxing switch\` to move off it first.` });
43
43
  return 1;
44
44
  }
package/src/cli/status.ts CHANGED
@@ -116,17 +116,17 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
116
116
  const teeAt = tee?.at ?? null;
117
117
  const modelUsage = loadModelUsage();
118
118
  const liveOAuth = readOAuthAccount();
119
- const activeOrg = liveOAuth?.organizationUuid ?? null;
119
+ const liveAccount = liveOAuth?.accountUuid ?? null;
120
120
  const probeOne = async (a: Account) => {
121
- const isActive = activeOrg != null && activeOrg === a.organizationUuid;
121
+ const isActive = liveAccount != null && liveAccount === a.accountUuid;
122
122
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
123
123
  const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
124
124
  const fromStatusLine: FullUsage | null =
125
- isActive && live && teeCurrent && live.org === a.organizationUuid
125
+ isActive && live && teeCurrent && live.account === a.accountUuid
126
126
  ? {
127
127
  session: live.fiveHour,
128
128
  weekAll: live.sevenDay,
129
- perModel: modelUsage && modelUsage.org === a.organizationUuid ? modelUsage.perModel : {},
129
+ perModel: modelUsage && modelUsage.account === a.accountUuid ? modelUsage.perModel : {},
130
130
  }
131
131
  : null;
132
132
  let viaTee = false;
@@ -159,7 +159,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
159
159
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
160
160
  }
161
161
  };
162
- const activeAccount = idx.accounts.find((a) => activeOrg != null && activeOrg === a.organizationUuid) ?? null;
162
+ const activeAccount = idx.accounts.find((a) => liveAccount != null && liveAccount === a.accountUuid) ?? null;
163
163
  if (activeAccount) await probeOne(activeAccount);
164
164
  try {
165
165
  await ensureLiveTokenFresh();
@@ -172,7 +172,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
172
172
 
173
173
  const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
174
174
  const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
175
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
175
+ const liveAccount = readOAuthAccount()?.accountUuid ?? null;
176
176
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
177
177
  const accounts = ordered.map((a): ClaudeStatusAccount => {
178
178
  const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
@@ -185,7 +185,7 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
185
185
  accountUuid: a.accountUuid,
186
186
  organizationUuid: a.organizationUuid,
187
187
  tier: claudeTierLabel(a),
188
- active: activeOrg != null && a.organizationUuid === activeOrg,
188
+ active: liveAccount != null && a.accountUuid === liveAccount,
189
189
  needsReauth: a.needsReauth === true,
190
190
  exhausted: isExhausted(a, { now, thresholds: bars, currentAccountUuid: idx.activeAccountUuid, switchFamilies: families }),
191
191
  usage: aggregate ? { fiveHour: currentWindow(aggregate.fiveHour, false, now), week: currentWindow(aggregate.sevenDay, true, now) } : null,
package/src/cli/switch.ts CHANGED
@@ -32,7 +32,6 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
32
32
  const idx = loadAccounts();
33
33
  const liveClaim = readOAuthAccount();
34
34
  const claimed = liveClaim?.accountUuid ?? null;
35
- const claimedOrg = liveClaim?.organizationUuid ?? null;
36
35
  const drifted = claimed != null && claimed !== idx.activeAccountUuid;
37
36
 
38
37
  const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
@@ -71,7 +70,7 @@ export async function cmdSwitch(selector?: string, json = false): Promise<number
71
70
  const cur = loadAccounts();
72
71
  const everyone = everyoneIn(cur.accounts);
73
72
  const active =
74
- (claimedOrg != null ? cur.accounts.find((a) => a.organizationUuid === claimedOrg) : null) ??
73
+ (claimed != null ? cur.accounts.find((a) => a.accountUuid === claimed) : null) ??
75
74
  cur.accounts.find((a) => a.accountUuid === cur.activeAccountUuid) ??
76
75
  null;
77
76
  if (active != null && currentWins(active, cur.accounts, everyone)) {
@@ -9,7 +9,6 @@ import { makeColors, makeUsagePaint } from "../cli/render.ts";
9
9
  import { fmtResetShort } from "../lib/usage.ts";
10
10
  import {
11
11
  AccountsIndexSchema,
12
- RateLimitsStdinSchema,
13
12
  StatusLineStdinSchema,
14
13
  UsageWindowSchema,
15
14
  type Account,
@@ -24,7 +23,7 @@ const RenderCtxSchema = z.object({
24
23
  perModel: z.record(z.string(), UsageWindowSchema),
25
24
  switchModels: z.array(z.string()),
26
25
  worktree: z.string().nullable(),
27
- liveOrg: z.string().nullable(),
26
+ liveAccount: z.string().nullable(),
28
27
  now: z.number(),
29
28
  color: z.boolean(),
30
29
  truecolor: z.boolean(),
@@ -79,7 +78,7 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
79
78
  windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
80
79
  }
81
80
  const seatUuid =
82
- (ctx.liveOrg != null ? ctx.accounts.accounts.find((a) => a.organizationUuid === ctx.liveOrg)?.accountUuid : undefined) ??
81
+ (ctx.liveAccount != null && ctx.accounts.accounts.some((a) => a.accountUuid === ctx.liveAccount) ? ctx.liveAccount : null) ??
83
82
  ctx.accounts.activeAccountUuid;
84
83
  const active =
85
84
  windows.length > 0
@@ -120,17 +119,13 @@ export async function runStatusline(): Promise<number> {
120
119
  }
121
120
  const now = Date.now();
122
121
 
123
- let stdinOrg: string | null = null;
124
-
125
- let org: string | null = null;
122
+ let account: string | null = null;
126
123
  try {
127
- org = readOAuthAccount()?.organizationUuid ?? null;
124
+ account = readOAuthAccount()?.accountUuid ?? null;
128
125
  const windows = obj == null ? null : parseStatusLineStdin(obj);
129
126
  const lastSwapAt = loadLastSwapAt();
130
- stdinOrg = windows != null ? (RateLimitsStdinSchema.safeParse(obj).data?.organizationUuid ?? null) : null;
131
- const teeOrg = stdinOrg ?? org;
132
- if (windows && (stdinOrg != null || lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
133
- const state: UsageState = { ...windows, org: teeOrg, ts: now, model: parseStatusLineModel(obj) };
127
+ if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
128
+ const state: UsageState = { ...windows, account, ts: now, model: parseStatusLineModel(obj) };
134
129
  writeUsage(state);
135
130
  }
136
131
  } catch {
@@ -145,10 +140,10 @@ export async function runStatusline(): Promise<number> {
145
140
  const colorterm = z.string().optional().parse(process.env.COLORTERM);
146
141
  const ctx: RenderCtx = {
147
142
  accounts: loadAccounts(),
148
- perModel: modelUsage && modelUsage.org === (stdinOrg ?? org) ? modelUsage.perModel : {},
143
+ perModel: modelUsage && modelUsage.account === account ? modelUsage.perModel : {},
149
144
  switchModels: cfg.policy.switchModels,
150
145
  worktree: dir == null ? null : worktreeName(dir),
151
- liveOrg: stdinOrg ?? org,
146
+ liveAccount: account,
152
147
  now,
153
148
  color: !process.env.NO_COLOR,
154
149
  truecolor: colorterm != null && (colorterm.includes("truecolor") || colorterm.includes("24bit")),
@@ -30,7 +30,7 @@ async function readStdin(): Promise<string> {
30
30
  export async function runStopFailureHook(): Promise<number> {
31
31
  if (process.env.TOKENMAXXING_PROBE) return 0;
32
32
 
33
- const org = readOAuthAccount()?.organizationUuid ?? null;
33
+ const account = readOAuthAccount()?.accountUuid ?? null;
34
34
  const now = Date.now();
35
35
  const raw = await readStdin();
36
36
  const parsed = StopFailureStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
@@ -51,12 +51,12 @@ export async function runStopFailureHook(): Promise<number> {
51
51
  const limit = found ? classifyEnforcedLimit(found.row, cfg.policy.switchModels) : null;
52
52
 
53
53
  let enforced: EnforcedLimit | null = null;
54
- if (limit && found && org) {
54
+ if (limit && found && account) {
55
55
  if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
56
- const stamp = await recordEnforcedLimit({ limit, org, now });
56
+ const stamp = await recordEnforcedLimit({ limit, account, now });
57
57
  log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
58
- if (stamp.outcome !== "org-moved") {
59
- enforced = { org, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
58
+ if (stamp.outcome !== "account-moved") {
59
+ enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
60
60
  }
61
61
  } else {
62
62
  log("stopfailure.unproven", { kind: limit.kind });
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";
@@ -36,22 +35,22 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
36
35
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
37
36
  }
38
37
 
39
- function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, org: string | null): Account[] {
38
+ function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
40
39
  return accounts.map((a) => {
41
- if (org == null || a.organizationUuid !== org) return a;
42
- const live = u && u.org === org ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
43
- const perModel = mu && mu.org === org && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
40
+ if (account == null || a.accountUuid !== account) return a;
41
+ const live = u && u.account === account ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
42
+ const perModel = mu && mu.account === account && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
44
43
  return { ...a, ...live, ...perModel };
45
44
  });
46
45
  }
47
46
 
48
- function isOver(u: UsageState | null, mu: ModelUsageState | null, org: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
49
- if (!u || !org || u.org !== org) return false;
47
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
48
+ if (!u || !account || u.account !== account) return false;
50
49
  if (
51
50
  liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
52
51
  liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
53
52
  ) return true;
54
- if (mu && mu.org === org) {
53
+ if (mu && mu.account === account) {
55
54
  for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
56
55
  const cap = capForFamily(mu, family, now);
57
56
  if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }) >= bars.weekly) return true;
@@ -64,9 +63,9 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
64
63
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
65
64
  }
66
65
 
67
- function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
68
- if (!u || !org || u.org !== org) return false;
69
- return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, org, bars, cfg, now);
66
+ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
67
+ if (!u || !account || u.account !== account) return false;
68
+ return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
70
69
  }
71
70
 
72
71
  const SnapshotsSchema = z.object({
@@ -76,41 +75,41 @@ const SnapshotsSchema = z.object({
76
75
  });
77
76
  type Snapshots = z.infer<typeof SnapshotsSchema>;
78
77
 
79
- function usageFresh(u: UsageState | null, uAt: number | null, org: string | null, ttl: number, now: number): boolean {
80
- return u != null && u.org === org && uAt != null && now - uAt <= ttl;
78
+ function usageFresh(u: UsageState | null, uAt: number | null, account: string | null, ttl: number, now: number): boolean {
79
+ return u != null && u.account === account && uAt != null && now - uAt <= ttl;
81
80
  }
82
81
 
83
82
  function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
84
83
  if (!u || uAt == null) return u;
85
84
  const stored = account?.lastUsage;
86
- if (stored && account.lastUsageAt != null && u.org === account.organizationUuid && uAt < account.lastUsageAt) {
85
+ if (stored && account.lastUsageAt != null && u.account === account.accountUuid && uAt < account.lastUsageAt) {
87
86
  return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
88
87
  }
89
88
  return { ...u, ts: uAt };
90
89
  }
91
90
 
92
- async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
91
+ async function loadFreshSnapshots(cfg: Config, account: string | null, now: number): Promise<Snapshots> {
93
92
  const snap = loadUsageSnapshot();
94
93
  let u = snap?.state ?? null;
95
94
  let uAt = snap?.at ?? null;
96
95
  let mu = loadModelUsage();
97
96
  const ttl = cfg.policy.usagePollTtlMs;
98
- const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
99
- if (org && !probeAttempted && (!usageFresh(u, uAt, org, ttl, now) || needsPerModel(u, cfg))) {
97
+ const probeAttempted = mu != null && mu.account === account && now - mu.ts <= ttl;
98
+ if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
100
99
  const full = await probeUsage();
101
100
  const ts = Date.now();
102
- if (readOAuthAccount()?.organizationUuid === org) {
101
+ if (readOAuthAccount()?.accountUuid === account) {
103
102
  if (full) {
104
103
  const teed = loadUsageSnapshot();
105
- if (teed && usageFresh(teed.state, teed.at, org, ttl, ts)) {
104
+ if (teed && usageFresh(teed.state, teed.at, account, ttl, ts)) {
106
105
  u = teed.state;
107
106
  uAt = teed.at;
108
107
  } else {
109
- u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
108
+ u = { fiveHour: full.session, sevenDay: full.weekAll, account, ts, model: null };
110
109
  writeUsage(u);
111
110
  uAt = ts;
112
111
  }
113
- mu = { perModel: full.perModel, org, ts, sampledAt: ts };
112
+ mu = { perModel: full.perModel, account, ts, sampledAt: ts };
114
113
  saveModelUsage(mu);
115
114
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
116
115
  const rows = Object.keys(full.perModel);
@@ -118,7 +117,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
118
117
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
119
118
  }
120
119
  } else {
121
- mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
120
+ mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
122
121
  saveModelUsage(mu);
123
122
  }
124
123
  }
@@ -127,8 +126,8 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
127
126
  }
128
127
 
129
128
  export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
130
- const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
131
- const enforced0 = enforced && enforced.org === activeOrg ? enforced : null;
129
+ const activeAccount = readOAuthAccount()?.accountUuid ?? null;
130
+ const enforced0 = enforced && enforced.account === activeAccount ? enforced : null;
132
131
 
133
132
  const lastSwapAt = loadLastSwapAt();
134
133
  if (!enforced0 && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
@@ -137,17 +136,17 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
137
136
 
138
137
  const cfg = loadConfig();
139
138
 
140
- const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
139
+ const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
141
140
  const pool = loadAccounts();
142
- const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
141
+ const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.accountUuid === activeAccount));
143
142
  const bars0 = effectiveBars(cfg, {
144
- accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
143
+ accounts: overlayLive(pool.accounts, usage, mu, activeAccount),
145
144
  now,
146
145
  switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
147
146
  });
148
147
 
149
- if (!enforced0 && !isEngaged(usage, mu, activeOrg, bars0, cfg, now)) {
150
- const measured = usage != null && activeOrg != null && usage.org === activeOrg;
148
+ if (!enforced0 && !isEngaged(usage, mu, activeAccount, bars0, cfg, now)) {
149
+ const measured = usage != null && activeAccount != null && usage.account === activeAccount;
151
150
  if (!measured) {
152
151
  const replay = depletedReplay(now);
153
152
  if (replay) return replay;
@@ -157,25 +156,25 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
157
156
 
158
157
  return withLock(paths.lockFile, async () => {
159
158
  const idx = loadAccounts();
160
- const org2 = readOAuthAccount()?.organizationUuid ?? null;
161
- const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
159
+ const account2 = readOAuthAccount()?.accountUuid ?? null;
160
+ const enforced2 = enforced0 && enforced0.account === account2 ? enforced0 : null;
162
161
  const tee = loadUsageSnapshot();
163
- const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
162
+ const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
164
163
  const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
165
164
  const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
166
165
 
167
- if (org2 != null && !active) {
166
+ if (account2 != null && !active) {
168
167
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
169
168
  }
170
169
 
171
170
  if (active) {
172
171
  let sampled = false;
173
- if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
172
+ if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
174
173
  active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
175
174
  active.lastUsageAt = tee.at;
176
175
  sampled = true;
177
176
  }
178
- if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
177
+ if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
179
178
  active.lastPerModel = mu2.perModel;
180
179
  active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
181
180
  sampled = true;
@@ -188,49 +187,59 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
188
187
  const barsOf = (accounts: Account[]): Thresholds => effectiveBars(cfg, { accounts, now, switchFamilies });
189
188
  const bars = barsOf(idx.accounts);
190
189
 
191
- if (!enforced2 && !isEngaged(u2, mu2, org2, bars, cfg, now)) {
190
+ if (!enforced2 && !isEngaged(u2, mu2, account2, bars, cfg, now)) {
192
191
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
193
192
  }
194
193
 
195
194
  const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
196
- idx2.accounts.find((a) => a.organizationUuid === org2) ??
195
+ idx2.accounts.find((a) => a.accountUuid === account2) ??
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" };
218
226
  }
219
227
  };
220
- if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
228
+ if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
221
229
 
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" };
@@ -251,9 +260,9 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
251
260
  const fresh = loadAccounts();
252
261
  const current = seatOf(fresh);
253
262
  const ctx = { now, thresholds: hardBars(cfg), currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
254
- const enforcedUntil = enforced2 && current && current.organizationUuid === enforced2.org ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
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 });
@@ -291,7 +300,7 @@ function depletedReplay(now: number): SwapDecision | null {
291
300
  if (!rec || rec.waitUntil <= now) return null;
292
301
  const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
293
302
  if (!account) return null;
294
- if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
303
+ if (account.accountUuid !== (readOAuthAccount()?.accountUuid ?? null)) return null;
295
304
  return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
296
305
  }
297
306
 
@@ -306,19 +315,19 @@ export function postSwapProof(input: { swapAt: number | null; launchedAt: number
306
315
  return (errorAt ?? now) - swapAt >= POST_SWAP_COOLDOWN_MS;
307
316
  }
308
317
 
309
- const StampSchema = z.object({ outcome: z.enum(["stamped", "org-moved", "no-carrier"]), resetsAt: z.number().nullable() });
318
+ const StampSchema = z.object({ outcome: z.enum(["stamped", "account-moved", "no-carrier"]), resetsAt: z.number().nullable() });
310
319
  export type Stamp = z.infer<typeof StampSchema>;
311
320
 
312
- export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: string; now: number }): Promise<Stamp> {
313
- const { limit, org, now } = input;
321
+ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account: string; now: number }): Promise<Stamp> {
322
+ const { limit, account: accountUuid, now } = input;
314
323
  return withLock(paths.lockFile, () => {
315
- if ((readOAuthAccount()?.organizationUuid ?? null) !== org) return { outcome: "org-moved", resetsAt: limit.resetsAt };
324
+ if ((readOAuthAccount()?.accountUuid ?? null) !== accountUuid) return { outcome: "account-moved", resetsAt: limit.resetsAt };
316
325
  const prior = loadUsage();
317
- const priorSame = prior && prior.org === org ? prior : null;
326
+ const priorSame = prior && prior.account === accountUuid ? prior : null;
318
327
  const idx = loadAccounts();
319
- const account = idx.accounts.find((a) => a.organizationUuid === org);
328
+ const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
320
329
  const mu = loadModelUsage();
321
- const muSame = mu && mu.org === org ? mu : null;
330
+ const muSame = mu && mu.account === accountUuid ? mu : null;
322
331
  const carriedRows = muSame?.perModel ?? {};
323
332
  const sampledAt = muSame?.sampledAt ?? muSame?.ts;
324
333
  if (limit.kind === "model") {
@@ -328,14 +337,14 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
328
337
  const resetsAt = limit.resetsAt ?? nextWeeklyReset(knownReset ?? weeklyReset, now);
329
338
  saveModelUsage({
330
339
  perModel: { ...carriedRows, [limit.family]: { usedPercentage: 100, resetsAt } },
331
- org,
340
+ account: accountUuid,
332
341
  ts: now,
333
342
  sampledAt: resetsAt == null ? now : sampledAt ?? now,
334
343
  });
335
344
  log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
336
345
  return { outcome: "stamped", resetsAt };
337
346
  }
338
- saveModelUsage({ perModel: carriedRows, org, ts: now, sampledAt });
347
+ saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
339
348
  if (account && limit.resetsAt != null) {
340
349
  account.enforcedUntil = limit.resetsAt;
341
350
  saveAccounts(idx);
@@ -346,7 +355,7 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
346
355
  writeUsage({
347
356
  fiveHour: limit.kind === "session" ? window : carrier.fiveHour,
348
357
  sevenDay: limit.kind === "weekly" ? window : carrier.sevenDay,
349
- org,
358
+ account: accountUuid,
350
359
  ts: now,
351
360
  model: carrier.model,
352
361
  });
@@ -357,21 +366,21 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
357
366
 
358
367
  const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
359
368
 
360
- export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
361
- const { cfg, org, now, decision } = input;
369
+ export function checkDelayMs(input: { cfg: Config; account: string | null; now: number; decision: SwapDecision }): number {
370
+ const { cfg, account, now, decision } = input;
362
371
  const tick = cfg.policy.checkIntervalMs;
363
372
  const max = maxCheckDelayMs(cfg);
364
373
  if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
365
374
  const swapAt = loadLastSwapAt();
366
375
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
367
376
  const snap = loadUsageSnapshot();
368
- if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
377
+ if (!account || !snap || !usageFresh(snap.state, snap.at, account, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
369
378
  const accounts = loadAccounts().accounts;
370
- const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
379
+ const u = freshest(snap.state, snap.at, accounts.find((a) => a.accountUuid === account)) ?? { ...snap.state, ts: snap.at };
371
380
  const mu = loadModelUsage();
372
- const muSame = mu && mu.org === org ? mu : null;
381
+ const muSame = mu && mu.account === account ? mu : null;
373
382
  const families = gatedFamilies(u.model, cfg.policy.switchModels);
374
- const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
383
+ const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, account), now, switchFamilies: families });
375
384
  const heads = [
376
385
  bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
377
386
  bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.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,11 +1,11 @@
1
1
  import { z } from "zod";
2
- import { http, safeErrorDetail } from "./http.ts";
3
- import { RefreshResponseSchema, RolesResponseSchema, type OAuthCreds, type RolesResponse } from "./types.ts";
2
+ import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
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);
6
6
  const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_TOKEN_URL) ?? "https://platform.claude.com/v1/oauth/token";
7
7
  const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_CLIENT_ID) ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
8
- const ROLES_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_ROLES_URL) ?? "https://api.anthropic.com/api/oauth/claude_cli/roles";
8
+ const PROFILE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_PROFILE_URL) ?? "https://api.anthropic.com/api/oauth/profile";
9
9
 
10
10
  const DEFAULT_SCOPES = [
11
11
  "user:profile",
@@ -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((() => {
@@ -75,20 +86,29 @@ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now =
75
86
  return !creds.expiresAt || creds.expiresAt - now <= skewMs;
76
87
  }
77
88
 
78
- export async function fetchTokenOrg(accessToken: string): Promise<RolesResponse> {
89
+ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
79
90
  let res: Response;
80
91
  try {
81
- res = await http.get(ROLES_URL, {
82
- headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
92
+ res = await http.get(PROFILE_URL, {
93
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
83
94
  });
84
95
  } catch (e) {
85
- throw new Error(`roles endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
96
+ throw new Error(`profile endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
86
97
  }
87
98
  const text = await res.text();
88
- if (!res.ok) throw new Error(`roles check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
89
- const parsed = RolesResponseSchema.safeParse((() => {
99
+ if (!res.ok) throw new Error(`identity check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
100
+ const parsed = ProfileResponseSchema.safeParse((() => {
90
101
  try { return JSON.parse(text); } catch { return null; }
91
102
  })());
92
- if (!parsed.success) throw new Error(`roles endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
93
- return parsed.data;
103
+ if (!parsed.success) throw new Error(`profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
104
+ return TokenIdentitySchema.parse({
105
+ accountUuid: parsed.data.account.uuid,
106
+ email: parsed.data.account.email ?? null,
107
+ organizationUuid: parsed.data.organization.uuid,
108
+ organizationName: parsed.data.organization.name ?? null,
109
+ });
110
+ }
111
+
112
+ export function describeIdentity(id: TokenIdentity): string {
113
+ return `${id.email ?? id.organizationName ?? "unknown"} (account ${id.accountUuid.slice(0, 8)})`;
94
114
  }
package/src/lib/sample.ts CHANGED
@@ -4,9 +4,9 @@ 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, fetchTokenOrg, 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
- import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
9
+ import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
10
 
11
11
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
12
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
@@ -22,14 +22,14 @@ const IdentityCheckSchema = z.discriminatedUnion("status", [
22
22
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
23
23
 
24
24
  async function checkIdentity(creds: OAuthCreds, account: Account): Promise<IdentityCheck> {
25
- let org: RolesResponse;
25
+ let identity: TokenIdentity;
26
26
  try {
27
- org = await fetchTokenOrg(creds.accessToken);
27
+ identity = await fetchTokenIdentity(creds.accessToken);
28
28
  } catch (e) {
29
29
  return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
30
30
  }
31
- if (org.organization_uuid === account.organizationUuid) return { status: "match" };
32
- return { status: "mismatch", reason: `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})` };
31
+ if (identity.accountUuid === account.accountUuid) return { status: "match" };
32
+ return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}` };
33
33
  }
34
34
 
35
35
  function refreshPlanFields(account: Account, creds: OAuthCreds): void {
@@ -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 liveOrg: string;
58
+ let liveCreds: OAuthCreds;
55
59
  try {
56
- const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
57
- liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
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 (liveOrg === account.organizationUuid) {
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, fetchTokenOrg, 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());
@@ -41,11 +44,11 @@ export async function performSwap(target: Account): Promise<void> {
41
44
  }
42
45
  }
43
46
  if (liveCreds != null) {
44
- const org = await fetchTokenOrg(liveCreds.accessToken);
45
- liveOwner = idx.accounts.find((a) => a.organizationUuid === org.organization_uuid) ?? null;
47
+ const identity = await fetchTokenIdentity(liveCreds.accessToken);
48
+ liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
46
49
  if (!liveOwner) {
47
50
  throw new Error(
48
- `live credential belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)}), which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
51
+ `live credential belongs to ${describeIdentity(identity)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
49
52
  );
50
53
  }
51
54
  if (liveOwner.accountUuid !== idx.activeAccountUuid) {
@@ -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
  }
package/src/lib/types.ts CHANGED
@@ -42,7 +42,7 @@ const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
42
42
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
43
43
 
44
44
  export const UsageStateSchema = UsageWindowsSchema.extend({
45
- org: z.string().nullable(),
45
+ account: z.string().nullable(),
46
46
  ts: z.number(),
47
47
  model: ModelInfoSchema.nullable().default(null),
48
48
  });
@@ -50,7 +50,7 @@ export type UsageState = z.infer<typeof UsageStateSchema>;
50
50
 
51
51
  export const ModelUsageStateSchema = z.object({
52
52
  perModel: z.record(z.string(), UsageWindowSchema).default({}),
53
- org: z.string().nullable(),
53
+ account: z.string().nullable(),
54
54
  ts: z.number(),
55
55
  sampledAt: z.number().optional(),
56
56
  });
@@ -87,7 +87,7 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
87
87
  export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
88
88
 
89
89
  export const EnforcedLimitSchema = z.object({
90
- org: z.string(),
90
+ account: z.string(),
91
91
  family: z.string().nullable(),
92
92
  resetsAt: z.number().nullable(),
93
93
  windowMs: z.number(),
@@ -153,7 +153,6 @@ export const RateLimitsStdinSchema = z.looseObject({
153
153
  })
154
154
  .optional(),
155
155
  model: z.looseObject({ id: z.string().optional(), display_name: z.string().optional() }).optional(),
156
- organizationUuid: z.string().optional(),
157
156
  });
158
157
 
159
158
  export const StatusLineStdinSchema = RateLimitsStdinSchema.extend({
@@ -204,11 +203,18 @@ export const RefreshResponseSchema = z.looseObject({
204
203
  token_type: z.string().optional(),
205
204
  });
206
205
 
207
- export const RolesResponseSchema = z.looseObject({
208
- organization_uuid: z.string(),
209
- organization_name: z.string(),
206
+ export const ProfileResponseSchema = z.looseObject({
207
+ account: z.looseObject({ uuid: z.string(), email: z.string().nullish() }),
208
+ organization: z.looseObject({ uuid: z.string(), name: z.string().nullish() }),
209
+ });
210
+
211
+ export const TokenIdentitySchema = z.object({
212
+ accountUuid: z.string(),
213
+ email: z.string().nullable(),
214
+ organizationUuid: z.string(),
215
+ organizationName: z.string().nullable(),
210
216
  });
211
- export type RolesResponse = z.infer<typeof RolesResponseSchema>;
217
+ export type TokenIdentity = z.infer<typeof TokenIdentitySchema>;
212
218
 
213
219
  const CodexTokensSchema = z.looseObject({
214
220
  id_token: z.string(),