tokenmaxxing 1.12.0 → 1.13.1
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/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/cli/config.ts +1 -0
- package/src/cli/status.ts +36 -11
- package/src/lib/decide.ts +43 -26
- package/src/lib/picker.ts +4 -0
- package/src/lib/sample.ts +12 -6
- package/src/lib/state.ts +20 -14
- package/src/lib/types.ts +1 -0
- package/src/lib/usage.ts +45 -11
package/DESIGN.md
CHANGED
|
@@ -79,7 +79,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The decision engages at `five_hour >= 80%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= <active rung>` (the 5h ladder `thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account still clears, the current one included) OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
|
|
82
|
+
The decision engages at `five_hour >= 80%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The automatic greedy path adds **incumbent hysteresis** (`policy.greedySwapMargin`, default 0.15): the seat holds unless a parked account's pace pressure beats it by more than that fraction, so near-tied accounts stop trading the seat back and forth and busting the per-org prompt cache (each such swap forces a full uncached re-read next turn). The margin only gates lateral under-bar swaps, never a crossed bar or the wall; bare `tokenmaxxing switch` sets it to zero and still converges on the strictly best account. The **Layer 1 screening bars** - `five_hour >= <active rung>` (the 5h ladder `thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account still clears, the current one included) OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
|
|
83
83
|
|
|
84
84
|
**Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
|
|
85
85
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.13.1",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -8,7 +8,7 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
8
8
|
## Vocabulary
|
|
9
9
|
|
|
10
10
|
- **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
|
|
11
|
-
- **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks.
|
|
11
|
+
- **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks. The automatic path adds incumbent hysteresis (`policy.greedySwapMargin`, default 0.15): hold the seat unless a parked account's pace pressure exceeds the current one's by more than that fraction, so near-tied accounts stop trading the seat and busting the per-org prompt cache. The margin gates lateral under-bar swaps only, never a crossed bar or the wall; bare `tokenmaxxing switch` uses margin 0 and always takes the strictly best account.
|
|
12
12
|
- **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
|
|
13
13
|
- **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
|
|
14
14
|
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
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/config.ts
CHANGED
package/src/cli/status.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
3
|
+
import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, 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";
|
|
@@ -33,6 +33,7 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
33
33
|
usageAt: z.number().nullable(),
|
|
34
34
|
sample: SampleReportSchema,
|
|
35
35
|
pingError: z.string().nullable(),
|
|
36
|
+
pingRejected: z.boolean(),
|
|
36
37
|
pinged: z.boolean(),
|
|
37
38
|
});
|
|
38
39
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
@@ -93,15 +94,18 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
|
|
|
93
94
|
console.error(c.dim(force ? "pinging every account (starts each 5h session timer) + sampling live usage..." : "sampling live usage..."));
|
|
94
95
|
await withLock(paths.lockFile, async () => {
|
|
95
96
|
idx = loadAccounts();
|
|
96
|
-
const
|
|
97
|
+
const tee = loadUsageSnapshot();
|
|
98
|
+
const live = tee?.state ?? null;
|
|
99
|
+
const teeAt = tee?.at ?? null;
|
|
97
100
|
const modelUsage = loadModelUsage();
|
|
98
101
|
const liveOAuth = readOAuthAccount();
|
|
99
102
|
const activeOrg = liveOAuth?.organizationUuid ?? null;
|
|
100
103
|
const probeOne = async (a: Account) => {
|
|
101
104
|
const isActive = activeOrg != null && activeOrg === a.organizationUuid;
|
|
102
105
|
if (isActive && liveOAuth?.organizationRateLimitTier != null) a.rateLimitTier = liveOAuth.organizationRateLimitTier;
|
|
106
|
+
const teeCurrent = teeAt != null && (a.lastUsageAt == null || teeAt >= a.lastUsageAt);
|
|
103
107
|
const fromStatusLine: FullUsage | null =
|
|
104
|
-
isActive && live && live.org === a.organizationUuid
|
|
108
|
+
isActive && live && teeCurrent && live.org === a.organizationUuid
|
|
105
109
|
? {
|
|
106
110
|
session: live.fiveHour,
|
|
107
111
|
weekAll: live.sevenDay,
|
|
@@ -115,7 +119,10 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
|
|
|
115
119
|
if (!outcome.ok && fromStatusLine && outcome.reason.includes("no limit data")) {
|
|
116
120
|
const failed = outcome;
|
|
117
121
|
outcome = { ok: true, usage: fromStatusLine };
|
|
118
|
-
if (failed.pingError != null)
|
|
122
|
+
if (failed.pingError != null) {
|
|
123
|
+
outcome.pingError = failed.pingError;
|
|
124
|
+
outcome.pingRejected = failed.pingRejected;
|
|
125
|
+
}
|
|
119
126
|
viaTee = true;
|
|
120
127
|
}
|
|
121
128
|
} else {
|
|
@@ -129,7 +136,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
|
|
|
129
136
|
samples.set(a.accountUuid, { outcome, viaTee });
|
|
130
137
|
if (!outcome.ok) return;
|
|
131
138
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
132
|
-
a.lastUsageAt = viaTee &&
|
|
139
|
+
a.lastUsageAt = viaTee && teeAt != null ? teeAt : Date.now();
|
|
133
140
|
if (Object.keys(outcome.usage.perModel).length > 0) {
|
|
134
141
|
a.lastPerModel = outcome.usage.perModel;
|
|
135
142
|
a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
|
|
@@ -169,6 +176,7 @@ async function collectClaude(input: { cfg: Config; force: boolean; now: number }
|
|
|
169
176
|
usageAt: a.lastUsageAt ?? null,
|
|
170
177
|
sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
|
|
171
178
|
pingError: sampled.outcome.pingError ?? null,
|
|
179
|
+
pingRejected: sampled.outcome.pingRejected === true,
|
|
172
180
|
pinged: force && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
|
|
173
181
|
};
|
|
174
182
|
});
|
|
@@ -277,8 +285,14 @@ function rowPrinter(now: number): (name: string, w: UsageWindow) => void {
|
|
|
277
285
|
};
|
|
278
286
|
}
|
|
279
287
|
|
|
280
|
-
function renderClaude(input: {
|
|
281
|
-
|
|
288
|
+
function renderClaude(input: {
|
|
289
|
+
claude: StatusReport["claude"];
|
|
290
|
+
codexPooled: boolean;
|
|
291
|
+
now: number;
|
|
292
|
+
staleAfterMs: number;
|
|
293
|
+
row: (name: string, w: UsageWindow) => void;
|
|
294
|
+
}): void {
|
|
295
|
+
const { claude, codexPooled, now, staleAfterMs, row } = input;
|
|
282
296
|
if (claude.accounts.length === 0) {
|
|
283
297
|
if (!codexPooled) {
|
|
284
298
|
console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
|
|
@@ -303,11 +317,21 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
|
|
|
303
317
|
row("week", a.usage.week);
|
|
304
318
|
}
|
|
305
319
|
for (const [name, w] of Object.entries(a.perModel)) row(name.toLowerCase(), w);
|
|
320
|
+
if (a.sample.ok && a.sample.source === "statusline") {
|
|
321
|
+
const stale = a.usageAt == null || now - a.usageAt > staleAfterMs;
|
|
322
|
+
const age = a.usageAt != null ? fmtAgo(a.usageAt, now) : "age unknown";
|
|
323
|
+
console.log(` ${(stale ? c.yellow : c.dim)(`statusline tee ${age}${stale ? " (stale)" : ""}`)}`);
|
|
324
|
+
}
|
|
306
325
|
if (!a.sample.ok) {
|
|
307
326
|
const cached = a.usage || Object.keys(a.perModel).length > 0 ? `cached${a.usageAt != null ? ` ${fmtAgo(a.usageAt, now)}` : ""}, ` : "";
|
|
308
327
|
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(a.sample.reason)}`);
|
|
309
328
|
}
|
|
310
|
-
if (a.pingError != null) {
|
|
329
|
+
if (a.pingError != null && a.pingRejected) {
|
|
330
|
+
const at = a.pingError.indexOf(": ");
|
|
331
|
+
const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
|
|
332
|
+
const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
|
|
333
|
+
console.log(` ${c.yellow(`ping ${head}`)}${tail ? `: ${c.dim(tail)}` : ""}`);
|
|
334
|
+
} else if (a.pingError != null) {
|
|
311
335
|
console.log(` ${c.yellow("ping failed (5h timer may not have started)")}: ${c.dim(a.pingError)}`);
|
|
312
336
|
}
|
|
313
337
|
if (a.pinged) {
|
|
@@ -321,11 +345,11 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
|
|
|
321
345
|
const { force = false, json = false } = opts;
|
|
322
346
|
const cfg = loadConfig();
|
|
323
347
|
const now = Date.now();
|
|
324
|
-
const row = rowPrinter(now);
|
|
325
348
|
const claude = await collectClaude({ cfg, force, now });
|
|
326
349
|
if (!json) {
|
|
327
350
|
opts.preRender?.();
|
|
328
|
-
|
|
351
|
+
const at = Date.now();
|
|
352
|
+
renderClaude({ claude, codexPooled: loadCodexAccounts().accounts.length > 0, now: at, staleAfterMs: cfg.policy.usagePollTtlMs, row: rowPrinter(at) });
|
|
329
353
|
}
|
|
330
354
|
const codex = await collectCodex({ cfg, now });
|
|
331
355
|
if (json) {
|
|
@@ -333,6 +357,7 @@ export async function cmdStatus(opts: { force?: boolean; json?: boolean; preRend
|
|
|
333
357
|
emitJson({ ok: true, ...report });
|
|
334
358
|
return 0;
|
|
335
359
|
}
|
|
336
|
-
|
|
360
|
+
const at = Date.now();
|
|
361
|
+
renderCodex({ codex, now: at, row: rowPrinter(at) });
|
|
337
362
|
return 0;
|
|
338
363
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage,
|
|
5
|
+
import { MAX_CHECK_DELAY_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
|
|
6
6
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
7
7
|
import { chooseAndSwap, performSwap } from "./swap.ts";
|
|
8
8
|
import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt } from "./picker.ts";
|
|
@@ -72,31 +72,43 @@ function isEngaged(u: UsageState | null, mu: ModelUsageState | null, org: string
|
|
|
72
72
|
const SnapshotsSchema = z.object({
|
|
73
73
|
u: UsageStateSchema.nullable(),
|
|
74
74
|
mu: ModelUsageStateSchema.nullable(),
|
|
75
|
+
uAt: z.number().nullable(),
|
|
75
76
|
});
|
|
76
77
|
type Snapshots = z.infer<typeof SnapshotsSchema>;
|
|
77
78
|
|
|
78
|
-
function usageFresh(u: UsageState | null, org: string | null, ttl: number, now: number): boolean {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
79
|
+
function usageFresh(u: UsageState | null, uAt: number | null, org: string | null, ttl: number, now: number): boolean {
|
|
80
|
+
return u != null && u.org === org && uAt != null && now - uAt <= ttl;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
|
|
84
|
+
if (!u || uAt == null) return u;
|
|
85
|
+
const stored = account?.lastUsage;
|
|
86
|
+
if (stored && account.lastUsageAt != null && u.org === account.organizationUuid && uAt < account.lastUsageAt) {
|
|
87
|
+
return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
|
|
88
|
+
}
|
|
89
|
+
return { ...u, ts: uAt };
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
async function loadFreshSnapshots(cfg: Config, org: string | null, now: number): Promise<Snapshots> {
|
|
85
|
-
|
|
93
|
+
const snap = loadUsageSnapshot();
|
|
94
|
+
let u = snap?.state ?? null;
|
|
95
|
+
let uAt = snap?.at ?? null;
|
|
86
96
|
let mu = loadModelUsage();
|
|
87
97
|
const ttl = cfg.policy.usagePollTtlMs;
|
|
88
98
|
const probeAttempted = mu != null && mu.org === org && now - mu.ts <= ttl;
|
|
89
|
-
if (org && !probeAttempted && (!usageFresh(u, org, ttl, now) || needsPerModel(u, cfg))) {
|
|
99
|
+
if (org && !probeAttempted && (!usageFresh(u, uAt, org, ttl, now) || needsPerModel(u, cfg))) {
|
|
90
100
|
const full = await probeUsage();
|
|
91
101
|
const ts = Date.now();
|
|
92
102
|
if (readOAuthAccount()?.organizationUuid === org) {
|
|
93
103
|
if (full) {
|
|
94
|
-
const teed =
|
|
95
|
-
if (usageFresh(teed, org, ttl, ts)) {
|
|
96
|
-
u = teed;
|
|
104
|
+
const teed = loadUsageSnapshot();
|
|
105
|
+
if (teed && usageFresh(teed.state, teed.at, org, ttl, ts)) {
|
|
106
|
+
u = teed.state;
|
|
107
|
+
uAt = teed.at;
|
|
97
108
|
} else {
|
|
98
109
|
u = { fiveHour: full.session, sevenDay: full.weekAll, org, ts, model: null };
|
|
99
110
|
writeUsage(u);
|
|
111
|
+
uAt = ts;
|
|
100
112
|
}
|
|
101
113
|
mu = { perModel: full.perModel, org, ts, sampledAt: ts };
|
|
102
114
|
saveModelUsage(mu);
|
|
@@ -111,7 +123,7 @@ async function loadFreshSnapshots(cfg: Config, org: string | null, now: number):
|
|
|
111
123
|
}
|
|
112
124
|
}
|
|
113
125
|
}
|
|
114
|
-
return { u, mu };
|
|
126
|
+
return { u, mu, uAt };
|
|
115
127
|
}
|
|
116
128
|
|
|
117
129
|
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
@@ -127,9 +139,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
127
139
|
|
|
128
140
|
const cfg = loadConfig();
|
|
129
141
|
|
|
130
|
-
const { u:
|
|
142
|
+
const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
143
|
+
const pool = loadAccounts();
|
|
144
|
+
const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
|
|
131
145
|
const bars0 = effectiveBars(cfg, {
|
|
132
|
-
accounts: overlayLive(
|
|
146
|
+
accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
|
|
133
147
|
now,
|
|
134
148
|
switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
|
|
135
149
|
});
|
|
@@ -147,19 +161,20 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
147
161
|
const idx = loadAccounts();
|
|
148
162
|
const org2 = readOAuthAccount()?.organizationUuid ?? null;
|
|
149
163
|
const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
|
|
150
|
-
const
|
|
164
|
+
const tee = loadUsageSnapshot();
|
|
165
|
+
const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
|
|
166
|
+
const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
|
|
151
167
|
const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
|
|
152
168
|
|
|
153
|
-
if (org2 != null && !
|
|
169
|
+
if (org2 != null && !active) {
|
|
154
170
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
155
171
|
}
|
|
156
172
|
|
|
157
|
-
const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
|
|
158
173
|
if (active) {
|
|
159
174
|
let sampled = false;
|
|
160
|
-
if (
|
|
161
|
-
active.lastUsage = { fiveHour:
|
|
162
|
-
active.lastUsageAt =
|
|
175
|
+
if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
|
|
176
|
+
active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
|
|
177
|
+
active.lastUsageAt = tee.at;
|
|
163
178
|
sampled = true;
|
|
164
179
|
}
|
|
165
180
|
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
|
|
@@ -184,11 +199,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
184
199
|
idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
|
|
185
200
|
null;
|
|
186
201
|
|
|
187
|
-
const greedy = async (): Promise<SwapDecision> => {
|
|
202
|
+
const greedy = async (holdMargin: number): Promise<SwapDecision> => {
|
|
188
203
|
while (true) {
|
|
189
204
|
const cur = loadAccounts();
|
|
190
205
|
const active = seatOf(cur);
|
|
191
|
-
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies };
|
|
206
|
+
const ctxAll = { now, thresholds: barsOf(cur.accounts), currentAccountUuid: null, switchFamilies, holdMargin };
|
|
192
207
|
if (currentWins(active, cur.accounts, ctxAll)) {
|
|
193
208
|
return { swapped: false, account: null, reason: "current-best" };
|
|
194
209
|
}
|
|
@@ -204,13 +219,13 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
204
219
|
return { swapped: true, account: best, reason: "swapped" };
|
|
205
220
|
}
|
|
206
221
|
};
|
|
207
|
-
if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy();
|
|
222
|
+
if (!enforced2 && !isOver(u2, mu2, org2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
|
|
208
223
|
|
|
209
224
|
while (true) {
|
|
210
225
|
const cur = loadAccounts();
|
|
211
226
|
const seat = seatOf(cur);
|
|
212
227
|
const thresholds = barsOf(cur.accounts);
|
|
213
|
-
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy();
|
|
228
|
+
if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
|
|
214
229
|
const best = pickBest(cur.accounts, { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies });
|
|
215
230
|
if (!best) break;
|
|
216
231
|
try {
|
|
@@ -351,12 +366,14 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
|
|
|
351
366
|
if (decision.waitUntil !== undefined) return Math.min(MAX_CHECK_DELAY_MS, Math.max(CHECK_DELAY_FLOOR_MS, decision.waitUntil - now));
|
|
352
367
|
const swapAt = loadLastSwapAt();
|
|
353
368
|
if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
|
|
354
|
-
const
|
|
355
|
-
if (!org || !
|
|
369
|
+
const snap = loadUsageSnapshot();
|
|
370
|
+
if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return CHECK_DELAY_UNKNOWN_MS;
|
|
371
|
+
const accounts = loadAccounts().accounts;
|
|
372
|
+
const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
|
|
356
373
|
const mu = loadModelUsage();
|
|
357
374
|
const muSame = mu && mu.org === org ? mu : null;
|
|
358
375
|
const families = gatedFamilies(u.model, cfg.policy.switchModels);
|
|
359
|
-
const bars = effectiveBars(cfg, { accounts: overlayLive(
|
|
376
|
+
const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
|
|
360
377
|
const heads = [
|
|
361
378
|
bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
|
|
362
379
|
bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
|
package/src/lib/picker.ts
CHANGED
|
@@ -35,6 +35,7 @@ const PickCtxSchema = z.object({
|
|
|
35
35
|
thresholds: ThresholdsSchema,
|
|
36
36
|
currentAccountUuid: z.string().nullable(),
|
|
37
37
|
switchFamilies: z.array(z.string()),
|
|
38
|
+
holdMargin: z.number().min(0).optional(),
|
|
38
39
|
});
|
|
39
40
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
40
41
|
|
|
@@ -110,6 +111,9 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
|
|
|
110
111
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
111
112
|
const best = pickBest(accounts, { ...ctx, currentAccountUuid: null });
|
|
112
113
|
if (best == null || best.accountUuid === active.accountUuid) return true;
|
|
114
|
+
const margin = ctx.holdMargin ?? 0;
|
|
115
|
+
const bestPace = pacePressure(best, ctx.now);
|
|
116
|
+
if (margin > 0 && bestPace > 0 && bestPace <= pacePressure(active, ctx.now) * (1 + margin)) return true;
|
|
113
117
|
return swapPreference(ctx.now).every((k) => k(active) === k(best));
|
|
114
118
|
}
|
|
115
119
|
|
package/src/lib/sample.ts
CHANGED
|
@@ -9,8 +9,8 @@ import { FullUsageSchema, pingSession, probeUsage } from "./usage.ts";
|
|
|
9
9
|
import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
12
|
-
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional() }),
|
|
13
|
-
z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional() }),
|
|
12
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema, pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
|
|
13
|
+
z.object({ ok: z.literal(false), reason: z.string(), pingError: z.string().optional(), pingRejected: z.boolean().optional() }),
|
|
14
14
|
]);
|
|
15
15
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
16
16
|
|
|
@@ -95,12 +95,15 @@ export async function probeParkedUsage(account: Account, opts: { ping?: boolean
|
|
|
95
95
|
try {
|
|
96
96
|
await writeItem(isoTarget, installed);
|
|
97
97
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
98
|
-
const
|
|
98
|
+
const ping = opts.ping ? await pingSession(dir) : null;
|
|
99
99
|
const usage = await probeUsage(dir);
|
|
100
100
|
const outcome: SampleOutcome = usage
|
|
101
101
|
? { ok: true, usage }
|
|
102
102
|
: { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
103
|
-
if (
|
|
103
|
+
if (ping != null) {
|
|
104
|
+
outcome.pingError = ping.reason;
|
|
105
|
+
outcome.pingRejected = ping.rejected;
|
|
106
|
+
}
|
|
104
107
|
return outcome;
|
|
105
108
|
} finally {
|
|
106
109
|
const afterIso = await readItem(isoTarget);
|
|
@@ -152,9 +155,12 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
|
|
|
152
155
|
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
153
156
|
refreshPlanFields(account, creds);
|
|
154
157
|
|
|
155
|
-
const
|
|
158
|
+
const ping = opts.ping ? await pingSession() : null;
|
|
156
159
|
const usage = await probeUsage();
|
|
157
160
|
const outcome: SampleOutcome = usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
158
|
-
if (
|
|
161
|
+
if (ping != null) {
|
|
162
|
+
outcome.pingError = ping.reason;
|
|
163
|
+
outcome.pingRejected = ping.rejected;
|
|
164
|
+
}
|
|
159
165
|
return outcome;
|
|
160
166
|
}
|
package/src/lib/state.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, rmSync,
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
|
|
2
2
|
import { isEqual } from "es-toolkit";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
|
|
@@ -22,7 +22,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
22
22
|
hardThresholds: { session: 100, weekly: 100 },
|
|
23
23
|
claudeBin: "",
|
|
24
24
|
codexBin: "",
|
|
25
|
-
policy: { projectionMargin: 0, greedySessionFloor: 80, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
25
|
+
policy: { projectionMargin: 0, greedySessionFloor: 80, greedySwapMargin: 0.15, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
const PercentSchema = z.number().min(0).max(100);
|
|
@@ -37,6 +37,7 @@ export const ConfigFileSchema = z
|
|
|
37
37
|
.object({
|
|
38
38
|
projectionMargin: PercentSchema,
|
|
39
39
|
greedySessionFloor: PercentSchema,
|
|
40
|
+
greedySwapMargin: z.number().min(0).max(1),
|
|
40
41
|
switchModels: z.array(z.string()),
|
|
41
42
|
usagePollTtlMs: z.number().int().positive(),
|
|
42
43
|
maxWaitMs: z.number().int().positive(),
|
|
@@ -66,6 +67,7 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
66
67
|
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
67
68
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
68
69
|
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
70
|
+
cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
|
|
69
71
|
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
70
72
|
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
71
73
|
if (p.policy?.switchModels) {
|
|
@@ -133,16 +135,28 @@ export function saveAccounts(idx: AccountsIndex): void {
|
|
|
133
135
|
writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
134
136
|
}
|
|
135
137
|
|
|
136
|
-
export function
|
|
137
|
-
|
|
138
|
+
export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
|
|
139
|
+
let fd: number;
|
|
138
140
|
try {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
+
fd = openSync(paths.usageJson, "r");
|
|
142
|
+
} catch {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const at = fstatSync(fd).mtimeMs;
|
|
147
|
+
const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(fd, "utf8")));
|
|
148
|
+
return parsed.success ? { state: parsed.data, at } : null;
|
|
141
149
|
} catch {
|
|
142
150
|
return null;
|
|
151
|
+
} finally {
|
|
152
|
+
closeSync(fd);
|
|
143
153
|
}
|
|
144
154
|
}
|
|
145
155
|
|
|
156
|
+
export function loadUsage(): UsageState | null {
|
|
157
|
+
return loadUsageSnapshot()?.state ?? null;
|
|
158
|
+
}
|
|
159
|
+
|
|
146
160
|
export function clearUsageSnapshots(): void {
|
|
147
161
|
rmSync(paths.usageJson, { force: true });
|
|
148
162
|
rmSync(paths.modelUsageJson, { force: true });
|
|
@@ -224,14 +238,6 @@ export function writeUsage(next: UsageState): boolean {
|
|
|
224
238
|
return true;
|
|
225
239
|
}
|
|
226
240
|
|
|
227
|
-
export function usageTeeAt(): number | null {
|
|
228
|
-
try {
|
|
229
|
-
return statSync(paths.usageJson).mtimeMs;
|
|
230
|
-
} catch {
|
|
231
|
-
return null;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
241
|
export function loadModelUsage(): ModelUsageState | null {
|
|
236
242
|
if (!existsSync(paths.modelUsageJson)) return null;
|
|
237
243
|
try {
|
package/src/lib/types.ts
CHANGED
|
@@ -121,6 +121,7 @@ export const ConfigSchema = z
|
|
|
121
121
|
policy: z.object({
|
|
122
122
|
projectionMargin: z.number().min(0).max(100),
|
|
123
123
|
greedySessionFloor: z.number().min(0).max(100),
|
|
124
|
+
greedySwapMargin: z.number().min(0).max(1),
|
|
124
125
|
switchModels: z.array(z.string()),
|
|
125
126
|
usagePollTtlMs: z.number().int().positive(),
|
|
126
127
|
maxWaitMs: z.number().int().positive(),
|
package/src/lib/usage.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { closeSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { delay } from "es-toolkit";
|
|
4
4
|
import { z } from "zod";
|
|
@@ -366,13 +366,36 @@ const PING_ARGS = [
|
|
|
366
366
|
"--output-format", "json",
|
|
367
367
|
];
|
|
368
368
|
|
|
369
|
-
const PingResultSchema = z.looseObject({ is_error: z.boolean(), result: z.string().optional() });
|
|
369
|
+
const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
|
|
370
370
|
|
|
371
|
-
export
|
|
371
|
+
export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
|
|
372
|
+
export type PingFailure = z.infer<typeof PingFailureSchema>;
|
|
373
|
+
|
|
374
|
+
export function transcriptSlug(cwd: string): string {
|
|
375
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
|
|
379
|
+
return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
|
|
383
|
+
|
|
384
|
+
export function pingRejection(rows: TranscriptRow[]): string | null {
|
|
385
|
+
const row = rows.findLast((r) => r.isApiErrorMessage === true);
|
|
386
|
+
if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
|
|
387
|
+
const type = row.quotaLimits?.rateLimitType ?? "";
|
|
388
|
+
if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
389
|
+
const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
|
|
390
|
+
const text = transcriptRowText(row);
|
|
391
|
+
return `${label} limit${text ? `: ${text}` : ""}`;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export async function pingSession(configDir?: string): Promise<PingFailure | null> {
|
|
372
395
|
const cwd = join(paths.sampleDir, "ping-cwd");
|
|
373
|
-
const fail = (reason: string):
|
|
374
|
-
log("usage.ping_failed", { dir: configDir ?? "live", reason: reason.slice(0, 200) });
|
|
375
|
-
return reason;
|
|
396
|
+
const fail = (reason: string, rejected = false): PingFailure => {
|
|
397
|
+
log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
|
|
398
|
+
return { reason, rejected };
|
|
376
399
|
};
|
|
377
400
|
let r: SpawnResult | null;
|
|
378
401
|
try {
|
|
@@ -382,10 +405,21 @@ export async function pingSession(configDir?: string): Promise<string | null> {
|
|
|
382
405
|
return fail(e instanceof Error ? e.message : String(e));
|
|
383
406
|
}
|
|
384
407
|
if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
|
|
385
|
-
if (r.exitCode !== 0) return fail(`claude exited ${r.exitCode ?? "on signal"}: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 160)}`);
|
|
386
408
|
const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
|
|
387
|
-
|
|
388
|
-
if (
|
|
389
|
-
|
|
390
|
-
|
|
409
|
+
const result = parsed.success ? parsed.data : null;
|
|
410
|
+
if (r.exitCode === 0 && result?.is_error === false) {
|
|
411
|
+
log("usage.ping_ok", { dir: configDir ?? "live" });
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
if (result?.session_id) {
|
|
415
|
+
const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
|
|
416
|
+
const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
|
|
417
|
+
if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
|
|
418
|
+
}
|
|
419
|
+
if (r.exitCode !== 0) {
|
|
420
|
+
const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
|
|
421
|
+
return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
|
|
422
|
+
}
|
|
423
|
+
if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
|
|
424
|
+
return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
|
|
391
425
|
}
|