tokenmaxxing 1.18.0 → 1.19.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/src/lib/oauth.ts CHANGED
@@ -29,11 +29,19 @@ export class RefreshRejectedError extends Error {
29
29
  }
30
30
  }
31
31
 
32
+ export class IdentityUnavailableError extends Error {
33
+ constructor(public readonly status: number | null, public readonly detail: string) {
34
+ super(status == null ? `profile endpoint unreachable: ${detail}` : `identity check failed (HTTP ${status}): ${detail}`);
35
+ this.name = "IdentityUnavailableError";
36
+ }
37
+ }
38
+
32
39
  export function isDeadCredential(creds: OAuthCreds): boolean {
33
40
  return creds.refreshToken === "" || creds.accessToken === "";
34
41
  }
35
42
 
36
43
  export async function refreshCredential(creds: OAuthCreds, now = Date.now()): Promise<OAuthCreds> {
44
+ if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
37
45
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
38
46
  const body = {
39
47
  grant_type: "refresh_token",
@@ -93,14 +101,19 @@ export async function fetchTokenIdentity(accessToken: string): Promise<TokenIden
93
101
  headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
94
102
  });
95
103
  } catch (e) {
96
- throw new Error(`profile endpoint unreachable: ${e instanceof Error ? e.message : String(e)}`);
104
+ throw new IdentityUnavailableError(null, e instanceof Error ? e.message : String(e));
97
105
  }
98
- const text = await res.text();
99
- if (!res.ok) throw new Error(`identity check failed (HTTP ${res.status}): ${safeErrorDetail({ text })}`);
106
+ let text: string;
107
+ try {
108
+ text = await res.text();
109
+ } catch (e) {
110
+ throw new IdentityUnavailableError(res.status, `profile response body unreadable: ${e instanceof Error ? e.message : String(e)}`);
111
+ }
112
+ if (!res.ok) throw new IdentityUnavailableError(res.status, safeErrorDetail({ text }));
100
113
  const parsed = ProfileResponseSchema.safeParse((() => {
101
114
  try { return JSON.parse(text); } catch { return null; }
102
115
  })());
103
- if (!parsed.success) throw new Error(`profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
116
+ if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
104
117
  return TokenIdentitySchema.parse({
105
118
  accountUuid: parsed.data.account.uuid,
106
119
  email: parsed.data.account.email ?? null,
package/src/lib/paths.ts CHANGED
@@ -19,6 +19,7 @@ export const paths = {
19
19
  usageJson: join(TM_HOME, "usage.json"),
20
20
  modelUsageJson: join(TM_HOME, "model-usage.json"),
21
21
  lastSwapJson: join(TM_HOME, "lastswap.json"),
22
+ lastResetJson: join(TM_HOME, "lastreset.json"),
22
23
  depletedJson: join(TM_HOME, "depleted.json"),
23
24
  nextCheckJson: join(TM_HOME, "nextcheck.json"),
24
25
  respawnDir: join(TM_HOME, "respawn"),
package/src/lib/sample.ts CHANGED
@@ -4,9 +4,12 @@ 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, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError } from "./oauth.ts";
7
+ import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
8
8
  import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
9
- import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
+ import { loadAccounts } from "./state.ts";
10
+ import { keepRotatedPair } from "./swap.ts";
11
+ import { log } from "./log.ts";
12
+ import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
10
13
 
11
14
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
12
15
  z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
@@ -16,8 +19,8 @@ export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
16
19
 
17
20
  const IdentityCheckSchema = z.discriminatedUnion("status", [
18
21
  z.object({ status: z.literal("match") }),
19
- z.object({ status: z.literal("mismatch"), reason: z.string() }),
20
- z.object({ status: z.literal("unavailable"), reason: z.string() }),
22
+ z.object({ status: z.literal("mismatch"), reason: z.string(), owner: TokenIdentitySchema }),
23
+ z.object({ status: z.literal("unavailable"), reason: z.string(), stale: z.boolean() }),
21
24
  ]);
22
25
  type IdentityCheck = z.infer<typeof IdentityCheckSchema>;
23
26
 
@@ -26,10 +29,14 @@ async function checkIdentity(creds: OAuthCreds, account: Account): Promise<Ident
26
29
  try {
27
30
  identity = await fetchTokenIdentity(creds.accessToken);
28
31
  } catch (e) {
29
- return { status: "unavailable", reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}` };
32
+ return {
33
+ status: "unavailable",
34
+ reason: `credential identity check failed: ${e instanceof Error ? e.message : String(e)}`,
35
+ stale: e instanceof IdentityUnavailableError && e.status === 401,
36
+ };
30
37
  }
