tokenmaxxing 1.44.0 → 1.46.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
@@ -31,8 +31,9 @@ It is a process manager only - spawn with inherited stdio plus saved `stty -g` t
31
31
  - Five `~/.claude/settings.json` entries (merged - other settings keys are preserved, but the `statusLine` slot is taken over): the tokenmaxxing `statusLine` renderer (native since 2026-07-11; it also tees usage), a `subagentStatusLine` (per-subagent rows in the agents panel), a `Stop` hook, a `StopFailure` hook, a `SessionStart` hook.
32
32
  - **`~/.config/tokenmaxxing/`** - the single home for config and state:
33
33
  - `config.json` - SPARSE overrides only (thresholds.session/weekly, claudeBin/codexBin pins, policy.*); defaults apply at read time, `xx config` prints the path and the effective values, and edits happen in an editor.
34
- - `accounts.json` - non-secret index `{id, label, email, tier, windows, lastUsageAt, lastProbeAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). `activeId` is `null` for Claude: there is no pool-wide active account.
34
+ - `accounts.json` - non-secret index `{id, label, email, tier, windows, lastUsageAt, lastProbeAt, enforcedUntil, needsReauth, oauthAccount}` (each cached window carries its name, duration, reset, and sample time; the same shape serves the Codex pool as `codex-accounts.json`). `activeId` is `null` for both pools: there is no pool-wide active account.
35
35
  - `stores/<uuid8>/` - one Claude Code credential store per pooled account (see below).
36
+ - `codex-stores/<uuid8>/` - one Codex credential store per pooled account: its `auth.json`, with everything else symlinked to the shared `~/.codex` so sessions, history, and config stay shared and `codex resume` works across stores.
36
37
  - `usage/<uuid8>.json` - the statusline tee per account, written by the sessions running on it.
37
38
  - `live/<session-id>` - PID-validated presence per supervised session, naming its account; placement counts them.
38
39
  - `respawn/<session-id>` - per-session respawn markers (the hook→supervisor signal): the target account plus a `waitUntil`.
@@ -81,10 +82,10 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
81
82
  ## 5. Placement and rotation policy
82
83
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, wait for the soonest reset. The same ranking places a session at launch.
83
84
 
84
- - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest weekly pace pressure (remaining weekly percent over time to the weekly reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex keeps the pure pace ordering and excludes accounts running in another codex session, because a codex session cannot share a login.
85
+ - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest weekly pace pressure (remaining weekly percent over time to the weekly reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex keeps the pure pace ordering and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
85
86
  - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples the account whose last `/usage` attempt is oldest, skipping any attempted within `policy.usagePollTtlMs`, under that account's store, so every account is attempted about once per tick per pooled account, or once per `policy.usagePollTtlMs` plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the `/usage` child.
86
87
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
87
- - The Claude pool has no post-swap cooldown and no manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick. Codex keeps both.
88
+ - Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
88
89
 
89
90
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
90
91
 
@@ -94,7 +95,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
94
95
 
95
96
  ## 6. Honest papercuts
96
97
  - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
97
- - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. Projected threshold reduces this.
98
+ - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
98
99
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
99
100
  - **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
100
101
  - **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
@@ -113,7 +114,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
113
114
 
114
115
  **Removed (1.37.0, issue #79):** the shared live credential and everything that served it: the swap-time harvest, park, and install, tokenmaxxing's own OAuth refresh grant and its interlock with Claude Code's refresh lock, the `~/.claude.json` `oauthAccount` rewrite, the 45-second post-swap cooldown, the pool-wide depleted-wait record, the parked-credential slots (`creds/`, `tokenmaxxing-cred-*`), the ambient `CLAUDE_CONFIG_DIR` refusal, and manual `switch` for the Claude pool.
115
116
 
116
- **Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, `codex-creds/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts remain candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one live `auth.json` for every codex session, usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`). Sibling sessions left on ANY non-live account are reconciled cross-session (owner decisions 2026-07-20): the deciding actor drops a reconcile marker addressed to the sibling's supervisor, and the sibling's own Stop hook promotes it into a respawn onto the live account at its next turn boundary - the only safe respawn point - while a blocked live seat never receives a signal.
117
+ **Shipped since (0.13.0):** Codex as a second pool with its own state (`codex-accounts.json`, per-account `codex-stores/`, own flock). Since 1.35.0 the engine is one: a generic account record (`id`, `label`, `email`, `tier`, `windows`), one picker, one decision, and one set of account commands run against a `Provider` (`src/lib/provider.ts`); `src/lib/claude.ts` and `src/lib/codex.ts` hold only what differs per product (credential store, identity, usage source, presence, login and install flows), and the provider's `seats` capability (`shared` for Claude, `live` for Codex) is how the engine knows whether present accounts stay candidates and whether a move needs the caller's respawn. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), one store per pooled account with `CODEX_HOME` set per session (everything but `auth.json` symlinked to the shared home, so resume works across stores), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (persisted into the account's own store the instant it returns, never refreshed for an account running in another session), and the codex Stop hook drives the auto-switch through a codex supervisor shim that respawns `codex resume <session-id>` under the target's store (hooks must be trusted once via `/hooks`).
117
118
 
118
119
  **Shipped since (1.29.0):** a Cursor Cloud relay. A cloud VM has no keychain, no pool state, and no supervisor, so the path uses what it does have (environment variables, a shell, repo files) and a different credential primitive, one-year inference-only setup tokens, minted once per pooled account by `tokenmaxxing setup-token` on the owner's machine and carried to the VM as one user-scoped Runtime Secret (`TOKENMAXXING_TOKENS`). `tokenmaxxing cursor init` writes a `claude` project subagent (`.cursor/agents/claude.md`) that forwards each task to `tokenmaxxing cloud run`, which spawns `claude -p --dangerously-skip-permissions --output-format json` under `CLAUDE_CODE_OAUTH_TOKEN` and pins the thread to that token (the API caches by exact prefix, so a rotation busts one thread, not all). A setup token carries no usage signal, so rotation is reactive. A run whose transcript gained a non-transient rate-limit row during that run (the structured row the StopFailure hook reads) walls the label until the reset the row names (five hours without one) and resumes the same session on the next token; the result's `api_error_status` alone never walls, so a transient 429 or an older row from a previous token does not cascade across the pool. The local switching engine never reads a setup token; `/usage` prints no percentages under one. Details in [Cursor Cloud](docs/content/docs/cursor-cloud.mdx).
119
120
 
package/README.md CHANGED
@@ -63,11 +63,10 @@ claude # use claude as always; each session gets its own accoun
63
63
  | command | what it does |
64
64
  |---|---|
65
65
  | `tokenmaxxing init` | log the first account in (isolated) + install supervisor & hooks |
66
- | `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
66
+ | `tokenmaxxing init --codex` | same for codex: log in the first account (isolated), install codex supervisor + Stop hook |
67
67
  | `tokenmaxxing add` | register an additional account (isolated login, harvested once into its own store) |
68
68
  | `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
69
69
  | `tokenmaxxing auth [--codex] [sel \| --all]` | reauthenticate a pooled account in place: bare lists the pool (emails shown) and asks which; a selector targets one account and tells you the email to sign in with; `--all` walks every account that is flagged or has no usable credential in its store, one by one; `--codex` does the same for the codex pool |
70
- | `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start); the claude pool has no manual switch, each session is placed at launch |
71
70
  | `tokenmaxxing status [--cached]` | accounts with 5h / weekly usage bars, live session counts, exhausted-until-reset; `--cached` renders the stored figures without sampling |
72
71
  | `tokenmaxxing config` | the config path and the effective values; edit the file in an editor, a bad value fails the next load with the field name |
73
72
  | `tokenmaxxing doctor` | verify the supervisor + settings entries survived and every store holds its account's credential |
@@ -76,7 +75,7 @@ claude # use claude as always; each session gets its own accoun
76
75
  | `tokenmaxxing setup-token [--print \| rm <label\|uuid>]` | Cursor Cloud only: mint one `claude setup-token` per pooled account (a browser sign-in each) and print the `TOKENMAXXING_TOKENS` secret value; `--print` prints the stored set, `rm` drops one |
77
76
  | `tokenmaxxing cursor init [dir]` | write the Claude relay subagent and `.cursor/environment.json` into a repo |
78
77
  | `tokenmaxxing cloud run [--session <id>] [--max-turns <n>] "<prompt>"` | on a Cursor Cloud VM: run `claude -p` on a setup token, rotate to the next token on a usage limit |
79
- | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, `check`, and `switch` (`ok` mirrors the exit code, failures add `error`) |
78
+ | `--json` | machine-readable output: one JSON document on stdout for `status`, `config`, and `check` (`ok` mirrors the exit code, failures add `error`) |
80
79
 
81
80
  ## How switching decides
82
81
 
