tokenmaxxing 1.9.1 → 1.11.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 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).
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, **50, then 80, then 95** as the pool fills, 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 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 climbs a ladder, 50 then 80 then 95, each rung taking over once every pooled account is past the one below (a 5h reset is cheap to sit out, so the 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
 
@@ -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 >= 50%` (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 >= 95%` 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.
82
+ The decision engages at `five_hour >= 50%` (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 50/80/95: 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
@@ -33,12 +33,11 @@ tokenmaxxing init
33
33
  nix-darwin:
34
34
 
35
35
  ```nix
36
- # flake inputs: tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
36
+ inputs.tokenmaxxing.url = "github:anaclumos/tokenmaxxing";
37
37
  modules = [
38
38
  inputs.tokenmaxxing.darwinModules.withOverlay
39
39
  { programs.tokenmaxxing.enable = true; }
40
40
  ];
41
- # then: tokenmaxxing init
42
41
  ```
43
42
 
44
43
  Home Manager:
@@ -47,9 +46,10 @@ Home Manager:
47
46
  imports = [ inputs.tokenmaxxing.homeManagerModules.default ];
48
47
  programs.tokenmaxxing.enable = true;
49
48
  programs.tokenmaxxing.package = inputs.tokenmaxxing.packages.${pkgs.system}.default;
50
- # then: tokenmaxxing init
51
49
  ```
52
50
 
51
+ With either module, run `tokenmaxxing init` afterwards.
52
+
53
53
  `init` imports the account you're already on, installs the `claude` supervisor + five `settings.json` entries (the tokenmaxxing statusLine, a subagentStatusLine, a Stop hook, a StopFailure hook, a SessionStart hook), and adds the supervisor's bin dir to PATH in your shell rc (idempotent; it must sit ahead of the real `claude` to intercept it). Restart your shell, then add more accounts and go:
54
54
 
55
55
  ```sh
@@ -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 **50% 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 hard screening bar - **95%** session or **98%** weekly - always forces a switch. The bars also screen candidates on any of:
83
+ Switching engages (configurable) once the active account's 5-hour session window is **50% 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 (**50**, then **80**, then **95**, each rung taking 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 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.
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 (95) because a 5-hour reset is cheap to sit out, and its lower rungs 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 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.
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,7 +95,7 @@ The **target** is chosen greedily off each account's cached windows: among usabl
94
95
 
95
96
  ```json
96
97
  {
97
- "thresholds": { "session": 95, "weekly": 98 },
98
+ "thresholds": { "session": [50, 80, 95], "weekly": 98 },
98
99
  "policy": {
99
100
  "projectionMargin": 0,
100
101
  "greedySessionFloor": 50,
@@ -104,7 +105,7 @@ The **target** is chosen greedily off each account's cached windows: among usabl
104
105
  }
105
106
  ```
106
107
 
107
- `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 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
 
@@ -116,19 +117,20 @@ For agents you build on the [Claude Agent SDK](https://docs.claude.com/en/api/ag
116
117
  import { query } from "@anthropic-ai/claude-agent-sdk";
117
118
  import { ensureBestAccount, pooledOptions, stopHookCheck } from "tokenmaxxing";
118
119
 
119
- await ensureBestAccount(); // run the switch decision before the spawn (swaps once it engages - see below)
120
+ await ensureBestAccount();
120
121
 
121
122
  for await (const message of query({
122
123
  prompt: "...",
123
124
  options: {
124
- ...pooledOptions(), // pinned real claude + scrubbed env -> the pooled live credential
125
- hooks: { Stop: [{ hooks: [stopHookCheck] }] }, // re-decide at every turn boundary
125
+ ...pooledOptions(),
126
+ hooks: { Stop: [{ hooks: [stopHookCheck] }] },
126
127
  },
127
128
  })) {
128
- // capture the session id from the init message if you want `resume` across swaps
129
129
  }
130
130
  ```
131
131
 
132
+ The loop body receives the message stream; keep the session id from the init message if you want `resume` across swaps.
133
+
132
134
  The SDK reads credentials when it spawns the claude subprocess and has no statusLine, so none of the CLI-side supervisor machinery applies; the integration is boundary-driven instead. `ensureBestAccount()` runs the exact greedy decision the CLI hooks and timer run (screening bars, pace-pressure target, post-swap cooldown - all shared code); like them, it deliberately does nothing until the decision engages (the active session past `policy.greedySessionFloor`, or a bar crossed), so a fresh account rides instead of churning. `pooledOptions()` pins `pathToClaudeCodeExecutable` to the real claude binary and supplies a full replacement `env` with every ambient credential override (`ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, ...) scrubbed, so the subprocess resolves the pool's live credential and nothing else. The pooled surface requires the default Claude Code credential store: it fails fast if `CLAUDE_CONFIG_DIR` or `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set in your app's environment, because a swap would write the live credential where those point while the spawned subprocess reads the default store. `stopHookCheck` re-runs the decision at turn boundaries; a swap it lands takes effect on the next subprocess spawn (it never yanks a mid-query token). If your app loads user settings (see the SDK's `settingSources`), the Stop hook `tokenmaxxing init` installed may already fire in SDK sessions too - `stopHookCheck` makes the check explicit and works when settings are restricted.
133
135
 
134
136
  This is for pooling **your own** subscription accounts in agents you run yourself - the same personal-use posture as the CLI. Anthropic does not allow third-party products to offer claude.ai login or rate limits, including agents built on the Agent SDK; don't ship this surface to third parties.
@@ -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.9.1",
4
+ "version": "1.11.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`.
@@ -1,15 +1,14 @@
1
1
  ---
2
2
  name: safe-contribution
3
- description: Safely change and ship tokenmaxxing (tests, PR window, Mac vs Linux skew, public-repo hygiene). Use when editing this repo, opening PRs, or verifying decision-path changes.
3
+ description: Safely change and ship tokenmaxxing (verification without a test suite, PR window, Mac vs Linux skew, public-repo hygiene). Use when editing this repo, opening PRs, or verifying decision-path changes.
4
4
  ---
5
5
 
6
6
  # Safe contribution
7
7
 
8
8
  ## Verify
9
9
 
10
- - Default suite: `bun test`.
11
- - After decision-path changes, also run `bun test/e2e/swap-concurrency.ts` by hand (not part of `bun test`).
12
- - Hermetic CLI: `TOKENMAXXING_HOME=/tmp/xx-test bun run src/main.ts ...`.
10
+ - The repo carries no test code and no comments (owner ruling 2026-09-02). Never add tests, a test script, or comments.
11
+ - Verify with `bun run typecheck`, then hermetic CLI runs: `TOKENMAXXING_HOME=/tmp/xx-test bun run src/main.ts ...` (for decision-path changes, write accounts.json, usage.json, config.json, and a claude.json under that root and run `check`; point `TOKENMAXXING_KEYCHAIN_SERVICE` and `TOKENMAXXING_KEYCHAIN_ACCOUNT` at throwaway names and `TOKENMAXXING_CLAUDE_JSON` at that claude.json, because the CLI resolves claude.json from HOME, not from `TOKENMAXXING_HOME`).
13
12
  - Ask before any run that meters real quota (`status --force`, live-pool inference). Free `/usage` / plain status is fine.
14
13
 
15
14
  ## Ship
@@ -19,10 +19,11 @@ for await (const message of query({
19
19
  hooks: { Stop: [{ hooks: [stopHookCheck] }] },
20
20
  },
21
21
  })) {
22
- // capture session id from init for resume across swaps
23
22
  }
24
23
  ```
25
24
 
25
+ The loop body receives the message stream; keep the session id from the init message for `resume` across swaps.
26
+
26
27
  ## Rules
27
28
 
28
29
  - Call `ensureBestAccount()` before each `query()` spawn (no mid-query hot-swap).
@@ -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 50) or any hard/screening bar crossed.
10
+ - **Engaged**: session used >= `policy.greedySessionFloor` (default 50) 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)` = thresholds minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong.
14
+ - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default 50/80/95: 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
 
@@ -1,7 +1,7 @@
1
1
  # Policy sources
2
2
 
3
3
  - `docs/content/docs/switching.mdx`
4
- - `src/lib/decide.ts`, `src/lib/picker.ts` (inline rationale)
4
+ - `src/lib/decide.ts`, `src/lib/picker.ts`
5
5
  - `.memory/switch-policy-pace-pressure.md`
6
6
 
7
- Default screening bars: session 95, weekly 98. Wall defaults 100. Cooldown 45s on the automatic path after a swap.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.9.1",
3
+ "version": "1.11.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",
@@ -32,9 +32,8 @@
32
32
  ],
