tokenmaxxing 1.31.0 → 1.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -31,7 +31,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
31
31
  - Four `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, lastPerModel, needsReauth, ...}` (window resets live inside lastUsage).
34
+ - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, needsReauth, ...}` (window resets and the per-model rows live inside lastUsage).
35
35
  - `usage.json` - live usage, written by the statusLine shim.
36
36
  - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
37
37
  - `bin/claude` - the supervisor.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.31.0",
3
+ "version": "1.33.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/add.ts CHANGED
@@ -4,6 +4,7 @@ import { credItemFor, paths } from "../lib/paths.ts";
4
4
  import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
5
5
  import { harvestIsolatedLogin } from "./onboard.ts";
6
6
  import { type Account } from "../lib/types.ts";
7
+ import { keepRows } from "../lib/usage.ts";
7
8
  import { c, claudeTierLabel, count } from "./render.ts";
8
9
 
9
10
  export async function cmdAdd(): Promise<number> {
@@ -22,6 +23,7 @@ export async function cmdAdd(): Promise<number> {
22
23
  await writeItem(parkedTarget(keychainItem), claudeAiOauthOnly(blobRaw));
23
24
  const idx = loadAccounts();
24
25
  const existing = idx.accounts.find((a) => a.accountUuid === uuid);
26
+ const sampledAt = Date.now();
25
27
  const fresh: Account = {
26
28
  accountUuid: uuid,
27
29
  email: oauthAccount.emailAddress,
@@ -33,10 +35,8 @@ export async function cmdAdd(): Promise<number> {
33
35
  subscriptionType: blob.claudeAiOauth.subscriptionType,
34
36
  rateLimitTier: blob.claudeAiOauth.rateLimitTier,
35
37
  needsReauth: false,
36
- lastUsage: sampled ? { fiveHour: sampled.session, sevenDay: sampled.weekAll } : existing?.lastUsage,
37
- lastPerModel: sampled && Object.keys(sampled.perModel).length > 0 ? sampled.perModel : existing?.lastPerModel,
38
- lastPerModelAt: sampled && Object.keys(sampled.perModel).length > 0 ? Date.now() : existing?.lastPerModelAt,
39
- lastUsageAt: sampled ? Date.now() : existing?.lastUsageAt,
38
+ lastUsage: sampled ? keepRows(sampled, existing?.lastUsage, sampledAt) : existing?.lastUsage,
39
+ lastUsageAt: sampled ? sampledAt : existing?.lastUsageAt,
40
40
  };
41
41
  if (existing) Object.assign(existing, fresh);
42
42
  else idx.accounts.push(fresh);
@@ -45,7 +45,7 @@ export async function cmdAdd(): Promise<number> {
45
45
  });
46
46
 
47
47
  console.log();
48
- const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
48
+ const usageNote = sampled ? ` (session ${sampled.fiveHour.usedPercentage}% / week ${sampled.sevenDay.usedPercentage}%)` : "";
49
49
  console.log(`${c.green("✓")} added ${c.bold(account.email)} (${claudeTierLabel(account) ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
50
50
  return 0;
51
51
  }
package/src/cli/auth.ts CHANGED
@@ -6,6 +6,7 @@ import { paths } from "../lib/paths.ts";
6
6
  import { writeItem, parkedTarget, claudeAiOauthOnly } from "../lib/credstore.ts";
7
7
  import { findAccount } from "./rename.ts";
8
8
  import { harvestIsolatedLogin } from "./onboard.ts";
9
+ import { keepRows } from "../lib/usage.ts";
9
10
  import { c, claudeTierLabel, count } from "./render.ts";
10
11
  import type { Account, AccountsIndex } from "../lib/types.ts";
11
12
 
@@ -101,19 +102,15 @@ async function reauthOne(target: Account): Promise<boolean> {
101
102
  account.rateLimitTier = blob.claudeAiOauth.rateLimitTier;
102
103
  account.needsReauth = false;
103
104
  if (sampled) {
104
- account.lastUsage = { fiveHour: sampled.session, sevenDay: sampled.weekAll };
105
105
  account.lastUsageAt = Date.now();
106
- if (Object.keys(sampled.perModel).length > 0) {
107
- account.lastPerModel = sampled.perModel;
108
- account.lastPerModelAt = account.lastUsageAt;
109
- }
106
+ account.lastUsage = keepRows(sampled, account.lastUsage, account.lastUsageAt);
110
107
  }
111
108
  saveAccounts(idx);
112
109
  return idx.activeAccountUuid === target.accountUuid;
113
110
  });
114
111
  if (isActive === null) return false;
115
112
 
116
- const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
113
+ const usageNote = sampled ? ` (session ${sampled.fiveHour.usedPercentage}% / week ${sampled.sevenDay.usedPercentage}%)` : "";
117
114
  const tier = claudeTierLabel(blob.claudeAiOauth) ?? "?";
118
115
  console.log(`${c.green("✓")} reauthed ${c.bold(oauthAccount.emailAddress)} (${tier})${usageNote}`);
