tokenmaxxing 1.25.0 → 1.26.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
@@ -54,7 +54,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
54
54
  3. Done - the running session adopts the new credential on its own within a request or two. Only when the pool is depleted (the decision returned a `waitUntil`: pre-parked on the soonest-recovering account, or staying on the current one when it recovers first) does the hook write `respawn/<session_id>` (atomic temp+rename).
55
55
 
56
56
  ### 3.3 Depleted-pool pause (supervisor)
57
- The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop.
57
+ The supervisor sees `respawn/<sid>`, SIGTERMs its child at the already-committed turn boundary, deletes the marker, resets the terminal, shows an interruptible countdown to the reset, and relaunches `claude --resume <sid>` when it passes. The resumed process reads the keychain cold → runs on the recovered account, same conversation. The `SessionStart` hook (source `resume`) re-checks the account before the first turn as a backstop. A marker that fails to parse is never deleted and treated as absent. The supervisor stops the child, restores the terminal, and exits naming the marker path.
58
58
 
59
59
  ### 3.4 Swap sequence (under the lock)
60
60
  1. **Harvest the live credential into its TRUE owner's backup** - read the current `Claude Code-credentials` blob and resolve which account it actually belongs to via the profile endpoint (`GET /api/oauth/profile`, matched on `account.uuid`), NOT the `accounts.json` active label and NOT the organization (team seats share one). The label drifts from the live blob (a kill mid-swap, a manual `/login`), and harvesting by label once overwrote another account's backup and destroyed its only credential. Mandatory anyway: Claude rotates the refresh token in place, so older backups are dead. Refuse the swap if the live credential belongs to no pooled account.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.25.0",
