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 +5 -4
- package/README.md +4 -5
- package/package.json +1 -1
- package/src/cli/auth.ts +2 -2
- package/src/cli/doctor.ts +33 -2
- package/src/cli/init.ts +6 -5
- package/src/cli/status.ts +17 -5
- package/src/entries/codexstophook.ts +7 -78
- package/src/entries/codexsupervisor.ts +39 -27
- package/src/lib/claude.ts +1 -0
- package/src/lib/codex.ts +86 -175
- package/src/lib/codexauth.ts +36 -27
- package/src/lib/grok.ts +188 -0
- package/src/lib/install.ts +35 -1
- package/src/lib/opencodego.ts +186 -0
- package/src/lib/paths.ts +69 -5
- package/src/lib/provider.ts +2 -1
- package/src/lib/state.ts +6 -2
- package/src/lib/types.ts +3 -6
- package/src/main.ts +28 -19
- package/src/cli/switch.ts +0 -116
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
|
|
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
|
|
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
|
-
-
|
|
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-
|
|
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:
|
|
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`,
|
|
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 #
|
|
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,
|
|
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.
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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 {
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { codexPaths
|
|
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
|
|
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 {
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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) -
|
|
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
|
|
99
|
-
|
|
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: {
|
|
128
|
+
env: {
|
|
129
|
+
...childEnv,
|
|
130
|
+
[CODEX_SUPERVISOR_ID_ENV]: supervisorId,
|
|
131
|
+
...(store ? { CODEX_HOME: store } : {}),
|
|
132
|
+
},
|
|
105
133
|
});
|
|
106
|
-
if (
|
|
107
|
-
|
|
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
|
-
|
|
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
|
}
|