tokenmaxxing 1.67.0 → 1.68.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 +5 -4
- package/README.md +3 -3
- package/package.json +1 -1
- package/src/entries/stopfailurehook.ts +1 -1
- package/src/entries/stophook.ts +1 -1
- package/src/entries/supervisor.ts +25 -4
- package/src/lib/decide.ts +40 -21
- package/src/lib/paths.ts +1 -0
- package/src/lib/picker.ts +9 -0
- package/src/lib/state.ts +38 -1
- package/src/lib/types.ts +13 -0
package/DESIGN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tokenmaxxing - design
|
|
2
2
|
|
|
3
|
-
Automatic Claude Code account switching. You run `claude` exactly as always; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**90%** of the 5h session window minus the 3-point projection margin, so 87 by default, or **98%** of a weekly window), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool pauses
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; every session starts on the pooled account with the most session-window headroom per running session, and when the account a session runs on crosses its bar (**90%** of the 5h session window minus the 3-point projection margin, so 87 by default, or **98%** of a weekly window), tokenmaxxing resumes that session on a fresher account at a safe turn boundary. Works across many concurrent sessions at once, each on its own account; a fully depleted pool queues sessions first come first served onto reset targets (at most four waiters per account, overflow takes the soonest anyway), pauses each one with a countdown, and auto-resumes at its assigned reset when that lands within `policy.maxWaitMs` (default 1h; further out, the session stays put rather than parking for hours).
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code first (macOS + Linux, the latter since 2026-07-09).** Codex support landed in 0.13.0 (2026-07-16) with its own parallel state and supervisor; since 1.35.0 both run on one engine. Codex's verified internals live in AGENTS.md's Codex sections.
|
|
6
6
|
>
|
|
@@ -12,7 +12,7 @@ Automatic Claude Code account switching. You run `claude` exactly as always; eve
|
|
|
12
12
|
|
|
13
13
|
Claude Code namespaces its credential by `CLAUDE_SECURESTORAGE_CONFIG_DIR` (verified in the 2.1.269 binary, `xSe`/`y_`): the variable moves only the credential store (a `.credentials.json` inside that directory on Linux, a keychain item named `Claude Code-credentials-<sha256(dir)[0:8]>` on macOS), while `~/.claude` (settings, transcripts, plugins) and `~/.claude.json` stay shared, so `--resume` works across stores. An empty namespaced store never falls back to the default login. Hooks, the statusline, and MCP servers inherit the variable. That is the whole mechanism: give every pooled account its own store, set the variable per session, and each session runs on its own account with no credential ever moving.
|
|
14
14
|
|
|
15
|
-
What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the
|
|
15
|
+
What the variable cannot give you is a change of account for a running process: the store is fixed at spawn. So the supervisor's job is placement and moves: on launch it **picks the seat** (see §5) and spawns claude under that account's store; when the session's Stop hook decides the seat is over its bar, the supervisor **replaces the process at a salvageable moment** - after a turn completes, the conversation is fully written to the transcript JSONL and `claude` is idle at the prompt, so killing it there loses nothing - and relaunches `claude --resume <session-id>` under the target's store. When the whole pool is over its bars it shows an interruptible countdown to the reset the wait queue assigned (at most four waiters per account, first come first served, overflow on the soonest). **Thresholds sit below 100: the headroom is the budget to reach a clean turn boundary before the account actually hits its real limit.** The session window moves at 90 minus the margin (a 5h reset is cheap to sit out), while the weekly windows drain to 98 (weekly allowance is use-it-or-lose-it).
|
|
16
16
|
|
|
17
17
|
A hook can't do the placement or the relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
|
|
18
18
|
|
|
@@ -37,6 +37,7 @@ It is a process manager only - spawn with inherited stdio (stdin through a relay
|
|
|
37
37
|
- `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
|
|
38
38
|
- `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
|
|
39
39
|
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account, the session to resume, a `waitUntil`, a `compact` flag, and the launch time the marker was written for.
|
|
40
|
+
- `wait-queue.json` - the depleted-pool wait claims (`{sessionId, accountId, at, waitUntil}`), so at most four sessions wait on one account; a session that arrives when every account already has four waiters takes the soonest reset anyway.
|
|
40
41
|
- `bin/claude` and `bin/codex` - the supervisors, beside the `tokenmaxxing` and `xx` entry points.
|
|
41
42
|
- `sessions/<sid>.json`, `onboard/`, `setup-tokens.json`, `setup-token/`, `cloud/`, `hub-key`, `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
43
|
- 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.
|
|
@@ -57,7 +58,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
57
58
|
1. Resolve the seat: the pooled account whose store the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` names. No seat (a session started outside the supervisor) means nothing to move.
|
|
58
59
|
2. Read the seat's tee and exit while it is under both bars, as defined in the [switching policy](docs/content/docs/switching.mdx).
|
|
59
60
|
3. Take the pool lock, re-check, fold every account's tee into the index, select a candidate under the switching policy from the cached figures, and verify that the target's store holds a usable credential (a store Claude Code dead-cleared flags the account for re-auth and the next candidate is tried).
|
|
60
|
-
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the
|
|
61
|
+
4. Write `respawn/<session-id>` (atomic temp+rename) naming the target, with `waitUntil` now for a plain move or the assigned reset when the pool is depleted. No credential is written.
|
|
61
62
|
|
|
62
63
|
### 3.3 Move and depleted-pool pause (supervisor)
|
|
63
64
|
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 <live-id> /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 <live-id>` (`--session-id <live-id>` when that id has no transcript yet, because `--resume` exits with `No conversation found` for it) with `CLAUDE_SECURESTORAGE_CONFIG_DIR` set to the target's store and rewrites the session's presence. The marker file is keyed by the launch id `<sid>`; the live id inside it is the session id the supervised process runs now, which `/clear` and an in-session `/resume` change. A hook adopts a differing stdin id as the live id (`current` in `sessions/<sid>.json`) only when its own process descends, through shell processes alone, from the presence record's pid; any other differing id is a nested session and writes nothing. The resumed process reads that store cold → runs on the target account, same conversation. The `--session-id` path is the exception: the cleared conversation has no prior turn, so it starts empty with no compaction and no first prompt. On the `--resume` path 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. In a terminal session the supervisor's own notices go to stderr. In a stream-json session (`--output-format stream-json`, read from the effective flags like the relay) there is no terminal and no statusline, so the supervisor writes each notice (the compaction and its failure, the move, the depleted-pool wait with its resume time, the resume) to stdout as a `system` line of subtype `informational` and level `warning`, the shape Claude Code emits for its own print-mode notices, which the harness renders in the conversation while the stream stays parseable; the per-second countdown ticks stay terminal-only, and the supervisor writes only while no child is running. 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. In a stream-json session that fatal, the wrap-depth abort, the wrap-rate abort, and a failed presence write also go to stdout as the same `informational`/`warning` line.
|
|
@@ -82,7 +83,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
|
|
|
82
83
|
---
|
|
83
84
|
|
|
84
85
|
## 5. Placement and rotation policy
|
|
85
|
-
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,
|
|
86
|
+
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, queue first come first served onto a reset target (at most four waiters per account, overflow takes the soonest anyway) and wait for that reset. The same ranking places a session at launch.
|
|
86
87
|
|
|
87
88
|
- 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.
|
|
88
89
|
- 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, 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 usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. An expired stored access token stays unmeasured until a session on that store refreshes it.
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# tokenmaxxing
|
|
2
2
|
|
|
3
|
-
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session pauses with a countdown
|
|
3
|
+
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most four sessions wait on one account) and pauses with a countdown until its assigned reset, when that reset falls within `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
|
|
4
4
|
|
|
5
5
|
> **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok and opencode-go as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys; the opencode-go pool, which holds Zen API keys, is the one exception.
|
|
6
6
|
|
|
@@ -92,7 +92,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
|
92
92
|
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.
|
|
93
93
|
|
|
94
94
|
- 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`, with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. An expired stored access token stays unmeasured until a session on that store refreshes it.
|
|
95
|
-
- When no account is usable, the session pauses until
|
|
95
|
+
- When no account is usable, the session queues first come first served onto a reset target (at most four waiters per account) and pauses until that reset if it lands within `policy.maxWaitMs`, and otherwise stays put.
|
|
96
96
|
|
|
97
97
|
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.
|
|
98
98
|
|
|
@@ -113,7 +113,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
|
|
|
113
113
|
}
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
-
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a usage sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a
|
|
116
|
+
`thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a usage sample stays fresh, for a seat's own sample and for the accounts each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a 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 - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick. `claudeBin`, `codexBin`, `grokBin`, and `opencodeBin` pin the real binaries; `init` writes them, and `TOKENMAXXING_CLAUDE_BIN`, `TOKENMAXXING_CODEX_BIN`, `TOKENMAXXING_GROK_BIN`, and `TOKENMAXXING_OPENCODE_BIN` override them for one process, except that `init` pins the binary it resolved into `config.json`.
|
|
117
117
|
|
|
118
118
|
State lives in `~/.config/tokenmaxxing/`; outside it the install touches Claude Code's `settings.json`, codex's `hooks.json`, the timer and usage hub units, and your shell rc. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
119
119
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.68.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",
|
|
@@ -80,7 +80,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced);
|
|
83
|
+
const decision = await evaluateAndMaybeSwap(claude, now, canRespawn && enforced != null, enforced, { waiterId: canRespawn ? session?.sid : undefined });
|
|
84
84
|
if (enforced && session && canRespawn && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
85
85
|
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: false });
|
|
86
86
|
log("stopfailure.marker", { session: session.sid.slice(0, 8), live: session.live.slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil ?? now });
|
package/src/entries/stophook.ts
CHANGED
|
@@ -24,7 +24,7 @@ export async function runBoundaryHook(event: "stop" | "sessionstart"): Promise<n
|
|
|
24
24
|
log(`${event}.nested_session`, { stdin: stdin.session_id?.slice(0, 8), supervised: supervised.live.slice(0, 8), source: stdin.source });
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
|
-
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null);
|
|
27
|
+
const decision = await evaluateAndMaybeSwap(claude, Date.now(), session != null, null, { waiterId: session?.sid });
|
|
28
28
|
if (session && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
29
29
|
writeRespawnMarker({ session, accountId: decision.account.id, waitUntil: decision.waitUntil ?? Date.now(), compact: true });
|
|
30
30
|
log(`${event}.${decision.waitUntil !== undefined ? "wait" : "move"}`, { source: stdin.source, account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil, session: session.sid.slice(0, 8), live: session.live.slice(0, 8) });
|
|
@@ -16,7 +16,7 @@ import { teeObservation } from "../lib/sample.ts";
|
|
|
16
16
|
import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough } from "../lib/supervise.ts";
|
|
17
17
|
import { saveTermios } from "../lib/tty.ts";
|
|
18
18
|
import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
|
|
19
|
-
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
19
|
+
import { loadAccounts, loadConfig, releaseWaitClaim } from "../lib/state.ts";
|
|
20
20
|
import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
|
|
21
21
|
import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
|
|
22
22
|
import { errorMessage, log } from "../lib/log.ts";
|
|
@@ -222,7 +222,7 @@ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, m
|
|
|
222
222
|
const families = gatedFamilies(model, cfg.policy.switchModels);
|
|
223
223
|
const until = seatBlockedUntil(seat.id, now, families, cfg);
|
|
224
224
|
if (until == null || until <= gate.overriddenUntil) return false;
|
|
225
|
-
const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
|
|
225
|
+
const decision = await evaluateAndMaybeSwap(claude, now, true, null, { seatId: seat.id, sessionFamilies: families, waiterId: sid });
|
|
226
226
|
log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
227
227
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
228
228
|
writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt, live: liveSessionId(sid) }, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
|
|
@@ -364,10 +364,27 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
364
364
|
|
|
365
365
|
let child: Subprocess | null = null;
|
|
366
366
|
let terminating = false;
|
|
367
|
+
let claimSid: string | null = null;
|
|
368
|
+
const releaseClaim = async (): Promise<void> => {
|
|
369
|
+
const claimed = claimSid;
|
|
370
|
+
if (claimed == null) return;
|
|
371
|
+
try {
|
|
372
|
+
await withLock(claudePool.lockFile, () => {
|
|
373
|
+
releaseWaitClaim(claimed);
|
|
374
|
+
});
|
|
375
|
+
} catch (e) {
|
|
376
|
+
log("supervisor.claim_release_failed", { err: errorMessage(e) });
|
|
377
|
+
}
|
|
378
|
+
};
|
|
367
379
|
process.on("SIGTERM", () => {
|
|
368
380
|
terminating = true;
|
|
369
|
-
|
|
370
|
-
|
|
381
|
+
const released = releaseClaim();
|
|
382
|
+
if (child) {
|
|
383
|
+
child.kill("SIGTERM");
|
|
384
|
+
void released;
|
|
385
|
+
} else {
|
|
386
|
+
released.finally(() => process.exit(143));
|
|
387
|
+
}
|
|
371
388
|
});
|
|
372
389
|
|
|
373
390
|
if (!info.manage || process.env[UNMANAGED_ENV]) {
|
|
@@ -392,6 +409,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
392
409
|
} else {
|
|
393
410
|
sid = crypto.randomUUID();
|
|
394
411
|
}
|
|
412
|
+
claimSid = sid;
|
|
395
413
|
|
|
396
414
|
if (resuming && base.length === 0) {
|
|
397
415
|
const persisted = loadSessionFlags(sid);
|
|
@@ -504,6 +522,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
504
522
|
if (m.waitUntil > Date.now()) {
|
|
505
523
|
if (await countdownWait(label, m.waitUntil, { stream, say })) overriddenUntil = m.waitUntil;
|
|
506
524
|
} else say(`\n\x1b[36m↻ tokenmaxxing: moving to ${label} - resuming...\x1b[0m\n`, `tokenmaxxing: moving to ${label} and resuming.`);
|
|
525
|
+
await releaseClaim();
|
|
507
526
|
wanted = m.accountId;
|
|
508
527
|
saveSessionFlags(m.sessionId, persistable, process.cwd());
|
|
509
528
|
const prompt = resumable ? resumePrompt(compacted) : null;
|
|
@@ -512,6 +531,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
512
531
|
continue;
|
|
513
532
|
}
|
|
514
533
|
clearPresence({ dir: paths.presenceDir, id: sid });
|
|
534
|
+
await releaseClaim();
|
|
515
535
|
log("supervisor.exit", { sid, respawns, code: proc.exitCode, signal: proc.signalCode, terminated: terminating || undefined });
|
|
516
536
|
return exitStatus(proc);
|
|
517
537
|
}
|
|
@@ -520,6 +540,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
520
540
|
const text = msg.startsWith("tokenmaxxing:") ? msg : `tokenmaxxing: ${msg}`;
|
|
521
541
|
say(`\n\x1b[31m${text}\x1b[0m\n`, text);
|
|
522
542
|
log("supervisor.fatal", { err: msg });
|
|
543
|
+
await releaseClaim();
|
|
523
544
|
return 1;
|
|
524
545
|
}
|
|
525
546
|
}
|
package/src/lib/decide.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withLock } from "./lock.ts";
|
|
2
|
-
import { loadAccounts, loadConfig, saveAccounts } from "./state.ts";
|
|
3
|
-
import { isExhausted, limitWindows, liveUsed, nextWeeklyReset, pickBest,
|
|
2
|
+
import { loadAccounts, loadConfig, liveWaitClaims, releaseWaitClaim, replaceWaitClaim, saveAccounts } from "./state.ts";
|
|
3
|
+
import { isExhausted, limitWindows, liveUsed, nextWeeklyReset, pickBest, pickWaitTarget, sessionWindow, thresholdBars, weeklyWindow, type PickCtx } from "./picker.ts";
|
|
4
4
|
import { familyTokens } from "./usage.ts";
|
|
5
5
|
import { errorMessage, log } from "./log.ts";
|
|
6
6
|
import type { Observation, Provider } from "./provider.ts";
|
|
@@ -8,6 +8,14 @@ import type { Account, EnforcedLimit } from "./types.ts";
|
|
|
8
8
|
|
|
9
9
|
export type SwapDecision = { swapped: boolean; account: Account | null; reason: string; waitUntil?: number };
|
|
10
10
|
|
|
11
|
+
export type EvalOpts = {
|
|
12
|
+
seatId?: string | null;
|
|
13
|
+
sessionFamilies?: string[];
|
|
14
|
+
waiterId?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const MAX_WAITERS_PER_ACCOUNT = 4;
|
|
18
|
+
|
|
11
19
|
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
12
20
|
const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
|
|
13
21
|
|
|
@@ -42,23 +50,30 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
|
|
|
42
50
|
return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null,
|
|
46
|
-
const activeId = seatId;
|
|
53
|
+
export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, opts: EvalOpts = {}): Promise<SwapDecision> {
|
|
54
|
+
const activeId = opts.seatId === undefined ? p.liveId() : opts.seatId;
|
|
55
|
+
const waiterId = canRespawn ? opts.waiterId : undefined;
|
|
47
56
|
const cfg = loadConfig();
|
|
48
57
|
const bars = thresholdBars(cfg);
|
|
49
58
|
const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
50
59
|
const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
|
|
51
|
-
const gated = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
60
|
+
const gated = opts.sessionFamilies ?? p.gatedFamilies(cfg);
|
|
52
61
|
const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
|
|
53
62
|
const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
|
|
54
63
|
|
|
55
64
|
if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
|
|
65
|
+
if (waiterId != null && liveWaitClaims(now).some((c) => c.sessionId === waiterId)) {
|
|
66
|
+
await withLock(p.pool.lockFile, () => {
|
|
67
|
+
releaseWaitClaim(waiterId);
|
|
68
|
+
});
|
|
69
|
+
}
|
|
56
70
|
return { swapped: false, account: null, reason: "under-threshold-or-stale" };
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
return withLock(p.pool.lockFile, async () => {
|
|
74
|
+
if (waiterId != null) releaseWaitClaim(waiterId);
|
|
60
75
|
const idx = loadAccounts(p.pool);
|
|
61
|
-
const id2 =
|
|
76
|
+
const id2 = activeId;
|
|
62
77
|
const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
|
|
63
78
|
|
|
64
79
|
const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
|
|
@@ -91,7 +106,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
91
106
|
}
|
|
92
107
|
saveAccounts(p.pool, idx);
|
|
93
108
|
|
|
94
|
-
const families = sessionFamilies ?? p.gatedFamilies(cfg);
|
|
109
|
+
const families = opts.sessionFamilies ?? p.gatedFamilies(cfg);
|
|
95
110
|
const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
|
|
96
111
|
const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
|
|
97
112
|
const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
|
|
@@ -142,31 +157,35 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
|
|
|
142
157
|
const fresh = loadAccounts(p.pool);
|
|
143
158
|
const current = seatOf(fresh);
|
|
144
159
|
const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
else if (current) { target = current; waitUntil = currentAt; }
|
|
152
|
-
else if (other) { target = other.account; waitUntil = other.availableAt; }
|
|
160
|
+
const waiters = new Map<string, number>();
|
|
161
|
+
for (const claim of liveWaitClaims(now)) {
|
|
162
|
+
if (claim.sessionId === waiterId) continue;
|
|
163
|
+
waiters.set(claim.accountId, (waiters.get(claim.accountId) ?? 0) + 1);
|
|
164
|
+
}
|
|
165
|
+
const target = pickWaitTarget(usable(fresh.accounts), ctx, waiters, MAX_WAITERS_PER_ACCOUNT, now + cfg.policy.maxWaitMs);
|
|
153
166
|
|
|
154
|
-
if (!target
|
|
167
|
+
if (!target) {
|
|
168
|
+
const soonest = pickWaitTarget(usable(fresh.accounts), ctx, new Map(), MAX_WAITERS_PER_ACCOUNT, Number.POSITIVE_INFINITY);
|
|
169
|
+
const waitUntil = soonest?.availableAt ?? Number.POSITIVE_INFINITY;
|
|
155
170
|
log("decide.depleted", { waitUntil: Number.isFinite(waitUntil) ? waitUntil : 0 });
|
|
156
171
|
return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
|
|
157
172
|
}
|
|
158
173
|
|
|
159
|
-
const
|
|
174
|
+
const waitUntil = target.availableAt;
|
|
175
|
+
const isCurrent = target.account.id === (current?.id ?? null);
|
|
160
176
|
if (!isCurrent) {
|
|
161
177
|
try {
|
|
162
|
-
await p.swap(target);
|
|
178
|
+
await p.swap(target.account);
|
|
163
179
|
} catch (e) {
|
|
164
|
-
skipOrThrow(e, target);
|
|
180
|
+
skipOrThrow(e, target.account);
|
|
165
181
|
continue;
|
|
166
182
|
}
|
|
167
183
|
}
|
|
168
|
-
|
|
169
|
-
|
|
184
|
+
if (waiterId != null) {
|
|
185
|
+
replaceWaitClaim({ sessionId: waiterId, accountId: target.account.id, at: now, waitUntil });
|
|
186
|
+
}
|
|
187
|
+
log("decide.depleted_wait", { account: target.account.id.slice(0, 8), waitUntil });
|
|
188
|
+
return { swapped: !isCurrent, account: target.account, reason: "depleted-wait", waitUntil };
|
|
170
189
|
}
|
|
171
190
|
});
|
|
172
191
|
}
|
package/src/lib/paths.ts
CHANGED
package/src/lib/picker.ts
CHANGED
|
@@ -135,3 +135,12 @@ export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestRe
|
|
|
135
135
|
.filter((x) => Number.isFinite(x.availableAt));
|
|
136
136
|
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
137
137
|
}
|
|
138
|
+
|
|
139
|
+
export function pickWaitTarget(accounts: Account[], ctx: PickCtx, waiters: Map<string, number>, cap: number, deadline: number): EarliestReset | null {
|
|
140
|
+
const mapped = accounts
|
|
141
|
+
.filter((a) => !a.needsReauth)
|
|
142
|
+
.map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
|
|
143
|
+
.filter((x) => Number.isFinite(x.availableAt) && x.availableAt <= deadline)
|
|
144
|
+
.sort((x, y) => x.availableAt - y.availableAt || (x.account.id === ctx.currentId ? -1 : y.account.id === ctx.currentId ? 1 : 0));
|
|
145
|
+
return mapped.find((x) => (waiters.get(x.account.id) ?? 0) < cap) ?? mapped[0] ?? null;
|
|
146
|
+
}
|
package/src/lib/state.ts
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
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
|
-
import { optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
|
|
4
|
+
import { claudePool, optionalEnv, paths, usageJsonFor, type PoolPaths } from "./paths.ts";
|
|
5
5
|
import { writeFileAtomic } from "./atomic.ts";
|
|
6
6
|
import {
|
|
7
7
|
AccountsIndexSchema,
|
|
8
8
|
ConfigSchema,
|
|
9
9
|
ErrnoSchema,
|
|
10
10
|
UsageStateSchema,
|
|
11
|
+
WaitQueueSchema,
|
|
11
12
|
type Account,
|
|
12
13
|
type AccountsIndex,
|
|
13
14
|
type Config,
|
|
14
15
|
type UsageState,
|
|
16
|
+
type WaitClaim,
|
|
15
17
|
type Window,
|
|
16
18
|
} from "./types.ts";
|
|
17
19
|
|
|
@@ -67,6 +69,41 @@ export function saveAccounts(pool: PoolPaths, idx: AccountsIndex): void {
|
|
|
67
69
|
writeFileAtomic(pool.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
function loadWaitQueue(): WaitClaim[] {
|
|
73
|
+
if (!existsSync(claudePool.waitQueueJson)) return [];
|
|
74
|
+
let json: unknown;
|
|
75
|
+
try {
|
|
76
|
+
json = JSON.parse(readFileSync(claudePool.waitQueueJson, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
throw new Error(`${claudePool.waitQueueJson} is corrupt (unparsable JSON) - refusing to treat a damaged wait queue as empty; repair or remove the file`);
|
|
79
|
+
}
|
|
80
|
+
const parsed = WaitQueueSchema.safeParse(json);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
throw new Error(`${claudePool.waitQueueJson} does not match the wait-queue schema - refusing to treat a damaged wait queue as empty; repair or remove the file`);
|
|
83
|
+
}
|
|
84
|
+
return parsed.data.claims;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveWaitQueue(claims: WaitClaim[]): void {
|
|
88
|
+
writeFileAtomic(claudePool.waitQueueJson, JSON.stringify(WaitQueueSchema.parse({ version: 1, claims }), null, 2) + "\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function liveWaitClaims(now: number): WaitClaim[] {
|
|
92
|
+
return loadWaitQueue().filter((c) => c.waitUntil > now);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function replaceWaitClaim(claim: WaitClaim): void {
|
|
96
|
+
saveWaitQueue([...liveWaitClaims(Date.now()).filter((c) => c.sessionId !== claim.sessionId), claim]);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function releaseWaitClaim(sessionId: string): void {
|
|
100
|
+
const now = Date.now();
|
|
101
|
+
const raw = loadWaitQueue();
|
|
102
|
+
const next = raw.filter((c) => c.waitUntil > now && c.sessionId !== sessionId);
|
|
103
|
+
if (next.length === raw.length) return;
|
|
104
|
+
saveWaitQueue(next);
|
|
105
|
+
}
|
|
106
|
+
|
|
70
107
|
export type Harvest = {
|
|
71
108
|
id: string;
|
|
72
109
|
email: string | null;
|
package/src/lib/types.ts
CHANGED
|
@@ -128,6 +128,19 @@ export const ConfigSchema = z
|
|
|
128
128
|
});
|
|
129
129
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
130
130
|
|
|
131
|
+
export const WaitClaimSchema = z.object({
|
|
132
|
+
sessionId: z.string(),
|
|
133
|
+
accountId: z.string(),
|
|
134
|
+
at: z.number(),
|
|
135
|
+
waitUntil: z.number(),
|
|
136
|
+
});
|
|
137
|
+
export type WaitClaim = z.infer<typeof WaitClaimSchema>;
|
|
138
|
+
|
|
139
|
+
export const WaitQueueSchema = z.object({
|
|
140
|
+
version: z.literal(1),
|
|
141
|
+
claims: z.array(WaitClaimSchema).default([]),
|
|
142
|
+
});
|
|
143
|
+
|
|
131
144
|
export const RespawnMarkerSchema = z.object({
|
|
132
145
|
accountId: z.string(),
|
|
133
146
|
ts: z.number(),
|