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 +1 -1
- package/package.json +1 -1
- package/src/entries/codexsupervisor.ts +19 -12
- package/src/entries/supervisor.ts +14 -10
- package/src/lib/codexoauth.ts +5 -4
- package/src/lib/http.ts +3 -2
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.
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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) ?
|
|
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
|
-
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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) {
|
package/src/lib/codexoauth.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 ?? ""] };
|