tokenmaxxing 1.16.0 → 1.17.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,7 +57,7 @@ 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.
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
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.
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.
@@ -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.17.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.17.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, 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";
@@ -94,9 +94,9 @@ export async function cmdInit(): Promise<number> {
94
94
  await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
95
95
  });
96
96
  }
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.`));
97
+ const identity = await fetchTokenIdentity(creds.accessToken);
98
+ if (identity.accountUuid !== oauthAccount.accountUuid) {
99
+ console.error(c.red(`the live credential belongs to ${describeIdentity(identity)}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
100
100
  console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
101
101
  return 1;
102
102
  }
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
@@ -36,22 +36,22 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
36
36
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
37
37
  }
38
38
 
39
- function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, org: string | null): Account[] {
39
+ function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
40
40
  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 } : {};
41
+ if (account == null || a.accountUuid !== account) return a;
42
+ const live = u && u.account === account ? { lastUsage: { fiveHour: u.fiveHour, sevenDay: u.sevenDay }, lastUsageAt: u.ts } : {};
43
+ const perModel = mu && mu.account === account && Object.keys(mu.perModel).length > 0 ? { lastPerModel: mu.perModel, lastPerModelAt: mu.sampledAt ?? mu.ts } : {};
44
44
  return { ...a, ...live, ...perModel };
45
45
  });
46
46
  }
47
47
 
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;
48
+ function isOver(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
49
+ if (!u || !account || u.account !== account) return false;
50
50
  if (
51
51
  liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
52
52
  liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
53
53
  ) return true;
54
- if (mu && mu.org === org) {
54
+ if (mu && mu.account === account) {
55
55
  for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
56
56
  const cap = capForFamily(mu, family, now);
57
57
  if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }) >= bars.weekly) return true;
@@ -64,9 +64,9 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
64
64
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
65
65
  }
66
66
 
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);
67
+ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
68
+ if (!u || !account || u.account !== account) return false;
69
+ return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
70
70
  }
71
71
 
72
72
  const SnapshotsSchema = z.object({
@@ -76,41 +76,41 @@ const SnapshotsSchema = z.object({
76
76
  });
77
77
  type Snapshots = z.infer<typeof SnapshotsSchema>;
78
78
 
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;
79
+ function usageFresh(u: UsageState | null, uAt: number | null, account: string | null, ttl: number, now: number): boolean {
80
+ return u != null && u.account === account && uAt != null && now - uAt <= ttl;
81
81
  }
82
82
 
83
83
  function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
84
84
  if (!u || uAt == null) return u;
85
85
  const stored = account?.lastUsage;
86
- if (stored && account.lastUsageAt != null && u.org === account.organizationUuid && uAt < account.lastUsageAt) {
86
+ if (stored && account.lastUsageAt != null && u.account === account.accountUuid && uAt < account.lastUsageAt) {
87
87
  return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
88
88
  }
89
89
  return { ...u, ts: uAt };
90
90
  }
91
91
 
92
- async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
92
+ async function loadFreshSnapshots(cfg: Config, account: string | null, now: number): Promise<Snapshots> {
93
93
  const snap = loadUsageSnapshot();
94
94
  let u = snap?.state ?? null;
95
95
  let uAt = snap?.at ?? null;
96
96
  let mu = loadModelUsage();
97
97
  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))) {
98
+ const probeAttempted = mu != null && mu.account === account && now - mu.ts <= ttl;
99
+ if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
100
100
  const full = await probeUsage();
101
101
  const ts = Date.now();
102
- if (readOAuthAccount()?.organizationUuid === org) {
102
+ if (readOAuthAccount()?.accountUuid === account) {
103
103
  if (full) {
104
104
  const teed = loadUsageSnapshot();
105
- if (teed && usageFresh(teed.state, teed.at, org, ttl, ts)) {
105
+ if (teed && usageFresh(teed.state, teed.at, account, ttl, ts)) {
106
106
  u = teed.state;
107
107
  uAt = teed.at;
108
108
  } else {
109
- u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
109
+ u = { fiveHour: full.session, sevenDay: full.weekAll, account, ts, model: null };
110
110
  writeUsage(u);
111
111
  uAt = ts;
112
112
  }
113
- mu = { perModel: full.perModel, org, ts, sampledAt: ts };
113
+ mu = { perModel: full.perModel, account, ts, sampledAt: ts };
114
114
  saveModelUsage(mu);
115
115
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
116
116
  const rows = Object.keys(full.perModel);
@@ -118,7 +118,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
118
118
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
119
119
  }
120
120
  } else {
121
- mu = { perModel: mu?.org === org ? (mu?.perModel ?? {}) : {}, org, ts, sampledAt: mu?.org === org ? (mu?.sampledAt ?? mu?.ts) : undefined };
121
+ mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
122
122
  saveModelUsage(mu);
123
123
  }
124
124
  }
@@ -127,8 +127,8 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
127
127
  }
128
128
 
129
129
  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;
130
+ const activeAccount = readOAuthAccount()?.accountUuid ?? null;
131
+ const enforced0 = enforced && enforced.account === activeAccount ? enforced : null;
132
132
 
133
133
  const lastSwapAt = loadLastSwapAt();
134
134
  if (!enforced0 && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
@@ -137,17 +137,17 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
137
137
 
138
138
  const cfg = loadConfig();
139
139
 
140
- const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
140
+ const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
141
141
  const pool = loadAccounts();
142
- const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
142
+ const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.accountUuid === activeAccount));
143
143
  const bars0 = effectiveBars(cfg, {
144
- accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
144
+ accounts: overlayLive(pool.accounts, usage, mu, activeAccount),
145
145
  now,
146
146
  switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
147
147
  });
