tokenmaxxing 1.33.0 → 1.35.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 +3 -5
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli/add.ts +23 -40
- package/src/cli/auth.ts +38 -52
- package/src/cli/check.ts +2 -1
- package/src/cli/cloudrun.ts +5 -15
- package/src/cli/cursorinit.ts +4 -8
- package/src/cli/doctor.ts +22 -36
- package/src/cli/init.ts +29 -126
- package/src/cli/rename.ts +14 -50
- package/src/cli/render.ts +1 -8
- package/src/cli/rm.ts +27 -44
- package/src/cli/setuptoken.ts +25 -38
- package/src/cli/status.ts +95 -238
- package/src/cli/switch.ts +54 -46
- package/src/entries/codexstophook.ts +22 -16
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +3 -2
- package/src/entries/statusline.ts +15 -13
- package/src/entries/stopfailurehook.ts +7 -5
- package/src/entries/stophook.ts +3 -2
- package/src/lib/claude.ts +320 -0
- package/src/lib/codex.ts +353 -0
- package/src/lib/codexauth.ts +6 -0
- package/src/lib/codexpresence.ts +0 -10
- package/src/lib/codexusage.ts +14 -32
- package/src/lib/decide.ts +86 -174
- package/src/lib/oauth.ts +7 -0
- package/src/lib/paths.ts +14 -6
- package/src/lib/picker.ts +56 -37
- package/src/lib/provider.ts +34 -0
- package/src/lib/sample.ts +33 -33
- package/src/lib/state.ts +49 -16
- package/src/lib/swap.ts +41 -38
- package/src/lib/types.ts +17 -51
- package/src/lib/usage.ts +17 -4
- package/src/main.ts +33 -37
- package/src/cli/codexadd.ts +0 -89
- package/src/cli/codexinit.ts +0 -120
- package/src/cli/codexrm.ts +0 -37
- package/src/cli/codexswitch.ts +0 -113
- package/src/cli/onboard.ts +0 -92
- package/src/lib/codexdecide.ts +0 -157
- package/src/lib/codexpick.ts +0 -80
- package/src/lib/codexsample.ts +0 -48
- package/src/lib/codexstate.ts +0 -22
- package/src/lib/codexswap.ts +0 -70
|
@@ -1,18 +1,17 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { codexPaths } from "../lib/paths.ts";
|
|
4
|
+
import { codexPaths, codexPool } from "../lib/paths.ts";
|
|
5
5
|
import { withLock } from "../lib/lock.ts";
|
|
6
6
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
7
|
+
import { codex, codexPickCtx, reconcileSiblings } from "../lib/codex.ts";
|
|
8
|
+
import { liveCodexAccountId } from "../lib/codexauth.ts";
|
|
9
|
+
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
10
|
+
import { isExhausted } from "../lib/picker.ts";
|
|
9
11
|
import { livingCodexPresences } from "../lib/codexpresence.ts";
|
|
10
|
-
import {
|
|
11
|
-
import { loadCodexAccounts } from "../lib/codexstate.ts";
|
|
12
|
-
import { loadConfig } from "../lib/state.ts";
|
|
13
|
-
import { thresholdBars } from "../lib/picker.ts";
|
|
12
|
+
import { loadAccounts } from "../lib/state.ts";
|
|
14
13
|
import { CODEX_SUPERVISOR_ID_ENV } from "./codexsupervisor.ts";
|
|
15
|
-
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema
|
|
14
|
+
import { CodexReconcileMarkerSchema, CodexRespawnMarkerSchema, CodexStopStdinSchema } from "../lib/types.ts";
|
|
16
15
|
import { log } from "../lib/log.ts";
|
|
17
16
|
|
|
18
17
|
const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
@@ -20,7 +19,7 @@ const SupervisorIdSchema = z.string().min(1).optional().catch(undefined);
|
|
|
20
19
|
async function promoteReconcile(input: { supervisorId: string; sessionId: string | null }): Promise<boolean> {
|
|
21
20
|
const markerPath = join(codexPaths.reconcileDir, input.supervisorId);
|
|
22
21
|
if (!existsSync(markerPath)) return false;
|
|
23
|
-
return withLock(
|
|
22
|
+
return withLock(codexPool.lockFile, async () => promoteReconcileLocked(input));
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
function promoteReconcileLocked(input: { supervisorId: string; sessionId: string | null }): boolean {
|
|
@@ -51,12 +50,8 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
51
50
|
return false;
|
|
52
51
|
}
|
|
53
52
|
const now = Date.now();
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const unusable = (account: CodexAccount): boolean =>
|
|
57
|
-
account.needsReauth === true || isCodexExhausted({ account, thresholds: bars, now });
|
|
58
|
-
const liveAccount = index.accounts.find((a) => a.accountId === liveId);
|
|
59
|
-
if (!liveAccount || unusable(liveAccount)) {
|
|
53
|
+
const liveAccount = loadAccounts(codexPool).accounts.find((a) => a.id === liveId);
|
|
54
|
+
if (!liveAccount || liveAccount.needsReauth === true || isExhausted(liveAccount, codexPickCtx(now, liveId))) {
|
|
60
55
|
rmSync(markerPath, { force: true });
|
|
61
56
|
log("codexstop.reconcile_blocked_target", {});
|
|
62
57
|
return false;
|
|
@@ -75,6 +70,14 @@ function promoteReconcileLocked(input: { supervisorId: string; sessionId: string
|
|
|
75
70
|
return true;
|
|
76
71
|
}
|
|
77
72
|
|
|
73
|
+
async function sweepSiblings(now: number): Promise<void> {
|
|
74
|
+
try {
|
|
75
|
+
await withLock(codexPool.lockFile, () => reconcileSiblings(now));
|
|
76
|
+
} catch (e) {
|
|
77
|
+
log("codexstop.resweep_failed", { err: e instanceof Error ? e.message : String(e) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
78
81
|
async function readStdin(): Promise<string> {
|
|
79
82
|
const chunks: Uint8Array[] = [];
|
|
80
83
|
for await (const chunk of Bun.stdin.stream()) chunks.push(chunk);
|
|
@@ -98,8 +101,11 @@ export async function handleCodexStop(input: { rawStdin: string }): Promise<void
|
|
|
98
101
|
return;
|
|
99
102
|
}
|
|
100
103
|
if (await promoteReconcile({ supervisorId, sessionId })) return;
|
|
101
|
-
const
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
await withLock(codexPool.lockFile, () => reconcileSiblings(now));
|
|
106
|
+
const decision = await evaluateAndMaybeSwap(codex, now);
|
|
102
107
|
if (decision.swapped && decision.account) {
|
|
108
|
+
await sweepSiblings(now);
|
|
103
109
|
mkdirSync(codexPaths.respawnDir, { recursive: true });
|
|
104
110
|
const payload = CodexRespawnMarkerSchema.parse({
|
|
105
111
|
account: decision.account.label,
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
-
import { codexPaths, paths } from "../lib/paths.ts";
|
|
4
|
+
import { codexPaths, codexPool, paths } from "../lib/paths.ts";
|
|
5
5
|
import { withLock } from "../lib/lock.ts";
|
|
6
6
|
import { LOOP_DIAGNOSIS, MAX_WRAP_DEPTH, UNMANAGED_ENV, WRAP_DEPTH_ENV, WRAP_RATE_MAX, WRAP_RATE_WINDOW_MS, wrapDepth, wrapperEntryRateTripped } from "../lib/claudebin.ts";
|
|
7
7
|
import { resolveRealCodex } from "../lib/codexbin.ts";
|
|
8
8
|
import { clearCodexPresence, writeCodexPresence } from "../lib/codexpresence.ts";
|
|
9
|
-
import { liveCodexAccountId } from "../lib/
|
|
9
|
+
import { liveCodexAccountId } from "../lib/codexauth.ts";
|
|
10
10
|
import { saveTermios, restoreTermios } from "../lib/tty.ts";
|
|
11
11
|
import { CodexRespawnMarkerSchema } from "../lib/types.ts";
|
|
12
12
|
import { log } from "../lib/log.ts";
|
|
@@ -95,7 +95,7 @@ export async function runCodexSupervisor(input: { argv: string[] }): Promise<num
|
|
|
95
95
|
if (existsSync(marker)) rmSync(marker, { force: true });
|
|
96
96
|
log("codexsupervisor.launch", { supervisorId: supervisorId.slice(0, 8), respawns, args: launchArgs.join(" ") });
|
|
97
97
|
|
|
98
|
-
const child = await withLock(
|
|
98
|
+
const child = await withLock(codexPool.lockFile, async () => {
|
|
99
99
|
const spawnAccountId = liveCodexAccountId();
|
|
100
100
|
const spawned = Bun.spawn([real, ...launchArgs], {
|
|
101
101
|
stdin: "inherit",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { claude } from "../lib/claude.ts";
|
|
2
3
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
3
4
|
import { log } from "../lib/log.ts";
|
|
4
5
|
|
|
@@ -21,9 +22,9 @@ export async function runSessionStart(): Promise<number> {
|
|
|
21
22
|
const source = parsed.success ? parsed.data.source : undefined;
|
|
22
23
|
|
|
23
24
|
try {
|
|
24
|
-
const decision = await evaluateAndMaybeSwap();
|
|
25
|
+
const decision = await evaluateAndMaybeSwap(claude);
|
|
25
26
|
if (decision.swapped && decision.account) {
|
|
26
|
-
log("sessionstart.swapped", { source, account: decision.account.
|
|
27
|
+
log("sessionstart.swapped", { source, account: decision.account.id.slice(0, 8) });
|
|
27
28
|
}
|
|
28
29
|
} catch (e) {
|
|
29
30
|
log("sessionstart.error", { err: e instanceof Error ? e.message : String(e) });
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
4
|
+
import { claudePool } from "../lib/paths.ts";
|
|
4
5
|
import { loadAccounts, loadConfig, loadLastSwapAt, writeUsage } from "../lib/state.ts";
|
|
5
6
|
import { familyTokens, matchedFamily, parseStatusLineStdin, parseStatusLineModel } from "../lib/usage.ts";
|
|
6
|
-
import { earliestReset, weeklyExpiry } from "../lib/picker.ts";
|
|
7
|
+
import { earliestReset, limitWindows, weeklyExpiry, weeklyWindow } from "../lib/picker.ts";
|
|
7
8
|
import { worktreeName } from "../lib/worktree.ts";
|
|
8
9
|
import { makeColors, makeUsagePaint } from "../cli/render.ts";
|
|
9
10
|
import { fmtResetShort } from "../lib/usage.ts";
|
|
10
11
|
import {
|
|
11
12
|
AccountsIndexSchema,
|
|
12
13
|
StatusLineStdinSchema,
|
|
13
|
-
|
|
14
|
+
WindowSchema,
|
|
14
15
|
type Account,
|
|
15
16
|
type UsageState,
|
|
16
17
|
type UsageWindow,
|
|
@@ -20,7 +21,7 @@ const ADOPTION_GRACE_MS = 45_000;
|
|
|
20
21
|
|
|
21
22
|
const RenderCtxSchema = z.object({
|
|
22
23
|
accounts: AccountsIndexSchema,
|
|
23
|
-
perModel: z.record(z.string(),
|
|
24
|
+
perModel: z.record(z.string(), WindowSchema),
|
|
24
25
|
switchModels: z.array(z.string()),
|
|
25
26
|
worktree: z.string().nullable(),
|
|
26
27
|
liveAccount: z.string().nullable(),
|
|
@@ -78,11 +79,11 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
78
79
|
windows.push(seg("", wins.sevenDay, wins.sevenDay.resetsAt));
|
|
79
80
|
}
|
|
80
81
|
const seatUuid =
|
|
81
|
-
(ctx.liveAccount != null && ctx.accounts.accounts.some((a) => a.
|
|
82
|
-
ctx.accounts.
|
|
82
|
+
(ctx.liveAccount != null && ctx.accounts.accounts.some((a) => a.id === ctx.liveAccount) ? ctx.liveAccount : null) ??
|
|
83
|
+
ctx.accounts.activeId;
|
|
83
84
|
const walled = (a: Account) => a.enforcedUntil != null && a.enforcedUntil > ctx.now;
|
|
84
85
|
const wallSeg = (wall: number) => seg("", { usedPercentage: 100, resetsAt: wall }, wall);
|
|
85
|
-
const seat = ctx.accounts.accounts.find((a) => a.
|
|
86
|
+
const seat = ctx.accounts.accounts.find((a) => a.id === seatUuid);
|
|
86
87
|
if (seat?.enforcedUntil != null && walled(seat)) windows.push(wallSeg(seat.enforcedUntil));
|
|
87
88
|
const seatMarker = seat && walled(seat) ? paint(100)("◆") : col.green("◆");
|
|
88
89
|
const active =
|
|
@@ -93,20 +94,20 @@ export function renderStatusline(stdinObj: unknown, ctx: RenderCtx): string {
|
|
|
93
94
|
: "";
|
|
94
95
|
|
|
95
96
|
const parked = sortBy(
|
|
96
|
-
ctx.accounts.accounts.filter((a) => a.
|
|
97
|
+
ctx.accounts.accounts.filter((a) => a.id !== seatUuid),
|
|
97
98
|
[(a) => (a.needsReauth ? 1 : 0), (a) => earliestReset(a, ctx.now)],
|
|
98
99
|
);
|
|
99
100
|
const poolSeg = (a: Account): string => {
|
|
100
101
|
const marker = a.needsReauth ? col.red("✗") : walled(a) ? paint(100)("◇") : col.cyan("◇");
|
|
101
102
|
if (a.enforcedUntil != null && walled(a)) return `${marker} ${wallSeg(a.enforcedUntil)}`;
|
|
102
|
-
const week = a
|
|
103
|
+
const week = weeklyWindow(a);
|
|
103
104
|
if (week == null) return `${marker} ?`;
|
|
104
105
|
const weekUsed = used(week);
|
|
105
106
|
if (Math.round(weekUsed) <= 0) return `${marker} ${paint(0)("full")}`;
|
|
106
107
|
|
|
107
108
|
const parts: string[] = [];
|
|
108
|
-
for (const
|
|
109
|
-
if (used(w) > weekUsed) parts.push(seg(initial(name), w, null));
|
|
109
|
+
for (const w of limitWindows(a)) {
|
|
110
|
+
if (used(w) > weekUsed) parts.push(seg(initial(w.name ?? ""), w, null));
|
|
110
111
|
}
|
|
111
112
|
const expiry = weeklyExpiry(a, ctx.now);
|
|
112
113
|
parts.push(seg("", week, Number.isFinite(expiry) ? expiry : null));
|
|
@@ -129,7 +130,7 @@ export async function runStatusline(): Promise<number> {
|
|
|
129
130
|
try {
|
|
130
131
|
account = readOAuthAccount()?.accountUuid ?? null;
|
|
131
132
|
const windows = obj == null ? null : parseStatusLineStdin(obj);
|
|
132
|
-
const lastSwapAt = loadLastSwapAt();
|
|
133
|
+
const lastSwapAt = loadLastSwapAt(claudePool);
|
|
133
134
|
if (windows && (lastSwapAt == null || now - lastSwapAt >= ADOPTION_GRACE_MS)) {
|
|
134
135
|
const state: UsageState = { fiveHour: windows.fiveHour, sevenDay: windows.sevenDay, account, ts: now, model: parseStatusLineModel(obj) };
|
|
135
136
|
writeUsage(state);
|
|
@@ -140,13 +141,14 @@ export async function runStatusline(): Promise<number> {
|
|
|
140
141
|
let line: string;
|
|
141
142
|
try {
|
|
142
143
|
const cfg = loadConfig();
|
|
143
|
-
const accounts = loadAccounts();
|
|
144
|
+
const accounts = loadAccounts(claudePool);
|
|
144
145
|
const stdin = StatusLineStdinSchema.safeParse(obj);
|
|
145
146
|
const dir = stdin.success ? (stdin.data.workspace?.current_dir ?? stdin.data.workspace?.project_dir ?? null) : null;
|
|
146
147
|
const colorterm = z.string().optional().parse(process.env.COLORTERM);
|
|
148
|
+
const live = accounts.accounts.find((a) => a.id === account);
|
|
147
149
|
const ctx: RenderCtx = {
|
|
148
150
|
accounts,
|
|
149
|
-
perModel:
|
|
151
|
+
perModel: Object.fromEntries((live ? limitWindows(live) : []).map((w) => [w.name ?? "", w])),
|
|
150
152
|
switchModels: cfg.policy.switchModels,
|
|
151
153
|
worktree: dir == null ? null : worktreeName(dir),
|
|
152
154
|
liveAccount: account,
|
|
@@ -2,9 +2,11 @@ import { join } from "node:path";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { paths } from "../lib/paths.ts";
|
|
4
4
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
|
+
import { claude } from "../lib/claude.ts";
|
|
5
6
|
import { readOAuthAccount } from "../lib/claudejson.ts";
|
|
6
7
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
7
8
|
import { withLock } from "../lib/lock.ts";
|
|
9
|
+
import { claudePool } from "../lib/paths.ts";
|
|
8
10
|
import { POST_SWAP_COOLDOWN_MS, loadConfig, loadLastSwapAt } from "../lib/state.ts";
|
|
9
11
|
import { classifyEnforcedLimit, findEnforcedRow, parseErrorBody, readTranscriptTail } from "../lib/usage.ts";
|
|
10
12
|
import { RespawnMarkerSchema, type EnforcedLimit } from "../lib/types.ts";
|
|
@@ -29,7 +31,7 @@ async function readStdin(): Promise<string> {
|
|
|
29
31
|
export async function runStopFailureHook(): Promise<number> {
|
|
30
32
|
if (process.env.TOKENMAXXING_PROBE) return 0;
|
|
31
33
|
|
|
32
|
-
const entry = await withLock(
|
|
34
|
+
const entry = await withLock(claudePool.lockFile, () => ({ swapClock: loadLastSwapAt(claudePool), account: readOAuthAccount()?.accountUuid ?? null }));
|
|
33
35
|
const account = entry.account;
|
|
34
36
|
const now = Date.now();
|
|
35
37
|
const raw = await readStdin();
|
|
@@ -44,7 +46,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
44
46
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null && mainLoop;
|
|
45
47
|
|
|
46
48
|
try {
|
|
47
|
-
const lastSwapAt = loadLastSwapAt();
|
|
49
|
+
const lastSwapAt = loadLastSwapAt(claudePool);
|
|
48
50
|
if (lastSwapAt != null && lastSwapAt === entry.swapClock && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
|
|
49
51
|
log("stopfailure.cooldown", { sinceSwapMs: now - lastSwapAt });
|
|
50
52
|
return 0;
|
|
@@ -68,7 +70,7 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
68
70
|
});
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
const decision = await evaluateAndMaybeSwap(now, canPause && enforced != null, enforced);
|
|
73
|
+
const decision = await evaluateAndMaybeSwap(claude, now, canPause && enforced != null, enforced);
|
|
72
74
|
if (enforced && canPause && pinnedSid && decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
73
75
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
74
76
|
const payload = RespawnMarkerSchema.parse({
|
|
@@ -79,9 +81,9 @@ export async function runStopFailureHook(): Promise<number> {
|
|
|
79
81
|
...(launchedAt != null ? { launchedAt } : {}),
|
|
80
82
|
});
|
|
81
83
|
writeFileAtomic(marker, JSON.stringify(payload));
|
|
82
|
-
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.
|
|
84
|
+
log("stopfailure.marker", { session: (stdinSid ?? pinnedSid).slice(0, 8), account: decision.account.id.slice(0, 8), waitUntil: payload.waitUntil });
|
|
83
85
|
} else {
|
|
84
|
-
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.
|
|
86
|
+
log("stopfailure.decision", { reason: decision.reason, swapped: decision.swapped, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
85
87
|
}
|
|
86
88
|
} catch (e) {
|
|
87
89
|
log("stopfailure.error", { err: e instanceof Error ? e.message : String(e) });
|
package/src/entries/stophook.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { paths } from "../lib/paths.ts";
|
|
4
4
|
import { writeFileAtomic } from "../lib/atomic.ts";
|
|
5
|
+
import { claude } from "../lib/claude.ts";
|
|
5
6
|
import { evaluateAndMaybeSwap } from "../lib/decide.ts";
|
|
6
7
|
import { RespawnMarkerSchema } from "../lib/types.ts";
|
|
7
8
|
import { log } from "../lib/log.ts";
|
|
@@ -24,9 +25,9 @@ export async function runStopHook(): Promise<number> {
|
|
|
24
25
|
|
|
25
26
|
try {
|
|
26
27
|
const canPause = process.env.TOKENMAXXING_SUPERVISED === "1" && pinnedSid != null;
|
|
27
|
-
const decision = await evaluateAndMaybeSwap(Date.now(), canPause);
|
|
28
|
+
const decision = await evaluateAndMaybeSwap(claude, Date.now(), canPause);
|
|
28
29
|
if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
|
|
29
|
-
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.
|
|
30
|
+
log(decision.swapped ? "stop.swapped" : "stop.wait", { account: decision.account.id.slice(0, 8), waitUntil: decision.waitUntil });
|
|
30
31
|
if (decision.waitUntil !== undefined && canPause && pinnedSid) {
|
|
31
32
|
const marker = join(paths.respawnDir, pinnedSid);
|
|
32
33
|
const launchedAt = z.coerce.number().finite().optional().catch(undefined).parse(process.env.TOKENMAXXING_LAUNCHED_AT);
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
5
|
+
import { resolveRealClaude, resolveVerifiedClaude } from "./claudebin.ts";
|
|
6
|
+
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
7
|
+
import { isApiKeyMode, readOAuthAccount } from "./claudejson.ts";
|
|
8
|
+
import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath, timerActivationHint } from "./install.ts";
|
|
9
|
+
import { withLock } from "./lock.ts";
|
|
10
|
+
import { log } from "./log.ts";
|
|
11
|
+
import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isAccessTokenExpiring, isDeadCredential, refreshCredential, InvalidGrantError } from "./oauth.ts";
|
|
12
|
+
import { claudePool, credItemFor, paths } from "./paths.ts";
|
|
13
|
+
import type { Observation, Provider, SampleReport } from "./provider.ts";
|
|
14
|
+
import { ensureLiveTokenFresh, probeActiveUsage, probeParkedUsage } from "./sample.ts";
|
|
15
|
+
import { loadAccounts, loadUsage, loadUsageSnapshot, pinBinOverride, saveAccounts, writeUsage, type Harvest } from "./state.ts";
|
|
16
|
+
import { isSkippableSwapError, performSwap } from "./swap.ts";
|
|
17
|
+
import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
|
|
18
|
+
import { saveTermios, restoreTermios } from "./tty.ts";
|
|
19
|
+
import { CRED_ENV_OVERRIDES, familyTokens, gatedFamilies, mergeWindows, probeUsage, windowsOf } from "./usage.ts";
|
|
20
|
+
import { CredentialBlobSchema, OAuthAccountSchema, type Account, type Config, type UsageState } from "./types.ts";
|
|
21
|
+
import { c } from "../cli/render.ts";
|
|
22
|
+
|
|
23
|
+
function teeObservation(account: Account, snap: { state: UsageState; at: number } | null): Observation | null {
|
|
24
|
+
if (!snap || snap.state.account !== account.id) return null;
|
|
25
|
+
if (account.lastUsageAt != null && snap.at < account.lastUsageAt) return { windows: account.windows, at: account.lastUsageAt };
|
|
26
|
+
const at = snap.state.sampledAt ?? snap.at;
|
|
27
|
+
const aggregate = windowsOf({ fiveHour: snap.state.fiveHour, sevenDay: snap.state.sevenDay, perModel: {} }, at);
|
|
28
|
+
return { windows: mergeWindows(aggregate, account.windows), at };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
|
|
32
|
+
let snap = loadUsageSnapshot();
|
|
33
|
+
const ttl = cfg.policy.usagePollTtlMs;
|
|
34
|
+
const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= ttl;
|
|
35
|
+
const fresh = snap != null && snap.state.account === account.id && now - snap.at <= ttl;
|
|
36
|
+
const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
|
|
37
|
+
if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
|
|
38
|
+
const startedAt = Date.now();
|
|
39
|
+
const full = await probeUsage();
|
|
40
|
+
const ts = Date.now();
|
|
41
|
+
if (readOAuthAccount()?.accountUuid === account.id) {
|
|
42
|
+
if (full) {
|
|
43
|
+
const teed = loadUsageSnapshot();
|
|
44
|
+
if (teed && teed.state.account === account.id && ts - teed.at <= ttl) {
|
|
45
|
+
snap = teed;
|
|
46
|
+
} else {
|
|
47
|
+
const state: UsageState = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account: account.id, ts, model: null };
|
|
48
|
+
writeUsage(state);
|
|
49
|
+
snap = { state, at: ts };
|
|
50
|
+
}
|
|
51
|
+
const expected = gatedFamilies(snap.state.model, cfg.policy.switchModels);
|
|
52
|
+
const rows = Object.keys(full.perModel);
|
|
53
|
+
if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
|
|
54
|
+
log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
await withLock(claudePool.lockFile, () => {
|
|
58
|
+
const idx = loadAccounts(claudePool);
|
|
59
|
+
const a = idx.accounts.find((x) => x.id === account.id);
|
|
60
|
+
if (!a) return;
|
|
61
|
+
a.lastProbeAt = startedAt;
|
|
62
|
+
if (full) {
|
|
63
|
+
const probed = windowsOf(full, startedAt);
|
|
64
|
+
const aggregate = a.windows.some((w) => w.name == null) ? a.windows.filter((w) => w.name == null) : probed.filter((w) => w.name == null);
|
|
65
|
+
if (a.lastUsageAt == null) a.lastUsageAt = startedAt;
|
|
66
|
+
a.windows = mergeWindows([...aggregate, ...probed.filter((w) => w.name != null)], a.windows);
|
|
67
|
+
}
|
|
68
|
+
saveAccounts(claudePool, idx);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const current = loadAccounts(claudePool).accounts.find((a) => a.id === account.id) ?? account;
|
|
73
|
+
return teeObservation(current, snap);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function samplePool(accounts: Account[], liveId: string | null): Promise<Map<string, SampleReport>> {
|
|
77
|
+
const reports = new Map<string, SampleReport>();
|
|
78
|
+
const tee = loadUsageSnapshot();
|
|
79
|
+
const probeOne = async (a: Account): Promise<void> => {
|
|
80
|
+
const isActive = liveId != null && liveId === a.id;
|
|
81
|
+
const teeCurrent = tee != null && (a.lastUsageAt == null || tee.at >= a.lastUsageAt);
|
|
82
|
+
if (isActive && tee && teeCurrent && tee.state.account === a.id) {
|
|
83
|
+
const at = tee.state.sampledAt ?? tee.at;
|
|
84
|
+
a.lastUsageAt = at;
|
|
85
|
+
a.windows = mergeWindows(windowsOf({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, at), a.windows);
|
|
86
|
+
reports.set(a.id, { ok: true, source: "statusline" });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const outcome = isActive ? await probeActiveUsage(a) : await probeParkedUsage(a);
|
|
90
|
+
if (!outcome.ok) {
|
|
91
|
+
reports.set(a.id, { ok: false, reason: outcome.reason });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const at = Date.now();
|
|
95
|
+
a.lastUsageAt = at;
|
|
96
|
+
a.windows = mergeWindows(windowsOf(outcome.usage, at), a.windows);
|
|
97
|
+
reports.set(a.id, { ok: true, source: "probe" });
|
|
98
|
+
};
|
|
99
|
+
const active = accounts.find((a) => liveId != null && liveId === a.id) ?? null;
|
|
100
|
+
if (active) await probeOne(active);
|
|
101
|
+
try {
|
|
102
|
+
await ensureLiveTokenFresh();
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
await Promise.all(accounts.filter((a) => a !== active).map(probeOne));
|
|
106
|
+
return reports;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function liveOwner(): Promise<string | null> {
|
|
110
|
+
const live = await readItem(liveTarget());
|
|
111
|
+
if (live == null) return null;
|
|
112
|
+
const creds = CredentialBlobSchema.parse(JSON.parse(live)).claudeAiOauth;
|
|
113
|
+
return (await fetchTokenIdentity(creds.accessToken)).accountUuid;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function removeCredentials(a: Account): Promise<void> {
|
|
117
|
+
const setupTokens = loadSetupTokens();
|
|
118
|
+
const item = credItemFor(a.id);
|
|
119
|
+
await deleteItem(parkedTarget(item));
|
|
120
|
+
for (const sampleDir of [join(paths.sampleDir, item), join(paths.sampleDir, `${item}-tick`)]) {
|
|
121
|
+
await deleteItem(isolatedTarget(sampleDir));
|
|
122
|
+
rmSync(sampleDir, { recursive: true, force: true });
|
|
123
|
+
}
|
|
124
|
+
if (setupTokens.tokens.some((t) => t.accountUuid === a.id)) {
|
|
125
|
+
saveSetupTokens({ ...setupTokens, tokens: setupTokens.tokens.filter((t) => t.accountUuid !== a.id) });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function identityReady(cjPath: string): boolean {
|
|
130
|
+
if (!existsSync(cjPath)) return false;
|
|
131
|
+
try {
|
|
132
|
+
const oauthAccount = JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount;
|
|
133
|
+
return z.object({ accountUuid: z.string().min(1) }).safeParse(oauthAccount).success;
|
|
134
|
+
} catch {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function login(): Promise<Harvest | null> {
|
|
140
|
+
const onboardDir = paths.onboardDir;
|
|
141
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
142
|
+
mkdirSync(onboardDir, { recursive: true });
|
|
143
|
+
const iso = isolatedTarget(onboardDir);
|
|
144
|
+
await deleteItem(iso);
|
|
145
|
+
const cjPath = join(onboardDir, ".claude.json");
|
|
146
|
+
const real = resolveRealClaude();
|
|
147
|
+
|
|
148
|
+
const savedTermios = saveTermios();
|
|
149
|
+
const env: Record<string, string> = { ...process.env, CLAUDE_CONFIG_DIR: onboardDir, TOKENMAXXING_PROBE: "1", TOKENMAXXING_SUPERVISED: "" };
|
|
150
|
+
for (const key of CRED_ENV_OVERRIDES) delete env[key];
|
|
151
|
+
const p = Bun.spawn([real], {
|
|
152
|
+
stdin: "inherit",
|
|
153
|
+
stdout: "inherit",
|
|
154
|
+
stderr: "inherit",
|
|
155
|
+
env,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
let exited = false;
|
|
160
|
+
const onExit = p.exited.then(() => { exited = true; });
|
|
161
|
+
while (!exited) {
|
|
162
|
+
await Bun.sleep(400);
|
|
163
|
+
if (identityReady(cjPath) && (await readItem(iso))) {
|
|
164
|
+
p.kill();
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
await p.exited;
|
|
169
|
+
await onExit;
|
|
170
|
+
restoreTermios(savedTermios);
|
|
171
|
+
|
|
172
|
+
const blobRaw = await readItem(iso);
|
|
173
|
+
if (!blobRaw || !identityReady(cjPath)) {
|
|
174
|
+
console.error(c.red("no login detected in the isolated session - nothing changed."));
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let blob, oauthAccount;
|
|
179
|
+
try {
|
|
180
|
+
blob = CredentialBlobSchema.parse(JSON.parse(blobRaw));
|
|
181
|
+
oauthAccount = OAuthAccountSchema.parse(JSON.parse(readFileSync(cjPath, "utf8")).oauthAccount);
|
|
182
|
+
} catch {
|
|
183
|
+
console.error(c.red("could not parse the onboarded account's credential/identity."));
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
console.log(c.dim("sampling usage..."));
|
|
188
|
+
const sampled = await probeUsage(onboardDir);
|
|
189
|
+
if (!sampled) console.log(c.yellow("could not sample usage now - it will fill in on first use."));
|
|
190
|
+
const at = Date.now();
|
|
191
|
+
const id = oauthAccount.accountUuid;
|
|
192
|
+
return {
|
|
193
|
+
id,
|
|
194
|
+
email: oauthAccount.emailAddress,
|
|
195
|
+
tier: claudeTierLabel(blob.claudeAiOauth),
|
|
196
|
+
oauthAccount,
|
|
197
|
+
sample: sampled ? { windows: windowsOf(sampled, at), at } : null,
|
|
198
|
+
park: () => writeItem(parkedTarget(credItemFor(id)), claudeAiOauthOnly(blobRaw)),
|
|
199
|
+
};
|
|
200
|
+
} finally {
|
|
201
|
+
if (p.exitCode === null) {
|
|
202
|
+
p.kill();
|
|
203
|
+
await p.exited;
|
|
204
|
+
}
|
|
205
|
+
restoreTermios(savedTermios);
|
|
206
|
+
await deleteItem(iso);
|
|
207
|
+
rmSync(onboardDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function importLive(): Promise<Harvest | null> {
|
|
212
|
+
if (isApiKeyMode()) {
|
|
213
|
+
console.error(c.yellow("tokenmaxxing pools subscription accounts, but you're authed via API key / apiKeyHelper."));
|
|
214
|
+
console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} with a Pro/Max account first, then re-run ${c.cyan("tokenmaxxing init")}.`);
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const oauthAccount = readOAuthAccount();
|
|
219
|
+
const liveRaw = await readItem(liveTarget());
|
|
220
|
+
if (!oauthAccount || !liveRaw) {
|
|
221
|
+
console.error(c.red("no active Claude subscription login found (missing oauthAccount or credential)."));
|
|
222
|
+
console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let blob;
|
|
227
|
+
try {
|
|
228
|
+
blob = CredentialBlobSchema.parse(JSON.parse(liveRaw));
|
|
229
|
+
} catch {
|
|
230
|
+
console.error(c.red("the live credential is not a recognizable Claude OAuth blob."));
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let creds = blob.claudeAiOauth;
|
|
235
|
+
if (isDeadCredential(creds)) {
|
|
236
|
+
console.error(c.red("the live credential was cleared after a failed refresh."));
|
|
237
|
+
console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} first, then re-run ${c.cyan("tokenmaxxing init")}.`);
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
if (isAccessTokenExpiring(creds)) {
|
|
241
|
+
await withClaudeRefreshLock(async (lock) => {
|
|
242
|
+
const raw2 = await readItem(liveTarget());
|
|
243
|
+
if (raw2 == null) throw new Error("live credential vanished while waiting for the refresh lock");
|
|
244
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
245
|
+
creds = isAccessTokenExpiring(current) ? await refreshCredential(current) : current;
|
|
246
|
+
if (creds === current) return;
|
|
247
|
+
if (lock.compromised()) throw new Error("refresh lock compromised mid-refresh - discarding the live rewrite");
|
|
248
|
+
await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
const identity = await fetchTokenIdentity(creds.accessToken);
|
|
252
|
+
if (identity.accountUuid !== oauthAccount.accountUuid) {
|
|
253
|
+
console.error(c.red(`the live credential belongs to ${describeIdentity(identity)}, but ~/.claude.json identifies ${oauthAccount.emailAddress} - identity drift.`));
|
|
254
|
+
console.error(`Run ${c.cyan("claude")} → ${c.cyan("/login")} to realign them, then re-run ${c.cyan("tokenmaxxing init")}.`);
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const id = oauthAccount.accountUuid;
|
|
259
|
+
const parked = JSON.stringify({ claudeAiOauth: creds });
|
|
260
|
+
return {
|
|
261
|
+
id,
|
|
262
|
+
email: oauthAccount.emailAddress,
|
|
263
|
+
tier: claudeTierLabel(creds),
|
|
264
|
+
oauthAccount,
|
|
265
|
+
sample: null,
|
|
266
|
+
park: () => writeItem(parkedTarget(credItemFor(id)), parked),
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function ensurePathAhead(): void {
|
|
271
|
+
const rc = shellRcPath();
|
|
272
|
+
if (!rc) {
|
|
273
|
+
console.log(c.yellow(`⚠ add to your shell rc: export PATH="${paths.binDir}:$PATH"`));
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const outcome = ensurePathInRc(rc);
|
|
277
|
+
if (outcome === "added") console.log(`${c.green("✓")} added ${paths.binDir} to PATH in ${rc} - restart your shell (or \`source ${rc}\`)`);
|
|
278
|
+
else if (outcome === "skipped") {
|
|
279
|
+
const hint = managedShellRcSkipLines();
|
|
280
|
+
console.log(c.yellow(`⚠ ${hint.headline}`));
|
|
281
|
+
console.log(c.yellow(` ${hint.detail}`));
|
|
282
|
+
console.log(c.yellow(` ${hint.exportLine}`));
|
|
283
|
+
} else console.log(c.yellow(`⚠ PATH line already in ${rc} - restart your shell to pick it up`));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function install(): void {
|
|
287
|
+
const out = installSupervisor();
|
|
288
|
+
console.log(`${c.green("✓")} installed ${c.bold("claude")} supervisor + statusLine/Stop/SessionStart hooks`);
|
|
289
|
+
if (out.timerLoaded) console.log(`${c.green("✓")} periodic check timer active (every ${out.checkIntervalS}s)`);
|
|
290
|
+
else console.log(c.yellow(`⚠ check timer written but not activated - run: ${timerActivationHint()}`));
|
|
291
|
+
if (!out.pathAhead) {
|
|
292
|
+
console.log();
|
|
293
|
+
ensurePathAhead();
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const claude: Provider = {
|
|
298
|
+
name: "claude",
|
|
299
|
+
flag: "",
|
|
300
|
+
pool: claudePool,
|
|
301
|
+
waitsWhenDepleted: true,
|
|
302
|
+
switchMargin: 1,
|
|
303
|
+
switchNote: "",
|
|
304
|
+
liveId: () => readOAuthAccount()?.accountUuid ?? null,
|
|
305
|
+
liveOwner,
|
|
306
|
+
presentIds: () => new Set(),
|
|
307
|
+
gatedFamilies: (cfg) => gatedFamilies(loadUsage()?.model ?? null, cfg.policy.switchModels),
|
|
308
|
+
observeLive,
|
|
309
|
+
samplePool,
|
|
310
|
+
mergeWindows,
|
|
311
|
+
swap: performSwap,
|
|
312
|
+
classifySwapError: (e) => (e instanceof InvalidGrantError ? "dead-grant" : isSkippableSwapError(e) ? "skip" : "fatal"),
|
|
313
|
+
removeCredentials,
|
|
314
|
+
login,
|
|
315
|
+
importLive,
|
|
316
|
+
preflight: () => pinBinOverride({ key: "claudeBin", bin: resolveVerifiedClaude() }),
|
|
317
|
+
install,
|
|
318
|
+
loginStep: (who) => `In the session that opens, run ${c.bold("/login")} with ${who}. It closes itself once you're in.`,
|
|
319
|
+
windowLabel: (name) => name.toLowerCase(),
|
|
320
|
+
};
|