tokenmaxxing 1.25.0 → 1.27.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/README.md +1 -2
- package/package.json +1 -1
- package/src/cli/init.ts +0 -1
- package/src/cli/status.ts +13 -63
- 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/src/lib/sample.ts +7 -21
- package/src/lib/usage.ts +3 -71
- package/src/main.ts +6 -31
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/README.md
CHANGED
|
@@ -70,8 +70,7 @@ claude # use claude as always
|
|
|
70
70
|
| `tokenmaxxing switch --codex [sel]` | switch the codex pool (takes effect on the next codex start) |
|
|
71
71
|
| `tokenmaxxing ls` | list pooled accounts |
|
|
72
72
|
| `tokenmaxxing status` | accounts with 5h / weekly usage bars, active + exhausted-until-reset |
|
|
73
|
-
| `tokenmaxxing
|
|
74
|
-
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30; never pings) |
|
|
73
|
+
| `tokenmaxxing watch [seconds]` | live status: re-render every N seconds (default 120, floor 30) |
|
|
75
74
|
| `tokenmaxxing config` | effective config with sources; `get`/`set`/`unset` dotted keys, `tidy` prunes unknown keys |
|
|
76
75
|
| `tokenmaxxing doctor` | verify the supervisor + settings entries survived |
|
|
77
76
|
| `tokenmaxxing rename [--codex] <sel> <label>` / `rm [--codex] <sel>` | manage the pool (`--codex` targets the codex pool: one email can hold both a claude and a codex account) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tokenmaxxing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.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/init.ts
CHANGED
|
@@ -37,7 +37,6 @@ export function printUsage(): void {
|
|
|
37
37
|
console.log(` ${c.bold("how to use")} - ${c.cyan("xx")} is shorthand for ${c.cyan("tokenmaxxing")}:`);
|
|
38
38
|
console.log(` ${c.cyan("claude")} use claude as always; it switches accounts near quota automatically`);
|
|
39
39
|
console.log(` ${c.cyan("xx")} show the pool with usage bars (same as ${c.cyan("xx status")})`);
|
|
40
|
-
console.log(` ${c.cyan("xx status --ping")} ping every account (one tiny haiku request each) so all 5h timers start now, then sample fresh; ${c.cyan("--count N")} pings N random accounts instead`);
|
|
41
40
|
console.log(` ${c.cyan("xx add")} log in and pool another account`);
|
|
42
41
|
console.log(` ${c.cyan("xx switch")} hop to the best account right now (the automatic switching needs no command)`);
|
|
43
42
|
console.log(` ${c.cyan("xx help")} everything else`);
|
package/src/cli/status.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { chunk,
|
|
1
|
+
import { chunk, sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { loadAccounts, loadConfig, loadUsage, loadUsageSnapshot, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
4
4
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
@@ -32,9 +32,6 @@ const ClaudeStatusAccountSchema = z.object({
|
|
|
32
32
|
perModel: z.record(z.string(), UsageWindowSchema),
|
|
33
33
|
usageAt: z.number().nullable(),
|
|
34
34
|
sample: SampleReportSchema,
|
|
35
|
-
pingError: z.string().nullable(),
|
|
36
|
-
pingRejected: z.boolean(),
|
|
37
|
-
pinged: z.boolean(),
|
|
38
35
|
});
|
|
39
36
|
type ClaudeStatusAccount = z.infer<typeof ClaudeStatusAccountSchema>;
|
|
40
37
|
|
|
@@ -86,31 +83,14 @@ function currentCodexWindow(w: CodexWindow, now: number): CodexWindow {
|
|
|
86
83
|
return { ...currentWindow(w, !isSessionWindow({ window: w }), now), windowSeconds: w.windowSeconds };
|
|
87
84
|
}
|
|
88
85
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
const usable = accounts.filter((a) => a.needsReauth !== true);
|
|
92
|
-
return sampleSize(usable, Math.min(pingCount, usable.length));
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function progressNote(input: { ping: boolean; pingCount: number | undefined; picked: Account[]; total: number }): string {
|
|
96
|
-
const { ping, pingCount, picked, total } = input;
|
|
97
|
-
if (!ping) return "sampling live usage...";
|
|
98
|
-
if (pingCount == null) return "pinging every account (starts each 5h session timer) + sampling live usage...";
|
|
99
|
-
if (picked.length === 0) return "no account to ping (every account needs reauth), sampling live usage...";
|
|
100
|
-
return `pinging ${picked.length} of ${count({ n: total, noun: "account" })} (${picked.map((a) => a.label || a.email).join(", ")}) so their 5h session timers start now + sampling live usage...`;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: number | undefined; now: number }): Promise<StatusReport["claude"]> {
|
|
104
|
-
const { cfg, ping, pingCount, now } = input;
|
|
86
|
+
async function collectClaude(input: { cfg: Config; now: number }): Promise<StatusReport["claude"]> {
|
|
87
|
+
const { cfg, now } = input;
|
|
105
88
|
let idx = loadAccounts();
|
|
106
89
|
const samples = new Map<string, { outcome: SampleOutcome; viaTee: boolean }>();
|
|
107
|
-
let pings = new Set<string>();
|
|
108
90
|
if (idx.accounts.length > 0) {
|
|
109
91
|
await withLock(paths.lockFile, async () => {
|
|
110
92
|
idx = loadAccounts();
|
|
111
|
-
|
|
112
|
-
pings = new Set(picked.map((a) => a.accountUuid));
|
|
113
|
-
console.error(c.dim(progressNote({ ping, pingCount, picked, total: idx.accounts.length })));
|
|
93
|
+
console.error(c.dim("sampling live usage..."));
|
|
114
94
|
const tee = loadUsageSnapshot();
|
|
115
95
|
const live = tee?.state ?? null;
|
|
116
96
|
const teeAt = tee?.at ?? null;
|
|
@@ -129,27 +109,12 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
129
109
|
perModel: modelUsage && modelUsage.account === a.accountUuid ? modelUsage.perModel : {},
|
|
130
110
|
}
|
|
131
111
|
: null;
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
outcome = { ok: true, usage: fromStatusLine };
|
|
139
|
-
if (failed.pingError != null) {
|
|
140
|
-
outcome.pingError = failed.pingError;
|
|
141
|
-
outcome.pingRejected = failed.pingRejected;
|
|
142
|
-
}
|
|
143
|
-
viaTee = true;
|
|
144
|
-
}
|
|
145
|
-
} else {
|
|
146
|
-
viaTee = fromStatusLine != null;
|
|
147
|
-
outcome = fromStatusLine
|
|
148
|
-
? { ok: true, usage: fromStatusLine }
|
|
149
|
-
: isActive
|
|
150
|
-
? await probeActiveUsage(a)
|
|
151
|
-
: await probeParkedUsage(a);
|
|
152
|
-
}
|
|
112
|
+
const viaTee = fromStatusLine != null;
|
|
113
|
+
const outcome: SampleOutcome = fromStatusLine
|
|
114
|
+
? { ok: true, usage: fromStatusLine }
|
|
115
|
+
: isActive
|
|
116
|
+
? await probeActiveUsage(a)
|
|
117
|
+
: await probeParkedUsage(a);
|
|
153
118
|
samples.set(a.accountUuid, { outcome, viaTee });
|
|
154
119
|
if (!outcome.ok) return;
|
|
155
120
|
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
@@ -192,9 +157,6 @@ async function collectClaude(input: { cfg: Config; ping: boolean; pingCount: num
|
|
|
192
157
|
perModel: Object.fromEntries(Object.entries(perModel).map(([name, w]) => [name, currentWindow(w, true, now)])),
|
|
193
158
|
usageAt: a.lastUsageAt ?? null,
|
|
194
159
|
sample: sampled.outcome.ok ? { ok: true, source: sampled.viaTee ? "statusline" : "probe" } : { ok: false, reason: sampled.outcome.reason },
|
|
195
|
-
pingError: sampled.outcome.pingError ?? null,
|
|
196
|
-
pingRejected: sampled.outcome.pingRejected === true,
|
|
197
|
-
pinged: pings.has(a.accountUuid) && sampled.outcome.ok && sampled.outcome.pingError == null && aggregate != null && aggregate.fiveHour.resetsAt == null,
|
|
198
160
|
};
|
|
199
161
|
});
|
|
200
162
|
return {
|
|
@@ -383,18 +345,6 @@ function claudeCard(a: ClaudeStatusAccount, now: number, staleAfterMs: number):
|
|
|
383
345
|
if (!a.sample.ok) {
|
|
384
346
|
notes.push(...sampleFailedNotes({ cached: a.usage != null || Object.keys(a.perModel).length > 0, usageAt: a.usageAt, reason: a.sample.reason, now }));
|
|
385
347
|
}
|
|
386
|
-
if (a.pingError != null && a.pingRejected) {
|
|
387
|
-
const at = a.pingError.indexOf(": ");
|
|
388
|
-
const head = at >= 0 ? a.pingError.slice(0, at) : a.pingError;
|
|
389
|
-
const tail = at >= 0 ? a.pingError.slice(at + 2) : "";
|
|
390
|
-
notes.push({ paint: c.yellow, text: `ping ${head}${tail ? ":" : ""}` });
|
|
391
|
-
if (tail) notes.push({ paint: c.dim, text: tail });
|
|
392
|
-
} else if (a.pingError != null) {
|
|
393
|
-
notes.push({ paint: c.yellow, text: "ping failed (5h timer may not have started):" }, { paint: c.dim, text: a.pingError });
|
|
394
|
-
}
|
|
395
|
-
if (a.pinged) {
|
|
396
|
-
notes.push({ paint: c.dim, text: "pinged - 5h timer started this run; the usage feed lags, re-run status shortly for the fresh window" });
|
|
397
|
-
}
|
|
398
348
|
return { lines, notes };
|
|
399
349
|
}
|
|
400
350
|
|
|
@@ -415,11 +365,11 @@ function renderClaude(input: { claude: StatusReport["claude"]; codexPooled: bool
|
|
|
415
365
|
renderGrid(claude.accounts.map((a) => claudeCard(a, now, staleAfterMs)));
|
|
416
366
|
}
|
|
417
367
|
|
|
418
|
-
export async function cmdStatus(opts: {
|
|
419
|
-
const {
|
|
368
|
+
export async function cmdStatus(opts: { json?: boolean; preRender?: () => void } = {}): Promise<number> {
|
|
369
|
+
const { json = false } = opts;
|
|
420
370
|
const cfg = loadConfig();
|
|
421
371
|
const now = Date.now();
|
|
422
|
-
const claude = await collectClaude({ cfg,
|
|
372
|
+
const claude = await collectClaude({ cfg, now });
|
|
423
373
|
if (!json) {
|
|
424
374
|
opts.preRender?.();
|
|
425
375
|
const at = Date.now();
|
|
@@ -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 ?? ""] };
|
package/src/lib/sample.ts
CHANGED
|
@@ -5,15 +5,15 @@ import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarg
|
|
|
5
5
|
import { credItemFor, paths } from "./paths.ts";
|
|
6
6
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
7
7
|
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
8
|
-
import { FullUsageSchema,
|
|
8
|
+
import { FullUsageSchema, probeUsage } from "./usage.ts";
|
|
9
9
|
import { loadAccounts } from "./state.ts";
|
|
10
10
|
import { keepRotatedPair } from "./swap.ts";
|
|
11
11
|
import { log } from "./log.ts";
|
|
12
12
|
import { CredentialBlobSchema, TokenIdentitySchema, type Account, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
13
13
|
|
|
14
14
|
const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
15
|
-
z.object({ ok: z.literal(true), usage: FullUsageSchema
|
|
16
|
-
z.object({ ok: z.literal(false), reason: z.string()
|
|
15
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
16
|
+
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
17
17
|
]);
|
|
18
18
|
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
19
19
|
|
|
@@ -46,7 +46,7 @@ function refreshPlanFields(account: Account, creds: OAuthCreds): void {
|
|
|
46
46
|
|
|
47
47
|
export async function probeParkedUsage(
|
|
48
48
|
account: Account,
|
|
49
|
-
opts: {
|
|
49
|
+
opts: { retries?: number; refreshParked?: boolean; killMs?: number; signal?: AbortSignal } = {},
|
|
50
50
|
): Promise<SampleOutcome> {
|
|
51
51
|
const backup = parkedTarget(account.keychainItem);
|
|
52
52
|
const parkedRaw = await readItem(backup);
|
|
@@ -165,16 +165,8 @@ export async function probeParkedUsage(
|
|
|
165
165
|
try {
|
|
166
166
|
await writeItem(isoTarget, installed);
|
|
167
167
|
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
168
|
-
const ping = opts.ping ? await pingSession(dir) : null;
|
|
169
168
|
const usage = await probeUsage(dir, Date.now(), opts);
|
|
170
|
-
|
|
171
|
-
? { ok: true, usage }
|
|
172
|
-
: { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
173
|
-
if (ping != null) {
|
|
174
|
-
outcome.pingError = ping.reason;
|
|
175
|
-
outcome.pingRejected = ping.rejected;
|
|
176
|
-
}
|
|
177
|
-
return outcome;
|
|
169
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
178
170
|
} finally {
|
|
179
171
|
try {
|
|
180
172
|
if (opts.refreshParked !== false) {
|
|
@@ -211,7 +203,7 @@ export async function ensureLiveTokenFresh(): Promise<void> {
|
|
|
211
203
|
});
|
|
212
204
|
}
|
|
213
205
|
|
|
214
|
-
export async function probeActiveUsage(account: Account
|
|
206
|
+
export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
|
|
215
207
|
try {
|
|
216
208
|
await ensureLiveTokenFresh();
|
|
217
209
|
} catch (e) {
|
|
@@ -232,12 +224,6 @@ export async function probeActiveUsage(account: Account, opts: { ping?: boolean
|
|
|
232
224
|
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
233
225
|
refreshPlanFields(account, creds);
|
|
234
226
|
|
|
235
|
-
const ping = opts.ping ? await pingSession() : null;
|
|
236
227
|
const usage = await probeUsage();
|
|
237
|
-
|
|
238
|
-
if (ping != null) {
|
|
239
|
-
outcome.pingError = ping.reason;
|
|
240
|
-
outcome.pingRejected = ping.rejected;
|
|
241
|
-
}
|
|
242
|
-
return outcome;
|
|
228
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
243
229
|
}
|
package/src/lib/usage.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
import { closeSync,
|
|
2
|
-
import { join } from "node:path";
|
|
1
|
+
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
3
2
|
import { delay } from "es-toolkit";
|
|
4
3
|
import { z } from "zod";
|
|
5
4
|
import { MAX_WRAP_DEPTH, WRAP_DEPTH_ENV, resolveRealClaude } from "./claudebin.ts";
|
|
6
5
|
import { log } from "./log.ts";
|
|
7
|
-
import { paths } from "./paths.ts";
|
|
8
6
|
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
9
7
|
|
|
10
8
|
export function normalizeResetsAt(v: unknown): number | null {
|
|
@@ -285,12 +283,11 @@ type SpawnResult = z.infer<typeof SpawnResultSchema>;
|
|
|
285
283
|
async function spawnClaudeBounded(
|
|
286
284
|
cmd: string[],
|
|
287
285
|
env: Record<string, string>,
|
|
288
|
-
cwd?: string,
|
|
289
286
|
killMs = PROBE_KILL_MS,
|
|
290
287
|
signal?: AbortSignal,
|
|
291
288
|
): Promise<SpawnResult | null> {
|
|
292
289
|
signal?.throwIfAborted();
|
|
293
|
-
const p = Bun.spawn(cmd, { env,
|
|
290
|
+
const p = Bun.spawn(cmd, { env, stdout: "pipe", stderr: "pipe" });
|
|
294
291
|
const abort = () => p.kill("SIGKILL");
|
|
295
292
|
signal?.addEventListener("abort", abort, { once: true });
|
|
296
293
|
const killer = setTimeout(() => p.kill("SIGKILL"), killMs);
|
|
@@ -313,7 +310,7 @@ async function spawnClaudeBounded(
|
|
|
313
310
|
async function probeUsageOnce(env: Record<string, string>, now: number, killMs: number, signal?: AbortSignal): Promise<FullUsage | null> {
|
|
314
311
|
let out: string;
|
|
315
312
|
try {
|
|
316
|
-
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env,
|
|
313
|
+
const r = await spawnClaudeBounded([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], env, killMs, signal);
|
|
317
314
|
if (r === null) {
|
|
318
315
|
log("usage.probe_failed", { err: "output pipes still open after child exit (leaked descendant)" });
|
|
319
316
|
return null;
|
|
@@ -364,68 +361,3 @@ export async function probeUsage(configDir?: string, now = Date.now(), opts: { r
|
|
|
364
361
|
await delay(PROBE_RETRY_DELAYS_MS[Math.min(attempt, PROBE_RETRY_DELAYS_MS.length - 1)]!);
|
|
365
362
|
}
|
|
366
363
|
}
|
|
367
|
-
|
|
368
|
-
const PING_ARGS = [
|
|
369
|
-
"-p", "Reply with exactly: ok",
|
|
370
|
-
"--model", "haiku",
|
|
371
|
-
"--settings", '{"disableAllHooks":true}',
|
|
372
|
-
"--output-format", "json",
|
|
373
|
-
];
|
|
374
|
-
|
|
375
|
-
const PingResultSchema = z.looseObject({ is_error: z.boolean().optional(), result: z.string().optional(), session_id: z.string().optional() });
|
|
376
|
-
|
|
377
|
-
export const PingFailureSchema = z.object({ reason: z.string(), rejected: z.boolean() });
|
|
378
|
-
export type PingFailure = z.infer<typeof PingFailureSchema>;
|
|
379
|
-
|
|
380
|
-
export function transcriptSlug(cwd: string): string {
|
|
381
|
-
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
export function pingTranscriptPath(input: { configDir: string; cwd: string; sessionId: string }): string {
|
|
385
|
-
return join(input.configDir, "projects", transcriptSlug(input.cwd), `${input.sessionId}.jsonl`);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
const LIMIT_LABELS: Record<string, string> = { five_hour: "5h", seven_day: "weekly" };
|
|
389
|
-
|
|
390
|
-
export function pingRejection(rows: TranscriptRow[]): string | null {
|
|
391
|
-
const row = rows.findLast((r) => r.isApiErrorMessage === true);
|
|
392
|
-
if (!row || row.error !== "rate_limit" || row.apiErrorIsTransient === true) return null;
|
|
393
|
-
const type = row.quotaLimits?.rateLimitType ?? "";
|
|
394
|
-
if (type === "" && parseErrorBody(row.errorDetails)?.error?.type !== "rate_limit_error") return null;
|
|
395
|
-
const label = LIMIT_LABELS[type] ?? (type !== "" ? type.replace(/_/g, " ") : "usage");
|
|
396
|
-
const text = transcriptRowText(row);
|
|
397
|
-
return `${label} limit${text ? `: ${text}` : ""}`;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
export async function pingSession(configDir?: string): Promise<PingFailure | null> {
|
|
401
|
-
const cwd = join(paths.sampleDir, "ping-cwd");
|
|
402
|
-
const fail = (reason: string, rejected = false): PingFailure => {
|
|
403
|
-
log("usage.ping_failed", { dir: configDir ?? "live", rejected, reason: reason.slice(0, 200) });
|
|
404
|
-
return { reason, rejected };
|
|
405
|
-
};
|
|
406
|
-
let r: SpawnResult | null;
|
|
407
|
-
try {
|
|
408
|
-
mkdirSync(cwd, { recursive: true });
|
|
409
|
-
r = await spawnClaudeBounded([resolveRealClaude(), ...PING_ARGS], probeEnv(configDir), cwd);
|
|
410
|
-
} catch (e) {
|
|
411
|
-
return fail(e instanceof Error ? e.message : String(e));
|
|
412
|
-
}
|
|
413
|
-
if (r === null) return fail("output pipes still open after child exit (leaked descendant)");
|
|
414
|
-
const parsed = PingResultSchema.safeParse((() => { try { return JSON.parse(r.stdout); } catch { return null; } })());
|
|
415
|
-
const result = parsed.success ? parsed.data : null;
|
|
416
|
-
if (r.exitCode === 0 && result?.is_error === false) {
|
|
417
|
-
log("usage.ping_ok", { dir: configDir ?? "live" });
|
|
418
|
-
return null;
|
|
419
|
-
}
|
|
420
|
-
if (result?.session_id) {
|
|
421
|
-
const transcript = pingTranscriptPath({ configDir: configDir ?? paths.claudeDir, cwd, sessionId: result.session_id });
|
|
422
|
-
const rejection = existsSync(transcript) ? pingRejection(readTranscriptTail(transcript)) : null;
|
|
423
|
-
if (rejection) return fail(`rejected at the ${rejection}`.slice(0, 200), true);
|
|
424
|
-
}
|
|
425
|
-
if (r.exitCode !== 0) {
|
|
426
|
-
const detail = result?.result?.trim() || r.stderr.trim() || r.stdout.trim();
|
|
427
|
-
return fail(`claude exited ${r.exitCode ?? "on signal"}: ${detail.slice(0, 160)}`);
|
|
428
|
-
}
|
|
429
|
-
if (result?.is_error === true) return fail((result.result?.trim() || "request errored").slice(0, 160));
|
|
430
|
-
return fail(`unrecognized ping output: ${r.stdout.trim().slice(0, 120)}`);
|
|
431
|
-
}
|
package/src/main.ts
CHANGED
|
@@ -45,8 +45,7 @@ function printHelp(): void {
|
|
|
45
45
|
${c.cyan("tokenmaxxing switch --codex")} [sel] switch the codex pool (takes effect on next codex start)
|
|
46
46
|
${c.cyan("tokenmaxxing ls")} list pooled accounts
|
|
47
47
|
${c.cyan("tokenmaxxing status")} accounts with 5h / weekly / per-model usage bars
|
|
48
|
-
${c.cyan("tokenmaxxing
|
|
49
|
-
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120, never pings)
|
|
48
|
+
${c.cyan("tokenmaxxing watch")} [seconds] live status: re-render every N seconds (default 120)
|
|
50
49
|
${c.cyan("tokenmaxxing config")} [get|set|unset|tidy] inspect and edit config.json (bare = effective config with sources)
|
|
51
50
|
${c.cyan("tokenmaxxing doctor")} verify the install is intact
|
|
52
51
|
${c.cyan("tokenmaxxing rename")} [--codex] <sel> <label>
|
|
@@ -60,28 +59,6 @@ function printHelp(): void {
|
|
|
60
59
|
|
|
61
60
|
let jsonMode = false;
|
|
62
61
|
|
|
63
|
-
function statusFlags(rest: string[]): { ping: boolean; pingCount: number | undefined } | { error: string } {
|
|
64
|
-
let ping = false;
|
|
65
|
-
let pingCount: number | undefined;
|
|
66
|
-
for (let i = 0; i < rest.length; i++) {
|
|
67
|
-
const arg = rest[i]!;
|
|
68
|
-
if (arg === "--ping") {
|
|
69
|
-
ping = true;
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (arg === "--count") {
|
|
73
|
-
if (pingCount != null) return { error: "--count may only be given once" };
|
|
74
|
-
const raw = rest[++i];
|
|
75
|
-
pingCount = Number(raw);
|
|
76
|
-
if (!Number.isInteger(pingCount) || pingCount < 1) return { error: `--count needs a positive whole number of accounts, got: ${raw ?? "nothing"}` };
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
return { error: `unknown status option: ${arg} (status takes --ping and --count N)` };
|
|
80
|
-
}
|
|
81
|
-
if (pingCount != null && !ping) return { error: "--count only applies together with --ping" };
|
|
82
|
-
return { ping, pingCount };
|
|
83
|
-
}
|
|
84
|
-
|
|
85
62
|
async function main(): Promise<number> {
|
|
86
63
|
if (process.platform !== "darwin" && process.platform !== "linux") {
|
|
87
64
|
console.error(`tokenmaxxing supports macOS and Linux only (this is ${process.platform})`);
|
|
@@ -126,15 +103,13 @@ async function main(): Promise<number> {
|
|
|
126
103
|
case "__session-start": return runSessionStart();
|
|
127
104
|
case "__codex-stop-hook": return runCodexStopHook();
|
|
128
105
|
case undefined:
|
|
129
|
-
case "status":
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
if ("error" in flags) {
|
|
134
|
-
emitError({ json, message: flags.error });
|
|
106
|
+
case "status": {
|
|
107
|
+
const extra = args[1];
|
|
108
|
+
if (extra != null) {
|
|
109
|
+
emitError({ json, message: `unknown status option: ${extra} (status takes no options)` });
|
|
135
110
|
return 2;
|
|
136
111
|
}
|
|
137
|
-
return cmdStatus({
|
|
112
|
+
return cmdStatus({ json });
|
|
138
113
|
}
|
|
139
114
|
case "switch": {
|
|
140
115
|
const rest = args.slice(1).filter((a) => a !== "--codex");
|