119
116
  if (isActive) {
@@ -3,17 +3,17 @@ import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { readItem, deleteItem, isolatedTarget } from "../lib/credstore.ts";
5
5
  import { resolveRealClaude } from "../lib/claudebin.ts";
6
- import { CRED_ENV_OVERRIDES, probeUsage, FullUsageSchema } from "../lib/usage.ts";
6
+ import { CRED_ENV_OVERRIDES, probeUsage } from "../lib/usage.ts";
7
7
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
8
8
  import { paths } from "../lib/paths.ts";
9
- import { CredentialBlobSchema, OAuthAccountSchema } from "../lib/types.ts";
9
+ import { CredentialBlobSchema, OAuthAccountSchema, UsageWindowsSchema } from "../lib/types.ts";
10
10
  import { c } from "./render.ts";
11
11
 
12
12
  const HarvestedLoginSchema = z.object({
13
13
  blobRaw: z.string(),
14
14
  blob: CredentialBlobSchema,
15
15
  oauthAccount: OAuthAccountSchema,
16
- sampled: FullUsageSchema.nullable(),
16
+ sampled: UsageWindowsSchema.nullable(),
17
17
  });
18
18
  export type HarvestedLogin = z.infer<typeof HarvestedLoginSchema>;
19
19
 
package/src/cli/status.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { chunk, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
- import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
3
+ import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, saveAccounts } from "../lib/state.ts";
4
4
  import { readOAuthAccount } from "../lib/claudejson.ts";
5
5
  import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
6
6
  import { withLock } from "../lib/lock.ts";
@@ -11,8 +11,8 @@ import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from
11
11
  import { isCodexExhausted } from "../lib/codexpick.ts";
12
12
  import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
13
13
  import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
14
- import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
15
- import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
14
+ import { gatedFamilies, keepRows } from "../lib/usage.ts";
15
+ import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow, type UsageWindows } from "../lib/types.ts";
16
16
 
