tokenmaxxing 1.20.0 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -98,8 +98,6 @@ The [wall policy](docs/content/docs/switching.mdx#the-wall) defines Layer 2 reco
98
98
 
99
99
  **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels` we read it from `claude -p '/usage'` (free, 0 tokens, TTL-cached) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session switches on the Fable cap; a Sonnet session rides the aggregate. Both layers apply the per-model gate: a burnt Fable cap screens an account out of a Layer 1 switch, and a Fable cap at the wall screens it out of the Layer 2 squeeze too.
100
100
 
101
- **Banked resets (opt-in).** Both CLIs can reset a hit limit on the same account: Claude Code's `/limit-reset` (the `juniper_tide` server program, claimed by `POST /api/organizations/<org>/reset_rate_limits`, once a week, and the reset window still counts toward the weekly limit; the slash command is interactive-only, so tokenmaxxing makes the claim call itself with the live credential) and Codex's banked reset (`POST .../wham/rate-limit-reset-credits/consume` with an idempotency key; it refreshes the 5h and weekly windows together). `policy.preferToUseBankedReset` (a provider list, default `[]`) makes the hard path spend one before swapping, because a swap cold-starts the per-org prompt cache while a reset keeps the seat. Claude holds between the session rung and the wall only while the account still has weekly room for one more session window at its measured rate (`sessionWindowWeeklyCost`, learned from the tee within one window; unmeasured swaps), claims at the wall, and on `reset` respawns the failed turn in place. Codex rides to the wall while the usage endpoint reports a credit, consumes one, re-samples, and never restarts. Detail: `docs/switching.mdx` §Banked resets, `src/lib/bankedreset.ts`, `src/lib/codexreset.ts`.
102
-
103
101
  ---
104
102
 
105
103
  ## 6. Honest papercuts
package/README.md CHANGED
@@ -108,13 +108,12 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
108
108
  "greedySessionFloor": 80,
109
109
  "switchModels": ["fable"],
110
110
  "usagePollTtlMs": 90000,
111
- "checkIntervalMs": 60000,
112
- "preferToUseBankedReset": []
111
+ "checkIntervalMs": 60000
113
112
  }
114
113
  }
115
114
  ```
116
115
 
117
- `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick; `preferToUseBankedReset` lists the providers (`claude`, `codex`) that spend a banked limit reset (Claude Code's weekly `/limit-reset`, Codex's banked reset) on the same account before swapping, which keeps the prompt cache a swap would cold-start - on Claude the seat holds past the session rung only while the account has weekly room for one more session window at its measured rate, and claims the reset at the wall.
116
+ `thresholds.session` is a ladder: the bar is the lowest rung some pooled account still clears, so the default `[90]` is one bar, while with `[50, 80, 95]` a seat past 50 hands off while a sibling is under 50, and the bar climbs to 80 and then 95 as the whole pool fills; `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `greedySessionFloor` is the session-used % at which the greedy convergence engages; `switchModels` names the models whose per-model cap triggers a switch; `usagePollTtlMs` is how long a `/usage` per-model poll stays fresh; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer and which every headroom band is a multiple of, one tick near a bar up to five with plenty of headroom (a depleted wait and the post-swap cooldown sleep to their own deadlines instead) - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
118
117
 
119
118
  State lives entirely in `~/.config/tokenmaxxing/`. Per-account credentials follow the platform's Claude Code store: the login keychain on macOS (`tokenmaxxing-cred-<uuid8>` items, never plaintext on disk), 0600 files under `~/.config/tokenmaxxing/creds/` on Linux (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
120
119
 
@@ -156,7 +155,7 @@ codex # use codex as always
156
155
 
157
156
  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.
158
157
 
159
- 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. With `codex` in `policy.preferToUseBankedReset`, a seat at its limit consumes a banked reset credit (which refreshes both windows) and keeps running instead of restarting onto another account.
158
+ 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.
160
159
 
161
160
  ## Honest limitations
162
161
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "tokenmaxxing",
4
- "version": "1.20.0",
4
+ "version": "1.21.0",
5
5
  "description": "Pool Claude Code and Codex logins, switch on pace pressure, and expose safe ops tools to agent clients.",
6
6
  "author": {
7
7
  "name": "anaclumos",
@@ -17,7 +17,6 @@ description: Explain and apply tokenmaxxing switch policy (greedy vs hard path,
17
17
  - **Manual switch** retains pure pace ranking with no organization preference or incumbent margin.
18
18
  - **Pace pressure**: remaining weekly percent / time to weekly reset (highest first). Not most-remaining.
19
19
  - **Effective bars**: `effectiveBars(cfg, pool)` = the active rung of the 5h ladder (`thresholds.session`, default `[90]`, a single rung: the lowest rung some pooled account, the current one included, still clears) and the weekly bar, each minus `policy.projectionMargin`. Trigger and screening must share these bars or swaps ping-pong. Codex reads `terminalBars(cfg)`, the top rung only. The check cadence is capped one band per rung climbed, and every band is a multiple of the tick `policy.checkIntervalMs` (default 60000).
20
- - **Banked reset**: opt-in via `policy.preferToUseBankedReset` (a provider list, default `[]`), hard path only. Claude: the seat holds between the session rung and the wall while a reset is believed available and the weekly windows have room for one more measured session window (`sessionWindowWeeklyCost`; unmeasured swaps), claims the `/limit-reset` server call at the wall, and keeps the seat on `reset` (the failed turn is retriggered in place). Codex: rides to the wall while a reset credit exists, consumes one, no restart. The greedy path never resets.
21
20
 
22
21
  ## Layer 2 (Claude only)
23
22
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  - `docs/content/docs/switching.mdx`
4
4
  - `src/lib/decide.ts`, `src/lib/picker.ts`
5
- - `src/lib/bankedreset.ts`, `src/lib/codexreset.ts` (banked limit resets)
6
5
  - `.cursor/rules/switch-verification.md`
7
6
 
8
7
  Automatic organization preference requires session usage below `greedySessionFloor` and more than `max(1, session bar - greedySessionFloor)` points below every applicable bar.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.20.0",
3
+ "version": "1.21.0",
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/check.ts CHANGED
@@ -30,7 +30,6 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
30
30
  ok: true,
31
31
  due: true,
32
32
  swapped: d.swapped,
33
- reset: d.reset === true,
34
33
  account: d.account?.label ?? null,
35
34
  reason: d.reason,
36
35
  waitUntil: d.waitUntil ?? null,
@@ -41,8 +40,6 @@ export async function cmdCheck(args: string[] = [], json = false): Promise<numbe
41
40
  const next = c.dim(`next in ${Math.round(delayMs / 1000)}s`);
42
41
  if (d.swapped && d.account) {
43
42
  console.log(`${c.green("↻")} switched to ${c.bold(d.account.label)} ${next}`);
44
- } else if (d.reset && d.account) {
45
- console.log(`${c.green("↻")} reset the session limit on ${c.bold(d.account.label)} ${next}`);
46
43
  } else if (d.waitUntil !== undefined && d.account) {
47
44
  console.log(c.yellow(`all accounts at limit - staying on ${c.bold(d.account.label)} (${fmtReset(d.waitUntil)})`) + ` ${next}`);
48
45
  } else {
package/src/cli/config.ts CHANGED
@@ -21,7 +21,6 @@ export const KNOWN_KEYS = [
21
21
  "policy.usagePollTtlMs",
22
22
  "policy.maxWaitMs",
23
23
  "policy.checkIntervalMs",
24
- "policy.preferToUseBankedReset",
25
24
  ] as const;
26
25
 
27
26
  const TIMER_REWRITE_NOTE = "note: run `tokenmaxxing init` to rewrite the periodic timer at the new tick";
package/src/cli/status.ts CHANGED
@@ -12,7 +12,7 @@ import { isCodexExhausted } from "../lib/codexpick.ts";
12
12
  import { codexLimitLabel, isSessionWindow } from "../lib/codexusage.ts";
13
13
  import { bar, c, claudeTierLabel, count, emitJson, fmtAgo } from "./render.ts";
14
14
  import { gatedFamilies, type FullUsage } from "../lib/usage.ts";
15
- import { BankedResetRecordSchema, ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
15
+ import { ThresholdsSchema, UsageWindowSchema, type Account, type CodexWindow, type Config, type UsageWindow } from "../lib/types.ts";
16
16
 
17
17
  const SampleReportSchema = z.discriminatedUnion("ok", [
18
18
  z.object({ ok: z.literal(true), source: z.enum(["statusline", "probe"]) }),
@@ -35,9 +35,6 @@ const ClaudeStatusAccountSchema = z.object({
35
35
  pingError: z.string().nullable(),
36
36
  pingRejected: z.boolean(),
37
37
  pinged: z.boolean(),
38
- bankedReset: BankedResetRecordSchema.nullable(),
39
- sessionWindowWeeklyCost: z.number().nullable(),
40
- sessionWindowFamilyCosts: z.record(z.string(), z.number()).nullable(),
41
38
  });
42
39
  type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
43
40
 
@@ -59,7 +56,6 @@ const CodexStatusAccountSchema = z.object({
59
56
  .nullable(),
60
57
  usageAt: z.number().nullable(),
61
58
  sample: SampleReportSchema,
62
- resetCredits: z.number().int().nullable(),
63
59
  });
64
60
  type CodexStatusAccount = z.infer<typeof CodexStatusAccountSchema>;
65
61
 
@@ -158,7 +154,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
158
154
  if (!outcome.ok) return;
159
155
  a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
160
156
  a.lastUsageAt = viaTee && teeAt != null ? (live?.sampledAt ?? teeAt) : Date.now();
161
- if (viaTee && live?.sessionWindowWeeklyCost != null) a.sessionWindowWeeklyCost = live.sessionWindowWeeklyCost;
162
157
  if (Object.keys(outcome.usage.perModel).length > 0) {
163
158
  a.lastPerModel = outcome.usage.perModel;
164
159
  a.lastPerModelAt = viaTee && modelUsage ? (modelUsage.sampledAt ?? modelUsage.ts) : a.lastUsageAt;
@@ -178,7 +173,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
178
173
  const families = gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels);
179
174
  const bars = effectiveBars(cfg, { accounts: idx.accounts, now, switchFamilies: families });
180
175
  const liveAccount = readOAuthAccount()?.accountUuid ?? null;
181
- const liveModelUsage = loadModelUsage();
182
176
  const ordered = sortBy(idx.accounts, [(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, now)]);
183
177
  const accounts = ordered.map((a): ClaudeStatusAccount => {
184
178
  const sampled = samples.get(a.accountUuid) ?? { outcome: { ok: false, reason: "not sampled" }, viaTee: false };
@@ -201,9 +195,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
201
195
  pingError: sampled.outcome.pingError ?? null,
202
196
  pingRejected: sampled.outcome.pingRejected === true,
203
197
  pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
204
- bankedReset: a.bankedReset ?? null,
205
- sessionWindowWeeklyCost: a.sessionWindowWeeklyCost ?? null,
206
- sessionWindowFamilyCosts: liveModelUsage && liveModelUsage.account === a.accountUuid ? (liveModelUsage.familyCosts ?? null) : null,
207
198
  };
208
199
  });
209
200
  return {
@@ -231,7 +222,7 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
231
222
  const outcome = await sampleCodexAccount({ account, liveAccountId: liveId, now });
232
223
  outcomes.set(account.accountId, outcome);
233
224
  if (outcome.ok) {
234
- account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit, resetCredits: outcome.usage.resetCredits, reachedType: outcome.usage.reachedType };
225
+ account.lastUsage = { aggregate: outcome.usage.aggregate, perLimit: outcome.usage.perLimit };
235
226
  account.lastUsageAt = Date.now();
236
227
  if (outcome.usage.email != null) account.email = outcome.usage.email;
237
228
  if (outcome.usage.planType != null) account.planType = outcome.usage.planType;
@@ -272,7 +263,6 @@ async function collectCodex(input: { cfg: Config; now: number }): Promise<Status
272
263
  : null,
273
264
  usageAt: account.lastUsageAt ?? null,
274
265
  sample: outcome.ok ? { ok: true, source: "probe" } : { ok: false, reason: outcome.reason },
275
- resetCredits: usage?.resetCredits ?? null,
276
266
  };
277
267
  });
278
268
  return { bars, accounts };
@@ -99,7 +99,6 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
99
99
  }
100
100
  if (await promoteReconcile({ supervisorId, sessionId })) return;
101
101
  const decision = await evaluateAndMaybeSwapCodex({});
102
- if (decision.reset && decision.account) log("codexstop.reset", { account: decision.account.accountId.slice(0, 8) });
103
102
  if (decision.swapped && decision.account) {
104
103
  mkdirSync(codexPaths.respawnDir, { recursive: true });
105
104
  const payload = CodexRespawnMarkerSchema.parse({
@@ -4,13 +4,12 @@ import { paths } from "../lib/paths.ts";
4
4
  import { writeFileAtomic } from "../lib/atomic.ts";
5
5
  import { readOAuthAccount } from "../lib/claudejson.ts";
6
6
  import { enforcedWindowMs, evaluateAndMaybeSwap, postSwapProof, recordEnforcedLimit } from "../lib/decide.ts";
7
- import { loadConfig, loadLastSeatChangeAt, loadLastSwapAt } from "../lib/state.ts";
7
+ import { loadConfig, loadLastSwapAt } from "../lib/state.ts";
8
8
  import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
9
9
  import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
10
10
  import { log } from "../lib/log.ts";
11
11
 
12
12
  export const RETRIGGER_PROMPT = "Continue where the previous turn left off; it was interrupted by a usage limit and tokenmaxxing switched accounts.";
13
- export const RETRIGGER_RESET_PROMPT = "Continue where the previous turn left off; it was interrupted by the session limit and tokenmaxxing reset that limit on the same account.";
14
13
 
15
14
  const StopFailureStdin = z.looseObject({
16
15
  session_id: z.uuid().optional().catch(undefined),
@@ -53,11 +52,11 @@ export async function runStopFailureHook(): Promise<number> {
53
52
 
54
53
  let enforced: EnforcedLimit | null = null;
55
54
  if (limit && found && account) {
56
- if (postSwapProof({ swapAt: limit.kind === "session" ? loadLastSeatChangeAt() : loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
55
+ if (postSwapProof({ swapAt: loadLastSwapAt(), launchedAt, errorAt: found.errorAt, now })) {
57
56
  const stamp = await recordEnforcedLimit({ limit, account, now });
58
57
  log("stopfailure.enforced", { kind: limit.kind, family: limit.kind === "model" ? limit.family : undefined, outcome: stamp.outcome, resetsAt: stamp.resetsAt, subagent: !mainLoop });
59
58
  if (stamp.outcome !== "account-moved") {
60
- enforced = { account, kind: limit.kind, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
59
+ enforced = { account, family: limit.kind === "model" ? limit.family : null, resetsAt: stamp.resetsAt, windowMs: enforcedWindowMs(limit) };
61
60
  }
62
61
  } else {
63
62
  log("stopfailure.unproven", { kind: limit.kind });
@@ -72,20 +71,18 @@ export async function runStopFailureHook(): Promise<number> {
72
71
  }
73
72
 
74
73
  const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
75
- const reset = decision.reset === true;
76
- if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined || reset)) {
74
+ if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
77
75
  const marker = join(paths.respawnDir, pinnedSid);
78
76
  const payload = RespawnMarkerSchema.parse({
79
77
  account: decision.account.label,
80
78
  ts: Date.now(),
81
79
  waitUntil: decision.waitUntil ?? now,
82
80
  sessionId: stdinSid ?? pinnedSid,
83
- prompt: reset ? RETRIGGER_RESET_PROMPT : RETRIGGER_PROMPT,
81
+ prompt: RETRIGGER_PROMPT,
84
82
  ...(launchedAt != null ? { launchedAt } : {}),
85
- ...(reset ? { reset } : {}),
86
83
  });
87
84
  writeFileAtomic(marker, JSON.stringify(payload));
88
- log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil, reset });
85
+ log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.accountUuid.slice(0, 8), waitUntil: payload.waitUntil });
89
86
  } else {
90
87
  log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
91
88
  }
@@ -25,7 +25,6 @@ export async function runStopHook(): Promise<number> {
25
25
  try {
26
26
  const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
27
27
  const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
28
- if (decision.reset && decision.account) log("stop.reset", { account: decision.account.accountUuid.slice(0, 8) });
29
28
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
30
29
  log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
31
30
  if (decision.waitUntil !== undefined && canPause && pinnedSid) {
@@ -286,8 +286,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
286
286
  respawns++;
287
287
  if (m.waitUntil > Date.now()) {
288
288
  if (await countdownWait(m.account, m.waitUntil)) overriddenUntil = m.waitUntil;
289
- } else if (m.reset) process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: reset the session limit on ${m.account} - resuming...\x1b[0m\n`);
290
- else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
289
+ } else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming...\x1b[0m\n`);
291
290
  saveSessionFlags(m.sessionId, persistable, process.cwd());
292
291
  launchArgs = ["--resume", m.sessionId, ...persistable, ...(m.prompt ? ["--", m.prompt] : [])];
293
292
  continue;
@@ -11,7 +11,6 @@ import { performCodexSwap } from "./codexswap.ts";
11
11
  import { CodexInvalidGrantError, refreshCodexAuth } from "./codexoauth.ts";
12
12
  import { fetchCodexUsage } from "./codexusage.ts";
13
13
  import { codexIdentityOf, isCodexAccessExpiring, readLiveCodexAuth, writeLiveCodexAuth, writeParkedCodexAuth } from "./codexauth.ts";
14
- import { codexBankedResetVerdict, consumeLiveCodexBankedReset } from "./codexreset.ts";
15
14
  import { liveCodexAccountId } from "./codexsample.ts";
16
15
  import { livingCodexPresences, presentCodexAccountIds, targetableCodexAccounts } from "./codexpresence.ts";
17
16
  import { terminalBars } from "./picker.ts";
@@ -22,7 +21,6 @@ const CodexSwapDecisionSchema = z.object({
22
21
  swapped: z.boolean(),
23
22
  account: CodexAccountSchema.nullable(),
24
23
  reason: z.string(),
25
- reset: z.boolean().optional(),
26
24
  });
27
25
  export type CodexSwapDecision = z.infer<typeof CodexSwapDecisionSchema>;
28
26
 
@@ -53,7 +51,7 @@ async function sampleLiveOntoOwner(input: { now: number }): Promise<string | nul
53
51
  writeParkedCodexAuth({ credFile: owner.credFile, auth: live });
54
52
  }
55
53
  const usage = await fetchCodexUsage({ auth: live });
56
- owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit, resetCredits: usage.resetCredits, reachedType: usage.reachedType };
54
+ owner.lastUsage = { aggregate: usage.aggregate, perLimit: usage.perLimit };
57
55
  owner.lastUsageAt = now;
58
56
  if (usage.email != null) owner.email = usage.email;
59
57
  if (usage.planType != null) owner.planType = usage.planType;
@@ -139,28 +137,6 @@ export async function evaluateAndMaybeSwapCodex(input: { now?: number }): Promis
139
137
  isCodexExhausted({ account: active, thresholds: bars, now });
140
138
  if (!engaged) return { swapped: false, account: null, reason: "under-threshold-or-stale" };
141
139
 
142
- if (cfg.policy.preferToUseBankedReset.includes("codex") && active.needsReauth !== true) {
143
- const seat = active;
144
- const verdict = codexBankedResetVerdict({ account: seat, thresholds: bars, now, pollTtlMs: cfg.policy.usagePollTtlMs });
145
- if (verdict === "hold") {
146
- log("codexdecide.banked_reset_hold", { account: seat.accountId.slice(0, 8) });
147
- return { swapped: false, account: seat, reason: "banked-reset-hold" };
148
- }
149
- if (verdict === "claim") {
150
- const claim = await consumeLiveCodexBankedReset({ account: seat, now });
151
- if (claim === "reset") {
152
- try {
153
- await sampleLiveOntoOwner({ now: Date.now() });
154
- } catch (e) {
155
- log("codexdecide.resample_failed", { err: e instanceof Error ? e.message : String(e) });
156
- }
157
- const fresh = loadCodexAccounts().accounts.find((account) => account.accountId === activeId) ?? seat;
158
- if (fresh.lastUsage) postSwapResweep({ liveAccountId: activeId, bars, now });
159
- return { swapped: false, account: fresh, reason: "banked-reset", reset: true };
160
- }
161
- }
162
- }
163
-
164
140
  if (!isCodexExhausted({ account: active, thresholds: bars, now })) {
165
141
  while (true) {
166
142
  const current = loadCodexAccounts();
@@ -3,20 +3,20 @@ import { nextWeeklyReset } from "./picker.ts";
3
3
  import { isSessionWindow, weeklyWindowOf } from "./codexusage.ts";
4
4
  import type { CodexAccount, CodexWindow, Thresholds } from "./types.ts";
5
5
 
6
- export function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
6
+ function liveUsed(input: { window: CodexWindow; now: number; sampledAt: number | null }): number {
7
7
  const { window, now, sampledAt } = input;
8
8
  if (window.resetsAt != null) return window.resetsAt <= now ? 0 : window.usedPercentage;
9
9
  if (sampledAt != null && window.windowSeconds != null && now >= sampledAt + window.windowSeconds * 1000) return 0;
10
10
  return window.usedPercentage;
11
11
  }
12
12
 
13
- export function allWindows(account: CodexAccount): CodexWindow[] {
13
+ function allWindows(account: CodexAccount): CodexWindow[] {
14
14
  const usage = account.lastUsage;
15
15
  if (!usage) return [];
16
16
  return [...usage.aggregate, ...Object.values(usage.perLimit).flat()];
17
17
  }
18
18
 
19
- export function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
19
+ function barFor(input: { window: CodexWindow; thresholds: Thresholds }): number {
20
20
  return isSessionWindow({ window: input.window }) ? input.thresholds.session : input.thresholds.weekly;
21
21
  }
22
22
 
@@ -25,8 +25,6 @@ const WireRateLimitSchema = z.looseObject({
25
25
  secondary_window: WireWindowSchema.nullish(),
26
26
  });
27
27
 
28
- const WireReachedTypeSchema = z.union([z.string(), z.looseObject({ type: z.string().nullish(), kind: z.string().nullish() })]).nullish().catch(null);
29
-
30
28
  const WireUsageSchema = z.looseObject({
31
29
  account_id: z.string(),
32
30
  email: z.string().nullish(),
@@ -35,8 +33,6 @@ const WireUsageSchema = z.looseObject({
35
33
  additional_rate_limits: z
36
34
  .array(z.looseObject({ limit_name: z.string(), rate_limit: WireRateLimitSchema.nullish() }))
37
35
  .nullish(),
38
- rate_limit_reset_credits: z.looseObject({ available_count: z.number().int().nullish() }).nullish(),
39
- rate_limit_reached_type: WireReachedTypeSchema,
40
36
  });
41
37
 
42
38
  function toWindows(rateLimit: z.infer<typeof WireRateLimitSchema> | null | undefined): CodexWindow[] {
@@ -96,11 +92,6 @@ export async function fetchCodexUsage(input: { auth: CodexAuthJson }): Promise<C
96
92
  planType: wire.plan_type ?? null,
97
93
  aggregate: toWindows(wire.rate_limit),
98
94
  perLimit,
99
- resetCredits: wire.rate_limit_reset_credits?.available_count ?? null,
100
- reachedType:
101
- typeof wire.rate_limit_reached_type === "string"
102
- ? wire.rate_limit_reached_type
103
- : (wire.rate_limit_reached_type?.type ?? wire.rate_limit_reached_type?.kind ?? null),
104
95
  });
105
96
  }
106
97
 
package/src/lib/decide.ts CHANGED
@@ -2,9 +2,8 @@ import { maxBy } from "es-toolkit";
2
2
  import { z } from "zod";
3
3
  import { withLock } from "./lock.ts";
4
4
  import { paths } from "./paths.ts";
5
- import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, measureFamilyWindow, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
5
+ import { MAX_CHECK_DELAY_TICKS, POST_SWAP_COOLDOWN_MS, maxCheckDelayMs, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts, saveDepletedWait, saveModelUsage, writeUsage } from "./state.ts";
6
6
  import { readOAuthAccount } from "./claudejson.ts";
7
- import { bankedResetBelievedAvailable, claimBankedReset } from "./bankedreset.ts";
8
7
  import { chooseAndSwap, isSkippableSwapError, performSwap, type VerifyVerdict } from "./swap.ts";
9
8
  import { probeParkedUsage } from "./sample.ts";
10
9
  import { currentWins, effectiveBars, hardBars, isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, sessionLadder, usableAt, type PickCtx } from "./picker.ts";
@@ -17,7 +16,6 @@ const SwapDecisionSchema = z.object({
17
16
  account: AccountSchema.nullable(),
18
17
  reason: z.string(),
19
18
  waitUntil: z.number().optional(),
20
- reset: z.boolean().optional(),
21
19
  });
22
20
  export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
23
21
 
@@ -40,17 +38,6 @@ function capForFamily(mu: ModelUsageState, family: string, now: number): UsageWi
40
38
  return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: mu.sampledAt ?? mu.ts, now }));
41
39
  }
42
40
 
43
- function familyCaps(perModel: Record<string, UsageWindow>, families: string[]): Record<string, number> {
44
- return Object.fromEntries(
45
- families.flatMap((family) => {
46
- const rows = Object.entries(perModel)
47
- .filter(([k]) => familyTokens(k).includes(family))
48
- .map(([, w]) => w.usedPercentage);
49
- return rows.length > 0 ? [[family, Math.max(...rows)] as const] : [];
50
- }),
51
- );
52
- }
53
-
54
41
  function overlayLive(accounts: Account[], u: UsageState | null, mu: ModelUsageState | null, account: string | null): Account[] {
55
42
  return accounts.map((a) => {
56
43
  if (account == null || a.accountUuid !== account) return a;
@@ -79,41 +66,6 @@ function needsPerModel(u: UsageState | null, cfg: Config): boolean {
79
66
  return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
80
67
  }
81
68
 
82
- const BankedVerdictSchema = z.enum(["hold", "claim", "pass"]);
83
- type BankedVerdict = z.infer<typeof BankedVerdictSchema>;
84
-
85
- function bankedResetVerdict(input: {
86
- seat: Account;
87
- u: UsageState | null;
88
- mu: ModelUsageState | null;
89
- enforced: EnforcedLimit | null;
90
- cfg: Config;
91
- bars: Thresholds;
92
- switchFamilies: string[];
93
- now: number;
94
- }): BankedVerdict {
95
- const { seat, u, mu, enforced, cfg, bars, switchFamilies, now } = input;
96
- if (enforced && enforced.kind !== "session") return "pass";
97
- if (!u || u.account !== seat.accountUuid) return "pass";
98
- if (!bankedResetBelievedAvailable(seat, now)) return "pass";
99
- if (u.sampledAt == null || now - u.sampledAt > cfg.policy.usagePollTtlMs) return "pass";
100
- const cost = u.sessionWindowWeeklyCost ?? seat.sessionWindowWeeklyCost;
101
- if (cost == null) return "pass";
102
- const weeklyUsed = liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now });
103
- if (weeklyUsed + cost > bars.weekly) return "pass";
104
- const muSame = mu && mu.account === seat.accountUuid && now - (mu.sampledAt ?? mu.ts) <= cfg.policy.usagePollTtlMs ? mu : null;
105
- for (const family of switchFamilies) {
106
- const cap = muSame ? capForFamily(muSame, family, now) : undefined;
107
- if (!cap || !muSame) return "pass";
108
- const familyCost = muSame.familyCosts?.[family];
109
- if (familyCost == null) return "pass";
110
- const capUsed = liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: muSame.sampledAt ?? muSame.ts, now });
111
- if (capUsed + familyCost > bars.weekly) return "pass";
112
- }
113
- const atWall = enforced?.kind === "session" || liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= hardBars(cfg).session;
114
- return atWall ? "claim" : "hold";
115
- }
116
-
117
69
  function isEngaged(u: UsageState | null, mu: ModelUsageState | null, account: string | null, bars: Thresholds, cfg: Config, now: number): boolean {
118
70
  if (!u || !account || u.account !== account) return false;
119
71
  return liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= cfg.policy.greedySessionFloor || isOver(u, mu, account, bars, cfg, now);
@@ -149,7 +101,6 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
149
101
  if (account && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
150
102
  const full = await probeUsage();
151
103
  const ts = Date.now();
152
- const prevMu = mu;
153
104
  if (readOAuthAccount()?.accountUuid === account) {
154
105
  if (full) {
155
106
  const teed = loadUsageSnapshot();
@@ -161,7 +112,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
161
112
  writeUsage(u);
162
113
  uAt = ts;
163
114
  }
164
- mu = { perModel: full.perModel, account, ts, sampledAt: ts, ...measureFamilyWindow(prevMu, account, full.session, familyCaps(full.perModel, cfg.policy.switchModels)) };
115
+ mu = { perModel: full.perModel, account, ts, sampledAt: ts };
165
116
  saveModelUsage(mu);
166
117
  const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
167
118
  const rows = Object.keys(full.perModel);
@@ -169,7 +120,7 @@ async function loadFreshSnapshots(cfg: Config, account: string | null, now: numb
169
120
  log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
170
121
  }
171
122
  } else {
172
- mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined, ...measureFamilyWindow(prevMu, account, null, {}) };
123
+ mu = { perModel: mu?.account === account ? (mu?.perModel ?? {}) : {}, account, ts, sampledAt: mu?.account === account ? (mu?.sampledAt ?? mu?.ts) : undefined };
173
124
  saveModelUsage(mu);
174
125
  }
175
126
  }
@@ -224,7 +175,6 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
224
175
  if (tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
225
176
  active.lastUsage = { fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay };
226
177
  active.lastUsageAt = tee.state.sampledAt ?? tee.at;
227
- if (tee.state.sessionWindowWeeklyCost != null) active.sessionWindowWeeklyCost = tee.state.sessionWindowWeeklyCost;
228
178
  sampled = true;
229
179
  }
230
180
  if (mu2 && mu2.account === account2 && Object.keys(mu2.perModel).length > 0) {
@@ -322,26 +272,12 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
322
272
  };
323
273
  if (!enforced2 && !isOver(u2, mu2, account2, bars, cfg, now)) return greedy(cfg.policy.greedySwapMargin);
324
274
 
325
- const resetPreferred = cfg.policy.preferToUseBankedReset.includes("claude");
326
- let resetTried = false;
327
275
  while (true) {
328
276
  const cur = loadAccounts();
329
277
  const seat = seatOf(cur);
330
278
  const pool = usable(cur.accounts);
331
279
  const thresholds = barsOf(cur.accounts);
332
280
  if (!enforced2 && seat && !seat.needsReauth && !isExhausted(seat, { now, thresholds, currentAccountUuid: null, switchFamilies })) return greedy(0);
333
- if (resetPreferred && !resetTried && seat && !seat.needsReauth) {
334
- const verdict = bankedResetVerdict({ seat, u: u2, mu: mu2, enforced: enforced2, cfg, bars: thresholds, switchFamilies, now });
335
- if (verdict === "hold") {
336
- log("decide.banked_reset_hold", { account: seat.accountUuid.slice(0, 8), enforced: enforced2 != null });
337
- return { swapped: false, account: seat, reason: "banked-reset-hold" };
338
- }
339
- if (verdict === "claim") {
340
- resetTried = true;
341
- const claim = await claimBankedReset({ account: seat });
342
- if (claim === "reset") return { swapped: false, account: seat, reason: "banked-reset", reset: true };
343
- }
344
- }
345
281
  const ctx = { now, thresholds, currentAccountUuid: seat?.accountUuid ?? null, currentOrganizationUuid: seat?.organizationUuid ?? null, orgAffinityFloor: cfg.policy.greedySessionFloor, switchFamilies };
346
282
  const best = pickBest(pool, ctx);
347
283
  if (!best) break;
@@ -452,12 +388,11 @@ export async function recordEnforcedLimit(input: { limit: EnforcedClass; account
452
388
  account: accountUuid,
453
389
  ts: now,
454
390
  sampledAt: resetsAt == null ? now : sampledAt ?? now,
455
- ...measureFamilyWindow(muSame, accountUuid, null, {}),
456
391
  });
457
392
  log("usage.enforced_limit", { kind: limit.kind, family: limit.family, resetsAt });
458
393
  return { outcome: "stamped", resetsAt };
459
394
  }
460
- saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt, ...measureFamilyWindow(muSame, accountUuid, null, {}) });
395
+ saveModelUsage({ perModel: carriedRows, account: accountUuid, ts: now, sampledAt });
461
396
  if (account && limit.resetsAt != null) {
462
397
  account.enforcedUntil = limit.resetsAt;
463
398
  saveAccounts(idx);
package/src/lib/paths.ts CHANGED
@@ -19,7 +19,6 @@ export const paths = {
19
19
  usageJson: join(TM_HOME, "usage.json"),
20
20
  modelUsageJson: join(TM_HOME, "model-usage.json"),
21
21
  lastSwapJson: join(TM_HOME, "lastswap.json"),
22
- lastResetJson: join(TM_HOME, "lastreset.json"),
23
22
  depletedJson: join(TM_HOME, "depleted.json"),
24
23
  nextCheckJson: join(TM_HOME, "nextcheck.json"),
25
24
  respawnDir: join(TM_HOME, "respawn"),
package/src/lib/state.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { closeSync, existsSync, fstatSync, openSync, readFileSync, rmSync, utimesSync } from "node:fs";
2
- import { isEqual, uniq } from "es-toolkit";
2
+ import { isEqual } from "es-toolkit";
3
3
  import { z } from "zod";
4
4
  import { paths, realClaudeBinFromEnv, realCodexBinFromEnv } from "./paths.ts";
5
5
  import { writeFileAtomic } from "./atomic.ts";
6
6
  import {
7
7
  AccountsIndexSchema,
8
- BankedResetProvidersSchema,
9
8
  ConfigSchema,
10
9
  LastSwapSchema,
11
10
  ModelUsageStateSchema,
@@ -14,11 +13,8 @@ import {
14
13
  UsageStateSchema,
15
14
  type AccountsIndex,
16
15
  type Config,
17
- type FamilyAnchor,
18
16
  type ModelUsageState,
19
17
  type UsageState,
20
- type UsageWindow,
21
- type WindowAnchor,
22
18
  } from "./types.ts";
23
19
 
24
20
  const DEFAULT_CONFIG: Config = {
@@ -34,7 +30,6 @@ const DEFAULT_CONFIG: Config = {
34
30
  usagePollTtlMs: 90_000,
35
31
  maxWaitMs: 3_600_000,
36
32
  checkIntervalMs: 60_000,
37
- preferToUseBankedReset: [],
38
33
  },
39
34
  };
40
35
 
@@ -55,7 +50,6 @@ export const ConfigFileSchema = z
55
50
  usagePollTtlMs: z.number().int().positive(),
56
51
  maxWaitMs: z.number().int().positive(),
57
52
  checkIntervalMs: z.number().int().min(10_000),
58
- preferToUseBankedReset: BankedResetProvidersSchema,
59
53
  })
60
54
  .partial(),
61
55
  })
@@ -89,9 +83,6 @@ export function mergeConfigFile(p: z.infer<typeof ConfigFileSchema>): MergeOutco
89
83
  if (p.policy?.switchModels) {
90
84
  cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
91
85
  }
92
- if (p.policy?.preferToUseBankedReset) {
93
- cfg.policy.preferToUseBankedReset = uniq(p.policy.preferToUseBankedReset);
94
- }
95
86
  const envBin = realClaudeBinFromEnv();
96
87
  if (envBin) cfg.claudeBin = envBin;
97
88
  const envCodexBin = realCodexBinFromEnv();
@@ -196,29 +187,6 @@ export function saveLastSwapAt(ts: number): void {
196
187
  writeFileAtomic(paths.lastSwapJson, JSON.stringify(LastSwapSchema.parse({ ts })));
197
188
  }
198
189
 
199
- export function loadLastResetAt(): number | null {
200
- if (!existsSync(paths.lastResetJson)) return null;
201
- let json: unknown;
202
- try {
203
- json = JSON.parse(readFileSync(paths.lastResetJson, "utf8"));
204
- } catch {
205
- throw new Error(`${paths.lastResetJson} is corrupt (unparsable JSON) - refusing to treat a damaged reset clock as never-reset; repair or remove the file`);
206
- }
207
- return LastSwapSchema.parse(json).ts;
208
- }
209
-
210
- export function saveLastResetAt(ts: number): void {
211
- writeFileAtomic(paths.lastResetJson, JSON.stringify(LastSwapSchema.parse({ ts })));
212
- }
213
-
214
- export function loadLastSeatChangeAt(): number | null {
215
- const swapAt = loadLastSwapAt();
216
- const resetAt = loadLastResetAt();
217
- if (swapAt == null) return resetAt;
218
- if (resetAt == null) return swapAt;
219
- return Math.max(swapAt, resetAt);
220
- }
221
-
222
190
  const DepletedWaitSchema = z.object({ waitUntil: z.number(), accountUuid: z.string(), ts: z.number() });
223
191
  export type DepletedWait = z.infer<typeof DepletedWaitSchema>;
224
192
 
@@ -270,61 +238,13 @@ export function clearNextCheck(): void {
270
238
 
271
239
  const USAGE_TS_REFRESH_MS = 10 * 60_000;
272
240
  const SAMPLED_AT_REFRESH_MS = 30_000;
273
- const SESSION_COST_MIN_DELTA = 25;
274
-
275
- function anchorHolds(anchor: WindowAnchor, next: UsageState): boolean {
276
- return (
277
- anchor.fiveHourResetsAt === next.fiveHour.resetsAt &&
278
- anchor.sevenDayResetsAt === next.sevenDay.resetsAt &&
279
- next.fiveHour.usedPercentage >= anchor.session &&
280
- next.sevenDay.usedPercentage >= anchor.weekly
281
- );
282
- }
283
-
284
- export function measureSessionWindow(prev: UsageState | null, next: UsageState, stamp: boolean): Pick<UsageState, "anchor" | "sessionWindowWeeklyCost"> {
285
- const same = prev != null && prev.account === next.account;
286
- const carried = same ? prev.sessionWindowWeeklyCost : undefined;
287
- if (stamp) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { sessionWindowWeeklyCost: carried } : {}) };
288
- const anchor: WindowAnchor =
289
- same && prev.anchor && anchorHolds(prev.anchor, next)
290
- ? prev.anchor
291
- : { fiveHourResetsAt: next.fiveHour.resetsAt, sevenDayResetsAt: next.sevenDay.resetsAt, session: next.fiveHour.usedPercentage, weekly: next.sevenDay.usedPercentage };
292
- const sessionDelta = next.fiveHour.usedPercentage - anchor.session;
293
- const weeklyDelta = next.sevenDay.usedPercentage - anchor.weekly;
294
- const measured = sessionDelta >= SESSION_COST_MIN_DELTA && weeklyDelta > 0 ? (100 * weeklyDelta) / sessionDelta : undefined;
295
- const cost = measured ?? carried;
296
- return { anchor, ...(cost != null ? { sessionWindowWeeklyCost: cost } : {}) };
297
- }
298
-
299
- function familyAnchorHolds(anchor: FamilyAnchor, session: UsageWindow, caps: Record<string, number>): boolean {
300
- return (
301
- anchor.fiveHourResetsAt === session.resetsAt &&
302
- session.usedPercentage >= anchor.session &&
303
- Object.entries(caps).every(([family, used]) => anchor.caps[family] == null || used >= anchor.caps[family])
304
- );
305
- }
306
-
307
- export function measureFamilyWindow(prev: ModelUsageState | null, account: string | null, session: UsageWindow | null, caps: Record<string, number>): Pick<ModelUsageState, "anchor" | "familyCosts"> {
308
- const same = prev != null && prev.account === account;
309
- const carried = same ? prev.familyCosts : undefined;
310
- if (session == null) return { ...(same && prev.anchor ? { anchor: prev.anchor } : {}), ...(carried != null ? { familyCosts: carried } : {}) };
311
- const held = same && prev.anchor && familyAnchorHolds(prev.anchor, session, caps) ? prev.anchor : null;
312
- const anchor: FamilyAnchor = held ? { ...held, caps: { ...caps, ...held.caps } } : { fiveHourResetsAt: session.resetsAt, session: session.usedPercentage, caps };
313
- const sessionDelta = session.usedPercentage - anchor.session;
314
- const measured = Object.entries(caps).flatMap(([family, used]) => {
315
- const capDelta = used - (anchor.caps[family] ?? used);
316
- return sessionDelta >= SESSION_COST_MIN_DELTA && capDelta > 0 ? [[family, (100 * capDelta) / sessionDelta] as const] : [];
317
- });
318
- const familyCosts = { ...carried, ...Object.fromEntries(measured) };
319
- return { anchor, ...(Object.keys(familyCosts).length > 0 ? { familyCosts } : {}) };
320
- }
321
241
 
322
242
  export function writeUsage(input: UsageState, opts: { stamp?: boolean } = {}): boolean {
323
243
  const prev = loadUsage();
324
244
  const stamp = opts.stamp === true;
325
245
  const carriedSampleAt = prev != null && prev.account === input.account ? (prev.sampledAt ?? prev.ts) : undefined;
326
246
  const sampledAt = stamp ? (carriedSampleAt ?? input.sampledAt) : input.ts;
327
- const next: UsageState = { ...input, ...measureSessionWindow(prev, input, stamp), ...(sampledAt != null ? { sampledAt } : {}) };
247
+ const next: UsageState = { ...input, ...(sampledAt != null ? { sampledAt } : {}) };
328
248
  if (
329
249
  prev &&
330
250
  isEqual({ ...prev, ts: 0, sampledAt: 0 }, { ...next, ts: 0, sampledAt: 0 }) &&
package/src/lib/types.ts CHANGED
@@ -41,51 +41,22 @@ export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
41
41
  const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
42
42
  export type ModelInfo = z.infer<typeof ModelInfoSchema>;
43
43
 
44
- const WindowAnchorSchema = z.object({
45
- fiveHourResetsAt: z.number().nullable(),
46
- sevenDayResetsAt: z.number().nullable(),
47
- session: z.number(),
48
- weekly: z.number(),
49
- });
50
- export type WindowAnchor = z.infer<typeof WindowAnchorSchema>;
51
-
52
44
  export const UsageStateSchema = UsageWindowsSchema.extend({
53
45
  account: z.string().nullable(),
54
46
  ts: z.number(),
55
47
  model: ModelInfoSchema.nullable().default(null),
56
- anchor: WindowAnchorSchema.optional(),
57
- sessionWindowWeeklyCost: z.number().positive().optional(),
58
48
  sampledAt: z.number().optional(),
59
49
  });
60
50
  export type UsageState = z.infer<typeof UsageStateSchema>;
61
51
 
62
- const FamilyAnchorSchema = z.object({
63
- fiveHourResetsAt: z.number().nullable(),
64
- session: z.number(),
65
- caps: z.record(z.string(), z.number()),
66
- });
67
- export type FamilyAnchor = z.infer<typeof FamilyAnchorSchema>;
68
-
69
52
  export const ModelUsageStateSchema = z.object({
70
53
  perModel: z.record(z.string(), UsageWindowSchema).default({}),
71
54
  account: z.string().nullable(),
72
55
  ts: z.number(),
73
56
  sampledAt: z.number().optional(),
74
- anchor: FamilyAnchorSchema.optional(),
75
- familyCosts: z.record(z.string(), z.number()).optional(),
76
57
  });
77
58
  export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
78
59
 
79
- export const BankedResetOutcomeSchema = z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]);
80
- export type BankedResetOutcome = z.infer<typeof BankedResetOutcomeSchema>;
81
-
82
- export const BankedResetRecordSchema = z.object({
83
- outcome: BankedResetOutcomeSchema,
84
- at: z.number(),
85
- nextAvailableAt: z.number().nullable(),
86
- });
87
- export type BankedResetRecord = z.infer<typeof BankedResetRecordSchema>;
88
-
89
60
  export const AccountSchema = z.object({
90
61
  accountUuid: z.string(),
91
62
  email: z.string(),
@@ -102,8 +73,6 @@ export const AccountSchema = z.object({
102
73
  needsReauth: z.boolean().optional(),
103
74
  subscriptionType: z.string().optional(),
104
75
  rateLimitTier: z.string().optional(),
105
- sessionWindowWeeklyCost: z.number().positive().optional(),
106
- bankedReset: BankedResetRecordSchema.optional(),
107
76
  });
108
77
  export type Account = z.infer<typeof AccountSchema>;
109
78
 
@@ -120,7 +89,6 @@ export const NextCheckSchema = z.object({ dueAt: z.number(), ts: z.number() });
120
89
 
121
90
  export const EnforcedLimitSchema = z.object({
122
91
  account: z.string(),
123
- kind: z.enum(["session", "weekly", "model"]),
124
92
  family: z.string().nullable(),
125
93
  resetsAt: z.number().nullable(),
126
94
  windowMs: z.number(),
@@ -145,10 +113,6 @@ export const ScreeningThresholdsSchema = z.object({
145
113
  weekly: z.number().min(0).max(100),
146
114
  });
147
115
 
148
- export const BankedResetProviderSchema = z.enum(["claude", "codex"]);
149
- export type BankedResetProvider = z.infer<typeof BankedResetProviderSchema>;
150
- export const BankedResetProvidersSchema = z.array(BankedResetProviderSchema);
151
-
152
116
  export const ConfigSchema = z
153
117
  .object({
154
118
  thresholds: ScreeningThresholdsSchema,
@@ -163,7 +127,6 @@ export const ConfigSchema = z
163
127
  usagePollTtlMs: z.number().int().positive(),
164
128
  maxWaitMs: z.number().int().positive(),
165
129
  checkIntervalMs: z.number().int().min(10_000),
166
- preferToUseBankedReset: BankedResetProvidersSchema,
167
130
  }),
168
131
  })
169
132
  .refine((cfg) => cfg.policy.projectionMargin < Math.min(...cfg.thresholds.session, cfg.thresholds.weekly), {
@@ -181,7 +144,6 @@ export const RespawnMarkerSchema = z.object({
181
144
  sessionId: z.string(),
182
145
  prompt: z.string().optional(),
183
146
  launchedAt: z.number().optional(),
184
- reset: z.boolean().optional(),
185
147
  });
186
148
 
187
149
  export const RateLimitsStdinSchema = z.looseObject({
@@ -281,21 +243,9 @@ export const CodexUsageSchema = z.object({
281
243
  planType: z.string().nullable(),
282
244
  aggregate: z.array(CodexWindowSchema),
283
245
  perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
284
- resetCredits: z.number().int().nullable(),
285
- reachedType: z.string().nullable(),
286
246
  });
287
247
  export type CodexUsage = z.infer<typeof CodexUsageSchema>;
288
248
 
289
- export const CodexBankedResetOutcomeSchema = z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]);
290
- export type CodexBankedResetOutcome = z.infer<typeof CodexBankedResetOutcomeSchema>;
291
-
292
- export const CodexBankedResetRecordSchema = z.object({
293
- outcome: CodexBankedResetOutcomeSchema,
294
- at: z.number(),
295
- key: z.string().optional(),
296
- });
297
- export type CodexBankedResetRecord = z.infer<typeof CodexBankedResetRecordSchema>;
298
-
299
249
  const BareFileNameSchema = z
300
250
  .string()
301
251
  .refine((s) => s.length > 0 && s !== "." && s !== ".." && !s.includes("/") && !s.includes("\\"), {
@@ -314,12 +264,9 @@ export const CodexAccountSchema = z.object({
314
264
  .object({
315
265
  aggregate: z.array(CodexWindowSchema),
316
266
  perLimit: z.record(z.string(), z.array(CodexWindowSchema)),
317
- resetCredits: z.number().int().nullable().optional(),
318
- reachedType: z.string().nullable().optional(),
319
267
  })
320
268
  .optional(),
321
269
  lastUsageAt: z.number().optional(),
322
- bankedReset: CodexBankedResetRecordSchema.optional(),
323
270
  });
324
271
  export type CodexAccount = z.infer<typeof CodexAccountSchema>;
325
272
 
@@ -1,234 +0,0 @@
1
- import { readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { z } from "zod";
4
- import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
5
- import { withClaudeRefreshLock } from "./claudelock.ts";
6
- import { liveTarget, mergeIntoLive, readItem, writeItem } from "./credstore.ts";
7
- import { http, safeErrorDetail } from "./http.ts";
8
- import { log } from "./log.ts";
9
- import { InvalidGrantError, fetchTokenIdentity, isAccessTokenExpiring, isDeadCredential, refreshCredential } from "./oauth.ts";
10
- import { clearDepletedWait, clearNextCheck, loadAccounts, loadUsage, saveAccounts, saveLastResetAt, writeUsage } from "./state.ts";
11
- import { keepRotatedPair } from "./swap.ts";
12
- import { normalizeResetsAt } from "./usage.ts";
13
- import { CredentialBlobSchema, type Account, type BankedResetOutcome, type BankedResetRecord, type OAuthCreds } from "./types.ts";
14
-
15
- const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
16
- const API_BASE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_API_BASE_URL) ?? "https://api.anthropic.com";
17
- const OAUTH_BETA = "oauth-2025-04-20";
18
- const RESET_PROGRAM = "juniper_tide";
19
- const CLAIM_TIMEOUT_MS = 25_000;
20
- const VERSION_PROBE_TIMEOUT_MS = 5_000;
21
- const REFRESH_SKEW_MS = 300_000;
22
- const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
23
- const INELIGIBLE_TTL_MS = 24 * 60 * 60 * 1000;
24
-
25
- const ClaimResponseSchema = z.looseObject({
26
- result: z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable"]).catch("unavailable"),
27
- next_available_at: z.union([z.string(), z.number()]).nullable().optional().catch(null),
28
- });
29
-
30
- const ClaimSchema = z.object({ outcome: z.enum(["reset", "pass"]) });
31
- export type Claim = z.infer<typeof ClaimSchema>["outcome"];
32
-
33
- export function bankedResetBelievedAvailable(account: Account, now: number): boolean {
34
- const rec = account.bankedReset;
35
- if (!rec) return true;
36
- if (rec.nextAvailableAt != null) return rec.nextAvailableAt <= now;
37
- if (rec.outcome === "reset" || rec.outcome === "already_used") return now - rec.at >= WEEK_MS;
38
- if (rec.outcome === "ineligible") return now - rec.at >= INELIGIBLE_TTL_MS;
39
- return true;
40
- }
41
-
42
- const VersionSchema = z.string().regex(/^\d+\.\d+\.\d+\S*$/);
43
- const PackageJsonSchema = z.looseObject({ version: VersionSchema });
44
-
45
- function claudeVersion(): string | undefined {
46
- const bin = resolveRealClaude();
47
- const fromPath = VersionSchema.safeParse(bin.match(/\/versions\/(\d+\.\d+\.\d+[^/]*)$/)?.[1]);
48
- if (fromPath.success) return fromPath.data;
49
- try {
50
- const pkg = PackageJsonSchema.safeParse(JSON.parse(readFileSync(join(dirname(bin), "package.json"), "utf8")));
51
- if (pkg.success) return pkg.data.version;
52
- } catch {
53
- }
54
- try {
55
- const r = Bun.spawnSync([bin, "--version"], {
56
- env: { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) },
57
- stdout: "pipe",
58
- stderr: "pipe",
59
- timeout: VERSION_PROBE_TIMEOUT_MS,
60
- });
61
- const spawned = VersionSchema.safeParse(r.stdout.toString("utf8").trim().match(/^(\d+\.\d+\.\d+\S*)/)?.[1]);
62
- if (spawned.success) return spawned.data;
63
- } catch {
64
- }
65
- return undefined;
66
- }
67
-
68
- function claudeUserAgent(): string {
69
- const version = claudeVersion();
70
- return version ? `claude-cli/${version} (external, cli)` : "claude-cli (external, cli)";
71
- }
72
-
73
- const LiveTokenSchema = z.discriminatedUnion("ok", [
74
- z.object({ ok: z.literal(true), accessToken: z.string(), organizationUuid: z.string() }),
75
- z.object({ ok: z.literal(false), outcome: z.enum(["auth_error", "error"]), detail: z.string() }),
76
- ]);
77
- type LiveToken = z.infer<typeof LiveTokenSchema>;
78
-
79
- function liveAccessToken(raw: string | null): string | null {
80
- if (raw == null) return null;
81
- const parsed = CredentialBlobSchema.safeParse((() => {
82
- try { return JSON.parse(raw); } catch { return null; }
83
- })());
84
- return parsed.success ? parsed.data.claudeAiOauth.accessToken : null;
85
- }
86
-
87
- async function liveTokenLocked(account: Account, lock: { compromised: () => boolean }): Promise<LiveToken> {
88
- const raw = await readItem(liveTarget());
89
- if (!raw) return { ok: false, outcome: "auth_error", detail: "no live credential" };
90
- let creds: OAuthCreds;
91
- try {
92
- creds = CredentialBlobSchema.parse(JSON.parse(raw)).claudeAiOauth;
93
- } catch (e) {
94
- return { ok: false, outcome: "error", detail: `live credential blob unreadable (${(e instanceof Error ? e.message : String(e)).slice(0, 80)})` };
95
- }
96
- if (isDeadCredential(creds)) return { ok: false, outcome: "auth_error", detail: "live credential was cleared after a failed refresh" };
97
- if (isAccessTokenExpiring(creds, REFRESH_SKEW_MS)) {
98
- let next: OAuthCreds;
99
- try {
100
- next = await refreshCredential(creds);
101
- } catch (e) {
102
- const detail = e instanceof Error ? e.message : String(e);
103
- return { ok: false, outcome: e instanceof InvalidGrantError ? "auth_error" : "error", detail };
104
- }
105
- if (lock.compromised()) {
106
- let owner: Account | null = null;
107
- try {
108
- const id = await fetchTokenIdentity(next.accessToken);
109
- owner = loadAccounts().accounts.find((a) => a.accountUuid === id.accountUuid) ?? null;
110
- } catch {
111
- }
112
- const kept = await keepRotatedPair({ fresh: next, owner, fallback: account, liveOwnerUuid: account.accountUuid, expectedLiveToken: creds.accessToken, lock });
113
- return { ok: false, outcome: "error", detail: `refresh lock compromised mid-refresh - the rotated token ${kept}` };
114
- }
115
- await writeItem(liveTarget(), mergeIntoLive(raw, next));
116
- creds = next;
117
- }
118
- let identity;
119
- try {
120
- identity = await fetchTokenIdentity(creds.accessToken);
121
- } catch (e) {
122
- return { ok: false, outcome: "error", detail: e instanceof Error ? e.message : String(e) };
123
- }
124
- if (identity.accountUuid !== account.accountUuid) {
125
- return { ok: false, outcome: "error", detail: `live credential belongs to account ${identity.accountUuid.slice(0, 8)}, not the seat` };
126
- }
127
- return { ok: true, accessToken: creds.accessToken, organizationUuid: identity.organizationUuid };
128
- }
129
-
130
- const PostResultSchema = z.object({
131
- outcome: z.enum(["reset", "already_used", "not_limited", "ineligible", "unavailable", "rate_limited", "auth_error", "error"]),
132
- nextAvailableAt: z.number().nullable(),
133
- detail: z.string(),
134
- ambiguous: z.boolean().optional(),
135
- });
136
- type PostResult = z.infer<typeof PostResultSchema>;
137
-
138
- async function postClaim(input: { accessToken: string; organizationUuid: string; userAgent: string }): Promise<PostResult> {
139
- const url = `${API_BASE_URL}/api/organizations/${input.organizationUuid}/reset_rate_limits`;
140
- let res: Response;
141
- try {
142
- res = await http.post(url, {
143
- headers: {
144
- Authorization: `Bearer ${input.accessToken}`,
145
- "anthropic-beta": OAUTH_BETA,
146
- "Content-Type": "application/json",
147
- "User-Agent": input.userAgent,
148
- },
149
- body: JSON.stringify({ program: RESET_PROGRAM }),
150
- timeout: CLAIM_TIMEOUT_MS,
151
- });
152
- } catch (e) {
153
- return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint unreachable: ${e instanceof Error ? e.message : String(e)}` };
154
- }
155
- let text: string;
156
- try {
157
- text = await res.text();
158
- } catch (e) {
159
- return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint answered HTTP ${res.status} but the body could not be read: ${e instanceof Error ? e.message : String(e)}`, ambiguous: true };
160
- }
161
- if (!res.ok) {
162
- const detail = `HTTP ${res.status}: ${safeErrorDetail({ text })}`;
163
- if (res.status === 429) return { outcome: "rate_limited", nextAvailableAt: null, detail };
164
- if (res.status === 401 || res.status === 403) return { outcome: "auth_error", nextAvailableAt: null, detail };
165
- return { outcome: "error", nextAvailableAt: null, detail };
166
- }
167
- const parsed = ClaimResponseSchema.safeParse((() => {
168
- try { return JSON.parse(text); } catch { return null; }
169
- })());
170
- if (!parsed.success) {
171
- return { outcome: "error", nextAvailableAt: null, detail: `reset endpoint answered HTTP ${res.status} with an unrecognized body (${text.length} bytes, withheld)`, ambiguous: true };
172
- }
173
- return { outcome: parsed.data.result, nextAvailableAt: normalizeResetsAt(parsed.data.next_available_at), detail: parsed.data.result };
174
- }
175
-
176
- function record(accountUuid: string, rec: BankedResetRecord, reset: boolean, now: number): void {
177
- const idx = loadAccounts();
178
- const account = idx.accounts.find((a) => a.accountUuid === accountUuid);
179
- if (!account) return;
180
- account.bankedReset = rec;
181
- if (reset) {
182
- delete account.enforcedUntil;
183
- if (account.lastUsage) {
184
- account.lastUsage = { ...account.lastUsage, fiveHour: { usedPercentage: 0, resetsAt: null } };
185
- }
186
- }
187
- saveAccounts(idx);
188
- if (!reset) return;
189
- const u = loadUsage();
190
- if (u && u.account === accountUuid) {
191
- writeUsage({ ...u, fiveHour: { usedPercentage: 0, resetsAt: null }, ts: now }, { stamp: true });
192
- }
193
- saveLastResetAt(now);
194
- clearDepletedWait();
195
- clearNextCheck();
196
- }
197
-
198
- export async function claimBankedReset(input: { account: Account }): Promise<Claim> {
199
- const { account } = input;
200
- const short = account.accountUuid.slice(0, 8);
201
- let result: PostResult;
202
- let moved = false;
203
- try {
204
- const userAgent = claudeUserAgent();
205
- ({ result, moved } = await withClaudeRefreshLock(async (lock) => {
206
- const token = await liveTokenLocked(account, lock);
207
- if (!token.ok) return { result: { outcome: token.outcome, nextAvailableAt: null, detail: token.detail }, moved: false };
208
- let posted = await postClaim({ accessToken: token.accessToken, organizationUuid: token.organizationUuid, userAgent });
209
- if (posted.ambiguous) {
210
- const again = await postClaim({ accessToken: token.accessToken, organizationUuid: token.organizationUuid, userAgent });
211
- posted =
212
- again.outcome === "already_used"
213
- ? { outcome: "reset", nextAvailableAt: again.nextAvailableAt, detail: "already_used on the confirming claim, so the ambiguous claim was the reset", ambiguous: true }
214
- : again;
215
- }
216
- return { result: posted, moved: liveAccessToken(await readItem(liveTarget())) !== token.accessToken };
217
- }));
218
- } catch (e) {
219
- result = { outcome: "error", nextAvailableAt: null, detail: `claim aborted: ${e instanceof Error ? e.message : String(e)}` };
220
- }
221
- const outcome: BankedResetOutcome = result.outcome;
222
- const reset = outcome === "reset";
223
- const completedAt = Date.now();
224
- record(account.accountUuid, { outcome, at: completedAt, nextAvailableAt: result.nextAvailableAt }, reset, completedAt);
225
- log(reset ? "bankedreset.claimed" : "bankedreset.refused", {
226
- account: short,
227
- outcome,
228
- nextAvailableAt: result.nextAvailableAt,
229
- moved,
230
- ambiguous: result.ambiguous === true ? true : undefined,
231
- detail: reset && result.ambiguous !== true ? undefined : result.detail.slice(0, 200),
232
- });
233
- return reset && !moved ? "reset" : "pass";
234
- }
@@ -1,122 +0,0 @@
1
- import { z } from "zod";
2
- import { codexIdentityOf, readLiveCodexAuth } from "./codexauth.ts";
3
- import { allWindows, barFor, liveUsed } from "./codexpick.ts";
4
- import { loadCodexAccounts, saveCodexAccounts } from "./codexstate.ts";
5
- import { http, safeErrorDetail } from "./http.ts";
6
- import { log } from "./log.ts";
7
- import type { CodexAccount, CodexAuthJson, CodexBankedResetOutcome, Thresholds } from "./types.ts";
8
-
9
- const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
10
- const CONSUME_URL =
11
- EnvOverrideSchema.parse(process.env.TOKENMAXXING_CODEX_RESET_URL) ?? "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits/consume";
12
- const CONSUME_TIMEOUT_MS = 30_000;
13
- const RETRY_KEY_TTL_MS = 10 * 60_000;
14
- const WALL_PERCENT = 100;
15
- const LIMIT_REACHED = "rate_limit_reached";
16
-
17
- const VerdictSchema = z.enum(["hold", "claim", "pass"]);
18
- export type CodexBankedVerdict = z.infer<typeof VerdictSchema>;
19
- const ClaimSchema = z.enum(["reset", "pass"]);
20
- export type CodexBankedClaim = z.infer<typeof ClaimSchema>;
21
-
22
- export function codexBankedResetVerdict(input: { account: CodexAccount; thresholds: Thresholds; now: number; pollTtlMs: number }): CodexBankedVerdict {
23
- const { account, thresholds, now, pollTtlMs } = input;
24
- const usage = account.lastUsage;
25
- if (!usage) return "pass";
26
- const credits = usage.resetCredits ?? null;
27
- if (credits == null || credits <= 0) return "pass";
28
- const rec = account.bankedReset;
29
- if (rec != null && rec.outcome !== "reset" && now - rec.at < pollTtlMs) return "pass";
30
- const sampledAt = account.lastUsageAt ?? null;
31
- const windows = allWindows(account);
32
- const over = windows.some((window) => liveUsed({ window, now, sampledAt }) >= barFor({ window, thresholds }));
33
- if (!over) return "pass";
34
- const atWall = usage.reachedType === LIMIT_REACHED || windows.some((window) => liveUsed({ window, now, sampledAt }) >= WALL_PERCENT);
35
- return atWall ? "claim" : "hold";
36
- }
37
-
38
- const ConsumeResponseSchema = z.looseObject({
39
- code: z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed"]),
40
- windows_reset: z.number().int().nullish(),
41
- });
42
-
43
- const ConsumeResultSchema = z.object({ outcome: z.enum(["reset", "nothing_to_reset", "no_credit", "already_redeemed", "auth_error", "error"]), detail: z.string() });
44
- type ConsumeResult = z.infer<typeof ConsumeResultSchema>;
45
-
46
- export async function consumeCodexBankedReset(input: { auth: CodexAuthJson; idempotencyKey: string }): Promise<ConsumeResult> {
47
- const { auth, idempotencyKey } = input;
48
- const identity = codexIdentityOf({ auth });
49
- let res: Response;
50
- let text: string;
51
- try {
52
- res = await http.post(CONSUME_URL, {
53
- headers: {
54
- Authorization: `Bearer ${auth.tokens.access_token}`,
55
- "ChatGPT-Account-Id": identity.accountId,
56
- "User-Agent": "codex-cli",
57
- "Content-Type": "application/json",
58
- },
59
- body: JSON.stringify({ redeem_request_id: idempotencyKey }),
60
- timeout: CONSUME_TIMEOUT_MS,
61
- });
62
- text = await res.text();
63
- } catch (e) {
64
- return { outcome: "error", detail: `reset endpoint unreachable: ${e instanceof Error ? e.message : String(e)}` };
65
- }
66
- if (!res.ok) {
67
- const detail = `HTTP ${res.status}: ${safeErrorDetail({ text })}`;
68
- if (res.status === 401 || res.status === 403) return { outcome: "auth_error", detail };
69
- return { outcome: "error", detail };
70
- }
71
- const parsed = ConsumeResponseSchema.safeParse((() => {
72
- try {
73
- return JSON.parse(text);
74
- } catch {
75
- return null;
76
- }
77
- })());
78
- if (!parsed.success) return { outcome: "error", detail: `reset endpoint returned an unrecognized body (${text.length} bytes, withheld)` };
79
- return { outcome: parsed.data.code, detail: `${parsed.data.code} (windows reset: ${parsed.data.windows_reset ?? "unknown"})` };
80
- }
81
-
82
- function rememberPendingKey(accountId: string, key: string, now: number): void {
83
- const index = loadCodexAccounts();
84
- const entry = index.accounts.find((a) => a.accountId === accountId);
85
- if (!entry) return;
86
- entry.bankedReset = { outcome: "error", at: now, key };
87
- saveCodexAccounts({ index });
88
- }
89
-
90
- export async function consumeLiveCodexBankedReset(input: { account: CodexAccount; now: number }): Promise<CodexBankedClaim> {
91
- const { account, now } = input;
92
- const short = account.accountId.slice(0, 8);
93
- const live = readLiveCodexAuth();
94
- let result: ConsumeResult;
95
- let idempotencyKey: string | null = null;
96
- if (!live) {
97
- result = { outcome: "auth_error", detail: "live auth.json vanished" };
98
- } else if (codexIdentityOf({ auth: live }).accountId !== account.accountId) {
99
- result = { outcome: "error", detail: "live codex credential belongs to another account" };
100
- } else {
101
- const prior = account.bankedReset;
102
- const retrying = prior != null && prior.outcome === "error" && prior.key != null && now - prior.at < RETRY_KEY_TTL_MS;
103
- idempotencyKey = retrying ? prior.key! : crypto.randomUUID();
104
- if (!retrying) rememberPendingKey(account.accountId, idempotencyKey, now);
105
- result = await consumeCodexBankedReset({ auth: live, idempotencyKey });
106
- if (result.outcome === "already_redeemed" && retrying) result = { outcome: "reset", detail: `${result.detail}; the retried key had already completed` };
107
- }
108
- const outcome: CodexBankedResetOutcome = result.outcome;
109
- const reset = outcome === "reset";
110
- const index = loadCodexAccounts();
111
- const entry = index.accounts.find((a) => a.accountId === account.accountId);
112
- if (entry) {
113
- entry.bankedReset = { outcome, at: now, ...(outcome === "error" && idempotencyKey != null ? { key: idempotencyKey } : {}) };
114
- if (reset) {
115
- delete entry.lastUsage;
116
- delete entry.lastUsageAt;
117
- }
118
- saveCodexAccounts({ index });
119
- }
120
- log(reset ? "codexreset.consumed" : "codexreset.refused", { account: short, outcome, detail: result.detail.slice(0, 200) });
121
- return reset ? "reset" : "pass";
122
- }