33
33
  "scripts": {
34
34
  "dev": "bun run src/main.ts",
35
- "test": "bun test",
36
35
  "typecheck": "tsc --noEmit",
37
- "prepublishOnly": "bun run typecheck && bun run test",
36
+ "prepublishOnly": "bun run typecheck",
38
37
  "nix:bun": "bunx bun2nix@2.1.2 -o bun.nix"
39
38
  },
40
39
  "devDependencies": {
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
- console.log(c.dim(`not due (${Math.ceil((dueAt - now) / 1000)}s)`));
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
- console.error(c.red(`check failed: ${detail}`));
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}`);
@@ -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
- console.error("usage: tokenmaxxing rm --codex <email|label|id>");
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
- console.error(c.red(`no codex account matches "${selector}"`));
19
+ emitError({ json, message: `no codex account matches "${selector}"` });
20
20
  return 1;
21
21
  }
22
22
  if (liveCodexAccountId() === account.accountId) {
23
- console.error(c.red(`${account.label} is the LIVE codex account - run \`tokenmaxxing switch --codex\` to move off it first.`));
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
- console.error(c.red(`${account.label} is running in a live codex session - close that session before removing it.`));
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
- console.log(`removed codex account ${c.bold(account.label)} from the pool (${index.accounts.length} left)`);
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
  }
