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