tokenmaxxing 1.33.0 → 1.35.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 -5
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/add.ts +23 -40
- package/src/cli/auth.ts +38 -52
- package/src/cli/check.ts +2 -1
- package/src/cli/cloudrun.ts +5 -15
- package/src/cli/cursorinit.ts +4 -8
- package/src/cli/doctor.ts +22 -36
- package/src/cli/init.ts +29 -126
- package/src/cli/rename.ts +14 -50
- package/src/cli/render.ts +1 -8
- package/src/cli/rm.ts +27 -44
- package/src/cli/setuptoken.ts +25 -38
- package/src/cli/status.ts +95 -238
- package/src/cli/switch.ts +54 -46
- package/src/entries/codexstophook.ts +22 -16
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +3 -2
- package/src/entries/statusline.ts +15 -13
- package/src/entries/stopfailurehook.ts +7 -5
- package/src/entries/stophook.ts +3 -2
- package/src/lib/claude.ts +320 -0
- package/src/lib/codex.ts +353 -0
- package/src/lib/codexauth.ts +6 -0
- package/src/lib/codexpresence.ts +0 -10
- package/src/lib/codexusage.ts +14 -32
- package/src/lib/decide.ts +86 -174
- package/src/lib/oauth.ts +7 -0
- package/src/lib/paths.ts +14 -6
- package/src/lib/picker.ts +56 -37
- package/src/lib/provider.ts +34 -0
- package/src/lib/sample.ts +33 -33
- package/src/lib/state.ts +49 -16
- package/src/lib/swap.ts +41 -38
- package/src/lib/types.ts +17 -51
- package/src/lib/usage.ts +17 -4
- package/src/main.ts +33 -37
- package/src/cli/codexadd.ts +0 -89
- package/src/cli/codexinit.ts +0 -120
- package/src/cli/codexrm.ts +0 -37
- package/src/cli/codexswitch.ts +0 -113
- package/src/cli/onboard.ts +0 -92
- package/src/lib/codexdecide.ts +0 -157
- package/src/lib/codexpick.ts +0 -80
- package/src/lib/codexsample.ts +0 -48
- package/src/lib/codexstate.ts +0 -22
- package/src/lib/codexswap.ts +0 -70
package/src/lib/sample.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { minBy } from "es-toolkit";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
6
|
-
import { credItemFor, paths } from "./paths.ts";
|
|
6
|
+
import { claudePool, credItemFor, paths } from "./paths.ts";
|
|
7
7
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
8
8
|
import { withLock } from "./lock.ts";
|
|
9
9
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
10
|
-
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
11
|
-
import {
|
|
10
|
+
import { claudeTierLabel, refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
11
|
+
import { mergeWindows, probeUsage, windowsOf } from "./usage.ts";
|
|
12
12
|
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
|
|
13
13
|
import { keepRotatedPair } from "./swap.ts";
|
|
14
14
|
import { log } from "./log.ts";
|
|
@@ -38,13 +38,12 @@ async function checkIdentity(creds: OAuthCreds, account: Account, signal?: Abort
|
|
|
38
38
|
stale: e instanceof IdentityUnavailableError && e.status === 401,
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
-
if (identity.accountUuid === account.
|
|
41
|
+
if (identity.accountUuid === account.id) return { status: "match" };
|
|
42
42
|
return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
45
|
+
function refreshTier(account: Account, creds: OAuthCreds): void {
|
|
46
|
+
account.tier = claudeTierLabel(creds) ?? account.tier;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
|
|
@@ -53,9 +52,10 @@ const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal
|
|
|
53
52
|
type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
|
|
54
53
|
|
|
55
54
|
async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
|
|
56
|
-
const backup = parkedTarget(account.
|
|
55
|
+
const backup = parkedTarget(credItemFor(account.id));
|
|
57
56
|
const parkedRaw = await readItem(backup);
|
|
58
57
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
58
|
+
if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
|
|
59
59
|
|
|
60
60
|
let creds: OAuthCreds;
|
|
61
61
|
try {
|
|
@@ -87,17 +87,17 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
87
87
|
} catch (e) {
|
|
88
88
|
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` };
|
|
89
89
|
}
|
|
90
|
-
if (liveAccount === account.
|
|
90
|
+
if (liveAccount === account.id) {
|
|
91
91
|
return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const relocate = async (owner: TokenIdentity): Promise<string> => {
|
|
97
|
-
const pooled = loadAccounts().accounts.find((a) => a.
|
|
98
|
-
if (pooled == null || pooled.
|
|
99
|
-
await writeItem(parkedTarget(pooled.
|
|
100
|
-
log("swap.parked_relocated", { account: pooled.
|
|
97
|
+
const pooled = loadAccounts(claudePool).accounts.find((a) => a.id === owner.accountUuid) ?? null;
|
|
98
|
+
if (pooled == null || pooled.id === liveAccount) return "was left in place";
|
|
99
|
+
await writeItem(parkedTarget(credItemFor(pooled.id)), JSON.stringify({ claudeAiOauth: creds }));
|
|
100
|
+
log("swap.parked_relocated", { account: pooled.id.slice(0, 8) });
|
|
101
101
|
return "was copied to that account's slot";
|
|
102
102
|
};
|
|
103
103
|
|
|
@@ -140,8 +140,8 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
140
140
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
|
|
141
141
|
return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
|
|
142
142
|
}
|
|
143
|
-
const trueOwner = loadAccounts().accounts.find((a) => a.
|
|
144
|
-
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account,
|
|
143
|
+
const trueOwner = loadAccounts(claudePool).accounts.find((a) => a.id === verified.owner.accountUuid) ?? null;
|
|
144
|
+
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerId: liveAccount, expectedLiveToken: liveToken, lock });
|
|
145
145
|
account.needsReauth = true;
|
|
146
146
|
return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
|
|
147
147
|
});
|
|
@@ -163,7 +163,7 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
163
163
|
if (identity.status === "unavailable") {
|
|
164
164
|
return { ok: false, reason: identity.reason };
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
refreshTier(account, creds);
|
|
167
167
|
|
|
168
168
|
rmSync(dir, { recursive: true, force: true });
|
|
169
169
|
mkdirSync(dir, { recursive: true });
|
|
@@ -185,7 +185,7 @@ async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<
|
|
|
185
185
|
|
|
186
186
|
async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
|
|
187
187
|
const isoTarget = isolatedTarget(prepared.dir);
|
|
188
|
-
const backup = parkedTarget(account.
|
|
188
|
+
const backup = parkedTarget(credItemFor(account.id));
|
|
189
189
|
try {
|
|
190
190
|
const afterIso = await readItem(isoTarget);
|
|
191
191
|
if (afterIso && afterIso !== prepared.installed) {
|
|
@@ -193,7 +193,7 @@ async function finishParkedProbe(account: Account, prepared: { dir: string; inst
|
|
|
193
193
|
const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
|
|
194
194
|
const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
|
|
195
195
|
if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
196
|
-
else log("sample.harvest_skipped", { account: account.
|
|
196
|
+
else log("sample.harvest_skipped", { account: account.id.slice(0, 8) });
|
|
197
197
|
}
|
|
198
198
|
} finally {
|
|
199
199
|
await deleteItem(isoTarget);
|
|
@@ -202,7 +202,7 @@ async function finishParkedProbe(account: Account, prepared: { dir: string; inst
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
205
|
-
const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.
|
|
205
|
+
const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.id)));
|
|
206
206
|
if (!prepared.ok) return prepared;
|
|
207
207
|
try {
|
|
208
208
|
return await runParkedProbe(prepared.dir, {});
|
|
@@ -214,21 +214,21 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
|
|
|
214
214
|
const PREPARE_DEADLINE_MS = 20_000;
|
|
215
215
|
|
|
216
216
|
export async function sampleOldestParked(cfg: Config): Promise<void> {
|
|
217
|
-
const reserved = await withLock(
|
|
217
|
+
const reserved = await withLock(claudePool.lockFile, async () => {
|
|
218
218
|
const now = Date.now();
|
|
219
|
-
const lastSwapAt = loadLastSwapAt();
|
|
219
|
+
const lastSwapAt = loadLastSwapAt(claudePool);
|
|
220
220
|
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
|
|
221
|
-
const idx = loadAccounts();
|
|
221
|
+
const idx = loadAccounts(claudePool);
|
|
222
222
|
const live = readOAuthAccount()?.accountUuid ?? null;
|
|
223
223
|
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
224
|
-
const stale = idx.accounts.filter((a) => a.
|
|
224
|
+
const stale = idx.accounts.filter((a) => a.id !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
|
|
225
225
|
const target = minBy(stale, sampledAt);
|
|
226
226
|
if (!target) return null;
|
|
227
227
|
target.lastProbeAt = now;
|
|
228
|
-
const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.
|
|
229
|
-
saveAccounts(idx);
|
|
228
|
+
const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.id)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
|
|
229
|
+
saveAccounts(claudePool, idx);
|
|
230
230
|
if (!prepared.ok) {
|
|
231
|
-
log("sample.parked_failed", { account: target.
|
|
231
|
+
log("sample.parked_failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
232
232
|
return null;
|
|
233
233
|
}
|
|
234
234
|
return { account: target, prepared };
|
|
@@ -240,18 +240,18 @@ export async function sampleOldestParked(cfg: Config): Promise<void> {
|
|
|
240
240
|
try {
|
|
241
241
|
outcome = await runParkedProbe(prepared.dir, { retries: 0 });
|
|
242
242
|
} finally {
|
|
243
|
-
await withLock(
|
|
243
|
+
await withLock(claudePool.lockFile, async () => {
|
|
244
244
|
await finishParkedProbe(account, prepared);
|
|
245
245
|
if (outcome == null) return;
|
|
246
|
-
const idx = loadAccounts();
|
|
247
|
-
const stored = idx.accounts.find((a) => a.
|
|
246
|
+
const idx = loadAccounts(claudePool);
|
|
247
|
+
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
248
248
|
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
249
|
-
stored.
|
|
249
|
+
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
250
250
|
stored.lastUsageAt = startedAt;
|
|
251
|
-
saveAccounts(idx);
|
|
251
|
+
saveAccounts(claudePool, idx);
|
|
252
252
|
}
|
|
253
253
|
log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
|
|
254
|
-
account: account.
|
|
254
|
+
account: account.id.slice(0, 8),
|
|
255
255
|
...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
|
|
256
256
|
});
|
|
257
257
|
});
|
|
@@ -300,7 +300,7 @@ export async function probeActiveUsage(account: Account): Promise<SampleOutcome>
|
|
|
300
300
|
const identity = await checkIdentity(creds, account);
|
|
301
301
|
if (identity.status === "mismatch") return { ok: false, reason: `live ${identity.reason} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
302
302
|
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
303
|
-
|
|
303
|
+
refreshTier(account, creds);
|
|
304
304
|
|
|
305
305
|
const usage = await probeUsage();
|
|
306
306
|
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
package/src/lib/state.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
2
|
import { isEqual } from "es-toolkit";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
|
|
4
|
+
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv, type PoolPaths } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import {
|
|
7
7
|
AccountsIndexSchema,
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
LastSwapSchema,
|
|
10
10
|
UsageStateSchema,
|
|
11
|
+
type Account,
|
|
11
12
|
type AccountsIndex,
|
|
12
13
|
type Config,
|
|
13
14
|
type UsageState,
|
|
15
|
+
type Window,
|
|
14
16
|
} from "./types.ts";
|
|
15
17
|
|
|
16
18
|
export function loadConfig(): Config {
|
|
@@ -44,25 +46,56 @@ export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: stri
|
|
|
44
46
|
writeFileAtomic(paths.configJson, JSON.stringify(raw, null, 2) + "\n");
|
|
45
47
|
}
|
|
46
48
|
|
|
47
|
-
const emptyIndex = (): AccountsIndex => ({ version:
|
|
49
|
+
const emptyIndex = (): AccountsIndex => ({ version: 2, activeId: null, accounts: [] });
|
|
48
50
|
|
|
49
|
-
export function loadAccounts(): AccountsIndex {
|
|
50
|
-
if (!existsSync(
|
|
51
|
+
export function loadAccounts(pool: PoolPaths): AccountsIndex {
|
|
52
|
+
if (!existsSync(pool.accountsJson)) return emptyIndex();
|
|
51
53
|
let json: unknown;
|
|
52
54
|
try {
|
|
53
|
-
json = JSON.parse(readFileSync(
|
|
55
|
+
json = JSON.parse(readFileSync(pool.accountsJson, "utf8"));
|
|
54
56
|
} catch {
|
|
55
|
-
throw new Error(`${
|
|
57
|
+
throw new Error(`${pool.accountsJson} is corrupt (unparsable JSON) - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
56
58
|
}
|
|
57
59
|
const parsed = AccountsIndexSchema.safeParse(json);
|
|
58
60
|
if (!parsed.success) {
|
|
59
|
-
throw new Error(`${
|
|
61
|
+
throw new Error(`${pool.accountsJson} does not match the accounts schema - refusing to treat a damaged pool as empty; repair or remove the file`);
|
|
60
62
|
}
|
|
61
63
|
return parsed.data;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
|
-
export function saveAccounts(idx: AccountsIndex): void {
|
|
65
|
-
writeFileAtomic(
|
|
66
|
+
export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
|
|
67
|
+
writeFileAtomic(pool.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export type Harvest = {
|
|
71
|
+
id: string;
|
|
72
|
+
email: string | null;
|
|
73
|
+
tier: string | null;
|
|
74
|
+
oauthAccount?: Account["oauthAccount"];
|
|
75
|
+
sample: { windows: Window[]; at: number } | null;
|
|
76
|
+
park: () => Promise<void>;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function upsertAccount(
|
|
80
|
+
idx: AccountsIndex,
|
|
81
|
+
h: Harvest,
|
|
82
|
+
mergeWindows: (next: Window[], prev: Window[]) => Window[],
|
|
83
|
+
): Account {
|
|
84
|
+
const existing = idx.accounts.find((a) => a.id === h.id);
|
|
85
|
+
const fresh: Account = {
|
|
86
|
+
id: h.id,
|
|
87
|
+
label: existing?.label ?? h.email ?? h.id.slice(0, 8),
|
|
88
|
+
email: h.email,
|
|
89
|
+
tier: h.tier,
|
|
90
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
91
|
+
windows: h.sample ? mergeWindows(h.sample.windows, existing?.windows ?? []) : (existing?.windows ?? []),
|
|
92
|
+
lastUsageAt: h.sample ? h.sample.at : existing?.lastUsageAt,
|
|
93
|
+
needsReauth: false,
|
|
94
|
+
...(h.oauthAccount ? { oauthAccount: h.oauthAccount } : {}),
|
|
95
|
+
};
|
|
96
|
+
if (existing) Object.assign(existing, fresh);
|
|
97
|
+
else idx.accounts.push(fresh);
|
|
98
|
+
return existing ?? fresh;
|
|
66
99
|
}
|
|
67
100
|
|
|
68
101
|
export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
|
|
@@ -91,22 +124,22 @@ export function clearUsageSnapshots(): void {
|
|
|
91
124
|
rmSync(paths.usageJson, { force: true });
|
|
92
125
|
}
|
|
93
126
|
|
|
94
|
-
export function loadLastSwapAt(): number | null {
|
|
95
|
-
if (!existsSync(
|
|
127
|
+
export function loadLastSwapAt(pool: PoolPaths): number | null {
|
|
128
|
+
if (!existsSync(pool.lastSwapJson)) return null;
|
|
96
129
|
let json: unknown;
|
|
97
130
|
try {
|
|
98
|
-
json = JSON.parse(readFileSync(
|
|
131
|
+
json = JSON.parse(readFileSync(pool.lastSwapJson, "utf8"));
|
|
99
132
|
} catch {
|
|
100
|
-
throw new Error(`${
|
|
133
|
+
throw new Error(`${pool.lastSwapJson} is corrupt (unparsable JSON) - refusing to treat a damaged swap clock as never-swapped; repair or remove the file`);
|
|
101
134
|
}
|
|
102
135
|
return LastSwapSchema.parse(json).ts;
|
|
103
136
|
}
|
|
104
137
|
|
|
105
|
-
export function saveLastSwapAt(ts: number): void {
|
|
106
|
-
writeFileAtomic(
|
|
138
|
+
export function saveLastSwapAt(pool: PoolPaths, ts: number): void {
|
|
139
|
+
writeFileAtomic(pool.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
|
|
107
140
|
}
|
|
108
141
|
|
|
109
|
-
const DepletedWaitSchema = z.object({ waitUntil: z.number(),
|
|
142
|
+
const DepletedWaitSchema = z.object({ waitUntil: z.number(), id: z.string(), ts: z.number() });
|
|
110
143
|
export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
|
|
111
144
|
|
|
112
145
|
export function loadDepletedWait(): DepletedWait | null {
|
package/src/lib/swap.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, merge
|
|
|
3
3
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
|
|
4
4
|
import { swapOAuthAccount } from "./claudejson.ts";
|
|
5
5
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
6
|
+
import { claudePool, credItemFor } from "./paths.ts";
|
|
6
7
|
import { log } from "./log.ts";
|
|
7
8
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
8
9
|
|
|
@@ -11,7 +12,9 @@ function parseBlob(raw: string) {
|
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export async function performSwap(target: Account): Promise<void> {
|
|
14
|
-
const idx = loadAccounts();
|
|
15
|
+
const idx = loadAccounts(claudePool);
|
|
16
|
+
const oauthAccount = target.oauthAccount;
|
|
17
|
+
if (!oauthAccount) throw new Error(`${target.label} has no oauthAccount on its record - re-add it with \`tokenmaxxing auth ${target.label}\``);
|
|
15
18
|
|
|
16
19
|
const preLive = await readItem(liveTarget());
|
|
17
20
|
let liveOwner: Account | null = null;
|
|
@@ -49,31 +52,31 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
49
52
|
} catch (e) {
|
|
50
53
|
throw new Error(`cannot resolve the live credential's owner (${e instanceof Error ? e.message : String(e)}) - refusing to swap over it; the next check retries`);
|
|
51
54
|
}
|
|
52
|
-
liveOwner = idx.accounts.find((a) => a.
|
|
55
|
+
liveOwner = idx.accounts.find((a) => a.id === identity.accountUuid) ?? null;
|
|
53
56
|
if (!liveOwner) {
|
|
54
57
|
throw new Error(
|
|
55
58
|
`live credential belongs to ${describeIdentity(identity)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
|
|
56
59
|
);
|
|
57
60
|
}
|
|
58
|
-
if (liveOwner.
|
|
61
|
+
if (liveOwner.id !== idx.activeId) {
|
|
59
62
|
log("swap.harvest_drift", {
|
|
60
|
-
labeled: idx.
|
|
61
|
-
actual: liveOwner.
|
|
63
|
+
labeled: idx.activeId?.slice(0, 8) ?? null,
|
|
64
|
+
actual: liveOwner.id.slice(0, 8),
|
|
62
65
|
});
|
|
63
66
|
}
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
const selfSwap = liveOwner != null && liveOwner.
|
|
70
|
+
const selfSwap = liveOwner != null && liveOwner.id === target.id;
|
|
68
71
|
let fresh: OAuthCreds | null = null;
|
|
69
72
|
if (!selfSwap) {
|
|
70
|
-
const parkedRaw = await readItem(parkedTarget(target.
|
|
71
|
-
if (!parkedRaw) throw new Error(`no parked credential for ${target.
|
|
73
|
+
const parkedRaw = await readItem(parkedTarget(credItemFor(target.id)));
|
|
74
|
+
if (!parkedRaw) throw new Error(`no parked credential for ${target.label}`);
|
|
72
75
|
const parked = parseBlob(parkedRaw).claudeAiOauth;
|
|
73
76
|
const markDead = (detail: string): InvalidGrantError => {
|
|
74
|
-
const t = idx.accounts.find((a) => a.
|
|
75
|
-
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
76
|
-
log("swap.invalid_grant", { account: target.
|
|
77
|
+
const t = idx.accounts.find((a) => a.id === target.id);
|
|
78
|
+
if (t) { t.needsReauth = true; saveAccounts(claudePool, idx); }
|
|
79
|
+
log("swap.invalid_grant", { account: target.id.slice(0, 8), detail });
|
|
77
80
|
return new InvalidGrantError(detail);
|
|
78
81
|
};
|
|
79
82
|
if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
|
|
@@ -82,14 +85,14 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
82
85
|
parkedOwner = await fetchTokenIdentity(parked.accessToken);
|
|
83
86
|
} catch (e) {
|
|
84
87
|
if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
|
|
85
|
-
log("swap.parked_token_stale", { account: target.
|
|
88
|
+
log("swap.parked_token_stale", { account: target.id.slice(0, 8) });
|
|
86
89
|
}
|
|
87
|
-
if (parkedOwner != null && parkedOwner.accountUuid !== target.
|
|
88
|
-
const owner = idx.accounts.find((a) => a.
|
|
90
|
+
if (parkedOwner != null && parkedOwner.accountUuid !== target.id) {
|
|
91
|
+
const owner = idx.accounts.find((a) => a.id === parkedOwner.accountUuid) ?? null;
|
|
89
92
|
let kept = "was left in place";
|
|
90
|
-
if (owner != null && owner.
|
|
91
|
-
await writeItem(parkedTarget(owner.
|
|
92
|
-
log("swap.parked_relocated", { account: owner.
|
|
93
|
+
if (owner != null && owner.id !== liveOwner?.id) {
|
|
94
|
+
await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: parked }));
|
|
95
|
+
log("swap.parked_relocated", { account: owner.id.slice(0, 8) });
|
|
93
96
|
kept = "was copied to that account's slot";
|
|
94
97
|
}
|
|
95
98
|
throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
@@ -99,7 +102,7 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
99
102
|
return await refreshCredential(parked);
|
|
100
103
|
} catch (e) {
|
|
101
104
|
if (e instanceof InvalidGrantError) throw markDead(e.detail);
|
|
102
|
-
if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.
|
|
105
|
+
if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.id.slice(0, 8), status: e.status, detail: e.detail });
|
|
103
106
|
throw e;
|
|
104
107
|
}
|
|
105
108
|
};
|
|
@@ -112,18 +115,18 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
112
115
|
try {
|
|
113
116
|
freshOwner = await fetchTokenIdentity(rotated.accessToken);
|
|
114
117
|
} catch (e) {
|
|
115
|
-
await writeItem(parkedTarget(target.
|
|
118
|
+
await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: rotated }));
|
|
116
119
|
throw new Error(`refreshed the parked credential but cannot verify its owner (${e instanceof Error ? e.message : String(e)}) - kept the rotated token parked; the next check retries`);
|
|
117
120
|
}
|
|
118
|
-
if (freshOwner.accountUuid !== target.
|
|
119
|
-
const owner = idx.accounts.find((a) => a.
|
|
120
|
-
const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target,
|
|
121
|
+
if (freshOwner.accountUuid !== target.id) {
|
|
122
|
+
const owner = idx.accounts.find((a) => a.id === freshOwner.accountUuid) ?? null;
|
|
123
|
+
const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerId: liveOwner?.id ?? null, expectedLiveToken, lock });
|
|
121
124
|
throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
|
|
122
125
|
}
|
|
123
126
|
return rotated;
|
|
124
127
|
});
|
|
125
128
|
}
|
|
126
|
-
await writeItem(parkedTarget(target.
|
|
129
|
+
await writeItem(parkedTarget(credItemFor(target.id)), JSON.stringify({ claudeAiOauth: fresh }));
|
|
127
130
|
}
|
|
128
131
|
|
|
129
132
|
await withClaudeRefreshLock(async (lock) => {
|
|
@@ -136,44 +139,44 @@ export async function performSwap(target: Account): Promise<void> {
|
|
|
136
139
|
}
|
|
137
140
|
|
|
138
141
|
if (liveOwner && currentLive) {
|
|
139
|
-
await writeItem(parkedTarget(liveOwner.
|
|
140
|
-
log("swap.harvest", { account: liveOwner.
|
|
142
|
+
await writeItem(parkedTarget(credItemFor(liveOwner.id)), claudeAiOauthOnly(currentLive));
|
|
143
|
+
log("swap.harvest", { account: liveOwner.id.slice(0, 8) });
|
|
141
144
|
}
|
|
142
145
|
|
|
143
146
|
if (fresh != null) {
|
|
144
147
|
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
145
148
|
}
|
|
146
|
-
swapOAuthAccount(
|
|
147
|
-
idx.
|
|
148
|
-
const t2 = idx.accounts.find((a) => a.
|
|
149
|
+
swapOAuthAccount(oauthAccount);
|
|
150
|
+
idx.activeId = target.id;
|
|
151
|
+
const t2 = idx.accounts.find((a) => a.id === target.id);
|
|
149
152
|
if (t2) { t2.needsReauth = false; }
|
|
150
|
-
saveAccounts(idx);
|
|
153
|
+
saveAccounts(claudePool, idx);
|
|
151
154
|
clearUsageSnapshots();
|
|
152
155
|
clearDepletedWait();
|
|
153
|
-
saveLastSwapAt(Date.now());
|
|
156
|
+
saveLastSwapAt(claudePool, Date.now());
|
|
154
157
|
});
|
|
155
|
-
log("swap.done", { account: target.
|
|
158
|
+
log("swap.done", { account: target.id.slice(0, 8), label: target.label });
|
|
156
159
|
}
|
|
157
160
|
|
|
158
161
|
export async function keepRotatedPair(input: {
|
|
159
162
|
fresh: OAuthCreds;
|
|
160
163
|
owner: Account | null;
|
|
161
164
|
fallback: Account;
|
|
162
|
-
|
|
165
|
+
liveOwnerId: string | null;
|
|
163
166
|
expectedLiveToken: string | null;
|
|
164
167
|
lock: { compromised: () => boolean };
|
|
165
168
|
}): Promise<string> {
|
|
166
169
|
const { fresh, owner } = input;
|
|
167
170
|
if (owner == null) {
|
|
168
|
-
await writeItem(parkedTarget(input.fallback.
|
|
169
|
-
log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.
|
|
171
|
+
await writeItem(parkedTarget(credItemFor(input.fallback.id)), JSON.stringify({ claudeAiOauth: fresh }));
|
|
172
|
+
log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.id.slice(0, 8) });
|
|
170
173
|
return "was kept in this account's stamped slot because its owner is not in the pool";
|
|
171
174
|
}
|
|
172
175
|
const park = async (): Promise<void> => {
|
|
173
|
-
await writeItem(parkedTarget(owner.
|
|
174
|
-
log("swap.rotated_parked_rescued", { account: owner.
|
|
176
|
+
await writeItem(parkedTarget(credItemFor(owner.id)), JSON.stringify({ claudeAiOauth: fresh }));
|
|
177
|
+
log("swap.rotated_parked_rescued", { account: owner.id.slice(0, 8) });
|
|
175
178
|
};
|
|
176
|
-
if (input.
|
|
179
|
+
if (input.liveOwnerId !== owner.id) {
|
|
177
180
|
await park();
|
|
178
181
|
return "was parked under that account";
|
|
179
182
|
}
|
|
@@ -182,7 +185,7 @@ export async function keepRotatedPair(input: {
|
|
|
182
185
|
if (input.lock.compromised()) throw new Error("refresh lock compromised");
|
|
183
186
|
if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
|
|
184
187
|
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
185
|
-
log("swap.rotated_live_rescued", { account: owner.
|
|
188
|
+
log("swap.rotated_live_rescued", { account: owner.id.slice(0, 8) });
|
|
186
189
|
return "was installed into the live store so that account's sessions continue";
|
|
187
190
|
} catch (e) {
|
|
188
191
|
await park();
|
package/src/lib/types.ts
CHANGED
|
@@ -38,10 +38,18 @@ const AggregateWindowsSchema = z.object({
|
|
|
38
38
|
|
|
39
39
|
export const UsageWindowsSchema = AggregateWindowsSchema.extend({
|
|
40
40
|
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
41
|
-
rowsAt: z.number().optional(),
|
|
42
41
|
});
|
|
43
42
|
export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
44
43
|
|
|
44
|
+
export const WindowSchema = z.object({
|
|
45
|
+
name: z.string().nullable(),
|
|
46
|
+
usedPercentage: z.number(),
|
|
47
|
+
resetsAt: z.number().nullable(),
|
|
48
|
+
windowSeconds: z.number().nullable(),
|
|
49
|
+
sampledAt: z.number(),
|
|
50
|
+
});
|
|
51
|
+
export type Window = z.infer<typeof WindowSchema>;
|
|
52
|
+
|
|
45
53
|
const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
46
54
|
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
47
55
|
|
|
@@ -54,26 +62,23 @@ export const UsageStateSchema = AggregateWindowsSchema.extend({
|
|
|
54
62
|
export type UsageState = z.infer<typeof UsageStateSchema>;
|
|
55
63
|
|
|
56
64
|
export const AccountSchema = z.object({
|
|
57
|
-
|
|
58
|
-
email: z.string(),
|
|
59
|
-
organizationUuid: z.string(),
|
|
65
|
+
id: z.string(),
|
|
60
66
|
label: z.string(),
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
email: z.string().nullable(),
|
|
68
|
+
tier: z.string().nullable(),
|
|
63
69
|
addedAt: z.string(),
|
|
64
|
-
|
|
70
|
+
windows: z.array(WindowSchema).default([]),
|
|
65
71
|
lastUsageAt: z.number().optional(),
|
|
66
72
|
lastProbeAt: z.number().optional(),
|
|
67
73
|
enforcedUntil: z.number().optional(),
|
|
68
74
|
needsReauth: z.boolean().optional(),
|
|
69
|
-
|
|
70
|
-
rateLimitTier: z.string().optional(),
|
|
75
|
+
oauthAccount: OAuthAccountSchema.optional(),
|
|
71
76
|
});
|
|
72
77
|
export type Account = z.infer<typeof AccountSchema>;
|
|
73
78
|
|
|
74
79
|
export const AccountsIndexSchema = z.object({
|
|
75
|
-
version: z.literal(
|
|
76
|
-
|
|
80
|
+
version: z.literal(2),
|
|
81
|
+
activeId: z.string().nullable(),
|
|
77
82
|
accounts: z.array(AccountSchema).default([]),
|
|
78
83
|
});
|
|
79
84
|
|
|
@@ -216,53 +221,14 @@ export const CodexAuthJsonSchema = z.looseObject({
|
|
|
216
221
|
});
|
|
217
222
|
export type CodexAuthJson = z.infer<typeof CodexAuthJsonSchema>;
|
|
218
223
|
|
|
219
|
-
const CodexWindowSchema = z.object({
|
|
220
|
-
usedPercentage: z.number(),
|
|
221
|
-
resetsAt: z.number().nullable(),
|
|
222
|
-
windowSeconds: z.number().nullable(),
|
|
223
|
-
});
|
|
224
|
-
export type CodexWindow = z.infer<typeof CodexWindowSchema>;
|
|
225
|
-
|
|
226
224
|
export const CodexUsageSchema = z.object({
|
|
227
225
|
accountId: z.string(),
|
|
228
226
|
email: z.string().nullable(),
|
|
229
227
|
planType: z.string().nullable(),
|
|
230
|
-
|
|
231
|
-
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
228
|
+
windows: z.array(WindowSchema),
|
|
232
229
|
});
|
|
233
230
|
export type CodexUsage = z.infer<typeof CodexUsageSchema>;
|
|
234
231
|
|
|
235
|
-
const BareFileNameSchema = z
|
|
236
|
-
.string()
|
|
237
|
-
.refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
|
|
238
|
-
message: "credFile must be a bare file name, not a path",
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
export const CodexAccountSchema = z.object({
|
|
242
|
-
accountId: z.string(),
|
|
243
|
-
email: z.string().nullable(),
|
|
244
|
-
label: z.string(),
|
|
245
|
-
planType: z.string().nullable(),
|
|
246
|
-
credFile: BareFileNameSchema,
|
|
247
|
-
addedAt: z.string(),
|
|
248
|
-
needsReauth: z.boolean().optional(),
|
|
249
|
-
lastUsage: z
|
|
250
|
-
.object({
|
|
251
|
-
aggregate: z.array(CodexWindowSchema),
|
|
252
|
-
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
253
|
-
})
|
|
254
|
-
.optional(),
|
|
255
|
-
lastUsageAt: z.number().optional(),
|
|
256
|
-
});
|
|
257
|
-
export type CodexAccount = z.infer<typeof CodexAccountSchema>;
|
|
258
|
-
|
|
259
|
-
export const CodexAccountsIndexSchema = z.object({
|
|
260
|
-
version: z.literal(1),
|
|
261
|
-
activeAccountId: z.string().nullable(),
|
|
262
|
-
accounts: z.array(CodexAccountSchema).default([]),
|
|
263
|
-
});
|
|
264
|
-
export type CodexAccountsIndex = z.infer<typeof CodexAccountsIndexSchema>;
|
|
265
|
-
|
|
266
232
|
export const CodexStopStdinSchema = z.looseObject({
|
|
267
233
|
session_id: z.string().optional(),
|
|
268
234
|
hook_event_name: z.string().optional(),
|
package/src/lib/usage.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { delay } from "es-toolkit";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
5
5
|
import { log } from "./log.ts";
|
|
6
|
-
import { RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
6
|
+
import { RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows, type Window } from "./types.ts";
|
|
7
7
|
|
|
8
8
|
export function normalizeResetsAt(v: unknown): number | null {
|
|
9
9
|
const num = z.number().finite().safeParse(v);
|
|
@@ -239,9 +239,22 @@ export function parseUsageText(text: string, now = Date.now()): UsageWindows | n
|
|
|
239
239
|
return { fiveHour, sevenDay, perModel };
|
|
240
240
|
}
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
242
|
+
const FIVE_HOURS_S = 5 * 3600;
|
|
243
|
+
const WEEK_S = 7 * 24 * 3600;
|
|
244
|
+
|
|
245
|
+
export function windowsOf(u: UsageWindows, at: number): Window[] {
|
|
246
|
+
return [
|
|
247
|
+
{ name: null, ...u.fiveHour, windowSeconds: FIVE_HOURS_S, sampledAt: at },
|
|
248
|
+
{ name: null, ...u.sevenDay, windowSeconds: WEEK_S, sampledAt: at },
|
|
249
|
+
...Object.entries(u.perModel).map(([name, w]) => ({ name, ...w, windowSeconds: WEEK_S, sampledAt: at })),
|
|
250
|
+
];
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function mergeWindows(next: Window[], prev: Window[]): Window[] {
|
|
254
|
+
const named = (ws: Window[]) => ws.filter((w) => w.name != null);
|
|
255
|
+
const newest = (ws: Window[]) => Math.max(0, ...ws.map((w) => w.sampledAt));
|
|
256
|
+
const rows = named(next).length > 0 && newest(named(prev)) <= newest(named(next)) ? named(next) : named(prev);
|
|
257
|
+
return [...next.filter((w) => w.name == null), ...rows];
|
|
245
258
|
}
|
|
246
259
|
|
|
247
260
|
export const CRED_ENV_OVERRIDES = [
|