tokenmaxxing 1.15.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 +2 -2
- 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 +4 -4
- package/src/cli/rm.ts +5 -5
- package/src/cli/status.ts +136 -80
- 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 +55 -55
- package/src/lib/oauth.ts +19 -10
- package/src/lib/sample.ts +9 -9
- package/src/lib/swap.ts +4 -4
- 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,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
|
|
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.
|
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.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 (
|
|
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.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,
|
|
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, 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
|
|
98
|
-
if (
|
|
99
|
-
console.error(c.red(`the live credential belongs to ${
|
|
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 {
|
|
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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { sampleSize, sortBy } from "es-toolkit";
|
|
1
|
+
import { chunk, sampleSize, sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
4
4
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -10,7 +10,7 @@ import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
|
10
10
|
import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
|
|
11
11
|
import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
12
12
|
import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
|
|
13
|
-
import { bar, c, claudeTierLabel, count, emitJson, fmtAgo
|
|
13
|
+
import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
|
|
14
14
|
import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
|
|
15
15
|
import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
|
|
16
16
|
|
|
@@ -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,
|
|
@@ -268,48 +268,138 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
|
|
|
268
268
|
return { bars, accounts };
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
if (
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
console.log(`${marker} ${c.bold(account.label)}${account.planType ? ` ${c.dim(account.planType)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
285
|
-
if (account.usage) {
|
|
286
|
-
for (const window of account.usage.aggregate) row(windowLabel(window), window);
|
|
287
|
-
for (const [name, windows] of Object.entries(account.usage.perLimit)) {
|
|
288
|
-
for (const window of windows) row(codexLimitLabel({ limitName: name }), window);
|
|
289
|
-
}
|
|
271
|
+
const CARD_GAP = 3;
|
|
272
|
+
const NOTE_INDENT = " ";
|
|
273
|
+
|
|
274
|
+
type Note = { paint: (s: string) => string; text: string };
|
|
275
|
+
type Card = { lines: string[]; notes: Note[] };
|
|
276
|
+
|
|
277
|
+
function splitToWidth(token: string, width: number): string[] {
|
|
278
|
+
const parts: string[] = [];
|
|
279
|
+
let part = "";
|
|
280
|
+
for (const ch of token) {
|
|
281
|
+
if (part !== "" && Bun.stringWidth(part + ch) > width) {
|
|
282
|
+
parts.push(part);
|
|
283
|
+
part = "";
|
|
290
284
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
285
|
+
part += ch;
|
|
286
|
+
}
|
|
287
|
+
if (part !== "") parts.push(part);
|
|
288
|
+
return parts;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function wrapWords(text: string, width: number): string[] {
|
|
292
|
+
const out: string[] = [];
|
|
293
|
+
let line = "";
|
|
294
|
+
for (const word of text.split(" ").flatMap((token) => splitToWidth(token, width))) {
|
|
295
|
+
if (line !== "" && Bun.stringWidth(`${line} ${word}`) > width) {
|
|
296
|
+
out.push(line);
|
|
297
|
+
line = word;
|
|
298
|
+
} else {
|
|
299
|
+
line = line === "" ? word : `${line} ${word}`;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
if (line !== "") out.push(line);
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function renderGrid(cards: Card[]): void {
|
|
307
|
+
const bodyWidth = Math.max(...cards.flatMap((card) => card.lines.map((line) => Bun.stringWidth(line))));
|
|
308
|
+
const termWidth = process.stdout.isTTY ? process.stdout.columns : Number.POSITIVE_INFINITY;
|
|
309
|
+
const columns = process.stdout.isTTY ? Math.min(cards.length, Math.max(1, Math.floor((termWidth + CARD_GAP) / (bodyWidth + CARD_GAP)))) : 1;
|
|
310
|
+
const cellWidth = columns === 1 ? termWidth : Math.floor((termWidth + CARD_GAP) / columns) - CARD_GAP;
|
|
311
|
+
const blocks = cards.map((card) => [
|
|
312
|
+
...card.lines,
|
|
313
|
+
...card.notes.flatMap((note) => wrapWords(note.text, cellWidth - NOTE_INDENT.length).map((line) => `${NOTE_INDENT}${note.paint(line)}`)),
|
|
314
|
+
]);
|
|
315
|
+
const width = Math.max(...blocks.flat().map((line) => Bun.stringWidth(line)));
|
|
316
|
+
for (const rowBlocks of chunk(blocks, columns)) {
|
|
317
|
+
const height = Math.max(...rowBlocks.map((block) => block.length));
|
|
318
|
+
for (let i = 0; i < height; i++) {
|
|
319
|
+
const cells = rowBlocks.map((block) => block[i] ?? "");
|
|
320
|
+
const padded = cells.map((cell, col) => (col === cells.length - 1 ? cell : cell + " ".repeat(Math.max(0, width + CARD_GAP - Bun.stringWidth(cell)))));
|
|
321
|
+
console.log(padded.join("").trimEnd());
|
|
294
322
|
}
|
|
295
323
|
console.log();
|
|
296
324
|
}
|
|
297
325
|
}
|
|
298
326
|
|
|
299
|
-
function
|
|
300
|
-
return (
|
|
301
|
-
|
|
302
|
-
|
|
327
|
+
function usageRow(name: string, w: UsageWindow): string {
|
|
328
|
+
return `${NOTE_INDENT}${name.padEnd(5)} ${bar(w.usedPercentage)}`;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function sampleFailedNotes(input: { cached: boolean; usageAt: number | null; reason: string; now: number }): Note[] {
|
|
332
|
+
const cached = input.cached ? `cached${input.usageAt != null ? ` ${fmtAgo(input.usageAt, input.now)}` : ""}, ` : "";
|
|
333
|
+
return [
|
|
334
|
+
{ paint: c.yellow, text: `${cached}live sample failed:` },
|
|
335
|
+
{ paint: c.dim, text: input.reason },
|
|
336
|
+
];
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function headerLine(input: { active: boolean; needsReauth: boolean; exhausted: boolean; name: string; tier: string | null }): string {
|
|
340
|
+
const marker = input.active ? c.green("●") : c.dim("○");
|
|
341
|
+
const badges: string[] = [];
|
|
342
|
+
if (input.active) badges.push(c.green("active"));
|
|
343
|
+
if (input.needsReauth) badges.push(c.red("needs-reauth"));
|
|
344
|
+
if (input.exhausted) badges.push(c.yellow("exhausted"));
|
|
345
|
+
return `${marker} ${c.bold(input.name)}${input.tier ? ` ${c.dim(input.tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function codexCard(account: CodexStatusAccount, now: number): Card {
|
|
349
|
+
const windowLabel = (window: CodexWindow) =>
|
|
350
|
+
isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
|
|
351
|
+
const lines = [headerLine({ ...account, name: account.label, tier: account.planType })];
|
|
352
|
+
if (account.usage) {
|
|
353
|
+
for (const window of account.usage.aggregate) lines.push(usageRow(windowLabel(window), window));
|
|
354
|
+
for (const [name, windows] of Object.entries(account.usage.perLimit)) {
|
|
355
|
+
for (const window of windows) lines.push(usageRow(codexLimitLabel({ limitName: name }), window));
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
const notes = account.sample.ok ? [] : sampleFailedNotes({ cached: account.usage != null, usageAt: account.usageAt, reason: account.sample.reason, now });
|
|
359
|
+
return { lines, notes };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function renderCodex(input: { codex: StatusReport["codex"]; now: number }): void {
|
|
363
|
+
const { codex, now } = input;
|
|
364
|
+
if (codex.accounts.length === 0) return;
|
|
365
|
+
console.log(c.dim(`codex (${count({ n: codex.accounts.length, noun: "account" })})`));
|
|
366
|
+
console.log();
|
|
367
|
+
renderGrid(codex.accounts.map((account) => codexCard(account, now)));
|
|
303
368
|
}
|
|
304
369
|
|
|
305
|
-
function
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
const
|
|
370
|
+
function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number): Card {
|
|
371
|
+
const lines = [headerLine({ ...a, name: a.label || a.email })];
|
|
372
|
+
if (a.usage) {
|
|
373
|
+
lines.push(usageRow("5h", a.usage.fiveHour));
|
|
374
|
+
lines.push(usageRow("week", a.usage.week));
|
|
375
|
+
}
|
|
376
|
+
for (const [name, w] of Object.entries(a.perModel)) lines.push(usageRow(name.toLowerCase(), w));
|
|
377
|
+
const notes: Note[] = [];
|
|
378
|
+
if (a.sample.ok && a.sample.source === "statusline") {
|
|
379
|
+
const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
|
|
380
|
+
const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
|
|
381
|
+
notes.push({ paint: stale ? c.yellow : c.dim, text: `statusline tee ${age}${stale ? " (stale)" : ""}` });
|
|
382
|
+
}
|
|
383
|
+
if (!a.sample.ok) {
|
|
384
|
+
notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
|
|
385
|
+
}
|
|
386
|
+
if (a.pingError != null && a.pingRejected) {
|
|
387
|
+
const at = a.pingError.indexOf(": ");
|
|
388
|
+
const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
|
|
389
|
+
const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
|
|
390
|
+
notes.push({ paint: c.yellow, text: `ping ${head}${tail ? ":" : ""}` });
|
|
391
|
+
if (tail) notes.push({ paint: c.dim, text: tail });
|
|
392
|
+
} else if (a.pingError != null) {
|
|
393
|
+
notes.push({ paint: c.yellow, text: "ping failed (5h timer may not have started):" }, { paint: c.dim, text: a.pingError });
|
|
394
|
+
}
|
|
395
|
+
if (a.pinged) {
|
|
396
|
+
notes.push({ paint: c.dim, text: "pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window" });
|
|
397
|
+
}
|
|
398
|
+
return { lines, notes };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: boolean; now: number; staleAfterMs: number }): void {
|
|
402
|
+
const { claude, codexPooled, now, staleAfterMs } = input;
|
|
313
403
|
if (claude.accounts.length === 0) {
|
|
314
404
|
if (!codexPooled) {
|
|
315
405
|
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
|
|
@@ -322,40 +412,7 @@ function renderClaude(input: {
|
|
|
322
412
|
|
|
323
413
|
console.log(c.dim(`thresholds 5h ${claude.thresholds.session.join("/")}% (at ${claude.bars.session + claude.projectionMargin}%) weekly ${claude.thresholds.weekly}% (${count({ n: claude.accounts.length, noun: "claude account" })})`));
|
|
324
414
|
console.log();
|
|
325
|
-
|
|
326
|
-
const marker = a.active ? c.green("●") : c.dim("○");
|
|
327
|
-
const badges: string[] = [];
|
|
328
|
-
if (a.active) badges.push(c.green("active"));
|
|
329
|
-
if (a.needsReauth) badges.push(c.red("needs-reauth"));
|
|
330
|
-
if (a.exhausted) badges.push(c.yellow("exhausted"));
|
|
331
|
-
console.log(`${marker} ${c.bold(a.label || a.email)}${a.tier ? ` ${c.dim(a.tier)}` : ""}${badges.length ? ` ${badges.join(" ")}` : ""}`);
|
|
332
|
-
if (a.usage) {
|
|
333
|
-
row("5h", a.usage.fiveHour);
|
|
334
|
-
row("week", a.usage.week);
|
|
335
|
-
}
|
|
336
|
-
for (const [name, w] of Object.entries(a.perModel)) row(name.toLowerCase(), w);
|
|
337
|
-
if (a.sample.ok && a.sample.source === "statusline") {
|
|
338
|
-
const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
|
|
339
|
-
const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
|
|
340
|
-
console.log(` ${(stale ? c.yellow : c.dim)(`statusline tee ${age}${stale ? " (stale)" : ""}`)}`);
|
|
341
|
-
}
|
|
342
|
-
if (!a.sample.ok) {
|
|
343
|
-
const cached = a.usage || Object.keys(a.perModel).length > 0 ? `cached${a.usageAt != null ? ` ${fmtAgo(a.usageAt, now)}` : ""}, ` : "";
|
|
344
|
-
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(a.sample.reason)}`);
|
|
345
|
-
}
|
|
346
|
-
if (a.pingError != null && a.pingRejected) {
|
|
347
|
-
const at = a.pingError.indexOf(": ");
|
|
348
|
-
const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
|
|
349
|
-
const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
|
|
350
|
-
console.log(` ${c.yellow(`ping ${head}`)}${tail ? `: ${c.dim(tail)}` : ""}`);
|
|
351
|
-
} else if (a.pingError != null) {
|
|
352
|
-
console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(a.pingError)}`);
|
|
353
|
-
}
|
|
354
|
-
if (a.pinged) {
|
|
355
|
-
console.log(` ${c.dim("pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window")}`);
|
|
356
|
-
}
|
|
357
|
-
console.log();
|
|
358
|
-
}
|
|
415
|
+
renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
|
|
359
416
|
}
|
|
360
417
|
|
|
361
418
|
export async function cmdStatus(opts: { ping?: boolean; pingCount?: number; json?: boolean; preRender?: () => void } = {}): Promise<number> {
|
|
@@ -366,7 +423,7 @@ export async function cmdStatus(opts: { ping?: boolean; pingCount?: number; json
|
|
|
366
423
|
if (!json) {
|
|
367
424
|
opts.preRender?.();
|
|
368
425
|
const at = Date.now();
|
|
369
|
-
renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs
|
|
426
|
+
renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs });
|
|
370
427
|
}
|
|
371
428
|
const codex = await collectCodex({ cfg, now });
|
|
372
429
|
if (json) {
|
|
@@ -374,7 +431,6 @@ export async function cmdStatus(opts: { ping?: boolean; pingCount?: number; json
|
|
|
374
431
|
emitJson({ ok: true, ...report });
|
|
375
432
|
return 0;
|
|
376
433
|
}
|
|
377
|
-
|
|
378
|
-
renderCodex({ codex, now: at, row: rowPrinter(at) });
|
|
434
|
+
renderCodex({ codex, now: Date.now() });
|
|
379
435
|
return 0;
|
|
380
436
|
}
|
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
|
@@ -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,
|
|
39
|
+
function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
|
|
40
40
|
return accounts.map((a) => {
|
|
41
|
-
if (
|
|
42
|
-
const live = u && u.
|
|
43
|
-
const perModel = mu && mu.
|
|
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,
|
|
49
|
-
if (!u || !
|
|
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.
|
|
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,
|
|
68
|
-
if (!u || !
|
|
69
|
-
return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu,
|
|
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,
|
|
80
|
-
return u != null && u.
|
|
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.
|
|
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,
|
|
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.
|
|
99
|
-
if (
|
|
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()?.
|
|
102
|
+
if (readOAuthAccount()?.accountUuid === account) {
|
|
103
103
|
if (full) {
|
|
104
104
|
const teed = loadUsageSnapshot();
|
|
105
|
-
if (teed && usageFresh(teed.state, teed.at,
|
|
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,
|
|
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,
|
|
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?.
|
|
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
|
|
131
|
-
const enforced0 = enforced && enforced.
|
|
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,
|
|
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.
|
|
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,
|
|
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,
|
|
150
|
-
const measured = usage != null &&
|
|
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
|
|
161
|
-
const enforced2 = enforced0 && enforced0.
|
|
160
|
+
const account2 = readOAuthAccount()?.accountUuid ?? null;
|
|
161
|
+
const enforced2 = enforced0 && enforced0.account === account2 ? enforced0 : null;
|
|
162
162
|
const tee = loadUsageSnapshot();
|
|
163
|
-
const active =
|
|
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 (
|
|
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.
|
|
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.
|
|
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,
|
|
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.
|
|
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,
|
|
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.
|
|
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.
|
|
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", "
|
|
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;
|
|
313
|
-
const { limit,
|
|
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()?.
|
|
315
|
+
if ((readOAuthAccount()?.accountUuid ?? null) !== accountUuid) return { outcome: "account-moved", resetsAt: limit.resetsAt };
|
|
316
316
|
const prior = loadUsage();
|
|
317
|
-
const priorSame = prior && prior.
|
|
317
|
+
const priorSame = prior && prior.account === accountUuid ? prior : null;
|
|
318
318
|
const idx = loadAccounts();
|
|
319
|
-
const account = idx.accounts.find((a) => a.
|
|
319
|
+
const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
|
|
320
320
|
const mu = loadModelUsage();
|
|
321
|
-
const muSame = mu && mu.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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;
|
|
361
|
-
const { cfg,
|
|
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 (!
|
|
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.
|
|
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.
|
|
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,
|
|
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,
|
|
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",
|
|
@@ -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
|
|
78
|
+
export async function fetchTokenIdentity(accessToken: string): Promise<TokenIdentity> {
|
|
79
79
|
let res: Response;
|
|
80
80
|
try {
|
|
81
|
-
res = await http.get(
|
|
82
|
-
headers: { Authorization: `Bearer ${accessToken}`, "
|
|
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(`
|
|
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(`
|
|
89
|
-
const parsed =
|
|
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(`
|
|
93
|
-
return
|
|
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,
|
|
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
|
|
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 {
|
|
@@ -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
|
|
54
|
+
let liveAccount: string;
|
|
55
55
|
try {
|
|
56
56
|
const liveCreds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
57
|
-
|
|
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 (
|
|
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,
|
|
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
|
|
45
|
-
liveOwner = idx.accounts.find((a) => a.
|
|
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 ${
|
|
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
|
-
|
|
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(),
|