tokenmaxxing 1.13.0 → 1.14.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/README.md +3 -2
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/switching-policy/SKILL.md +1 -1
- package/agent-plugin/skills/switching-policy/references/policy.md +1 -1
- package/package.json +1 -1
- package/src/cli/check.ts +10 -10
- package/src/cli/config.ts +5 -0
- package/src/cli/init.ts +1 -1
- package/src/cli/status.ts +36 -11
- package/src/lib/decide.ts +46 -31
- package/src/lib/install.ts +10 -7
- package/src/lib/sample.ts +12 -6
- package/src/lib/state.ts +36 -17
- package/src/lib/types.ts +1 -0
- package/src/lib/usage.ts +45 -11
package/DESIGN.md
CHANGED
|
@@ -37,7 +37,7 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
|
|
|
37
37
|
- `bin/claude` - the supervisor.
|
|
38
38
|
- Per-account **credentials** follow the platform's Claude Code store: macOS = login-keychain items `tokenmaxxing-cred-<accountUuid[:8]>` (never plaintext on disk); Linux = 0600 files `creds/tokenmaxxing-cred-<accountUuid[:8]>.json` (the same plaintext model claude itself uses - its Linux build has no keyring path at all, binary-verified 2.1.205). One `credstore` facade dispatches on a `{kind: keychain|file}` target; call sites never branch on platform.
|
|
39
39
|
|
|
40
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check --if-due`
|
|
40
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check --if-due` once per tick (`policy.checkIntervalMs`, 60s by default), self-paced up to five ticks from the live account's headroom: hooks alone miss long agentic turns, so the timer is the backstop that keeps switching engaged mid-turn.
|
|
41
41
|
|
|
42
42
|
The switching path runs no long-lived daemon. The statusline pushes usage. Hooks and the supervisor react. The periodic check job above is the only recurring process. The `claude` binary and `~/.claude` layout are untouched.
|
|
43
43
|
|
package/README.md
CHANGED
|
@@ -100,12 +100,13 @@ The **target** is chosen greedily off each account's cached windows: among usabl
|
|
|
100
100
|
"projectionMargin": 0,
|
|
101
101
|
"greedySessionFloor": 80,
|
|
102
102
|
"switchModels": ["fable"],
|
|
103
|
-
"usagePollTtlMs": 90000
|
|
103
|
+
"usagePollTtlMs": 90000,
|
|
104
|
+
"checkIntervalMs": 60000
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
```
|
|
107
108
|
|
|
108
|
-
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers).
|
|
109
|
+
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
|
|
109
110
|
|
|
110
111
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
111
112
|
|
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.14.0",
|
|
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",
|
|
@@ -11,7 +11,7 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
11
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
|
-
- **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.
|
|
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, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
|
|
15
15
|
|
|
16
16
|
## Layer 2 (Claude only)
|
|
17
17
|
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
- `src/lib/decide.ts`, `src/lib/picker.ts`
|
|
5
5
|
- `.memory/switch-policy-pace-pressure.md`
|
|
6
6
|
|
|
7
|
-
Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check cadence ceiling per rung:
|
|
7
|
+
Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check tick `policy.checkIntervalMs` (default 60000, minimum 10000); `init` writes it into the timer unit. Check cadence ceiling per rung: five ticks, three, two, then the one-tick floor, with the headroom bands at the same multiples (40 points sleeps five ticks, 20 three, 8 two, less every tick).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.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/check.ts
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { checkDelayMs, evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
2
2
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
3
3
|
import { log } from "../lib/log.ts";
|
|
4
4
|
import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
|
|
5
5
|
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
6
6
|
|
|
7
|
-
const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
|
|
8
|
-
|
|
9
7
|
export async function cmdCheck(args: string[] = [], json = false): Promise<number> {
|
|
10
8
|
const now = Date.now();
|
|
11
|
-
|
|
12
|
-
if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
|
|
13
|
-
if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
|
|
14
|
-
else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
15
|
-
return 0;
|
|
16
|
-
}
|
|
9
|
+
let cfg;
|
|
17
10
|
let d;
|
|
18
11
|
try {
|
|
12
|
+
cfg = loadConfig();
|
|
13
|
+
const dueAt = args.includes("--if-due") ? loadNextCheckDueAt({ now, cfg }) : null;
|
|
14
|
+
if (dueAt != null && now + cfg.policy.checkIntervalMs / 2 < dueAt) {
|
|
15
|
+
if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
|
|
16
|
+
else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
17
|
+
return 0;
|
|
18
|
+
}
|
|
19
19
|
d = await evaluateAndMaybeSwap(now);
|
|
20
20
|
} catch (e) {
|
|
21
21
|
const detail = e instanceof Error ? e.message : String(e);
|
|
@@ -23,7 +23,7 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
|
|
|
23
23
|
emitError({ json, message: `check failed: ${detail}` });
|
|
24
24
|
return 1;
|
|
25
25
|
}
|
|
26
|
-
const delayMs = checkDelayMs({ cfg
|
|
26
|
+
const delayMs = checkDelayMs({ cfg, org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
|
|
27
27
|
saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
|
|
28
28
|
if (json) {
|
|
29
29
|
emitJson({
|
package/src/cli/config.ts
CHANGED
|
@@ -20,8 +20,11 @@ export const KNOWN_KEYS = [
|
|
|
20
20
|
"policy.switchModels",
|
|
21
21
|
"policy.usagePollTtlMs",
|
|
22
22
|
"policy.maxWaitMs",
|
|
23
|
+
"policy.checkIntervalMs",
|
|
23
24
|
] as const;
|
|
24
25
|
|
|
26
|
+
const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
|
|
27
|
+
|
|
25
28
|
const RawFileSchema = z.record(z.string(), z.unknown());
|
|
26
29
|
|
|
27
30
|
function readRawFile(): Record<string, unknown> {
|
|
@@ -150,6 +153,7 @@ function cmdSet(key: string, valueText: string, json: boolean): number {
|
|
|
150
153
|
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
151
154
|
);
|
|
152
155
|
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
156
|
+
if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
|
|
153
157
|
return 0;
|
|
154
158
|
}
|
|
155
159
|
|
|
@@ -189,6 +193,7 @@ function cmdUnset(key: string, json: boolean): number {
|
|
|
189
193
|
return 0;
|
|
190
194
|
}
|
|
191
195
|
console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
|
|
196
|
+
if (key === "policy.checkIntervalMs") console.log(c.yellow(TIMER_REWRITE_NOTE));
|
|
192
197
|
return 0;
|
|
193
198
|
}
|
|
194
199
|
|
package/src/cli/init.ts
CHANGED
|
@@ -28,7 +28,7 @@ function ensurePathAhead(): void {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
function reportTimer(out: InstallOutcome): void {
|
|
31
|
-
if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every
|
|
31
|
+
if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
|
|
32
32
|
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
33
33
|
}
|
|
34
34
|
|
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 {
|
|
5
|
+
import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, 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,11 +123,9 @@ 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
|
-
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
118
|
-
|
|
119
129
|
export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
|
|
120
130
|
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
121
131
|
const enforced0 = enforced && enforced.org === activeOrg ? enforced : null;
|
|
@@ -127,9 +137,11 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
127
137
|
|
|
128
138
|
const cfg = loadConfig();
|
|
129
139
|
|
|
130
|
-
const { u:
|
|
140
|
+
const { u: teeUsage, mu, uAt } = await loadFreshSnapshots(cfg, activeOrg, now);
|
|
141
|
+
const pool = loadAccounts();
|
|
142
|
+
const usage = freshest(teeUsage, uAt, pool.accounts.find((a) => a.organizationUuid === activeOrg));
|
|
131
143
|
const bars0 = effectiveBars(cfg, {
|
|
132
|
-
accounts: overlayLive(
|
|
144
|
+
accounts: overlayLive(pool.accounts, usage, mu, activeOrg),
|
|
133
145
|
now,
|
|
134
146
|
switchFamilies: gatedFamilies(usage?.model ?? null, cfg.policy.switchModels),
|
|
135
147
|
});
|
|
@@ -147,19 +159,20 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
|
|
|
147
159
|
const idx = loadAccounts();
|
|
148
160
|
const org2 = readOAuthAccount()?.organizationUuid ?? null;
|
|
149
161
|
const enforced2 = enforced0 && enforced0.org === org2 ? enforced0 : null;
|
|
150
|
-
const
|
|
162
|
+
const tee = loadUsageSnapshot();
|
|
163
|
+
const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
|
|
164
|
+
const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
|
|
151
165
|
const mu2 = needsPerModel(u2, cfg) || enforced2?.family ? loadModelUsage() ?? mu : null;
|
|
152
166
|
|
|
153
|
-
if (org2 != null && !
|
|
167
|
+
if (org2 != null && !active) {
|
|
154
168
|
return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
|
|
155
169
|
}
|
|
156
170
|
|
|
157
|
-
const active = org2 ? idx.accounts.find((a) => a.organizationUuid === org2) : undefined;
|
|
158
171
|
if (active) {
|
|
159
172
|
let sampled = false;
|
|
160
|
-
if (
|
|
161
|
-
active.lastUsage = { fiveHour:
|
|
162
|
-
active.lastUsageAt =
|
|
173
|
+
if (tee && tee.state.org === org2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
|
|
174
|
+
active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
|
|
175
|
+
active.lastUsageAt = tee.at;
|
|
163
176
|
sampled = true;
|
|
164
177
|
}
|
|
165
178
|
if (mu2 && mu2.org === org2 && Object.keys(mu2.perModel).length > 0) {
|
|
@@ -342,21 +355,23 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; org: st
|
|
|
342
355
|
});
|
|
343
356
|
}
|
|
344
357
|
|
|
345
|
-
|
|
346
|
-
const CHECK_DELAY_UNKNOWN_MS = 180_000;
|
|
347
|
-
const STAGE_CEILING_MS = [MAX_CHECK_DELAY_MS, 180_000, 120_000];
|
|
358
|
+
const STAGE_CEILING_TICKS = [MAX_CHECK_DELAY_TICKS, 3, 2];
|
|
348
359
|
|
|
349
360
|
export function checkDelayMs(input: { cfg: Config; org: string | null; now: number; decision: SwapDecision }): number {
|
|
350
361
|
const { cfg, org, now, decision } = input;
|
|
351
|
-
|
|
362
|
+
const tick = cfg.policy.checkIntervalMs;
|
|
363
|
+
const max = maxCheckDelayMs(cfg);
|
|
364
|
+
if (decision.waitUntil !== undefined) return Math.min(max, Math.max(tick, decision.waitUntil - now));
|
|
352
365
|
const swapAt = loadLastSwapAt();
|
|
353
366
|
if (swapAt != null && now - swapAt < POST_SWAP_COOLDOWN_MS) return swapAt + POST_SWAP_COOLDOWN_MS - now;
|
|
354
|
-
const
|
|
355
|
-
if (!org || !
|
|
367
|
+
const snap = loadUsageSnapshot();
|
|
368
|
+
if (!org || !snap || !usageFresh(snap.state, snap.at, org, cfg.policy.usagePollTtlMs, now)) return 3 * tick;
|
|
369
|
+
const accounts = loadAccounts().accounts;
|
|
370
|
+
const u = freshest(snap.state, snap.at, accounts.find((a) => a.organizationUuid === org)) ?? { ...snap.state, ts: snap.at };
|
|
356
371
|
const mu = loadModelUsage();
|
|
357
372
|
const muSame = mu && mu.org === org ? mu : null;
|
|
358
373
|
const families = gatedFamilies(u.model, cfg.policy.switchModels);
|
|
359
|
-
const bars = effectiveBars(cfg, { accounts: overlayLive(
|
|
374
|
+
const bars = effectiveBars(cfg, { accounts: overlayLive(accounts, u, muSame, org), now, switchFamilies: families });
|
|
360
375
|
const heads = [
|
|
361
376
|
bars.session - liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }),
|
|
362
377
|
bars.weekly - liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }),
|
|
@@ -369,8 +384,8 @@ export function checkDelayMs(input: { cfg: Config; org: string | null; now: numb
|
|
|
369
384
|
else capMissing = true;
|
|
370
385
|
}
|
|
371
386
|
const headroom = Math.min(...heads);
|
|
372
|
-
const banded = headroom >= 40 ?
|
|
387
|
+
const banded = headroom >= 40 ? max : headroom >= 20 ? 3 * tick : headroom >= 8 ? 2 * tick : tick;
|
|
373
388
|
const stage = sessionLadder(cfg).indexOf(bars.session);
|
|
374
|
-
const staged = Math.min(banded,
|
|
375
|
-
return capMissing ? Math.min(staged,
|
|
389
|
+
const staged = Math.min(banded, (STAGE_CEILING_TICKS[stage] ?? 1) * tick);
|
|
390
|
+
return capMissing ? Math.min(staged, 2 * tick) : staged;
|
|
376
391
|
}
|
package/src/lib/install.ts
CHANGED
|
@@ -6,12 +6,14 @@ import { codexPaths, HOME, paths } from "./paths.ts";
|
|
|
6
6
|
import { writeFileAtomic } from "./atomic.ts";
|
|
7
7
|
import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
|
|
8
8
|
import { resolveRealClaude } from "./claudebin.ts";
|
|
9
|
+
import { loadConfig } from "./state.ts";
|
|
9
10
|
|
|
10
11
|
const InstallOutcomeSchema = z.object({
|
|
11
12
|
claudeWrapper: z.string(),
|
|
12
13
|
installedBin: z.string(),
|
|
13
14
|
pathAhead: z.boolean(),
|
|
14
15
|
timerLoaded: z.boolean(),
|
|
16
|
+
checkIntervalS: z.number().int().positive(),
|
|
15
17
|
});
|
|
16
18
|
export type InstallOutcome = z.infer<typeof InstallOutcomeSchema>;
|
|
17
19
|
|
|
@@ -105,11 +107,13 @@ export function installSupervisor(): InstallOutcome {
|
|
|
105
107
|
writeFileAtomic(join(paths.binDir, "xx"), `#!/bin/sh\nexec ${JSON.stringify(target)} "$@"\n`, 0o755);
|
|
106
108
|
|
|
107
109
|
installSettings();
|
|
110
|
+
const checkIntervalS = Math.ceil(loadConfig().policy.checkIntervalMs / 1000);
|
|
108
111
|
return {
|
|
109
112
|
claudeWrapper: paths.supervisorLink,
|
|
110
113
|
installedBin: target,
|
|
111
114
|
pathAhead: isBinDirAhead(),
|
|
112
|
-
timerLoaded: installCheckTimer(),
|
|
115
|
+
timerLoaded: installCheckTimer(checkIntervalS),
|
|
116
|
+
checkIntervalS,
|
|
113
117
|
};
|
|
114
118
|
}
|
|
115
119
|
|
|
@@ -179,7 +183,6 @@ export function uninstallCodexSupervisor(): void {
|
|
|
179
183
|
if (existsSync(codexSupervisorLink())) rmSync(codexSupervisorLink(), { force: true });
|
|
180
184
|
}
|
|
181
185
|
|
|
182
|
-
const CHECK_INTERVAL_S = 60;
|
|
183
186
|
const LAUNCHD_LABEL = "com.tokenmaxxing.check";
|
|
184
187
|
|
|
185
188
|
function launchdPlist(): string {
|
|
@@ -199,7 +202,7 @@ function run(cmd: string[]): boolean {
|
|
|
199
202
|
}
|
|
200
203
|
}
|
|
201
204
|
|
|
202
|
-
function installCheckTimer(): boolean {
|
|
205
|
+
function installCheckTimer(intervalS: number): boolean {
|
|
203
206
|
if (skipImperativeTimer()) return true;
|
|
204
207
|
|
|
205
208
|
if (process.platform === "darwin") {
|
|
@@ -212,7 +215,7 @@ function installCheckTimer(): boolean {
|
|
|
212
215
|
<dict>
|
|
213
216
|
<key>Label</key><string>${LAUNCHD_LABEL}</string>
|
|
214
217
|
<key>ProgramArguments</key><array><string>${escape(installedBin())}</string><string>check</string><string>--if-due</string></array>
|
|
215
|
-
<key>StartInterval</key><integer>${
|
|
218
|
+
<key>StartInterval</key><integer>${intervalS}</integer>
|
|
216
219
|
<key>StandardOutPath</key><string>/dev/null</string>
|
|
217
220
|
<key>StandardErrorPath</key><string>${escape(join(paths.home, "check.stderr.log"))}</string>
|
|
218
221
|
</dict>
|
|
@@ -244,9 +247,9 @@ ExecStart=${exec}
|
|
|
244
247
|
Description=tokenmaxxing periodic account-switch check
|
|
245
248
|
|
|
246
249
|
[Timer]
|
|
247
|
-
OnBootSec
|
|
248
|
-
OnUnitActiveSec=${
|
|
249
|
-
AccuracySec
|
|
250
|
+
OnBootSec=${intervalS}
|
|
251
|
+
OnUnitActiveSec=${intervalS}
|
|
252
|
+
AccuracySec=${Math.max(1, Math.floor(intervalS / 12))}
|
|
250
253
|
|
|
251
254
|
[Install]
|
|
252
255
|
WantedBy=timers.target
|
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,15 @@ const DEFAULT_CONFIG: Config = {
|
|
|
22
22
|
hardThresholds: { session: 100, weekly: 100 },
|
|
23
23
|
claudeBin: "",
|
|
24
24
|
codexBin: "",
|
|
25
|
-
policy: {
|
|
25
|
+
policy: {
|
|
26
|
+
projectionMargin: 0,
|
|
27
|
+
greedySessionFloor: 80,
|
|
28
|
+
greedySwapMargin: 0.15,
|
|
29
|
+
switchModels: ["fable"],
|
|
30
|
+
usagePollTtlMs: 90_000,
|
|
31
|
+
maxWaitMs: 3_600_000,
|
|
32
|
+
checkIntervalMs: 60_000,
|
|
33
|
+
},
|
|
26
34
|
};
|
|
27
35
|
|
|
28
36
|
const PercentSchema = z.number().min(0).max(100);
|
|
@@ -41,6 +49,7 @@ export const ConfigFileSchema = z
|
|
|
41
49
|
switchModels: z.array(z.string()),
|
|
42
50
|
usagePollTtlMs: z.number().int().positive(),
|
|
43
51
|
maxWaitMs: z.number().int().positive(),
|
|
52
|
+
checkIntervalMs: z.number().int().min(10_000),
|
|
44
53
|
})
|
|
45
54
|
.partial(),
|
|
46
55
|
})
|
|
@@ -70,6 +79,7 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
|
|
|
70
79
|
cfg.policy.greedySwapMargin = p.policy?.greedySwapMargin ?? cfg.policy.greedySwapMargin;
|
|
71
80
|
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
72
81
|
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
82
|
+
cfg.policy.checkIntervalMs = p.policy?.checkIntervalMs ?? cfg.policy.checkIntervalMs;
|
|
73
83
|
if (p.policy?.switchModels) {
|
|
74
84
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
75
85
|
}
|
|
@@ -135,14 +145,26 @@ export function saveAccounts(idx: AccountsIndex): void {
|
|
|
135
145
|
writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
136
146
|
}
|
|
137
147
|
|
|
138
|
-
export function
|
|
139
|
-
|
|
148
|
+
export function loadUsageSnapshot(): { state: UsageState; at: number } | null {
|
|
149
|
+
let fd: number;
|
|
140
150
|
try {
|
|
141
|
-
|
|
142
|
-
return parsed.success ? parsed.data : null;
|
|
151
|
+
fd = openSync(paths.usageJson, "r");
|
|
143
152
|
} catch {
|
|
144
153
|
return null;
|
|
145
154
|
}
|
|
155
|
+
try {
|
|
156
|
+
const at = fstatSync(fd).mtimeMs;
|
|
157
|
+
const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(fd, "utf8")));
|
|
158
|
+
return parsed.success ? { state: parsed.data, at } : null;
|
|
159
|
+
} catch {
|
|
160
|
+
return null;
|
|
161
|
+
} finally {
|
|
162
|
+
closeSync(fd);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function loadUsage(): UsageState | null {
|
|
167
|
+
return loadUsageSnapshot()?.state ?? null;
|
|
146
168
|
}
|
|
147
169
|
|
|
148
170
|
export function clearUsageSnapshots(): void {
|
|
@@ -187,9 +209,14 @@ export function clearDepletedWait(): void {
|
|
|
187
209
|
rmSync(paths.depletedJson, { force: true });
|
|
188
210
|
}
|
|
189
211
|
|
|
190
|
-
export const
|
|
212
|
+
export const MAX_CHECK_DELAY_TICKS = 5;
|
|
213
|
+
export const POST_SWAP_COOLDOWN_MS = 45_000;
|
|
191
214
|
|
|
192
|
-
export function
|
|
215
|
+
export function maxCheckDelayMs(cfg: Config): number {
|
|
216
|
+
return MAX_CHECK_DELAY_TICKS * cfg.policy.checkIntervalMs;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function loadNextCheckDueAt(input: { now: number; cfg: Config }): number | null {
|
|
193
220
|
if (!existsSync(paths.nextCheckJson)) return null;
|
|
194
221
|
let parsed;
|
|
195
222
|
try {
|
|
@@ -198,7 +225,7 @@ export function loadNextCheckDueAt(now: number): number | null {
|
|
|
198
225
|
return null;
|
|
199
226
|
}
|
|
200
227
|
if (!parsed.success) return null;
|
|
201
|
-
return parsed.data.dueAt - now >
|
|
228
|
+
return parsed.data.dueAt - input.now > Math.max(maxCheckDelayMs(input.cfg), POST_SWAP_COOLDOWN_MS) ? null : parsed.data.dueAt;
|
|
202
229
|
}
|
|
203
230
|
|
|
204
231
|
export function saveNextCheckDueAt(input: { dueAt: number; ts: number }): void {
|
|
@@ -226,14 +253,6 @@ export function writeUsage(next: UsageState): boolean {
|
|
|
226
253
|
return true;
|
|
227
254
|
}
|
|
228
255
|
|
|
229
|
-
export function usageTeeAt(): number | null {
|
|
230
|
-
try {
|
|
231
|
-
return statSync(paths.usageJson).mtimeMs;
|
|
232
|
-
} catch {
|
|
233
|
-
return null;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
256
|
export function loadModelUsage(): ModelUsageState | null {
|
|
238
257
|
if (!existsSync(paths.modelUsageJson)) return null;
|
|
239
258
|
try {
|
package/src/lib/types.ts
CHANGED
|
@@ -125,6 +125,7 @@ export const ConfigSchema = z
|
|
|
125
125
|
switchModels: z.array(z.string()),
|
|
126
126
|
usagePollTtlMs: z.number().int().positive(),
|
|
127
127
|
maxWaitMs: z.number().int().positive(),
|
|
128
|
+
checkIntervalMs: z.number().int().min(10_000),
|
|
128
129
|
}),
|
|
129
130
|
})
|
|
130
131
|
.refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
|
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
|
}
|