tokenmaxxing 0.12.0 → 0.13.1
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 +3 -1
- package/README.md +17 -0
- package/package.json +1 -1
- package/src/cli/add.ts +3 -3
- package/src/cli/codexadd.ts +99 -0
- package/src/cli/codexinit.ts +104 -0
- package/src/cli/codexswitch.ts +80 -0
- package/src/cli/doctor.ts +2 -2
- package/src/cli/ls.ts +21 -3
- package/src/cli/render.ts +5 -0
- package/src/cli/status.ts +75 -5
- package/src/cli/watch.ts +4 -4
- package/src/entries/codexstophook.ts +67 -0
- package/src/entries/codexsupervisor.ts +150 -0
- package/src/lib/codexauth.ts +122 -0
- package/src/lib/codexbin.ts +64 -0
- package/src/lib/codexdecide.ts +157 -0
- package/src/lib/codexoauth.ts +99 -0
- package/src/lib/codexpick.ts +106 -0
- package/src/lib/codexpresence.ts +77 -0
- package/src/lib/codexsample.ts +62 -0
- package/src/lib/codexstate.ts +29 -0
- package/src/lib/codexswap.ts +93 -0
- package/src/lib/codexusage.ts +135 -0
- package/src/lib/http.ts +26 -0
- package/src/lib/install.ts +80 -1
- package/src/lib/paths.ts +42 -4
- package/src/lib/state.ts +7 -2
- package/src/lib/types.ts +89 -0
- package/src/main.ts +15 -3
- package/src/sdk.ts +1 -1
package/DESIGN.md
CHANGED
|
@@ -101,7 +101,9 @@ The decision engages at `five_hour >= 50%` (policy.greedySessionFloor): from the
|
|
|
101
101
|
|
|
102
102
|
**Shipped since (0.11.0):** a programmatic SDK surface (`src/sdk.ts`, the package's `exports["."]`) for pairing with the Claude Agent SDK - personal use across the owner's own pooled accounts. The Agent SDK reads credentials per subprocess spawn and has no statusLine, so the surface is boundary-driven: run the shared switch decision before a spawn (`ensureBestAccount`) and at Stop-hook turn boundaries (`stopHookCheck`), and hand the SDK a pinned real-claude path plus a full replacement env scrubbed of credential overrides (`pooledOptions`).
|
|
103
103
|
|
|
104
|
-
**Later:**
|
|
104
|
+
**Later:** tool-agnostic picker.
|
|
105
|
+
|
|
106
|
+
**Shipped since (0.13.0):** Codex as a second pool, parallel state (`codex-accounts.json`, `codex-creds/`, own flock), same pace-pressure policy. Codex differences that shaped it: restart IS the switch (a running codex refuses another account's credential), usage is a free direct GET with epoch resets and a duration-classified window set (the weekly window is primary on current plans), the refresh token rotates with reuse punished (harvest-by-true-owner, persist every rotation), and codex's new Stop-hook system drives the auto-swap through a codex supervisor shim that respawns `codex resume <session-id>` (hooks must be trusted once via `/hooks`).
|
|
105
107
|
|
|
106
108
|
**Non-goals:** an API/MITM proxy; reimplementing OAuth beyond the single refresh-grant call in the swap.
|
|
107
109
|
|
package/README.md
CHANGED
|
@@ -36,7 +36,10 @@ claude # use claude as always
|
|
|
36
36
|
| command | what it does |
|
|
37
37
|
|---|---|
|
|
38
38
|
| `tokenmaxxing init` | import the current account + install supervisor & hooks |
|
|
39
|
+
| `tokenmaxxing init --codex` | same for codex: import login, install codex supervisor + Stop hook |
|
|
39
40
|
| `tokenmaxxing add` | register an additional account (isolated login, harvested into the pool) |
|
|
41
|
+
| `tokenmaxxing add --codex` | register an additional codex account (isolated login) |
|
|
42
|
+
| `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
|
|
40
43
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
41
44
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
42
45
|
| `tokenmaxxing status --force` | additionally ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh |
|
|
@@ -101,6 +104,20 @@ The SDK reads credentials when it spawns the claude subprocess and has no status
|
|
|
101
104
|
|
|
102
105
|
This is for pooling **your own** subscription accounts in agents you run yourself - the same personal-use posture as the CLI. Anthropic does not allow third-party products to offer claude.ai login or rate limits, including agents built on the Agent SDK; don't ship this surface to third parties.
|
|
103
106
|
|
|
107
|
+
## Codex support
|
|
108
|
+
|
|
109
|
+
The same pooling works for OpenAI's Codex CLI (your own ChatGPT-subscription accounts):
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
tokenmaxxing init --codex # import your current codex login + install the codex supervisor & Stop hook
|
|
113
|
+
tokenmaxxing add --codex # log in another account, isolated - your primary login is untouched
|
|
114
|
+
codex # use codex as always
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
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**. The installed Stop hook runs the same greedy pace-pressure decision at each turn boundary (usage read free from codex's own rate-limit endpoint: percentages plus absolute reset times, weekly aggregate and per-model caps alike); when it swaps, the supervisor relaunches `codex resume <session-id>` on the fresh account with the transcript intact. `tokenmaxxing switch --codex [sel]` does it manually, `status`/`watch`/`ls` show both pools.
|
|
118
|
+
|
|
119
|
+
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.
|
|
120
|
+
|
|
104
121
|
## Honest limitations
|
|
105
122
|
|
|
106
123
|
- **One cold turn.** The first turn after resuming on a new account re-uploads context once (prompt cache is org-scoped).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/cli/add.ts
CHANGED
|
@@ -15,7 +15,7 @@ import { withLock } from "../lib/lock.ts";
|
|
|
15
15
|
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
16
16
|
import { credItemFor, paths } from "../lib/paths.ts";
|
|
17
17
|
import { CredentialBlobSchema, OAuthAccountSchema, type Account } from "../lib/types.ts";
|
|
18
|
-
import { c } from "./render.ts";
|
|
18
|
+
import { c, count } from "./render.ts";
|
|
19
19
|
|
|
20
20
|
/** True once `/login` has written a usable identity into the onboard dir. */
|
|
21
21
|
function identityReady(cjPath: string): boolean {
|
|
@@ -129,7 +129,7 @@ export async function cmdAdd(): Promise<number> {
|
|
|
129
129
|
await cleanup();
|
|
130
130
|
|
|
131
131
|
console.log();
|
|
132
|
-
const usageNote = sampled ? `
|
|
133
|
-
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${poolSize
|
|
132
|
+
const usageNote = sampled ? ` (session ${sampled.session.usedPercentage}% / week ${sampled.weekAll.usedPercentage}%)` : "";
|
|
133
|
+
console.log(`${c.green("✓")} added ${c.bold(account.email)} (${account.subscriptionType ?? "?"})${usageNote} → pool now has ${count({ n: poolSize, noun: "account" })}`);
|
|
134
134
|
return 0;
|
|
135
135
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `tokenmaxxing add --codex` - register an ADDITIONAL codex account. Runs
|
|
2
|
+
// `codex login` inside a throwaway CODEX_HOME pinned to file-mode credential
|
|
3
|
+
// storage (auto mode would keyring-namespace the login on macOS and leave
|
|
4
|
+
// nothing harvestable), then parks the resulting auth.json under the token's
|
|
5
|
+
// OWN identity and indexes it. The primary login is never touched.
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV } from "../lib/claudebin.ts";
|
|
10
|
+
import { resolveRealCodex } from "../lib/codexbin.ts";
|
|
11
|
+
import { codexIdentityOf, readCodexAuthAt, writeParkedCodexAuth } from "../lib/codexauth.ts";
|
|
12
|
+
import { CodexUsageReadError, fetchCodexUsage } from "../lib/codexusage.ts";
|
|
13
|
+
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
14
|
+
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
15
|
+
import { withLock } from "../lib/lock.ts";
|
|
16
|
+
import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
|
|
17
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
18
|
+
import type { CodexAccount, CodexUsage } from "../lib/types.ts";
|
|
19
|
+
import { c, count } from "./render.ts";
|
|
20
|
+
|
|
21
|
+
export async function cmdCodexAdd(): Promise<number> {
|
|
22
|
+
const real = resolveRealCodex();
|
|
23
|
+
const onboardDir = codexPaths.onboardDir;
|
|
24
|
+
// Deliberate hard delete (not trash; user decision 2026-07-16): the onboard
|
|
25
|
+
// dir holds a plaintext live credential after login, and a trashed copy
|
|
26
|
+
// would keep that token readable. The dir is tokenmaxxing's own transient
|
|
27
|
+
// artifact; its one durable output is parked separately before cleanup.
|
|
28
|
+
// Same rationale at the end-of-run cleanup below.
|
|
29
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
30
|
+
mkdirSync(onboardDir, { recursive: true });
|
|
31
|
+
writeFileAtomic(join(onboardDir, "config.toml"), 'cli_auth_credentials_store_mode = "file"\n');
|
|
32
|
+
|
|
33
|
+
console.log(c.cyan("Opening an isolated codex login - your primary login is untouched."));
|
|
34
|
+
console.log(c.dim("Complete the browser sign-in with the account to add; the command exits once you're in."));
|
|
35
|
+
console.log();
|
|
36
|
+
|
|
37
|
+
const savedTermios = saveTermios();
|
|
38
|
+
const p = Bun.spawn([real, "login"], {
|
|
39
|
+
stdin: "inherit",
|
|
40
|
+
stdout: "inherit",
|
|
41
|
+
stderr: "inherit",
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
CODEX_HOME: onboardDir,
|
|
45
|
+
TOKENMAXXING_PROBE: "1",
|
|
46
|
+
[WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH),
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
await p.exited;
|
|
50
|
+
restoreTermios(savedTermios);
|
|
51
|
+
|
|
52
|
+
const cleanup = () => rmSync(onboardDir, { recursive: true, force: true });
|
|
53
|
+
|
|
54
|
+
const auth = readCodexAuthAt({ path: join(onboardDir, "auth.json") });
|
|
55
|
+
if (p.exitCode !== 0 || !auth) {
|
|
56
|
+
console.error(c.red("no codex login landed in the isolated home - nothing added."));
|
|
57
|
+
cleanup();
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const identity = codexIdentityOf({ auth });
|
|
62
|
+
console.log(c.dim("sampling usage..."));
|
|
63
|
+
let usage: CodexUsage | null = null;
|
|
64
|
+
try {
|
|
65
|
+
usage = await fetchCodexUsage({ auth });
|
|
66
|
+
} catch (e) {
|
|
67
|
+
if (!(e instanceof CodexUsageReadError)) throw e;
|
|
68
|
+
console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const credFile = codexCredItemFor(identity.accountId);
|
|
72
|
+
writeParkedCodexAuth({ credFile, auth });
|
|
73
|
+
|
|
74
|
+
const { account, poolSize } = await withLock(codexPaths.lockFile, () => {
|
|
75
|
+
const index = loadCodexAccounts();
|
|
76
|
+
const existing = index.accounts.find((entry) => entry.accountId === identity.accountId);
|
|
77
|
+
const fresh: CodexAccount = {
|
|
78
|
+
accountId: identity.accountId,
|
|
79
|
+
email: usage?.email ?? identity.email,
|
|
80
|
+
label: existing?.label ?? usage?.email ?? identity.email ?? identity.accountId.slice(0, 8),
|
|
81
|
+
planType: usage?.planType ?? identity.planType,
|
|
82
|
+
credFile,
|
|
83
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
84
|
+
needsReauth: false,
|
|
85
|
+
lastUsage: usage ? { aggregate: usage.aggregate, perLimit: usage.perLimit } : existing?.lastUsage,
|
|
86
|
+
lastUsageAt: usage ? Date.now() : existing?.lastUsageAt,
|
|
87
|
+
};
|
|
88
|
+
if (existing) Object.assign(existing, fresh);
|
|
89
|
+
else index.accounts.push(fresh);
|
|
90
|
+
saveCodexAccounts({ index });
|
|
91
|
+
return { account: fresh, poolSize: index.accounts.length };
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
cleanup();
|
|
95
|
+
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(`${c.green("✓")} added codex account ${c.bold(account.label)} (${account.planType ?? "?"}) - codex pool now has ${count({ n: poolSize, noun: "account" })}`);
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// `tokenmaxxing init --codex` - bring the codex side up: verify + pin the real
|
|
2
|
+
// codex binary, import the existing live login as codex account #1, install
|
|
3
|
+
// the on-PATH codex supervisor shim and the Stop hook declaration, and tell
|
|
4
|
+
// the user about the one step tokenmaxxing cannot do for them: codex skips
|
|
5
|
+
// hooks it has not been TRUSTED to run (trust is recorded against the hook's
|
|
6
|
+
// hash via /hooks in the codex TUI), so auto-switching stays inert until then.
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
9
|
+
import { resolveRealCodex, verifyRealCodex } from "../lib/codexbin.ts";
|
|
10
|
+
import { codexIdentityOf, readLiveCodexAuth, writeParkedCodexAuth } from "../lib/codexauth.ts";
|
|
11
|
+
import { CodexUsageReadError, fetchCodexUsage } from "../lib/codexusage.ts";
|
|
12
|
+
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
13
|
+
import { loadConfig, saveConfig } from "../lib/state.ts";
|
|
14
|
+
import { installCodexSupervisor, codexSupervisorLink, ensurePathInRc, shellRcPath } from "../lib/install.ts";
|
|
15
|
+
import { withLock } from "../lib/lock.ts";
|
|
16
|
+
import { codexCredItemFor, codexPaths } from "../lib/paths.ts";
|
|
17
|
+
import type { CodexAccount, CodexUsage } from "../lib/types.ts";
|
|
18
|
+
import { c } from "./render.ts";
|
|
19
|
+
|
|
20
|
+
/** Fail fast when config.toml pins the credential store away from the plain
|
|
21
|
+
* file tokenmaxxing swaps (structural line scan: the repo ships no TOML
|
|
22
|
+
* parser, and this single key is the only one we ever inspect). */
|
|
23
|
+
function keyringStoreConfigured(): boolean {
|
|
24
|
+
const configToml = `${codexPaths.home}/config.toml`;
|
|
25
|
+
if (!existsSync(configToml)) return false;
|
|
26
|
+
for (const rawLine of readFileSync(configToml, "utf8").split("\n")) {
|
|
27
|
+
const line = rawLine.trim();
|
|
28
|
+
if (!line.startsWith("cli_auth_credentials_store_mode")) continue;
|
|
29
|
+
return line.includes("keyring");
|
|
30
|
+
}
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function cmdCodexInit(): Promise<number> {
|
|
35
|
+
const real = resolveRealCodex();
|
|
36
|
+
const fail = verifyRealCodex({ bin: real });
|
|
37
|
+
if (fail !== null) {
|
|
38
|
+
console.error(c.red(`codex binary failed verification: ${real}: ${fail}`));
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
const cfg = loadConfig();
|
|
42
|
+
cfg.codexBin = real;
|
|
43
|
+
saveConfig(cfg);
|
|
44
|
+
|
|
45
|
+
if (keyringStoreConfigured()) {
|
|
46
|
+
console.error(c.red("codex config.toml pins cli_auth_credentials_store_mode to keyring - tokenmaxxing swaps the plain auth.json file."));
|
|
47
|
+
console.error(c.dim('recovery: set cli_auth_credentials_store_mode = "file" in ~/.codex/config.toml, run `codex login`, then re-run this.'));
|
|
48
|
+
return 1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const live = readLiveCodexAuth();
|
|
52
|
+
if (!live) {
|
|
53
|
+
console.error(c.red(`no codex login found at ${codexPaths.authJson} - run \`codex login\` first, then re-run this.`));
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
const identity = codexIdentityOf({ auth: live });
|
|
57
|
+
|
|
58
|
+
console.log(c.dim("sampling usage..."));
|
|
59
|
+
let usage: CodexUsage | null = null;
|
|
60
|
+
try {
|
|
61
|
+
usage = await fetchCodexUsage({ auth: live });
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (!(e instanceof CodexUsageReadError)) throw e;
|
|
64
|
+
console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const credFile = codexCredItemFor(identity.accountId);
|
|
68
|
+
writeParkedCodexAuth({ credFile, auth: live });
|
|
69
|
+
|
|
70
|
+
const account = await withLock(codexPaths.lockFile, () => {
|
|
71
|
+
const index = loadCodexAccounts();
|
|
72
|
+
const existing = index.accounts.find((entry) => entry.accountId === identity.accountId);
|
|
73
|
+
const fresh: CodexAccount = {
|
|
74
|
+
accountId: identity.accountId,
|
|
75
|
+
email: usage?.email ?? identity.email,
|
|
76
|
+
label: existing?.label ?? usage?.email ?? identity.email ?? identity.accountId.slice(0, 8),
|
|
77
|
+
planType: usage?.planType ?? identity.planType,
|
|
78
|
+
credFile,
|
|
79
|
+
addedAt: existing?.addedAt ?? new Date().toISOString(),
|
|
80
|
+
needsReauth: false,
|
|
81
|
+
lastUsage: usage ? { aggregate: usage.aggregate, perLimit: usage.perLimit } : existing?.lastUsage,
|
|
82
|
+
lastUsageAt: usage ? Date.now() : existing?.lastUsageAt,
|
|
83
|
+
};
|
|
84
|
+
if (existing) Object.assign(existing, fresh);
|
|
85
|
+
else index.accounts.push(fresh);
|
|
86
|
+
index.activeAccountId = identity.accountId;
|
|
87
|
+
saveCodexAccounts({ index });
|
|
88
|
+
return fresh;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
installCodexSupervisor();
|
|
92
|
+
const rc = shellRcPath();
|
|
93
|
+
if (rc) ensurePathInRc(rc);
|
|
94
|
+
|
|
95
|
+
console.log();
|
|
96
|
+
console.log(`${c.green("✓")} imported codex account ${c.bold(account.label)} (${account.planType ?? "?"})`);
|
|
97
|
+
console.log(`${c.green("✓")} codex supervisor installed at ${codexSupervisorLink()}`);
|
|
98
|
+
console.log(`${c.green("✓")} Stop hook declared in ${codexPaths.hooksJson}`);
|
|
99
|
+
console.log();
|
|
100
|
+
console.log(c.bold(c.yellow("one manual step: codex skips untrusted hooks.")));
|
|
101
|
+
console.log(c.yellow("open codex, run /hooks, and trust the tokenmaxxing Stop hook - auto-switching is inert until then."));
|
|
102
|
+
console.log(c.dim("then add more accounts with `tokenmaxxing add --codex`."));
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// `tokenmaxxing switch --codex [sel]`. Bare form is GREEDY and IDEMPOTENT off
|
|
2
|
+
// cached windows, exactly like the claude switch: rank every codex account
|
|
3
|
+
// (current included) by pace pressure among those under their screening bars;
|
|
4
|
+
// when the current account already wins, do nothing. The current account is
|
|
5
|
+
// the LIVE auth.json's own identity, never the stored label. The swap takes
|
|
6
|
+
// effect for the NEXT codex start: a running codex refuses a different
|
|
7
|
+
// account's credential (restart is the switch; the Stop hook + supervisor
|
|
8
|
+
// automate that for supervised sessions).
|
|
9
|
+
|
|
10
|
+
import { withLock } from "../lib/lock.ts";
|
|
11
|
+
import { codexPaths } from "../lib/paths.ts";
|
|
12
|
+
import { loadConfig } from "../lib/state.ts";
|
|
13
|
+
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
14
|
+
import { codexCurrentWins, pickBestCodex } from "../lib/codexpick.ts";
|
|
15
|
+
import { performCodexSwap } from "../lib/codexswap.ts";
|
|
16
|
+
import { CodexInvalidGrantError } from "../lib/codexoauth.ts";
|
|
17
|
+
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
18
|
+
import { presentCodexAccountIds, targetableCodexAccounts } from "../lib/codexpresence.ts";
|
|
19
|
+
import { effectiveBars } from "../lib/picker.ts";
|
|
20
|
+
import { c } from "./render.ts";
|
|
21
|
+
|
|
22
|
+
export async function cmdCodexSwitch(sel?: string): Promise<number> {
|
|
23
|
+
const cfg = loadConfig();
|
|
24
|
+
const bars = effectiveBars(cfg);
|
|
25
|
+
const now = Date.now();
|
|
26
|
+
|
|
27
|
+
return withLock(codexPaths.lockFile, async () => {
|
|
28
|
+
const index = loadCodexAccounts();
|
|
29
|
+
if (index.accounts.length === 0) {
|
|
30
|
+
console.log(c.dim("no codex accounts yet - run `tokenmaxxing init --codex`"));
|
|
31
|
+
return 1;
|
|
32
|
+
}
|
|
33
|
+
const currentId = liveCodexAccountId();
|
|
34
|
+
|
|
35
|
+
if (sel !== undefined) {
|
|
36
|
+
const target = index.accounts.find(
|
|
37
|
+
(account) => account.label === sel || account.email === sel || account.accountId.startsWith(sel),
|
|
38
|
+
);
|
|
39
|
+
if (!target) {
|
|
40
|
+
console.error(c.red(`no codex account matches "${sel}"`));
|
|
41
|
+
for (const account of index.accounts) console.error(c.dim(` ${account.label} (${account.accountId.slice(0, 8)})`));
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
if (target.accountId === currentId) {
|
|
45
|
+
console.log(`already on ${c.bold(target.label)}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
if (presentCodexAccountIds().has(target.accountId)) {
|
|
49
|
+
console.error(c.red(`${target.label} is running in a live codex session - swapping onto it would break that session's credential`));
|
|
50
|
+
return 1;
|
|
51
|
+
}
|
|
52
|
+
await performCodexSwap({ target });
|
|
53
|
+
console.log(`${c.green("✓")} switched codex to ${c.bold(target.label)} (takes effect on the next codex start)`);
|
|
54
|
+
return 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
while (true) {
|
|
58
|
+
const current = loadCodexAccounts();
|
|
59
|
+
const candidates = targetableCodexAccounts({ accounts: current.accounts, activeAccountId: currentId });
|
|
60
|
+
const active = candidates.find((account) => account.accountId === currentId) ?? null;
|
|
61
|
+
if (codexCurrentWins({ active, accounts: candidates, thresholds: bars, now })) {
|
|
62
|
+
console.log(`already on the best codex account: ${c.bold(active?.label ?? "?")}`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
const best = pickBestCodex({ accounts: candidates, thresholds: bars, now, currentAccountId: currentId });
|
|
66
|
+
if (!best) {
|
|
67
|
+
console.log(c.yellow("no usable codex switch target (all at their bars, unmeasured, or needing reauth)"));
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
await performCodexSwap({ target: best });
|
|
72
|
+
} catch (e) {
|
|
73
|
+
if (e instanceof CodexInvalidGrantError) continue;
|
|
74
|
+
throw e;
|
|
75
|
+
}
|
|
76
|
+
console.log(`${c.green("✓")} switched codex to ${c.bold(best.label)} (takes effect on the next codex start)`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -52,7 +52,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
52
52
|
try {
|
|
53
53
|
const org = await blobOrg(live);
|
|
54
54
|
if (org) check(org.organization_uuid === active.organizationUuid, `live credential identity matches active (${active.email})`, `token belongs to ${org.organization_name} - run \`tokenmaxxing switch\``);
|
|
55
|
-
else console.log(c.dim(`
|
|
55
|
+
else console.log(c.dim(` - live credential identity unverifiable (access token expired)`));
|
|
56
56
|
} catch (e) {
|
|
57
57
|
check(false, `live credential identity matches active (${active.email})`, String((e as Error).message ?? e).slice(0, 100));
|
|
58
58
|
}
|
|
@@ -65,7 +65,7 @@ export async function cmdDoctor(): Promise<number> {
|
|
|
65
65
|
try {
|
|
66
66
|
const org = await blobOrg(parked);
|
|
67
67
|
if (org) check(org.organization_uuid === a.organizationUuid, `parked credential identity matches ${a.email}`, `token belongs to ${org.organization_name} - re-auth with \`tokenmaxxing add\``);
|
|
68
|
-
else console.log(c.dim(`
|
|
68
|
+
else console.log(c.dim(` - ${a.email} identity unverifiable (access token expired)`));
|
|
69
69
|
} catch (e) {
|
|
70
70
|
check(false, `parked credential identity matches ${a.email}`, String((e as Error).message ?? e).slice(0, 100));
|
|
71
71
|
}
|
package/src/cli/ls.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
// `tokenmaxxing ls` - compact list of pooled accounts.
|
|
1
|
+
// `tokenmaxxing ls` - compact list of pooled accounts, claude then codex.
|
|
2
2
|
|
|
3
3
|
import { loadAccounts } from "../lib/state.ts";
|
|
4
|
+
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
5
|
+
import { liveCodexAccountId } from "../lib/codexsample.ts";
|
|
4
6
|
import { c } from "./render.ts";
|
|
5
7
|
|
|
6
8
|
export function cmdLs(): number {
|
|
7
9
|
const idx = loadAccounts();
|
|
8
10
|
if (idx.accounts.length === 0) {
|
|
9
11
|
console.log(c.dim("no accounts yet - run `tokenmaxxing init` then `tokenmaxxing add`"));
|
|
10
|
-
return 0;
|
|
11
12
|
}
|
|
12
13
|
for (const a of idx.accounts) {
|
|
13
14
|
const active = a.accountUuid === idx.activeAccountUuid;
|
|
@@ -18,7 +19,24 @@ export function cmdLs(): number {
|
|
|
18
19
|
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
19
20
|
const label = a.label || a.email;
|
|
20
21
|
console.log(`${marker} ${c.bold(label)}${tag}`);
|
|
21
|
-
console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}
|
|
22
|
+
console.log(` ${c.dim(`org ${a.organizationUuid.slice(0, 8)}, ${a.subscriptionType ?? "?"}, uuid ${a.accountUuid.slice(0, 8)}`)}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const codex = loadCodexAccounts();
|
|
26
|
+
if (codex.accounts.length > 0) {
|
|
27
|
+
const liveId = liveCodexAccountId();
|
|
28
|
+
console.log();
|
|
29
|
+
console.log(c.dim("codex"));
|
|
30
|
+
for (const account of codex.accounts) {
|
|
31
|
+
const active = account.accountId === liveId;
|
|
32
|
+
const marker = active ? c.green("●") : c.dim("○");
|
|
33
|
+
const flags: string[] = [];
|
|
34
|
+
if (active) flags.push(c.green("active"));
|
|
35
|
+
if (account.needsReauth) flags.push(c.red("needs-reauth"));
|
|
36
|
+
const tag = flags.length ? ` ${flags.join(" ")}` : "";
|
|
37
|
+
console.log(`${marker} ${c.bold(account.label)}${tag}`);
|
|
38
|
+
console.log(` ${c.dim(`${account.planType ?? "?"}, id ${account.accountId.slice(0, 8)}`)}`);
|
|
39
|
+
}
|
|
22
40
|
}
|
|
23
41
|
return 0;
|
|
24
42
|
}
|
package/src/cli/render.ts
CHANGED
|
@@ -18,6 +18,11 @@ export function makeColors(enabled: boolean) {
|
|
|
18
18
|
|
|
19
19
|
export const c = makeColors(!process.env.NO_COLOR && !!process.stdout.isTTY);
|
|
20
20
|
|
|
21
|
+
/** "1 account" / "3 accounts": counted nouns always pluralize properly. */
|
|
22
|
+
export function count(input: { n: number; noun: string }): string {
|
|
23
|
+
return `${input.n} ${input.noun}${input.n === 1 ? "" : "s"}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
/** A fixed-width usage bar, colored by fill. */
|
|
22
27
|
export function bar(pct: number, width = 16): string {
|
|
23
28
|
const clamped = clamp(pct, 0, 100);
|
package/src/cli/status.ts
CHANGED
|
@@ -16,11 +16,15 @@ import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } fro
|
|
|
16
16
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
17
17
|
import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
18
18
|
import { withLock } from "../lib/lock.ts";
|
|
19
|
-
import { paths } from "../lib/paths.ts";
|
|
19
|
+
import { codexPaths, paths } from "../lib/paths.ts";
|
|
20
20
|
import { effectiveBars, isExhausted, nextWeeklyReset } from "../lib/picker.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { loadCodexAccounts, saveCodexAccounts } from "../lib/codexstate.ts";
|
|
22
|
+
import { liveCodexAccountId, sampleCodexAccount, type CodexSampleOutcome } from "../lib/codexsample.ts";
|
|
23
|
+
import { isCodexExhausted } from "../lib/codexpick.ts";
|
|
24
|
+
import { isSessionWindow } from "../lib/codexusage.ts";
|
|
25
|
+
import { bar, c, count, fmtAgo, fmtReset } from "./render.ts";
|
|
22
26
|
import type { FullUsage } from "../lib/usage.ts";
|
|
23
|
-
import type { UsageWindow } from "../lib/types.ts";
|
|
27
|
+
import type { Config, CodexWindow, UsageWindow } from "../lib/types.ts";
|
|
24
28
|
|
|
25
29
|
/** `preRender` runs after sampling, right before the first output line: `watch`
|
|
26
30
|
* keeps the previous frame on screen through the multi-second sample and
|
|
@@ -93,7 +97,7 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
93
97
|
});
|
|
94
98
|
|
|
95
99
|
preRender?.();
|
|
96
|
-
console.log(c.dim(`
|
|
100
|
+
console.log(c.dim(`thresholds 5h ${cfg.thresholds.session}% weekly ${cfg.thresholds.weekly}% (${count({ n: idx.accounts.length, noun: "claude account" })})`));
|
|
97
101
|
console.log();
|
|
98
102
|
|
|
99
103
|
// A window whose cached reset has passed is empty again; weekly windows recur
|
|
@@ -129,7 +133,7 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
129
133
|
}
|
|
130
134
|
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w, true);
|
|
131
135
|
if (failed && outcome && !outcome.ok) {
|
|
132
|
-
const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""}
|
|
136
|
+
const cached = aggregate || perModel ? `cached${a.lastUsageAt != null ? ` ${fmtAgo(a.lastUsageAt, now)}` : ""}, ` : "";
|
|
133
137
|
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
|
|
134
138
|
}
|
|
135
139
|
if (outcome?.pingError != null) {
|
|
@@ -144,5 +148,71 @@ export async function cmdStatus(force = false, preRender?: () => void): Promise<
|
|
|
144
148
|
}
|
|
145
149
|
console.log();
|
|
146
150
|
}
|
|
151
|
+
|
|
152
|
+
await renderCodexSection({ cfg, now, row });
|
|
147
153
|
return 0;
|
|
148
154
|
}
|
|
155
|
+
|
|
156
|
+
/** The codex pool, appended when it is non-empty. Every account samples via
|
|
157
|
+
* the free usage GET (nothing metered, no window started): the live account
|
|
158
|
+
* through the live auth.json, parked accounts through their parked blobs. */
|
|
159
|
+
async function renderCodexSection(input: {
|
|
160
|
+
cfg: Config;
|
|
161
|
+
now: number;
|
|
162
|
+
row: (name: string, w: UsageWindow, weekly: boolean) => void;
|
|
163
|
+
}): Promise<void> {
|
|
164
|
+
const { cfg, now, row } = input;
|
|
165
|
+
let index = loadCodexAccounts();
|
|
166
|
+
if (index.accounts.length === 0) return;
|
|
167
|
+
|
|
168
|
+
console.error(c.dim("sampling codex usage..."));
|
|
169
|
+
const outcomes = new Map<string, CodexSampleOutcome>();
|
|
170
|
+
let liveId: string | null = null;
|
|
171
|
+
await withLock(codexPaths.lockFile, async () => {
|
|
172
|
+
index = loadCodexAccounts();
|
|
173
|
+
liveId = liveCodexAccountId();
|
|
174
|
+
await Promise.all(
|
|
175
|
+
index.accounts.map(async (account) => {
|
|
176
|
+
const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
|
|
177
|
+
outcomes.set(account.accountId, outcome);
|
|
178
|
+
if (outcome.ok) {
|
|
179
|
+
account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
|
|
180
|
+
account.lastUsageAt = Date.now();
|
|
181
|
+
if (outcome.usage.email != null) account.email = outcome.usage.email;
|
|
182
|
+
if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
|
|
183
|
+
} else if (outcome.deadGrant) {
|
|
184
|
+
account.needsReauth = true;
|
|
185
|
+
}
|
|
186
|
+
}),
|
|
187
|
+
);
|
|
188
|
+
saveCodexAccounts({ index });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
console.log(c.dim(`codex (${count({ n: index.accounts.length, noun: "account" })})`));
|
|
192
|
+
console.log();
|
|
193
|
+
const windowLabel = (window: CodexWindow) =>
|
|
194
|
+
isSessionWindow({ window }) ? `${Math.round((window.windowSeconds ?? 0) / 3600)}h` : "week";
|
|
195
|
+
for (const account of index.accounts) {
|
|
196
|
+
const active = account.accountId === liveId;
|
|
197
|
+
const marker = active ? c.green("●") : c.dim("○");
|
|
198
|
+
const badges: string[] = [];
|
|
199
|
+
if (active) badges.push(c.green("active"));
|
|
200
|
+
if (account.needsReauth) badges.push(c.red("needs-reauth"));
|
|
201
|
+
if (isCodexExhausted({ account, thresholds: effectiveBars(cfg), now })) badges.push(c.yellow("exhausted"));
|
|
202
|
+
console.log(`${marker} ${c.bold(account.label)} ${account.planType ? c.dim(account.planType) : ""} ${badges.join(" ")}`);
|
|
203
|
+
|
|
204
|
+
const usage = account.lastUsage;
|
|
205
|
+
if (usage) {
|
|
206
|
+
for (const window of usage.aggregate) row(windowLabel(window), window, !isSessionWindow({ window }));
|
|
207
|
+
for (const [name, windows] of Object.entries(usage.perLimit)) {
|
|
208
|
+
for (const window of windows) row(name, window, !isSessionWindow({ window }));
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const outcome = outcomes.get(account.accountId);
|
|
212
|
+
if (outcome && !outcome.ok) {
|
|
213
|
+
const cached = usage ? `cached${account.lastUsageAt != null ? ` ${fmtAgo(account.lastUsageAt, now)}` : ""}, ` : "";
|
|
214
|
+
console.log(` ${c.yellow(`${cached}live sample failed`)}: ${c.dim(outcome.reason)}`);
|
|
215
|
+
}
|
|
216
|
+
console.log();
|
|
217
|
+
}
|
|
218
|
+
}
|
package/src/cli/watch.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// 5h session windows, so watching must stay free; each refresh costs only the
|
|
5
5
|
// parked accounts' `/usage` probes the one-shot status already does.
|
|
6
6
|
|
|
7
|
-
import { delay } from "es-toolkit";
|
|
7
|
+
import { clamp, delay } from "es-toolkit";
|
|
8
8
|
import { loadAccounts } from "../lib/state.ts";
|
|
9
9
|
import { cmdStatus } from "./status.ts";
|
|
10
10
|
import { c } from "./render.ts";
|
|
@@ -22,7 +22,7 @@ export function resolveWatchInterval(arg?: string): number | null {
|
|
|
22
22
|
if (arg === undefined) return DEFAULT_INTERVAL_S;
|
|
23
23
|
const n = Number(arg);
|
|
24
24
|
if (!Number.isFinite(n) || n <= 0) return null;
|
|
25
|
-
return
|
|
25
|
+
return clamp(n, MIN_INTERVAL_S, MAX_INTERVAL_S);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/** Home + clear screen + clear scrollback. Written only once the fresh frame is
|
|
@@ -40,7 +40,7 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
|
|
|
40
40
|
|
|
41
41
|
const paintHeader = () => {
|
|
42
42
|
process.stdout.write(process.stdout.isTTY ? CLEAR : "\n");
|
|
43
|
-
console.log(c.dim(`watch
|
|
43
|
+
console.log(c.dim(`watch: every ${intervalS}s, ${new Date().toLocaleTimeString()}, ctrl-c to quit`));
|
|
44
44
|
};
|
|
45
45
|
while (true) {
|
|
46
46
|
// One failed tick (a mid-write ~/.claude.json read, a transient probe or
|
|
@@ -50,7 +50,7 @@ export async function cmdWatch(intervalArg?: string): Promise<number> {
|
|
|
50
50
|
await cmdStatus(false, paintHeader);
|
|
51
51
|
} catch (e) {
|
|
52
52
|
paintHeader();
|
|
53
|
-
console.error(c.red(`status failed this tick: ${
|
|
53
|
+
console.error(c.red(`status failed this tick: ${e instanceof Error ? e.message : String(e)}`));
|
|
54
54
|
}
|
|
55
55
|
await delay(intervalS * 1000);
|
|
56
56
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Codex Stop hook (installed in ~/.codex/hooks.json by `init --codex`). Fires
|
|
2
|
+
// when codex finishes a turn: the transcript is committed and the process is
|
|
3
|
+
// idle, the one boundary where killing it loses nothing. If the decision swaps
|
|
4
|
+
// accounts and this session runs under the codex supervisor, drop a respawn
|
|
5
|
+
// marker keyed by the supervisor's id; the supervisor SIGTERMs codex and
|
|
6
|
+
// relaunches `codex resume <session-id>` on the new account (a running codex
|
|
7
|
+
// never adopts a different account's credential: restart IS the switch).
|
|
8
|
+
//
|
|
9
|
+
// Contract with codex (verified against the 0.144.4 binary + hooks reference):
|
|
10
|
+
// stdin carries session_id, stdout `{}` on exit 0 is the documented no-op, and
|
|
11
|
+
// a hook failure must never block the stop - errors are logged, not thrown.
|
|
12
|
+
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { mkdirSync } from "node:fs";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import { codexPaths } from "../lib/paths.ts";
|
|
17
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
18
|
+
import { evaluateAndMaybeSwapCodex } from "../lib/codexdecide.ts";
|
|
19
|
+
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
20
|
+
import { CodexRespawnMarkerSchema, CodexStopStdinSchema } from "../lib/types.ts";
|
|
21
|
+
import { log } from "../lib/log.ts";
|
|
22
|
+
|
|
23
|
+
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
24
|
+
|
|
25
|
+
async function readStdin(): Promise<string> {
|
|
26
|
+
const chunks: Uint8Array[] = [];
|
|
27
|
+
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
28
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The testable core: decide, and on a swap under a supervisor, drop the
|
|
32
|
+
* respawn marker. Never throws (a hook failure must not block the stop). */
|
|
33
|
+
export async function handleCodexStop(input: { rawStdin: string }): Promise<void> {
|
|
34
|
+
const parsed = CodexStopStdinSchema.safeParse((() => {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(input.rawStdin);
|
|
37
|
+
} catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
})());
|
|
41
|
+
const sessionId = parsed.success ? (parsed.data.session_id ?? null) : null;
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const decision = await evaluateAndMaybeSwapCodex({});
|
|
45
|
+
const supervisorId = SupervisorIdSchema.parse(process.env[CODEX_SUPERVISOR_ID_ENV]);
|
|
46
|
+
if (decision.swapped && decision.account && supervisorId) {
|
|
47
|
+
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
48
|
+
const payload = CodexRespawnMarkerSchema.parse({
|
|
49
|
+
account: decision.account.label,
|
|
50
|
+
sessionId,
|
|
51
|
+
ts: Date.now(),
|
|
52
|
+
});
|
|
53
|
+
writeFileAtomic(join(codexPaths.respawnDir, supervisorId), JSON.stringify(payload));
|
|
54
|
+
log("codexstop.marker", { supervisorId: supervisorId.slice(0, 8) });
|
|
55
|
+
}
|
|
56
|
+
} catch (e) {
|
|
57
|
+
log("codexstop.error", { err: e instanceof Error ? e.message : String(e) });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function runCodexStopHook(): Promise<number> {
|
|
62
|
+
if (!process.env.TOKENMAXXING_PROBE) {
|
|
63
|
+
await handleCodexStop({ rawStdin: await readStdin() });
|
|
64
|
+
}
|
|
65
|
+
process.stdout.write("{}"); // documented no-op hook output; never block the stop
|
|
66
|
+
return 0;
|
|
67
|
+
}
|