tokenmaxxing 1.10.0 → 1.12.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 +4 -4
- package/README.md +6 -5
- package/agent-plugin/plugin.json +1 -1
- package/agent-plugin/skills/pool-status/SKILL.md +2 -2
- package/agent-plugin/skills/pool-status/references/commands.md +1 -0
- package/agent-plugin/skills/switching-policy/SKILL.md +2 -2
- package/agent-plugin/skills/switching-policy/references/policy.md +1 -1
- package/package.json +1 -1
- package/src/cli/check.ts +17 -4
- package/src/cli/codexrm.ts +8 -7
- package/src/cli/codexswitch.ts +48 -18
- package/src/cli/config.ts +86 -42
- package/src/cli/doctor.ts +25 -9
- package/src/cli/ls.ts +37 -2
- package/src/cli/rename.ts +17 -14
- package/src/cli/render.ts +20 -0
- package/src/cli/rm.ts +12 -8
- package/src/cli/status.ts +239 -110
- package/src/cli/switch.ts +64 -28
- package/src/cli/watch.ts +13 -7
- package/src/entries/mcp.ts +1 -1
- package/src/lib/state.ts +2 -2
- package/src/main.ts +44 -22
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 (the active rung of the 5h session ladder, **
|
|
3
|
+
Automatic Claude Code account switching. You run `claude` exactly as always; when the active account crosses its swap threshold (the active rung of the 5h session ladder, **90** by default, or **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 when it 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, decision engine, and supervisor; its 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; whe
|
|
|
12
12
|
|
|
13
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 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
|
|
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 screens on a ladder, a single rung at 90 by default; a configured ladder like 50 then 80 then 95 has each rung take over once every pooled account is past the one below (a 5h reset is cheap to sit out, so a multi-rung pool drains level by level), 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 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
|
|
|
@@ -49,7 +49,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
|
|
|
49
49
|
The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage.json` (write-on-change, O(ms)) and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). Cold-start fallback if `usage.json` is absent: `TOKENMAXXING_PROBE=1 claude -p '/usage'`, with `[ -n "$TOKENMAXXING_PROBE" ] && exit 0` as the hook's first line to stop the nested process recursing (hooks fire in `-p` too). The probe scrubs every ambient credential override claude reads before the keychain (`CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_SECURESTORAGE_CONFIG_DIR`, etc.) so it can only meter the credential in the keychain item, and retries the transient empty-footer case (claude prints local stats with no percentages when its own usage fetch throttles).
|
|
50
50
|
|
|
51
51
|
### 3.2 Detect + swap + signal (Stop hook, per turn)
|
|
52
|
-
1. Read `usage.json`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default
|
|
52
|
+
1. Read `usage.json`; `exit 0` fast below the engagement floor (`policy.greedySessionFloor`, default 80% of the 5h window) unless a screening bar is already crossed. Engaged-but-under-every-bar runs the GREEDY convergence: stay when the current account wins or ties, else swap onto the strictly better account. A crossed bar forces the hard path (metered per `organizationUuid`).
|
|
53
53
|
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).
|
|
54
54
|
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).
|
|
55
55
|
|
|
@@ -79,7 +79,7 @@ Each terminal ran the supervisor, so each has its own child `claude` and its own
|
|
|
79
79
|
---
|
|
80
80
|
|
|
81
81
|
## 5. Rotation policy
|
|
82
|
-
The decision engages at `five_hour >=
|
|
82
|
+
The decision engages at `five_hour >= 80%` (policy.greedySessionFloor): from there it greedily converges on the usable account furthest behind its weekly pace, staying put whenever the current account wins or ties. The **Layer 1 screening bars** - `five_hour >= <active rung>` (the 5h ladder `thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account still clears, the current one included) OR `seven_day >= 98%`, per org (`thresholds`) - force a switch onto a fresher account and also screen candidates. "Exhausted" is a **timestamped state** (`resets_at`), not a flag - an account is a candidate again after it resets. Optional projected threshold (`bar - policy.projectionMargin`, a fixed configured margin) so a single large turn is less likely to blow past 100% before the next Stop hook.
|
|
83
83
|
|
|
84
84
|
**Two layers - pump the last drops.** The screening bars deliberately leave headroom, so when *every* account is over them Layer 1 alone would park the pool with 2-5% of each account's quota still unspent. **Layer 2 - the wall bars** (`hardThresholds`, default `100/100`, the server's own limit) - is the fallback reached only at that point: the session **holds its seat and squeezes** while it is under the wall, else swaps onto the best still-under-wall account (the same pace-pressure ranking as every other swap - squeeze the account whose weekly quota is most about to be forfeited first), and only parks (depleted-wait) once every account has truly walled. Recovery is then measured against the wall, not the screening bar, so an account whose 5h window drops below 100 is squeezable again even while its weekly window still sits above the Layer 1 bar. The wall reading is the statusLine's own `rate_limits` feed - the same server-side figure claude's `/rate-limit-options` renders - so when an account genuinely maxes out the tee shows 100 and Layer 2 moves on; a single-turn overshoot is caught one boundary later (the periodic `check` timer, or the next Stop hook) without needing to sniff assistant text. Set `hardThresholds` equal to `thresholds` to disable Layer 2. **Layer 2 is Claude-only:** a swap on Claude is a hot, in-place credential adoption every concurrent session follows automatically, whereas a running Codex refuses another account's credential (restart is the switch), so a last-drop-swap there would strand any sibling still on the walled account - Codex instead keeps riding its current account to the wall (its existing all-exhausted stay-put already squeezes it).
|
|
85
85
|
|
package/README.md
CHANGED
|
@@ -76,15 +76,16 @@ claude # use claude as always
|
|
|
76
76
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
77
77
|
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
|
78
78
|
| `tokenmaxxing uninstall` | remove supervisor + settings entries (accounts/credentials kept) |
|
|
79
|
+
| `--json` | machine-readable output: one JSON document on stdout for `status`, `ls`, `config`, `doctor`, `check`, `switch`, `rename`, `rm`, `uninstall`, and one per tick for `watch` (`ok` mirrors the exit code, failures add `error`) |
|
|
79
80
|
|
|
80
81
|
## How switching decides
|
|
81
82
|
|
|
82
|
-
Switching engages (configurable) once the active account's 5-hour session window is **
|
|
83
|
+
Switching engages (configurable) once the active account's 5-hour session window is **80% used** - from there, every evaluation greedily converges on the usable account **furthest behind its own weekly pace**, and does nothing when the current account already wins. Independent of that, crossing a screening bar always forces a switch: the session bar is the active rung of a ladder (a single rung at **90** by default; with more rungs configured, each takes over once every pooled account is past the one below) and the weekly bar is **98%**. The bars also screen candidates on any of:
|
|
83
84
|
|
|
84
85
|
- **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine.
|
|
85
86
|
- **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.
|
|
86
87
|
|
|
87
|
-
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 ladder tops out lower (
|
|
88
|
+
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 ladder tops out lower (90) because a 5-hour reset is cheap to sit out, and any lower rungs you add keep the pool draining level by level; weekly quota is use-it-or-lose-it, so it drains closer to the wall (98). The greedy engagement floor (80) sits below both: weekly allowance is forfeited at each account's fixed reset, so once most of a session window is spent, quota is best burned on whichever account has the most at risk.
|
|
88
89
|
|
|
89
90
|
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.
|
|
90
91
|
|
|
@@ -94,17 +95,17 @@ The **target** is chosen greedily off each account's cached windows: among usabl
|
|
|
94
95
|
|
|
95
96
|
```json
|
|
96
97
|
{
|
|
97
|
-
"thresholds": { "session": [
|
|
98
|
+
"thresholds": { "session": [90], "weekly": 98 },
|
|
98
99
|
"policy": {
|
|
99
100
|
"projectionMargin": 0,
|
|
100
|
-
"greedySessionFloor":
|
|
101
|
+
"greedySessionFloor": 80,
|
|
101
102
|
"switchModels": ["fable"],
|
|
102
103
|
"usagePollTtlMs": 90000
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
106
|
```
|
|
106
107
|
|
|
107
|
-
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers).
|
|
108
|
+
`thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers).
|
|
108
109
|
|
|
109
110
|
State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
|
|
110
111
|
|
package/agent-plugin/plugin.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
|
3
3
|
"name": "tokenmaxxing",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.12.0",
|
|
5
5
|
"description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "anaclumos",
|
|
@@ -10,7 +10,7 @@ description: Read the Claude and Codex account pool safely (list, usage bars, wa
|
|
|
10
10
|
- `pool_ls` for labels, active marker, needs-reauth
|
|
11
11
|
- `pool_status` for 5h / weekly / per-model bars (free `/usage` path)
|
|
12
12
|
|
|
13
|
-
If MCP is unavailable, run `tokenmaxxing ls` or `tokenmaxxing status` (alias `xx`). Never add `--force`.
|
|
13
|
+
If MCP is unavailable, run `tokenmaxxing ls --json` or `tokenmaxxing status --json` (alias `xx`) and parse the document. Never add `--force`.
|
|
14
14
|
|
|
15
15
|
## Hard stops
|
|
16
16
|
|
|
@@ -22,6 +22,6 @@ If MCP is unavailable, run `tokenmaxxing ls` or `tokenmaxxing status` (alias `xx
|
|
|
22
22
|
|
|
23
23
|
- Active Claude usage often comes from the statusLine tee; parked accounts are probed in isolation.
|
|
24
24
|
- `watch` re-renders status on an interval and never force-pings.
|
|
25
|
-
- Hermetic agents: set `TOKENMAXXING_HOME` to a throwaway directory.
|
|
25
|
+
- Hermetic agents: set `TOKENMAXXING_HOME` to a throwaway directory. That isolates state files only: `init`, `uninstall`, and the codex hook install still write settings.json, codex `hooks.json`, the shell rc, and the timer unit under `HOME`, so never run them from an agent.
|
|
26
26
|
|
|
27
27
|
See [references/commands.md](references/commands.md).
|
|
@@ -4,5 +4,6 @@
|
|
|
4
4
|
- `tokenmaxxing status --force`: DENIED for agents without explicit user approval. Meters every account.
|
|
5
5
|
- `tokenmaxxing ls`: compact list.
|
|
6
6
|
- `tokenmaxxing watch [seconds]`: live re-render (default 120), never `--force`.
|
|
7
|
+
- `--json` on any of these: one JSON document on stdout (`ok` mirrors the exit code); parse it instead of scraping the bars. `watch --json` prints one document per tick and never exits on its own.
|
|
7
8
|
|
|
8
9
|
Docs: `docs/content/docs/commands.mdx`.
|
|
@@ -7,11 +7,11 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
|
|
|
7
7
|
|
|
8
8
|
## Vocabulary
|
|
9
9
|
|
|
10
|
-
- **Engaged**: session used >= `policy.greedySessionFloor` (default
|
|
10
|
+
- **Engaged**: session used >= `policy.greedySessionFloor` (default 80) or any screening bar crossed.
|
|
11
11
|
- **GREEDY path**: engaged but under every bar. Rank all accounts by pace pressure; keep seat on best-or-tie (`currentWins`); else swap to strictly better. Never depleted-waits or pre-parks.
|
|
12
12
|
- **HARD path**: a screening bar crossed. Swap to best usable target; if none, Layer 2 wall logic (Claude only).
|
|
13
13
|
- **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
|
|
14
|
-
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default
|
|
14
|
+
- **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed.
|
|
15
15
|
|
|
16
16
|
## Layer 2 (Claude only)
|
|
17
17
|
|
|
@@ -4,4 +4,4 @@
|
|
|
4
4
|
- `src/lib/decide.ts`, `src/lib/picker.ts`
|
|
5
5
|
- `.memory/switch-policy-pace-pressure.md`
|
|
6
6
|
|
|
7
|
-
Default screening bars: session ladder [50, 80, 95] with the active rung resolved per pool, weekly 98. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check cadence ceiling per rung: 300s, 180s, 120s, then the 60s floor.
|
|
7
|
+
Default screening bars: session ladder [90] (a single rung; add lower rungs such as [50, 80, 95] to hand off earlier) with the active rung resolved per pool, weekly 98. Engagement floor 80. Wall defaults 100. Cooldown 45s on the automatic path after a swap. Check cadence ceiling per rung: 300s, 180s, 120s, then the 60s floor.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/check.ts
CHANGED
|
@@ -2,15 +2,16 @@ import { CHECK_DELAY_FLOOR_MS, checkDelayMs, evaluateAndMaybeSwap } from "../lib
|
|
|
2
2
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
3
3
|
import { log } from "../lib/log.ts";
|
|
4
4
|
import { loadConfig, loadNextCheckDueAt, saveNextCheckDueAt } from "../lib/state.ts";
|
|
5
|
-
import { c, fmtReset } from "./render.ts";
|
|
5
|
+
import { c, emitError, emitJson, fmtReset } from "./render.ts";
|
|
6
6
|
|
|
7
7
|
const TICK_SLACK_MS = CHECK_DELAY_FLOOR_MS / 2;
|
|
8
8
|
|
|
9
|
-
export async function cmdCheck(args: string[] = []): Promise<number> {
|
|
9
|
+
export async function cmdCheck(args: string[] = [], json = false): Promise<number> {
|
|
10
10
|
const now = Date.now();
|
|
11
11
|
const dueAt = args.includes("--if-due") ? loadNextCheckDueAt(now) : null;
|
|
12
12
|
if (dueAt != null && now + TICK_SLACK_MS < dueAt) {
|
|
13
|
-
|
|
13
|
+
if (json) emitJson({ ok: true, due: false, nextCheckAt: dueAt });
|
|
14
|
+
else console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
|
|
14
15
|
return 0;
|
|
15
16
|
}
|
|
16
17
|
let d;
|
|
@@ -19,11 +20,23 @@ export async function cmdCheck(args: string[] = []): Promise<number> {
|
|
|
19
20
|
} catch (e) {
|
|
20
21
|
const detail = e instanceof Error ? e.message : String(e);
|
|
21
22
|
log("check.error", { err: detail });
|
|
22
|
-
|
|
23
|
+
emitError({ json, message: `check failed: ${detail}` });
|
|
23
24
|
return 1;
|
|
24
25
|
}
|
|
25
26
|
const delayMs = checkDelayMs({ cfg: loadConfig(), org: readOAuthAccount()?.organizationUuid ?? null, now, decision: d });
|
|
26
27
|
saveNextCheckDueAt({ dueAt: now + delayMs, ts: now });
|
|
28
|
+
if (json) {
|
|
29
|
+
emitJson({
|
|
30
|
+
ok: true,
|
|
31
|
+
due: true,
|
|
32
|
+
swapped: d.swapped,
|
|
33
|
+
account: d.account?.label ?? null,
|
|
34
|
+
reason: d.reason,
|
|
35
|
+
waitUntil: d.waitUntil ?? null,
|
|
36
|
+
nextCheckAt: now + delayMs,
|
|
37
|
+
});
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
27
40
|
const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
|
|
28
41
|
if (d.swapped && d.account) {
|
|
29
42
|
console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
|
package/src/cli/codexrm.ts
CHANGED
|
@@ -5,32 +5,33 @@ import { deleteParkedCodexAuth } from "../lib/codexauth.ts";
|
|
|
5
5
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
6
6
|
import { presentCodexAccountIds } from "../lib/codexpresence.ts";
|
|
7
7
|
import { findCodexAccount } from "./rename.ts";
|
|
8
|
-
import { c } from "./render.ts";
|
|
8
|
+
import { c, emitError, emitJson, plain } from "./render.ts";
|
|
9
9
|
|
|
10
|
-
export async function cmdCodexRm(selector?: string): Promise<number> {
|
|
10
|
+
export async function cmdCodexRm(selector?: string, json = false): Promise<number> {
|
|
11
11
|
if (!selector) {
|
|
12
|
-
|
|
12
|
+
emitError({ json, message: "usage: tokenmaxxing rm --codex <email|label|id>", paint: plain });
|
|
13
13
|
return 2;
|
|
14
14
|
}
|
|
15
15
|
return withLock(codexPaths.lockFile, async () => {
|
|
16
16
|
const index = loadCodexAccounts();
|
|
17
17
|
const account = findCodexAccount(index.accounts, selector);
|
|
18
18
|
if (!account) {
|
|
19
|
-
|
|
19
|
+
emitError({ json, message: `no codex account matches "${selector}"` });
|
|
20
20
|
return 1;
|
|
21
21
|
}
|
|
22
22
|
if (liveCodexAccountId() === account.accountId) {
|
|
23
|
-
|
|
23
|
+
emitError({ json, message: `${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.` });
|
|
24
24
|
return 1;
|
|
25
25
|
}
|
|
26
26
|
if (presentCodexAccountIds().has(account.accountId)) {
|
|
27
|
-
|
|
27
|
+
emitError({ json, message: `${account.label} is running in a live codex session - close that session before removing it.` });
|
|
28
28
|
return 1;
|
|
29
29
|
}
|
|
30
30
|
deleteParkedCodexAuth({ credFile: account.credFile });
|
|
31
31
|
index.accounts = index.accounts.filter((x) => x.accountId !== account.accountId);
|
|
32
32
|
saveCodexAccounts({ index });
|
|
33
|
-
|
|
33
|
+
if (json) emitJson({ ok: true, pool: "codex", removed: account.label, remaining: index.accounts.length });
|
|
34
|
+
else console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
|
|
34
35
|
return 0;
|
|
35
36
|
});
|
|
36
37
|
}
|
package/src/cli/codexswitch.ts
CHANGED
|
@@ -8,9 +8,20 @@ import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
|
|
|
8
8
|
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
9
9
|
import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
|
|
10
10
|
import { terminalBars } from "../lib/picker.ts";
|
|
11
|
-
import { c } from "./render.ts";
|
|
11
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
12
12
|
|
|
13
|
-
export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
13
|
+
export async function cmdCodexSwitch(sel?: string, json = false): Promise<number> {
|
|
14
|
+
const deadGrants: string[] = [];
|
|
15
|
+
const withDeadGrants = (report: Record<string, unknown>) => (deadGrants.length > 0 ? { ...report, deadGrants } : report);
|
|
16
|
+
const emit = (text: string, report: Record<string, unknown>): void => {
|
|
17
|
+
if (json) emitJson({ ok: true, ...withDeadGrants(report) });
|
|
18
|
+
else console.log(text);
|
|
19
|
+
};
|
|
20
|
+
const fail = (message: string, opts: { paint?: (s: string) => string; notes?: string[]; extra?: Record<string, unknown> } = {}): number => {
|
|
21
|
+
emitError({ json, message, paint: opts.paint, notes: opts.notes, extra: withDeadGrants(opts.extra ?? {}) });
|
|
22
|
+
return 1;
|
|
23
|
+
};
|
|
24
|
+
const deadGrantMessage = (label: string) => `${label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``;
|
|
14
25
|
const cfg = loadConfig();
|
|
15
26
|
const bars = terminalBars(cfg);
|
|
16
27
|
const now = Date.now();
|
|
@@ -18,7 +29,8 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
18
29
|
return withLock(codexPaths.lockFile, async () => {
|
|
19
30
|
const index = loadCodexAccounts();
|
|
20
31
|
if (index.accounts.length === 0) {
|
|
21
|
-
|
|
32
|
+
if (json) emitJson({ ok: false, error: "no codex accounts yet - run `tokenmaxxing init --codex`" });
|
|
33
|
+
else console.log(c.dim("no codex accounts yet - run `tokenmaxxing init --codex`"));
|
|
22
34
|
return 1;
|
|
23
35
|
}
|
|
24
36
|
const currentId = liveCodexAccountId();
|
|
@@ -28,32 +40,35 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
28
40
|
(account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
|
|
29
41
|
);
|
|
30
42
|
if (!target) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
return fail(`no codex account matches "${sel}"`, {
|
|
44
|
+
notes: index.accounts.map((account) => ` ${account.label} (${account.accountId.slice(0, 8)})`),
|
|
45
|
+
extra: { accounts: index.accounts.map((account) => account.label) },
|
|
46
|
+
});
|
|
34
47
|
}
|
|
35
48
|
if (target.accountId === currentId) {
|
|
36
|
-
|
|
49
|
+
emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
|
|
37
50
|
return 0;
|
|
38
51
|
}
|
|
39
52
|
if (presentCodexAccountIds().has(target.accountId)) {
|
|
40
|
-
|
|
41
|
-
return 1;
|
|
53
|
+
return fail(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`);
|
|
42
54
|
}
|
|
43
55
|
if (target.needsReauth) {
|
|
44
|
-
|
|
45
|
-
return 1;
|
|
56
|
+
return fail(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``);
|
|
46
57
|
}
|
|
47
58
|
try {
|
|
48
59
|
await performCodexSwap({ target });
|
|
49
60
|
} catch (e) {
|
|
50
61
|
if (e instanceof CodexInvalidGrantError) {
|
|
51
|
-
|
|
52
|
-
return
|
|
62
|
+
deadGrants.push(target.label);
|
|
63
|
+
return fail(deadGrantMessage(target.label));
|
|
53
64
|
}
|
|
54
65
|
throw e;
|
|
55
66
|
}
|
|
56
|
-
|
|
67
|
+
emit(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`, {
|
|
68
|
+
switched: true,
|
|
69
|
+
account: target.label,
|
|
70
|
+
reason: "selected",
|
|
71
|
+
});
|
|
57
72
|
return 0;
|
|
58
73
|
}
|
|
59
74
|
|
|
@@ -62,21 +77,36 @@ export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
|
62
77
|
const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: currentId });
|
|
63
78
|
const active = candidates.find((account) => account.accountId === currentId) ?? null;
|
|
64
79
|
if (codexCurrentWins({ active, accounts: candidates, thresholds: bars, now })) {
|
|
65
|
-
|
|
80
|
+
emit(`already on the best codex account: ${c.bold(active?.label ?? "?")}`, {
|
|
81
|
+
switched: false,
|
|
82
|
+
account: active?.label ?? null,
|
|
83
|
+
reason: "current-wins",
|
|
84
|
+
});
|
|
66
85
|
return 0;
|
|
67
86
|
}
|
|
68
87
|
const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: currentId });
|
|
69
88
|
if (!best) {
|
|
70
|
-
|
|
89
|
+
const message = "no usable codex switch target (all at their bars, unmeasured, or needing reauth)";
|
|
90
|
+
const reauthNeeded = current.accounts.filter((account) => account.needsReauth).map((account) => account.label);
|
|
91
|
+
if (json) return fail(message, { extra: { reauthNeeded } });
|
|
92
|
+
console.log(c.yellow(message));
|
|
71
93
|
return 1;
|
|
72
94
|
}
|
|
73
95
|
try {
|
|
74
96
|
await performCodexSwap({ target: best });
|
|
75
97
|
} catch (e) {
|
|
76
|
-
if (e instanceof CodexInvalidGrantError)
|
|
98
|
+
if (e instanceof CodexInvalidGrantError) {
|
|
99
|
+
deadGrants.push(best.label);
|
|
100
|
+
if (!json) console.error(c.red(deadGrantMessage(best.label)));
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
77
103
|
throw e;
|
|
78
104
|
}
|
|
79
|
-
|
|
105
|
+
emit(`${c.green("✓")} switched codex to ${c.bold(best.label)} (takes effect on the next codex start)`, {
|
|
106
|
+
switched: true,
|
|
107
|
+
account: best.label,
|
|
108
|
+
reason: "best",
|
|
109
|
+
});
|
|
80
110
|
return 0;
|
|
81
111
|
}
|
|
82
112
|
});
|
package/src/cli/config.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { z } from "zod";
|
|
|
5
5
|
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "../lib/paths.ts";
|
|
6
6
|
import { ConfigFileSchema, loadConfig, mergeConfigFile } from "../lib/state.ts";
|
|
7
7
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
8
|
-
import { c } from "./render.ts";
|
|
8
|
+
import { c, emitError, emitJson } from "./render.ts";
|
|
9
9
|
|
|
10
10
|
export const KNOWN_KEYS = [
|
|
11
11
|
"thresholds.session",
|
|
@@ -49,22 +49,53 @@ function unknownFileKeys(raw: Record<string, unknown>): string[] {
|
|
|
49
49
|
return dottedKeys(raw).filter((key) => !known.has(key));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
function isKnownKey(key: string): boolean {
|
|
53
|
+
return KNOWN_KEYS.some((known) => known === key);
|
|
54
|
+
}
|
|
55
|
+
|
|
52
56
|
function envSourceFor(key: string): string | null {
|
|
53
57
|
if (key === "claudeBin" && realClaudeBinFromEnv()) return "TOKENMAXXING_CLAUDE_BIN";
|
|
54
58
|
if (key === "codexBin" && realCodexBinFromEnv()) return "TOKENMAXXING_CODEX_BIN";
|
|
55
59
|
return null;
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
function
|
|
62
|
+
function sourceOf(input: { key: string; raw: Record<string, unknown> }): { source: "env" | "file" | "default"; env: string | null } {
|
|
63
|
+
const env = envSourceFor(input.key);
|
|
64
|
+
if (env) return { source: "env", env };
|
|
65
|
+
return { source: get(input.raw, input.key) !== undefined ? "file" : "default", env: null };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function unknownKey(input: { key: string; json: boolean }): number {
|
|
69
|
+
emitError({
|
|
70
|
+
json: input.json,
|
|
71
|
+
message: `unknown config key: ${input.key}`,
|
|
72
|
+
notes: [`known keys: ${KNOWN_KEYS.join(", ")}`],
|
|
73
|
+
extra: { knownKeys: KNOWN_KEYS },
|
|
74
|
+
});
|
|
75
|
+
return 1;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function printEffective(json: boolean): number {
|
|
59
79
|
const effective = loadConfig();
|
|
60
80
|
const raw = readRawFile();
|
|
81
|
+
const unknown = unknownFileKeys(raw);
|
|
82
|
+
if (json) {
|
|
83
|
+
const sources: Record<string, string> = {};
|
|
84
|
+
const envOverrides: Record<string, string> = {};
|
|
85
|
+
for (const key of KNOWN_KEYS) {
|
|
86
|
+
const { source, env } = sourceOf({ key, raw });
|
|
87
|
+
sources[key] = source;
|
|
88
|
+
if (env) envOverrides[key] = env;
|
|
89
|
+
}
|
|
90
|
+
emitJson({ ok: true, path: paths.configJson, config: effective, sources, envOverrides, unknownKeys: unknown });
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
61
93
|
console.log(c.dim(`config.json: ${paths.configJson}`));
|
|
62
94
|
for (const key of KNOWN_KEYS) {
|
|
63
|
-
const env =
|
|
64
|
-
const
|
|
65
|
-
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${
|
|
95
|
+
const { source, env } = sourceOf({ key, raw });
|
|
96
|
+
const painted = source === "env" ? c.yellow(`env ${env}`) : source === "file" ? c.green("file") : c.dim("default");
|
|
97
|
+
console.log(` ${key.padEnd(28)} ${JSON.stringify(get(effective, key))} ${painted}`);
|
|
66
98
|
}
|
|
67
|
-
const unknown = unknownFileKeys(raw);
|
|
68
99
|
if (unknown.length > 0) {
|
|
69
100
|
console.log();
|
|
70
101
|
console.log(c.yellow(`unknown keys in the file (ignored by the loader): ${unknown.join(", ")}`));
|
|
@@ -73,13 +104,14 @@ function printEffective(): number {
|
|
|
73
104
|
return 0;
|
|
74
105
|
}
|
|
75
106
|
|
|
76
|
-
function cmdGet(key: string): number {
|
|
77
|
-
if (!
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
107
|
+
function cmdGet(key: string, json: boolean): number {
|
|
108
|
+
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
109
|
+
const value = get(loadConfig(), key);
|
|
110
|
+
if (json) {
|
|
111
|
+
emitJson({ ok: true, key, value, source: sourceOf({ key, raw: readRawFile() }).source });
|
|
112
|
+
return 0;
|
|
81
113
|
}
|
|
82
|
-
console.log(JSON.stringify(
|
|
114
|
+
console.log(JSON.stringify(value));
|
|
83
115
|
return 0;
|
|
84
116
|
}
|
|
85
117
|
|
|
@@ -91,31 +123,31 @@ function parseValue(text: string): unknown {
|
|
|
91
123
|
}
|
|
92
124
|
}
|
|
93
125
|
|
|
94
|
-
function cmdSet(key: string, valueText: string): number {
|
|
95
|
-
if (!
|
|
96
|
-
console.error(c.red(`unknown config key: ${key}`));
|
|
97
|
-
console.error(c.dim(`known keys: ${KNOWN_KEYS.join(", ")}`));
|
|
98
|
-
return 1;
|
|
99
|
-
}
|
|
126
|
+
function cmdSet(key: string, valueText: string, json: boolean): number {
|
|
127
|
+
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
100
128
|
const raw = readRawFile();
|
|
101
129
|
const next = structuredClone(raw);
|
|
102
130
|
set(next, key, parseValue(valueText));
|
|
103
131
|
const validated = ConfigFileSchema.safeParse(next);
|
|
104
132
|
if (!validated.success) {
|
|
105
|
-
|
|
133
|
+
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
106
134
|
return 1;
|
|
107
135
|
}
|
|
108
136
|
const mergedCheck = mergeConfigFile(validated.data);
|
|
109
137
|
if (!mergedCheck.ok) {
|
|
110
|
-
|
|
138
|
+
emitError({ json, message: `rejected: ${mergedCheck.detail}` });
|
|
111
139
|
return 1;
|
|
112
140
|
}
|
|
113
141
|
writeRawFile({ raw: next });
|
|
114
142
|
const beforeFile = get(raw, key);
|
|
143
|
+
const env = envSourceFor(key);
|
|
144
|
+
if (json) {
|
|
145
|
+
emitJson({ ok: true, key, before: beforeFile === undefined ? null : beforeFile, after: get(next, key), envOverride: env });
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
115
148
|
console.log(
|
|
116
149
|
`${key}: ${beforeFile === undefined ? "(default)" : JSON.stringify(beforeFile)} -> ${JSON.stringify(get(next, key))}`,
|
|
117
150
|
);
|
|
118
|
-
const env = envSourceFor(key);
|
|
119
151
|
if (env) console.log(c.yellow(`note: ${env} is set and overrides the file value in this environment`));
|
|
120
152
|
return 0;
|
|
121
153
|
}
|
|
@@ -128,14 +160,12 @@ function pruneEmptyParents(raw: Record<string, unknown>): void {
|
|
|
128
160
|
}
|
|
129
161
|
}
|
|
130
162
|
|
|
131
|
-
function cmdUnset(key: string): number {
|
|
132
|
-
if (!
|
|
133
|
-
console.error(c.red(`unknown config key: ${key}`));
|
|
134
|
-
return 1;
|
|
135
|
-
}
|
|
163
|
+
function cmdUnset(key: string, json: boolean): number {
|
|
164
|
+
if (!isKnownKey(key)) return unknownKey({ key, json });
|
|
136
165
|
const raw = readRawFile();
|
|
137
166
|
if (get(raw, key) === undefined) {
|
|
138
|
-
|
|
167
|
+
if (json) emitJson({ ok: true, key, unset: false, value: get(loadConfig(), key) });
|
|
168
|
+
else console.log(c.dim(`${key} has no file override (default already applies)`));
|
|
139
169
|
return 0;
|
|
140
170
|
}
|
|
141
171
|
const next = structuredClone(raw);
|
|
@@ -143,20 +173,25 @@ function cmdUnset(key: string): number {
|
|
|
143
173
|
pruneEmptyParents(next);
|
|
144
174
|
const validated = ConfigFileSchema.safeParse(next);
|
|
145
175
|
if (!validated.success) {
|
|
146
|
-
|
|
176
|
+
emitError({ json, message: `rejected: ${validated.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}` });
|
|
147
177
|
return 1;
|
|
148
178
|
}
|
|
149
179
|
const mergedCheck = mergeConfigFile(validated.data);
|
|
150
180
|
if (!mergedCheck.ok) {
|
|
151
|
-
|
|
181
|
+
emitError({ json, message: `rejected: ${mergedCheck.detail} (adjust the conflicting override before unsetting ${key})` });
|
|
152
182
|
return 1;
|
|
153
183
|
}
|
|
154
184
|
writeRawFile({ raw: next });
|
|
155
|
-
|
|
185
|
+
const value = get(loadConfig(), key);
|
|
186
|
+
if (json) {
|
|
187
|
+
emitJson({ ok: true, key, unset: true, value });
|
|
188
|
+
return 0;
|
|
189
|
+
}
|
|
190
|
+
console.log(`${key} unset -> ${JSON.stringify(value)} (default)`);
|
|
156
191
|
return 0;
|
|
157
192
|
}
|
|
158
193
|
|
|
159
|
-
function cmdTidy(): number {
|
|
194
|
+
function cmdTidy(json: boolean): number {
|
|
160
195
|
const raw = readRawFile();
|
|
161
196
|
const dropped = unknownFileKeys(raw);
|
|
162
197
|
const parsed = ConfigFileSchema.parse(raw);
|
|
@@ -167,30 +202,39 @@ function cmdTidy(): number {
|
|
|
167
202
|
if (normalized != null) set(next, "policy.switchModels", normalized);
|
|
168
203
|
pruneEmptyParents(next);
|
|
169
204
|
|
|
170
|
-
|
|
205
|
+
const changed = JSON.stringify(next) !== JSON.stringify(raw);
|
|
206
|
+
if (changed) writeRawFile({ raw: next });
|
|
207
|
+
if (json) {
|
|
208
|
+
emitJson({ ok: true, changed, droppedKeys: dropped, casingNormalized: casingChanged });
|
|
209
|
+
return 0;
|
|
210
|
+
}
|
|
211
|
+
if (!changed) {
|
|
171
212
|
console.log(c.dim("nothing to tidy"));
|
|
172
213
|
return 0;
|
|
173
214
|
}
|
|
174
|
-
writeRawFile({ raw: next });
|
|
175
215
|
if (dropped.length > 0) console.log(`dropped unknown keys: ${dropped.join(", ")}`);
|
|
176
216
|
if (casingChanged) console.log("normalized switchModels casing");
|
|
177
217
|
if (dropped.length === 0 && !casingChanged) console.log("canonicalized file layout (pruned empty sections / key order)");
|
|
178
218
|
return 0;
|
|
179
219
|
}
|
|
180
220
|
|
|
181
|
-
export function cmdConfig(args: string[]): number {
|
|
221
|
+
export function cmdConfig(args: string[], json = false): number {
|
|
182
222
|
const [sub, key, value] = args;
|
|
183
223
|
try {
|
|
184
|
-
if (sub === undefined) return printEffective();
|
|
185
|
-
if (sub === "get" && key !== undefined) return cmdGet(key);
|
|
186
|
-
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value);
|
|
187
|
-
if (sub === "unset" && key !== undefined) return cmdUnset(key);
|
|
188
|
-
if (sub === "tidy") return cmdTidy();
|
|
224
|
+
if (sub === undefined) return printEffective(json);
|
|
225
|
+
if (sub === "get" && key !== undefined) return cmdGet(key, json);
|
|
226
|
+
if (sub === "set" && key !== undefined && value !== undefined) return cmdSet(key, value, json);
|
|
227
|
+
if (sub === "unset" && key !== undefined) return cmdUnset(key, json);
|
|
228
|
+
if (sub === "tidy") return cmdTidy(json);
|
|
189
229
|
} catch (e) {
|
|
190
|
-
|
|
191
|
-
|
|
230
|
+
emitError({
|
|
231
|
+
json,
|
|
232
|
+
message: `config.json is unreadable: ${e instanceof Error ? e.message : String(e)}`,
|
|
233
|
+
notes: [`fix or delete ${paths.configJson} (defaults apply when it is absent), then re-run`],
|
|
234
|
+
extra: { path: paths.configJson },
|
|
235
|
+
});
|
|
192
236
|
return 1;
|
|
193
237
|
}
|
|
194
|
-
|
|
238
|
+
emitError({ json, message: "usage: tokenmaxxing config [get <key> | set <key> <value> | unset <key> | tidy]" });
|
|
195
239
|
return 2;
|
|
196
240
|
}
|