tokenmaxxing 1.51.0 → 1.53.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 +3 -3
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/entries/stopfailurehook.ts +15 -2
- package/src/entries/supervisor.ts +59 -11
- package/src/lib/claude.ts +5 -5
- package/src/lib/decide.ts +3 -3
- package/src/lib/sample.ts +28 -36
- package/src/lib/usage.ts +24 -13
package/DESIGN.md
CHANGED
|
@@ -41,7 +41,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
41
41
|
- `sessions/<sid>.json`, `sample/`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `update.json`, `spawnrate.json`, `tokenmaxxing.log`, the codex `codex-onboard/`, `codex-respawn/`, and `codex-live/`, and the `grok-*` and `opencode-go-*` state of the status-only pools: the full table is in [State directory layout](docs/content/docs/state-directory.mdx).
|
|
42
42
|
- Per-account **credential stores** follow the platform's Claude Code store: macOS = a login-keychain item keyed by the store path (never plaintext on disk); Linux = a 0600 `.credentials.json` inside the 0700 store directory (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. tokenmaxxing writes a store once, at onboarding; Claude Code's own refresh is the only writer afterwards.
|
|
43
43
|
|
|
44
|
-
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last
|
|
44
|
+
- A periodic check job (`com.tokenmaxxing.check` launchd agent on macOS, `tokenmaxxing-check.timer` systemd user timer on Linux) running `tokenmaxxing check` once per tick (`policy.checkIntervalMs`, 60s by default): it runs the decision with no seat (nothing moves), folds fresh tees into the index, samples up to three accounts whose last sample attempt is oldest (the direct no-spend usage GET first, the `/usage` probe under the store for a near-expiry token or a failed read), and on a Bun global install checks npm for a newer version once a day, so the cached figures every placement reads stay fresh.
|
|
45
45
|
|
|
46
46
|
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, `~/.claude.json`, and Claude Code's default credential store are untouched; inside `~/.claude` only `settings.json` gets the five entries.
|
|
47
47
|
|
|
@@ -59,7 +59,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
59
59
|
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the soonest reset when the pool is depleted. No credential is written.
|
|
60
60
|
|
|
61
61
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
62
|
-
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
|
|
62
|
+
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, compacts the conversation when the marker asks for it (`compact: true` from the Stop and SessionStart hooks and from the supervisor's own seat watch, `false` from StopFailure: it runs `claude -p --resume <sid> /compact` under the old seat's store, bounded at five minutes, and a failure only prints why), and (when `waitUntil` is in the future) shows an interruptible countdown to the reset; then it relaunches `claude --resume <sid>` with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The resumed process reads that store cold → runs on the target account, same conversation. The relaunch submits a first prompt that names the move (and the compaction, when it landed) and asks Claude to continue the task, so the conversation goes on without a keystroke: in a terminal session it is the positional prompt after the session id; in a stream-json session (`--input-format stream-json`, where the binary ignores a positional prompt) the supervisor owns stdin, relays the harness's lines to the child through a pipe, and writes the prompt as the first `user` line of the new process. The supervisor's own notices go to stderr, so a stream-json stdout stays parseable. The `SessionStart` hook (every source) re-runs the decision before the first turn and writes the same marker if the seat is already over its bar or the pool is depleted. The supervisor also watches its own seat every ten seconds while the child runs (account record plus tee, the same bars) and writes the marker itself when the seat is at or over a bar, so a move never depends on a hook firing: a harness session between turns whose background workflow burns the window, or a subagent refusal that stamped the wall, is moved within seconds. SIGTERM to the supervisor is forwarded to the child and the supervisor exits after it, so a harness that stops the wrapper (the Agent SDK does) never leaves an orphaned claude on the old seat. A marker whose `launchedAt` does not match the running child is discarded, and after a Ctrl-C on the countdown a later marker whose `waitUntil` falls inside the skipped wait is ignored. A marker that fails to parse is never deleted and never treated as absent: the supervisor stops the child, restores the terminal, and exits naming the marker path.
|
|
63
63
|
|
|
64
64
|
### 3.4 Placement (supervisor, under the pool lock)
|
|
65
65
|
1. Fold every account's tee into the index.
|
|
@@ -84,7 +84,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
84
84
|
The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
|
|
85
85
|
|
|
86
86
|
- Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
|
|
87
|
-
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last
|
|
87
|
+
- Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, over the direct no-spend usage read with the account's stored token, or the `/usage` child under its store when the token is near expiry or the read fails, so every account is attempted about once per three ticks per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the read or the `/usage` child.
|
|
88
88
|
- The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
|
|
89
89
|
- Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
|
|
90
90
|
|
package/README.md
CHANGED
|
@@ -90,7 +90,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
|
90
90
|
|
|
91
91
|
Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
|
|
92
92
|
|
|
93
|
-
- Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last
|
|
93
|
+
- Every periodic check tick folds fresh statusline tees into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`: the direct no-spend usage read first, and `claude -p '/usage'` under the account's store only for a near-expiry token or a failed read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push.
|
|
94
94
|
- When no account is usable, the session pauses until the soonest reset if that lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
95
95
|
|
|
96
96
|
See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.53.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -3,7 +3,7 @@ import { claude } from "../lib/claude.ts";
|
|
|
3
3
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
4
4
|
import { supervisedSession, writeRespawnMarker } from "../lib/sessions.ts";
|
|
5
5
|
import { loadConfig } from "../lib/state.ts";
|
|
6
|
-
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
6
|
+
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail, type TranscriptRow } from "../lib/usage.ts";
|
|
7
7
|
import { paths } from "../lib/paths.ts";
|
|
8
8
|
import { JsonTextSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
9
9
|
import { log } from "../lib/log.ts";
|
|
@@ -17,6 +17,18 @@ const StopFailureStdin = z.looseObject({
|
|
|
17
17
|
last_assistant_message: z.string().optional().catch(undefined),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
|
+
const ROW_WAIT_MS = 3_000;
|
|
21
|
+
const ROW_POLL_MS = 200;
|
|
22
|
+
|
|
23
|
+
async function awaitEnforcedRow(input: { transcriptPath: string; lastAssistantMessage: string | undefined; now: number }): Promise<TranscriptRow | null> {
|
|
24
|
+
const deadline = Date.now() + ROW_WAIT_MS;
|
|
25
|
+
while (true) {
|
|
26
|
+
const row = findEnforcedRow({ rows: readTranscriptTail(input.transcriptPath), lastAssistantMessage: input.lastAssistantMessage, now: input.now });
|
|
27
|
+
if (row || Date.now() >= deadline) return row;
|
|
28
|
+
await Bun.sleep(ROW_POLL_MS);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
20
32
|
export async function runStopFailureHook(): Promise<number> {
|
|
21
33
|
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
22
34
|
|
|
@@ -35,7 +47,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
35
47
|
try {
|
|
36
48
|
const cfg = loadConfig();
|
|
37
49
|
const row = stdin.transcript_path
|
|
38
|
-
?
|
|
50
|
+
? await awaitEnforcedRow({ transcriptPath: stdin.transcript_path, lastAssistantMessage: stdin.last_assistant_message, now })
|
|
39
51
|
: null;
|
|
40
52
|
const limit = row ? classifyEnforcedLimit(row, cfg.policy.switchModels) : null;
|
|
41
53
|
|
|
@@ -46,6 +58,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
46
58
|
} else {
|
|
47
59
|
log("stopfailure.unclassified", {
|
|
48
60
|
seat: account != null,
|
|
61
|
+
subagent: !mainLoop,
|
|
49
62
|
row: row != null,
|
|
50
63
|
type: row?.quotaLimits?.rateLimitType,
|
|
51
64
|
transient: row?.apiErrorIsTransient,
|
|
@@ -5,13 +5,16 @@ import { maxBy } from "es-toolkit";
|
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import { claudePool, paths, storeDirFor } from "../lib/paths.ts";
|
|
7
7
|
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, resolveRealClaude, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
8
|
-
import { pickSeat } from "../lib/claude.ts";
|
|
8
|
+
import { claude, pickSeat } from "../lib/claude.ts";
|
|
9
9
|
import { compactClaudeSession } from "../lib/compact.ts";
|
|
10
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
10
11
|
import { withLock } from "../lib/lock.ts";
|
|
12
|
+
import { thresholdBars, usableAt } from "../lib/picker.ts";
|
|
11
13
|
import { clearPresence, writePresence } from "../lib/presence.ts";
|
|
14
|
+
import { teeObservation } from "../lib/sample.ts";
|
|
12
15
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
13
|
-
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags } from "../lib/sessions.ts";
|
|
14
|
-
import { loadAccounts } from "../lib/state.ts";
|
|
16
|
+
import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
17
|
+
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
15
18
|
import { RespawnMarkerSchema, type Account } from "../lib/types.ts";
|
|
16
19
|
import { log } from "../lib/log.ts";
|
|
17
20
|
|
|
@@ -190,6 +193,35 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
|
|
|
190
193
|
return m;
|
|
191
194
|
}
|
|
192
195
|
|
|
196
|
+
const SEAT_POLL_MS = 10_000;
|
|
197
|
+
const SEAT_RETRY_MS = 60_000;
|
|
198
|
+
|
|
199
|
+
function seatBlockedUntil(seatId: string, now: number): number | null {
|
|
200
|
+
const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
|
|
201
|
+
if (!account) return null;
|
|
202
|
+
const cfg = loadConfig();
|
|
203
|
+
const observed = teeObservation(account);
|
|
204
|
+
const current = observed ? { ...account, windows: observed.windows } : account;
|
|
205
|
+
const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families: cfg.policy.switchModels, seats: null });
|
|
206
|
+
return until > now ? until : null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
|
|
210
|
+
try {
|
|
211
|
+
const now = Date.now();
|
|
212
|
+
const until = seatBlockedUntil(seat.id, now);
|
|
213
|
+
if (until == null || until <= gate.overriddenUntil) return false;
|
|
214
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
|
|
215
|
+
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
216
|
+
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
217
|
+
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
|
|
218
|
+
}
|
|
219
|
+
} catch (e) {
|
|
220
|
+
log("supervisor.seat_watch_error", { err: e instanceof Error ? e.message : String(e) });
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
|
|
193
225
|
async function countdownWait(acct: string, until: number): Promise<boolean> {
|
|
194
226
|
let aborted = false;
|
|
195
227
|
const onInt = () => { aborted = true; };
|
|
@@ -324,11 +356,20 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
324
356
|
const info = analyzeArgs(argv);
|
|
325
357
|
const childEnv = { ...process.env, [WRAP_DEPTH_ENV]: String(depth + 1) };
|
|
326
358
|
|
|
359
|
+
let child: Subprocess | null = null;
|
|
360
|
+
let terminating = false;
|
|
361
|
+
process.on("SIGTERM", () => {
|
|
362
|
+
terminating = true;
|
|
363
|
+
if (child) child.kill("SIGTERM");
|
|
364
|
+
else process.exit(143);
|
|
365
|
+
});
|
|
366
|
+
|
|
327
367
|
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
328
368
|
const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
|
|
329
369
|
delete passthroughEnv.TOKENMAXXING_SUPERVISED;
|
|
330
370
|
delete passthroughEnv.TOKENMAXXING_SESSION_ID;
|
|
331
371
|
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit", env: passthroughEnv });
|
|
372
|
+
child = p;
|
|
332
373
|
await p.exited;
|
|
333
374
|
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
334
375
|
}
|
|
@@ -374,7 +415,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
374
415
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
375
416
|
|
|
376
417
|
const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
|
|
377
|
-
const { child, seat } = await withLock(claudePool.lockFile, async () => {
|
|
418
|
+
const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
|
|
378
419
|
const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
|
|
379
420
|
log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
|
|
380
421
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
@@ -395,35 +436,42 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
395
436
|
return { child: spawned, seat: picked };
|
|
396
437
|
});
|
|
397
438
|
|
|
439
|
+
child = proc;
|
|
398
440
|
let done = false;
|
|
441
|
+
let seatCheckAt = 0;
|
|
399
442
|
const markerWatch = (async () => {
|
|
400
443
|
while (!done) {
|
|
401
444
|
if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
|
|
445
|
+
if (seat && !terminating && Date.now() >= seatCheckAt) {
|
|
446
|
+
const decided = await moveExhaustedSeat(seat, sid, gate);
|
|
447
|
+
seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
|
|
448
|
+
}
|
|
402
449
|
await Bun.sleep(150);
|
|
403
450
|
}
|
|
404
451
|
return false;
|
|
405
452
|
})();
|
|
406
|
-
const exited =
|
|
453
|
+
const exited = proc.exited.then(() => { done = true; return "exit" as const; });
|
|
407
454
|
try {
|
|
408
455
|
const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
|
|
409
456
|
if (winner === "marker") {
|
|
410
457
|
relay?.detach();
|
|
411
|
-
|
|
458
|
+
proc.kill();
|
|
412
459
|
}
|
|
413
460
|
} catch (e) {
|
|
414
461
|
relay?.detach();
|
|
415
|
-
|
|
462
|
+
proc.kill();
|
|
416
463
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
|
417
464
|
throw e;
|
|
418
465
|
} finally {
|
|
419
|
-
await
|
|
466
|
+
await proc.exited;
|
|
467
|
+
child = null;
|
|
420
468
|
relay?.detach();
|
|
421
469
|
done = true;
|
|
422
470
|
await markerWatch.catch(() => {});
|
|
423
471
|
restoreTermios(savedTermios);
|
|
424
472
|
}
|
|
425
473
|
|
|
426
|
-
const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
|
|
474
|
+
const m = !terminating && existsSync(marker) ? consumableMarker(marker, gate) : null;
|
|
427
475
|
if (m) {
|
|
428
476
|
rmSync(marker, { force: true });
|
|
429
477
|
respawns++;
|
|
@@ -452,7 +500,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
452
500
|
continue;
|
|
453
501
|
}
|
|
454
502
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
|
455
|
-
log("supervisor.exit", { sid, respawns, code:
|
|
456
|
-
return
|
|
503
|
+
log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
|
|
504
|
+
return proc.exitCode ?? (proc.signalCode ? 1 : 0);
|
|
457
505
|
}
|
|
458
506
|
}
|
package/src/lib/claude.ts
CHANGED
|
@@ -12,11 +12,11 @@ import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./pat
|
|
|
12
12
|
import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
|
|
13
13
|
import { seatCounts } from "./presence.ts";
|
|
14
14
|
import type { Observation, Provider, SampleReport } from "./provider.ts";
|
|
15
|
-
import { foldTee,
|
|
15
|
+
import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
|
|
16
16
|
import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
17
17
|
import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
|
|
18
18
|
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
|
-
import { CRED_ENV_OVERRIDES, gatedFamilies, mergeWindows,
|
|
19
|
+
import { CRED_ENV_OVERRIDES, fetchUsageDirect, gatedFamilies, mergeWindows, windowsOf } from "./usage.ts";
|
|
20
20
|
import { CredentialBlobSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
|
|
21
21
|
import { c } from "../cli/render.ts";
|
|
22
22
|
|
|
@@ -46,7 +46,7 @@ async function observeLive(account: Account, cfg: Config, now: number, opts: { p
|
|
|
46
46
|
const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
|
|
47
47
|
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
48
48
|
const startedAt = Date.now();
|
|
49
|
-
const outcome = await
|
|
49
|
+
const outcome = await sampleAccountUsage(account, { retries: 0 });
|
|
50
50
|
await withLock(claudePool.lockFile, () => {
|
|
51
51
|
const idx = loadAccounts(claudePool);
|
|
52
52
|
const a = idx.accounts.find((x) => x.id === account.id);
|
|
@@ -81,7 +81,7 @@ async function samplePool(accounts: Account[]): Promise<Map<string, SampleReport
|
|
|
81
81
|
reports.set(a.id, { ok: true, source: "statusline" });
|
|
82
82
|
return;
|
|
83
83
|
}
|
|
84
|
-
const outcome = await
|
|
84
|
+
const outcome = await sampleAccountUsage(a);
|
|
85
85
|
if (!outcome.ok) {
|
|
86
86
|
reports.set(a.id, { ok: false, reason: outcome.reason });
|
|
87
87
|
return;
|
|
@@ -217,7 +217,7 @@ async function login(): Promise<Harvest | null> {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
console.log(c.dim("sampling usage..."));
|
|
220
|
-
const sampled = await
|
|
220
|
+
const sampled = await fetchUsageDirect(blob.claudeAiOauth.accessToken);
|
|
221
221
|
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
222
222
|
const at = Date.now();
|
|
223
223
|
const id = oauthAccount.accountUuid;
|
package/src/lib/decide.ts
CHANGED
|
@@ -43,8 +43,8 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
43
43
|
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
|
|
47
|
-
const activeId =
|
|
46
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
|
|
47
|
+
const activeId = seatId;
|
|
48
48
|
|
|
49
49
|
const lastSwapAt = loadLastSwapAt(p.pool);
|
|
50
50
|
if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
@@ -68,7 +68,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
68
68
|
return { swapped: false, account: null, reason: "raced-already-swapped" };
|
|
69
69
|
}
|
|
70
70
|
const idx = loadAccounts(p.pool);
|
|
71
|
-
const id2 =
|
|
71
|
+
const id2 = seatId;
|
|
72
72
|
const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
|
|
73
73
|
|
|
74
74
|
const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
|
package/src/lib/sample.ts
CHANGED
|
@@ -14,16 +14,16 @@ import { fetchUsageDirect, mergeWindows, probeUsage, windowsOf } from "./usage.t
|
|
|
14
14
|
import { UsageWindowsSchema, type Account, type Config } from "./types.ts";
|
|
15
15
|
|
|
16
16
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
17
|
-
z.object({ ok: z.literal(true), usage: UsageWindowsSchema }),
|
|
17
|
+
z.object({ ok: z.literal(true), usage: UsageWindowsSchema, via: z.enum(["get", "probe"]) }),
|
|
18
18
|
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
19
19
|
]);
|
|
20
20
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
21
21
|
|
|
22
|
-
const
|
|
23
|
-
z.object({ ok: z.literal(true),
|
|
22
|
+
const PreparedSampleSchema = z.discriminatedUnion("ok", [
|
|
23
|
+
z.object({ ok: z.literal(true), token: z.string().nullable() }),
|
|
24
24
|
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
25
25
|
]);
|
|
26
|
-
type
|
|
26
|
+
type PreparedSample = z.infer<typeof PreparedSampleSchema>;
|
|
27
27
|
|
|
28
28
|
export function teeObservation(account: Account): Observation | null {
|
|
29
29
|
const stored = account.lastUsageAt != null ? { windows: account.windows, at: account.lastUsageAt } : null;
|
|
@@ -43,7 +43,7 @@ export function foldTee(account: Account): boolean {
|
|
|
43
43
|
return true;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
export async function
|
|
46
|
+
export async function prepareSample(account: Account): Promise<PreparedSample> {
|
|
47
47
|
let creds;
|
|
48
48
|
try {
|
|
49
49
|
creds = await readStore(account.id);
|
|
@@ -57,20 +57,28 @@ export async function prepareProbe(account: Account, suffix = ""): Promise<Prepa
|
|
|
57
57
|
}
|
|
58
58
|
if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
|
|
59
59
|
account.tier = claudeTierLabel(creds) ?? account.tier;
|
|
60
|
+
return { ok: true, token: isAccessTokenExpiring(creds) ? null : creds.accessToken };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function runProbe(account: Account, suffix: string, retries: number | undefined): Promise<SampleOutcome> {
|
|
60
64
|
const dir = sampleDirFor(account.id, suffix);
|
|
61
65
|
mkdirSync(dir, { recursive: true });
|
|
62
66
|
writeFileAtomic(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
63
|
-
|
|
67
|
+
const usage = await probeUsage({ configDir: dir, store: storeDirFor(account.id) }, Date.now(), { retries });
|
|
68
|
+
return usage ? { ok: true, usage, via: "probe" } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
64
69
|
}
|
|
65
70
|
|
|
66
|
-
export async function
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
export async function runSample(account: Account, token: string | null, opts: { retries?: number; suffix?: string }): Promise<SampleOutcome> {
|
|
72
|
+
if (token) {
|
|
73
|
+
const usage = await fetchUsageDirect(token);
|
|
74
|
+
if (usage) return { ok: true, usage, via: "get" };
|
|
75
|
+
}
|
|
76
|
+
return runProbe(account, opts.suffix ?? "", opts.retries);
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
export async function
|
|
72
|
-
const prepared = await
|
|
73
|
-
return prepared.ok ?
|
|
79
|
+
export async function sampleAccountUsage(account: Account, opts: { retries?: number } = {}): Promise<SampleOutcome> {
|
|
80
|
+
const prepared = await prepareSample(account);
|
|
81
|
+
return prepared.ok ? runSample(account, prepared.token, opts) : prepared;
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
const SAMPLE_BATCH = 3;
|
|
@@ -83,18 +91,19 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
83
91
|
let dirty = false;
|
|
84
92
|
for (const a of idx.accounts) dirty = foldTee(a) || dirty;
|
|
85
93
|
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
94
|
+
const seats = seatCounts(paths.presenceDir);
|
|
86
95
|
const stale = idx.accounts
|
|
87
96
|
.filter((a) => a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs)
|
|
88
|
-
.sort((a, b) => sampledAt(a) - sampledAt(b))
|
|
97
|
+
.sort((a, b) => Number(seats.has(b.id)) - Number(seats.has(a.id)) || sampledAt(a) - sampledAt(b))
|
|
89
98
|
.slice(0, SAMPLE_BATCH);
|
|
90
99
|
if (stale.length === 0) {
|
|
91
100
|
if (dirty) saveAccounts(claudePool, idx);
|
|
92
101
|
return [];
|
|
93
102
|
}
|
|
94
|
-
const batch: { account: Account;
|
|
103
|
+
const batch: { account: Account; token: string | null }[] = [];
|
|
95
104
|
for (const target of stale) {
|
|
96
105
|
target.lastProbeAt = now;
|
|
97
|
-
const prepared = await
|
|
106
|
+
const prepared = await prepareSample(target);
|
|
98
107
|
if (!prepared.ok) {
|
|
99
108
|
target.storeFails = (target.storeFails ?? 0) + 1;
|
|
100
109
|
if (target.storeFails >= STORE_FAILS_REAUTH) target.needsReauth = true;
|
|
@@ -102,32 +111,15 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
102
111
|
continue;
|
|
103
112
|
}
|
|
104
113
|
target.storeFails = 0;
|
|
105
|
-
|
|
106
|
-
if (!seatCounts(paths.presenceDir).has(target.id)) {
|
|
107
|
-
const creds = await readStore(target.id).catch(() => null);
|
|
108
|
-
if (creds && !isDeadCredential(creds) && !isAccessTokenExpiring(creds)) token = creds.accessToken;
|
|
109
|
-
}
|
|
110
|
-
batch.push({ account: target, dir: prepared.dir, token });
|
|
114
|
+
batch.push({ account: target, token: prepared.token });
|
|
111
115
|
}
|
|
112
116
|
saveAccounts(claudePool, idx);
|
|
113
117
|
return batch;
|
|
114
118
|
});
|
|
115
119
|
await Promise.all(
|
|
116
|
-
reserved.map(async ({ account,
|
|
120
|
+
reserved.map(async ({ account, token }) => {
|
|
117
121
|
const startedAt = Date.now();
|
|
118
|
-
|
|
119
|
-
let outcome: SampleOutcome;
|
|
120
|
-
if (token) {
|
|
121
|
-
const usage = await fetchUsageDirect(token);
|
|
122
|
-
if (usage) {
|
|
123
|
-
via = "get";
|
|
124
|
-
outcome = { ok: true, usage };
|
|
125
|
-
} else {
|
|
126
|
-
outcome = await runProbe(account, dir, { retries: 0 });
|
|
127
|
-
}
|
|
128
|
-
} else {
|
|
129
|
-
outcome = await runProbe(account, dir, { retries: 0 });
|
|
130
|
-
}
|
|
122
|
+
const outcome = await runSample(account, token, { retries: 0, suffix: "-tick" });
|
|
131
123
|
await withLock(claudePool.lockFile, () => {
|
|
132
124
|
const idx = loadAccounts(claudePool);
|
|
133
125
|
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
@@ -138,7 +130,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
|
|
|
138
130
|
}
|
|
139
131
|
log(outcome.ok ? "sample.ok" : "sample.failed", {
|
|
140
132
|
account: account.id.slice(0, 8),
|
|
141
|
-
...(outcome.ok ? { via } : { reason: outcome.reason.slice(0, 200) }),
|
|
133
|
+
...(outcome.ok ? { via: outcome.via } : { reason: outcome.reason.slice(0, 200) }),
|
|
142
134
|
});
|
|
143
135
|
});
|
|
144
136
|
}),
|
package/src/lib/usage.ts
CHANGED
|
@@ -359,12 +359,28 @@ export async function probeUsage(target: ProbeTarget, now = Date.now(), opts: {
|
|
|
359
359
|
const USAGE_URL = env("TOKENMAXXING_OAUTH_USAGE_URL", "https://api.anthropic.com/api/oauth/usage");
|
|
360
360
|
const USAGE_DEADLINE_MS = 10_000;
|
|
361
361
|
|
|
362
|
-
const
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
362
|
+
const UsageLimitSchema = z.looseObject({ utilization: z.number(), resets_at: z.unknown() });
|
|
363
|
+
const UsageScopedLimitSchema = z.looseObject({
|
|
364
|
+
kind: z.string(),
|
|
365
|
+
percent: z.number(),
|
|
366
|
+
resets_at: z.unknown(),
|
|
367
|
+
scope: z.looseObject({ model: z.looseObject({ display_name: z.string() }) }),
|
|
367
368
|
});
|
|
369
|
+
const UsageResponseSchema = z.looseObject({
|
|
370
|
+
five_hour: UsageLimitSchema.nullish(),
|
|
371
|
+
seven_day: UsageLimitSchema.nullish(),
|
|
372
|
+
limits: z.array(z.unknown()).nullish(),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
function scopedRows(limits: unknown[]): Record<string, UsageWindow> {
|
|
376
|
+
const perModel: Record<string, UsageWindow> = {};
|
|
377
|
+
for (const raw of limits) {
|
|
378
|
+
const row = UsageScopedLimitSchema.safeParse(raw);
|
|
379
|
+
if (!row.success || row.data.kind !== "weekly_scoped") continue;
|
|
380
|
+
perModel[row.data.scope.model.display_name] = { usedPercentage: row.data.percent, resetsAt: normalizeResetsAt(row.data.resets_at) };
|
|
381
|
+
}
|
|
382
|
+
return perModel;
|
|
383
|
+
}
|
|
368
384
|
|
|
369
385
|
export async function fetchUsageDirect(accessToken: string): Promise<UsageWindows | null> {
|
|
370
386
|
let res: Response;
|
|
@@ -382,15 +398,10 @@ export async function fetchUsageDirect(accessToken: string): Promise<UsageWindow
|
|
|
382
398
|
return null;
|
|
383
399
|
}
|
|
384
400
|
const parsed = UsageResponseSchema.safeParse(JsonTextSchema.safeParse(await res.text()).data);
|
|
385
|
-
|
|
386
|
-
const seven = parsed.success ? parsed.data.rate_limits?.seven_day : null;
|
|
387
|
-
if (!five || !seven) {
|
|
401
|
+
if (!parsed.success || !parsed.data.five_hour || !parsed.data.seven_day) {
|
|
388
402
|
log("usage.get_incomplete", { ok: parsed.success });
|
|
389
403
|
return null;
|
|
390
404
|
}
|
|
391
|
-
const win = (w: z.infer<typeof
|
|
392
|
-
|
|
393
|
-
resetsAt: w.resets_at != null ? w.resets_at * 1000 : null,
|
|
394
|
-
});
|
|
395
|
-
return { fiveHour: win(five), sevenDay: win(seven), perModel: {} };
|
|
405
|
+
const win = (w: z.infer<typeof UsageLimitSchema>): UsageWindow => ({ usedPercentage: w.utilization, resetsAt: normalizeResetsAt(w.resets_at) });
|
|
406
|
+
return { fiveHour: win(parsed.data.five_hour), sevenDay: win(parsed.data.seven_day), perModel: scopedRows(parsed.data.limits ?? []) };
|
|
396
407
|
}
|