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 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 and - at the next safe turn boundary - **restarts your session resumed on it, automatically**. Works across many concurrent sessions at once.
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. What a swap alone cannot give you is a *clean cutover*: a mid-flight 429 retry keeps its already-snapshotted token, and an account already at the wall still needs the session paused until something resets.
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 to **replace 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 - killing it there loses nothing, and a `claude --resume <session-id>` comes back cold on the new account and continues exactly where it left off. **This is why we swap below 100%: the headroom is the budget to reach a clean turn boundary and respawn 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).
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 respawn - when `claude` exits, the shell owns the terminal. So tokenmaxxing installs a **supervisor** (aliased to `claude`) that owns the process lifecycle:
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. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
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 Respawn (supervisor)
55
- The supervisor's `claude` call returns; it sees `respawn/<sid>`, deletes it, resets the terminal, prints `↻ switched to <account>`, and relaunches `claude --resume <sid>`. The resumed process reads the keychain cold → runs on the new account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
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`, its own `--session-id`, and its own respawn marker. When the shared account hits a threshold, the first Stop hook to win the `flock` performs the one swap; **every** session's Stop hook writes its own respawn marker and SIGTERMs its own `claude`; **every** supervisor independently relaunches `claude --resume <its-own-sid>`. All of them come back on the new account, each continuing its own conversation. (They share one credential, so they always move together - consistent with "one current account, many windows.")
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.** At the swap turn you see `claude` restart (~1–2s) and resume. Anything you typed in the split second before respawn is lost - mitigate by respawning fast and showing a clear "switching" state; the supervisor resets terminal mode so nothing is left garbled.
87
- - **One cold turn.** Prompt cache is org-scoped: the first turn after resuming on B re-uploads context once (bigger on long transcripts).
88
- - **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 respawn then still recovers it. Projected threshold reduces this.
89
- - **Shared blast radius.** All default-profile sessions share one keychain item, so a swap moves them all (each via its own respawn). The `flock` + re-check is mandatory or racing hooks burn two accounts at once.
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 crosses its usage limit, tokenmaxxing swaps to a fresh account and - at the next safe turn boundary - restarts your session *resumed on it*, automatically. Works across many concurrent sessions.
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
- tokenmaxxing: switched to work@acme.com - resuming...
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). tokenmaxxing performs the swap at a committed turn boundary and **respawns** `claude --resume <id>` (the transcript is already on disk, so nothing is lost) - the respawn is what gives you a clean cutover and, when the whole pool is depleted, a countdown that auto-resumes at the soonest reset. A thin `claude` supervisor on your PATH owns that respawn; everything else about `claude` is unchanged - all flags, MCP, hooks, and skills pass through.
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>` · `rm <sel>` | manage the pool |
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 and respawn 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.
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 after resuming on a new account re-uploads context once (prompt cache is org-scoped).
124
- - **Respawn hiccup.** At the swap you see `claude` restart (~1–2s); anything typed in that split second is lost.
125
- - **Shared blast radius.** All default-profile sessions share one live credential, so a swap moves them all together (each respawns its own session). A `flock` + re-check keeps racing hooks from burning two accounts.
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.13.1",
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
+ }
@@ -1,8 +1,10 @@
1
1
  // Stop hook. Fires when claude finishes a turn (transcript already committed).
2
- // If the active account crossed the threshold, swap the credential and - when
3
- // running under the supervisor - drop a respawn marker keyed by this session id.
4
- // The supervisor watches for the marker and SIGTERMs its child at this clean
5
- // boundary, then relaunches `--resume`. We never kill claude ourselves.
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
- if (process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
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/SessionStart hook. When the marker appears it
5
- // SIGTERMs its own child at the (already-committed) turn boundary and relaunches
6
- // `claude --resume <id>` on the freshly-swapped account. Process/terminal manager
7
- // only - it never reads or proxies tokens.
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 && m.waitUntil > Date.now()) await countdownWait(m.account, 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
- const ConfigFileSchema = z
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
- /** when set, the supervisor waits until this epoch ms before relaunching. */
149
- waitUntil: z.number().optional(),
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();