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.
@@ -0,0 +1,106 @@
1
+ // Choose the codex account to switch TO. Same policy as the claude picker:
2
+ // among usable accounts (no reauth, no window at/over its screening bar that
3
+ // has not reset), take the one furthest behind its own weekly pace (highest
4
+ // pacePressure), because weekly allowance is forfeited at a fixed per-account
5
+ // reset. Windows are duration-classified (isSessionWindow), never assumed:
6
+ // codex's 5h window is absent on current Plus/Pro plans (July 2026), so the
7
+ // weekly aggregate may be the only bar there is.
8
+
9
+ import { sortBy } from "es-toolkit";
10
+ import { nextWeeklyReset } from "./picker.ts";
11
+ import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
12
+ import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
13
+
14
+ function liveUsed(input: { window: CodexWindow; now: number }): number {
15
+ const { window, now } = input;
16
+ return window.resetsAt != null && window.resetsAt <= now ? 0 : window.usedPercentage;
17
+ }
18
+
19
+ function allWindows(account: CodexAccount): CodexWindow[] {
20
+ const usage = account.lastUsage;
21
+ if (!usage) return [];
22
+ return [...usage.aggregate, ...Object.values(usage.perLimit).flat()];
23
+ }
24
+
25
+ function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
26
+ return isSessionWindow({ window: input.window }) ? input.thresholds.session : input.thresholds.weekly;
27
+ }
28
+
29
+ /** A window at/over its bar whose reset has not passed blocks the account. */
30
+ export function isCodexExhausted(input: { account: CodexAccount; thresholds: Thresholds; now: number }): boolean {
31
+ const { account, thresholds, now } = input;
32
+ return allWindows(account).some(
33
+ (window) => liveUsed({ window, now }) >= barFor({ window, thresholds }),
34
+ );
35
+ }
36
+
37
+ /** Forward pace pressure on the weekly aggregate: the burn rate the remaining
38
+ * weekly quota demands before its reset forfeits it. No sampled weekly window
39
+ * or no reset anchor ranks last (0): unmeasured must not look urgent. */
40
+ export function codexPacePressure(input: { account: CodexAccount; now: number }): number {
41
+ const { account, now } = input;
42
+ const weekly = account.lastUsage ? weeklyWindowOf({ aggregate: account.lastUsage.aggregate }) : null;
43
+ if (!weekly) return 0;
44
+ const reset = nextWeeklyReset(weekly.resetsAt, now);
45
+ if (reset == null) return 0;
46
+ return Math.max(0, 100 - liveUsed({ window: weekly, now })) / Math.max(1, reset - now);
47
+ }
48
+
49
+ function weeklyExpiryOf(input: { account: CodexAccount; now: number }): number {
50
+ const { account, now } = input;
51
+ const weekly = account.lastUsage ? weeklyWindowOf({ aggregate: account.lastUsage.aggregate }) : null;
52
+ return nextWeeklyReset(weekly?.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
53
+ }
54
+
55
+ const codexSwapPreference = (now: number) => [
56
+ (account: CodexAccount) => -codexPacePressure({ account, now }),
57
+ (account: CodexAccount) => weeklyExpiryOf({ account, now }),
58
+ ];
59
+
60
+ export function pickBestCodex(input: {
61
+ accounts: CodexAccount[];
62
+ thresholds: Thresholds;
63
+ now: number;
64
+ currentAccountId: string | null;
65
+ }): CodexAccount | null {
66
+ const { accounts, thresholds, now, currentAccountId } = input;
67
+ const usable = accounts.filter(
68
+ (account) =>
69
+ account.accountId !== currentAccountId &&
70
+ account.needsReauth !== true &&
71
+ !isCodexExhausted({ account, thresholds, now }),
72
+ );
73
+ return sortBy(usable, codexSwapPreference(now))[0] ?? null;
74
+ }
75
+
76
+ /** A codex greedy swap is a SIGTERM + respawn of a live session (codex cannot
77
+ * hot-adopt), and engagement is chronic on plans whose only window is weekly:
78
+ * without a margin, every hair of pace-pressure drift would visibly restart
79
+ * the user's session. The challenger must beat the incumbent by this factor
80
+ * (adversarial review catch, 2026-07-16); a crossed bar bypasses the margin
81
+ * entirely via the hard path. */
82
+ export const CODEX_SWAP_IMPROVEMENT = 1.2;
83
+
84
+ /** Greedy idempotence, mirroring picker.currentWins with the respawn-cost
85
+ * margin: the active account keeps its seat while usable unless a challenger
86
+ * beats its pace pressure by CODEX_SWAP_IMPROVEMENT. */
87
+ export function codexCurrentWins(input: {
88
+ active: CodexAccount | null;
89
+ accounts: CodexAccount[];
90
+ thresholds: Thresholds;
91
+ now: number;
92
+ }): boolean {
93
+ const { active, accounts, thresholds, now } = input;
94
+ if (!active || active.needsReauth === true || isCodexExhausted({ account: active, thresholds, now })) return false;
95
+ const best = pickBestCodex({ accounts, thresholds, now, currentAccountId: null });
96
+ if (best == null || best.accountId === active.accountId) return true;
97
+ return codexPacePressure({ account: best, now }) <= codexPacePressure({ account: active, now }) * CODEX_SWAP_IMPROVEMENT;
98
+ }
99
+
100
+ /** True once any window is at/over the greedy engagement floor: with no 5h
101
+ * window on current codex plans, the floor reads against every window class
102
+ * rather than the session window alone. */
103
+ export function isCodexEngaged(input: { account: CodexAccount; floor: number; now: number }): boolean {
104
+ const { account, floor, now } = input;
105
+ return allWindows(account).some((window) => liveUsed({ window, now }) >= floor);
106
+ }
@@ -0,0 +1,77 @@
1
+ // Which codex accounts are RUNNING right now. Codex cannot hot-swap, so a swap
2
+ // respawns only the session whose Stop hook decided it; sibling supervised
3
+ // sessions keep running on whatever account they started with, rotating that
4
+ // account's token live. Such an account is a landmine for the rest of the
5
+ // pool: its parked copy is superseded (refresh trips reuse punishment) and
6
+ // installing it as live would yank the running session's grant. Supervisors
7
+ // therefore declare their session's account in a presence file at every
8
+ // (re)spawn; the picker refuses to target present accounts and the sampler
9
+ // refuses to refresh their parked blobs. Staleness is PID-based: a presence
10
+ // whose supervisor died is ignored and cleaned up.
11
+
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { z } from "zod";
15
+ import { codexPaths } from "./paths.ts";
16
+ import { writeFileAtomic } from "./atomic.ts";
17
+
18
+ const PresenceSchema = z.object({
19
+ accountId: z.string(),
20
+ pid: z.number(),
21
+ ts: z.number(),
22
+ });
23
+
24
+ export function writeCodexPresence(input: { supervisorId: string; accountId: string }): void {
25
+ mkdirSync(codexPaths.presenceDir, { recursive: true });
26
+ writeFileAtomic(
27
+ join(codexPaths.presenceDir, input.supervisorId),
28
+ JSON.stringify(PresenceSchema.parse({ accountId: input.accountId, pid: process.pid, ts: Date.now() })),
29
+ );
30
+ }
31
+
32
+ export function clearCodexPresence(input: { supervisorId: string }): void {
33
+ rmSync(join(codexPaths.presenceDir, input.supervisorId), { force: true });
34
+ }
35
+
36
+ function pidAlive(pid: number): boolean {
37
+ try {
38
+ process.kill(pid, 0);
39
+ return true;
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ /** Account ids with a LIVING supervisor. Dead supervisors' files are removed. */
46
+ export function presentCodexAccountIds(): Set<string> {
47
+ const present = new Set<string>();
48
+ if (!existsSync(codexPaths.presenceDir)) return present;
49
+ for (const name of readdirSync(codexPaths.presenceDir)) {
50
+ const file = join(codexPaths.presenceDir, name);
51
+ const parsed = PresenceSchema.safeParse((() => {
52
+ try {
53
+ return JSON.parse(readFileSync(file, "utf8"));
54
+ } catch {
55
+ return null;
56
+ }
57
+ })());
58
+ if (!parsed.success || !pidAlive(parsed.data.pid)) {
59
+ rmSync(file, { force: true });
60
+ continue;
61
+ }
62
+ present.add(parsed.data.accountId);
63
+ }
64
+ return present;
65
+ }
66
+
67
+ /** The accounts a swap may target: running accounts are off limits, except the
68
+ * seat itself (it is ranked as the incumbent, never installed over itself). */
69
+ export function targetableCodexAccounts<T extends { accountId: string }>(input: {
70
+ accounts: T[];
71
+ activeAccountId: string | null;
72
+ }): T[] {
73
+ const present = presentCodexAccountIds();
74
+ return input.accounts.filter(
75
+ (account) => account.accountId === input.activeAccountId || !present.has(account.accountId),
76
+ );
77
+ }
@@ -0,0 +1,62 @@
1
+ // Per-account codex usage sampling for status/ls. The ACTIVE account (the live
2
+ // auth.json's own identity, never the possibly-drifted label) samples through
3
+ // the LIVE blob: its parked copy goes stale as the running codex rotates
4
+ // tokens, and refreshing a superseded parked refresh token would trip the
5
+ // server's reuse punishment and kill the grant family. Parked accounts sample
6
+ // through their parked blobs (their owners are not running, so rotation on
7
+ // refresh is safe) and every rotation is persisted back immediately.
8
+ //
9
+ // Caller holds the codex flock: refresh writes must not interleave with a swap.
10
+
11
+ import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, readParkedCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
12
+ import { CodexInvalidGrantError, CodexRefreshFailedError, refreshCodexAuth } from "./codexoauth.ts";
13
+ import { CodexUsageReadError, fetchCodexUsage } from "./codexusage.ts";
14
+ import { presentCodexAccountIds } from "./codexpresence.ts";
15
+ import type { CodexAccount, CodexUsage } from "./types.ts";
16
+ import { z } from "zod";
17
+
18
+ const CodexSampleOutcomeSchema = z.union([
19
+ z.object({ ok: z.literal(true), usage: z.custom<CodexUsage>() }),
20
+ z.object({ ok: z.literal(false), reason: z.string(), deadGrant: z.boolean() }),
21
+ ]);
22
+ export type CodexSampleOutcome = z.infer<typeof CodexSampleOutcomeSchema>;
23
+
24
+ /** The live credential's own account id, or null when absent/unreadable. */
25
+ export function liveCodexAccountId(): string | null {
26
+ const live = readLiveCodexAuth();
27
+ if (!live) return null;
28
+ return codexIdentityOf({ auth: live }).accountId;
29
+ }
30
+
31
+ export async function sampleCodexAccount(input: { account: CodexAccount; liveAccountId: string | null; now?: number }): Promise<CodexSampleOutcome> {
32
+ const { account, liveAccountId, now = Date.now() } = input;
33
+ const isLive = liveAccountId != null && account.accountId === liveAccountId;
34
+ try {
35
+ let auth = isLive ? readLiveCodexAuth() : readParkedCodexAuth({ credFile: account.credFile });
36
+ if (!auth) return { ok: false, reason: isLive ? "live auth.json vanished" : "no parked credential", deadGrant: false };
37
+ if (isCodexAccessExpiring({ auth, now })) {
38
+ // A parked blob whose account is RUNNING in another supervised session
39
+ // is superseded by that session's live rotations: refreshing it would
40
+ // trip the server's reuse punishment and kill the running session's
41
+ // grant family. Skip the refresh and report a miss instead.
42
+ if (!isLive && presentCodexAccountIds().has(account.accountId)) {
43
+ return { ok: false, reason: "running in a live codex session (parked token refresh unsafe)", deadGrant: false };
44
+ }
45
+ auth = await refreshCodexAuth({ auth, now });
46
+ if (isLive) writeLiveCodexAuth({ auth });
47
+ writeParkedCodexAuth({ credFile: account.credFile, auth });
48
+ }
49
+ const usage = await fetchCodexUsage({ auth });
50
+ return { ok: true, usage };
51
+ } catch (e) {
52
+ // Only the EXPECTED operational failures become a sample miss; parse and
53
+ // filesystem errors keep propagating (they are drift or bugs to surface).
54
+ if (e instanceof CodexInvalidGrantError) {
55
+ return { ok: false, reason: e.message, deadGrant: true };
56
+ }
57
+ if (e instanceof CodexRefreshFailedError || e instanceof CodexUsageReadError) {
58
+ return { ok: false, reason: e.message, deadGrant: false };
59
+ }
60
+ throw e;
61
+ }
62
+ }
@@ -0,0 +1,29 @@
1
+ // codex-accounts.json + codex-lastswap.json persistence. Parallel to the
2
+ // claude files in state.ts, deliberately a separate file pair: claude and
3
+ // codex accounts have different shapes and swap independently, so neither
4
+ // index can clobber the other. Absent files are the empty state; a file that
5
+ // exists but fails to parse THROWS (a fabricated empty pool would silently
6
+ // orphan parked credentials).
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { writeFileAtomic } from "./atomic.ts";
10
+ import { codexPaths } from "./paths.ts";
11
+ import { CodexAccountsIndexSchema, LastSwapSchema, type CodexAccountsIndex } from "./types.ts";
12
+
13
+ export function loadCodexAccounts(): CodexAccountsIndex {
14
+ if (!existsSync(codexPaths.accountsJson)) return { version: 1, activeAccountId: null, accounts: [] };
15
+ return CodexAccountsIndexSchema.parse(JSON.parse(readFileSync(codexPaths.accountsJson, "utf8")));
16
+ }
17
+
18
+ export function saveCodexAccounts(input: { index: CodexAccountsIndex }): void {
19
+ writeFileAtomic(codexPaths.accountsJson, JSON.stringify(CodexAccountsIndexSchema.parse(input.index), null, 2) + "\n");
20
+ }
21
+
22
+ export function loadCodexLastSwapAt(): number | null {
23
+ if (!existsSync(codexPaths.lastSwapJson)) return null;
24
+ return LastSwapSchema.parse(JSON.parse(readFileSync(codexPaths.lastSwapJson, "utf8"))).ts;
25
+ }
26
+
27
+ export function saveCodexLastSwapAt(input: { ts: number }): void {
28
+ writeFileAtomic(codexPaths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts: input.ts })));
29
+ }
@@ -0,0 +1,93 @@
1
+ // The codex account-switch sequence. Runs under tokenmaxxing's codex flock
2
+ // (held by the caller). Codex itself has NO cross-process lock on auth.json
3
+ // (verified rust-v0.144.5: refresh serialization is an in-process semaphore),
4
+ // so this flock only serializes tokenmaxxing actors; a RUNNING codex can still
5
+ // refresh-write the live file concurrently. The sequence therefore keeps the
6
+ // harvest-install window as short as possible, and the Stop-hook trigger fires
7
+ // only at an idle turn boundary where the running codex has just finished
8
+ // using (and refreshing, if needed) its token.
9
+ //
10
+ // refresh target's parked blob (network, validates the grant, rotates)
11
+ // harvest live auth.json verbatim under its OWN identity (labels drift,
12
+ // the id_token cannot lie; an unknown live identity refuses the swap)
13
+ // install the refreshed target as the whole live auth.json
14
+ // persist the rotation into the target's parked blob
15
+ // commit activeAccountId in the same critical section
16
+
17
+ import { codexIdentityOf, readLiveCodexAuth, readParkedCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
18
+ import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
19
+ import { loadCodexAccounts, saveCodexAccounts, saveCodexLastSwapAt } from "./codexstate.ts";
20
+ import { log } from "./log.ts";
21
+ import type { CodexAccount } from "./types.ts";
22
+
23
+ export async function performCodexSwap(input: { target: CodexAccount }): Promise<void> {
24
+ const { target } = input;
25
+ const index = loadCodexAccounts();
26
+
27
+ const parked = readParkedCodexAuth({ credFile: target.credFile });
28
+ if (!parked) throw new Error(`no parked codex credential for ${target.label}`);
29
+
30
+ // Resolve the live credential's owner BEFORE touching the network: a refused
31
+ // swap must refuse with the target's parked token still valid. The refresh
32
+ // rotates it server-side, and codex punishes reuse of the superseded one, so
33
+ // rotating first and then throwing would kill the target's grant family
34
+ // (adversarial review catch, 2026-07-16).
35
+ const live = readLiveCodexAuth();
36
+ let liveOwner = null;
37
+ if (live) {
38
+ const liveIdentity = codexIdentityOf({ auth: live });
39
+ // Backstop against a drifted caller: installing the LIVE account over
40
+ // itself would refresh its superseded parked token (reuse punishment kills
41
+ // the grant family) and rotate the token out from under a running session.
42
+ if (liveIdentity.accountId === target.accountId) {
43
+ throw new Error(
44
+ `${target.label} is already the live codex credential - refusing to swap an account onto itself`,
45
+ );
46
+ }
47
+ liveOwner = index.accounts.find((account) => account.accountId === liveIdentity.accountId) ?? null;
48
+ if (!liveOwner) {
49
+ throw new Error(
50
+ `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\``,
51
+ );
52
+ }
53
+ if (liveOwner.accountId !== index.activeAccountId) {
54
+ log("codexswap.harvest_drift", {
55
+ labeled: index.activeAccountId?.slice(0, 8) ?? null,
56
+ actual: liveOwner.accountId.slice(0, 8),
57
+ });
58
+ }
59
+ }
60
+
61
+ let fresh;
62
+ try {
63
+ fresh = await refreshCodexAuth({ auth: parked });
64
+ } catch (e) {
65
+ if (e instanceof CodexInvalidGrantError) {
66
+ const entry = index.accounts.find((account) => account.accountId === target.accountId);
67
+ if (entry) {
68
+ entry.needsReauth = true;
69
+ saveCodexAccounts({ index });
70
+ }
71
+ log("codexswap.invalid_grant", { account: target.accountId.slice(0, 8) });
72
+ }
73
+ throw e;
74
+ }
75
+ // The rotation exists server-side from this instant: persist it before ANY
76
+ // later step can fail, or a crash strands the parked file on the superseded
77
+ // (reuse-punished) refresh token.
78
+ writeParkedCodexAuth({ credFile: target.credFile, auth: fresh });
79
+
80
+ if (live && liveOwner) {
81
+ writeParkedCodexAuth({ credFile: liveOwner.credFile, auth: live });
82
+ log("codexswap.harvest", { account: liveOwner.accountId.slice(0, 8) });
83
+ }
84
+
85
+ writeLiveCodexAuth({ auth: fresh });
86
+ index.activeAccountId = target.accountId;
87
+ const entry = index.accounts.find((account) => account.accountId === target.accountId);
88
+ if (entry) entry.needsReauth = false;
89
+ saveCodexAccounts({ index });
90
+ saveCodexLastSwapAt({ ts: Date.now() });
91
+ log("codexswap.done", { account: target.accountId.slice(0, 8), label: target.label });
92
+ }
93
+
@@ -0,0 +1,135 @@
1
+ // The free codex usage read (user decision 2026-07-16: the direct GET, the same
2
+ // HTTP call the codex CLI makes for its own /status). One authed GET returns
3
+ // BOTH the token's true identity (account_id/email/plan: the codex analog of
4
+ // the claude roles endpoint) and every rate-limit window. No inference runs and
5
+ // no session window starts.
6
+ //
7
+ // Response shape pinned against a live read 2026-07-16: windows carry
8
+ // used_percent + reset_at (epoch SECONDS) + limit_window_seconds, the weekly
9
+ // window is PRIMARY on plans whose 5h window was removed (July 2026), and
10
+ // per-model caps arrive as additional_rate_limits[] rows keyed by limit_name.
11
+ // Classification is therefore duration-driven, never position-driven.
12
+
13
+ import { z } from "zod";
14
+ import { http, safeErrorDetail } from "./http.ts";
15
+ import { CodexUsageSchema, type CodexAuthJson, type CodexUsage, type CodexWindow } from "./types.ts";
16
+ import { codexIdentityOf } from "./codexauth.ts";
17
+
18
+ const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
19
+ const USAGE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_USAGE_URL) ?? "https://chatgpt.com/backend-api/wham/usage";
20
+
21
+ /** The usage read could not complete (endpoint unreachable, HTTP failure,
22
+ * drifted body): an operational miss to fall back on cached figures for,
23
+ * NOT a bug to swallow. */
24
+ export class CodexUsageReadError extends Error {
25
+ constructor(detail: string) {
26
+ super(`codex usage read failed: ${detail}`);
27
+ this.name = "CodexUsageReadError";
28
+ }
29
+ }
30
+
31
+ const WireWindowSchema = z.looseObject({
32
+ used_percent: z.number(),
33
+ limit_window_seconds: z.number().nullish(),
34
+ reset_at: z.number().nullish(),
35
+ });
36
+
37
+ const WireRateLimitSchema = z.looseObject({
38
+ primary_window: WireWindowSchema.nullish(),
39
+ secondary_window: WireWindowSchema.nullish(),
40
+ });
41
+
42
+ const WireUsageSchema = z.looseObject({
43
+ account_id: z.string(),
44
+ email: z.string().nullish(),
45
+ plan_type: z.string().nullish(),
46
+ rate_limit: WireRateLimitSchema.nullish(),
47
+ additional_rate_limits: z
48
+ .array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
49
+ .nullish(),
50
+ });
51
+
52
+ function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined): CodexWindow[] {
53
+ const out: CodexWindow[] = [];
54
+ for (const wire of [rateLimit?.primary_window, rateLimit?.secondary_window]) {
55
+ if (wire == null) continue;
56
+ out.push({
57
+ usedPercentage: wire.used_percent,
58
+ resetsAt: wire.reset_at != null ? wire.reset_at * 1000 : null,
59
+ windowSeconds: wire.limit_window_seconds ?? null,
60
+ });
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * One free usage read for the credential in `auth`. Throws on HTTP failure
67
+ * (401 included: the caller decides whether to refresh and retry); error
68
+ * text carries only a response-body snippet, never request headers.
69
+ */
70
+ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<CodexUsage> {
71
+ const { auth } = input;
72
+ const identity = codexIdentityOf({ auth });
73
+ let res: Response;
74
+ try {
75
+ res = await http.get(USAGE_URL, {
76
+ headers: {
77
+ Authorization: `Bearer ${auth.tokens.access_token}`,
78
+ "ChatGPT-Account-Id": identity.accountId,
79
+ "User-Agent": "codex-cli",
80
+ },
81
+ });
82
+ } catch (e) {
83
+ const message = e instanceof Error ? e.message : String(e);
84
+ throw new CodexUsageReadError(`endpoint unreachable: ${message}`);
85
+ }
86
+ const text = await res.text();
87
+ if (!res.ok) {
88
+ throw new CodexUsageReadError(`HTTP ${res.status}: ${safeErrorDetail({ text })}`);
89
+ }
90
+ const parsed = WireUsageSchema.safeParse((() => {
91
+ try {
92
+ return JSON.parse(text);
93
+ } catch {
94
+ return null;
95
+ }
96
+ })());
97
+ if (!parsed.success) {
98
+ throw new CodexUsageReadError("endpoint returned an unexpected body shape (withheld)");
99
+ }
100
+ const wire = parsed.data;
101
+
102
+ const perLimit: Record<string, CodexWindow[]> = {};
103
+ for (const row of wire.additional_rate_limits ?? []) {
104
+ const windows = toWindows(row.rate_limit);
105
+ if (windows.length > 0) perLimit[row.limit_name] = windows;
106
+ }
107
+
108
+ return CodexUsageSchema.parse({
109
+ accountId: wire.account_id,
110
+ email: wire.email ?? null,
111
+ planType: wire.plan_type ?? null,
112
+ aggregate: toWindows(wire.rate_limit),
113
+ perLimit,
114
+ });
115
+ }
116
+
117
+ const SESSION_WINDOW_MAX_S = 6 * 3600;
118
+
119
+ /** A window's screening bar: short windows (5h-class) screen at the session
120
+ * bar, everything else (weekly aggregates and per-model caps) at the weekly
121
+ * bar. An unknown duration is treated as weekly: the strictest reading. */
122
+ export function isSessionWindow(input: { window: CodexWindow }): boolean {
123
+ const seconds = input.window.windowSeconds;
124
+ return seconds != null && seconds <= SESSION_WINDOW_MAX_S;
125
+ }
126
+
127
+ /** The account's weekly aggregate window: the longest-duration aggregate row. */
128
+ export function weeklyWindowOf(input: { aggregate: CodexWindow[] }): CodexWindow | null {
129
+ let best: CodexWindow | null = null;
130
+ for (const window of input.aggregate) {
131
+ if (isSessionWindow({ window })) continue;
132
+ if (best == null || (window.windowSeconds ?? 0) > (best.windowSeconds ?? 0)) best = window;
133
+ }
134
+ return best;
135
+ }
package/src/lib/http.ts CHANGED
@@ -4,6 +4,32 @@
4
4
  // so callers read the body and shape their own fail-fast error; retry still runs.
5
5
 
6
6
  import ky from "ky";
7
+ import { z } from "zod";
8
+
9
+ /** The error-shape fields OAuth/usage endpoints legitimately explain themselves
10
+ * with. Error bodies are NEVER surfaced raw: a token endpoint's failure body
11
+ * can echo request material, so anything outside this allowlist is dropped. */
12
+ const ErrorBodySchema = z.looseObject({
13
+ error: z.string().optional(),
14
+ error_description: z.string().optional(),
15
+ detail: z.string().optional(),
16
+ message: z.string().optional(),
17
+ });
18
+
19
+ /** Allowlisted, token-safe rendering of an HTTP error body. */
20
+ export function safeErrorDetail(input: { text: string }): string {
21
+ const parsed = ErrorBodySchema.safeParse((() => {
22
+ try {
23
+ return JSON.parse(input.text);
24
+ } catch {
25
+ return null;
26
+ }
27
+ })());
28
+ if (!parsed.success) return "(unparsable error body withheld)";
29
+ const fields = [parsed.data.error, parsed.data.error_description, parsed.data.detail, parsed.data.message];
30
+ const detail = fields.filter((field): field is string => field != null && field.length > 0).join(": ");
31
+ return detail.length > 0 ? detail.slice(0, 200) : "(no error detail)";
32
+ }
7
33
 
8
34
  export const http = ky.create({
9
35
  timeout: 15_000,
@@ -6,7 +6,7 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, rmSy
6
6
  import { basename, dirname, join } from "node:path";
7
7
  import { escape } from "es-toolkit";
8
8
  import { z } from "zod";
9
- import { HOME, paths } from "./paths.ts";
9
+ import { codexPaths, HOME, paths } from "./paths.ts";
10
10
  import { writeFileAtomic } from "./atomic.ts";
11
11
  import { installedBin, installSettings, uninstallSettings } from "./settings.ts";
12
12
  import { resolveRealClaude } from "./claudebin.ts";
@@ -56,6 +56,70 @@ export function installSupervisor(): InstallOutcome {
56
56
  };
57
57
  }
58
58
 
59
+ // ---- codex supervisor + Stop hook -------------------------------------------
60
+
61
+ /** Codex hook declarations we merge into. Loose everywhere: every other event
62
+ * and every foreign Stop entry rides along verbatim. */
63
+ const CodexHooksFileSchema = z.looseObject({
64
+ Stop: z.array(z.looseObject({ hooks: z.array(z.looseObject({ command: z.string().optional() })).default([]) })).default([]),
65
+ });
66
+
67
+ const CODEX_STOP_HOOK_SUBCOMMAND = "__codex-stop-hook";
68
+
69
+ function codexStopHookCommand(): string {
70
+ // Quoted like the claude shim commands: an install path with a space would
71
+ // otherwise mis-split and the hook would silently never run.
72
+ return `${JSON.stringify(installedBin())} ${CODEX_STOP_HOOK_SUBCOMMAND}`;
73
+ }
74
+
75
+ /** Idempotently install the tokenmaxxing Stop entry in ~/.codex/hooks.json,
76
+ * preserving every other declaration. Codex skips new hooks until the user
77
+ * trusts them via /hooks (trust is recorded against the hook's hash), so the
78
+ * caller must surface that step. */
79
+ export function installCodexStopHook(): void {
80
+ const current = existsSync(codexPaths.hooksJson)
81
+ ? CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")))
82
+ : CodexHooksFileSchema.parse({});
83
+ const foreign = current.Stop.filter(
84
+ (group) => !group.hooks.some((hook) => hook.command?.includes(CODEX_STOP_HOOK_SUBCOMMAND)),
85
+ );
86
+ const next = {
87
+ ...current,
88
+ Stop: [
89
+ ...foreign,
90
+ { hooks: [{ type: "command", command: codexStopHookCommand(), timeout: 120, statusMessage: "tokenmaxxing switch check" }] },
91
+ ],
92
+ };
93
+ mkdirSync(codexPaths.home, { recursive: true });
94
+ writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
95
+ }
96
+
97
+ export function uninstallCodexStopHook(): void {
98
+ if (!existsSync(codexPaths.hooksJson)) return;
99
+ const current = CodexHooksFileSchema.parse(JSON.parse(readFileSync(codexPaths.hooksJson, "utf8")));
100
+ const next = {
101
+ ...current,
102
+ Stop: current.Stop.filter((group) => !group.hooks.some((hook) => hook.command?.includes(CODEX_STOP_HOOK_SUBCOMMAND))),
103
+ };
104
+ writeFileAtomic(codexPaths.hooksJson, JSON.stringify(next, null, 2) + "\n");
105
+ }
106
+
107
+ export function codexSupervisorLink(): string {
108
+ return join(paths.binDir, "codex");
109
+ }
110
+
111
+ /** The on-PATH `codex` wrapper + the Stop hook declaration. */
112
+ export function installCodexSupervisor(): void {
113
+ mkdirSync(paths.binDir, { recursive: true });
114
+ writeFileAtomic(codexSupervisorLink(), `#!/bin/sh\nexec ${JSON.stringify(installedBin())} __supervise-codex "$@"\n`, 0o755);
115
+ installCodexStopHook();
116
+ }
117
+
118
+ export function uninstallCodexSupervisor(): void {
119
+ uninstallCodexStopHook();
120
+ if (existsSync(codexSupervisorLink())) rmSync(codexSupervisorLink(), { force: true });
121
+ }
122
+
59
123
  // ---- periodic `check` timer ------------------------------------------------
60
124
  // The hooks evaluate only at turn boundaries; one long agentic turn can burn a
61
125
  // window from healthy to depleted with zero boundaries (2026-07-10 incident).
@@ -243,6 +307,7 @@ export function findClaudeShadowers(rcText: string): ShellShadower[] {
243
307
  export function uninstallSupervisor(): void {
244
308
  uninstallSettings();
245
309
  uninstallCheckTimer();
310
+ uninstallCodexSupervisor();
246
311
  for (const f of [paths.supervisorLink, join(paths.binDir, "xx"), installedBin()]) {
247
312
  if (existsSync(f)) rmSync(f, { force: true });
248
313
  }