148
148
 
149
- if (!enforced0 && !isEngaged(usage, mu, activeOrg, bars0, cfg, now)) {
150
- const measured = usage != null && activeOrg != null && usage.org === activeOrg;
149
+ if (!enforced0 && !isEngaged(usage, mu, activeAccount, bars0, cfg, now)) {
150
+ const measured = usage != null && activeAccount != null && usage.account === activeAccount;
151
151
  if (!measured) {
152
152
  const replay = depletedReplay(now);
153
153
  if (replay) return replay;
@@ -157,25 +157,25 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
157
157
 
158
158
  return withLock(paths.lockFile, async () => {
159
159
  const idx = loadAccounts();
160
- const org2 = readOAuthAccount()?.organizationUuid ?? null;
161
- const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
160
+ const account2 = readOAuthAccount()?.accountUuid ?? null;
161
+ const enforced2 = enforced0 && enforced0.account === account2 ? enforced0 : null;
162
162
  const tee = loadUsageSnapshot();
163
- const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
163
+ const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
164
164
  const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
165
165
  const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
166
166
 
167
- if (org2 != null && !active) {
167
+ if (account2 != null && !active) {
168
168
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
169
169
  }
170
170
 
171
171
  if (active) {
172
172
  let sampled = false;
173
- if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
173
+ if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
174
174
  active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
175
175
  active.lastUsageAt = tee.at;
176
176
  sampled = true;
177
177
  }
178
- if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
178
+ if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
179
179
  active.lastPerModel = mu2.perModel;
180
180
  active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
181
181
  sampled = true;
@@ -188,12 +188,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
188
188
  const barsOf = (accounts: Account[]): Thresholds => effectiveBars(cfg, { accounts, now, switchFamilies });
189
189
  const bars = barsOf(idx.accounts);
190
190
 
191
- if (!enforced2 && !isEngaged(u2, mu2, org2, bars, cfg, now)) {
191
+ if (!enforced2 && !isEngaged(u2, mu2, account2, bars, cfg, now)) {
192
192
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
193
193
  }
194
194
 
195
195
  const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
196
- idx2.accounts.find((a) => a.organizationUuid === org2) ??
196
+ idx2.accounts.find((a) => a.accountUuid === account2) ??
197
197
  idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
198
198
  null;
199
199
 
@@ -217,7 +217,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
217
217
  return { swapped: true, account: best, reason: "swapped" };
218
218
  }
219
219
  };
220
- if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
220
+ if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
221
221
 
222
222
  while (true) {
223
223
  const cur = loadAccounts();
@@ -251,7 +251,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
251
251
  const fresh = loadAccounts();
252
252
  const current = seatOf(fresh);
253
253
  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;
254
+ const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
255
255
  const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
256
256
  const other = pickEarliestReset(fresh.accounts, ctx);
257
257
 
@@ -291,7 +291,7 @@ function depletedReplay(now: number): SwapDecision | null {
291
291
  if (!rec || rec.waitUntil <= now) return null;
292
292
  const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
293
293
  if (!account) return null;
294
- if (account.organizationUuid !== (readOAuthAccount()?.organizationUuid ?? null)) return null;
294
+ if (account.accountUuid !== (readOAuthAccount()?.accountUuid ?? null)) return null;
295
295
  return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
296
296
  }
297
297
 
@@ -306,19 +306,19 @@ export function postSwapProof(input: { swapAt: number | null; launchedAt: number
306
306
  return (errorAt ?? now) - swapAt >= POST_SWAP_COOLDOWN_MS;
307
307
  }
308
308
 
309
- const StampSchema = z.object({ outcome: z.enum(["stamped", "org-moved", "no-carrier"]), resetsAt: z.number().nullable() });
309
+ const StampSchema = z.object({ outcome: z.enum(["stamped", "account-moved", "no-carrier"]), resetsAt: z.number().nullable() });
310
310
  export type Stamp = z.infer<typeof StampSchema>;
311
311
 
312
- export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: string; now: number }): Promise<Stamp> {
313
- const { limit, org, now } = input;
312
+ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account: string; now: number }): Promise<Stamp> {
313
+ const { limit, account: accountUuid, now } = input;
314
314
  return withLock(paths.lockFile, () => {
315
- if ((readOAuthAccount()?.organizationUuid ?? null) !== org) return { outcome: "org-moved", resetsAt: limit.resetsAt };
315
+ if ((readOAuthAccount()?.accountUuid ?? null) !== accountUuid) return { outcome: "account-moved", resetsAt: limit.resetsAt };
316
316
  const prior = loadUsage();
317
- const priorSame = prior && prior.org === org ? prior : null;
317
+ const priorSame = prior && prior.account === accountUuid ? prior : null;
318
318
  const idx = loadAccounts();
319
- const account = idx.accounts.find((a) => a.organizationUuid === org);
319
+ const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
320
320
  const mu = loadModelUsage();
321
- const muSame = mu && mu.org === org ? mu : null;
321
+ const muSame = mu && mu.account === accountUuid ? mu : null;
322
322
  const carriedRows = muSame?.perModel ?? {};
323
323
  const sampledAt = muSame?.sampledAt ?? muSame?.ts;
324
324
  if (limit.kind === "model") {
@@ -328,14 +328,14 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
328
328
  const resetsAt = limit.resetsAt ?? nextWeeklyReset(knownReset ?? weeklyReset, now);
329
329
  saveModelUsage({
330
330
  perModel: { ...carriedRows, [limit.family]: { usedPercentage: 100, resetsAt } },
331
- org,
331
+ account: accountUuid,
332
332
  ts: now,
333
333
  sampledAt: resetsAt == null ? now : sampledAt ?? now,
334
334
  });
335
335
  log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
336
336
  return { outcome: "stamped", resetsAt };
337
337
  }
338
- saveModelUsage({ perModel: carriedRows, org, ts: now, sampledAt });
338
+ saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
339
339
  if (account && limit.resetsAt != null) {
340
340
  account.enforcedUntil = limit.resetsAt;
341
341
  saveAccounts(idx);
@@ -346,7 +346,7 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
346
346
  writeUsage({
347
347
  fiveHour: limit.kind === "session" ? window : carrier.fiveHour,
348
348
  sevenDay: limit.kind === "weekly" ? window : carrier.sevenDay,
349
- org,
349
+ account: accountUuid,
350
350
  ts: now,
351
351
  model: carrier.model,
352
352
  });
@@ -357,21 +357,21 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
357
357
 
358
358
  const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
359
359
 
360
- export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
361
- const { cfg, org, now, decision } = input;
360
+ export function checkDelayMs(input: { cfg: Config; account: string | null; now: number; decision: SwapDecision }): number {
361
+ const { cfg, account, now, decision } = input;
362
362
  const tick = cfg.policy.checkIntervalMs;
363
363
  const max = maxCheckDelayMs(cfg);
364
364
  if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
365
365
  const swapAt = loadLastSwapAt();
366
366
  if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
367
367
  const snap = loadUsageSnapshot();
368
- if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
368
+ if (!account || !snap || !usageFresh(snap.state, snap.at, account, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
369
369
  const accounts = loadAccounts().accounts;
370
- const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
370
+ const u = freshest(snap.state, snap.at, accounts.find((a) => a.accountUuid === account)) ?? { ...snap.state, ts: snap.at };
371
371
  const mu = loadModelUsage();
372
- const muSame = mu && mu.org === org ? mu : null;
372
+ const muSame = mu && mu.account === account ? mu : null;
373
373
  const families = gatedFamilies(u.model, cfg.policy.switchModels);
374
- const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
374
+ const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, account), now, switchFamilies: families });
375
375
  const heads = [
376
376
  bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
377
377
  bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
package/src/lib/oauth.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { z } from "zod";
2
2
  import { http, safeErrorDetail } from "./http.ts";
3
- import { RefreshResponseSchema, RolesResponseSchema, type OAuthCreds, type RolesResponse } from "./types.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",
@@ -75,20 +75,29 @@ export function isAccessTokenExpiring(creds: OAuthCreds, skewMs = 120_000, now =
75
75
  return !creds.expiresAt || creds.expiresAt - now <= skewMs;
76
76
  }
77
77
 
78
- export async function fetchTokenOrg(accessToken: string): Promise<RolesResponse> {
78
+ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
79
79
  let res: Response;
80
80
  try {
81
- res = await http.get(ROLES_URL, {
82
- headers: { Authorization: `Bearer ${accessToken}`, "anthropic-beta": "oauth-2025-04-20" },
81
+ res = await http.get(PROFILE_URL, {
82
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
83
83
  });
84
84
  } catch (e) {
85
- throw new Error(`roles endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
85
+ throw new Error(`profile endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
86
86
  }
87
87
  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((() => {
88
+ if (!res.ok) throw new Error(`identity check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
89
+ const parsed = ProfileResponseSchema.safeParse((() => {
90
90
  try { return JSON.parse(text); } catch { return null; }
91
91
  })());
92
- if (!parsed.success) throw new Error(`roles endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
93
- return parsed.data;
92
+ if (!parsed.success) throw new Error(`profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
93
+ return TokenIdentitySchema.parse({
94
+ accountUuid: parsed.data.account.uuid,
95
+ email: parsed.data.account.email ?? null,
96
+ organizationUuid: parsed.data.organization.uuid,
97
+ organizationName: parsed.data.organization.name ?? null,
98
+ });
99
+ }
100
+
101
+ export function describeIdentity(id: TokenIdentity): string {
102
+ return `${id.email ?? id.organizationName ?? "unknown"} (account ${id.accountUuid.slice(0, 8)})`;
94
103
  }
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, 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 {
@@ -51,14 +51,14 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
51
51
 
52
52
  const liveRaw = await readItem(liveTarget());
53
53
  if (liveRaw != null) {
54
- let liveOrg: string;
54
+ let liveAccount: string;
55
55
  try {
56
56
  const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
57
- liveOrg = (await fetchTokenOrg(liveCreds.accessToken)).organization_uuid;
57
+ liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
58
58
  } catch (e) {
59
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` };
60
60
  }
61
- if (liveOrg === account.organizationUuid) {
61
+ if (liveAccount === account.accountUuid) {
62
62
  return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
63
63
  }
64
64
  }
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, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
4
4
  import { swapOAuthAccount } from "./claudejson.ts";
5
5
  import { withClaudeRefreshLock } from "./claudelock.ts";
6
6
  import { log } from "./log.ts";
@@ -41,11 +41,11 @@ export async function performSwap(target: Account): Promise<void> {
41
41
  }
42
42
  }
43
43
  if (liveCreds != null) {
44
- const org = await fetchTokenOrg(liveCreds.accessToken);
45
- liveOwner = idx.accounts.find((a) => a.organizationUuid === org.organization_uuid) ?? null;
44
+ const identity = await fetchTokenIdentity(liveCreds.accessToken);
45
+ liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
46
46
  if (!liveOwner) {
47
47
  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\``,
48
+ `live credential belongs to ${describeIdentity(identity)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
49
49
  );
50
50
  }
51
51
  if (liveOwner.accountUuid !== idx.activeAccountUuid) {
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(),