tokenmaxxing 0.2.0 → 0.2.1
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 +2 -2
- package/README.md +4 -6
- package/package.json +5 -10
- package/src/cli/add.ts +129 -0
- package/src/cli/doctor.ts +80 -0
- package/src/cli/init.ts +124 -0
- package/src/cli/ls.ts +24 -0
- package/src/cli/rename.ts +33 -0
- package/src/cli/render.ts +36 -0
- package/src/cli/rm.ts +28 -0
- package/src/cli/status.ts +99 -0
- package/src/cli/switch.ts +61 -0
- package/src/entries/sessionstart.ts +39 -0
- package/src/entries/statusline.ts +52 -0
- package/src/entries/stophook.ts +48 -0
- package/src/entries/supervisor.ts +202 -0
- package/src/lib/atomic.ts +24 -0
- package/src/lib/claudebin.ts +21 -0
- package/src/lib/claudejson.ts +40 -0
- package/src/lib/claudelock.ts +54 -0
- package/src/lib/credstore.ts +97 -0
- package/src/lib/decide.ts +149 -0
- package/src/lib/http.ts +19 -0
- package/src/lib/install.ts +87 -0
- package/src/lib/keychain.ts +84 -0
- package/src/lib/lock.ts +78 -0
- package/src/lib/log.ts +28 -0
- package/src/lib/oauth.ts +117 -0
- package/src/lib/paths.ts +90 -0
- package/src/lib/picker.ts +63 -0
- package/src/lib/sample.ts +144 -0
- package/src/lib/sessions.ts +27 -0
- package/src/lib/settings.ts +141 -0
- package/src/lib/state.ts +125 -0
- package/src/lib/swap.ts +143 -0
- package/src/lib/tty.ts +14 -0
- package/src/lib/types.ts +152 -0
- package/src/lib/usage.ts +216 -0
- package/src/main.ts +82 -0
- package/dist/tokenmaxxing +0 -0
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// `tokenmaxxing switch [selector]` (also the bare `tokenmaxxing` / `xx`).
|
|
2
|
+
// No selector → auto-pick the best available account. With a selector → switch to
|
|
3
|
+
// that one. Manual/recovery tool: no threshold gate, runs under the flock. When
|
|
4
|
+
// everything is depleted it still switches to the soonest-resetting account.
|
|
5
|
+
|
|
6
|
+
import { withLock } from "../lib/lock.ts";
|
|
7
|
+
import { paths } from "../lib/paths.ts";
|
|
8
|
+
import { loadAccounts, loadConfig } from "../lib/state.ts";
|
|
9
|
+
import { performSwap, chooseAndSwap } from "../lib/swap.ts";
|
|
10
|
+
import { pickEarliestReset } from "../lib/picker.ts";
|
|
11
|
+
import { InvalidGrantError } from "../lib/oauth.ts";
|
|
12
|
+
import { findAccount } from "./rename.ts";
|
|
13
|
+
import { c, fmtReset } from "./render.ts";
|
|
14
|
+
|
|
15
|
+
export async function cmdSwitch(selector?: string): Promise<number> {
|
|
16
|
+
const idx0 = loadAccounts();
|
|
17
|
+
if (idx0.accounts.length < 2) {
|
|
18
|
+
console.error(c.yellow("need at least 2 accounts to switch - add one with `tokenmaxxing add`"));
|
|
19
|
+
return 1;
|
|
20
|
+
}
|
|
21
|
+
const cfg = loadConfig();
|
|
22
|
+
const now = Date.now();
|
|
23
|
+
|
|
24
|
+
return withLock(paths.lockFile, async () => {
|
|
25
|
+
const idx = loadAccounts();
|
|
26
|
+
|
|
27
|
+
if (selector) {
|
|
28
|
+
const target = findAccount(idx.accounts, selector);
|
|
29
|
+
if (!target) { console.error(c.red(`no account matches "${selector}"`)); return 1; }
|
|
30
|
+
if (target.accountUuid === idx.activeAccountUuid) { console.log(`already on ${c.bold(target.label)}`); return 0; }
|
|
31
|
+
if (target.needsReauth) { console.error(c.red(`${target.label} needs re-auth - \`tokenmaxxing add\``)); return 1; }
|
|
32
|
+
try {
|
|
33
|
+
await performSwap(target);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
if (e instanceof InvalidGrantError) { console.error(c.red(`${target.label}'s refresh token is dead - re-add it`)); return 1; }
|
|
36
|
+
throw e;
|
|
37
|
+
}
|
|
38
|
+
console.log(`${c.green("↻")} switched to ${c.bold(target.label)}`);
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// auto: best account under threshold
|
|
43
|
+
const landed = await chooseAndSwap({ now, threshold: cfg.threshold });
|
|
44
|
+
if (landed) {
|
|
45
|
+
console.log(`${c.green("↻")} switched to ${c.bold(landed.label)}`);
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// everything is depleted → switch to whichever recovers soonest
|
|
50
|
+
const earliest = pickEarliestReset(idx.accounts, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid });
|
|
51
|
+
if (!earliest) { console.error(c.yellow("no switchable account (all need re-auth?)")); return 1; }
|
|
52
|
+
try {
|
|
53
|
+
await performSwap(earliest.account);
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (e instanceof InvalidGrantError) { console.error(c.red(`${earliest.account.label}'s refresh token is dead`)); return 1; }
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
console.log(`${c.yellow("↻")} all accounts at limit - switched to ${c.bold(earliest.account.label)} (${fmtReset(earliest.availableAt, now)})`);
|
|
59
|
+
return 0;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SessionStart hook. A launch/resume backstop: if the active account is already
|
|
2
|
+
// over threshold with FRESH usage (e.g. a prior session left it exhausted), swap
|
|
3
|
+
// the credential before this session's first turn so it starts on a good account.
|
|
4
|
+
// Right after a respawn, usage.json is stale for the new org, so the org guard in
|
|
5
|
+
// evaluateAndMaybeSwap makes this correctly no-op.
|
|
6
|
+
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
9
|
+
import { log } from "../lib/log.ts";
|
|
10
|
+
|
|
11
|
+
const SessionStartStdin = z.looseObject({
|
|
12
|
+
source: z.string().optional(), // startup | resume | clear | compact
|
|
13
|
+
session_id: z.string().optional(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
async function readStdin(): Promise<string> {
|
|
17
|
+
const chunks: Uint8Array[] = [];
|
|
18
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
19
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runSessionStart(): Promise<number> {
|
|
23
|
+
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
24
|
+
|
|
25
|
+
const raw = await readStdin();
|
|
26
|
+
const parsed = SessionStartStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
27
|
+
const source = parsed.success ? parsed.data.source : undefined;
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const decision = await evaluateAndMaybeSwap();
|
|
31
|
+
if (decision.swapped && decision.account) {
|
|
32
|
+
// fresh session → it will read the new credential on its first API call.
|
|
33
|
+
log("sessionstart.swapped", { source, account: decision.account.accountUuid.slice(0, 8) });
|
|
34
|
+
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
log("sessionstart.error", { err: String((e as Error).message ?? e) });
|
|
37
|
+
}
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// statusLine shim. Reads Claude's statusLine stdin, tees the rate-limit data to
|
|
2
|
+
// usage.json (write-on-change, O(ms)), then transparently delegates to the user's
|
|
3
|
+
// prior statusLine command (same stdin) and passes its stdout through unchanged.
|
|
4
|
+
// Must NEVER break the user's status line: every step is best-effort.
|
|
5
|
+
|
|
6
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
7
|
+
import { readPriorStatusLine } from "../lib/settings.ts";
|
|
8
|
+
import { writeUsage } from "../lib/state.ts";
|
|
9
|
+
import { parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
10
|
+
import type { UsageState } from "../lib/types.ts";
|
|
11
|
+
|
|
12
|
+
async function readStdin(): Promise<string> {
|
|
13
|
+
const chunks: Uint8Array[] = [];
|
|
14
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
15
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function runStatusline(): Promise<number> {
|
|
19
|
+
const raw = await readStdin();
|
|
20
|
+
|
|
21
|
+
// 1) tee usage - best effort, never throws out.
|
|
22
|
+
try {
|
|
23
|
+
const obj = JSON.parse(raw);
|
|
24
|
+
const windows = parseStatusLineStdin(obj);
|
|
25
|
+
if (windows) {
|
|
26
|
+
const org = readOAuthAccount()?.organizationUuid ?? null;
|
|
27
|
+
const state: UsageState = { ...windows, org, ts: Date.now(), model: parseStatusLineModel(obj) };
|
|
28
|
+
writeUsage(state);
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// malformed stdin - skip usage, still delegate below
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2) delegate to the prior statusLine, feeding it the same stdin.
|
|
35
|
+
const prior = readPriorStatusLine();
|
|
36
|
+
if (prior) {
|
|
37
|
+
try {
|
|
38
|
+
const p = Bun.spawn(["/bin/sh", "-c", prior], {
|
|
39
|
+
stdin: new TextEncoder().encode(raw),
|
|
40
|
+
stdout: "pipe",
|
|
41
|
+
stderr: "inherit",
|
|
42
|
+
});
|
|
43
|
+
const out = await new Response(p.stdout).text();
|
|
44
|
+
await p.exited;
|
|
45
|
+
if (out) process.stdout.write(out);
|
|
46
|
+
return p.exitCode ?? 0;
|
|
47
|
+
} catch {
|
|
48
|
+
// fall through to no-op
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return 0;
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Stop hook. Fires when claude finishes a turn (transcript already committed).
|
|
2
|
+
// If the active account crossed the threshold, swap the credential and - when
|
|
3
|
+
// running under the supervisor - drop a respawn marker keyed by this session id.
|
|
4
|
+
// The supervisor watches for the marker and SIGTERMs its child at this clean
|
|
5
|
+
// boundary, then relaunches `--resume`. We never kill claude ourselves.
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { paths } from "../lib/paths.ts";
|
|
10
|
+
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
11
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
12
|
+
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
13
|
+
import { log } from "../lib/log.ts";
|
|
14
|
+
|
|
15
|
+
const StopStdin = z.looseObject({ session_id: z.string().optional() });
|
|
16
|
+
|
|
17
|
+
async function readStdin(): Promise<string> {
|
|
18
|
+
const chunks: Uint8Array[] = [];
|
|
19
|
+
for await (const c of Bun.stdin.stream()) chunks.push(c);
|
|
20
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runStopHook(): Promise<number> {
|
|
24
|
+
// recursion guard - our own `-p '/usage'` probe re-enters hooks.
|
|
25
|
+
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
26
|
+
|
|
27
|
+
const raw = await readStdin();
|
|
28
|
+
const parsed = StopStdin.safeParse((() => { try { return JSON.parse(raw); } catch { return {}; } })());
|
|
29
|
+
const sessionId =
|
|
30
|
+
(parsed.success ? parsed.data.session_id : undefined) ?? process.env.TOKENMAXXING_SESSION_ID;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const decision = await evaluateAndMaybeSwap();
|
|
34
|
+
// Respawn on a swap, or on a depleted-pool wait (relaunch after the reset).
|
|
35
|
+
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
36
|
+
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.accountUuid.slice(0, 8), waitUntil: decision.waitUntil });
|
|
37
|
+
if (process.env.TOKENMAXXING_SUPERVISED === "1" && sessionId) {
|
|
38
|
+
const marker = join(paths.respawnDir, sessionId);
|
|
39
|
+
const payload = RespawnMarkerSchema.parse({ account: decision.account.label, ts: Date.now(), waitUntil: decision.waitUntil });
|
|
40
|
+
writeFileAtomic(marker, JSON.stringify(payload));
|
|
41
|
+
log("stop.marker", { session: sessionId.slice(0, 8) });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
log("stop.error", { err: String((e as Error).message ?? e) });
|
|
46
|
+
}
|
|
47
|
+
return 0; // never block the stop
|
|
48
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// The `claude` supervisor. Invoked in place of claude (via ~/.config/tokenmaxxing/
|
|
2
|
+
// bin/claude on PATH). Runs the REAL claude with inherited stdio (claude owns the
|
|
3
|
+
// real terminal exactly as if run directly), pins a session id, and watches for a
|
|
4
|
+
// respawn marker dropped by the Stop/SessionStart hook. When the marker appears it
|
|
5
|
+
// SIGTERMs its own child at the (already-committed) turn boundary and relaunches
|
|
6
|
+
// `claude --resume <id>` on the freshly-swapped account. Process/terminal manager
|
|
7
|
+
// only - it never reads or proxies tokens.
|
|
8
|
+
|
|
9
|
+
import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { maxBy } from "es-toolkit";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { paths } from "../lib/paths.ts";
|
|
14
|
+
import { resolveRealClaude } from "../lib/claudebin.ts";
|
|
15
|
+
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
16
|
+
import { loadSessionFlags, saveSessionFlags } from "../lib/sessions.ts";
|
|
17
|
+
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
18
|
+
import { log } from "../lib/log.ts";
|
|
19
|
+
|
|
20
|
+
const NONINTERACTIVE_SUBCMDS = new Set([
|
|
21
|
+
"mcp", "config", "doctor", "update", "install", "migrate-installer",
|
|
22
|
+
"setup-token", "plugin", "agents", "completion", "help",
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
const isUuid = (s: string) => z.uuid().safeParse(s).success;
|
|
26
|
+
|
|
27
|
+
const AnalysisSchema = z.object({
|
|
28
|
+
manage: z.boolean(),
|
|
29
|
+
sessionId: z.string().nullable(),
|
|
30
|
+
resumeId: z.string().nullable(),
|
|
31
|
+
continueLatest: z.boolean(),
|
|
32
|
+
});
|
|
33
|
+
type Analysis = z.infer<typeof AnalysisSchema>;
|
|
34
|
+
|
|
35
|
+
export function analyzeArgs(argv: string[]): Analysis {
|
|
36
|
+
let sessionId: string | null = null;
|
|
37
|
+
let resumeId: string | null = null;
|
|
38
|
+
let continueLatest = false;
|
|
39
|
+
let printMode = false;
|
|
40
|
+
let firstPositional: string | null = null;
|
|
41
|
+
|
|
42
|
+
for (let i = 0; i < argv.length; i++) {
|
|
43
|
+
const a = argv[i]!;
|
|
44
|
+
if (a === "-p" || a === "--print") printMode = true;
|
|
45
|
+
else if (a === "--version" || a === "-v" || a === "--help" || a === "-h") printMode = true;
|
|
46
|
+
else if (a === "--session-id") sessionId = argv[++i] ?? null;
|
|
47
|
+
else if (a === "-c" || a === "--continue") continueLatest = true;
|
|
48
|
+
else if (a === "-r" || a === "--resume") {
|
|
49
|
+
const next = argv[i + 1];
|
|
50
|
+
if (next && !next.startsWith("-") && isUuid(next)) { resumeId = next; i++; }
|
|
51
|
+
} else if (!a.startsWith("-") && firstPositional === null) {
|
|
52
|
+
firstPositional = a;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
|
|
57
|
+
const manage = !printMode && !isSubcmd && !process.env.TOKENMAXXING_PROBE;
|
|
58
|
+
return { manage, sessionId, resumeId, continueLatest };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Remove session-selecting flags so we can inject our own on respawn. */
|
|
62
|
+
export function stripSessionFlags(argv: string[]): string[] {
|
|
63
|
+
const out: string[] = [];
|
|
64
|
+
for (let i = 0; i < argv.length; i++) {
|
|
65
|
+
const a = argv[i]!;
|
|
66
|
+
if (a === "--session-id") { i++; continue; }
|
|
67
|
+
if (a === "-c" || a === "--continue") continue;
|
|
68
|
+
if (a === "-r" || a === "--resume") {
|
|
69
|
+
const next = argv[i + 1];
|
|
70
|
+
if (next && !next.startsWith("-") && isUuid(next)) i++;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
out.push(a);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Newest transcript session id for the current cwd (for `-c`/`-r`-without-id). */
|
|
79
|
+
function latestSessionForCwd(): string | null {
|
|
80
|
+
const slug = process.cwd().replace(/[/.]/g, "-");
|
|
81
|
+
const projDir = join(paths.claudeDir, "projects", slug);
|
|
82
|
+
if (!existsSync(projDir)) return null;
|
|
83
|
+
try {
|
|
84
|
+
const files = readdirSync(projDir)
|
|
85
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
86
|
+
.map((f) => ({ f, m: statSync(join(projDir, f)).mtimeMs }));
|
|
87
|
+
const newest = maxBy(files, (x) => x.m);
|
|
88
|
+
return newest ? newest.f.replace(/\.jsonl$/, "") : null;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Interruptible countdown until `until`, shown in the terminal (claude is dead,
|
|
95
|
+
* so the statusLine can't render it). Ctrl-C resumes immediately. */
|
|
96
|
+
async function countdownWait(acct: string, until: number): Promise<void> {
|
|
97
|
+
let aborted = false;
|
|
98
|
+
const onInt = () => { aborted = true; };
|
|
99
|
+
process.on("SIGINT", onInt);
|
|
100
|
+
process.stdout.write(`\n\x1b[36m⏳ tokenmaxxing: all accounts at their limit. Resuming on ${acct} when it resets (Ctrl-C to resume now).\x1b[0m\n`);
|
|
101
|
+
while (!aborted && Date.now() < until) {
|
|
102
|
+
const left = until - Date.now();
|
|
103
|
+
const m = Math.floor(left / 60000);
|
|
104
|
+
const s = Math.floor((left % 60000) / 1000);
|
|
105
|
+
process.stdout.write(`\r\x1b[36m resuming in ${m}m ${String(s).padStart(2, "0")}s \x1b[0m`);
|
|
106
|
+
await Bun.sleep(1000);
|
|
107
|
+
}
|
|
108
|
+
process.removeListener("SIGINT", onInt);
|
|
109
|
+
process.stdout.write(`\n\x1b[36m↻ resuming on ${acct}…\x1b[0m\n`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Entry point: `claude ...args`. */
|
|
113
|
+
export async function runSupervisor(argv: string[]): Promise<number> {
|
|
114
|
+
const real = resolveRealClaude();
|
|
115
|
+
const info = analyzeArgs(argv);
|
|
116
|
+
|
|
117
|
+
// Pass-through: no session management, no respawn - exact stock behavior.
|
|
118
|
+
if (!info.manage) {
|
|
119
|
+
const p = Bun.spawn([real, ...argv], { stdin: "inherit", stdout: "inherit", stderr: "inherit" });
|
|
120
|
+
await p.exited;
|
|
121
|
+
return p.exitCode ?? (p.signalCode ? 1 : 0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Decide the managed session id + whether we're resuming an existing one.
|
|
125
|
+
let base = stripSessionFlags(argv);
|
|
126
|
+
let sid: string;
|
|
127
|
+
let resuming = false;
|
|
128
|
+
if (info.sessionId) {
|
|
129
|
+
sid = info.sessionId;
|
|
130
|
+
} else if (info.resumeId) {
|
|
131
|
+
sid = info.resumeId;
|
|
132
|
+
resuming = true;
|
|
133
|
+
} else if (info.continueLatest) {
|
|
134
|
+
const latest = latestSessionForCwd();
|
|
135
|
+
if (latest) { sid = latest; resuming = true; } else sid = crypto.randomUUID();
|
|
136
|
+
} else {
|
|
137
|
+
sid = crypto.randomUUID();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Restore the original launch flags when resuming a session with none given
|
|
141
|
+
// this time (a bare `claude --resume <id>`, or the depleted-pool recovery).
|
|
142
|
+
if (resuming && base.length === 0) {
|
|
143
|
+
const persisted = loadSessionFlags(sid);
|
|
144
|
+
if (persisted) base = persisted;
|
|
145
|
+
}
|
|
146
|
+
saveSessionFlags(sid, base, process.cwd());
|
|
147
|
+
|
|
148
|
+
let launchArgs = resuming ? ["--resume", sid, ...base] : ["--session-id", sid, ...base];
|
|
149
|
+
|
|
150
|
+
mkdirSync(paths.respawnDir, { recursive: true });
|
|
151
|
+
const marker = join(paths.respawnDir, sid);
|
|
152
|
+
const savedTermios = saveTermios();
|
|
153
|
+
|
|
154
|
+
// Supervisor survives the SIGINT/SIGHUP that flow to the foreground group;
|
|
155
|
+
// claude (the child, same pgrp) receives and handles them itself.
|
|
156
|
+
process.on("SIGINT", () => {});
|
|
157
|
+
process.on("SIGHUP", () => {});
|
|
158
|
+
|
|
159
|
+
let respawns = 0;
|
|
160
|
+
while (true) {
|
|
161
|
+
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
162
|
+
log("supervisor.launch", { sid, respawns, args: launchArgs.join(" ") });
|
|
163
|
+
|
|
164
|
+
const child = Bun.spawn([real, ...launchArgs], {
|
|
165
|
+
stdin: "inherit",
|
|
166
|
+
stdout: "inherit",
|
|
167
|
+
stderr: "inherit",
|
|
168
|
+
env: { ...process.env, TOKENMAXXING_SUPERVISED: "1", TOKENMAXXING_SESSION_ID: sid },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Race the child's own exit against the appearance of a respawn marker.
|
|
172
|
+
let done = false;
|
|
173
|
+
const markerWatch = (async () => {
|
|
174
|
+
while (!done) {
|
|
175
|
+
if (await Bun.file(marker).exists()) return true;
|
|
176
|
+
await Bun.sleep(150);
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
})();
|
|
180
|
+
const exited = child.exited.then(() => { done = true; return "exit" as const; });
|
|
181
|
+
const winner = await Promise.race([exited, markerWatch.then((m) => (m ? "marker" : "exit"))]);
|
|
182
|
+
|
|
183
|
+
if (winner === "marker") {
|
|
184
|
+
child.kill(); // SIGTERM at the committed turn boundary
|
|
185
|
+
}
|
|
186
|
+
await child.exited;
|
|
187
|
+
done = true;
|
|
188
|
+
await markerWatch.catch(() => {});
|
|
189
|
+
restoreTermios(savedTermios);
|
|
190
|
+
|
|
191
|
+
if (existsSync(marker)) {
|
|
192
|
+
const m = RespawnMarkerSchema.parse(await Bun.file(marker).json());
|
|
193
|
+
rmSync(marker, { force: true });
|
|
194
|
+
respawns++;
|
|
195
|
+
if (m.waitUntil && m.waitUntil > Date.now()) await countdownWait(m.account, m.waitUntil);
|
|
196
|
+
else process.stdout.write(`\n\x1b[36m↻ tokenmaxxing: switched to ${m.account} - resuming…\x1b[0m\n`);
|
|
197
|
+
launchArgs = ["--resume", sid, ...base];
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
return child.exitCode ?? (child.signalCode ? 1 : 0);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Atomic file writes (temp + rename on the same filesystem) and small fs utils.
|
|
2
|
+
|
|
3
|
+
import { closeSync, mkdirSync, openSync, renameSync, writeSync, fsyncSync } from "node:fs";
|
|
4
|
+
import { dirname } from "node:path";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Write `data` to `file` atomically: write a sibling temp file, fsync it, then
|
|
8
|
+
* rename over the target. A concurrent reader sees either the old or new file,
|
|
9
|
+
* never a partial one.
|
|
10
|
+
*/
|
|
11
|
+
export function writeFileAtomic(file: string, data: string | Uint8Array, mode = 0o600): void {
|
|
12
|
+
const dir = dirname(file);
|
|
13
|
+
mkdirSync(dir, { recursive: true });
|
|
14
|
+
const tmp = `${file}.tmp.${process.pid}.${Math.floor(Math.random() * 1e9).toString(36)}`;
|
|
15
|
+
const bytes = data instanceof Uint8Array ? data : new TextEncoder().encode(data);
|
|
16
|
+
const fd = openSync(tmp, "wx", mode);
|
|
17
|
+
try {
|
|
18
|
+
writeSync(fd, bytes);
|
|
19
|
+
fsyncSync(fd);
|
|
20
|
+
} finally {
|
|
21
|
+
closeSync(fd);
|
|
22
|
+
}
|
|
23
|
+
renameSync(tmp, file);
|
|
24
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Resolve the REAL claude binary (never our shim on PATH).
|
|
2
|
+
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { paths } from "./paths.ts";
|
|
6
|
+
import { loadConfig } from "./state.ts";
|
|
7
|
+
|
|
8
|
+
export function resolveRealClaude(): string {
|
|
9
|
+
const cfg = loadConfig();
|
|
10
|
+
if (cfg.claudeBin && existsSync(cfg.claudeBin)) return cfg.claudeBin;
|
|
11
|
+
if (process.env.TOKENMAXXING_CLAUDE_BIN && existsSync(process.env.TOKENMAXXING_CLAUDE_BIN))
|
|
12
|
+
return process.env.TOKENMAXXING_CLAUDE_BIN;
|
|
13
|
+
for (const d of (process.env.PATH ?? "").split(":")) {
|
|
14
|
+
if (!d || d === paths.binDir) continue;
|
|
15
|
+
const cand = join(d, "claude");
|
|
16
|
+
try {
|
|
17
|
+
if (existsSync(cand) && statSync(cand).isFile()) return cand;
|
|
18
|
+
} catch { /* ignore */ }
|
|
19
|
+
}
|
|
20
|
+
throw new Error("could not locate the real `claude` binary (set claudeBin in config.json)");
|
|
21
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Read/replace the `oauthAccount` identity object in ~/.claude.json.
|
|
2
|
+
// We touch ONLY oauthAccount and preserve every other key verbatim.
|
|
3
|
+
|
|
4
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { paths } from "./paths.ts";
|
|
7
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
8
|
+
import { OAuthAccountSchema, type OAuthAccount } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
export function readClaudeJson(): Record<string, unknown> {
|
|
11
|
+
if (!existsSync(paths.claudeJson)) return {};
|
|
12
|
+
return JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function readOAuthAccount(): OAuthAccount | null {
|
|
16
|
+
const parsed = OAuthAccountSchema.safeParse(readClaudeJson()["oauthAccount"]);
|
|
17
|
+
return parsed.success ? parsed.data : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Detect API-key / helper auth mode (no poolable subscription credential). */
|
|
21
|
+
export function isApiKeyMode(): boolean {
|
|
22
|
+
if (process.env.ANTHROPIC_API_KEY || process.env.ANTHROPIC_AUTH_TOKEN) return true;
|
|
23
|
+
const j = readClaudeJson();
|
|
24
|
+
// apiKeyHelper is configured in settings, but a persisted flag may appear here too.
|
|
25
|
+
if (z.string().min(1).safeParse(j["apiKeyHelper"]).success) return true;
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Atomically replace ONLY oauthAccount, preserving all other keys and their
|
|
31
|
+
* insertion order. Reads fresh from disk so we never clobber concurrent edits
|
|
32
|
+
* to unrelated keys with a stale in-memory copy.
|
|
33
|
+
*/
|
|
34
|
+
export function swapOAuthAccount(next: OAuthAccount): void {
|
|
35
|
+
const j = existsSync(paths.claudeJson)
|
|
36
|
+
? (JSON.parse(readFileSync(paths.claudeJson, "utf8")) as Record<string, unknown>)
|
|
37
|
+
: {};
|
|
38
|
+
j["oauthAccount"] = next;
|
|
39
|
+
writeFileAtomic(paths.claudeJson, JSON.stringify(j, null, 2) + "\n", 0o600);
|
|
40
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Best-effort interlock with Claude Code's OWN credential-refresh lock so our
|
|
2
|
+
// keychain write can't collide with a concurrent token refresh. Claude uses the
|
|
3
|
+
// npm `proper-lockfile` (mkdir-based) at <claudeDir>/.oauth_refresh.lock - the
|
|
4
|
+
// lock is the DIRECTORY itself. We mkdir it; on contention we wait briefly, then
|
|
5
|
+
// proceed anyway (our own flock already serializes tokenmaxxing swaps, and claude
|
|
6
|
+
// only refreshes near token expiry / on 401, rarely at the 95% usage trigger).
|
|
7
|
+
|
|
8
|
+
import { mkdirSync, rmdirSync, statSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { paths } from "./paths.ts";
|
|
11
|
+
import { log } from "./log.ts";
|
|
12
|
+
|
|
13
|
+
const STALE_MS = 20_000; // proper-lockfile default stale is 10s; be generous
|
|
14
|
+
|
|
15
|
+
export async function withClaudeRefreshLock<T>(
|
|
16
|
+
fn: () => Promise<T> | T,
|
|
17
|
+
opts: { timeoutMs?: number } = {},
|
|
18
|
+
): Promise<T> {
|
|
19
|
+
const lockDir = join(paths.claudeDir, ".oauth_refresh.lock");
|
|
20
|
+
const timeoutMs = opts.timeoutMs ?? 3000;
|
|
21
|
+
const start = Date.now();
|
|
22
|
+
let held = false;
|
|
23
|
+
|
|
24
|
+
while (Date.now() - start < timeoutMs) {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(lockDir); // atomic; EEXIST means someone holds it
|
|
27
|
+
held = true;
|
|
28
|
+
break;
|
|
29
|
+
} catch (e) {
|
|
30
|
+
const code = (e as { code?: string }).code;
|
|
31
|
+
if (code === "EEXIST") {
|
|
32
|
+
try {
|
|
33
|
+
if (Date.now() - statSync(lockDir).mtimeMs > STALE_MS) {
|
|
34
|
+
rmdirSync(lockDir); // reclaim a stale lock
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
} catch { /* raced away */ }
|
|
38
|
+
await Bun.sleep(150);
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
// parent dir missing or other error → skip the interlock entirely
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (!held) log("claudelock.proceed_without", {});
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
return await fn();
|
|
49
|
+
} finally {
|
|
50
|
+
if (held) {
|
|
51
|
+
try { rmdirSync(lockDir); } catch { /* already gone */ }
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Platform credential store - ONE backend per platform, selected when a target
|
|
2
|
+
// is constructed:
|
|
3
|
+
// darwin : login-keychain generic-password via security(1) (keychain.ts)
|
|
4
|
+
// linux : plaintext files, matching claude's own Linux store
|
|
5
|
+
// (live = <configDir>/.credentials.json, mode 0600; verified 2.1.205:
|
|
6
|
+
// the Linux build has no keyring path at all)
|
|
7
|
+
// Parked backups live in the keychain (darwin) or ~/.config/tokenmaxxing/creds
|
|
8
|
+
// (linux). Call sites never branch on platform - they pass targets around.
|
|
9
|
+
|
|
10
|
+
import { mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
14
|
+
import * as kc from "./keychain.ts";
|
|
15
|
+
import { credDir, keychain as kcNames, namespacedCredService, paths } from "./paths.ts";
|
|
16
|
+
import { CredentialBlobSchema } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
const CredTargetSchema = z.discriminatedUnion("kind", [
|
|
19
|
+
z.object({ kind: z.literal("keychain"), service: z.string(), account: z.string() }),
|
|
20
|
+
z.object({ kind: z.literal("file"), path: z.string() }),
|
|
21
|
+
]);
|
|
22
|
+
export type CredTarget = z.infer<typeof CredTargetSchema>;
|
|
23
|
+
|
|
24
|
+
const darwin = process.platform === "darwin";
|
|
25
|
+
|
|
26
|
+
function isEnoent(e: unknown): boolean {
|
|
27
|
+
return (e as { code?: string }).code === "ENOENT";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Read a target's credential blob. Returns null if it does not exist. */
|
|
31
|
+
export async function readItem(t: CredTarget): Promise<string | null> {
|
|
32
|
+
if (t.kind === "keychain") return kc.readItem(t);
|
|
33
|
+
try {
|
|
34
|
+
return readFileSync(t.path, "utf8");
|
|
35
|
+
} catch (e) {
|
|
36
|
+
if (isEnoent(e)) return null;
|
|
37
|
+
throw e;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Create-or-update a target with `secret` as its blob. Throws on failure. */
|
|
42
|
+
export async function writeItem(t: CredTarget, secret: string): Promise<void> {
|
|
43
|
+
if (t.kind === "keychain") return kc.writeItem(t, secret);
|
|
44
|
+
mkdirSync(dirname(t.path), { recursive: true, mode: 0o700 });
|
|
45
|
+
writeFileAtomic(t.path, secret, 0o600);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Delete a target. Returns true if it existed and was removed. */
|
|
49
|
+
export async function deleteItem(t: CredTarget): Promise<boolean> {
|
|
50
|
+
if (t.kind === "keychain") return kc.deleteItem(t);
|
|
51
|
+
try {
|
|
52
|
+
unlinkSync(t.path);
|
|
53
|
+
return true;
|
|
54
|
+
} catch (e) {
|
|
55
|
+
if (isEnoent(e)) return false;
|
|
56
|
+
throw e;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The live default credential claude reads (`Claude Code-credentials` / $USER
|
|
61
|
+
* on darwin, `<credDir()>/.credentials.json` on linux). */
|
|
62
|
+
export function liveTarget(): CredTarget {
|
|
63
|
+
return darwin
|
|
64
|
+
? { kind: "keychain", service: kcNames.service, account: kcNames.account }
|
|
65
|
+
: { kind: "file", path: join(credDir(), ".credentials.json") };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A parked account's backup (`tokenmaxxing-cred-<uuid8>` item / .json file). */
|
|
69
|
+
export function parkedTarget(itemName: string): CredTarget {
|
|
70
|
+
return darwin
|
|
71
|
+
? { kind: "keychain", service: itemName, account: kcNames.account }
|
|
72
|
+
: { kind: "file", path: join(paths.credsDir, `${itemName}.json`) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The credential claude uses under CLAUDE_CONFIG_DIR=`configDirRaw`: a
|
|
76
|
+
* hash-namespaced keychain service on darwin, the file inside the dir on linux.
|
|
77
|
+
* The dir string must be byte-stable on darwin (hash is over the RAW string). */
|
|
78
|
+
export function isolatedTarget(configDirRaw: string): CredTarget {
|
|
79
|
+
return darwin
|
|
80
|
+
? { kind: "keychain", service: namespacedCredService(configDirRaw), account: kcNames.account }
|
|
81
|
+
: { kind: "file", path: join(configDirRaw, ".credentials.json") };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Reduce a full credential blob to just `{claudeAiOauth}` - what parked items
|
|
85
|
+
* store (small → always the ps-safe keychain write path on darwin). */
|
|
86
|
+
export function claudeAiOauthOnly(fullBlobRaw: string): string {
|
|
87
|
+
const b = CredentialBlobSchema.parse(JSON.parse(fullBlobRaw));
|
|
88
|
+
return JSON.stringify({ claudeAiOauth: b.claudeAiOauth });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Merge a fresh `claudeAiOauth` into the CURRENT live blob, preserving every
|
|
92
|
+
* sibling key (MCP OAuth state, etc.). Returns the full blob string to install. */
|
|
93
|
+
export function mergeIntoLive(currentLiveRaw: string | null, freshClaudeAiOauth: unknown): string {
|
|
94
|
+
const base = currentLiveRaw ? (JSON.parse(currentLiveRaw) as Record<string, unknown>) : {};
|
|
95
|
+
base["claudeAiOauth"] = freshClaudeAiOauth;
|
|
96
|
+
return JSON.stringify(base);
|
|
97
|
+
}
|