tokenmaxxing 1.43.0 → 1.45.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
 
@@ -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
 
@@ -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.43.0",
3
+ "version": "1.45.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
@@ -8,7 +8,7 @@ import { usageNote } from "./add.ts";
8
8
  import { c, count } from "./render.ts";
9
9
  import type { Account, AccountsIndex } from "../lib/types.ts";
10
10
 
11
- const AUTH_USAGE = "usage: tokenmaxxing auth [--codex] [<email|label|id> | --all]";
11
+ const AUTH_USAGE = "usage: tokenmaxxing auth [--codex | --grok | --opencode-go] [<email|label|id> | --all]";
12
12
 
13
13
  const AuthPlanSchema = z.discriminatedUnion("kind", [
14
14
  z.object({ kind: z.literal("usage") }),
@@ -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
@@ -8,10 +8,13 @@ import { c, count } from "./render.ts";
8
8
  export function printUsage(p: Provider): void {
9
9
  console.log();
10
10
  console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
11
- console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
11
+ if (p.statusOnly) {
12
+ console.log(` ${c.cyan(`xx status`)} view pooled ${p.name} accounts (status-only pool: no automatic switching yet)`);
13
+ } else {
14
+ console.log(` ${c.cyan(p.name)} use ${p.name} as always; it switches accounts near quota automatically`);
15
+ }
12
16
  console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
13
17
  console.log(` ${c.cyan(`xx add${p.flag}`)} log in and pool another account`);
14
- console.log(` ${c.cyan(`xx switch${p.flag}`)} hop to the best account right now (the automatic switching needs no command)`);
15
18
  console.log(` ${c.cyan("xx help")} everything else`);
16
19
  }
17
20
 
@@ -23,9 +26,7 @@ export async function cmdInit(p: Provider): Promise<number> {
23
26
  const existing = loadAccounts(p.pool);
24
27
  if (existing.accounts.length > 0) {
25
28
  p.install();
26
- const active = existing.accounts.find((a) => a.id === existing.activeId);
27
29
  console.log(`${c.green("✓")} re-installed (pool already has ${count({ n: existing.accounts.length, noun: "account" })} - not re-importing)`);
28
- if (active && p.seats === "live") console.log(` active: ${c.bold(active.label)}`);
29
30
  printUsage(p);
30
31
  return 0;
31
32
  }
@@ -37,7 +38,7 @@ export async function cmdInit(p: Provider): Promise<number> {
37
38
  await harvested.park();
38
39
  const idx = loadAccounts(p.pool);
39
40
  const imported = upsertAccount(idx, harvested, p.mergeWindows);
40
- idx.activeId = p.seats === "live" ? harvested.id : null;
41
+ idx.activeId = null;
41
42
  saveAccounts(p.pool, idx);
42
43
  return imported;
43
44
  });
package/src/cli/status.ts CHANGED
@@ -2,9 +2,11 @@ import { chunk, sortBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { claude } from "../lib/claude.ts";
4
4
  import { codex } from "../lib/codex.ts";
5
+ import { grok } from "../lib/grok.ts";
6
+ import { opencodeGo } from "../lib/opencodego.ts";
5
7
  import { loadAccounts, loadConfig, saveAccounts } from "../lib/state.ts";
6
8
  import { withLock } from "../lib/lock.ts";
7
- import { codexPool } from "../lib/paths.ts";
9
+ import { codexPool, grokPool, opencodeGoPool } from "../lib/paths.ts";
8
10
  import { earliestReset, isExhausted, isSessionWindow, limitWindows, nextWeeklyReset, sessionWindow, thresholdBars, weeklyWindow } from "../lib/picker.ts";
9
11
  import type { Provider, SampleReport } from "../lib/provider.ts";
10
12
  import { bar, c, count, emitJson, fmtAgo } from "./render.ts";
@@ -52,7 +54,7 @@ const PoolReportSchema = z.object({
52
54
  });
53
55
  type PoolReport = z.infer<typeof PoolReportSchema>;
54
56
 
55
- const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema });
57
+ const StatusReportSchema = z.object({ now: z.number(), claude: PoolReportSchema, codex: PoolReportSchema, grok: PoolReportSchema, opencodeGo: PoolReportSchema });
56
58
  export type StatusReport = z.infer<typeof StatusReportSchema>;
57
59
 
58
60
  function currentWindow(w: Window, now: number): WindowReport {
@@ -239,9 +241,11 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
239
241
  const claudeReport = await collect(claude, cfg, now, cached);
240
242
  if (!json) {
241
243
  const codexPooled = loadAccounts(codexPool).accounts.length > 0;
244
+ const grokPooled = loadAccounts(grokPool).accounts.length > 0;
245
+ const opencodeGoPooled = loadAccounts(opencodeGoPool).accounts.length > 0;
242
246
  if (claudeReport.accounts.length === 0) {
243
- if (!codexPooled) {
244
- console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`)"));
247
+ if (!codexPooled && !grokPooled && !opencodeGoPooled) {
248
+ console.log(c.dim("no accounts yet, run `tokenmaxxing init` (or `tokenmaxxing init --codex`, `--grok`, `--opencode-go`)"));
245
249
  return 0;
246
250
  }
247
251
  console.log(c.dim("no claude accounts (run `tokenmaxxing init` to pool claude too)"));
@@ -252,13 +256,21 @@ export async function cmdStatus(opts: { json?: boolean; cached?: boolean } = {})
252
256
  }
253
257
  }
254
258
  const codexReport = await collect(codex, cfg, now, cached);
259
+ const grokReport = await collect(grok, cfg, now, cached);
260
+ const opencodeGoReport = await collect(opencodeGo, cfg, now, cached);
255
261
  if (json) {
256
- const report: StatusReport = { now, claude: claudeReport, codex: codexReport };
262
+ const report: StatusReport = { now, claude: claudeReport, codex: codexReport, grok: grokReport, opencodeGo: opencodeGoReport };
257
263
  emitJson({ ok: true, ...report });
258
264
  return 0;
259
265
  }
260
266
  if (codexReport.accounts.length > 0) {
261
267
  renderPool(codex, codexReport, `codex (${count({ n: codexReport.accounts.length, noun: "account" })})`, Date.now(), cfg.policy.usagePollTtlMs);
262
268
  }
269
+ if (grokReport.accounts.length > 0) {
270
+ renderPool(grok, grokReport, `grok (${count({ n: grokReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
271
+ }
272
+ if (opencodeGoReport.accounts.length > 0) {
273
+ renderPool(opencodeGo, opencodeGoReport, `opencode-go (${count({ n: opencodeGoReport.accounts.length, noun: "account" })}, status-only)`, Date.now(), cfg.policy.usagePollTtlMs);
274
+ }
263
275
  return 0;
264
276
  }
@@ -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/claude.ts CHANGED
@@ -274,6 +274,7 @@ export const claude: Provider = {
274
274
  pool: claudePool,
275
275
  seats: "shared",
276
276
  waitsWhenDepleted: true,
277
+ statusOnly: false,
277
278
  liveId,
278
279
  presence,
279
280
  gatedFamilies: (cfg) => {