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 +3 -3
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/credentials-hygiene/SKILL.md +3 -2
- package/package.json +1 -1
- package/src/cli/check.ts +1 -1
- package/src/cli/doctor.ts +8 -8
- package/src/cli/init.ts +9 -4
- package/src/cli/rm.ts +5 -5
- package/src/cli/status.ts +7 -7
- package/src/cli/switch.ts +1 -2
- package/src/entries/statusline.ts +8 -13
- package/src/entries/stopfailurehook.ts +5 -5
- package/src/lib/decide.ts +79 -70
- package/src/lib/http.ts +40 -13
- package/src/lib/log.ts +1 -0
- package/src/lib/oauth.ts +35 -15
- package/src/lib/sample.ts +30 -12
- package/src/lib/swap.ts +30 -14
- package/src/lib/types.ts +14 -8
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 `
|
|
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
|
|
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.
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -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.
|
|
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 (
|
|
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
|
|
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.
|
|
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,
|
|
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,
|
|
9
|
-
import { CredentialBlobSchema, type
|
|
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
|
|
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
|
|
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
|
|
58
|
-
if (
|
|
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
|
|
71
|
-
if (
|
|
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,
|
|
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
|
|
98
|
-
if (
|
|
99
|
-
console.error(c.red(`the live credential belongs to ${
|
|
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 {
|
|
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
|
|
30
|
+
let liveAccount: string;
|
|
31
31
|
try {
|
|
32
32
|
const liveCreds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
|
|
33
|
-
|
|
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
|
|
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 (
|
|
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
|
|
119
|
+
const liveAccount = liveOAuth?.accountUuid ?? null;
|
|
120
120
|
const probeOne = async (a: Account) => {
|
|
121
|
-
const isActive =
|
|
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.
|
|
125
|
+
isActive && live && teeCurrent && live.account === a.accountUuid
|
|
126
126
|
? {
|
|
127
127
|
session: live.fiveHour,
|
|
128
128
|
weekAll: live.sevenDay,
|
|
129
|
-
perModel: modelUsage && modelUsage.
|
|
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) =>
|
|
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
|
|
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:
|
|
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
|
-
(
|
|
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
|
-
|
|
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.
|
|
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
|
|
124
|
-
|
|
125
|
-
let org: string | null = null;
|
|
122
|
+
let account: string | null = null;
|
|
126
123
|
try {
|
|
127
|
-
|
|
124
|
+
account = readOAuthAccount()?.accountUuid ?? null;
|
|
128
125
|
const windows = obj == null ? null : parseStatusLineStdin(obj);
|
|
129
126
|
const lastSwapAt = loadLastSwapAt();
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
143
|
+
perModel: modelUsage && modelUsage.account === account ? modelUsage.perModel : {},
|
|
149
144
|
switchModels: cfg.policy.switchModels,
|
|
150
145
|
worktree: dir == null ? null : worktreeName(dir),
|
|
151
|
-
|
|
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
|
|
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 &&
|
|
54
|
+
if (limit && found && account) {
|
|
55
55
|
if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
|
|
56
|
-
const stamp = await recordEnforcedLimit({ limit,
|
|
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 !== "
|
|
59
|
-
enforced = {
|
|
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,
|
|
38
|
+
function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
|
|
40
39
|
return accounts.map((a) => {
|
|
41
|
-
if (
|
|
42
|
-
const live = u && u.
|
|
43
|
-
const perModel = mu && mu.
|
|
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,
|
|
49
|
-
if (!u || !
|
|
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.
|
|
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,
|
|
68
|
-
if (!u || !
|
|
69
|
-
return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu,
|
|
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,
|
|
80
|
-
return u != null && u.
|
|
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.
|
|
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,
|
|
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.
|
|
99
|
-
if (
|
|
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()?.
|
|
101
|
+
if (readOAuthAccount()?.accountUuid === account) {
|
|
103
102
|
if (full) {
|
|
104
103
|
const teed = loadUsageSnapshot();
|
|
105
|
-
if (teed && usageFresh(teed.state, teed.at,
|
|
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,
|
|
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,
|
|
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?.
|
|
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
|
|
131
|
-
const enforced0 = enforced && enforced.
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
150
|
-
const measured = usage != null &&
|
|
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
|
|
161
|
-
const enforced2 = enforced0 && enforced0.
|
|
159
|
+
const account2 = readOAuthAccount()?.accountUuid ?? null;
|
|
160
|
+
const enforced2 = enforced0 && enforced0.account === account2 ? enforced0 : null;
|
|
162
161
|
const tee = loadUsageSnapshot();
|
|
163
|
-
const active =
|
|
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 (
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
|
205
|
-
|
|
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(
|
|
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
|
-
|
|
214
|
-
|
|
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,
|
|
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
|
|
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(
|
|
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
|
-
|
|
233
|
-
|
|
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.
|
|
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
|
-
|
|
279
|
-
|
|
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.
|
|
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", "
|
|
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;
|
|
313
|
-
const { limit,
|
|
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()?.
|
|
324
|
+
if ((readOAuthAccount()?.accountUuid ?? null) !== accountUuid) return { outcome: "account-moved", resetsAt: limit.resetsAt };
|
|
316
325
|
const prior = loadUsage();
|
|
317
|
-
const priorSame = prior && prior.
|
|
326
|
+
const priorSame = prior && prior.account === accountUuid ? prior : null;
|
|
318
327
|
const idx = loadAccounts();
|
|
319
|
-
const account = idx.accounts.find((a) => a.
|
|
328
|
+
const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
|
|
320
329
|
const mu = loadModelUsage();
|
|
321
|
-
const muSame = mu && mu.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|
|
361
|
-
const { cfg,
|
|
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 (!
|
|
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.
|
|
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.
|
|
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,
|
|
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
|
|
5
|
-
error: z.string()
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
throw new Error(`token refresh failed (HTTP ${res.status}): ${
|
|
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
|
|
89
|
+
export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
|
|
79
90
|
let res: Response;
|
|
80
91
|
try {
|
|
81
|
-
res = await http.get(
|
|
82
|
-
headers: { Authorization: `Bearer ${accessToken}`, "
|
|
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(`
|
|
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(`
|
|
89
|
-
const parsed =
|
|
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(`
|
|
93
|
-
return
|
|
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,
|
|
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
|
|
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
|
|
25
|
+
let identity: TokenIdentity;
|
|
26
26
|
try {
|
|
27
|
-
|
|
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 (
|
|
32
|
-
return { status: "mismatch", reason: `credential actually belongs to ${
|
|
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
|
|
58
|
+
let liveCreds: OAuthCreds;
|
|
55
59
|
try {
|
|
56
|
-
|
|
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
|
|
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 (
|
|
62
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
45
|
-
liveOwner = idx.accounts.find((a) => a.
|
|
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 ${
|
|
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(
|
|
81
|
+
fresh = await refreshCredential(parked);
|
|
67
82
|
} catch (e) {
|
|
68
|
-
if (e instanceof InvalidGrantError)
|
|
69
|
-
|
|
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
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
217
|
+
export type TokenIdentity = z.infer<typeof TokenIdentitySchema>;
|
|
212
218
|
|
|
213
219
|
const CodexTokensSchema = z.looseObject({
|
|
214
220
|
id_token: z.string(),
|