tokenmaxxing 0.1.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 +125 -0
- package/README.md +88 -0
- package/package.json +28 -7
- 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/index.js +0 -38
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// `tokenmaxxing rename <selector> <new-label>` - relabel a pooled account.
|
|
2
|
+
|
|
3
|
+
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
4
|
+
import { c } from "./render.ts";
|
|
5
|
+
import type { Account } from "../lib/types.ts";
|
|
6
|
+
|
|
7
|
+
/** Resolve an account by email, label, or accountUuid prefix. */
|
|
8
|
+
export function findAccount(accounts: Account[], selector: string): Account | undefined {
|
|
9
|
+
const s = selector.toLowerCase();
|
|
10
|
+
return (
|
|
11
|
+
accounts.find((a) => a.email.toLowerCase() === s) ??
|
|
12
|
+
accounts.find((a) => a.label.toLowerCase() === s) ??
|
|
13
|
+
accounts.find((a) => a.accountUuid.toLowerCase().startsWith(s))
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function cmdRename(selector?: string, newLabel?: string): number {
|
|
18
|
+
if (!selector || !newLabel) {
|
|
19
|
+
console.error("usage: tokenmaxxing rename <email|label|uuid> <new-label>");
|
|
20
|
+
return 2;
|
|
21
|
+
}
|
|
22
|
+
const idx = loadAccounts();
|
|
23
|
+
const a = findAccount(idx.accounts, selector);
|
|
24
|
+
if (!a) {
|
|
25
|
+
console.error(c.red(`no account matches "${selector}"`));
|
|
26
|
+
return 1;
|
|
27
|
+
}
|
|
28
|
+
const old = a.label;
|
|
29
|
+
a.label = newLabel;
|
|
30
|
+
saveAccounts(idx);
|
|
31
|
+
console.log(`renamed ${c.dim(old)} → ${c.bold(newLabel)}`);
|
|
32
|
+
return 0;
|
|
33
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Terminal rendering helpers for the CLI (bars, colors, relative times).
|
|
2
|
+
|
|
3
|
+
import { clamp } from "es-toolkit";
|
|
4
|
+
|
|
5
|
+
const useColor = !process.env.NO_COLOR && process.stdout.isTTY;
|
|
6
|
+
|
|
7
|
+
export const c = {
|
|
8
|
+
dim: (s: string) => (useColor ? `\x1b[2m${s}\x1b[0m` : s),
|
|
9
|
+
bold: (s: string) => (useColor ? `\x1b[1m${s}\x1b[0m` : s),
|
|
10
|
+
green: (s: string) => (useColor ? `\x1b[32m${s}\x1b[0m` : s),
|
|
11
|
+
yellow: (s: string) => (useColor ? `\x1b[33m${s}\x1b[0m` : s),
|
|
12
|
+
red: (s: string) => (useColor ? `\x1b[31m${s}\x1b[0m` : s),
|
|
13
|
+
cyan: (s: string) => (useColor ? `\x1b[36m${s}\x1b[0m` : s),
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** A fixed-width usage bar, colored by fill. */
|
|
17
|
+
export function bar(pct: number, width = 16): string {
|
|
18
|
+
const clamped = clamp(pct, 0, 100);
|
|
19
|
+
const filled = Math.round((clamped / 100) * width);
|
|
20
|
+
const body = "█".repeat(filled) + "░".repeat(width - filled);
|
|
21
|
+
const label = `${clamped.toFixed(0).padStart(3)}%`;
|
|
22
|
+
const paint = clamped >= 95 ? c.red : clamped >= 75 ? c.yellow : c.green;
|
|
23
|
+
return `${paint(body)} ${label}`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Relative-time string for a reset epoch, e.g. "resets in 2h13m". */
|
|
27
|
+
export function fmtReset(epochMs: number | null | undefined, now = Date.now()): string {
|
|
28
|
+
if (epochMs == null) return "";
|
|
29
|
+
const dsec = Math.round((epochMs - now) / 1000);
|
|
30
|
+
if (dsec <= 0) return "reset now";
|
|
31
|
+
const h = Math.floor(dsec / 3600);
|
|
32
|
+
const m = Math.floor((dsec % 3600) / 60);
|
|
33
|
+
if (h > 24) return `resets in ${Math.floor(h / 24)}d${h % 24}h`;
|
|
34
|
+
if (h > 0) return `resets in ${h}h${m}m`;
|
|
35
|
+
return `resets in ${m}m`;
|
|
36
|
+
}
|
package/src/cli/rm.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `tokenmaxxing rm <selector>` - remove a pooled account (not the active one).
|
|
2
|
+
|
|
3
|
+
import { deleteItem, parkedTarget } from "../lib/credstore.ts";
|
|
4
|
+
import { loadAccounts, saveAccounts } from "../lib/state.ts";
|
|
5
|
+
import { findAccount } from "./rename.ts";
|
|
6
|
+
import { c } from "./render.ts";
|
|
7
|
+
|
|
8
|
+
export async function cmdRm(selector?: string): Promise<number> {
|
|
9
|
+
if (!selector) {
|
|
10
|
+
console.error("usage: tokenmaxxing rm <email|label|uuid>");
|
|
11
|
+
return 2;
|
|
12
|
+
}
|
|
13
|
+
const idx = loadAccounts();
|
|
14
|
+
const a = findAccount(idx.accounts, selector);
|
|
15
|
+
if (!a) {
|
|
16
|
+
console.error(c.red(`no account matches "${selector}"`));
|
|
17
|
+
return 1;
|
|
18
|
+
}
|
|
19
|
+
if (a.accountUuid === idx.activeAccountUuid) {
|
|
20
|
+
console.error(c.red(`${a.email} is the ACTIVE account - switch away before removing it.`));
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
await deleteItem(parkedTarget(a.keychainItem));
|
|
24
|
+
idx.accounts = idx.accounts.filter((x) => x.accountUuid !== a.accountUuid);
|
|
25
|
+
saveAccounts(idx);
|
|
26
|
+
console.log(`removed ${c.bold(a.label)} from the pool (${idx.accounts.length} left)`);
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// `tokenmaxxing status`: accounts with 5h / weekly / per-model usage bars.
|
|
2
|
+
// Parked accounts are live-sampled in isolation (`claude -p /usage`); the active
|
|
3
|
+
// account is read off the free statusLine feed (usage.json) so we never poll its
|
|
4
|
+
// own busy token. A sample that fails falls back to the last-known values with a
|
|
5
|
+
// visible "(cached)" note - never a silent stale number. Fresh figures are
|
|
6
|
+
// persisted onto each account for the picker/switch logic.
|
|
7
|
+
|
|
8
|
+
import { loadAccounts, loadConfig, loadUsage, loadModelUsage, saveAccounts } from "../lib/state.ts";
|
|
9
|
+
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
10
|
+
import { probeActiveUsage, probeParkedUsage, type SampleOutcome } from "../lib/sample.ts";
|
|
11
|
+
import { withLock } from "../lib/lock.ts";
|
|
12
|
+
import { paths } from "../lib/paths.ts";
|
|
13
|
+
import { isExhausted } from "../lib/picker.ts";
|
|
14
|
+
import { bar, c, fmtReset } from "./render.ts";
|
|
15
|
+
import type { FullUsage } from "../lib/usage.ts";
|
|
16
|
+
import type { UsageWindow } from "../lib/types.ts";
|
|
17
|
+
|
|
18
|
+
export async function cmdStatus(): Promise<number> {
|
|
19
|
+
const idx = loadAccounts();
|
|
20
|
+
const cfg = loadConfig();
|
|
21
|
+
const live = loadUsage();
|
|
22
|
+
const modelUsage = loadModelUsage();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
|
|
25
|
+
if (idx.accounts.length === 0) {
|
|
26
|
+
console.log(c.dim("no accounts yet, run `tokenmaxxing init`"));
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const activeOrg = readOAuthAccount()?.organizationUuid ?? null;
|
|
31
|
+
|
|
32
|
+
// Sample under the flock so parked refreshes can't collide with an in-flight swap.
|
|
33
|
+
console.error(c.dim("sampling live usage…"));
|
|
34
|
+
const outcomes = new Map<string, SampleOutcome>();
|
|
35
|
+
await withLock(paths.lockFile, () =>
|
|
36
|
+
Promise.all(
|
|
37
|
+
idx.accounts.map(async (a) => {
|
|
38
|
+
const isActive = a.accountUuid === idx.activeAccountUuid && activeOrg === a.organizationUuid;
|
|
39
|
+
// Active account: prefer the free statusLine push (usage.json) so we never
|
|
40
|
+
// poll its own token, which is busy exactly when it matters. per-model
|
|
41
|
+
// comes from model-usage.json (also statusLine-driven).
|
|
42
|
+
const fromStatusLine: FullUsage | null =
|
|
43
|
+
isActive && live && live.org === a.organizationUuid
|
|
44
|
+
? {
|
|
45
|
+
session: live.fiveHour,
|
|
46
|
+
weekAll: live.sevenDay,
|
|
47
|
+
perModel: modelUsage && modelUsage.org === a.organizationUuid ? modelUsage.perModel : {},
|
|
48
|
+
}
|
|
49
|
+
: null;
|
|
50
|
+
const outcome: SampleOutcome = fromStatusLine
|
|
51
|
+
? { ok: true, usage: fromStatusLine }
|
|
52
|
+
: isActive
|
|
53
|
+
? await probeActiveUsage(a)
|
|
54
|
+
: await probeParkedUsage(a);
|
|
55
|
+
outcomes.set(a.accountUuid, outcome);
|
|
56
|
+
if (!outcome.ok) return;
|
|
57
|
+
a.lastUsage = { fiveHour: outcome.usage.session, sevenDay: outcome.usage.weekAll };
|
|
58
|
+
if (Object.keys(outcome.usage.perModel).length > 0) a.lastPerModel = outcome.usage.perModel;
|
|
59
|
+
}),
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
saveAccounts(idx);
|
|
63
|
+
|
|
64
|
+
console.log(c.dim(`threshold ${cfg.threshold}% · ${idx.accounts.length} account(s)`));
|
|
65
|
+
console.log();
|
|
66
|
+
|
|
67
|
+
const row = (name: string, w: UsageWindow) =>
|
|
68
|
+
console.log(` ${name.padEnd(5)} ${bar(w.usedPercentage)} ${c.dim(fmtReset(w.resetsAt, now))}`);
|
|
69
|
+
|
|
70
|
+
for (const a of idx.accounts) {
|
|
71
|
+
const active = a.accountUuid === idx.activeAccountUuid;
|
|
72
|
+
const outcome = outcomes.get(a.accountUuid);
|
|
73
|
+
const failed = outcome ? !outcome.ok : false;
|
|
74
|
+
// On a failed sample, fall back to the last-known values (with a note below).
|
|
75
|
+
const usage = outcome?.ok ? outcome.usage : undefined;
|
|
76
|
+
const aggregate = usage ? { fiveHour: usage.session, sevenDay: usage.weekAll } : a.lastUsage;
|
|
77
|
+
const perModel = usage ? usage.perModel : a.lastPerModel;
|
|
78
|
+
|
|
79
|
+
const marker = active ? c.green("●") : c.dim("○");
|
|
80
|
+
const badges: string[] = [];
|
|
81
|
+
if (active) badges.push(c.green("active"));
|
|
82
|
+
if (a.needsReauth) badges.push(c.red("needs-reauth"));
|
|
83
|
+
if (isExhausted(a, { now, threshold: cfg.threshold, currentAccountUuid: idx.activeAccountUuid }))
|
|
84
|
+
badges.push(c.yellow("exhausted"));
|
|
85
|
+
|
|
86
|
+
console.log(`${marker} ${c.bold(a.label || a.email)} ${badges.join(" ")}`);
|
|
87
|
+
if (aggregate) {
|
|
88
|
+
row("5h", aggregate.fiveHour);
|
|
89
|
+
row("week", aggregate.sevenDay);
|
|
90
|
+
}
|
|
91
|
+
if (perModel) for (const [name, w] of Object.entries(perModel)) row(name, w);
|
|
92
|
+
if (failed && outcome && !outcome.ok) {
|
|
93
|
+
const note = aggregate || perModel ? "cached · live sample failed" : "live sample failed";
|
|
94
|
+
console.log(` ${c.yellow(note)}: ${c.dim(outcome.reason)}`);
|
|
95
|
+
}
|
|
96
|
+
console.log();
|
|
97
|
+
}
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
@@ -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
|
+
}
|