@@ -102,7 +101,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
102
101
  {
103
102
  "thresholds": { "session": 90, "weekly": 98 },
104
103
  "policy": {
105
- "projectionMargin": 0,
104
+ "projectionMargin": 3,
106
105
  "switchModels": ["fable"],
107
106
  "usagePollTtlMs": 90000,
108
107
  "checkIntervalMs": 60000
@@ -110,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
110
109
  }
111
110
  ```
112
111
 
113
- `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `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); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
112
+ `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `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); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
114
113
 
115
114
  State lives entirely in `~/.config/tokenmaxxing/`. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
116
115
 
@@ -119,12 +118,12 @@ State lives entirely in `~/.config/tokenmaxxing/`. Each account's credential sto
119
118
  The same pooling works for OpenAI's Codex CLI (your own ChatGPT-subscription accounts):
120
119
 
121
120
  ```sh
122
- tokenmaxxing init --codex # import your current codex login + install the codex supervisor & Stop hook
121
+ tokenmaxxing init --codex # log in the first account, isolated + install the codex supervisor & Stop hook
123
122
  tokenmaxxing add --codex # log in another account, isolated - your primary login is untouched
124
123
  codex # use codex as always
125
124
  ```
126
125
 
127
- Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, and codex has no per-session credential store, so **a restart is the switch** and every codex session shares one live login. The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status` (and `status --cached`) shows both pools.
126
+ Codex mechanics differ from Claude Code in one hard way: a running codex process refuses a credential swapped to a different account, so **a restart is the switch**. Each codex session runs on its own account's store (`codex-stores/<uuid8>/`, only `auth.json` per account, everything else shared with `~/.codex` so resume works across stores). The installed Stop hook runs the same pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it moves, the supervisor relaunches `codex resume <session-id>` under the target's store with the transcript intact. `status` (and `status --cached`) shows both pools.
128
127
 
129
128
  Two codex-specific facts worth knowing: codex does not run hooks it has not been told to trust, so after `init --codex` you must open codex once and trust the tokenmaxxing Stop hook via `/hooks` (auto-switching is inert until then); and codex has no cross-process lock on `auth.json`, so tokenmaxxing serializes all of its own credential writes behind its own lock and swaps only at idle turn boundaries.
130
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.44.0",
3
+ "version": "1.46.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli/auth.ts CHANGED
@@ -100,7 +100,7 @@ async function reauthOne(p: Provider, target: Account): Promise<boolean> {
100
100
  const note = harvested.sample ? usageNote(result.account) : "";
101
101
  console.log(`${c.green("✓")} reauthed ${c.bold(result.account.email ?? result.account.label)} (${result.account.tier ?? "?"})${note}`);
102
102
  if (result.isActive) {
103
- console.log(c.dim("this account is the active one: the fresh credential is parked as its backup; the live session keeps its current token until the next swap."));
103
+ console.log(c.dim("this account has a running session: it keeps its current login until it respawns."));
104
104
  }
105
105
  return true;
106
106
  }
package/src/cli/doctor.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { verifyRealClaude } from "../lib/claudebin.ts";
3
3
  import { checkSettings, installedBin } from "../lib/settings.ts";
4
- import { checkTimerHealthy, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
5
- import { claudePool, paths } from "../lib/paths.ts";
4
+ import { checkTimerHealthy, codexStoreHookTrust, findClaudeShadowers, isBinDirAhead, shellRcPath, timerActivationHint } from "../lib/install.ts";
5
+ import { claudePool, codexPool, paths } from "../lib/paths.ts";
6
6
  import { loadAccounts, loadConfig } from "../lib/state.ts";
7
7
  import { readStore } from "../lib/credstore.ts";
8
+ import { codexIdentityOf, readCodexStoreAuth } from "../lib/codexauth.ts";
8
9
  import { isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity } from "../lib/oauth.ts";
9
10
  import { SETUP_TOKEN_STALE_MS, loadSetupTokens } from "../lib/setuptokens.ts";
10
11
  import { c, fmtAgo } from "./render.ts";
@@ -57,6 +58,36 @@ export async function cmdDoctor(): Promise<number> {
57
58
  if (a.needsReauth) check(false, `${a.label} needs re-auth`, `run \`tokenmaxxing auth ${a.label}\` to re-login`);
58
59
  }
59
60
 
61
+ const cidx = loadAccounts(codexPool);
62
+ if (cidx.accounts.length === 0) {
63
+ note("no codex accounts in the pool (run `tokenmaxxing init --codex` to pool codex too)");
64
+ }
65
+ for (const a of cidx.accounts) {
66
+ let auth = null;
67
+ let authErr: string | null = null;
68
+ try {
69
+ auth = readCodexStoreAuth(a.id);
70
+ } catch (e) {
71
+ authErr = (e instanceof Error ? e.message : String(e)).slice(0, 100);
72
+ }
73
+ check(auth != null, `codex store credential present for ${a.label}`, authErr ?? `run \`tokenmaxxing auth --codex ${a.label}\``);
74
+ if (auth) {
75
+ let match = false;
76
+ let matchHint = `run \`tokenmaxxing auth --codex ${a.label}\``;
77
+ try {
78
+ match = codexIdentityOf({ auth }).accountId === a.id;
79
+ if (!match) matchHint = `store credential belongs to another account - ${matchHint}`;
80
+ } catch (e) {
81
+ matchHint = (e instanceof Error ? e.message : String(e)).slice(0, 100);
82
+ }
83
+ check(match, `codex store credential identity matches ${a.label}`, matchHint);
84
+ }
85
+ if (a.needsReauth) check(false, `${a.label} (codex) needs re-auth`, `run \`tokenmaxxing auth --codex ${a.label}\` to re-login`);
86
+ const trust = codexStoreHookTrust(a.id);
87
+ if (trust === "untrusted") warn(`${a.label} (codex): Stop hook not trusted for this seat - open a supervised codex session on it, run /hooks, and trust it, or auto-switching stays inert there`);
88
+ else if (trust === "unknown") note(`${a.label} (codex): hook trust unknown (no hooks.json or config.toml yet - launch a supervised session once, then trust via /hooks)`);
89
+ }
90
+
60
91
  if (existsSync(paths.setupTokensJson)) {
61
92
  const setupTokens = loadSetupTokens().tokens;
62
93
  for (const a of idx.accounts) {
package/src/cli/init.ts CHANGED
@@ -15,7 +15,6 @@ export function printUsage(p: Provider): void {
15
15
  }
16
16
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
17
17
  console.log(` ${c.cyan(`xx add${p.flag}`)} log in and pool another account`);
18
- console.log(` ${c.cyan(`xx switch${p.flag}`)} hop to the best account right now (the automatic switching needs no command)`);
19
18
  console.log(` ${c.cyan("xx help")} everything else`);
20
19
  }
21
20
 
@@ -27,9 +26,7 @@ export async function cmdInit(p: Provider): Promise<number> {
27
26
  const existing = loadAccounts(p.pool);
28
27
  if (existing.accounts.length > 0) {
29
28
  p.install();
30
- const active = existing.accounts.find((a) => a.id === existing.activeId);
31
29
  console.log(`${c.green("✓")} re-installed (pool already has ${count({ n: existing.accounts.length, noun: "account" })} - not re-importing)`);
32
- if (active && p.seats === "live") console.log(` active: ${c.bold(active.label)}`);
33
30
  printUsage(p);
34
31
  return 0;
35
32
  }
@@ -41,7 +38,7 @@ export async function cmdInit(p: Provider): Promise<number> {
41
38
  await harvested.park();
42
39
  const idx = loadAccounts(p.pool);
43
40
  const imported = upsertAccount(idx, harvested, p.mergeWindows);
44
- idx.activeId = !p.statusOnly && p.seats === "live" ? harvested.id : null;
41
+ idx.activeId = null;
45
42
  saveAccounts(p.pool, idx);
46
43
  return imported;
47
44
  });
@@ -1,93 +1,26 @@
1
1
  import { join } from "node:path";
2
- import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import { mkdirSync } from "node:fs";
3
3
  import { z } from "zod";
4
- import { codexPaths, codexPool } from "../lib/paths.ts";
5
- import { withLock } from "../lib/lock.ts";
4
+ import { codexPaths } from "../lib/paths.ts";
6
5
  import { writeFileAtomic } from "../lib/atomic.ts";
7
6
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
8
- import { codex, codexPickCtx, reconcileSiblings } from "../lib/codex.ts";
9
- import { liveCodexAccountId } from "../lib/codexauth.ts";
7
+ import { codex, codexPickCtx } from "../lib/codex.ts";
10
8
  import { resolveRealCodex } from "../lib/codexbin.ts";
11
9
  import { compactCodexThread } from "../lib/compact.ts";
12
10
  import { evaluateAndMaybeSwap } from "../lib/decide.ts";
13
11
  import { isExhausted } from "../lib/picker.ts";
14
- import { livingPresences } from "../lib/presence.ts";
15
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadLastSwapAt } from "../lib/state.ts";
12
+ import { loadAccounts, loadConfig } from "../lib/state.ts";
16
13
  import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
17
- import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema, JsonTextSchema } from "../lib/types.ts";
14
+ import { CodexRespawnMarkerSchema, CodexStopStdinSchema, JsonTextSchema } from "../lib/types.ts";
18
15
  import { log } from "../lib/log.ts";
19
16
 
20
17
  const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
21
18
 
22
- async function promoteReconcile(input: { supervisorId: string; sessionId: string | null }): Promise<boolean> {
23
- const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
24
- if (!existsSync(markerPath)) return false;
25
- return withLock(codexPool.lockFile, async () => promoteReconcileLocked(input));
26
- }
27
-
28
- function promoteReconcileLocked(input: { supervisorId: string; sessionId: string | null }): boolean {
29
- const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
30
- if (!existsSync(markerPath)) return false;
31
- const parsed = CodexReconcileMarkerSchema.safeParse((() => {
32
- try {
33
- return JSON.parse(readFileSync(markerPath, "utf8"));
34
- } catch {
35
- return null;
36
- }
37
- })());
38
- if (!parsed.success) {
39
- rmSync(markerPath, { force: true });
40
- log("codexstop.reconcile_unparsable", {});
41
- return false;
42
- }
43
- const presence = livingPresences(codexPaths.presenceDir).find((p) => p.id === input.supervisorId) ?? null;
44
- if (presence == null || presence.accountId !== parsed.data.accountId) {
45
- rmSync(markerPath, { force: true });
46
- log("codexstop.reconcile_stale", {});
47
- return false;
48
- }
49
- const liveId = liveCodexAccountId();
50
- if (liveId == null || liveId === presence.accountId) {
51
- rmSync(markerPath, { force: true });
52
- log("codexstop.reconcile_moot", {});
53
- return false;
54
- }
55
- const now = Date.now();
56
- const liveAccount = loadAccounts(codexPool).accounts.find((a) => a.id === liveId);
57
- if (!liveAccount || liveAccount.needsReauth === true || isExhausted(liveAccount, codexPickCtx(now, liveId))) {
58
- rmSync(markerPath, { force: true });
59
- log("codexstop.reconcile_blocked_target", {});
60
- return false;
61
- }
62
- if (input.sessionId == null || input.sessionId.trim() === "") {
63
- log("codexstop.reconcile_no_session", {});
64
- return false;
65
- }
66
- mkdirSync(codexPaths.respawnDir, { recursive: true });
67
- writeFileAtomic(
68
- join(codexPaths.respawnDir, input.supervisorId),
69
- JSON.stringify(CodexRespawnMarkerSchema.parse({ account: liveAccount.label, sessionId: input.sessionId, ts: now })),
70
- );
71
- rmSync(markerPath, { force: true });
72
- log("codexstop.reconcile_respawn", { supervisorId: input.supervisorId.slice(0, 8), account: liveId.slice(0, 8) });
73
- return true;
74
- }
75
-
76
- async function sweepSiblings(now: number): Promise<void> {
77
- try {
78
- await withLock(codexPool.lockFile, () => reconcileSiblings(now));
79
- } catch (e) {
80
- log("codexstop.resweep_failed", { err: e instanceof Error ? e.message : String(e) });
81
- }
82
- }
83
-
84
19
  async function compactBeforeMove(input: { sessionId: string | null; now: number }): Promise<void> {
85
20
  const { sessionId, now } = input;
86
21
  if (sessionId == null || sessionId.trim() === "") return;
87
- const lastSwapAt = loadLastSwapAt(codexPool);
88
- if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return;
89
22
  const liveId = codex.liveId();
90
- const live = loadAccounts(codexPool).accounts.find((a) => a.id === liveId);
23
+ const live = liveId == null ? undefined : loadAccounts(codex.pool).accounts.find((a) => a.id === liveId);
91
24
  if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
92
25
  const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
93
26
  if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
@@ -113,16 +46,13 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
113
46
  log("codexstop.unsupervised_skip", {});
114
47
  return;
115
48
  }
116
- if (await promoteReconcile({ supervisorId, sessionId })) return;
117
49
  const now = Date.now();
118
- await withLock(codexPool.lockFile, () => reconcileSiblings(now));
119
50
  await compactBeforeMove({ sessionId, now });
120
51
  const decision = await evaluateAndMaybeSwap(codex, now);
121
52
  if (decision.swapped && decision.account) {
122
- await sweepSiblings(now);
123
53
  mkdirSync(codexPaths.respawnDir, { recursive: true });
124
54
  const payload = CodexRespawnMarkerSchema.parse({
125
- account: decision.account.label,
55
+ accountId: decision.account.id,
126
56
  sessionId,
127
57
  ts: Date.now(),
128
58
  });
@@ -130,7 +60,6 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
130
60
  log("codexstop.marker", { supervisorId: supervisorId.slice(0, 8) });
131
61
  return;
132
62
  }
133
- await promoteReconcile({ supervisorId, sessionId });
134
63
  } catch (e) {
135
64
  log("codexstop.error", { err: e instanceof Error ? e.message : String(e) });
136
65
  }