3
+ "version": "1.26.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",
@@ -21,13 +21,14 @@ const NONINTERACTIVE_SUBCMDS = new Set([
21
21
 
22
22
  const PASSTHROUGH_FLAGS = new Set(["--version", "-V", "--help", "-h"]);
23
23
 
24
- function consumableCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> | null {
24
+ function readCodexMarker(marker: string): z.infer<typeof CodexRespawnMarkerSchema> {
25
25
  try {
26
26
  return CodexRespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
27
27
  } catch (e) {
28
- rmSync(marker, { force: true });
29
28
  log("codexsupervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
30
- return null;
29
+ throw new Error(
30
+ `${marker} is corrupt (unparsable JSON or off-schema) - auth.json may already hold another account, so the codex session was stopped instead of resumed on stale credentials; inspect and remove the marker, then run \`codex resume\``,
31
+ );
31
32
  }
32
33
  }
33
34
 
@@ -126,7 +127,10 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
126
127
  let done = false;
127
128
  const markerWatch = (async () => {
128
129
  while (!done) {
129
- if (existsSync(marker) && consumableCodexMarker(marker) != null) return true;
130
+ if (existsSync(marker)) {
131
+ readCodexMarker(marker);
132
+ return true;
133
+ }
130
134
  await Bun.sleep(150);
131
135
  }
132
136
  return false;
@@ -135,17 +139,20 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
135
139
  done = true;
136
140
  return "exit";
137
141
  });
138
- const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
139
-
140
- if (winner === "marker") {
142
+ try {
143
+ const winner = await Promise.race([exited, markerWatch.then((found) => (found ? "marker" : "exit"))]);
144
+ if (winner === "marker") child.kill();
145
+ } catch (e) {
141
146
  child.kill();
147
+ throw e;
148
+ } finally {
149
+ await child.exited;
150
+ done = true;
151
+ await markerWatch.catch(() => false);
152
+ restoreTermios(savedTermios);
142
153
  }
143
- await child.exited;
144
- done = true;
145
- await markerWatch.catch(() => false);
146
- restoreTermios(savedTermios);
147
154
 
148
- const payload = existsSync(marker) ? consumableCodexMarker(marker) : null;
155
+ const payload = existsSync(marker) ? readCodexMarker(marker) : null;
149
156
  if (payload) {
150
157
  rmSync(marker, { force: true });
151
158
  respawns++;
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, rmSync, readdirSync, statSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { basename, join } from "node:path";
3
3
  import { maxBy } from "es-toolkit";
4
4
  import { z } from "zod";
5
5
  import { paths } from "../lib/paths.ts";
@@ -152,9 +152,10 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
152
152
  try {
153
153
  m = RespawnMarkerSchema.parse(JSON.parse(readFileSync(marker, "utf8")));
154
154
  } catch (e) {
155
- rmSync(marker, { force: true });
156
155
  log("supervisor.marker_invalid", { err: e instanceof Error ? e.message : String(e) });
157
- return null;
156
+ throw new Error(
157
+ `${marker} is corrupt (unparsable JSON or off-schema) - the session was stopped instead of guessing whether the pool is depleted; inspect the marker, then run \`claude --resume ${basename(marker)}\` (a fresh launch clears it)`,
158
+ );
158
159
  }
159
160
  if (m.launchedAt !== undefined && m.launchedAt !== gate.launchedAt) {
160
161
  rmSync(marker, { force: true });
@@ -270,15 +271,18 @@ export async function runSupervisor(argv: string[]): Promise<number> {
270
271
  return false;
271
272
  })();
272
273
  const exited = child.exited.then(() => { done = true; return "exit" as const; });
273
- const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
274
-
275
- if (winner === "marker") {
274
+ try {
275
+ const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
276
+ if (winner === "marker") child.kill();
277
+ } catch (e) {
276
278
  child.kill();
279
+ throw e;
280
+ } finally {
281
+ await child.exited;
282
+ done = true;
283
+ await markerWatch.catch(() => {});
284
+ restoreTermios(savedTermios);
277
285
  }
278
- await child.exited;
279
- done = true;
280
- await markerWatch.catch(() => {});
281
- restoreTermios(savedTermios);
282
286
 
283
287
  const m = existsSync(marker) ? consumableMarker(marker, gate) : null;
284
288
  if (m) {
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { http, safeErrorDetail } from "./http.ts";
2
+ import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
3
3
  import { CodexAuthJsonSchema, type CodexAuthJson } from "./types.ts";
4
4
 
5
5
  const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
@@ -26,12 +26,12 @@ const CodexRefreshResponseSchema = z.looseObject({
26
26
  refresh_token: z.string().optional(),
27
27
  });
28
28
 
29
- const DEAD_GRANT_MARKERS = [
29
+ const DEAD_GRANT_CODES = new Set([
30
30
  "invalid_grant",
31
31
  "refresh_token_reused",
32
32
  "refresh_token_expired",
33
33
  "refresh_token_invalidated",
34
- ];
34
+ ]);
35
35
 
36
36
  export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: number }): Promise<CodexAuthJson> {
37
37
  const { auth, now = Date.now() } = input;
@@ -53,7 +53,8 @@ export async function refreshCodexAuth(input: { auth: CodexAuthJson; now?: numbe
53
53
  const text = await res.text();
54
54
  if (!res.ok) {
55
55
  const detail = safeErrorDetail({ text });
56
- if (DEAD_GRANT_MARKERS.some((marker) => text.includes(marker))) {
56
+ const code = oauthErrorCode({ text })?.toLowerCase();
57
+ if (code != null && DEAD_GRANT_CODES.has(code)) {
57
58
  throw new CodexInvalidGrantError(detail);
58
59
  }
59
60
  throw new CodexRefreshFailedError(`HTTP ${res.status}: ${detail}`);
package/src/lib/http.ts CHANGED
@@ -7,7 +7,7 @@ const OAuthErrorBodySchema = z.looseObject({
7
7
  });
8
8
 
9
9
  const NestedErrorBodySchema = z.looseObject({
10
- error: z.looseObject({ type: z.string().optional(), message: z.string().optional() }),
10
+ error: z.looseObject({ code: z.string().nullish(), type: z.string().optional(), message: z.string().optional() }),
11
11
  request_id: z.string().nullish(),
12
12
  });
13
13
 
@@ -31,7 +31,8 @@ function parseErrorBody(text: string): ErrorDetail | null {
31
31
  const nested = NestedErrorBodySchema.safeParse(json);
32
32
  if (nested.success) {
33
33
  const requestId = nested.data.request_id ? ` (${nested.data.request_id})` : "";
34
- return { code: nested.data.error.type ?? null, fields: [nested.data.error.type ?? "", (nested.data.error.message ?? "") + requestId] };
34
+ const code = nested.data.error.code ?? nested.data.error.type ?? null;
35
+ return { code, fields: [code ?? "", (nested.data.error.message ?? "") + requestId] };
35
36
  }
36
37
  const plain = PlainErrorBodySchema.safeParse(json);
37
38
  if (plain.success) return { code: null, fields: [plain.data.detail ?? "", plain.data.message ?? ""] };