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 +2 -2
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/cli/add.ts +5 -5
- package/src/cli/auth.ts +3 -6
- package/src/cli/config.ts +14 -231
- package/src/cli/onboard.ts +3 -3
- package/src/cli/status.ts +9 -21
- package/src/entries/statusline.ts +6 -6
- package/src/lib/decide.ts +55 -58
- package/src/lib/paths.ts +0 -1
- package/src/lib/picker.ts +2 -2
- package/src/lib/sample.ts +4 -8
- package/src/lib/state.ts +12 -94
- package/src/lib/types.ts +29 -23
- package/src/lib/usage.ts +16 -24
- package/src/main.ts +1 -1
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
|
|
34
|
-
- `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, label, 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` |
|
|
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.
|
|
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 ?
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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 {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
115
|
-
return 0;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function parseValue(text: string): unknown {
|
|
11
|
+
let effective: Config;
|
|
119
12
|
try {
|
|
120
|
-
|
|
121
|
-
} catch {
|
|
122
|
-
|
|
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,
|
|
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
|
-
|
|
218
|
-
|
|
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
|
-
}
|
package/src/cli/onboard.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
-
|
|
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
|
|
146
|
-
const
|
|
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:
|
|
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,
|
|
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.
|
|
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 = {
|
|
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
|
|
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
|
|
143
|
-
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,
|
|
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,
|
|
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
|
|
32
|
-
|
|
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:
|
|
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,
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
84
|
-
|
|
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.
|
|
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,
|
|
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,
|
|
127
|
-
const
|
|
128
|
-
const usage = freshest(teeUsage, uAt,
|
|
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,
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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,
|
|
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
|
|
278
|
-
const knownReset =
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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.
|
|
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,
|
|
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 {
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
|
101
|
-
if (!
|
|
102
|
-
|
|
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
|
|
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 =
|
|
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:
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
244
|
-
let
|
|
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")
|
|
249
|
-
else if (/^all models$/i.test(m[2]!.trim()))
|
|
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 (!
|
|
253
|
-
return {
|
|
245
|
+
if (!fiveHour || !sevenDay) return null;
|
|
246
|
+
return { fiveHour, sevenDay, perModel };
|
|
254
247
|
}
|
|
255
248
|
|
|
256
|
-
export function
|
|
257
|
-
|
|
258
|
-
|
|
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<
|
|
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 =
|
|
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.
|
|
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<
|
|
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")}
|
|
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>
|