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 +6 -5
- package/README.md +6 -7
- package/package.json +1 -1
- package/src/cli/auth.ts +1 -1
- package/src/cli/doctor.ts +33 -2
- package/src/cli/init.ts +1 -4
- package/src/entries/codexstophook.ts +7 -78
- package/src/entries/codexsupervisor.ts +39 -27
- package/src/lib/codex.ts +85 -175
- package/src/lib/codexauth.ts +36 -27
- package/src/lib/install.ts +35 -1
- package/src/lib/paths.ts +14 -5
- package/src/lib/picker.ts +17 -6
- package/src/lib/types.ts +4 -9
- package/src/main.ts +5 -14
- 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
|
|
|
@@ -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.
|
|
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-
|
|
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
|
|
|
@@ -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":
|
|
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
|
|
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 #
|
|
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.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
|
|
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 =
|
|
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 {
|
|
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
|
}
|
package/src/lib/codex.ts
CHANGED
|
@@ -1,23 +1,38 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync,
|
|
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,
|
|
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 {
|
|
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 {
|
|
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,
|
|
16
|
+
import { loadAccounts, loadConfig, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
|
|
17
17
|
import { restoreTermios, saveTermios } from "./tty.ts";
|
|
18
|
-
import {
|
|
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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
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
|
|
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
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
111
|
+
auth = readCodexStoreAuth(target.id);
|
|
144
112
|
} catch (e) {
|
|
145
|
-
|
|
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
|
-
|
|
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 () =>
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
291
|
-
console.log(c.yellow("open codex, run /hooks, and trust the tokenmaxxing Stop hook - auto-switching
|
|
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
|
|
299
|
-
const
|
|
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
|
|
303
|
-
if (
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
330
|
-
presence
|
|
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) =>
|
|
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
|
|
250
|
+
return readCodexStoreAuth(a.id) != null;
|
|
341
251
|
} catch {
|
|
342
252
|
return false;
|
|
343
253
|
}
|
package/src/lib/codexauth.ts
CHANGED
|
@@ -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
|
|
23
|
-
return readCodexAuthAt({ path:
|
|
22
|
+
export function readCodexStoreAuth(accountId: string): CodexAuthJson | null {
|
|
23
|
+
return readCodexAuthAt({ path: codexAuthJsonFor(accountId) });
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
export function
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
|
|
31
|
+
export function deleteCodexStoreAuth(accountId: string): void {
|
|
32
|
+
rmSync(codexStoreDirFor(accountId), { recursive: true, force: true });
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
function
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
let
|
|
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
|
-
|
|
42
|
+
names = readdirSync(codexPaths.home);
|
|
44
43
|
} catch (e) {
|
|
45
|
-
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return
|
|
44
|
+
if (ErrnoSchema.safeParse(e).data?.code === "ENOENT") return store;
|
|
46
45
|
throw e;
|
|
47
46
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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({
|
package/src/lib/install.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
83
|
-
return
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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 <
|
|
128
|
+
.refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
|
|
129
129
|
path: ["policy", "projectionMargin"],
|
|
130
|
-
message: "must be strictly below
|
|
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"
|
|
38
|
-
const CODEX_COMMANDS = new Set(["init", "add", "auth", "
|
|
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:
|
|
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
|
|
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-
|
|
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
|
-
}
|