31
38
  if (identity.accountUuid === account.accountUuid) return { status: "match" };
32
- return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}` };
39
+ return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
33
40
  }
34
41
 
35
42
  function refreshPlanFields(account: Account, creds: OAuthCreds): void {
@@ -54,6 +61,8 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
54
61
  }
55
62
 
56
63
  const liveRaw = await readItem(liveTarget());
64
+ let liveAccount: string | null = null;
65
+ let liveToken: string | null = null;
57
66
  if (liveRaw != null) {
58
67
  let liveCreds: OAuthCreds;
59
68
  try {
@@ -62,7 +71,7 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
62
71
  return { ok: false, reason: `cannot read the live credential (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
63
72
  }
64
73
  if (!isDeadCredential(liveCreds)) {
65
- let liveAccount: string;
74
+ liveToken = liveCreds.accessToken;
66
75
  try {
67
76
  liveAccount = (await fetchTokenIdentity(liveCreds.accessToken)).accountUuid;
68
77
  } catch (e) {
@@ -74,28 +83,64 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
74
83
  }
75
84
  }
76
85
 
86
+ const relocate = async (owner: TokenIdentity): Promise<string> => {
87
+ const pooled = loadAccounts().accounts.find((a) => a.accountUuid === owner.accountUuid) ?? null;
88
+ if (pooled == null || pooled.accountUuid === liveAccount) return "was left in place";
89
+ await writeItem(parkedTarget(pooled.keychainItem), JSON.stringify({ claudeAiOauth: creds }));
90
+ log("swap.parked_relocated", { account: pooled.accountUuid.slice(0, 8) });
91
+ return "was copied to that account's slot";
92
+ };
93
+
77
94
  if (isAccessTokenExpiring(creds, 300_000)) {
78
95
  const owner = await checkIdentity(creds, account);
79
96
  if (owner.status === "mismatch") {
97
+ const kept = await relocate(owner.owner);
80
98
  account.needsReauth = true;
81
- return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth\`` };
99
+ return { ok: false, reason: `${owner.reason} - refusing to spend another account's grant; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
82
100
  }
83
- try {
84
- creds = await refreshCredential(creds);
85
- await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
86
- } catch (e) {
87
- if (e instanceof InvalidGrantError) {
88
- account.needsReauth = true;
89
- return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" };
101
+ if (owner.status === "unavailable" && !owner.stale) {
102
+ return { ok: false, reason: `${owner.reason} - refusing to refresh a parked credential whose owner cannot be verified` };
103
+ }
104
+ const rotate = async (): Promise<{ fresh: OAuthCreds } | { failed: SampleOutcome }> => {
105
+ try {
106
+ return { fresh: await refreshCredential(creds) };
107
+ } catch (e) {
108
+ if (e instanceof InvalidGrantError) {
109
+ account.needsReauth = true;
110
+ return { failed: { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing auth`" } };
111
+ }
112
+ return { failed: { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` } };
113
+ }
114
+ };
115
+ let result: { fresh: OAuthCreds } | { failed: SampleOutcome };
116
+ if (owner.status === "match") {
117
+ result = await rotate();
118
+ } else {
119
+ try {
120
+ result = await withClaudeRefreshLock(async (lock) => {
121
+ const rotated = await rotate();
122
+ if ("failed" in rotated) return rotated;
123
+ const verified = await checkIdentity(rotated.fresh, account);
124
+ if (verified.status !== "mismatch") return rotated;
125
+ const trueOwner = loadAccounts().accounts.find((a) => a.accountUuid === verified.owner.accountUuid) ?? null;
126
+ const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
127
+ account.needsReauth = true;
128
+ return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
129
+ });
130
+ } catch (e) {
131
+ return { ok: false, reason: `cannot arbitrate a stale parked credential: ${e instanceof Error ? e.message : String(e)}` };
90
132
  }
91
- return { ok: false, reason: `token refresh failed: ${e instanceof Error ? e.message : String(e)}` };
92
133
  }
134
+ if ("failed" in result) return result.failed;
135
+ creds = result.fresh;
136
+ await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
93
137
  }
94
138
 
95
139
  const identity = await checkIdentity(creds, account);
96
140
  if (identity.status === "mismatch") {
141
+ const kept = await relocate(identity.owner);
97
142
  account.needsReauth = true;
98
- return { ok: false, reason: `${identity.reason} - this account's own credential is gone; re-auth with \`tokenmaxxing auth\`` };
143
+ return { ok: false, reason: `${identity.reason} - this account's own credential is gone; the pair ${kept}; re-auth with \`tokenmaxxing auth\`` };
99
144
  }
100
145
  if (identity.status === "unavailable") {
101
146
  return { ok: false, reason: identity.reason };
package/src/lib/state.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
- import { isEqual } from "es-toolkit";
2
+ import { isEqual, uniq } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
8
+ BankedResetProvidersSchema,
8
9
  ConfigSchema,
9
10
  LastSwapSchema,
10
11
  ModelUsageStateSchema,
@@ -13,8 +14,11 @@ import {
13
14
  UsageStateSchema,
14
15
  type AccountsIndex,
15
16
  type Config,
17
+ type FamilyAnchor,
16
18
  type ModelUsageState,
17
19
  type UsageState,
20
+ type UsageWindow,
21
+ type WindowAnchor,
18
22
  } from "./types.ts";
19
23
 
20
24
  const DEFAULT_CONFIG: Config = {
@@ -30,6 +34,7 @@ const DEFAULT_CONFIG: Config = {
30
34
  usagePollTtlMs: 90_000,
31
35
  maxWaitMs: 3_600_000,
32
36
  checkIntervalMs: 60_000,
37
+ preferToUseBankedReset: [],
33
38
  },
34
39
  };
35
40
 
@@ -50,6 +55,7 @@ export const ConfigFileSchema = z
50
55
  usagePollTtlMs: z.number().int().positive(),
51
56
  maxWaitMs: z.number().int().positive(),
52
57
  checkIntervalMs: z.number().int().min(10_000),
58
+ preferToUseBankedReset: BankedResetProvidersSchema,
53
59
  })
54
60
  .partial(),
55
61
  })
