tokenmaxxing 0.13.1 → 0.15.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 +14 -13
- package/README.md +11 -10
- package/package.json +1 -1
- package/src/cli/config.ts +200 -0
- package/src/entries/stophook.ts +9 -6
- package/src/entries/supervisor.ts +6 -5
- package/src/lib/state.ts +3 -2
- package/src/lib/types.ts +4 -3
- package/src/main.ts +3 -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; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps to a fresh account
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (**95%** of the 5h session window, **98%** of a weekly window), tokenmaxxing swaps the credential to a fresh account at a safe turn boundary and **your running session adopts it in place - no restart**. Works across many concurrent sessions at once; a fully depleted pool pauses with a countdown and auto-resumes at the soonest reset.
|
|
4
4
|
|
|
5
5
|
> Scope: **Claude Code only, macOS first.** Codex and other CLIs deferred (see `.memory/cc-codex-auth-mechanics.md`).
|
|
6
6
|
>
|
|
@@ -10,11 +10,11 @@ Automatic Claude Code account switching. You run `claude` exactly as always; whe
|
|
|
10
10
|
|
|
11
11
|
## 1. Why there is a thin supervisor (and why that's the whole trick)
|
|
12
12
|
|
|
13
|
-
A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux.
|
|
13
|
+
A **running** `claude` DOES adopt an externally swapped credential (verified live 2026-07-10, correcting this document's original claim): an ensure-fresh poll re-reads the credential store around every request, so a swap lands within ~30s on macOS (raw keychain cache) and on the next request on Linux. A plain swap therefore needs no process management at all (since 0.15.0, 2026-07-16; earlier versions respawned on every swap): the Stop hook swaps the credential and the session keeps running. What adoption cannot give you is the depleted case: when every account is at the wall the session must be PAUSED until something resets, and a live `claude` cannot pause itself.
|
|
14
14
|
|
|
15
|
-
So the supervisor's job is
|
|
15
|
+
So the supervisor's job is narrow: on a depleted pool it **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 - shows an interruptible countdown to the soonest reset, and relaunches `claude --resume <session-id>` when it passes. **Thresholds still sit below 100%: the headroom is the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the account actually hits the wall.** The session window swaps at 95 (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
|
-
A hook can't do the
|
|
17
|
+
A hook can't do the pause-and-relaunch - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
supervisor (you type `claude`) → real claude (in a PTY) → Stop hook
|
|
@@ -33,7 +33,7 @@ It is a process/PTY manager only - spawn, forward the terminal, wait, restore te
|
|
|
33
33
|
- `config.json` - threshold, account order/policy.
|
|
34
34
|
- `accounts.json` - non-secret index `{email, organizationUuid, accountUuid, lastUsage, resetsAt, needs_reauth}`.
|
|
35
35
|
- `usage.json` - live usage, written by the statusLine shim.
|
|
36
|
-
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal).
|
|
36
|
+
- `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal, depleted-pool waits only).
|
|
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
|
|
|
@@ -49,10 +49,10 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
|
|
|
49
49
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
50
50
|
1. Read `usage.json`; `exit 0` fast if every window is under its threshold (metered per `organizationUuid`).
|
|
51
51
|
2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, furthest behind its own weekly pace first: highest remaining% / time-to-weekly-reset, since unused allowance is forfeited at the fixed per-account reset; tiebreak soonest expiry then lowest 7-day usage), and **swap the credential** (§3.4).
|
|
52
|
-
3.
|
|
52
|
+
3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
|
|
53
53
|
|
|
54
|
-
### 3.3
|
|
55
|
-
The supervisor
|
|
54
|
+
### 3.3 Depleted-pool pause (supervisor)
|
|
55
|
+
The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
|
|
56
56
|
|
|
57
57
|
### 3.4 Swap sequence (under the lock)
|
|
58
58
|
1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the roles endpoint (`GET /api/oauth/claude_cli/roles`), NOT the `accounts.json` active label. The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
|
|
@@ -62,7 +62,7 @@ The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, res
|
|
|
62
62
|
5. Do steps 1, 3, 4 inside Claude's own `~/.claude.lock` so the writes can't collide with a token refresh.
|
|
63
63
|
|
|
64
64
|
### 3.5 Multiple concurrent sessions
|
|
65
|
-
Each terminal ran the supervisor, so each has its own child `claude
|
|
65
|
+
Each terminal ran the supervisor, so each has its own child `claude` and its own `--session-id`. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; every running session then adopts the new credential in place - no restarts. (They share one credential, so they always move together - consistent with "one current account, many windows.") Only a depleted pool fans out: each supervised session's Stop hook writes its own `respawn/<sid>` marker, and each supervisor independently pauses and later relaunches `claude --resume <its-own-sid>`.
|
|
66
66
|
|
|
67
67
|
---
|
|
68
68
|
|
|
@@ -83,10 +83,11 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
83
83
|
---
|
|
84
84
|
|
|
85
85
|
## 6. Honest papercuts
|
|
86
|
-
- **Respawn hiccup.**
|
|
87
|
-
- **
|
|
88
|
-
- **
|
|
89
|
-
- **
|
|
86
|
+
- **Respawn hiccup (depleted pause only).** Plain swaps never restart the session. When the whole pool is depleted you see `claude` stop, a countdown, and a resume; anything typed in the split second before the SIGTERM is lost, and the supervisor resets terminal mode so nothing is left garbled.
|
|
87
|
+
- **Adoption lag.** macOS reads the keychain through a raw 30s cache, so at most the first turn after a swap can still meter the old account. The bars' headroom absorbs it.
|
|
88
|
+
- **One cold turn.** Prompt cache is org-scoped: the first turn on B re-uploads context once (bigger on long transcripts).
|
|
89
|
+
- **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn can end rate-limited before the Stop hook swaps; the swap then still recovers the session (its next turn adopts the fresh account). Projected threshold reduces this.
|
|
90
|
+
- **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each adopts in place). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
|
|
90
91
|
- **Refresh-token rotation / parked-token rot.** Step 1 re-harvest is mandatory; a parked refresh token can be invalidated by logging in elsewhere → picker must catch `invalid_grant`, mark `needs_reauth`, fall through.
|
|
91
92
|
- **statusLine fragility.** The shim is the most visible surface - a bug flickers or breaks your real status line. Keep it O(ms), write-on-change.
|
|
92
93
|
- **Keychain blob size & ps-safety.** The live `Claude Code-credentials` item also holds per-MCP-server OAuth state, so it can exceed `security -i`'s ~4KB interactive line buffer (verified on a real machine - a 4.3KB blob truncated). tokenmaxxing therefore stores parked backups as **`claudeAiOauth`-only** (small → always the ps-safe stdin write) and, on a swap, **merges** the fresh `claudeAiOauth` into the *current* live blob so MCP tokens survive the switch - using the argv write path (secret briefly visible in `ps` on your own machine) only for that one oversized live write.
|
package/README.md
CHANGED
|
@@ -1,19 +1,18 @@
|
|
|
1
1
|
# tokenmaxxing
|
|
2
2
|
|
|
3
|
-
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account
|
|
3
|
+
**Automatic Claude Code account switching.** Run `claude` exactly as you always do; when the active account nears its usage limit, tokenmaxxing swaps the credential to a fresher account at a safe turn boundary and your session keeps running on it - no restart, same conversation. Works across many concurrent sessions. Only when the whole pool is at its limit does anything visible happen: a countdown that auto-resumes at the soonest reset.
|
|
4
4
|
|
|
5
5
|
> **Scope:** Claude Code only, macOS and Linux. It pools **subscription** accounts (Pro/Max), not API keys.
|
|
6
6
|
|
|
7
7
|
```
|
|
8
8
|
$ claude
|
|
9
|
-
...you work normally...
|
|
10
|
-
|
|
11
|
-
...same conversation, fresh quota...
|
|
9
|
+
...you work normally; swaps are invisible (watch the statusline account flip)...
|
|
10
|
+
⏳ tokenmaxxing: all accounts at their limit. Resuming on work@acme.com when it resets (Ctrl-C to resume now).
|
|
12
11
|
```
|
|
13
12
|
|
|
14
13
|
## Why
|
|
15
14
|
|
|
16
|
-
A running `claude` re-checks the credential store between requests, so a swapped credential is adopted in-place (within ~30s on macOS, the next request on Linux)
|
|
15
|
+
A running `claude` re-checks the credential store between requests, so a swapped credential is adopted in-place (within ~30s on macOS, the next request on Linux) - a swap never restarts your session. The one case that still needs process management is a fully depleted pool: a session cannot pause itself, so a thin `claude` supervisor on your PATH stops it at a committed turn boundary (the transcript is already on disk, nothing is lost), shows a countdown, and auto-resumes `claude --resume <id>` at the soonest reset. Everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
|
|
17
16
|
|
|
18
17
|
## Install
|
|
19
18
|
|
|
@@ -44,8 +43,9 @@ claude # use claude as always
|
|
|
44
43
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
45
44
|
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
46
45
|
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
46
|
+
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
47
47
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
48
|
-
| `tokenmaxxing rename <sel> <label>`
|
|
48
|
+
| `tokenmaxxing rename <sel> <label>` / `rm <sel>` | manage the pool |
|
|
49
49
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
50
50
|
|
|
51
51
|
## How switching decides
|
|
@@ -55,7 +55,7 @@ Switching engages (configurable) once the active account's 5-hour session window
|
|
|
55
55
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
56
56
|
- **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) whenever the active model is one of `policy.switchModels`, so a Fable session switches on the Fable cap while a Sonnet session rides the aggregate.
|
|
57
57
|
|
|
58
|
-
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
|
|
58
|
+
The bars' headroom is deliberate: it's the budget to reach a clean turn boundary (plus up to one turn of adoption lag on macOS) before the wall. The session bar sits lower (95) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor sits far below both: weekly allowance is forfeited at each account's fixed reset, so once half a session window justifies the swap, quota is best burned on whichever account has the most at risk.
|
|
59
59
|
|
|
60
60
|
The **target** is chosen greedily off each account's cached windows: among usable accounts (every window under its bar, or past its reset), the one **furthest behind its own weekly pace** - highest remaining% divided by time to its weekly reset - because unused weekly allowance is forfeited at the fixed per-account reset. Cached resets are absolute UTC epochs, so a stale snapshot still resolves correctly: a weekly reset that has passed extrapolates forward in 7-day steps, and a session window past its reset counts as empty. Both `tokenmaxxing switch` and the automatic path rank the current account too and do nothing when it already wins, so they are idempotent - evaluating periodically converges on the right account.
|
|
61
61
|
|
|
@@ -120,9 +120,10 @@ Two codex-specific facts worth knowing: codex does not run hooks it has not been
|
|
|
120
120
|
|
|
121
121
|
## Honest limitations
|
|
122
122
|
|
|
123
|
-
- **One cold turn.** The first turn
|
|
124
|
-
- **
|
|
125
|
-
- **
|
|
123
|
+
- **One cold turn.** The first turn on a new account re-uploads context once (prompt cache is org-scoped).
|
|
124
|
+
- **Depleted-pause hiccup.** Plain swaps never restart the session. Only when the whole pool is at its limit does `claude` stop for the countdown; anything typed in that split second is lost.
|
|
125
|
+
- **Adoption lag.** On macOS the first turn within ~30s of a swap can still meter the old account; the bars' headroom absorbs it.
|
|
126
|
+
- **Shared blast radius.** All default-profile sessions share one live credential, so a swap moves them all together (each adopts in place). A `flock` + re-check keeps racing hooks from burning two accounts.
|
|
126
127
|
- **Keychain ACL (macOS).** `init`/`add` touch the keychain interactively so the first `security` access isn't cold inside a headless hook.
|
|
127
128
|
- **Plaintext credentials (Linux).** Claude Code itself stores Linux credentials as a 0600 plaintext file; tokenmaxxing's parked copies follow the same model.
|
|
128
129
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// `tokenmaxxing config` - inspect, edit, and housekeep config.json (user ask
|
|
2
|
+
// 2026-07-16). Operates on the SPARSE file: config.json holds only overrides
|
|
3
|
+
// and loadConfig merges defaults at read time, so baking defaults into the
|
|
4
|
+
// file would freeze future default changes. `tidy` is the housekeeper: it
|
|
5
|
+
// drops keys the schema no longer knows (e.g. the pre-0.7 flat `threshold`)
|
|
6
|
+
// and normalizes switchModels casing; get/set/unset only ever report them.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { isPlainObject } from "es-toolkit";
|
|
10
|
+
import { get, set, unset } from "es-toolkit/compat";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
13
|
+
import { ConfigFileSchema, loadConfig } from "../lib/state.ts";
|
|
14
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
15
|
+
import { c } from "./render.ts";
|
|
16
|
+
|
|
17
|
+
/** Hand-maintained mirror of ConfigFileSchema's dotted keys; an invariant test
|
|
18
|
+
* (test/config.test.ts) pins the two together so a new schema field cannot
|
|
19
|
+
* silently become invisible to get/set/tidy. */
|
|
20
|
+
export const KNOWN_KEYS = [
|
|
21
|
+
"thresholds.session",
|
|
22
|
+
"thresholds.weekly",
|
|
23
|
+
"claudeBin",
|
|
24
|
+
"codexBin",
|
|
25
|
+
"policy.projectionMargin",
|
|
26
|
+
"policy.greedySessionFloor",
|
|
27
|
+
"policy.switchModels",
|
|
28
|
+
"policy.usagePollTtlMs",
|
|
29
|
+
"policy.maxWaitMs",
|
|
30
|
+
] as const;
|
|
31
|
+
|
|
32
|
+
const RawFileSchema = z.record(z.string(), z.unknown());
|
|
33
|
+
|
|
34
|
+
function readRawFile(): Record<string, unknown> {
|
|
35
|
+
if (!existsSync(paths.configJson)) return {};
|
|
36
|
+
return RawFileSchema.parse(JSON.parse(readFileSync(paths.configJson, "utf8")));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeRawFile(input: { raw: Record<string, unknown> }): void {
|
|
40
|
+
writeFileAtomic(paths.configJson, JSON.stringify(input.raw, null, 2) + "\n");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Dotted keys in `obj` (two levels: this config nests exactly once). */
|
|
44
|
+
function dottedKeys(obj: Record<string, unknown>): string[] {
|
|
45
|
+
const keys: string[] = [];
|
|
46
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
47
|
+
if (isPlainObject(value)) {
|
|
48
|
+
for (const nested of Object.keys(value)) keys.push(`${key}.${nested}`);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
keys.push(key);
|
|
52
|
+
}
|
|
53
|
+
return keys;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unknownFileKeys(raw: Record<string, unknown>): string[] {
|
|
57
|
+
const known = new Set<string>(KNOWN_KEYS);
|
|
58
|
+
return dottedKeys(raw).filter((key) => !known.has(key));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function envSourceFor(key: string): string | null {
|
|
62
|
+
if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
|
|
63
|
+
if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function printEffective(): number {
|
|
68
|
+
const effective = loadConfig();
|
|
69
|
+
const raw = readRawFile();
|
|
70
|
+
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
71
|
+
for (const key of KNOWN_KEYS) {
|
|
72
|
+
const env = envSourceFor(key);
|
|
73
|
+
const source = env ? c.yellow(`env ${env}`) : get(raw, key) !== undefined ? c.green("file") : c.dim("default");
|
|
74
|
+
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${source}`);
|
|
75
|
+
}
|
|
76
|
+
const unknown = unknownFileKeys(raw);
|
|
77
|
+
if (unknown.length > 0) {
|
|
78
|
+
console.log();
|
|
79
|
+
console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
|
|
80
|
+
console.log(c.dim("run `tokenmaxxing config tidy` to drop them"));
|
|
81
|
+
}
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function cmdGet(key: string): number {
|
|
86
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
87
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
88
|
+
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
89
|
+
return 1;
|
|
90
|
+
}
|
|
91
|
+
console.log(JSON.stringify(get(loadConfig(), key)));
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** JSON when it parses (numbers, booleans, arrays), else the literal string. */
|
|
96
|
+
function parseValue(text: string): unknown {
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(text);
|
|
99
|
+
} catch {
|
|
100
|
+
return text;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function cmdSet(key: string, valueText: string): number {
|
|
105
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
106
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
107
|
+
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
108
|
+
return 1;
|
|
109
|
+
}
|
|
110
|
+
const raw = readRawFile();
|
|
111
|
+
const next = structuredClone(raw);
|
|
112
|
+
set(next, key, parseValue(valueText));
|
|
113
|
+
const validated = ConfigFileSchema.safeParse(next);
|
|
114
|
+
if (!validated.success) {
|
|
115
|
+
console.error(c.red(`rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`));
|
|
116
|
+
return 1;
|
|
117
|
+
}
|
|
118
|
+
writeRawFile({ raw: next });
|
|
119
|
+
// Report the FILE-level change: with an env override in place, the effective
|
|
120
|
+
// value would not move, and an unchanged-looking arrow would misrepresent
|
|
121
|
+
// the write that just happened.
|
|
122
|
+
const beforeFile = get(raw, key);
|
|
123
|
+
console.log(
|
|
124
|
+
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
125
|
+
);
|
|
126
|
+
const env = envSourceFor(key);
|
|
127
|
+
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Remove parents emptied by a deletion or a strip: the sparse file must not
|
|
132
|
+
* accumulate `{}` husks. */
|
|
133
|
+
function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
134
|
+
for (const [topKey, value] of Object.entries(raw)) {
|
|
135
|
+
if (isPlainObject(value) && Object.keys(value).length === 0) {
|
|
136
|
+
delete raw[topKey];
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function cmdUnset(key: string): number {
|
|
142
|
+
if (!KNOWN_KEYS.some((known) => known === key)) {
|
|
143
|
+
console.error(c.red(`unknown config key: ${key}`));
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
const raw = readRawFile();
|
|
147
|
+
if (get(raw, key) === undefined) {
|
|
148
|
+
console.log(c.dim(`${key} has no file override (default already applies)`));
|
|
149
|
+
return 0;
|
|
150
|
+
}
|
|
151
|
+
const next = structuredClone(raw);
|
|
152
|
+
unset(next, key);
|
|
153
|
+
pruneEmptyParents(next);
|
|
154
|
+
writeRawFile({ raw: next });
|
|
155
|
+
console.log(`${key} unset -> ${JSON.stringify(get(loadConfig(), key))} (default)`);
|
|
156
|
+
return 0;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function cmdTidy(): number {
|
|
160
|
+
const raw = readRawFile();
|
|
161
|
+
const dropped = unknownFileKeys(raw);
|
|
162
|
+
// ConfigFileSchema strips unknown keys at both levels; switchModels casing
|
|
163
|
+
// normalizes to what the loader would use anyway.
|
|
164
|
+
const parsed = ConfigFileSchema.parse(raw);
|
|
165
|
+
const next = RawFileSchema.parse(JSON.parse(JSON.stringify(parsed)));
|
|
166
|
+
const models = get(next, "policy.switchModels");
|
|
167
|
+
const normalized = Array.isArray(models) ? models.map((model) => String(model).toLowerCase()) : null;
|
|
168
|
+
const casingChanged = normalized != null && JSON.stringify(normalized) !== JSON.stringify(models);
|
|
169
|
+
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
170
|
+
pruneEmptyParents(next);
|
|
171
|
+
|
|
172
|
+
// Honest housekeeping: say exactly what changed, write only when something did.
|
|
173
|
+
if (JSON.stringify(next) === JSON.stringify(raw)) {
|
|
174
|
+
console.log(c.dim("nothing to tidy"));
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
writeRawFile({ raw: next });
|
|
178
|
+
if (dropped.length > 0) console.log(`dropped unknown keys: ${dropped.join(", ")}`);
|
|
179
|
+
if (casingChanged) console.log("normalized switchModels casing");
|
|
180
|
+
if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function cmdConfig(args: string[]): number {
|
|
185
|
+
const [sub, key, value] = args;
|
|
186
|
+
try {
|
|
187
|
+
if (sub === undefined) return printEffective();
|
|
188
|
+
if (sub === "get" && key !== undefined) return cmdGet(key);
|
|
189
|
+
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value);
|
|
190
|
+
if (sub === "unset" && key !== undefined) return cmdUnset(key);
|
|
191
|
+
if (sub === "tidy") return cmdTidy();
|
|
192
|
+
} catch (e) {
|
|
193
|
+
// A corrupt config.json fails fast with a recovery step, not a stack trace.
|
|
194
|
+
console.error(c.red(`config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`));
|
|
195
|
+
console.error(c.dim(`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`));
|
|
196
|
+
return 1;
|
|
197
|
+
}
|
|
198
|
+
console.error(c.red("usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]"));
|
|
199
|
+
return 2;
|
|
200
|
+
}
|
package/src/entries/stophook.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Stop hook. Fires when claude finishes a turn (transcript already committed).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
2
|
+
// A plain swap needs no respawn: the running session adopts the swapped
|
|
3
|
+
// credential on its own (<=30s on macOS, next request on Linux). Only a
|
|
4
|
+
// depleted-pool wait - when running under the supervisor - drops a respawn
|
|
5
|
+
// marker keyed by this session id: the supervisor SIGTERMs its child at this
|
|
6
|
+
// clean boundary, counts down to the reset, then relaunches `--resume`. We
|
|
7
|
+
// never kill claude ourselves.
|
|
6
8
|
|
|
7
9
|
import { join } from "node:path";
|
|
8
10
|
import { z } from "zod";
|
|
@@ -34,10 +36,11 @@ export async function runStopHook(): Promise<number> {
|
|
|
34
36
|
// will actually pause the session until the reset.
|
|
35
37
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId != null;
|
|
36
38
|
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
37
|
-
// Respawn on a swap, or on a depleted-pool wait (relaunch after the reset).
|
|
38
39
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
39
40
|
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
40
|
-
|
|
41
|
+
// Respawn only for a depleted-pool wait: pausing until the reset requires
|
|
42
|
+
// killing the child. A plain swap leaves the session running to adopt.
|
|
43
|
+
if (decision.waitUntil !== undefined && process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
|
|
41
44
|
const marker = join(paths.respawnDir, sessionId);
|
|
42
45
|
const payload = RespawnMarkerSchema.parse({ account: decision.account.label, ts: Date.now(), waitUntil: decision.waitUntil });
|
|
43
46
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// The `claude` supervisor. Invoked in place of claude (via ~/.config/tokenmaxxing/
|
|
2
2
|
// bin/claude on PATH). Runs the REAL claude with inherited stdio (claude owns the
|
|
3
3
|
// real terminal exactly as if run directly), pins a session id, and watches for a
|
|
4
|
-
// respawn marker dropped by the Stop
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// only - it never
|
|
4
|
+
// respawn marker dropped by the Stop hook on a depleted-pool wait (plain swaps
|
|
5
|
+
// adopt in place and never respawn). When the marker appears it SIGTERMs its own
|
|
6
|
+
// child at the (already-committed) turn boundary, counts down to the reset, and
|
|
7
|
+
// relaunches `claude --resume <id>`. Process/terminal manager only - it never
|
|
8
|
+
// reads or proxies tokens.
|
|
8
9
|
|
|
9
10
|
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
10
11
|
import { join } from "node:path";
|
|
@@ -213,7 +214,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
|
|
|
213
214
|
const m = RespawnMarkerSchema.parse(await Bun.file(marker).json());
|
|
214
215
|
rmSync(marker, { force: true });
|
|
215
216
|
respawns++;
|
|
216
|
-
if (m.waitUntil
|
|
217
|
+
if (m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
|
|
217
218
|
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
|
|
218
219
|
launchArgs = ["--resume", sid, ...base];
|
|
219
220
|
continue;
|
package/src/lib/state.ts
CHANGED
|
@@ -31,8 +31,9 @@ const DEFAULT_CONFIG: Config = {
|
|
|
31
31
|
policy: { projectionMargin: 0, greedySessionFloor: 50, switchModels: ["fable"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
|
-
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
35
|
-
|
|
34
|
+
/** On-disk shape (all optional); validated via Zod, merged over defaults.
|
|
35
|
+
* Exported for `xx config`, which edits and housekeeps the sparse file. */
|
|
36
|
+
export const ConfigFileSchema = z
|
|
36
37
|
.object({
|
|
37
38
|
thresholds: z.object({ session: z.number(), weekly: z.number() }).partial(),
|
|
38
39
|
claudeBin: z.string(),
|
package/src/lib/types.ts
CHANGED
|
@@ -141,12 +141,13 @@ export const ConfigSchema = z.object({
|
|
|
141
141
|
});
|
|
142
142
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
143
143
|
|
|
144
|
-
/** The hook -> supervisor respawn marker at respawn/<session-id>.
|
|
144
|
+
/** The hook -> supervisor respawn marker at respawn/<session-id>. Written only
|
|
145
|
+
* for a depleted-pool wait (plain swaps adopt in place, no respawn). */
|
|
145
146
|
export const RespawnMarkerSchema = z.object({
|
|
146
147
|
account: z.string(),
|
|
147
148
|
ts: z.number(),
|
|
148
|
-
/**
|
|
149
|
-
waitUntil: z.number()
|
|
149
|
+
/** the supervisor waits until this epoch ms before relaunching. */
|
|
150
|
+
waitUntil: z.number(),
|
|
150
151
|
});
|
|
151
152
|
export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
|
|
152
153
|
|
package/src/main.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { cmdRm } from "./cli/rm.ts";
|
|
|
23
23
|
import { cmdRename } from "./cli/rename.ts";
|
|
24
24
|
import { cmdSwitch } from "./cli/switch.ts";
|
|
25
25
|
import { cmdCheck } from "./cli/check.ts";
|
|
26
|
+
import { cmdConfig } from "./cli/config.ts";
|
|
26
27
|
import { uninstallSupervisor } from "./lib/install.ts";
|
|
27
28
|
import { c } from "./cli/render.ts";
|
|
28
29
|
|
|
@@ -41,6 +42,7 @@ function printHelp(): void {
|
|
|
41
42
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
42
43
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
43
44
|
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
45
|
+
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
44
46
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
45
47
|
${c.cyan("tokenmaxxing rename")} <sel> <label>
|
|
46
48
|
${c.cyan("tokenmaxxing rm")} <sel>
|
|
@@ -75,6 +77,7 @@ async function main(): Promise<number> {
|
|
|
75
77
|
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
76
78
|
case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
|
|
77
79
|
case "check": return cmdCheck();
|
|
80
|
+
case "config": return cmdConfig(args.slice(1));
|
|
78
81
|
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
79
82
|
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
80
83
|
case "ls": return cmdLs();
|