@@ -5,9 +5,11 @@ import { codexPaths, codexPool, paths } from "../lib/paths.ts";
5
5
  import { withLock } from "../lib/lock.ts";
6
6
  import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
7
7
  import { resolveRealCodex } from "../lib/codexbin.ts";
8
+ import { ensureCodexStoreHome } from "../lib/codexauth.ts";
9
+ import { pickCodexSeat } from "../lib/codex.ts";
8
10
  import { clearPresence, writePresence } from "../lib/presence.ts";
9
- import { liveCodexAccountId } from "../lib/codexauth.ts";
10
11
  import { saveTermios, restoreTermios } from "../lib/tty.ts";
12
+ import { loadAccounts } from "../lib/state.ts";
11
13
  import { CodexRespawnMarkerSchema } from "../lib/types.ts";
12
14
  import { log } from "../lib/log.ts";
13
15
 
@@ -27,7 +29,7 @@ function readCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchem
27
29
  } catch (e) {
28
30
  log("codexsupervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
29
31
  throw new Error(
30
- `${marker} is corrupt (unparsable JSON or off-schema) - auth.json may already hold another account, so the codex session was stopped instead of resumed on stale credentials; inspect and remove the marker, then run \`codex resume\``,
32
+ `${marker} is corrupt (unparsable JSON or off-schema) - the codex session was stopped instead of resumed on a stale target; inspect and remove the marker, then run \`codex resume\``,
31
33
  );
32
34
  }
33
35
  }
@@ -52,6 +54,25 @@ export function shouldManageCodex(input: { argv: string[] }): boolean {
52
54
  return firstPositional === null || !NONINTERACTIVE_SUBCMDS.has(firstPositional);
53
55
  }
54
56
 
57
+ async function recordCodexPresence(child: { pid: number; exitCode: number | null; signalCode: string | null; kill: () => void; exited: Promise<unknown> }, supervisorId: string, seat: { id: string }, savedTermios: string | null): Promise<void> {
58
+ for (let attempt = 0; ; attempt++) {
59
+ try {
60
+ writePresence({ dir: codexPaths.presenceDir, id: supervisorId, accountId: seat.id, pid: child.pid });
61
+ return;
62
+ } catch (e) {
63
+ if (child.exitCode !== null || child.signalCode !== null) return;
64
+ if (attempt >= 9) {
65
+ log("codexsupervisor.presence_failed", { err: e instanceof Error ? e.message : String(e) });
66
+ child.kill();
67
+ await child.exited;
68
+ restoreTermios(savedTermios);
69
+ throw new Error("could not write the codex presence file - refusing to run an unprotected session (its account would look like a swap target)");
70
+ }
71
+ await Bun.sleep(100);
72
+ }
73
+ }
74
+ }
75
+
55
76
  export async function runCodexSupervisor(input: { argv: string[] }): Promise<number> {
56
77
  const { argv } = input;
57
78
  const depth = wrapDepth();
@@ -91,37 +112,27 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
91
112
 
92
113
  let launchArgs = argv;
93
114
  let respawns = 0;
115
+ let wanted: string | null = null;
94
116
  while (true) {
95
117
  if (existsSync(marker)) rmSync(marker, { force: true });
96
- log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, args: launchArgs.join(" ") });
97
118
 