@@ -83,6 +89,9 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
83
89
  if (p.policy?.switchModels) {
84
90
  cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
85
91
  }
92
+ if (p.policy?.preferToUseBankedReset) {
93
+ cfg.policy.preferToUseBankedReset = uniq(p.policy.preferToUseBankedReset);
94
+ }
86
95
  const envBin = realClaudeBinFromEnv();
87
96
  if (envBin) cfg.claudeBin = envBin;
88
97
  const envCodexBin = realCodexBinFromEnv();
@@ -187,6 +196,29 @@ export function saveLastSwapAt(ts: number): void {
187
196
  writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
188
197
  }
189
198
 
199
+ export function loadLastResetAt(): number | null {
200
+ if (!existsSync(paths.lastResetJson)) return null;
201
+ let json: unknown;
202
+ try {
203
+ json = JSON.parse(readFileSync(paths.lastResetJson, "utf8"));
204
+ } catch {
205
+ throw new Error(`${paths.lastResetJson} is corrupt (unparsable JSON) - refusing to treat a damaged reset clock as never-reset; repair or remove the file`);
206
+ }
207
+ return LastSwapSchema.parse(json).ts;
208
+ }
209
+
210
+ export function saveLastResetAt(ts: number): void {
211
+ writeFileAtomic(paths.lastResetJson, JSON.stringify(LastSwapSchema.parse({ ts })));
212
+ }
213
+
214
+ export function loadLastSeatChangeAt(): number | null {
215
+ const swapAt = loadLastSwapAt();
216
+ const resetAt = loadLastResetAt();
217
+ if (swapAt == null) return resetAt;
218
+ if (resetAt == null) return swapAt;
219
+ return Math.max(swapAt, resetAt);
220
+ }
221
+
190
222
  const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
191
223
  export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
192
224
 
@@ -237,10 +269,68 @@ export function clearNextCheck(): void {
237
269
  }
238
270
 
