tokenmaxxing 1.30.0 → 1.32.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
@@ -30,8 +30,8 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
30
30
  - A `claude` **supervisor** on your PATH ahead of the real binary (`~/.config/tokenmaxxing/bin/claude`), or a shell function - you invoke it identically.
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
- - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults merge at read time, `xx config` edits it.
34
- - `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, lastUsage, lastPerModel, needsReauth, ...}` (window resets live inside lastUsage).
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, 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/README.md CHANGED
@@ -69,7 +69,7 @@ claude # use claude as always
69
69
  | `tokenmaxxing switch [sel]` | switch the claude pool: bare picks the best account (no-op when the current one wins), a selector targets one |
70
70
  | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
71
71
  | `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, active + exhausted-until-reset; `--cached` renders the stored figures without sampling |
72
- | `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
72
+ | `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
73
73
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
74
74
  | `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
75
75
  | `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.30.0",
3
+ "version": "1.32.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) {
package/src/cli/config.ts CHANGED
@@ -1,242 +1,25 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { isPlainObject } from "es-toolkit";
3
- import { get, set, unset } from "es-toolkit/compat";
4
- import { z } from "zod";
5
- import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
6
- import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
7
- import { writeFileAtomic } from "../lib/atomic.ts";
1
+ import { paths } from "../lib/paths.ts";
2
+ import { loadConfig } from "../lib/state.ts";
3
+ import type { Config } from "../lib/types.ts";
8
4
  import { c, emitError, emitJson } from "./render.ts";
9
5
 
10
- export const KNOWN_KEYS = [
11
- "thresholds.session",
12
- "thresholds.weekly",
13
- "claudeBin",
14
- "codexBin",
15
- "policy.projectionMargin",
16
- "policy.switchModels",
17
- "policy.usagePollTtlMs",
18
- "policy.maxWaitMs",
19
- "policy.checkIntervalMs",
20
- ] as const;
21
-
22
- const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
23
-
24
- const RawFileSchema = z.record(z.string(), z.unknown());
25
-
26
- function readRawFile(): Record<string, unknown> {
27
- if (!existsSync(paths.configJson)) return {};
28
- return RawFileSchema.parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
29
- }
30
-
31
- function writeRawFile(input: { raw: Record<string, unknown> }): void {
32
- writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
33
- }
34
-
35
- function dottedKeys(obj: Record<string, unknown>): string[] {
36
- const keys: string[] = [];
37
- for (const [key, value] of Object.entries(obj)) {
38
- if (isPlainObject(value)) {
39
- for (const nested of Object.keys(value)) keys.push(`${key}.${nested}`);
40
- continue;
41
- }
42
- keys.push(key);
43
- }
44
- return keys;
45
- }
46
-
47
- function unknownFileKeys(raw: Record<string, unknown>): string[] {
48
- const known = new Set<string>(KNOWN_KEYS);
49
- return dottedKeys(raw).filter((key) => !known.has(key));
50
- }
51
-
52
- function isKnownKey(key: string): boolean {
53
- return KNOWN_KEYS.some((known) => known === key);
54
- }
55
-
56
- function envSourceFor(key: string): string | null {
57
- if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
58
- if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
59
- return null;
60
- }
61
-
62
- function sourceOf(input: { key: string; raw: Record<string, unknown> }): { source: "env" | "file" | "default"; env: string | null } {
63
- const env = envSourceFor(input.key);
64
- if (env) return { source: "env", env };
65
- return { source: get(input.raw, input.key) !== undefined ? "file" : "default", env: null };
66
- }
67
-
68
- function unknownKey(input: { key: string; json: boolean }): number {
69
- emitError({
70
- json: input.json,
71
- message: `unknown config key: ${input.key}`,
72
- notes: [`known keys: ${KNOWN_KEYS.join(", ")}`],
73
- extra: { knownKeys: KNOWN_KEYS },
74
- });
75
- return 1;
76
- }
77
-
78
- function printEffective(json: boolean): number {
79
- const effective = loadConfig();
80
- const raw = readRawFile();
81
- const unknown = unknownFileKeys(raw);
82
- if (json) {
83
- const sources: Record<string, string> = {};
84
- const envOverrides: Record<string, string> = {};
85
- for (const key of KNOWN_KEYS) {
86
- const { source, env } = sourceOf({ key, raw });
87
- sources[key] = source;
88
- if (env) envOverrides[key] = env;
89
- }
90
- emitJson({ ok: true, path: paths.configJson, config: effective, sources, envOverrides, unknownKeys: unknown });
91
- return 0;
92
- }
93
- console.log(c.dim(`config.json: ${paths.configJson}`));
94
- for (const key of KNOWN_KEYS) {
95
- const { source, env } = sourceOf({ key, raw });
96
- const painted = source === "env" ? c.yellow(`env ${env}`) : source === "file" ? c.green("file") : c.dim("default");
97
- console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${painted}`);
98
- }
99
- if (unknown.length > 0) {
100
- console.log();
101
- console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
102
- console.log(c.dim("run `tokenmaxxing config tidy` to drop them"));
103
- }
104
- return 0;
105
- }
106
-
107
- function cmdGet(key: string, json: boolean): number {
108
- if (!isKnownKey(key)) return unknownKey({ key, json });
109
- const value = get(loadConfig(), key);
110
- if (json) {
111
- emitJson({ ok: true, key, value, source: sourceOf({ key, raw: readRawFile() }).source });
112
- return 0;
6
+ export function cmdConfig(args: string[], json = false): number {
7
+ if (args.length > 0) {
8
+ emitError({ json, message: `unknown config option: ${args[0]} (config takes no options; edit ${paths.configJson} in an editor)` });
9
+ return 2;
113
10
  }
114
- console.log(JSON.stringify(value));
115
- return 0;
116
- }
117
-
118
- function parseValue(text: string): unknown {
11
+ let effective: Config;
119
12
  try {
120
- return JSON.parse(text);
121
- } catch {
122
- return text;
123
- }
124
- }
125
-
126
- function cmdSet(key: string, valueText: string, json: boolean): number {
127
- if (!isKnownKey(key)) return unknownKey({ key, json });
128
- const raw = readRawFile();
129
- const next = structuredClone(raw);
130
- set(next, key, parseValue(valueText));
131
- const validated = ConfigFileSchema.safeParse(next);
132
- if (!validated.success) {
133
- emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
134
- return 1;
135
- }
136
- const mergedCheck = mergeConfigFile(validated.data);
137
- if (!mergedCheck.ok) {
138
- emitError({ json, message: `rejected: ${mergedCheck.detail}` });
139
- return 1;
140
- }
141
- writeRawFile({ raw: next });
142
- const beforeFile = get(raw, key);
143
- const env = envSourceFor(key);
144
- if (json) {
145
- emitJson({ ok: true, key, before: beforeFile === undefined ? null : beforeFile, after: get(next, key), envOverride: env });
146
- return 0;
147
- }
148
- console.log(
149
- `${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
150
- );
151
- if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
152
- if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
153
- return 0;
154
- }
155
-
156
- function pruneEmptyParents(raw: Record<string, unknown>): void {
157
- for (const [topKey, value] of Object.entries(raw)) {
158
- if (isPlainObject(value) && Object.keys(value).length === 0) {
159
- delete raw[topKey];
160
- }
161
- }
162
- }
163
-
164
- function cmdUnset(key: string, json: boolean): number {
165
- if (!isKnownKey(key)) return unknownKey({ key, json });
166
- const raw = readRawFile();
167
- if (get(raw, key) === undefined) {
168
- if (json) emitJson({ ok: true, key, unset: false, value: get(loadConfig(), key) });
169
- else console.log(c.dim(`${key} has no file override (default already applies)`));
170
- return 0;
171
- }
172
- const next = structuredClone(raw);
173
- unset(next, key);
174
- pruneEmptyParents(next);
175
- const validated = ConfigFileSchema.safeParse(next);
176
- if (!validated.success) {
177
- emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
178
- return 1;
179
- }
180
- const mergedCheck = mergeConfigFile(validated.data);
181
- if (!mergedCheck.ok) {
182
- emitError({ json, message: `rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})` });
13
+ effective = loadConfig();
14
+ } catch (e) {
15
+ emitError({ json, message: e instanceof Error ? e.message : String(e), extra: { path: paths.configJson } });
183
16
  return 1;
184
17
  }
185
- writeRawFile({ raw: next });
186
- const value = get(loadConfig(), key);
187
18
  if (json) {
188
- emitJson({ ok: true, key, unset: true, value });
189
- return 0;
190
- }
191
- console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
192
- if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
193
- return 0;
194
- }
195
-
196
- function cmdTidy(json: boolean): number {
197
- const raw = readRawFile();
198
- const dropped = unknownFileKeys(raw);
199
- const parsed = ConfigFileSchema.parse(raw);
200
- const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
201
- const models = get(next, "policy.switchModels");
202
- const normalized = Array.isArray(models) ? models.map((model) => String(model).toLowerCase()) : null;
203
- const casingChanged = normalized != null && JSON.stringify(normalized) !== JSON.stringify(models);
204
- if (normalized != null) set(next, "policy.switchModels", normalized);
205
- pruneEmptyParents(next);
206
-
207
- const changed = JSON.stringify(next) !== JSON.stringify(raw);
208
- if (changed) writeRawFile({ raw: next });
209
- if (json) {
210
- emitJson({ ok: true, changed, droppedKeys: dropped, casingNormalized: casingChanged });
211
- return 0;
212
- }
213
- if (!changed) {
214
- console.log(c.dim("nothing to tidy"));
19
+ emitJson({ ok: true, path: paths.configJson, effective });
215
20
  return 0;
216
21
  }
217
- if (dropped.length > 0) console.log(`dropped unknown keys: ${dropped.join(", ")}`);
218
- if (casingChanged) console.log("normalized switchModels casing");
219
- if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
22
+ console.log(c.dim(`config.json: ${paths.configJson}`));
23
+ console.log(JSON.stringify(effective, null, 2));
220
24
  return 0;
221
25
  }
222
-
223
- export function cmdConfig(args: string[], json = false): number {
224
- const [sub, key, value] = args;
225
- try {
226
- if (sub === undefined) return printEffective(json);
227
- if (sub === "get" && key !== undefined) return cmdGet(key, json);
228
- if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value, json);
229
- if (sub === "unset" && key !== undefined) return cmdUnset(key, json);
230
- if (sub === "tidy") return cmdTidy(json);
231
- } catch (e) {
232
- emitError({
233
- json,
234
- message: `config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`,
235
- notes: [`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`],
236
- extra: { path: paths.configJson },
237
- });
238
- return 1;
239
- }
240
- emitError({ json, message: "usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]" });
241
- return 2;
242
- }
@@ -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";
@@ -99,7 +99,7 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
99
99
  if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
100
100
 
101
101
  const parts: string[] = [];
102
- for (const [name, w] of Object.entries(a.lastPerModel ?? {})) {
102
+ for (const [name, w] of Object.entries(a.lastUsage?.perModel ?? {})) {
103
103
  if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
104
104
  }
105
105
  const expiry = weeklyExpiry(a, ctx.now);
@@ -125,7 +125,7 @@ export async function runStatusline(): Promise<number> {
125
125
  const windows = obj == null ? null : parseStatusLineStdin(obj);
126
126
  const lastSwapAt = loadLastSwapAt();
127
127
  if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
128
- const state: UsageState = { ...windows, account, ts: now, model: parseStatusLineModel(obj) };
128
+ const state: UsageState = { fiveHour: windows.fiveHour, sevenDay: windows.sevenDay, account, ts: now, model: parseStatusLineModel(obj) };
129
129
  writeUsage(state);
130
130
  }
131
131
  } catch {
@@ -134,13 +134,13 @@ export async function runStatusline(): Promise<number> {
134
134
  let line: string;
135
135
  try {
136
136
  const cfg = loadConfig();
137
- const modelUsage = loadModelUsage();
137
+ const accounts = loadAccounts();
138
138
  const stdin = StatusLineStdinSchema.safeParse(obj);
139
139
  const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
140
140
  const colorterm = z.string().optional().parse(process.env.COLORTERM);
141
141
  const ctx: RenderCtx = {
142
- accounts: loadAccounts(),
143
- perModel: modelUsage && modelUsage.account === account ? modelUsage.perModel : {},
142
+ accounts,
143
+ perModel: accounts.accounts.find((a) => a.accountUuid === account)?.lastUsage?.perModel ?? {},
144
144
  switchModels: cfg.policy.switchModels,
145
145
  worktree: dir == null ? null : worktreeName(dir),
146
146
  liveAccount: account,
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, loadUsage, loadUsageSnapshot, saveAccounts, saveDepletedWait, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
7
  import { isSkippableSwapError, performSwap } from "./swap.ts";
8
8
  import { nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
9
- import { familyTokens, gatedFamilies, probeUsage, type EnforcedClass } from "./usage.ts";
9
+ import { familyTokens, gatedFamilies, keepRows, probeUsage, type EnforcedClass } 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,26 @@ 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 (!u || !account || u.account !== account.accountUuid) return false;
40
44
  if (
41
45
  liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
42
46
  liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
43
47
  ) 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
- }
48
+ for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
49
+ const cap = capForFamily(account, family, now);
50
+ if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }) >= bars.weekly) return true;
49
51
  }
50
52
  return false;
51
53
  }
@@ -56,7 +58,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
56
58
 
57
59
  const SnapshotsSchema = z.object({
58
60
  u: UsageStateSchema.nullable(),
59
- mu: ModelUsageStateSchema.nullable(),
60
61
  uAt: z.number().nullable(),
61
62
  });
62
63
  type Snapshots = z.infer<typeof SnapshotsSchema>;
@@ -78,10 +79,11 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
78
79
  const snap = loadUsageSnapshot();
79
80
  let u = snap?.state ?? null;
80
81
  let uAt = snap?.at ?? null;
81
- let mu = loadModelUsage();
82
82
  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))) {
83
+ const stored = loadAccounts().accounts.find((a) => a.accountUuid === account);
84
+ const probeAttempted = stored?.lastProbeAt != null && now - stored.lastProbeAt <= ttl;
85
+ if (account && stored && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
86
+ const startedAt = Date.now();
85
87
  const full = await probeUsage();
86
88
  const ts = Date.now();
87
89
  if (readOAuthAccount()?.accountUuid === account) {
@@ -91,24 +93,30 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
91
93
  u = teed.state;
92
94
  uAt = teed.at;
93
95
  } else {
94
- u = { fiveHour: full.session, sevenDay: full.weekAll, account, ts, model: null };
96
+ u = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account, ts, model: null };
95
97
  writeUsage(u);
96
98
  uAt = ts;
97
99
  }
98
- mu = { perModel: full.perModel, account, ts, sampledAt: ts };
99
- saveModelUsage(mu);
100
100
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
101
101
  const rows = Object.keys(full.perModel);
102
102
  if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
103
103
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
104
104
  }
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
105
  }
106
+ await withLock(paths.lockFile, () => {
107
+ const idx = loadAccounts();
108
+ const a = idx.accounts.find((x) => x.accountUuid === account);
109
+ if (!a) return;
110
+ a.lastProbeAt = startedAt;
111
+ if (full) {
112
+ if (!a.lastUsage) a.lastUsageAt = startedAt;
113
+ a.lastUsage = keepRows({ ...(a.lastUsage ?? full), perModel: full.perModel }, a.lastUsage, startedAt);
114
+ }
115
+ saveAccounts(idx);
116
+ });
109
117
  }
110
118
  }
111
- return { u, mu, uAt };
119
+ return { u, uAt };
112
120
  }
113
121
 
114
122
  export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
@@ -123,11 +131,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
123
131
  const cfg = loadConfig();
124
132
  const bars = thresholdBars(cfg);
125
133
 
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));
134
+ const { u: teeUsage, uAt } = await loadFreshSnapshots(cfg, activeAccount, now);
135
+ const stored = loadAccounts().accounts.find((a) => a.accountUuid === activeAccount);
136
+ const usage = freshest(teeUsage, uAt, stored);
129
137
 
130
- if (!enforced0 && !isOver(usage, mu, activeAccount, bars, cfg, now)) {
138
+ if (!enforced0 && !isOver(usage, stored, bars, cfg, now)) {
131
139
  const measured = usage != null && activeAccount != null && usage.account === activeAccount;
132
140
  if (!measured) {
133
141
  const replay = depletedReplay(now);
@@ -143,31 +151,21 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
143
151
  const tee = loadUsageSnapshot();
144
152
  const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
145
153
  const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
146
- const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
147
154
 
148
155
  if (account2 != null && !active) {
149
156
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
150
157
  }
151
158
 
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);
159
+ if (active && tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
160
+ active.lastUsageAt = tee.state.sampledAt ?? tee.at;
161
+ active.lastUsage = keepRows({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, active.lastUsage, active.lastUsageAt);
162
+ saveAccounts(idx);
165
163
  }
166
164
 
167
165
  const gated = gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
168
166
  const switchFamilies = enforced2?.family && !gated.includes(enforced2.family) ? [...gated, enforced2.family] : gated;
169
167
 
170
- if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) {
168
+ if (!enforced2 && !isOver(u2, active, bars, cfg, now)) {
171
169
  return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
172
170
  }
173
171
 
@@ -269,27 +267,26 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
269
267
  const priorSame = prior && prior.account === accountUuid ? prior : null;
270
268
  const idx = loadAccounts();
271
269
  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
270
  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;
271
+ const rows = account?.lastUsage?.perModel ?? {};
272
+ const knownReset = Object.entries(rows).filter(([k]) => familyTokens(k).includes(limit.family)).map(([, w]) => w.resetsAt).find((r): r is number => r != null) ?? null;
279
273
  const weeklyReset = priorSame?.sevenDay.resetsAt ?? account?.lastUsage?.sevenDay.resetsAt ?? null;
280
274
  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
- });
275
+ if (!account) return { outcome: "no-carrier", resetsAt };
276
+ if (account.lastUsage) {
277
+ const others = Object.fromEntries(Object.entries(rows).filter(([k]) => !familyTokens(k).includes(limit.family)));
278
+ account.lastUsage = { ...account.lastUsage, perModel: { ...others, [limit.family]: { usedPercentage: 100, resetsAt } }, ...(resetsAt == null ? { rowsAt: now } : {}) };
279
+ } else {
280
+ account.enforcedUntil = resetsAt ?? now + WEEK_MS;
281
+ }
282
+ account.lastProbeAt = now;
283
+ saveAccounts(idx);
287
284
  log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
288
285
  return { outcome: "stamped", resetsAt };
289
286
  }
290
- saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
291
- if (account && limit.resetsAt != null) {
292
- account.enforcedUntil = limit.resetsAt;
287
+ if (account) {
288
+ account.lastProbeAt = now;
289
+ if (limit.resetsAt != null) account.enforcedUntil = limit.resetsAt;
293
290
  saveAccounts(idx);
294
291
  }
295
292
  const carrier = priorSame ?? (account?.lastUsage ? { ...account.lastUsage, model: null } : null);
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,99 +7,32 @@ 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
 
18
- const DEFAULT_CONFIG: Config = {
19
- thresholds: { session: 90, weekly: 98 },
20
- claudeBin: "",
21
- codexBin: "",
22
- policy: {
23
- projectionMargin: 0,
24
- switchModels: ["fable"],
25
- usagePollTtlMs: 90_000,
26
- maxWaitMs: 3_600_000,
27
- checkIntervalMs: 60_000,
28
- },
29
- };
30
-
31
- const PercentSchema = z.number().min(0).max(100);
32
-
33
- export const ConfigFileSchema = z
34
- .object({
35
- thresholds: z.object({ session: PercentSchema, weekly: PercentSchema }).partial(),
36
- claudeBin: z.string(),
37
- codexBin: z.string(),
38
- policy: z
39
- .object({
40
- projectionMargin: PercentSchema,
41
- switchModels: z.array(z.string()),
42
- usagePollTtlMs: z.number().int().positive(),
43
- maxWaitMs: z.number().int().positive(),
44
- checkIntervalMs: z.number().int().min(10_000),
45
- })
46
- .partial(),
47
- })
48
- .partial();
49
-
50
- const MergeOutcomeSchema = z.union([
51
- z.object({ ok: z.literal(true), config: ConfigSchema }),
52
- z.object({ ok: z.literal(false), detail: z.string() }),
53
- ]);
54
- export type MergeOutcome = z.infer<typeof MergeOutcomeSchema>;
55
-
56
- export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutcome {
57
- const cfg: Config = {
58
- ...DEFAULT_CONFIG,
59
- thresholds: { ...DEFAULT_CONFIG.thresholds },
60
- policy: { ...DEFAULT_CONFIG.policy },
61
- };
62
- cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
63
- cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
64
- cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
65
- cfg.codexBin = p.codexBin ?? cfg.codexBin;
66
- cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
67
- cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
68
- cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
69
- cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
70
- if (p.policy?.switchModels) {
71
- cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
72
- }
73
- const envBin = realClaudeBinFromEnv();
74
- if (envBin) cfg.claudeBin = envBin;
75
- const envCodexBin = realCodexBinFromEnv();
76
- if (envCodexBin) cfg.codexBin = envCodexBin;
77
- const merged = ConfigSchema.safeParse(cfg);
78
- if (!merged.success) {
79
- return { ok: false, detail: merged.error.issues.map((issue) => issue.message).join("; ") };
80
- }
81
- return { ok: true, config: merged.data };
82
- }
83
-
84
16
  export function loadConfig(): Config {
85
- let fileData: z.infer<typeof ConfigFileSchema> = {};
17
+ let raw: unknown = {};
86
18
  if (existsSync(paths.configJson)) {
87
- let raw: unknown;
88
19
  try {
89
20
  raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
90
21
  } catch {
91
22
  throw new Error(`${paths.configJson} is corrupt (unparsable JSON) - fix or remove it`);
92
23
  }
93
- const parsed = ConfigFileSchema.safeParse(raw);
94
- if (!parsed.success) {
95
- const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
96
- throw new Error(`${paths.configJson} has wrong-typed values (${fields}) - fix or remove them`);
97
- }
98
- fileData = parsed.data;
99
24
  }
100
- const outcome = mergeConfigFile(fileData);
101
- if (!outcome.ok) throw new Error(`${paths.configJson} is invalid: ${outcome.detail} - fix or remove the offending values`);
102
- return outcome.config;
25
+ const parsed = ConfigSchema.safeParse(raw);
26
+ if (!parsed.success) {
27
+ const fields = parsed.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join(", ");
28
+ throw new Error(`${paths.configJson} is invalid (${fields}) - fix or remove the offending values`);
29
+ }
30
+ const cfg = parsed.data;
31
+ const envBin = realClaudeBinFromEnv();
32
+ if (envBin) cfg.claudeBin = envBin;
33
+ const envCodexBin = realCodexBinFromEnv();
34
+ if (envCodexBin) cfg.codexBin = envCodexBin;
35
+ return cfg;
103
36
  }
104
37
 
105
38
  export function pinBinOverride(input: { key: "claudeBin" | "codexBin"; bin: string }): void {
@@ -156,7 +89,6 @@ export function loadUsage(): UsageState | null {
156
89
 
157
90
  export function clearUsageSnapshots(): void {
158
91
  rmSync(paths.usageJson, { force: true });
159
- rmSync(paths.modelUsageJson, { force: true });
160
92
  }
161
93
 
162
94
  export function loadLastSwapAt(): number | null {
@@ -224,17 +156,3 @@ export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): b
224
156
  writeFileAtomic(paths.usageJson, JSON.stringify(next));
225
157
  return true;
226
158
  }
227
-
228
- export function loadModelUsage(): ModelUsageState | null {
229
- if (!existsSync(paths.modelUsageJson)) return null;
230
- try {
231
- const parsed = ModelUsageStateSchema.safeParse(JSON.parse(readFileSync(paths.modelUsageJson, "utf8")));
232
- return parsed.success ? parsed.data : null;
233
- } catch {
234
- return null;
235
- }
236
- }
237
-
238
- export function saveModelUsage(next: ModelUsageState): void {
239
- writeFileAtomic(paths.modelUsageJson, JSON.stringify(next));
240
- }
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(),
@@ -101,19 +96,30 @@ export type Thresholds = z.infer<typeof ThresholdsSchema>;
101
96
 
102
97
  export const ConfigSchema = z
103
98
  .object({
104
- thresholds: ThresholdsSchema,
105
- claudeBin: z.string(),
106
- codexBin: z.string(),
107
- policy: z.object({
108
- projectionMargin: z.number().min(0).max(100),
109
- switchModels: z.array(z.string()),
110
- usagePollTtlMs: z.number().int().positive(),
111
- maxWaitMs: z.number().int().positive(),
112
- checkIntervalMs: z.number().int().min(10_000),
113
- }),
99
+ thresholds: z
100
+ .object({
101
+ session: z.number().min(0).max(100).default(90),
102
+ weekly: z.number().min(0).max(100).default(98),
103
+ })
104
+ .prefault({}),
105
+ claudeBin: z.string().default(""),
106
+ codexBin: z.string().default(""),
107
+ policy: z
108
+ .object({
109
+ projectionMargin: z.number().min(0).max(100).default(0),
110
+ switchModels: z
111
+ .array(z.string())
112
+ .default(["fable"])
113
+ .transform((models) => models.map((model) => model.toLowerCase())),
114
+ usagePollTtlMs: z.number().int().positive().default(90_000),
115
+ maxWaitMs: z.number().int().positive().default(3_600_000),
116
+ checkIntervalMs: z.number().int().min(10_000).default(60_000),
117
+ })
118
+ .prefault({}),
114
119
  })
115
120
  .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
116
- message: "policy.projectionMargin must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
121
+ path: ["policy", "projectionMargin"],
122
+ message: "must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
117
123
  });
118
124
  export type Config = z.infer<typeof ConfigSchema>;
119
125
 
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
  };
@@ -237,26 +230,25 @@ export function classifyEnforcedLimit(row: TranscriptRow, switchModels: string[]
237
230
  return family ? { kind: "model", family, resetsAt: null } : null;
238
231
  }
239
232
 
240
- export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
233
+ export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
241
234
  if (!text) return null;
242
235
  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;
236
+ let fiveHour: UsageWindow | null = null;
237
+ let sevenDay: UsageWindow | null = null;
245
238
  const perModel: Record<string, UsageWindow> = {};
246
239
  for (let m = re.exec(text); m !== null; m = re.exec(text)) {
247
240
  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;
241
+ if (m[1]!.toLowerCase() === "session") fiveHour = window;
242
+ else if (/^all models$/i.test(m[2]!.trim())) sevenDay = window;
250
243
  else perModel[m[2]!.trim()] = window;
251
244
  }
252
- if (!session || !weekAll) return null;
253
- return { session, weekAll, perModel };
245
+ if (!fiveHour || !sevenDay) return null;
246
+ return { fiveHour, sevenDay, perModel };
254
247
  }
255
248
 
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 };
249
+ export function keepRows(next: UsageWindows, prev: UsageWindows | undefined, at: number): UsageWindows {
250
+ if (Object.keys(next.perModel).length > 0 && (prev?.rowsAt == null || prev.rowsAt <= at)) return { ...next, rowsAt: at };
251
+ return { ...next, perModel: prev?.perModel ?? {}, ...(prev?.rowsAt != null ? { rowsAt: prev.rowsAt } : {}) };
260
252
  }
261
253
 
262
254
  export const CRED_ENV_OVERRIDES = [
@@ -298,7 +290,7 @@ async function spawnClaudeBounded(cmd: string[], env: Record<string, string>): P
298
290
  }
299
291
  }
300
292
 
301
- async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
293
+ async function probeUsageOnce(env: Record<string, string>, now: number): Promise<UsageWindows | null> {
302
294
  let out: string;
303
295
  try {
304
296
  const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env);
@@ -318,12 +310,12 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
318
310
 
319
311
  const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
320
312
  const text = j.success ? j.data.result : out;
321
- const full = parseUsageTextFull(text, now);
313
+ const full = parseUsageText(text, now);
322
314
  if (!full) {
323
315
  log("usage.probe_unparsed", { sample: text.trim().slice(0, 200) });
324
316
  } else {
325
317
  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)) {
318
+ if (clockLine && [full.fiveHour, full.sevenDay, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
327
319
  log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
328
320
  }
329
321
  }
@@ -344,7 +336,7 @@ function probeEnv(configDir?: string): Record<string, string> {
344
336
  return env;
345
337
  }
346
338
 
347
- export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<FullUsage | null> {
339
+ export async function probeUsage(configDir?: string, now = Date.now(), opts: { retries?: number } = {}): Promise<UsageWindows | null> {
348
340
  const env = probeEnv(configDir);
349
341
 
350
342
  for (let attempt = 0; ; attempt++) {
package/src/main.ts CHANGED
@@ -46,7 +46,7 @@ function printHelp(): void {
46
46
  ${c.cyan("tokenmaxxing auth")} [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every needs-reauth account, one by one)
47
47
  ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
48
48
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
49
- ${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
49
+ ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
50
50
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
51
51
  ${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
52
52
  ${c.cyan("tokenmaxxing rm")} [--codex] <sel>