17
17
  const SampleReportSchema = z.discriminatedUnion("ok", [
18
18
  z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe", "cached"]) }),
@@ -95,21 +95,14 @@ async function collectClaude(input: { cfg: Config; now: number; cached: boolean
95
95
  const tee = loadUsageSnapshot();
96
96
  const live = tee?.state ?? null;
97
97
  const teeAt = tee?.at ?? null;
98
- const modelUsage = loadModelUsage();
99
98
  const liveOAuth = readOAuthAccount();
100
99
  const liveAccount = liveOAuth?.accountUuid ?? null;
101
100
  const probeOne = async (a: Account) => {
102
101
  const isActive = liveAccount != null && liveAccount === a.accountUuid;
103
102
  if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
104
103
  const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
105
- const fromStatusLine: FullUsage | null =
106
- isActive && live && teeCurrent && live.account === a.accountUuid
107
- ? {
108
- session: live.fiveHour,
109
- weekAll: live.sevenDay,
110
- perModel: modelUsage && modelUsage.account === a.accountUuid ? modelUsage.perModel : {},
111
- }
112
- : null;
104
+ const fromStatusLine: UsageWindows | null =
105
+ isActive && live && teeCurrent && live.account === a.accountUuid ? { fiveHour: live.fiveHour, sevenDay: live.sevenDay, perModel: {} } : null;
113
106
  const viaTee = fromStatusLine != null;
114
107
  const outcome: SampleOutcome = fromStatusLine
115
108
  ? { ok: true, usage: fromStatusLine }
@@ -118,12 +111,8 @@ async function collectClaude(input: { cfg: Config; now: number; cached: boolean
118
111
  : await probeParkedUsage(a);
119
112
  samples.set(a.accountUuid, { outcome, viaTee });
120
113
  if (!outcome.ok) return;
121
- a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
122
114
  a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
123
- if (Object.keys(outcome.usage.perModel).length > 0) {
124
- a.lastPerModel = outcome.usage.perModel;
125
- a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
126
- }
115
+ a.lastUsage = keepRows(outcome.usage, a.lastUsage, a.lastUsageAt);
127
116
  };
128
117
  const activeAccount = idx.accounts.find((a) => liveAccount != null && liveAccount === a.accountUuid) ?? null;
129
118
  if (activeAccount) await probeOne(activeAccount);
@@ -142,9 +131,8 @@ async function collectClaude(input: { cfg: Config; now: number; cached: boolean
142
131
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
143
132
  const accounts = ordered.map((a): ClaudeStatusAccount => {
144
133
  const sampled = samples.get(a.accountUuid);
145
- const usage = sampled?.outcome.ok ? sampled.outcome.usage : undefined;
146
- const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
147
- const perModel = usage ? usage.perModel : (a.lastPerModel ?? {});
134
+ const aggregate = a.lastUsage;
135
+ const perModel = aggregate?.perModel ?? {};
148
136
  return {
149
137
  label: a.label,
150
138
  email: a.email,
@@ -157,7 +145,7 @@ async function collectClaude(input: { cfg: Config; now: number; cached: boolean
157
145
  usage: aggregate ? { fiveHour: currentWindow(aggregate.fiveHour, false, now), week: currentWindow(aggregate.sevenDay, true, now) } : null,
158
146
  perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
159
147
  usageAt: a.lastUsageAt ?? null,
160
- perModelAt: a.lastPerModelAt ?? null,
148
+ perModelAt: aggregate?.rowsAt ?? null,
161
149
  sample: sampled
162
150
  ? sampled.outcome.ok
163
151
  ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" }
@@ -1,7 +1,7 @@
1
1
  import { sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { readOAuthAccount } from "../lib/claudejson.ts";
4
- import { loadAccounts, loadConfig, loadLastSwapAt, loadModelUsage, writeUsage } from "../lib/state.ts";
4
+ import { loadAccounts, loadConfig, loadLastSwapAt, writeUsage } from "../lib/state.ts";
5
5
  import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
6
6
  import { earliestReset, weeklyExpiry } from "../lib/picker.ts";
7
7
  import { worktreeName } from "../lib/worktree.ts";
@@ -80,11 +80,16 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
80
80
  const seatUuid =
81
81
  (ctx.liveAccount != null && ctx.accounts.accounts.some((a) => a.accountUuid === ctx.liveAccount) ? ctx.liveAccount : null) ??
82
82
  ctx.accounts.activeAccountUuid;
83
+ const walled = (a: Account) => a.enforcedUntil != null && a.enforcedUntil > ctx.now;
84
+ const wallSeg = (wall: number) => seg("", { usedPercentage: 100, resetsAt: wall }, wall);
85
+ const seat = ctx.accounts.accounts.find((a) => a.accountUuid === seatUuid);
86
+ if (seat?.enforcedUntil != null && walled(seat)) windows.push(wallSeg(seat.enforcedUntil));
87
+ const seatMarker = seat && walled(seat) ? paint(100)("◆") : col.green("◆");
83
88
  const active =
84
89
  windows.length > 0
85
- ? `${col.green("◆")} ${windows.join(" ")}`
90
+ ? `${seatMarker} ${windows.join(" ")}`
86
91
  : seatUuid != null
87
- ? `${col.green("◆")} ?`
92
+ ? `${seatMarker} ?`
88
93
  : "";
89
94
 
90
95
  const parked = sortBy(
@@ -92,14 +97,15 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
92
97
  [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
93
98
  );
94
99
  const poolSeg = (a: Account): string => {
95
- const marker = a.needsReauth ? col.red("✗") : col.cyan("◇");
100
+ const marker = a.needsReauth ? col.red("✗") : walled(a) ? paint(100)("◇") : col.cyan("◇");
101
+ if (a.enforcedUntil != null && walled(a)) return `${marker} ${wallSeg(a.enforcedUntil)}`;
96
102
  const week = a.lastUsage?.sevenDay;
97
103
  if (week == null) return `${marker} ?`;
98
104
  const weekUsed = used(week);
99
105
  if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
100
106
 
101
107
  const parts: string[] = [];
102
- for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
108
+ for (const [name, w] of Object.entries(a.lastUsage?.perModel ?? {})) {
103
109
  if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
104
110
  }
105
111
  const expiry = weeklyExpiry(a, ctx.now);
@@ -125,7 +131,7 @@ export async function runStatusline(): Promise<number> {
125
131
  const windows = obj == null ? null : parseStatusLineStdin(obj);
126
132
  const lastSwapAt = loadLastSwapAt();
127
133
  if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
128
- const state: UsageState = { ...windows, account, ts: now, model: parseStatusLineModel(obj) };
134
+ const state: UsageState = { fiveHour: windows.fiveHour, sevenDay: windows.sevenDay, account, ts: now, model: parseStatusLineModel(obj) };
129
135
  writeUsage(state);
130
136
  }
131
137
  } catch {
@@ -134,13 +140,13 @@ export async function runStatusline(): Promise<number> {
134
140
  let line: string;
135
141
  try {
136
142
  const cfg = loadConfig();
137
- const modelUsage = loadModelUsage();
143
+ const accounts = loadAccounts();
138
144
  const stdin = StatusLineStdinSchema.safeParse(obj);
139
145
  const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
140
146
  const colorterm = z.string().optional().parse(process.env.COLORTERM);
141
147
  const ctx: RenderCtx = {
142
- accounts: loadAccounts(),
143
- perModel: modelUsage && modelUsage.account === account ? modelUsage.perModel : {},
148
+ accounts,
149
+ perModel: accounts.accounts.find((a) => a.accountUuid === account)?.lastUsage?.perModel ?? {},
144
150
  switchModels: cfg.policy.switchModels,
145
151
  worktree: dir == null ? null : worktreeName(dir),
146
152
  liveAccount: account,
@@ -3,14 +3,13 @@ import { z } from "zod";
3
3
  import { paths } from "../lib/paths.ts";
4
4
  import { writeFileAtomic } from "../lib/atomic.ts";
5
5
  import { readOAuthAccount } from "../lib/claudejson.ts";
6
- import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
7
- import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
6
+ import { evaluateAndMaybeSwap } from "../lib/decide.ts";
7
+ import { withLock } from "../lib/lock.ts";
8
+ import { POST_SWAP_COOLDOWN_MS, loadConfig, loadLastSwapAt } from "../lib/state.ts";
8
9
  import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
9
10
  import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
10
11
  import { log } from "../lib/log.ts";
11
12
 
12
- export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
13
-
14
13
  const StopFailureStdin = z.looseObject({
15
14
  session_id: z.uuid().optional().catch(undefined),
16
15
  transcript_path: z.string().optional().catch(undefined),
@@ -30,7 +29,8 @@ async function readStdin(): Promise<string> {
30
29
  export async function runStopFailureHook(): Promise<number> {
31
30
  if (process.env.TOKENMAXXING_PROBE) return 0;
32
31
 
33
- const account = readOAuthAccount()?.accountUuid ?? null;
32
+ const entry = await withLock(paths.lockFile, () => ({ swapClock: loadLastSwapAt(), account: readOAuthAccount()?.accountUuid ?? null }));
33
+ const account = entry.account;
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 {}; } })());
@@ -44,29 +44,27 @@ export async function runStopFailureHook(): Promise<number> {
44
44
  const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null && mainLoop;
45
45
 
46
46
  try {
47
+ const lastSwapAt = loadLastSwapAt();
48
+ if (lastSwapAt != null && lastSwapAt === entry.swapClock && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
49
+ log("stopfailure.cooldown", { sinceSwapMs: now - lastSwapAt });
50
+ return 0;
51
+ }
47
52
  const cfg = loadConfig();
48
- const found = stdin.transcript_path
53
+ const row = stdin.transcript_path
49
54
  ? findEnforcedRow({ rows: readTranscriptTail(stdin.transcript_path), lastAssistantMessage: stdin.last_assistant_message, now })
50
55
  : null;
51
- const limit = found ? classifyEnforcedLimit(found.row, cfg.policy.switchModels) : null;
56
+ const limit = row ? classifyEnforcedLimit(row, cfg.policy.switchModels) : null;
52
57
 
53
58
  let enforced: EnforcedLimit | null = null;
54
- if (limit && found && account) {
55
- if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
56
- const stamp = await recordEnforcedLimit({ limit, account, now });
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 !== "account-moved") {
59
- enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
60
- }
61
- } else {
62
- log("stopfailure.unproven", { kind: limit.kind });
63
- }
59
+ if (limit && account) {
60
+ enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: limit.resetsAt, blind: !mainLoop && limit.kind !== "model" };
61
+ log("stopfailure.enforced", { kind: limit.kind, family: enforced.family ?? undefined, resetsAt: limit.resetsAt, subagent: !mainLoop });
64
62
  } else {
65
63
  log("stopfailure.unclassified", {
66
- row: found != null,
67
- type: found?.row.quotaLimits?.rateLimitType,
68
- transient: found?.row.apiErrorIsTransient,
69
- body: found ? parseErrorBody(found.row.errorDetails)?.error?.type : undefined,
64
+ row: row != null,
65
+ type: row?.quotaLimits?.rateLimitType,
66
+ transient: row?.apiErrorIsTransient,
67
+ body: row ? parseErrorBody(row.errorDetails)?.error?.type : undefined,
70
68
  });
71
69
  }
72
70
 
@@ -78,7 +76,6 @@ export async function runStopFailureHook(): Promise<number> {
78
76
  ts: Date.now(),
79
77
  waitUntil: decision.waitUntil ?? now,
80
78
  sessionId: stdinSid ?? pinnedSid,
81
- prompt: RETRIGGER_PROMPT,
82
79
  ...(launchedAt != null ? { launchedAt } : {}),
83
80
  });
84
81
  writeFileAtomic(marker, JSON.stringify(payload));
@@ -292,7 +292,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
292
292
  if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
293
293
  } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
294
294
  saveSessionFlags(m.sessionId, persistable, process.cwd());
295
- launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
295
+ launchArgs = ["--resume", m.sessionId, ...persistable];
296
296
  continue;
297
297
  }
298
298
  log("supervisor.exit", { sid, respawns, code: child.exitCode, signal: child.signalCode });
package/src/lib/decide.ts CHANGED
@@ -2,13 +2,13 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
5
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsageSnapshot, saveAccounts, saveDepletedWait, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { isSkippableSwapError, performSwap } from "./swap.ts";
8
- import { nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
9
- import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
8
+ import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
9
+ import { familyTokens, gatedFamilies, keepRows, probeUsage } from "./usage.ts";
10
10
  import { log } from "./log.ts";
11
- import { AccountSchema, ModelUsageStateSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type ModelUsageState, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
11
+ import { AccountSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
12
12
 
13
13
  const SwapDecisionSchema = z.object({
14
14
  swapped: z.boolean(),
@@ -28,24 +28,28 @@ function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: num
28
28
  return w.usedPercentage;
29
29
  }
30
30
 
31
- function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWindow | undefined {
32
- const rows = Object.entries(mu.perModel)
31
+ function rowsSampledAt(account: Account, now: number): number {
32
+ return account.lastUsage?.rowsAt ?? account.lastUsageAt ?? now;
33
+ }
34
+
35
+ function capForFamily(account: Account, family: string, now: number): UsageWindow | undefined {
36
+ const rows = Object.entries(account.lastUsage?.perModel ?? {})
33
37
  .filter(([k]) => familyTokens(k).includes(family))
34
38
  .map(([, w]) => w);
35
- return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
39
+ return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }));
36
40
  }
37
41
 
38
- function isOver(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
39
- if (!u || !account || u.account !== account) return false;
42
+ function isOver(u: UsageState | null, account: Account | undefined, bars: Thresholds, cfg: Config, now: number): boolean {
43
+ if (!account) return false;
44
+ if (account.enforcedUntil != null && account.enforcedUntil > now) return true;
45
+ if (!u || u.account !== account.accountUuid) return false;
40
46
  if (
41
47
  liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
42
48
  liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
43
49
  ) return true;
44
- if (mu && mu.account === account) {
45
- for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
46
- const cap = capForFamily(mu, family, now);
47
- if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }) >= bars.weekly) return true;
48
- }
50
+ for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
51
+ const cap = capForFamily(account, family, now);
52
+ if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }) >= bars.weekly) return true;
49
53
  }
50
54
  return false;
51
55
  }
@@ -56,7 +60,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
56
60
 
57
61
  const SnapshotsSchema = z.object({
58
62
  u: UsageStateSchema.nullable(),
59
- mu: ModelUsageStateSchema.nullable(),
60
63
  uAt: z.number().nullable(),
61
64
  });
62
65
  type Snapshots = z.infer<typeof SnapshotsSchema>;
@@ -74,14 +77,16 @@ function freshest(u: UsageState | null, uAt: number | null, account: Account | u
74
77
  return { ...u, ts: uAt };
75
78
  }
76
79
 
77
- async function loadFreshSnapshots(cfg: Config, account: string | null, now: number): Promise<Snapshots> {
80
+ async function loadFreshSnapshots(cfg: Config, account: string | null, now: number, probe: boolean): Promise<Snapshots> {
78
81
  const snap = loadUsageSnapshot();
79
82
  let u = snap?.state ?? null;
80
83
  let uAt = snap?.at ?? null;
81
- let mu = loadModelUsage();
82
84
  const ttl = cfg.policy.usagePollTtlMs;
83
- const probeAttempted = mu != null && mu.account === account && now - mu.ts <= ttl;
84
- if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
85
+ const stored = loadAccounts().accounts.find((a) => a.accountUuid === account);
86
+ const probeAttempted = stored?.lastProbeAt != null && now - stored.lastProbeAt <= ttl;
87
+ const walled = stored?.enforcedUntil != null && stored.enforcedUntil > now;
88
+ if (probe && account && stored && !walled && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
89
+ const startedAt = Date.now();
85
90
  const full = await probeUsage();
86
91
  const ts = Date.now();
87
92
  if (readOAuthAccount()?.accountUuid === account) {
@@ -91,43 +96,48 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
91
96
  u = teed.state;
92
97
  uAt = teed.at;
93
98
  } else {
94
- u = { fiveHour: full.session, sevenDay: full.weekAll, account, ts, model: null };
99
+ u = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account, ts, model: null };
95
100
  writeUsage(u);
96
101
  uAt = ts;
97
102
  }
98
- mu = { perModel: full.perModel, account, ts, sampledAt: ts };
99
- saveModelUsage(mu);
100
103
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
101
104
  const rows = Object.keys(full.perModel);
102
105
  if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
103
106
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
104
107
  }
105
- } else {
106
- mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
107
- saveModelUsage(mu);
108
108
  }
109
+ await withLock(paths.lockFile, () => {
110
+ const idx = loadAccounts();
111
+ const a = idx.accounts.find((x) => x.accountUuid === account);
112
+ if (!a) return;
113
+ a.lastProbeAt = startedAt;
114
+ if (full) {
115
+ if (!a.lastUsage) a.lastUsageAt = startedAt;
116
+ a.lastUsage = keepRows({ ...(a.lastUsage ?? full), perModel: full.perModel }, a.lastUsage, startedAt);
117
+ }
118
+ saveAccounts(idx);
119
+ });
109
120
  }
110
121
  }
111
- return { u, mu, uAt };
122
+ return { u, uAt };
112
123
  }
113
124
 
114
125
  export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
115
126
  const activeAccount = readOAuthAccount()?.accountUuid ?? null;
116
- const enforced0 = enforced && enforced.account === activeAccount ? enforced : null;
117
127
 
118
128
  const lastSwapAt = loadLastSwapAt();
119
- if (!enforced0 && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
129
+ if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
120
130
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
121
131
  }
122
132
 
123
133
  const cfg = loadConfig();
124
134
  const bars = thresholdBars(cfg);
125
135
 
126
- const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
127
- const pool = loadAccounts();
128
- const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.accountUuid === activeAccount));
136
+ const { u: teeUsage, uAt } = await loadFreshSnapshots(cfg, activeAccount, now, enforced == null);
137
+ const stored = loadAccounts().accounts.find((a) => a.accountUuid === activeAccount);
138
+ const usage = freshest(teeUsage, uAt, stored);
129
139
 
130
- if (!enforced0 && !isOver(usage, mu, activeAccount, bars, cfg, now)) {
140
+ if (!enforced && !isOver(usage, stored, bars, cfg, now)) {
131
141
  const measured = usage != null && activeAccount != null && usage.account === activeAccount;
132
142
  if (!measured) {
133
143
  const replay = depletedReplay(now);
@@ -139,35 +149,38 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
139
149
  return withLock(paths.lockFile, async () => {
140
150
  const idx = loadAccounts();
141
151
  const account2 = readOAuthAccount()?.accountUuid ?? null;
142
- const enforced2 = enforced0 && enforced0.account === account2 ? enforced0 : null;
143
152
  const tee = loadUsageSnapshot();
144
153
  const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
145
154
  const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
146
- const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
155
+
156
+ const origin = enforced ? idx.accounts.find((a) => a.accountUuid === enforced.account) : undefined;
157
+ const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
158
+ if (enforced && origin) {
159
+ origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
160
+ origin.lastProbeAt = now;
161
+ saveAccounts(idx);
162
+ log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active });
163
+ }
164
+ const enforced2 = enforced && origin && origin === active ? enforced : null;
147
165
 
148
166
  if (account2 != null && !active) {
149
167
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
150
168
  }
151
169
 
152
- if (active) {
153
- let sampled = false;
154
- if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
155
- active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
156
- active.lastUsageAt = tee.state.sampledAt ?? tee.at;
157
- sampled = true;
158
- }
159
- if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
160
- active.lastPerModel = mu2.perModel;
161
- active.lastPerModelAt = mu2.sampledAt ?? mu2.ts;
162
- sampled = true;
163
- }
164
- if (sampled) saveAccounts(idx);
170
+ if (active && tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
171
+ active.lastUsageAt = tee.state.sampledAt ?? tee.at;
172
+ active.lastUsage = keepRows({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, active.lastUsage, active.lastUsageAt);
173
+ saveAccounts(idx);
165
174
  }
166
175
 
167
- const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
168
- const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
176
+ const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
177
+ const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
178
+ const gated = blindScreen ? cfg.policy.switchModels : gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
179
+ const family = enforced?.family ?? null;
180
+ const switchFamilies = family != null && !gated.includes(family) ? [...gated, family] : gated;
169
181
 
170
- if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) {
182
+ const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentAccountUuid: account2, switchFamilies });
183
+ if (!enforced2 && !isOver(u2, active, bars, cfg, now) && !(enforced && seatExhausted)) {
171
184
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
172
185
  }
173
186
 
@@ -203,8 +216,7 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
203
216
  const fresh = loadAccounts();
204
217
  const current = seatOf(fresh);
205
218
  const ctx = { now, thresholds: bars, currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
206
- const enforcedUntil = enforced2 && current && current.accountUuid === enforced2.account ? (enforced2.resetsAt ?? now + enforced2.windowMs) : 0;
207
- const currentAt = current ? Math.max(usableAt(current, ctx), enforcedUntil) : Number.POSITIVE_INFINITY;
219
+ const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
208
220
  const other = pickEarliestReset(usable(fresh.accounts), ctx);
209
221
 
210
222
  let target: Account | null = null;
@@ -247,64 +259,19 @@ function depletedReplay(now: number): SwapDecision | null {
247
259
  return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
248
260
  }
249
261
 
250
- export function enforcedWindowMs(limit: EnforcedClass): number {
251
- return limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS;
252
- }
253
-
254
- export function postSwapProof(input: { swapAt: number | null; launchedAt: number | null; errorAt: number | null; now: number }): boolean {
255
- const { swapAt, launchedAt, errorAt, now } = input;
256
- if (swapAt == null) return true;
257
- if (launchedAt != null && launchedAt > swapAt) return true;
258
- return (errorAt ?? now) - swapAt >= POST_SWAP_COOLDOWN_MS;
259
- }
260
-
261
- const StampSchema = z.object({ outcome: z.enum(["stamped", "account-moved", "no-carrier"]), resetsAt: z.number().nullable() });
262
- export type Stamp = z.infer<typeof StampSchema>;
263
-
264
- export async function recordEnforcedLimit(input: { limit: EnforcedClass; account: string; now: number }): Promise<Stamp> {
265
- const { limit, account: accountUuid, now } = input;
266
- return withLock(paths.lockFile, () => {
267
- if ((readOAuthAccount()?.accountUuid ?? null) !== accountUuid) return { outcome: "account-moved", resetsAt: limit.resetsAt };
268
- const prior = loadUsage();
269
- const priorSame = prior && prior.account === accountUuid ? prior : null;
270
- const idx = loadAccounts();
271
- const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
272
- const mu = loadModelUsage();
273
- const muSame = mu && mu.account === accountUuid ? mu : null;
274
- const carriedRows = muSame?.perModel ?? {};
275
- const sampledAt = muSame?.sampledAt ?? muSame?.ts;
276
- if (limit.kind === "model") {
277
- const rowsFor = (rows: Record<string, UsageWindow>) => Object.entries(rows).filter(([k]) => familyTokens(k).includes(limit.family)).map(([, w]) => w);
278
- const knownReset = [...rowsFor(carriedRows), ...rowsFor(account?.lastPerModel ?? {})].map((w) => w.resetsAt).find((r): r is number => r != null) ?? null;
279
- const weeklyReset = priorSame?.sevenDay.resetsAt ?? account?.lastUsage?.sevenDay.resetsAt ?? null;
280
- const resetsAt = limit.resetsAt ?? nextWeeklyReset(knownReset ?? weeklyReset, now);
281
- saveModelUsage({
282
- perModel: { ...carriedRows, [limit.family]: { usedPercentage: 100, resetsAt } },
283
- account: accountUuid,
284
- ts: now,
285
- sampledAt: resetsAt == null ? now : sampledAt ?? now,
286
- });
287
- log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
288
- return { outcome: "stamped", resetsAt };
289
- }
290
- saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
291
- if (account && limit.resetsAt != null) {
292
- account.enforcedUntil = limit.resetsAt;
293
- saveAccounts(idx);
294
- }
295
- const carrier = priorSame ?? (account?.lastUsage ? { ...account.lastUsage, model: null } : null);
296
- if (!carrier) return { outcome: "no-carrier", resetsAt: limit.resetsAt };
297
- const window: UsageWindow = { usedPercentage: 100, resetsAt: limit.resetsAt };
298
- writeUsage({
299
- ...(priorSame ?? {}),
300
- ...(priorSame == null && account?.lastUsageAt != null ? { sampledAt: account.lastUsageAt } : {}),
301
- fiveHour: limit.kind === "session" ? window : carrier.fiveHour,
302
- sevenDay: limit.kind === "weekly" ? window : carrier.sevenDay,
303
- account: accountUuid,
304
- ts: now,
305
- model: carrier.model,
306
- }, { stamp: true });
307
- log("usage.enforced_limit", { kind: limit.kind, resetsAt: limit.resetsAt });
308
- return { outcome: "stamped", resetsAt: limit.resetsAt };
309
- });
262
+ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
263
+ const cached = account.lastUsage;
264
+ const { family } = limit;
265
+ const familyReset =
266
+ family == null
267
+ ? null
268
+ : Object.entries(cached?.perModel ?? {})
269
+ .filter(([k]) => familyTokens(k).includes(family))
270
+ .map(([, w]) => w.resetsAt)
271
+ .find((r): r is number => r != null) ?? null;
272
+ const cachedReset =
273
+ limit.kind === "session"
274
+ ? cached?.fiveHour.resetsAt != null && cached.fiveHour.resetsAt > now ? cached.fiveHour.resetsAt : null
275
+ : nextWeeklyReset(familyReset ?? cached?.sevenDay.resetsAt ?? null, now);
276
+ return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
310
277
  }
package/src/lib/paths.ts CHANGED
@@ -17,7 +17,6 @@ export const paths = {
17
17
  configJson: join(TM_HOME, "config.json"),
18
18
  accountsJson: join(TM_HOME, "accounts.json"),
19
19
  usageJson: join(TM_HOME, "usage.json"),
20
- modelUsageJson: join(TM_HOME, "model-usage.json"),
21
20
  lastSwapJson: join(TM_HOME, "lastswap.json"),
22
21
  depletedJson: join(TM_HOME, "depleted.json"),
23
22
  respawnDir: join(TM_HOME, "respawn"),
package/src/lib/picker.ts CHANGED
@@ -19,7 +19,7 @@ const PickCtxSchema = z.object({
19
19
  export type PickCtx = z.infer<typeof PickCtxSchema>;
20
20
 
21
21
  function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
22
- return Object.entries(a.lastPerModel ?? {})
22
+ return Object.entries(a.lastUsage?.perModel ?? {})
23
23
  .filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
24
24
  .map(([, w]) => w);
25
25
  }
@@ -42,7 +42,7 @@ function blockingUntil(a: Account, ctx: PickCtx): number[] {
42
42
  blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
43
43
  ]
44
44
  : []),
45
- ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, a.lastPerModelAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
45
+ ...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, u?.rowsAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
46
46
  ...(a.enforcedUntil != null ? [a.enforcedUntil] : []),
47
47
  ];
48
48
  }
package/src/lib/sample.ts CHANGED
@@ -8,14 +8,14 @@ import { withClaudeRefreshLock } from "./claudelock.ts";
8
8
  import { withLock } from "./lock.ts";
9
9
  import { readOAuthAccount } from "./claudejson.ts";
10
10
  import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
11
- import { FullUsageSchema, probeUsage } from "./usage.ts";
11
+ import { keepRows, probeUsage } 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";
15
- import { CredentialBlobSchema, TokenIdentitySchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
15
+ import { CredentialBlobSchema, TokenIdentitySchema, UsageWindowsSchema, type Account, type Config, type OAuthCreds, type TokenIdentity } from "./types.ts";
16
16
 
17
17
  const SampleOutcomeSchema = z.discriminatedUnion("ok", [
18
- z.object({ ok: z.literal(true), usage: FullUsageSchema }),
18
+ z.object({ ok: z.literal(true), usage: UsageWindowsSchema }),
19
19
  z.object({ ok: z.literal(false), reason: z.string() }),
20
20
  ]);
21
21
  export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
@@ -246,12 +246,8 @@ export async function sampleOldestParked(cfg: Config): Promise<void> {
246
246
  const idx = loadAccounts();
247
247
  const stored = idx.accounts.find((a) => a.accountUuid === account.accountUuid);
248
248
  if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
249
- stored.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
249
+ stored.lastUsage = keepRows(outcome.usage, stored.lastUsage, startedAt);
250
250
  stored.lastUsageAt = startedAt;
251
- if (Object.keys(outcome.usage.perModel).length > 0) {
252
- stored.lastPerModel = outcome.usage.perModel;
253
- stored.lastPerModelAt = startedAt;
254
- }
255
251
  saveAccounts(idx);
256
252
  }
257
253
  log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
package/src/lib/state.ts CHANGED
@@ -7,11 +7,9 @@ import {
7
7
  AccountsIndexSchema,
8
8
  ConfigSchema,
9
9
  LastSwapSchema,
10
- ModelUsageStateSchema,
11
10
  UsageStateSchema,
12
11
  type AccountsIndex,
13
12
  type Config,
14
- type ModelUsageState,
15
13
  type UsageState,
16
14
  } from "./types.ts";
17
15
 
@@ -91,7 +89,6 @@ export function loadUsage(): UsageState | null {
91
89
 
92
90
  export function clearUsageSnapshots(): void {
93
91
  rmSync(paths.usageJson, { force: true });
94
- rmSync(paths.modelUsageJson, { force: true });
95
92
  }
96
93
 
97
94
  export function loadLastSwapAt(): number | null {
@@ -159,17 +156,3 @@ export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): b
159
156
  writeFileAtomic(paths.usageJson, JSON.stringify(next));
160
157
  return true;
161
158
  }
162
-
163
- export function loadModelUsage(): ModelUsageState | null {
164
- if (!existsSync(paths.modelUsageJson)) return null;
165
- try {
166
- const parsed = ModelUsageStateSchema.safeParse(JSON.parse(readFileSync(paths.modelUsageJson, "utf8")));
167
- return parsed.success ? parsed.data : null;
168
- } catch {
169
- return null;
170
- }
171
- }
172
-
173
- export function saveModelUsage(next: ModelUsageState): void {
174
- writeFileAtomic(paths.modelUsageJson, JSON.stringify(next));
175
- }
package/src/lib/types.ts CHANGED
@@ -31,16 +31,21 @@ export const UsageWindowSchema = z.object({
31
31
  });
32
32
  export type UsageWindow = z.infer<typeof UsageWindowSchema>;
33
33
 
34
- const UsageWindowsSchema = z.object({
34
+ const AggregateWindowsSchema = z.object({
35
35
  fiveHour: UsageWindowSchema,
36
36
  sevenDay: UsageWindowSchema,
37
37
  });
38
+
39
+ export const UsageWindowsSchema = AggregateWindowsSchema.extend({
40
+ perModel: z.record(z.string(), UsageWindowSchema).default({}),
41
+ rowsAt: z.number().optional(),
42
+ });
38
43
  export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
39
44
 
40
45
  const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
41
46
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
42
47
 
43
- export const UsageStateSchema = UsageWindowsSchema.extend({
48
+ export const UsageStateSchema = AggregateWindowsSchema.extend({
44
49
  account: z.string().nullable(),
45
50
  ts: z.number(),
46
51
  model: ModelInfoSchema.nullable().default(null),
@@ -48,14 +53,6 @@ export const UsageStateSchema = UsageWindowsSchema.extend({
48
53
  });
49
54
  export type UsageState = z.infer<typeof UsageStateSchema>;
50
55
 
51
- export const ModelUsageStateSchema = z.object({
52
- perModel: z.record(z.string(), UsageWindowSchema).default({}),
53
- account: z.string().nullable(),
54
- ts: z.number(),
55
- sampledAt: z.number().optional(),
56
- });
57
- export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
58
-
59
56
  export const AccountSchema = z.object({
60
57
  accountUuid: z.string(),
61
58
  email: z.string(),
@@ -65,8 +62,6 @@ export const AccountSchema = z.object({
65
62
  oauthAccount: OAuthAccountSchema,
66
63
  addedAt: z.string(),
67
64
  lastUsage: UsageWindowsSchema.optional(),
68
- lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
69
- lastPerModelAt: z.number().optional(),
70
65
  lastUsageAt: z.number().optional(),
71
66
  lastProbeAt: z.number().optional(),
72
67
  enforcedUntil: z.number().optional(),
@@ -87,9 +82,10 @@ export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
87
82
 
88
83
  export const EnforcedLimitSchema = z.object({
89
84
  account: z.string(),
85
+ kind: z.enum(["session", "weekly", "model"]),
90
86
  family: z.string().nullable(),
91
87
  resetsAt: z.number().nullable(),
92
- windowMs: z.number(),
88
+ blind: z.boolean(),
93
89
  });
94
90
  export type EnforcedLimit = z.infer<typeof EnforcedLimitSchema>;
95
91
 
@@ -133,7 +129,6 @@ export const RespawnMarkerSchema = z.object({
133
129
  ts: z.number(),
134
130
  waitUntil: z.number(),
135
131
  sessionId: z.string(),
136
- prompt: z.string().optional(),
137
132
  launchedAt: z.number().optional(),
138
133
  });
139
134
 
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, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
6
+ import { RateLimitsStdinSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
7
7
 
8
8
  export function normalizeResetsAt(v: unknown): number | null {
9
9
  const num = z.number().finite().safeParse(v);
@@ -30,7 +30,7 @@ export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
30
30
  if (!parsed.success) return null;
31
31
  const rl = parsed.data.rate_limits;
32
32
  if (!rl?.five_hour || !rl.seven_day) return null;
33
- return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
33
+ return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day), perModel: {} };
34
34
  }
35
35
 
36
36
  export function parseStatusLineModel(obj: unknown): ModelInfo | null {
@@ -57,13 +57,6 @@ export function gatedFamilies(model: ModelInfo | null, families: string[]): stri
57
57
  return family ? [family] : [];
58
58
  }
59
59
 
60
- export const FullUsageSchema = z.object({
61
- session: UsageWindowSchema,
62
- weekAll: UsageWindowSchema,
63
- perModel: z.record(z.string(), UsageWindowSchema),
64
- });
65
- export type FullUsage = z.infer<typeof FullUsageSchema>;
66
-
67
60
  const MONTHS: Record<string, number> = {
68
61
  jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
69
62
  };
@@ -176,13 +169,7 @@ export function transcriptRowText(row: TranscriptRow): string {
176
169
 
177
170
  const ROW_RECENCY_MS = 60_000;
178
171
 
179
- const EnforcedRowSchema = z.object({
180
- row: TranscriptRowSchema,
181
- errorAt: z.number().nullable(),
182
- });
183
- export type EnforcedRow = z.infer<typeof EnforcedRowSchema>;
184
-
185
- export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMessage: string | undefined; now: number }): EnforcedRow | null {
172
+ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMessage: string | undefined; now: number }): TranscriptRow | null {
186
173
  const { rows, lastAssistantMessage, now } = input;
187
174
  for (let i = rows.length - 1; i >= 0; i--) {
188
175
  const row = rows[i]!;
@@ -190,8 +177,7 @@ export function findEnforcedRow(input: { rows: TranscriptRow[]; lastAssistantMes
190
177
  const ts = row.timestamp ? Date.parse(row.timestamp) : Number.NaN;
191
178
  const byContent = lastAssistantMessage != null && lastAssistantMessage !== "" && transcriptRowText(row) === lastAssistantMessage;
192
179
  const byRecency = Number.isFinite(ts) && Math.abs(now - ts) <= ROW_RECENCY_MS;
193
- if (!byContent && !byRecency) continue;
194
- return { row, errorAt: Number.isFinite(ts) ? ts : null };
180
+ if (byContent || byRecency) return row;
195
181
  }
196
182
  return null;
197
183
  }
@@ -237,26 +223,25 @@ export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]
237
223
  return family ? { kind: "model", family, resetsAt: null } : null;
238
224
  }
239
225
 
240
- export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
226
+ export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
241
227
  if (!text) return null;
242
228
  const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:(?:(?!current)[^\n])*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
243
- let session: UsageWindow | null = null;
244
- let weekAll: UsageWindow | null = null;
229
+ let fiveHour: UsageWindow | null = null;
230
+ let sevenDay: UsageWindow | null = null;
245
231
  const perModel: Record<string, UsageWindow> = {};
246
232
  for (let m = re.exec(text); m !== null; m = re.exec(text)) {
247
233
  const window: UsageWindow = { usedPercentage: Number(m[3]), resetsAt: m[4] ? parseResetClock(m[4], now) : null };
248
- if (m[1]!.toLowerCase() === "session") session = window;
249
- else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
234
+ if (m[1]!.toLowerCase() === "session") fiveHour = window;
235
+ else if (/^all models$/i.test(m[2]!.trim())) sevenDay = window;
250
236
  else perModel[m[2]!.trim()] = window;
251
237
  }
252
- if (!session || !weekAll) return null;
253
- return { session, weekAll, perModel };
238
+ if (!fiveHour || !sevenDay) return null;
239
+ return { fiveHour, sevenDay, perModel };
254
240
  }
255
241
 
256
- export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
257
- const f = parseUsageTextFull(text, now);
258
- if (!f) return null;
259
- return { fiveHour: f.session, sevenDay: f.weekAll };
242
+ export function keepRows(next: UsageWindows, prev: UsageWindows | undefined, at: number): UsageWindows {
243
+ if (Object.keys(next.perModel).length > 0 && (prev?.rowsAt == null || prev.rowsAt <= at)) return { ...next, rowsAt: at };
244
+ return { ...next, perModel: prev?.perModel ?? {}, ...(prev?.rowsAt != null ? { rowsAt: prev.rowsAt } : {}) };
260
245
  }
261
246
 
262
247
  export const CRED_ENV_OVERRIDES = [
@@ -298,7 +283,7 @@ async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): P
298
283
  }
299
284
  }
300
285
 
301
- async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
286
+ async function probeUsageOnce(env: Record<string, string>, now: number): Promise<UsageWindows | null> {
302
287
  let out: string;
303
288
  try {
304
289
  const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
@@ -318,12 +303,12 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
318
303
 
319
304
  const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
320
305
  const text = j.success ? j.data.result : out;
321
- const full = parseUsageTextFull(text, now);
306
+ const full = parseUsageText(text, now);
322
307
  if (!full) {
323
308
  log("usage.probe_unparsed", { sample: text.trim().slice(0, 200) });
324
309
  } else {
325
310
  const clockLine = text.split("\n").find((l) => /^current /i.test(l) && /\bresets\b/i.test(l));
326
- if (clockLine && [full.session, full.weekAll, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
311
+ if (clockLine && [full.fiveHour, full.sevenDay, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
327
312
  log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
328
313
  }
329
314
  }
@@ -344,7 +329,7 @@ function probeEnv(configDir?: string): Record<string, string> {
344
329
  return env;
345
330
  }
346
331
 
347
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<FullUsage | null> {
332
+ export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
348
333
  const env = probeEnv(configDir);
349
334
 
350
335
  for (let attempt = 0; ; attempt++) {