239
271
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
272
+ const SAMPLED_AT_REFRESH_MS = 30_000;
273
+ const SESSION_COST_MIN_DELTA = 25;
274
+
275
+ function anchorHolds(anchor: WindowAnchor, next: UsageState): boolean {
276
+ return (
277
+ anchor.fiveHourResetsAt === next.fiveHour.resetsAt &&
278
+ anchor.sevenDayResetsAt === next.sevenDay.resetsAt &&
279
+ next.fiveHour.usedPercentage >= anchor.session &&
280
+ next.sevenDay.usedPercentage >= anchor.weekly
281
+ );
282
+ }
283
+
284
+ export function measureSessionWindow(prev: UsageState | null, next: UsageState, stamp: boolean): Pick<UsageState, "anchor" | "sessionWindowWeeklyCost"> {
285
+ const same = prev != null && prev.account === next.account;
286
+ const carried = same ? prev.sessionWindowWeeklyCost : undefined;
287
+ if (stamp) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { sessionWindowWeeklyCost: carried } : {}) };
288
+ const anchor: WindowAnchor =
289
+ same && prev.anchor && anchorHolds(prev.anchor, next)
290
+ ? prev.anchor
291
+ : { fiveHourResetsAt: next.fiveHour.resetsAt, sevenDayResetsAt: next.sevenDay.resetsAt, session: next.fiveHour.usedPercentage, weekly: next.sevenDay.usedPercentage };
292
+ const sessionDelta = next.fiveHour.usedPercentage - anchor.session;
293
+ const weeklyDelta = next.sevenDay.usedPercentage - anchor.weekly;
294
+ const measured = sessionDelta >= SESSION_COST_MIN_DELTA && weeklyDelta > 0 ? (100 * weeklyDelta) / sessionDelta : undefined;
295
+ const cost = measured ?? carried;
296
+ return { anchor, ...(cost != null ? { sessionWindowWeeklyCost: cost } : {}) };
297
+ }
298
+
299
+ function familyAnchorHolds(anchor: FamilyAnchor, session: UsageWindow, caps: Record<string, number>): boolean {
300
+ return (
301
+ anchor.fiveHourResetsAt === session.resetsAt &&
302
+ session.usedPercentage >= anchor.session &&
303
+ Object.entries(caps).every(([family, used]) => anchor.caps[family] == null || used >= anchor.caps[family])
304
+ );
305
+ }
306
+
307
+ export function measureFamilyWindow(prev: ModelUsageState | null, account: string | null, session: UsageWindow | null, caps: Record<string, number>): Pick<ModelUsageState, "anchor" | "familyCosts"> {
308
+ const same = prev != null && prev.account === account;
309
+ const carried = same ? prev.familyCosts : undefined;
310
+ if (session == null) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { familyCosts: carried } : {}) };
311
+ const held = same && prev.anchor && familyAnchorHolds(prev.anchor, session, caps) ? prev.anchor : null;
312
+ const anchor: FamilyAnchor = held ? { ...held, caps: { ...caps, ...held.caps } } : { fiveHourResetsAt: session.resetsAt, session: session.usedPercentage, caps };
313
+ const sessionDelta = session.usedPercentage - anchor.session;
314
+ const measured = Object.entries(caps).flatMap(([family, used]) => {
315
+ const capDelta = used - (anchor.caps[family] ?? used);
316
+ return sessionDelta >= SESSION_COST_MIN_DELTA && capDelta > 0 ? [[family, (100 * capDelta) / sessionDelta] as const] : [];
317
+ });
318
+ const familyCosts = { ...carried, ...Object.fromEntries(measured) };
319
+ return { anchor, ...(Object.keys(familyCosts).length > 0 ? { familyCosts } : {}) };
320
+ }
240
321
 