98
- const child = await withLock(codexPool.lockFile, async () => {
99
- const spawnAccountId = liveCodexAccountId();
119
+ const launchedAt = Date.now();
120
+ const { child } = await withLock(codexPool.lockFile, async () => {
121
+ const picked = (wanted == null ? null : (loadAccounts(codexPool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickCodexSeat(launchedAt, wanted);
122
+ log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" ") });
123
+ const store = picked ? ensureCodexStoreHome(picked.id) : undefined;
100
124
  const spawned = Bun.spawn([real, ...launchArgs], {
101
125
  stdin: "inherit",
102
126
  stdout: "inherit",
103
127
  stderr: "inherit",
104
- env: { ...childEnv, [CODEX_SUPERVISOR_ID_ENV]: supervisorId },
128
+ env: {
129
+ ...childEnv,
130
+ [CODEX_SUPERVISOR_ID_ENV]: supervisorId,
131
+ ...(store ? { CODEX_HOME: store } : {}),
132
+ },
105
133
  });
106
- if (spawnAccountId) {
107
- for (let attempt = 0; attempt < 10; attempt++) {
108
- try {
109
- writePresence({ dir: codexPaths.presenceDir, id: supervisorId, accountId: spawnAccountId, pid: spawned.pid });
110
- break;
111
- } catch (e) {
112
- if (spawned.exitCode !== null || spawned.signalCode !== null) break;
113
- if (attempt === 9) {
114
- log("codexsupervisor.presence_failed", { err: e instanceof Error ? e.message : String(e) });
115
- spawned.kill();
116
- await spawned.exited;
117
- restoreTermios(savedTermios);
118
- throw new Error("could not write the codex presence file - refusing to run an unprotected session (its account would look like a swap target)");
119
- }
120
- await Bun.sleep(100);
121
- }
122
- }
123
- }
124
- return spawned;
134
+ if (picked) await recordCodexPresence(spawned, supervisorId, picked, savedTermios);
135
+ return { child: spawned, seat: picked };
125
136
  });
126
137
 
127
138
  let done = false;
@@ -156,12 +167,13 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
156
167
  if (payload) {
157
168
  rmSync(marker, { force: true });
158
169
  respawns++;
159
- process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${payload.account} - resuming...\x1b[0m\n`);
170
+ const label = loadAccounts(codexPool).accounts.find((a) => a.id === payload.accountId)?.label ?? payload.accountId.slice(0, 8);
171
+ process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched codex to ${label} - resuming...\x1b[0m\n`);
172
+ wanted = payload.accountId;
160
173
  launchArgs = payload.sessionId ? ["resume", payload.sessionId] : ["resume", "--last"];
161
174
  continue;
162
175
  }
163
176
  clearPresence({ dir: codexPaths.presenceDir, id: supervisorId });
164
- rmSync(join(codexPaths.reconcileDir, supervisorId), { force: true });
165
177
  log("codexsupervisor.exit", { supervisorId: supervisorId.slice(0, 8), respawns, code: child.exitCode, signal: child.signalCode });
166
178
  return child.exitCode ?? (child.signalCode ? 1 : 0);
167
179
  }
package/src/lib/codex.ts CHANGED
@@ -1,23 +1,38 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { writeFileAtomic } from "./atomic.ts";
4
4
  import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "./claudebin.ts";
5
- import { codexIdentityOf, deleteParkedCodexAuth, isCodexAccessExpiring, liveCodexAccountId, readCodexAuthAt, readLiveCodexAuth, readParkedCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
5
+ import { codexIdentityOf, deleteCodexStoreAuth, isCodexAccessExpiring, readCodexAuthAt, readCodexStoreAuth, writeCodexStoreAuth } from "./codexauth.ts";
6
6
  import { resolveRealCodex, verifyRealCodex } from "./codexbin.ts";
7
7
  import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
8
- import { livingPresences, seatCounts } from "./presence.ts";
8
+ import { seatCounts } from "./presence.ts";
9
9
  import { CodexUsageReadError, codexLimitLabel, fetchCodexUsage } from "./codexusage.ts";
10
10
  import { codexSupervisorLink, ensurePathInRc, installCodexSupervisor, managedShellRcSkipLines, shellRcPath } from "./install.ts";
11
11
  import { withLock } from "./lock.ts";
12
12
  import { log } from "./log.ts";
13
- import { codexCredItemFor, codexPaths, codexPool } from "./paths.ts";
14
- import { isExhausted, thresholdBars, type PickCtx } from "./picker.ts";
13
+ import { codexPaths, codexPool, codexSeatFromEnv } from "./paths.ts";
14
+ import { isExhausted, pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
15
15
  import type { Observation, Provider, SampleReport } from "./provider.ts";
16
- import { loadAccounts, loadConfig, pinBinOverride, saveAccounts, saveLastSwapAt, type Harvest } from "./state.ts";
16
+ import { loadAccounts, loadConfig, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { restoreTermios, saveTermios } from "./tty.ts";
18
- import { CodexReconcileMarkerSchema, type Account, type CodexAuthJson, type CodexUsage, type Config } from "./types.ts";
18
+ import type { Account, CodexAuthJson, CodexUsage, Config } from "./types.ts";
19
19
  import { c } from "../cli/render.ts";
20
20
 
21
+ class StoreUnusableError extends Error {
22
+ constructor(message: string) {
23
+ super(message);
24
+ this.name = "StoreUnusableError";
25
+ }
26
+ }
27
+
28
+ function liveId(): string | null {
29
+ return codexSeatFromEnv(loadAccounts(codexPool).accounts.map((a) => a.id));
30
+ }
31
+
32
+ function presence(): Map<string, number> {
33
+ return seatCounts(codexPaths.presenceDir);
34
+ }
35
+
21
36
  function applyUsage(account: Account, usage: CodexUsage, at: number): void {
22
37
  account.windows = usage.windows;
23
38
  account.lastUsageAt = at;
@@ -25,59 +40,23 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
25
40
  if (usage.planType != null) account.tier = usage.planType;
26
41
  }
27
42
 
28
- async function sampleLiveOntoOwner(now: number): Promise<void> {
29
- let live = readLiveCodexAuth();
30
- if (!live) return;
31
- const idx = loadAccounts(codexPool);
32
- const identity = codexIdentityOf({ auth: live });
33
- const owner = idx.accounts.find((a) => a.id === identity.accountId);
34
- if (!owner) return;
35
-
36
- if (isCodexAccessExpiring({ auth: live, now }) && !seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
37
- try {
38
- live = await refreshCodexAuth({ auth: live, now });
39
- } catch (e) {
40
- if (e instanceof CodexInvalidGrantError) {
41
- owner.needsReauth = true;
42
- saveAccounts(codexPool, idx);
43
- log("codex.live_invalid_grant", { account: owner.id.slice(0, 8) });
44
- return;
45
- }
46
- throw e;
47
- }
48
- writeLiveCodexAuth({ auth: live });
49
- writeParkedCodexAuth({ credFile: codexCredItemFor(owner.id), auth: live });
50
- }
51
- applyUsage(owner, await fetchCodexUsage({ auth: live, at: now }), now);
52
- saveAccounts(codexPool, idx);
53
- }
43
+ type CodexReadOutcome = { ok: true; usage: CodexUsage; at: number } | { ok: false; reason: string; deadGrant: boolean };
54
44
 
55
- async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
56
- if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
57
- await withLock(codexPool.lockFile, () => sampleLiveOntoOwner(now));
45
+ async function readCodexUsage(account: Account, now: number): Promise<CodexReadOutcome> {
46
+ let auth: CodexAuthJson | null;
47
+ try {
48
+ auth = readCodexStoreAuth(account.id);
49
+ } catch (e) {
50
+ return { ok: false, reason: `store credential unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})`, deadGrant: false };
58
51
  }
59
- const fresh = loadAccounts(codexPool).accounts.find((a) => a.id === account.id);
60
- return fresh?.lastUsageAt != null ? { windows: fresh.windows, at: fresh.lastUsageAt } : null;
61
- }
62
-
63
- type CodexSampleOutcome = { ok: true; usage: CodexUsage; at: number } | { ok: false; reason: string; deadGrant: boolean };
64
-
65
- async function sampleAccount(account: Account, liveId: string | null, now: number): Promise<CodexSampleOutcome> {
66
- const isLive = liveId != null && account.id === liveId;
67
- const credFile = codexCredItemFor(account.id);
52
+ if (!auth) return { ok: false, reason: "no credential in this account's store - run `tokenmaxxing auth --codex`", deadGrant: false };
68
53
  try {
69
- let auth = isLive ? readLiveCodexAuth() : readParkedCodexAuth({ credFile });
70
- if (!auth) return { ok: false, reason: isLive ? "live auth.json vanished" : "no parked credential", deadGrant: false };
71
54
  if (isCodexAccessExpiring({ auth, now })) {
72
- const running = seatCounts(codexPaths.presenceDir).has(account.id);
73
- if (running && !isLive) {
74
- return { ok: false, reason: "running in a live codex session (parked token refresh unsafe)", deadGrant: false };
75
- }
76
- if (!running) {
77
- auth = await refreshCodexAuth({ auth, now });
78
- if (isLive) writeLiveCodexAuth({ auth });
79
- writeParkedCodexAuth({ credFile, auth });
55
+ if (seatCounts(codexPaths.presenceDir).has(account.id)) {
56
+ return { ok: false, reason: "running in a live codex session (store refresh unsafe)", deadGrant: false };
80
57
  }
58
+ auth = await refreshCodexAuth({ auth, now });
59
+ writeCodexStoreAuth(account.id, auth);
81
60
  }
82
61
  const at = Date.now();
83
62
  return { ok: true, usage: await fetchCodexUsage({ auth, at }), at };
@@ -88,11 +67,32 @@ async function sampleAccount(account: Account, liveId: string | null, now: numbe
88
67
  }
89
68
  }
90
69
 
91
- async function samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
70
+ async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
71
+ if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
72
+ const outcome = await readCodexUsage(account, now);
73
+ await withLock(codexPool.lockFile, () => {
74
+ const idx = loadAccounts(codexPool);
75
+ const a = idx.accounts.find((x) => x.id === account.id);
76
+ if (!a) return;
77
+ if (outcome.ok) {
78
+ if (a.lastUsageAt == null || outcome.at > a.lastUsageAt) {
79
+ applyUsage(a, outcome.usage, outcome.at);
80
+ }
81
+ } else if (outcome.deadGrant) {
82
+ a.needsReauth = true;
83
+ }
84
+ saveAccounts(codexPool, idx);
85
+ });
86
+ }
87
+ const current = loadAccounts(codexPool).accounts.find((a) => a.id === account.id) ?? account;
88
+ return current.lastUsageAt != null ? { windows: current.windows, at: current.lastUsageAt } : null;
89
+ }
90
+
91
+ async function samplePool(accounts: Account[], _liveId: string | null, now: number): Promise<Map<string, SampleReport>> {
92
92
  const reports = new Map<string, SampleReport>();
93
93
  await Promise.all(
94
94
  accounts.map(async (account) => {
95
- const outcome = await sampleAccount(account, liveId, now);
95
+ const outcome = await readCodexUsage(account, now);
96
96
  if (outcome.ok) {
97
97
  applyUsage(account, outcome.usage, outcome.at);
98
98
  reports.set(account.id, { ok: true, source: "probe" });
@@ -105,68 +105,15 @@ async function samplePool(accounts: Account[], liveId: string | null, now: numbe
105
105
  return reports;
106
106
  }
107
107
 
108
- async function swap(target: Account): Promise<void> {
109
- const idx = loadAccounts(codexPool);
110
- const live = readLiveCodexAuth();
111
- let liveOwner: Account | null = null;
112
- if (live) {
113
- const liveIdentity = codexIdentityOf({ auth: live });
114
- liveOwner = idx.accounts.find((a) => a.id === liveIdentity.accountId) ?? null;
115
- if (!liveOwner) {
116
- throw new Error(
117
- `live codex credential belongs to ${liveIdentity.email ?? liveIdentity.accountId.slice(0, 8)}, which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add --codex\``,
118
- );
119
- }
120
- if (liveOwner.id !== idx.activeId) {
121
- log("codexswap.harvest_drift", { labeled: idx.activeId?.slice(0, 8) ?? null, actual: liveOwner.id.slice(0, 8) });
122
- }
123
- }
124
- const commit = (): void => {
125
- idx.activeId = target.id;
126
- const entry = idx.accounts.find((a) => a.id === target.id);
127
- if (entry) entry.needsReauth = false;
128
- saveAccounts(codexPool, idx);
129
- };
130
-
131
- if (live && liveOwner && liveOwner.id === target.id) {
132
- writeParkedCodexAuth({ credFile: codexCredItemFor(target.id), auth: live });
133
- commit();
134
- log("codexswap.reconciled", { account: target.id.slice(0, 8) });
135
- return;
136
- }
137
-
138
- const parked = readParkedCodexAuth({ credFile: codexCredItemFor(target.id) });
139
- if (!parked) throw new Error(`no parked codex credential for ${target.label}`);
140
-
141
- let fresh: CodexAuthJson;
108
+ async function prepareMove(target: Account): Promise<void> {
109
+ let auth: CodexAuthJson | null;
142
110
  try {
143
- fresh = await refreshCodexAuth({ auth: parked });
111
+ auth = readCodexStoreAuth(target.id);
144
112
  } catch (e) {
145
- if (e instanceof CodexInvalidGrantError) {
146
- const entry = idx.accounts.find((a) => a.id === target.id);
147
- if (entry) {
148
- entry.needsReauth = true;
149
- saveAccounts(codexPool, idx);
150
- }
151
- log("codexswap.invalid_grant", { account: target.id.slice(0, 8) });
152
- }
153
- throw e;
154
- }
155
- writeParkedCodexAuth({ credFile: codexCredItemFor(target.id), auth: fresh });
156
-
157
- if (live && liveOwner) {
158
- const liveNow = readLiveCodexAuth();
159
- if (!liveNow || codexIdentityOf({ auth: liveNow }).accountId !== liveOwner.id) {
160
- throw new Error("live codex credential changed mid-swap - refusing to harvest under a stale identity; retry");
161
- }
162
- writeParkedCodexAuth({ credFile: codexCredItemFor(liveOwner.id), auth: liveNow });
163
- log("codexswap.harvest", { account: liveOwner.id.slice(0, 8) });
113
+ throw new StoreUnusableError(`${target.label}'s store is unreadable (${e instanceof Error ? e.message : String(e)}) - re-auth with \`tokenmaxxing auth --codex ${target.label}\``);
164
114
  }
165
-
166
- writeLiveCodexAuth({ auth: fresh });
167
- commit();
168
- saveLastSwapAt(codexPool, Date.now());
169
- log("codexswap.done", { account: target.id.slice(0, 8), label: target.label });
115
+ if (!auth) throw new StoreUnusableError(`${target.label} has no credential in its store - re-auth with \`tokenmaxxing auth --codex ${target.label}\``);
116
+ log("move.prepared", { account: target.id.slice(0, 8), label: target.label });
170
117
  }
171
118
 
172
119
  async function login(): Promise<Harvest | null> {
@@ -204,7 +151,7 @@ async function login(): Promise<Harvest | null> {
204
151
  email: usage?.usage.email ?? identity.email,
205
152
  tier: usage?.usage.planType ?? identity.planType,
206
153
  sample: usage ? { windows: usage.usage.windows, at: usage.at } : null,
207
- park: async () => writeParkedCodexAuth({ credFile: codexCredItemFor(identity.accountId), auth }),
154
+ park: async () => writeCodexStoreAuth(identity.accountId, auth),
208
155
  };
209
156
  } finally {
210
157
  rmSync(onboardDir, { recursive: true, force: true });
@@ -224,36 +171,9 @@ async function sampleLogin(auth: CodexAuthJson): Promise<{ usage: CodexUsage; at
224
171
  }
225
172
 
226
173
  async function importLive(): Promise<Harvest | null> {
227
- const live = readLiveCodexAuth();
228
- if (!live) {
229
- console.error(c.red(`no codex login found at ${codexPaths.authJson} - run \`codex login\` first, then re-run this.`));
230
- return null;
231
- }
232
- const identity = codexIdentityOf({ auth: live });
233
- const usage = await sampleLogin(live);
234
-
235
- if (seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
236
- console.error(c.red("a live supervised codex session is running this account - its token rotates under us, so parking a snapshot now could poison the backup."));
237
- console.error(c.dim("close that codex session (or let it exit) and re-run `tokenmaxxing init --codex`."));
238
- return null;
239
- }
240
-
241
- return {
242
- id: identity.accountId,
243
- email: usage?.usage.email ?? identity.email,
244
- tier: usage?.usage.planType ?? identity.planType,
245
- sample: usage ? { windows: usage.usage.windows, at: usage.at } : null,
246
- park: async () => {
247
- if (seatCounts(codexPaths.presenceDir).has(identity.accountId)) {
248
- throw new Error("a live supervised codex session started running this account mid-init - close it and re-run `tokenmaxxing init --codex`");
249
- }
250
- const fresh2 = readLiveCodexAuth();
251
- if (!fresh2 || codexIdentityOf({ auth: fresh2 }).accountId !== identity.accountId) {
252
- throw new Error("the live codex login changed while init was running (a concurrent swap?) - re-run `tokenmaxxing init --codex`");
253
- }
254
- writeParkedCodexAuth({ credFile: codexCredItemFor(identity.accountId), auth: fresh2 });
255
- },
256
- };
174
+ console.log(c.cyan("Opening an isolated codex login for your first pooled account - the login you already have stays as it is for sessions started outside the supervisor."));
175
+ console.log();
176
+ return login();
257
177
  }
258
178
 
259
179
  function storePinnedAwayFromFile(): boolean {
@@ -287,36 +207,26 @@ function install(): void {
287
207
  console.log(`${c.green("✓")} codex supervisor installed at ${codexSupervisorLink()}`);
288
208
  console.log(`${c.green("✓")} Stop hook declared in ${codexPaths.hooksJson}`);
289
209
  console.log();
290
- console.log(c.bold(c.yellow("one manual step: codex skips untrusted hooks.")));
291
- console.log(c.yellow("open codex, run /hooks, and trust the tokenmaxxing Stop hook - auto-switching is inert until then."));
210
+ console.log(c.bold(c.yellow("one manual step per seat: codex trusts hooks per store path.")));
211
+ console.log(c.yellow("open a supervised codex session on each pooled account, run /hooks, and trust the tokenmaxxing Stop hook - auto-switching stays inert on an untrusted seat."));
212
+ console.log(c.yellow("`tokenmaxxing doctor` lists seats still needing trust."));
292
213
  }
293
214
 
294
215
  export function codexPickCtx(now: number, currentId: string | null): PickCtx {
295
216
  return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
296
217
  }
297
218
 
298
- export function reconcileSiblings(now: number): void {
299
- const liveId = liveCodexAccountId();
300
- if (liveId == null) return;
219
+ export function pickCodexSeat(now: number, wantedId: string | null = null): Account | null {
220
+ const cfg = loadConfig();
301
221
  const idx = loadAccounts(codexPool);
302
- const liveAccount = idx.accounts.find((a) => a.id === liveId);
303
- if (!liveAccount || liveAccount.needsReauth === true || isExhausted(liveAccount, codexPickCtx(now, liveId))) return;
304
- const living = livingPresences(codexPaths.presenceDir);
305
- if (existsSync(codexPaths.reconcileDir)) {
306
- const alive = new Set(living.map((presence) => presence.id));
307
- for (const name of readdirSync(codexPaths.reconcileDir)) {
308
- if (!alive.has(name)) rmSync(join(codexPaths.reconcileDir, name), { force: true });
309
- }
310
- }
311
- for (const presence of living) {
312
- if (presence.accountId === liveId) continue;
313
- if (!idx.accounts.some((a) => a.id === presence.accountId)) continue;
314
- const markerPath = join(codexPaths.reconcileDir, presence.id);
315
- if (existsSync(markerPath)) continue;
316
- mkdirSync(codexPaths.reconcileDir, { recursive: true });
317
- writeFileAtomic(markerPath, JSON.stringify(CodexReconcileMarkerSchema.parse({ accountId: presence.accountId, ts: now })));
318
- log("codex.reconcile_signal", { supervisorId: presence.id.slice(0, 8), account: presence.accountId.slice(0, 8) });
222
+ const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: null, seats: null };
223
+ if (wantedId != null) {
224
+ const wanted = idx.accounts.find((a) => a.id === wantedId && a.needsReauth !== true && !isExhausted(a, { ...ctx, currentId: wantedId }));
225
+ if (wanted) return wanted;
319
226
  }
227
+ const present = seatCounts(codexPaths.presenceDir);
228
+ const usable = idx.accounts.filter((a) => a.needsReauth !== true && !isExhausted(a, ctx) && !present.has(a.id));
229
+ return pickBest(usable, ctx) ?? pickEarliestReset(idx.accounts.filter((a) => a.needsReauth !== true && !present.has(a.id)), ctx)?.account ?? null;
320
230
  }
321
231
 
322
232
  export const codex: Provider = {
@@ -326,18 +236,18 @@ export const codex: Provider = {
326
236
  seats: "live",
327
237
  waitsWhenDepleted: false,
328
238
  statusOnly: false,
329
- liveId: liveCodexAccountId,
330
- presence: () => seatCounts(codexPaths.presenceDir),
239
+ liveId,
240
+ presence,
331
241
  gatedFamilies: () => null,
332
242
  observeLive,
333
243
  samplePool,
334
244
  mergeWindows: (next) => next,
335
- swap,
336
- classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : "fatal"),
337
- removeCredentials: async (a) => deleteParkedCodexAuth({ credFile: codexCredItemFor(a.id) }),
245
+ swap: prepareMove,
246
+ classifySwapError: (e) => (e instanceof CodexInvalidGrantError ? "dead-grant" : e instanceof StoreUnusableError ? "skip" : "fatal"),
247
+ removeCredentials: async (a) => deleteCodexStoreAuth(a.id),
338
248
  storeUsable: async (a) => {
339
249
  try {
340
- return readParkedCodexAuth({ credFile: codexCredItemFor(a.id) }) != null;
250
+ return readCodexStoreAuth(a.id) != null;
341
251
  } catch {
342
252
  return false;
343
253
  }
@@ -1,8 +1,8 @@
1
- import { readFileSync, rmSync } from "node:fs";
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, symlinkSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { writeFileAtomic } from "./atomic.ts";
5
- import { codexPaths } from "./paths.ts";
5
+ import { codexAuthJsonFor, codexPaths, codexStoreDirFor } from "./paths.ts";
6
6
  import { CodexAuthJsonSchema, ErrnoSchema, type CodexAuthJson } from "./types.ts";
7
7
 
8
8
  export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
@@ -19,41 +19,50 @@ export function readCodexAuthAt(input: { path: string }): CodexAuthJson | null {
19
19
  return CodexAuthJsonSchema.parse(parsed);
20
20
  }
21
21
 
22
- export function readLiveCodexAuth(): CodexAuthJson | null {
23
- return readCodexAuthAt({ path: codexPaths.authJson });
22
+ export function readCodexStoreAuth(accountId: string): CodexAuthJson | null {
23
+ return readCodexAuthAt({ path: codexAuthJsonFor(accountId) });
24
24
  }
25
25
 
26
- export function liveCodexAccountId(): string | null {
27
- const live = readLiveCodexAuth();
28
- if (!live) return null;
29
- return codexIdentityOf({ auth: live }).accountId;
26
+ export function writeCodexStoreAuth(accountId: string, auth: CodexAuthJson): void {
27
+ mkdirSync(codexStoreDirFor(accountId), { recursive: true });
28
+ writeFileAtomic(codexAuthJsonFor(accountId), JSON.stringify(CodexAuthJsonSchema.parse(auth), null, 2), 0o600);
30
29
  }
31
30
 
32
- export function writeLiveCodexAuth(input: { auth: CodexAuthJson }): void {
33
- writeFileAtomic(codexPaths.authJson, JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
31
+ export function deleteCodexStoreAuth(accountId: string): void {
32
+ rmSync(codexStoreDirFor(accountId), { recursive: true, force: true });
34
33
  }
35
34
 
36
- function parkedPath(input: { credFile: string }): string {
37
- return join(codexPaths.credsDir, `${input.credFile}.json`);
38
- }
39
-
40
- export function readParkedCodexAuth(input: { credFile: string }): CodexAuthJson | null {
41
- let raw: string;
35
+ export function ensureCodexStoreHome(accountId: string): string {
36
+ const store = codexStoreDirFor(accountId);
37
+ mkdirSync(store, { recursive: true });
38
+ mkdirSync(codexPaths.home, { recursive: true });
39
+ mkdirSync(join(codexPaths.home, "sessions"), { recursive: true });
40
+ let names: string[] = [];
42
41
  try {
43
- raw = readFileSync(parkedPath(input), "utf8");
42
+ names = readdirSync(codexPaths.home);
44
43
  } catch (e) {
45
- if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return null;
44
+ if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return store;
46
45
  throw e;
47
46
  }
48
- return CodexAuthJsonSchema.parse(JSON.parse(raw));
49
- }
50
-
51
- export function writeParkedCodexAuth(input: { credFile: string; auth: CodexAuthJson }): void {
52
- writeFileAtomic(parkedPath(input), JSON.stringify(CodexAuthJsonSchema.parse(input.auth), null, 2), 0o600);
53
- }
54
-
55
- export function deleteParkedCodexAuth(input: { credFile: string }): void {
56
- rmSync(parkedPath(input), { force: true });
47
+ for (const name of names) {
48
+ if (name === "auth.json") continue;
49
+ const target = join(codexPaths.home, name);
50
+ const link = join(store, name);
51
+ if (existsSync(link)) {
52
+ try {
53
+ if (lstatSync(link).isSymbolicLink()) continue;
54
+ } catch {
55
+ continue;
56
+ }
57
+ continue;
58
+ }
59
+ try {
60
+ symlinkSync(target, link);
61
+ } catch (e) {
62
+ if (ErrnoSchema.safeParse(e).data?.code !== "EEXIST") throw e;
63
+ }
64
+ }
65
+ return store;
57
66
  }
58
67
 
59
68
  const IdClaimsSchema = z.looseObject({
@@ -2,7 +2,7 @@ import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileS
2
2
  import { basename, dirname, join } from "node:path";
3
3
  import { escape } from "es-toolkit";
4
4
  import { z } from "zod";
5
- import { codexPaths, HOME, paths } from "./paths.ts";
5
+ import { codexPaths, codexStoreDirFor, HOME, paths } from "./paths.ts";
6
6
  import { writeFileAtomic } from "./atomic.ts";
7
7
  import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
8
8
  import { resolveRealClaude } from "./claudebin.ts";
@@ -165,6 +165,40 @@ export function uninstallCodexStopHook(): void {
165
165
  writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
166
166
  }
167
167
 
168
+ export function codexStopHookGroupIndex(): number | null {
169
+ if (!existsSync(codexPaths.hooksJson)) return null;
170
+ let parsed: z.infer<typeof CodexHooksFileSchema>;
171
+ try {
172
+ parsed = CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")));
173
+ } catch {
174
+ return null;
175
+ }
176
+ const idx = parsed.hooks.Stop.findIndex((group) => group.hooks.some((hook) => (hook.command ?? "").includes(CODEX_STOP_HOOK_SUBCOMMAND)));
177
+ return idx >= 0 ? idx : null;
178
+ }
179
+
180
+ export function codexStoreHookTrust(accountId: string): "trusted" | "untrusted" | "unknown" {
181
+ const group = codexStopHookGroupIndex();
182
+ if (group == null) return "unknown";
183
+ const key = `${join(codexStoreDirFor(accountId), "hooks.json")}:stop:${group}:0`;
184
+ let text: string;
185
+ try {
186
+ text = readFileSync(join(codexPaths.home, "config.toml"), "utf8");
187
+ } catch {
188
+ return "unknown";
189
+ }
190
+ let inBlock = false;
191
+ for (const line of text.split("\n")) {
192
+ const trimmed = line.trim();
193
+ if (trimmed.startsWith("[")) {
194
+ inBlock = trimmed.startsWith("[hooks.state.") && trimmed.includes(key);
195
+ } else if (inBlock && trimmed.startsWith("trusted_hash")) {
196
+ return "trusted";
197
+ }
198
+ }
199
+ return "untrusted";
200
+ }
201
+
168
202
  export function codexSupervisorLink(): string {
169
203
  return join(paths.binDir, "codex");
170
204
  }
package/src/lib/paths.ts CHANGED
@@ -48,7 +48,7 @@ export const claudePool = {
48
48
 
49
49
  export const codexPool = {
50
50
  accountsJson: join(TM_HOME, "codex-accounts.json"),
51
- lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
51
+ lastSwapJson: null,
52
52
  lockFile: join(TM_HOME, "codex-lock"),
53
53
  } as const;
54
54
 
@@ -72,15 +72,24 @@ export const codexPaths = {
72
72
  home: CODEX_HOME,
73
73
  authJson: join(CODEX_HOME, "auth.json"),
74
74
  hooksJson: join(CODEX_HOME, "hooks.json"),
75
- credsDir: join(TM_HOME, "codex-creds"),
75
+ storesDir: join(TM_HOME, "codex-stores"),
76
76
  onboardDir: join(TM_HOME, "codex-onboard"),
77
77
  respawnDir: join(TM_HOME, "codex-respawn"),
78
78
  presenceDir: join(TM_HOME, "codex-live"),
79
- reconcileDir: join(TM_HOME, "codex-reconcile"),
80
79
  } as const;
81
80
 
82
- export function codexCredItemFor(accountId: string): string {
83
- return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
81
+ export function codexStoreDirFor(accountId: string): string {
82
+ return join(codexPaths.storesDir, shortId(accountId));
83
+ }
84
+
85
+ export function codexAuthJsonFor(accountId: string): string {
86
+ return join(codexStoreDirFor(accountId), "auth.json");
87
+ }
88
+
89
+ export function codexSeatFromEnv(accountIds: string[], env: Record<string, string | undefined> = process.env): string | null {
90
+ const home = env.CODEX_HOME;
91
+ if (home == null || home === "") return null;
92
+ return accountIds.find((id) => codexStoreDirFor(id) === home) ?? null;
84
93
  }
85
94
 
86
95
  const GROK_HOME_DEFAULT = join(HOME, ".grok");
package/src/lib/picker.ts CHANGED
@@ -6,7 +6,7 @@ import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresh
6
6
  export function thresholdBars(cfg: Config): Thresholds {
7
7
  return {
8
8
  session: cfg.thresholds.session - cfg.policy.projectionMargin,
9
- weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
9
+ weekly: cfg.thresholds.weekly,
10
10
  };
11
11
  }
12
12
 
@@ -87,11 +87,22 @@ export function earliestReset(a: Account, now: number): number {
87
87
  return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
88
88
  }
89
89
 
90
- export function pacePressure(a: Account, now: number): number {
90
+ function windowResetAt(w: Window, now: number): number | null {
91
+ if (w.resetsAt != null) return nextWeeklyReset(w.resetsAt, now);
92
+ if (w.windowSeconds != null) return w.sampledAt + w.windowSeconds * 1000;
93
+ return null;
94
+ }
95
+
96
+ export function pacePressure(a: Account, ctx: PickCtx): number {
97
+ const binding = minBy(gatedWindows(a, ctx.families), (w) => Math.max(0, 100 - liveUsed(w, ctx.now)));
98
+ if (binding != null) {
99
+ const reset = windowResetAt(binding, ctx.now);
100
+ if (reset != null) return Math.max(0, 100 - liveUsed(binding, ctx.now)) / Math.max(1, reset - ctx.now);
101
+ }
91
102
  const weekly = weeklyWindow(a);
92
- const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
103
+ const reset = nextWeeklyReset(weekly?.resetsAt ?? null, ctx.now);
93
104
  if (weekly == null || reset == null) return 0;
94
- return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
105
+ return Math.max(0, 100 - liveUsed(weekly, ctx.now)) / Math.max(1, reset - ctx.now);
95
106
  }
96
107
 
97
108
  export function seatHeadroom(a: Account, ctx: PickCtx): number {
@@ -102,7 +113,7 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
102
113
 
103
114
  const swapPreference = (ctx: PickCtx) => [
104
115
  ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
105
- (a: Account) => -pacePressure(a, ctx.now),
116
+ (a: Account) => -pacePressure(a, ctx),
106
117
  (a: Account) => weeklyExpiry(a, ctx.now),
107
118
  (a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
108
119
  ];
@@ -116,7 +127,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
116
127
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
117
128
  const best = pickBest(accounts, { ...ctx, currentId: null });
118
129
  if (best == null || best.id === active.id) return true;
119
- if (margin > 1) return pacePressure(best, ctx.now) <= pacePressure(active, ctx.now) * margin;
130
+ if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
120
131
  return swapPreference(ctx).every((k) => k(active) === k(best));
121
132
  }
122
133
 
package/src/lib/types.ts CHANGED
@@ -114,7 +114,7 @@ export const ConfigSchema = z
114
114
  opencodeBin: z.string().default(""),
115
115
  policy: z
116
116
  .object({
117
- projectionMargin: z.number().min(0).max(100).default(0),
117
+ projectionMargin: z.number().min(0).max(100).default(3),
118
118
  switchModels: z
119
119
  .array(z.string())
120
120
  .default(["fable"])
@@ -125,9 +125,9 @@ export const ConfigSchema = z
125
125
  })
126
126
  .prefault({}),
127
127
  })
128
- .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
128
+ .refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
129
129
  path: ["policy", "projectionMargin"],
130
- message: "must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
130
+ message: "must be strictly below the session threshold (the session bar would hit zero and every account would read as exhausted)",
131
131
  });
132
132
  export type Config = z.infer<typeof ConfigSchema>;
133
133
 
@@ -229,13 +229,8 @@ export const CodexStopStdinSchema = z.looseObject({
229
229
  });
230
230
 
231
231
  export const CodexRespawnMarkerSchema = z.object({
232
- account: z.string(),
233
- sessionId: z.string().nullable(),
234
- ts: z.number(),
235
- });
236
-
237
- export const CodexReconcileMarkerSchema = z.object({
238
232
  accountId: z.string(),
233
+ sessionId: z.string().nullable(),
239
234
  ts: z.number(),
240
235
  });
241
236
 
package/src/main.ts CHANGED
@@ -20,7 +20,6 @@ import { cmdStatus } from "./cli/status.ts";
20
20
  import { cmdDoctor } from "./cli/doctor.ts";
21
21
  import { cmdRm } from "./cli/rm.ts";
22
22
  import { cmdRename } from "./cli/rename.ts";
23
- import { cmdSwitch } from "./cli/switch.ts";
24
23
  import { cmdCheck } from "./cli/check.ts";
25
24
  import { cmdConfig } from "./cli/config.ts";
26
25
  import { cmdSetupToken } from "./cli/setuptoken.ts";
@@ -34,8 +33,8 @@ const CACHED_FLAG = "--cached";
34
33
  const CODEX_FLAG = "--codex";
35
34
  const GROK_FLAG = "--grok";
36
35
  const OPENCODE_GO_FLAG = "--opencode-go";
37
- const JSON_COMMANDS = new Set(["status", "config", "check", "switch"]);
38
- const CODEX_COMMANDS = new Set(["init", "add", "auth", "switch", "rm", "rename"]);
36
+ const JSON_COMMANDS = new Set(["status", "config", "check"]);
37
+ const CODEX_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
39
38
  const STATUS_ONLY_COMMANDS = new Set(["init", "add", "auth", "rm", "rename"]);
40
39
 
41
40
  function printHelp(): void {
@@ -44,13 +43,12 @@ function printHelp(): void {
44
43
  ${c.cyan("tokenmaxxing")} show the pool with usage bars (alias of ${c.cyan("status")})
45
44
  ${c.cyan("tokenmaxxing check")} sample the account whose usage figure is oldest (run by the periodic timer)
46
45
  ${c.cyan("tokenmaxxing init")} log in the first account (isolated) + install supervisor & hooks
47
- ${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
46
+ ${c.cyan("tokenmaxxing init --codex")} same for codex: log in the first account, isolated, install codex supervisor + Stop hook
48
47
  ${c.cyan("tokenmaxxing init --grok")} pool grok Build logins (status-only: no supervisor yet)
49
48
  ${c.cyan("tokenmaxxing init --opencode-go")} pool opencode-go API keys (status-only: no supervisor yet)
50
49
  ${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
51
50
  ${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
52
51
  ${c.cyan("tokenmaxxing auth")} [--codex | --grok | --opencode-go] [sel | --all] reauthenticate a pooled account in place (bare = pick from a list; --all = every account that is flagged or has no usable credential in its store, one by one)
53
- ${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
54
52
  ${c.cyan("tokenmaxxing status")} [--cached] accounts with 5h / weekly / per-model usage bars (--cached: the stored figures, no sampling)
55
53
  ${c.cyan("tokenmaxxing config")} print the config path and the effective values (edit the file in an editor)
56
54
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
@@ -61,7 +59,7 @@ function printHelp(): void {
61
59
  ${c.cyan("tokenmaxxing cursor init")} [dir] write the Claude relay subagent (.cursor/agents/claude.md) and .cursor/environment.json into a repo
62
60
  ${c.cyan("tokenmaxxing cloud run")} [--session <id>] [--max-turns <n>] "<prompt>" on a Cursor Cloud VM: run claude -p on a setup token from TOKENMAXXING_TOKENS, rotate to the next token on a usage limit
63
61
 
64
- ${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, check, switch); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
62
+ ${c.cyan("--json")} print one JSON document on stdout instead of text (status, config, check); every document carries ${c.bold("ok")}, failures add ${c.bold("error")}
65
63
 
66
64
  ${c.dim("(aliased as")} ${c.cyan("xx")}${c.dim(")")} - then just run ${c.bold("claude")} as always; it switches accounts near quota automatically.`);
67
65
  }
@@ -131,13 +129,6 @@ async function main(): Promise<number> {
131
129
  }
132
130
  return cmdStatus({ json, cached });
133
131
  }
134
- case "switch": {
135
- if (provider !== codex) {
136
- emitError({ json, message: "Claude account placement is per session at launch; manual switching is available with `tokenmaxxing switch --codex`." });
137
- return 2;
138
- }
139
- return cmdSwitch(args[1], json);
140
- }
141
132
  case "check": {
142
133
  if (args.length > 1) {
143
134
  emitError({ json, message: `unknown check option: ${args[1]} (check takes no options; the timer runs a plain check every tick)` });
@@ -174,7 +165,7 @@ async function main(): Promise<number> {
174
165
  console.log(`removed ${removed.join(", ")}`);
175
166
  if (!out.timerDeactivated) console.log(c.yellow(`⚠ the check job may still be loaded - run: ${timerDeactivationHint()}`));
176
167
  if (!out.pathLineRemoved) console.log(c.dim("(no tokenmaxxing PATH line found in the shell rc)"));
177
- console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-creds/) - remove accounts with \`xx rm\` to delete their credentials`);
168
+ console.log(`kept: accounts.json, config.json, and every account credential store (claude: stores/ and its keychain items on macOS; codex: codex-stores/) - remove accounts with \`xx rm\` to delete their credentials`);
178
169
  return 0;
179
170
  }
180
171
  case "help":
package/src/cli/switch.ts DELETED
@@ -1,116 +0,0 @@
1
- import { withLock } from "../lib/lock.ts";
2
- import { codex } from "../lib/codex.ts";
3
- import { loadAccounts, loadConfig } from "../lib/state.ts";
4
- import { currentWins, pickBest, thresholdBars, weeklyExpiry, type PickCtx } from "../lib/picker.ts";
5
- import { findAccount } from "./rename.ts";
6
- import { log } from "../lib/log.ts";
7
- import { c, emitError, emitJson, fmtReset } from "./render.ts";
8
- import type { Account } from "../lib/types.ts";
9
-
10
- const SWITCH_MARGIN = 1.2;
11
-
12
- export async function cmdSwitch(selector?: string, json = false): Promise<number> {
13
- const p = codex;
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; extra?: Record<string, unknown> } = {}): number => {
21
- emitError({ json, message, paint: opts.paint, extra: withDeadGrants(opts.extra ?? {}) });
22
- return 1;
23
- };
24
- const reauthHint = (a: Account) => `run \`tokenmaxxing auth${p.flag} ${a.label}\``;
25
- const deadGrantMessage = (a: Account) => `${a.label}'s refresh token is dead - ${reauthHint(a)}`;
26
- const switched = (target: Account) => `${c.green("↻")} switched to ${c.bold(target.label)} (takes effect on the next codex start)`;
27
-
28
- const idx0 = loadAccounts(p.pool);
29
- if (idx0.accounts.length < 2) return fail(`need at least 2 accounts to switch - add one with \`tokenmaxxing add${p.flag}\``, { paint: c.yellow });
30
- const cfg = loadConfig();
31
- const now = Date.now();
32
-
33
- return withLock(p.pool.lockFile, async () => {
34
- const idx = loadAccounts(p.pool);
35
- const claimed = p.liveId();
36
- const drifted = claimed != null && claimed !== idx.activeId;
37
- const present = p.presence();
38
-
39
- const swapTo = async (target: Account, reason: string, extra: Record<string, unknown> = {}): Promise<number> => {
40
- try {
41
- await p.swap(target);
42
- } catch (e) {
43
- if (p.classifySwapError(e) === "dead-grant") {
44
- deadGrants.push(target.label);
45
- return fail(deadGrantMessage(target));
46
- }
47
- throw e;
48
- }
49
- log("switch.manual", { account: target.id.slice(0, 8), reason });
50
- emit(switched(target), { switched: true, account: target.label, reason, ...extra });
51
- return 0;
52
- };
53
-
54
- if (selector) {
55
- const target = findAccount(idx.accounts, selector);
56
- if (!target) return fail(`no account matches "${selector}"`);
57
- if (target.id === idx.activeId && !drifted) {
58
- emit(`already on ${c.bold(target.label)}`, { switched: false, account: target.label, reason: "already-on" });
59
- return 0;
60
- }
61
- if (target.id !== claimed && present.has(target.id)) {
62
- return fail(`${target.label} is running in a live ${p.name} session - swapping onto it would break that session's credential`);
63
- }
64
- if (target.needsReauth) return fail(`${target.label} needs re-auth - ${reauthHint(target)}`);
65
- return swapTo(target, "selected");
66
- }
67
-
68
- const everyone: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: p.gatedFamilies(cfg), seats: null };
69
- const rejected = new Set<string>();
70
- const candidatesOf = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === claimed || !present.has(a.id)));
71
- while (true) {
72
- const cur = loadAccounts(p.pool);
73
- const pool = candidatesOf(cur.accounts);
74
- const active =
75
- (claimed != null ? cur.accounts.find((a) => a.id === claimed) : null) ??
76
- cur.accounts.find((a) => a.id === cur.activeId) ??
77
- null;
78
- if (active != null && currentWins(active, pool, everyone, SWITCH_MARGIN)) {
79
- if (drifted) return swapTo(active, "drift-reconciled");
80
- const expiry = weeklyExpiry(active, now);
81
- const why = Number.isFinite(expiry) ? ` (weekly ${fmtReset(expiry, now)})` : "";
82
- emit(`already on the best account: ${c.bold(active.label)}${why}`, {
83
- switched: false,
84
- account: active.label,
85
- reason: "current-wins",
86
- weeklyResetsAt: Number.isFinite(expiry) ? expiry : null,
87
- });
88
- return 0;
89
- }
90
- const best = pickBest(pool, { ...everyone, currentId: active?.id ?? null });
91
- if (!best) break;
92
- try {
93
- await p.swap(best);
94
- } catch (e) {
95
- rejected.add(best.id);
96
- const kind = p.classifySwapError(e);
97
- if (kind === "dead-grant") {
98
- deadGrants.push(best.label);
99
- if (!json) console.error(c.red(deadGrantMessage(best)));
100
- continue;
101
- }
102
- if (kind === "skip") {
103
- if (!json) console.error(c.yellow(`${best.label}: ${e instanceof Error ? e.message : String(e)} - skipped for this run`));
104
- continue;
105
- }
106
- throw e;
107
- }
108
- log("switch.manual", { account: best.id.slice(0, 8), reason: "best" });
109
- emit(switched(best), { switched: true, account: best.label, reason: "best" });
110
- return 0;
111
- }
112
-
113
- const reauth = loadAccounts(p.pool).accounts.filter((a) => a.needsReauth).map((a) => a.label);
114
- return fail(`no usable ${p.name} switch target (all at their bars, unmeasured, or needing reauth)`, { paint: c.yellow, extra: { reauthNeeded: reauth } });
115
- });
116
- }