tokenmaxxing 0.11.0 → 0.13.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/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
- const v = process.env[name];
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
- const v = process.env.TOKENMAXXING_CLAUDE_BIN;
92
- return v && v.length > 0 ? v : undefined;
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 override wins for the claude binary (tests / relocation)
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,8 +10,14 @@ 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";
20
+ import { cmdWatch } from "./cli/watch.ts";
15
21
  import { cmdDoctor } from "./cli/doctor.ts";
16
22
  import { cmdRm } from "./cli/rm.ts";
17
23
  import { cmdRename } from "./cli/rename.ts";
@@ -27,10 +33,14 @@ function printHelp(): void {
27
33
  ${c.cyan("tokenmaxxing switch")} [sel] switch to the best (or a specific) account; no-op when already on it
28
34
  ${c.cyan("tokenmaxxing check")} evaluate once, switch if over threshold (run by the periodic timer)
29
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)
30
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)
31
40
  ${c.cyan("tokenmaxxing ls")} list pooled accounts
32
41
  ${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
33
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
43
+ ${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
34
44
  ${c.cyan("tokenmaxxing doctor")} verify the install is intact
35
45
  ${c.cyan("tokenmaxxing rename")} <sel> <label>
36
46
  ${c.cyan("tokenmaxxing rm")} <sel>
@@ -52,19 +62,24 @@ async function main(): Promise<number> {
52
62
  if (argv0 === "claude" || sub === "__supervise") {
53
63
  return runSupervisor(sub === "__supervise" ? args.slice(1) : args);
54
64
  }
65
+ if (argv0 === "codex" || sub === "__supervise-codex") {
66
+ return runCodexSupervisor({ argv: sub === "__supervise-codex" ? args.slice(1) : args });
67
+ }
55
68
 
56
69
  switch (sub) {
57
70
  case "__statusline": return runStatusline();
58
71
  case "__stop-hook": return runStopHook();
59
72
  case "__session-start": return runSessionStart();
73
+ case "__codex-stop-hook": return runCodexStopHook();
60
74
  case undefined: return cmdStatus(); // bare `tokenmaxxing` / `xx` → status
61
75
  case "--force": return cmdStatus(true); // bare `xx --force` → status --force
62
- case "switch": return cmdSwitch(args[1]);
76
+ case "switch": return args[1] === "--codex" ? cmdCodexSwitch(args[2]) : cmdSwitch(args[1]);
63
77
  case "check": return cmdCheck();
64
- case "init": return cmdInit();
65
- case "add": return cmdAdd();
78
+ case "init": return args.includes("--codex") ? cmdCodexInit() : cmdInit();
79
+ case "add": return args.includes("--codex") ? cmdCodexAdd() : cmdAdd();
66
80
  case "ls": return cmdLs();
67
81
  case "status": return cmdStatus(args.includes("--force"));
82
+ case "watch": return cmdWatch(args[1]);
68
83
  case "doctor": return cmdDoctor();
69
84
  case "rm": return cmdRm(args[1]);
70
85
  case "rename": return cmdRename(args[1], args[2]);
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 = String((e as Error).message ?? e);
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
  }