241
- export function writeUsage(next: UsageState): boolean {
322
+ export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
242
323
  const prev = loadUsage();
243
- if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 }) && next.ts - prev.ts < USAGE_TS_REFRESH_MS) {
324
+ const stamp = opts.stamp === true;
325
+ const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
326
+ const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
327
+ const next: UsageState = { ...input, ...measureSessionWindow(prev, input, stamp), ...(sampledAt != null ? { sampledAt } : {}) };
328
+ if (
329
+ prev &&
330
+ isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
331
+ next.ts - prev.ts < USAGE_TS_REFRESH_MS &&
332
+ (stamp || next.ts - (prev.sampledAt ?? prev.ts) < SAMPLED_AT_REFRESH_MS)
333
+ ) {
244
334
  try {
245
335
  utimesSync(paths.usageJson, new Date(next.ts), new Date(next.ts));
246
336
  } catch (e) {
package/src/lib/swap.ts CHANGED
@@ -1,11 +1,11 @@
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, isDeadCredential, fetchTokenIdentity, describeIdentity, InvalidGrantError, RefreshRejectedError } from "./oauth.ts";
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
6
  import { log } from "./log.ts";
7
7
  import { pickBest, type PickCtx } from "./picker.ts";
8
- import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
8
+ import { CredentialBlobSchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
9
9
 
10
10
  function parseBlob(raw: string) {
11
11
  return CredentialBlobSchema.parse(JSON.parse(raw));
@@ -44,7 +44,12 @@ export async function performSwap(target: Account): Promise<void> {
44
44
  }
45
45
  }
46
46
  if (liveCreds != null) {
47
- const identity = await fetchTokenIdentity(liveCreds.accessToken);
47
+ let identity: TokenIdentity;
48
+ try {
49
+ identity = await fetchTokenIdentity(liveCreds.accessToken);
50
+ } catch (e) {
51
+ 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`);
52
+ }
48
53
  liveOwner = idx.accounts.find((a) => a.accountUuid === identity.accountUuid) ?? null;
49
54
  if (!liveOwner) {
50
55
  throw new Error(
@@ -73,16 +78,51 @@ export async function performSwap(target: Account): Promise<void> {
73
78
  return new InvalidGrantError(detail);
74
79
  };
75
80
  if (isDeadCredential(parked)) throw markDead("parked credential was cleared after a failed refresh - re-auth with `tokenmaxxing auth`");
76
- const parkedOwner = await fetchTokenIdentity(parked.accessToken).catch(() => null);
77
- if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
78
- throw markDead(`parked credential belongs to ${describeIdentity(parkedOwner)} - refusing to spend another account's grant; re-auth with \`tokenmaxxing auth ${target.label}\``);
79
- }
81
+ let parkedOwner: TokenIdentity | null = null;
80
82
  try {
81
- fresh = await refreshCredential(parked);
83
+ parkedOwner = await fetchTokenIdentity(parked.accessToken);
82
84
  } catch (e) {
83
- if (e instanceof InvalidGrantError) throw markDead(e.detail);
84
- if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
85
- throw e;
85
+ if (!(e instanceof IdentityUnavailableError && e.status === 401)) throw e;
86
+ log("swap.parked_token_stale", { account: target.accountUuid.slice(0, 8) });
87
+ }
88
+ if (parkedOwner != null && parkedOwner.accountUuid !== target.accountUuid) {
89
+ const owner = idx.accounts.find((a) => a.accountUuid === parkedOwner.accountUuid) ?? null;
90
+ let kept = "was left in place";
91
+ if (owner != null && owner.accountUuid !== liveOwner?.accountUuid) {
92
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: parked }));
93
+ log("swap.parked_relocated", { account: owner.accountUuid.slice(0, 8) });
94
+ kept = "was copied to that account's slot";
95
+ }
96
+ 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}\``);
97
+ }
98
+ const refreshParked = async (): Promise<OAuthCreds> => {
99
+ try {
100
+ return await refreshCredential(parked);
101
+ } catch (e) {
102
+ if (e instanceof InvalidGrantError) throw markDead(e.detail);
103
+ if (e instanceof RefreshRejectedError) log("swap.refresh_rejected", { account: target.accountUuid.slice(0, 8), status: e.status, detail: e.detail });
104
+ throw e;
105
+ }
106
+ };
107
+ if (parkedOwner != null) {
108
+ fresh = await refreshParked();
109
+ } else {
110
+ fresh = await withClaudeRefreshLock(async (lock) => {
111
+ const rotated = await refreshParked();
112
+ let freshOwner: TokenIdentity;
113
+ try {
114
+ freshOwner = await fetchTokenIdentity(rotated.accessToken);
115
+ } catch (e) {
116
+ await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: rotated }));
117
+ 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`);
118
+ }
119
+ if (freshOwner.accountUuid !== target.accountUuid) {
120
+ const owner = idx.accounts.find((a) => a.accountUuid === freshOwner.accountUuid) ?? null;
121
+ const kept = await keepRotatedPair({ fresh: rotated, owner, fallback: target, liveOwnerUuid: liveOwner?.accountUuid ?? null, expectedLiveToken, lock });
122
+ throw markDead(`parked credential belongs to ${describeIdentity(freshOwner)}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth ${target.label}\``);
123
+ }
124
+ return rotated;
125
+ });
86
126
  }
