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/src/lib/paths.ts
CHANGED
|
@@ -4,12 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
7
8
|
|
|
8
9
|
const HOME = homedir();
|
|
9
10
|
|
|
11
|
+
/** A set env override; empty or unset parses to undefined and the fallback applies. */
|
|
12
|
+
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
13
|
+
|
|
10
14
|
function env(name: string, fallback: string): string {
|
|
11
|
-
|
|
12
|
-
return v && v.length > 0 ? v : fallback;
|
|
15
|
+
return EnvOverrideSchema.parse(process.env[name]) ?? fallback;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
/** Root of all tokenmaxxing config + state. Default ~/.config/tokenmaxxing. */
|
|
@@ -52,6 +55,37 @@ export function claudeLockPath(): string {
|
|
|
52
55
|
return env("TOKENMAXXING_CLAUDE_LOCK", join(HOME, ".claude.lock"));
|
|
53
56
|
}
|
|
54
57
|
|
|
58
|
+
/** Codex home: where the live auth.json lives. Test override first, then
|
|
59
|
+
* codex's own CODEX_HOME env, then its default ~/.codex. */
|
|
60
|
+
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
61
|
+
|
|
62
|
+
export const codexPaths = {
|
|
63
|
+
home: CODEX_HOME,
|
|
64
|
+
/** the live credential file (codex file-mode store; verified 0.144.4/5). */
|
|
65
|
+
authJson: join(CODEX_HOME, "auth.json"),
|
|
66
|
+
/** user-level hook declarations codex reads (verified against the binary + docs). */
|
|
67
|
+
hooksJson: join(CODEX_HOME, "hooks.json"),
|
|
68
|
+
/** tokenmaxxing's codex pool state, parallel to the claude files in TM_HOME. */
|
|
69
|
+
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
70
|
+
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
71
|
+
lockFile: join(TM_HOME, "codex-lock"),
|
|
72
|
+
/** parked auth.json blobs: 0600 files on BOTH platforms (codex's own store is
|
|
73
|
+
* a plaintext file, and parked blobs at ~6KB would risk the security(1)
|
|
74
|
+
* write-size trap that once truncated a 4.3KB claude blob). */
|
|
75
|
+
credsDir: join(TM_HOME, "codex-creds"),
|
|
76
|
+
onboardDir: join(TM_HOME, "codex-onboard"),
|
|
77
|
+
respawnDir: join(TM_HOME, "codex-respawn"),
|
|
78
|
+
/** one file per RUNNING supervised codex session: {accountId, pid, ts}. A
|
|
79
|
+
* running account's parked token must never be refreshed or targeted (its
|
|
80
|
+
* live rotations supersede the parked copy, and reuse is punished). */
|
|
81
|
+
presenceDir: join(TM_HOME, "codex-live"),
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
/** Per-account parked codex credential file name: tokenmaxxing-codex-<id8>. */
|
|
85
|
+
export function codexCredItemFor(accountId: string): string {
|
|
86
|
+
return `tokenmaxxing-codex-${accountId.slice(0, 8)}`;
|
|
87
|
+
}
|
|
88
|
+
|
|
55
89
|
/** The macOS login-keychain generic-password the live `claude` reads. */
|
|
56
90
|
export const keychain = {
|
|
57
91
|
service: env("TOKENMAXXING_KEYCHAIN_SERVICE", "Claude Code-credentials"),
|
|
@@ -88,8 +122,12 @@ export function namespacedCredService(configDirRaw: string): string {
|
|
|
88
122
|
|
|
89
123
|
/** Resolve the REAL claude binary (never our shim). Order: explicit env, config, PATH scan. */
|
|
90
124
|
export function realClaudeBinFromEnv(): string | undefined {
|
|
91
|
-
|
|
92
|
-
|
|
125
|
+
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CLAUDE_BIN);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Same override hook for the real codex binary (tests / relocation). */
|
|
129
|
+
export function realCodexBinFromEnv(): string | undefined {
|
|
130
|
+
return EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_BIN);
|
|
93
131
|
}
|
|
94
132
|
|
|
95
133
|
export { HOME };
|
package/src/lib/state.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import { existsSync, readFileSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
4
4
|
import { isEqual } from "es-toolkit";
|
|
5
5
|
import { z } from "zod";
|
|
6
|
-
import { paths, realClaudeBinFromEnv } from "./paths.ts";
|
|
6
|
+
import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
|
|
7
7
|
import { writeFileAtomic } from "./atomic.ts";
|
|
8
8
|
import {
|
|
9
9
|
AccountsIndexSchema,
|
|
@@ -24,6 +24,7 @@ const DEFAULT_CONFIG: Config = {
|
|
|
24
24
|
// cheap to sit out, weekly quota is use-it-or-lose-it so it drains to 98.
|
|
25
25
|
thresholds: { session: 95, weekly: 98 },
|
|
26
26
|
claudeBin: "",
|
|
27
|
+
codexBin: "",
|
|
27
28
|
// per-model weekly caps exist only for Sonnet and Fable (no Opus-only quota,
|
|
28
29
|
// per the user 2026-07-12), and only Fable's is worth switching on.
|
|
29
30
|
// greedySessionFloor 50: half a session window buys the swap (user 2026-07-16).
|
|
@@ -35,6 +36,7 @@ const ConfigFileSchema = z
|
|
|
35
36
|
.object({
|
|
36
37
|
thresholds: z.object({ session: z.number(), weekly: z.number() }).partial(),
|
|
37
38
|
claudeBin: z.string(),
|
|
39
|
+
codexBin: z.string(),
|
|
38
40
|
policy: z
|
|
39
41
|
.object({
|
|
40
42
|
projectionMargin: z.number(),
|
|
@@ -61,6 +63,7 @@ export function loadConfig(): Config {
|
|
|
61
63
|
cfg.thresholds.session = p.thresholds?.session ?? cfg.thresholds.session;
|
|
62
64
|
cfg.thresholds.weekly = p.thresholds?.weekly ?? cfg.thresholds.weekly;
|
|
63
65
|
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
66
|
+
cfg.codexBin = p.codexBin ?? cfg.codexBin;
|
|
64
67
|
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
65
68
|
cfg.policy.greedySessionFloor = p.policy?.greedySessionFloor ?? cfg.policy.greedySessionFloor;
|
|
66
69
|
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
@@ -69,9 +72,11 @@ export function loadConfig(): Config {
|
|
|
69
72
|
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
70
73
|
}
|
|
71
74
|
}
|
|
72
|
-
// env
|
|
75
|
+
// env overrides win for the real binaries (tests / relocation)
|
|
73
76
|
const envBin = realClaudeBinFromEnv();
|
|
74
77
|
if (envBin) cfg.claudeBin = envBin;
|
|
78
|
+
const envCodexBin = realCodexBinFromEnv();
|
|
79
|
+
if (envCodexBin) cfg.codexBin = envCodexBin;
|
|
75
80
|
return ConfigSchema.parse(cfg);
|
|
76
81
|
}
|
|
77
82
|
|
package/src/lib/types.ts
CHANGED
|
@@ -122,6 +122,8 @@ export type Thresholds = z.infer<typeof ThresholdsSchema>;
|
|
|
122
122
|
export const ConfigSchema = z.object({
|
|
123
123
|
thresholds: ThresholdsSchema,
|
|
124
124
|
claudeBin: z.string(),
|
|
125
|
+
/** the real codex binary (empty = resolve from PATH); pinned by `init --codex`. */
|
|
126
|
+
codexBin: z.string(),
|
|
125
127
|
policy: z.object({
|
|
126
128
|
projectionMargin: z.number(),
|
|
127
129
|
/** session-used % at which the greedy convergence engages: from here on,
|
|
@@ -204,3 +206,90 @@ export const RolesResponseSchema = z.looseObject({
|
|
|
204
206
|
organization_name: z.string(),
|
|
205
207
|
});
|
|
206
208
|
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
|
209
|
+
|
|
210
|
+
// ---- Codex pool ------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/** `tokens` inside $CODEX_HOME/auth.json (verified against a live 0.144.4
|
|
213
|
+
* auth.json). Loose: preserve unknown siblings so a harvest and reinstall
|
|
214
|
+
* round-trip is lossless. */
|
|
215
|
+
export const CodexTokensSchema = z.looseObject({
|
|
216
|
+
id_token: z.string(),
|
|
217
|
+
access_token: z.string(),
|
|
218
|
+
refresh_token: z.string(),
|
|
219
|
+
account_id: z.string().optional(),
|
|
220
|
+
});
|
|
221
|
+
export type CodexTokens = z.infer<typeof CodexTokensSchema>;
|
|
222
|
+
|
|
223
|
+
/** The whole auth.json. Loose: auth_mode, OPENAI_API_KEY (may be null), and
|
|
224
|
+
* future siblings ride along verbatim. */
|
|
225
|
+
export const CodexAuthJsonSchema = z.looseObject({
|
|
226
|
+
tokens: CodexTokensSchema,
|
|
227
|
+
last_refresh: z.string().optional(),
|
|
228
|
+
});
|
|
229
|
+
export type CodexAuthJson = z.infer<typeof CodexAuthJsonSchema>;
|
|
230
|
+
|
|
231
|
+
/** One rate-limit window as tokenmaxxing stores it: percent used, absolute
|
|
232
|
+
* epoch ms reset, and the server-declared duration. Codex windows are
|
|
233
|
+
* duration-driven (the weekly window is PRIMARY on plans whose 5h window was
|
|
234
|
+
* removed in July 2026), so classification must go by windowSeconds, never
|
|
235
|
+
* by primary/secondary position. */
|
|
236
|
+
export const CodexWindowSchema = z.object({
|
|
237
|
+
usedPercentage: z.number(),
|
|
238
|
+
resetsAt: z.number().nullable(),
|
|
239
|
+
windowSeconds: z.number().nullable(),
|
|
240
|
+
});
|
|
241
|
+
export type CodexWindow = z.infer<typeof CodexWindowSchema>;
|
|
242
|
+
|
|
243
|
+
/** Everything one free usage read yields: the token's OWN identity (the codex
|
|
244
|
+
* analog of the claude roles endpoint: labels drift, the token cannot lie)
|
|
245
|
+
* plus every rate-limit window, aggregate and per-limit-family. */
|
|
246
|
+
export const CodexUsageSchema = z.object({
|
|
247
|
+
accountId: z.string(),
|
|
248
|
+
email: z.string().nullable(),
|
|
249
|
+
planType: z.string().nullable(),
|
|
250
|
+
aggregate: z.array(CodexWindowSchema),
|
|
251
|
+
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
252
|
+
});
|
|
253
|
+
export type CodexUsage = z.infer<typeof CodexUsageSchema>;
|
|
254
|
+
|
|
255
|
+
/** A pooled codex account (codex-accounts.json - NON-secret). */
|
|
256
|
+
export const CodexAccountSchema = z.object({
|
|
257
|
+
accountId: z.string(),
|
|
258
|
+
email: z.string().nullable(),
|
|
259
|
+
label: z.string(),
|
|
260
|
+
planType: z.string().nullable(),
|
|
261
|
+
credFile: z.string(),
|
|
262
|
+
addedAt: z.string(),
|
|
263
|
+
needsReauth: z.boolean().optional(),
|
|
264
|
+
lastUsage: z
|
|
265
|
+
.object({
|
|
266
|
+
aggregate: z.array(CodexWindowSchema),
|
|
267
|
+
perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
|
|
268
|
+
})
|
|
269
|
+
.optional(),
|
|
270
|
+
lastUsageAt: z.number().optional(),
|
|
271
|
+
});
|
|
272
|
+
export type CodexAccount = z.infer<typeof CodexAccountSchema>;
|
|
273
|
+
|
|
274
|
+
export const CodexAccountsIndexSchema = z.object({
|
|
275
|
+
version: z.literal(1),
|
|
276
|
+
activeAccountId: z.string().nullable(),
|
|
277
|
+
accounts: z.array(CodexAccountSchema).default([]),
|
|
278
|
+
});
|
|
279
|
+
export type CodexAccountsIndex = z.infer<typeof CodexAccountsIndexSchema>;
|
|
280
|
+
|
|
281
|
+
/** Codex Stop-hook stdin (verified against the 0.144.4 binary wire schema and
|
|
282
|
+
* the official hooks reference): only what the swap trigger consumes. */
|
|
283
|
+
export const CodexStopStdinSchema = z.looseObject({
|
|
284
|
+
session_id: z.string().optional(),
|
|
285
|
+
hook_event_name: z.string().optional(),
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
/** The codex supervisor's respawn marker payload: which session to resume and
|
|
289
|
+
* under which account label (for the switch banner). */
|
|
290
|
+
export const CodexRespawnMarkerSchema = z.object({
|
|
291
|
+
account: z.string(),
|
|
292
|
+
sessionId: z.string().nullable(),
|
|
293
|
+
ts: z.number(),
|
|
294
|
+
});
|
|
295
|
+
export type CodexRespawnMarker = z.infer<typeof CodexRespawnMarkerSchema>;
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { runStopHook } from "./entries/stophook.ts";
|
|
|
10
10
|
import { runSessionStart } from "./entries/sessionstart.ts";
|
|
11
11
|
import { cmdInit } from "./cli/init.ts";
|
|
12
12
|
import { cmdAdd } from "./cli/add.ts";
|
|
13
|
+
import { cmdCodexAdd } from "./cli/codexadd.ts";
|
|
14
|
+
import { cmdCodexInit } from "./cli/codexinit.ts";
|
|
15
|
+
import { cmdCodexSwitch } from "./cli/codexswitch.ts";
|
|
16
|
+
import { runCodexSupervisor } from "./entries/codexsupervisor.ts";
|
|
17
|
+
import { runCodexStopHook } from "./entries/codexstophook.ts";
|
|
13
18
|
import { cmdLs } from "./cli/ls.ts";
|
|
14
19
|
import { cmdStatus } from "./cli/status.ts";
|
|
15
20
|
import { cmdWatch } from "./cli/watch.ts";
|
|
@@ -28,7 +33,10 @@ function printHelp(): void {
|
|
|
28
33
|
${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
|
|
29
34
|
${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
|
|
30
35
|
${c.cyan("tokenmaxxing init")} import the current account + install supervisor & hooks
|
|
36
|
+
${c.cyan("tokenmaxxing init --codex")} same for codex: import login, install codex supervisor + Stop hook (trust it via /hooks)
|
|
31
37
|
${c.cyan("tokenmaxxing add")} register an additional account (isolated login)
|
|
38
|
+
${c.cyan("tokenmaxxing add --codex")} register an additional codex account (isolated login)
|
|
39
|
+
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
32
40
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
33
41
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
34
42
|
${c.cyan("tokenmaxxing status --force")} ping every account (one tiny haiku request each) so all 5h session timers start now, then sample fresh; ${c.cyan("xx --force")} works too
|
|
@@ -54,17 +62,21 @@ async function main(): Promise<number> {
|
|
|
54
62
|
if (argv0 === "claude" || sub === "__supervise") {
|
|
55
63
|
return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
|
|
56
64
|
}
|
|
65
|
+
if (argv0 === "codex" || sub === "__supervise-codex") {
|
|
66
|
+
return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
|
|
67
|
+
}
|
|
57
68
|
|
|
58
69
|
switch (sub) {
|
|
59
70
|
case "__statusline": return runStatusline();
|
|
60
71
|
case "__stop-hook": return runStopHook();
|
|
61
72
|
case "__session-start": return runSessionStart();
|
|
73
|
+
case "__codex-stop-hook": return runCodexStopHook();
|
|
62
74
|
case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
|
|
63
75
|
case "--force": return cmdStatus(true); // bare `xx --force` → status --force
|
|
64
|
-
case "switch": return cmdSwitch(args[1]);
|
|
76
|
+
case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
|
|
65
77
|
case "check": return cmdCheck();
|
|
66
|
-
case "init": return cmdInit();
|
|
67
|
-
case "add": return cmdAdd();
|
|
78
|
+
case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
|
|
79
|
+
case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
|
|
68
80
|
case "ls": return cmdLs();
|
|
69
81
|
case "status": return cmdStatus(args.includes("--force"));
|
|
70
82
|
case "watch": return cmdWatch(args[1]);
|
package/src/sdk.ts
CHANGED
|
@@ -102,7 +102,7 @@ export async function stopHookCheck(): Promise<Record<string, never>> {
|
|
|
102
102
|
try {
|
|
103
103
|
await evaluateAndMaybeSwap(Date.now(), false);
|
|
104
104
|
} catch (e) {
|
|
105
|
-
const err =
|
|
105
|
+
const err = e instanceof Error ? e.message : String(e);
|
|
106
106
|
console.error(`tokenmaxxing: switch check failed at turn boundary: ${err}`);
|
|
107
107
|
log("sdk.stop_error", { err });
|
|
108
108
|
}
|