@@ -7,18 +7,30 @@ import { performCodexSwap } from "../lib/codexswap.ts";
7
7
  import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
8
8
  import { liveCodexAccountId } from "../lib/codexsample.ts";
9
9
  import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
10
- import { effectiveBars } from "../lib/picker.ts";
11
- import { c } from "./render.ts";
10
+ import { terminalBars } from "../lib/picker.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
- const bars = effectiveBars(cfg);
26
+ const bars = terminalBars(cfg);
16
27
  const now = Date.now();
17
28
 
18
29
  return withLock(codexPaths.lockFile, async () => {
19
30
  const index = loadCodexAccounts();
20
31
  if (index.accounts.length === 0) {
21
- console.log(c.dim("no codex accounts yet - run `tokenmaxxing init --codex`"));
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
- console.error(c.red(`no codex account matches "${sel}"`));
32
- for (const account of index.accounts) console.error(c.dim(` ${account.label} (${account.accountId.slice(0, 8)})`));
33
- return 1;
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
- console.log(`already on ${c.bold(target.label)}`);
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
- console.error(c.red(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`));
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
- console.error(c.red(`${target.label} needs re-auth - run \`codex login\` in an isolated home and \`tokenmaxxing add --codex\``));
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
- console.error(c.red(`${target.label}'s refresh token is dead - re-add it with \`tokenmaxxing add --codex\``));
52
- return 1;
62
+ deadGrants.push(target.label);
63
+ return fail(deadGrantMessage(target.label));
53
64
  }
54
65
  throw e;
55
66
  }
56
- console.log(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`);
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
- console.log(`already on the best codex account: ${c.bold(active?.label ?? "?")}`);
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
- console.log(c.yellow("no usable codex switch target (all at their bars, unmeasured, or needing reauth)"));
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) continue;
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
- console.log(`${c.green("✓")} switched codex to ${c.bold(best.label)} (takes effect on the next codex start)`);
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
  });