87
127
  await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
88
128
  }
@@ -117,8 +157,43 @@ export async function performSwap(target: Account): Promise<void> {
117
157
  log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
118
158
  }
119
159
 
160
+ export async function keepRotatedPair(input: {
161
+ fresh: OAuthCreds;
162
+ owner: Account | null;
163
+ fallback: Account;
164
+ liveOwnerUuid: string | null;
165
+ expectedLiveToken: string | null;
166
+ lock: { compromised: () => boolean };
167
+ }): Promise<string> {
168
+ const { fresh, owner } = input;
169
+ if (owner == null) {
170
+ await writeItem(parkedTarget(input.fallback.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
171
+ log("swap.rotated_kept_in_stamped_slot", { account: input.fallback.accountUuid.slice(0, 8) });
172
+ return "was kept in this account's stamped slot because its owner is not in the pool";
173
+ }
174
+ const park = async (): Promise<void> => {
175
+ await writeItem(parkedTarget(owner.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
176
+ log("swap.rotated_parked_rescued", { account: owner.accountUuid.slice(0, 8) });
177
+ };
178
+ if (input.liveOwnerUuid !== owner.accountUuid) {
179
+ await park();
180
+ return "was parked under that account";
181
+ }
182
+ try {
183
+ const currentLive = await readItem(liveTarget());
184
+ if (input.lock.compromised()) throw new Error("refresh lock compromised");
185
+ if (currentLive == null || parseBlob(currentLive).claudeAiOauth.accessToken !== input.expectedLiveToken) throw new Error("live credential changed while unlocked");
186
+ await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
187
+ log("swap.rotated_live_rescued", { account: owner.accountUuid.slice(0, 8) });
188
+ return "was installed into the live store so that account's sessions continue";
189
+ } catch (e) {
190
+ await park();
191
+ return `was parked under that account (${e instanceof Error ? e.message : String(e)})`;
192
+ }
193
+ }
194
+
120
195
  export function isSkippableSwapError(e: unknown): boolean {
121
- return e instanceof InvalidGrantError || e instanceof RefreshRejectedError;
196
+ return e instanceof InvalidGrantError || e instanceof RefreshRejectedError || e instanceof IdentityUnavailableError;
122
197
  }
123
198
 
124
199
  export async function chooseAndSwap(ctx: PickCtx, exclude: ReadonlySet<string> = new Set()): Promise<Account | null> {
package/src/lib/types.ts CHANGED
@@ -41,21 +41,51 @@ export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
41
41
  const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
42
42
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
43
43
 
44
+ const WindowAnchorSchema = z.object({
45
+ fiveHourResetsAt: z.number().nullable(),
46
+ sevenDayResetsAt: z.number().nullable(),
47
+ session: z.number(),
48
+ weekly: z.number(),
49
+ });
50
+ export type WindowAnchor = z.infer<typeof WindowAnchorSchema>;
51
+
44
52
  export const UsageStateSchema = UsageWindowsSchema.extend({
45
53
  account: z.string().nullable(),
46
54
  ts: z.number(),
47
55
  model: ModelInfoSchema.nullable().default(null),
56
+ anchor: WindowAnchorSchema.optional(),
57
+ sessionWindowWeeklyCost: z.number().positive().optional(),
58
+ sampledAt: z.number().optional(),
48
59
  });
49
60
  export type UsageState = z.infer<typeof UsageStateSchema>;
50
61
 
62
+ const FamilyAnchorSchema = z.object({
63
+ fiveHourResetsAt: z.number().nullable(),
64
+ session: z.number(),
65
+ caps: z.record(z.string(), z.number()),
66
+ });
67
+ export type FamilyAnchor = z.infer<typeof FamilyAnchorSchema>;
68
+
51
69
  export const ModelUsageStateSchema = z.object({
52
70
  perModel: z.record(z.string(), UsageWindowSchema).default({}),
53
71
  account: z.string().nullable(),
54
72
  ts: z.number(),
55
73
  sampledAt: z.number().optional(),
74
+ anchor: FamilyAnchorSchema.optional(),
75
+ familyCosts: z.record(z.string(), z.number()).optional(),
56
76
  });
57
77
  export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
58
78
 
79
+ export const BankedResetOutcomeSchema = z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]);
80
+ export type BankedResetOutcome = z.infer<typeof BankedResetOutcomeSchema>;
81
+
82
+ export const BankedResetRecordSchema = z.object({
83
+ outcome: BankedResetOutcomeSchema,
84
+ at: z.number(),
85
+ nextAvailableAt: z.number().nullable(),
86
+ });
87
+ export type BankedResetRecord = z.infer<typeof BankedResetRecordSchema>;
88
+
59
89
  export const AccountSchema = z.object({
60
90
  accountUuid: z.string(),
61
91
  email: z.string(),
@@ -72,6 +102,8 @@ export const AccountSchema = z.object({
72
102
  needsReauth: z.boolean().optional(),
73
103
  subscriptionType: z.string().optional(),
74
104
  rateLimitTier: z.string().optional(),
105
+ sessionWindowWeeklyCost: z.number().positive().optional(),
106
+ bankedReset: BankedResetRecordSchema.optional(),
75
107
  });
76
108
  export type Account = z.infer<typeof AccountSchema>;
77
109
 
@@ -88,6 +120,7 @@ export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
88
120
 
89
121
  export const EnforcedLimitSchema = z.object({
90
122
  account: z.string(),
123
+ kind: z.enum(["session", "weekly", "model"]),
91
124
  family: z.string().nullable(),
92
125
  resetsAt: z.number().nullable(),
93
126
  windowMs: z.number(),
@@ -112,6 +145,10 @@ export const ScreeningThresholdsSchema = z.object({
112
145
  weekly: z.number().min(0).max(100),
113
146
  });
114
147
 
148
+ export const BankedResetProviderSchema = z.enum(["claude", "codex"]);
149
+ export type BankedResetProvider = z.infer<typeof BankedResetProviderSchema>;
150
+ export const BankedResetProvidersSchema = z.array(BankedResetProviderSchema);
151
+
115
152
  export const ConfigSchema = z
116
153
  .object({
117
154
  thresholds: ScreeningThresholdsSchema,
@@ -126,6 +163,7 @@ export const ConfigSchema = z
126
163
  usagePollTtlMs: z.number().int().positive(),
127
164
  maxWaitMs: z.number().int().positive(),
128
165
  checkIntervalMs: z.number().int().min(10_000),
166
+ preferToUseBankedReset: BankedResetProvidersSchema,
129
167
  }),
130
168
  })
131
169
  .refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
@@ -143,6 +181,7 @@ export const RespawnMarkerSchema = z.object({
143
181
  sessionId: z.string(),
144
182
  prompt: z.string().optional(),
145
183
  launchedAt: z.number().optional(),
184
+ reset: z.boolean().optional(),
146
185
  });
147
186
 
148
187
  export const RateLimitsStdinSchema = z.looseObject({
@@ -242,9 +281,21 @@ export const CodexUsageSchema = z.object({
242
281
  planType: z.string().nullable(),
243
282
  aggregate: z.array(CodexWindowSchema),
244
283
  perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
284
+ resetCredits: z.number().int().nullable(),
285
+ reachedType: z.string().nullable(),
245
286
  });
246
287
  export type CodexUsage = z.infer<typeof CodexUsageSchema>;
247
288
 
289
+ export const CodexBankedResetOutcomeSchema = z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]);
290
+ export type CodexBankedResetOutcome = z.infer<typeof CodexBankedResetOutcomeSchema>;
291
+
292
+ export const CodexBankedResetRecordSchema = z.object({
293
+ outcome: CodexBankedResetOutcomeSchema,
294
+ at: z.number(),
295
+ key: z.string().optional(),
296
+ });
297
+ export type CodexBankedResetRecord = z.infer<typeof CodexBankedResetRecordSchema>;
298
+
248
299
  const BareFileNameSchema = z
249
300
  .string()
250
301
  .refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
@@ -263,9 +314,12 @@ export const CodexAccountSchema = z.object({
263
314
  .object({
264
315
  aggregate: z.array(CodexWindowSchema),
265
316
  perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
317
+ resetCredits: z.number().int().nullable().optional(),
318
+ reachedType: z.string().nullable().optional(),
266
319
  })
267
320
  .optional(),
268
321
  lastUsageAt: z.number().optional(),
322
+ bankedReset: CodexBankedResetRecordSchema.optional(),
269
323
  });
270
324
  export type CodexAccount = z.infer<typeof CodexAccountSchema>;
271
325