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
package/src/lib/state.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Config + accounts index + usage snapshot persistence. All writes atomic.
|
|
2
|
+
|
|
3
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
4
|
+
import { isEqual } from "es-toolkit";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { paths, realClaudeBinFromEnv } from "./paths.ts";
|
|
7
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
8
|
+
import {
|
|
9
|
+
AccountsIndexSchema,
|
|
10
|
+
ConfigSchema,
|
|
11
|
+
ModelUsageStateSchema,
|
|
12
|
+
UsageStateSchema,
|
|
13
|
+
type AccountsIndex,
|
|
14
|
+
type Config,
|
|
15
|
+
type ModelUsageState,
|
|
16
|
+
type UsageState,
|
|
17
|
+
} from "./types.ts";
|
|
18
|
+
|
|
19
|
+
// ---- config.json (minimal, fixed schema) ---------------------------------
|
|
20
|
+
|
|
21
|
+
const DEFAULT_CONFIG: Config = {
|
|
22
|
+
threshold: 95,
|
|
23
|
+
claudeBin: "",
|
|
24
|
+
policy: { projectionMargin: 0, switchModels: ["fable", "opus"], usagePollTtlMs: 90_000, maxWaitMs: 3_600_000 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** On-disk shape (all optional); validated via Zod, merged over defaults. */
|
|
28
|
+
const ConfigFileSchema = z
|
|
29
|
+
.object({
|
|
30
|
+
threshold: z.number(),
|
|
31
|
+
claudeBin: z.string(),
|
|
32
|
+
policy: z
|
|
33
|
+
.object({
|
|
34
|
+
projectionMargin: z.number(),
|
|
35
|
+
switchModels: z.array(z.string()),
|
|
36
|
+
usagePollTtlMs: z.number(),
|
|
37
|
+
maxWaitMs: z.number(),
|
|
38
|
+
})
|
|
39
|
+
.partial(),
|
|
40
|
+
})
|
|
41
|
+
.partial();
|
|
42
|
+
|
|
43
|
+
export function loadConfig(): Config {
|
|
44
|
+
const cfg: Config = { ...DEFAULT_CONFIG, policy: { ...DEFAULT_CONFIG.policy } };
|
|
45
|
+
if (existsSync(paths.configJson)) {
|
|
46
|
+
let raw: unknown = {};
|
|
47
|
+
try {
|
|
48
|
+
raw = JSON.parse(readFileSync(paths.configJson, "utf8"));
|
|
49
|
+
} catch {
|
|
50
|
+
raw = {};
|
|
51
|
+
}
|
|
52
|
+
const parsed = ConfigFileSchema.safeParse(raw);
|
|
53
|
+
const p = parsed.success ? parsed.data : {};
|
|
54
|
+
cfg.threshold = p.threshold ?? cfg.threshold;
|
|
55
|
+
cfg.claudeBin = p.claudeBin ?? cfg.claudeBin;
|
|
56
|
+
cfg.policy.projectionMargin = p.policy?.projectionMargin ?? cfg.policy.projectionMargin;
|
|
57
|
+
cfg.policy.usagePollTtlMs = p.policy?.usagePollTtlMs ?? cfg.policy.usagePollTtlMs;
|
|
58
|
+
cfg.policy.maxWaitMs = p.policy?.maxWaitMs ?? cfg.policy.maxWaitMs;
|
|
59
|
+
if (p.policy?.switchModels) {
|
|
60
|
+
cfg.policy.switchModels = p.policy.switchModels.map((s) => s.toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
// env override wins for the claude binary (tests / relocation)
|
|
64
|
+
const envBin = realClaudeBinFromEnv();
|
|
65
|
+
if (envBin) cfg.claudeBin = envBin;
|
|
66
|
+
return ConfigSchema.parse(cfg);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function saveConfig(c: Config): void {
|
|
70
|
+
writeFileAtomic(paths.configJson, JSON.stringify(ConfigSchema.parse(c), null, 2) + "\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- accounts.json -------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
const emptyIndex = (): AccountsIndex => ({ version: 1, activeAccountUuid: null, accounts: [] });
|
|
76
|
+
|
|
77
|
+
export function loadAccounts(): AccountsIndex {
|
|
78
|
+
if (!existsSync(paths.accountsJson)) return emptyIndex();
|
|
79
|
+
try {
|
|
80
|
+
const parsed = AccountsIndexSchema.safeParse(JSON.parse(readFileSync(paths.accountsJson, "utf8")));
|
|
81
|
+
return parsed.success ? parsed.data : emptyIndex();
|
|
82
|
+
} catch {
|
|
83
|
+
return emptyIndex();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function saveAccounts(idx: AccountsIndex): void {
|
|
88
|
+
writeFileAtomic(paths.accountsJson, JSON.stringify(AccountsIndexSchema.parse(idx), null, 2) + "\n");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ---- usage.json ----------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
export function loadUsage(): UsageState | null {
|
|
94
|
+
if (!existsSync(paths.usageJson)) return null;
|
|
95
|
+
try {
|
|
96
|
+
const parsed = UsageStateSchema.safeParse(JSON.parse(readFileSync(paths.usageJson, "utf8")));
|
|
97
|
+
return parsed.success ? parsed.data : null;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Write-on-change: skip the write (and its fsync) when only `ts` would differ. */
|
|
104
|
+
export function writeUsage(next: UsageState): boolean {
|
|
105
|
+
const prev = loadUsage();
|
|
106
|
+
if (prev && isEqual({ ...prev, ts: 0 }, { ...next, ts: 0 })) return false;
|
|
107
|
+
writeFileAtomic(paths.usageJson, JSON.stringify(next));
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- model-usage.json (per-model caps from `/usage`, TTL-cached) ----------
|
|
112
|
+
|
|
113
|
+
export function loadModelUsage(): ModelUsageState | null {
|
|
114
|
+
if (!existsSync(paths.modelUsageJson)) return null;
|
|
115
|
+
try {
|
|
116
|
+
const parsed = ModelUsageStateSchema.safeParse(JSON.parse(readFileSync(paths.modelUsageJson, "utf8")));
|
|
117
|
+
return parsed.success ? parsed.data : null;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function saveModelUsage(next: ModelUsageState): void {
|
|
124
|
+
writeFileAtomic(paths.modelUsageJson, JSON.stringify(next));
|
|
125
|
+
}
|
package/src/lib/swap.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The account-switch sequence. Runs under the tokenmaxxing flock (held by the
|
|
2
|
+
// caller - the Stop hook or a CLI command). The keychain/json writes additionally
|
|
3
|
+
// run under claude's own refresh lock so they can't interleave with a token refresh.
|
|
4
|
+
//
|
|
5
|
+
// refresh B (network, no lock)
|
|
6
|
+
// resolve the live credential's TRUE owner (network, no lock)
|
|
7
|
+
// ── under claude refresh lock ──
|
|
8
|
+
// harvest live → its OWNER's backup (mandatory: refresh token rotates in place)
|
|
9
|
+
// install B into the live item
|
|
10
|
+
// persist B's rotated token into B's backup
|
|
11
|
+
// rewrite oauthAccount in ~/.claude.json
|
|
12
|
+
// mark B active (inside the lock: a crash before this write leaves a stale
|
|
13
|
+
// active label, which is exactly what once made a harvest destroy a backup)
|
|
14
|
+
|
|
15
|
+
import { loadAccounts, saveAccounts } from "./state.ts";
|
|
16
|
+
import { readItem, writeItem, liveTarget, parkedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
17
|
+
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
18
|
+
import { swapOAuthAccount } from "./claudejson.ts";
|
|
19
|
+
import { withLock } from "./lock.ts";
|
|
20
|
+
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
21
|
+
import { paths } from "./paths.ts";
|
|
22
|
+
import { log } from "./log.ts";
|
|
23
|
+
import { pickBest, type PickCtx } from "./picker.ts";
|
|
24
|
+
import { CredentialBlobSchema, type Account, type OAuthCreds } from "./types.ts";
|
|
25
|
+
|
|
26
|
+
function parseBlob(raw: string) {
|
|
27
|
+
return CredentialBlobSchema.parse(JSON.parse(raw));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Mechanically switch the LIVE credential to `target`. Assumes the caller holds
|
|
32
|
+
* the tokenmaxxing flock. Throws InvalidGrantError (after marking needs_reauth)
|
|
33
|
+
* when target's refresh token is dead.
|
|
34
|
+
*/
|
|
35
|
+
export async function performSwap(target: Account): Promise<void> {
|
|
36
|
+
const idx = loadAccounts();
|
|
37
|
+
|
|
38
|
+
// 1. refresh target's parked credential (network) BEFORE taking claude's lock.
|
|
39
|
+
const parkedRaw = await readItem(parkedTarget(target.keychainItem));
|
|
40
|
+
if (!parkedRaw) throw new Error(`no parked credential for ${target.email}`);
|
|
41
|
+
let fresh: OAuthCreds;
|
|
42
|
+
try {
|
|
43
|
+
fresh = await refreshCredential(parseBlob(parkedRaw).claudeAiOauth);
|
|
44
|
+
} catch (e) {
|
|
45
|
+
if (e instanceof InvalidGrantError) {
|
|
46
|
+
const t = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
47
|
+
if (t) { t.needsReauth = true; saveAccounts(idx); }
|
|
48
|
+
log("swap.invalid_grant", { account: target.accountUuid.slice(0, 8) });
|
|
49
|
+
}
|
|
50
|
+
throw e;
|
|
51
|
+
}
|
|
52
|
+
// 2. resolve the live credential's TRUE owner - the harvest destination.
|
|
53
|
+
// activeAccountUuid is a label, and labels drift from the blob they describe
|
|
54
|
+
// (crash mid-swap, manual /login, historical re-init); harvesting by label is
|
|
55
|
+
// how a backup once got destroyed. The token itself cannot lie. A rotation
|
|
56
|
+
// between here and the harvest write keeps the same owner, so this can stay
|
|
57
|
+
// outside the (fast, local) critical section.
|
|
58
|
+
const preLive = await readItem(liveTarget());
|
|
59
|
+
let liveOwner: Account | null = null;
|
|
60
|
+
if (preLive) {
|
|
61
|
+
let liveCreds = parseBlob(preLive).claudeAiOauth;
|
|
62
|
+
let identifiable = true;
|
|
63
|
+
if (isAccessTokenExpiring(liveCreds, 60_000)) {
|
|
64
|
+
try {
|
|
65
|
+
liveCreds = await refreshCredential(liveCreds);
|
|
66
|
+
await writeItem(liveTarget(), mergeIntoLive(preLive, liveCreds));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
if (!(e instanceof InvalidGrantError)) throw e;
|
|
69
|
+
// dead credential family: nothing worth preserving, skip the harvest.
|
|
70
|
+
identifiable = false;
|
|
71
|
+
log("swap.harvest_skipped_dead_live", {});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (identifiable) {
|
|
75
|
+
const org = await fetchTokenOrg(liveCreds.accessToken);
|
|
76
|
+
liveOwner = idx.accounts.find((a) => a.organizationUuid === org.organization_uuid) ?? null;
|
|
77
|
+
if (!liveOwner) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`live credential belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)}), which is not in the pool - refusing to swap over it; import it first with \`tokenmaxxing add\``,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
if (liveOwner.accountUuid !== idx.activeAccountUuid) {
|
|
83
|
+
log("swap.harvest_drift", {
|
|
84
|
+
labeled: idx.activeAccountUuid?.slice(0, 8) ?? null,
|
|
85
|
+
actual: liveOwner.accountUuid.slice(0, 8),
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 3. the fast, local, atomic-vs-claude-refresh critical section.
|
|
92
|
+
await withClaudeRefreshLock(async () => {
|
|
93
|
+
const currentLive = await readItem(liveTarget());
|
|
94
|
+
|
|
95
|
+
// harvest the live claudeAiOauth into its OWNER's (small) backup item.
|
|
96
|
+
if (liveOwner && currentLive) {
|
|
97
|
+
await writeItem(parkedTarget(liveOwner.keychainItem), claudeAiOauthOnly(currentLive));
|
|
98
|
+
log("swap.harvest", { account: liveOwner.accountUuid.slice(0, 8) });
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// install B: merge B's fresh claudeAiOauth into the CURRENT live blob so all
|
|
102
|
+
// sibling state (MCP OAuth tokens, etc.) is preserved across the swap.
|
|
103
|
+
await writeItem(liveTarget(), mergeIntoLive(currentLive, fresh));
|
|
104
|
+
// persist B's rotated token into its small backup item.
|
|
105
|
+
await writeItem(parkedTarget(target.keychainItem), JSON.stringify({ claudeAiOauth: fresh }));
|
|
106
|
+
swapOAuthAccount(target.oauthAccount);
|
|
107
|
+
// record B as active INSIDE the critical section so a crash cannot leave the
|
|
108
|
+
// installed credential and the active label pointing at different accounts.
|
|
109
|
+
idx.activeAccountUuid = target.accountUuid;
|
|
110
|
+
const t2 = idx.accounts.find((a) => a.accountUuid === target.accountUuid);
|
|
111
|
+
if (t2) { t2.needsReauth = false; }
|
|
112
|
+
saveAccounts(idx);
|
|
113
|
+
});
|
|
114
|
+
log("swap.done", { account: target.accountUuid.slice(0, 8), email: target.email });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Pick the best candidate and swap to it, retrying past dead refresh tokens.
|
|
119
|
+
* Assumes the caller holds the flock (does NOT lock - avoids same-process
|
|
120
|
+
* flock self-deadlock). Returns the account landed on, or null if none usable.
|
|
121
|
+
*/
|
|
122
|
+
export async function chooseAndSwap(ctx: Omit<PickCtx, "currentAccountUuid">): Promise<Account | null> {
|
|
123
|
+
const tried = new Set<string>();
|
|
124
|
+
while (true) {
|
|
125
|
+
const idx = loadAccounts();
|
|
126
|
+
const candidates = idx.accounts.filter((a) => !tried.has(a.accountUuid));
|
|
127
|
+
const best = pickBest(candidates, { ...ctx, currentAccountUuid: idx.activeAccountUuid });
|
|
128
|
+
if (!best) return null;
|
|
129
|
+
tried.add(best.accountUuid);
|
|
130
|
+
try {
|
|
131
|
+
await performSwap(best);
|
|
132
|
+
return best;
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e instanceof InvalidGrantError) continue; // dead token - next candidate
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Standalone lock-taking variant for CLI/manual use (NEVER call under a held flock). */
|
|
141
|
+
export async function swapToBest(ctx: Omit<PickCtx, "currentAccountUuid">): Promise<Account | null> {
|
|
142
|
+
return withLock(paths.lockFile, () => chooseAndSwap(ctx));
|
|
143
|
+
}
|
package/src/lib/tty.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Save/restore the controlling terminal's line settings around a child that owns
|
|
2
|
+
// the tty via inherited stdio. If we SIGTERM such a child (supervisor respawn, or
|
|
3
|
+
// `add` auto-exit), the terminal can be left in raw mode; restoring stty fixes it.
|
|
4
|
+
|
|
5
|
+
export function saveTermios(): string | null {
|
|
6
|
+
const p = Bun.spawnSync(["/bin/sh", "-c", "stty -g </dev/tty"]);
|
|
7
|
+
const s = p.stdout?.toString().trim();
|
|
8
|
+
return s && p.exitCode === 0 ? s : null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function restoreTermios(saved: string | null): void {
|
|
12
|
+
const cmd = saved ? `stty ${saved} </dev/tty` : "stty sane </dev/tty";
|
|
13
|
+
Bun.spawnSync(["/bin/sh", "-c", cmd], { stdout: "ignore", stderr: "ignore" });
|
|
14
|
+
}
|
package/src/lib/types.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Shared data model - Zod schemas are the single source of truth; TS types are
|
|
2
|
+
// inferred from them. Everything that crosses an external boundary (keychain
|
|
3
|
+
// blob, ~/.claude.json, hook/statusLine stdin, OAuth response, our own state
|
|
4
|
+
// files) is validated through these instead of hand-checked.
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
/** OAuth object inside the keychain blob (`claudeAiOauth`). Loose: preserve any
|
|
9
|
+
* extra fields claude may add so a harvest→install round-trip is lossless. */
|
|
10
|
+
export const OAuthCredsSchema = z.looseObject({
|
|
11
|
+
accessToken: z.string(),
|
|
12
|
+
refreshToken: z.string(),
|
|
13
|
+
expiresAt: z.number(),
|
|
14
|
+
refreshTokenExpiresAt: z.number().optional(),
|
|
15
|
+
scopes: z.array(z.string()).default([]),
|
|
16
|
+
subscriptionType: z.string().optional(),
|
|
17
|
+
rateLimitTier: z.string().optional(),
|
|
18
|
+
});
|
|
19
|
+
export type OAuthCreds = z.infer<typeof OAuthCredsSchema>;
|
|
20
|
+
|
|
21
|
+
/** The `Claude Code-credentials` keychain item. Loose: the live item also holds
|
|
22
|
+
* sibling state (e.g. per-MCP-server OAuth tokens), which we must preserve when
|
|
23
|
+
* swapping - we only ever replace `claudeAiOauth`. */
|
|
24
|
+
export const CredentialBlobSchema = z.looseObject({ claudeAiOauth: OAuthCredsSchema });
|
|
25
|
+
export type CredentialBlob = z.infer<typeof CredentialBlobSchema>;
|
|
26
|
+
|
|
27
|
+
/** The `oauthAccount` identity object in ~/.claude.json. Loose: preserve every
|
|
28
|
+
* key so we can reinstall it verbatim on activation. Only the three ids are
|
|
29
|
+
* required; real blobs carry many more fields (some null), so descriptive ones
|
|
30
|
+
* are `.nullish()` (string | null | undefined) to tolerate them. */
|
|
31
|
+
export const OAuthAccountSchema = z.looseObject({
|
|
32
|
+
accountUuid: z.string(),
|
|
33
|
+
emailAddress: z.string(),
|
|
34
|
+
organizationUuid: z.string(),
|
|
35
|
+
organizationName: z.string().nullish(),
|
|
36
|
+
seatTier: z.string().nullish(),
|
|
37
|
+
billingType: z.string().nullish(),
|
|
38
|
+
displayName: z.string().nullish(),
|
|
39
|
+
});
|
|
40
|
+
export type OAuthAccount = z.infer<typeof OAuthAccountSchema>;
|
|
41
|
+
|
|
42
|
+
export const UsageWindowSchema = z.object({
|
|
43
|
+
usedPercentage: z.number(),
|
|
44
|
+
resetsAt: z.number().nullable(),
|
|
45
|
+
});
|
|
46
|
+
export type UsageWindow = z.infer<typeof UsageWindowSchema>;
|
|
47
|
+
|
|
48
|
+
export const UsageWindowsSchema = z.object({
|
|
49
|
+
fiveHour: UsageWindowSchema,
|
|
50
|
+
sevenDay: UsageWindowSchema,
|
|
51
|
+
});
|
|
52
|
+
export type UsageWindows = z.infer<typeof UsageWindowsSchema>;
|
|
53
|
+
|
|
54
|
+
export const ModelInfoSchema = z.object({ id: z.string(), display: z.string() });
|
|
55
|
+
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
|
56
|
+
|
|
57
|
+
/** usage.json - written by the statusLine shim, read by the Stop hook. Carries
|
|
58
|
+
* the two AGGREGATE windows (session=fiveHour, week-all-models=sevenDay) plus
|
|
59
|
+
* the active model. Per-model caps live in ModelUsageState (from `/usage`). */
|
|
60
|
+
export const UsageStateSchema = UsageWindowsSchema.extend({
|
|
61
|
+
org: z.string().nullable(),
|
|
62
|
+
ts: z.number(),
|
|
63
|
+
model: ModelInfoSchema.nullable().default(null),
|
|
64
|
+
});
|
|
65
|
+
export type UsageState = z.infer<typeof UsageStateSchema>;
|
|
66
|
+
|
|
67
|
+
/** model-usage.json - per-model weekly caps parsed from `claude -p '/usage'`,
|
|
68
|
+
* TTL-cached so we don't poll every turn. Keyed by model display name ("Fable"). */
|
|
69
|
+
export const ModelUsageStateSchema = z.object({
|
|
70
|
+
perModel: z.record(z.string(), UsageWindowSchema).default({}),
|
|
71
|
+
org: z.string().nullable(),
|
|
72
|
+
ts: z.number(),
|
|
73
|
+
});
|
|
74
|
+
export type ModelUsageState = z.infer<typeof ModelUsageStateSchema>;
|
|
75
|
+
|
|
76
|
+
/** A parked account in the pool (accounts.json - NON-secret). */
|
|
77
|
+
export const AccountSchema = z.object({
|
|
78
|
+
accountUuid: z.string(),
|
|
79
|
+
email: z.string(),
|
|
80
|
+
organizationUuid: z.string(),
|
|
81
|
+
label: z.string(),
|
|
82
|
+
keychainItem: z.string(),
|
|
83
|
+
oauthAccount: OAuthAccountSchema,
|
|
84
|
+
addedAt: z.string(),
|
|
85
|
+
lastUsage: UsageWindowsSchema.optional(),
|
|
86
|
+
lastPerModel: z.record(z.string(), UsageWindowSchema).optional(),
|
|
87
|
+
needsReauth: z.boolean().optional(),
|
|
88
|
+
subscriptionType: z.string().optional(),
|
|
89
|
+
});
|
|
90
|
+
export type Account = z.infer<typeof AccountSchema>;
|
|
91
|
+
|
|
92
|
+
export const AccountsIndexSchema = z.object({
|
|
93
|
+
version: z.literal(1),
|
|
94
|
+
activeAccountUuid: z.string().nullable(),
|
|
95
|
+
accounts: z.array(AccountSchema).default([]),
|
|
96
|
+
});
|
|
97
|
+
export type AccountsIndex = z.infer<typeof AccountsIndexSchema>;
|
|
98
|
+
|
|
99
|
+
export const ConfigSchema = z.object({
|
|
100
|
+
threshold: z.number(),
|
|
101
|
+
claudeBin: z.string(),
|
|
102
|
+
policy: z.object({
|
|
103
|
+
projectionMargin: z.number(),
|
|
104
|
+
/** models whose PER-MODEL weekly cap should trigger a switch (display names, lowercased). */
|
|
105
|
+
switchModels: z.array(z.string()),
|
|
106
|
+
/** how long a `/usage` per-model poll stays fresh before we re-poll (ms). */
|
|
107
|
+
usagePollTtlMs: z.number(),
|
|
108
|
+
/** when every account is depleted, auto-wait for a reset only if it is within this window (ms). */
|
|
109
|
+
maxWaitMs: z.number(),
|
|
110
|
+
}),
|
|
111
|
+
});
|
|
112
|
+
export type Config = z.infer<typeof ConfigSchema>;
|
|
113
|
+
|
|
114
|
+
/** The hook -> supervisor respawn marker at respawn/<session-id>. */
|
|
115
|
+
export const RespawnMarkerSchema = z.object({
|
|
116
|
+
account: z.string(),
|
|
117
|
+
ts: z.number(),
|
|
118
|
+
/** when set, the supervisor waits until this epoch ms before relaunching. */
|
|
119
|
+
waitUntil: z.number().optional(),
|
|
120
|
+
});
|
|
121
|
+
export type RespawnMarker = z.infer<typeof RespawnMarkerSchema>;
|
|
122
|
+
|
|
123
|
+
/** rate_limits + model as they appear in statusLine stdin (epoch-seconds resets). */
|
|
124
|
+
export const RateLimitsStdinSchema = z.looseObject({
|
|
125
|
+
rate_limits: z
|
|
126
|
+
.looseObject({
|
|
127
|
+
five_hour: z.looseObject({ used_percentage: z.number(), resets_at: z.number().nullable().optional() }).optional(),
|
|
128
|
+
seven_day: z.looseObject({ used_percentage: z.number(), resets_at: z.number().nullable().optional() }).optional(),
|
|
129
|
+
})
|
|
130
|
+
.optional(),
|
|
131
|
+
model: z.looseObject({ id: z.string().optional(), display_name: z.string().optional() }).optional(),
|
|
132
|
+
organizationUuid: z.string().optional(),
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/** Success body of the OAuth refresh grant. */
|
|
136
|
+
export const RefreshResponseSchema = z.looseObject({
|
|
137
|
+
access_token: z.string(),
|
|
138
|
+
refresh_token: z.string().optional(),
|
|
139
|
+
expires_in: z.number().optional(),
|
|
140
|
+
refresh_token_expires_in: z.number().optional(),
|
|
141
|
+
scope: z.string().optional(),
|
|
142
|
+
token_type: z.string().optional(),
|
|
143
|
+
});
|
|
144
|
+
export type RefreshResponse = z.infer<typeof RefreshResponseSchema>;
|
|
145
|
+
|
|
146
|
+
/** Success body of GET /api/oauth/claude_cli/roles - the org a token ACTUALLY
|
|
147
|
+
* belongs to, independent of any stored label. */
|
|
148
|
+
export const RolesResponseSchema = z.looseObject({
|
|
149
|
+
organization_uuid: z.string(),
|
|
150
|
+
organization_name: z.string(),
|
|
151
|
+
});
|
|
152
|
+
export type RolesResponse = z.infer<typeof RolesResponseSchema>;
|
package/src/lib/usage.ts
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// Usage data from two sources: statusLine stdin (pushed every turn, aggregate
|
|
2
|
+
// windows only, epoch resets) and `claude -p '/usage'` (free, 0 tokens, all
|
|
3
|
+
// three limit kinds). `/usage` is a client-side command that renders the same
|
|
4
|
+
// figures claude's own usage screen shows; we run it in a throwaway
|
|
5
|
+
// CLAUDE_CONFIG_DIR to sample a parked account without disturbing the live login.
|
|
6
|
+
|
|
7
|
+
import { delay } from "es-toolkit";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { resolveRealClaude } from "./claudebin.ts";
|
|
10
|
+
import { log } from "./log.ts";
|
|
11
|
+
import { RateLimitsStdinSchema, UsageWindowSchema, type ModelInfo, type UsageWindow, type UsageWindows } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
/** Normalize a resets_at value (epoch s, epoch ms, or ISO string) to epoch ms. */
|
|
14
|
+
export function normalizeResetsAt(v: unknown): number | null {
|
|
15
|
+
const num = z.number().finite().safeParse(v);
|
|
16
|
+
if (num.success) {
|
|
17
|
+
return num.data < 1e12 ? Math.round(num.data * 1000) : Math.round(num.data);
|
|
18
|
+
}
|
|
19
|
+
const str = z.string().safeParse(v);
|
|
20
|
+
if (str.success && str.data.trim() !== "") {
|
|
21
|
+
const n = Number(str.data);
|
|
22
|
+
if (Number.isFinite(n)) return normalizeResetsAt(n);
|
|
23
|
+
const t = Date.parse(str.data);
|
|
24
|
+
return Number.isFinite(t) ? t : null;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const win = (w?: { used_percentage: number; resets_at?: number | null }): UsageWindow => ({
|
|
30
|
+
usedPercentage: w?.used_percentage ?? 0,
|
|
31
|
+
resetsAt: normalizeResetsAt(w?.resets_at),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/** Extract the two AGGREGATE windows from statusLine stdin. null if absent. */
|
|
35
|
+
export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
|
|
36
|
+
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
37
|
+
if (!parsed.success) return null;
|
|
38
|
+
const rl = parsed.data.rate_limits;
|
|
39
|
+
if (!rl || (!rl.five_hour && !rl.seven_day)) return null;
|
|
40
|
+
return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Extract the active model from statusLine stdin. null if absent. */
|
|
44
|
+
export function parseStatusLineModel(obj: unknown): ModelInfo | null {
|
|
45
|
+
const parsed = RateLimitsStdinSchema.safeParse(obj);
|
|
46
|
+
if (!parsed.success) return null;
|
|
47
|
+
const m = parsed.data.model;
|
|
48
|
+
if (!m?.id && !m?.display_name) return null;
|
|
49
|
+
return { id: m?.id ?? m?.display_name ?? "", display: m?.display_name ?? m?.id ?? "" };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const FullUsageSchema = z.object({
|
|
53
|
+
session: UsageWindowSchema,
|
|
54
|
+
weekAll: UsageWindowSchema,
|
|
55
|
+
perModel: z.record(z.string(), UsageWindowSchema),
|
|
56
|
+
});
|
|
57
|
+
export type FullUsage = z.infer<typeof FullUsageSchema>;
|
|
58
|
+
|
|
59
|
+
// ---- `/usage` text parsing -----------------------------------------------
|
|
60
|
+
|
|
61
|
+
const MONTHS: Record<string, number> = {
|
|
62
|
+
jan: 0, feb: 1, mar: 2, apr: 3, may: 4, jun: 5, jul: 6, aug: 7, sep: 8, oct: 9, nov: 10, dec: 11,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** The tz offset (wall-clock minus UTC, in ms) in `tz` at instant `utcMs`. */
|
|
66
|
+
function tzOffsetMs(utcMs: number, tz: string): number {
|
|
67
|
+
const parts = new Intl.DateTimeFormat("en-US", {
|
|
68
|
+
timeZone: tz,
|
|
69
|
+
hourCycle: "h23",
|
|
70
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
71
|
+
hour: "2-digit", minute: "2-digit", second: "2-digit",
|
|
72
|
+
}).formatToParts(new Date(utcMs));
|
|
73
|
+
const p: Record<string, number> = {};
|
|
74
|
+
for (const part of parts) if (part.type !== "literal") p[part.type] = Number(part.value);
|
|
75
|
+
const asUTC = Date.UTC(p.year!, p.month! - 1, p.day!, p.hour!, p.minute!, p.second!);
|
|
76
|
+
return asUTC - utcMs;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Interpret a wall-clock time in `tz` as an epoch (DST-correct via one refine). */
|
|
80
|
+
function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min: number, tz: string): number {
|
|
81
|
+
const guess = Date.UTC(y, mon, day, hour, min);
|
|
82
|
+
const off1 = tzOffsetMs(guess, tz);
|
|
83
|
+
const off2 = tzOffsetMs(guess - off1, tz);
|
|
84
|
+
return guess - off2;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
|
|
89
|
+
* `Jul 9 at 11:20pm (Asia/Seoul)` to epoch ms. The text carries no year, so we
|
|
90
|
+
* pick the year whose resulting instant is nearest `now` (resets are always days
|
|
91
|
+
* away, so the correct year wins by ~360 days). Returns null if unparseable.
|
|
92
|
+
*/
|
|
93
|
+
export function parseResetClock(clock: string, now = Date.now()): number | null {
|
|
94
|
+
const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
const mon = MONTHS[m[1]!.slice(0, 3).toLowerCase()];
|
|
97
|
+
if (mon === undefined) return null;
|
|
98
|
+
const day = Number(m[2]);
|
|
99
|
+
let hour = Number(m[3]) % 12;
|
|
100
|
+
if (m[5]!.toLowerCase() === "p") hour += 12;
|
|
101
|
+
const min = m[4] ? Number(m[4]) : 0;
|
|
102
|
+
const tz = m[6]!.trim();
|
|
103
|
+
|
|
104
|
+
const baseYear = new Date(now).getUTCFullYear();
|
|
105
|
+
let best: number | null = null;
|
|
106
|
+
for (const y of [baseYear - 1, baseYear, baseYear + 1]) {
|
|
107
|
+
let epoch: number;
|
|
108
|
+
try {
|
|
109
|
+
epoch = zonedWallToEpoch(y, mon, day, hour, min, tz);
|
|
110
|
+
} catch {
|
|
111
|
+
return null; // invalid tz
|
|
112
|
+
}
|
|
113
|
+
if (best === null || Math.abs(epoch - now) < Math.abs(best - now)) best = epoch;
|
|
114
|
+
}
|
|
115
|
+
return best;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Parse `claude -p '/usage'` .result text into all three limit kinds:
|
|
120
|
+
* Current session: N% used · resets <clock> → session (5h)
|
|
121
|
+
* Current week (all models): N% used · resets … → weekAll (7d aggregate)
|
|
122
|
+
* Current week (<Model>): N% used · resets … → perModel[<Model>]
|
|
123
|
+
*/
|
|
124
|
+
export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
|
|
125
|
+
if (!text) return null;
|
|
126
|
+
// The reset clock is required in full (month day at h[:mm]am/pm (tz)) inside its
|
|
127
|
+
// optional group, so the lazy bridge is forced to find it when present yet the
|
|
128
|
+
// group cleanly skips a line that has no clock - and it never swallows a
|
|
129
|
+
// following "Current …" entry on a single line.
|
|
130
|
+
const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:[^\n]*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}\s+at\s+\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
|
|
131
|
+
let session: UsageWindow | null = null;
|
|
132
|
+
let weekAll: UsageWindow | null = null;
|
|
133
|
+
const perModel: Record<string, UsageWindow> = {};
|
|
134
|
+
for (let m = re.exec(text); m !== null; m = re.exec(text)) {
|
|
135
|
+
const window: UsageWindow = { usedPercentage: Number(m[3]), resetsAt: m[4] ? parseResetClock(m[4], now) : null };
|
|
136
|
+
if (m[1]!.toLowerCase() === "session") session = window;
|
|
137
|
+
else if (/^all models$/i.test(m[2]!.trim())) weekAll = window;
|
|
138
|
+
else perModel[m[2]!.trim()] = window;
|
|
139
|
+
}
|
|
140
|
+
if (!session && !weekAll && Object.keys(perModel).length === 0) return null;
|
|
141
|
+
return {
|
|
142
|
+
session: session ?? { usedPercentage: 0, resetsAt: null },
|
|
143
|
+
weekAll: weekAll ?? { usedPercentage: 0, resetsAt: null },
|
|
144
|
+
perModel,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Aggregate windows only (session→5h, week-all→7d), for the cold-start fallback. */
|
|
149
|
+
export function parseUsageText(text: string, now = Date.now()): UsageWindows | null {
|
|
150
|
+
const f = parseUsageTextFull(text, now);
|
|
151
|
+
if (!f) return null;
|
|
152
|
+
return { fiveHour: f.session, sevenDay: f.weekAll };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Env-var identity/credential overrides the claude binary honors BEFORE its
|
|
156
|
+
* keychain lookup (verified 2.1.205). A probe MUST scrub every one of these or
|
|
157
|
+
* an ambient value silently meters the wrong account. */
|
|
158
|
+
const CRED_ENV_OVERRIDES = [
|
|
159
|
+
"ANTHROPIC_API_KEY",
|
|
160
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
161
|
+
"CLAUDE_CODE_OAUTH_TOKEN",
|
|
162
|
+
"CLAUDE_CODE_SUBSCRIPTION_TYPE",
|
|
163
|
+
"CLAUDE_CODE_RATE_LIMIT_TIER",
|
|
164
|
+
"CLAUDE_SECURESTORAGE_CONFIG_DIR",
|
|
165
|
+
"CLAUDE_CODE_USE_BEDROCK",
|
|
166
|
+
"CLAUDE_CODE_USE_VERTEX",
|
|
167
|
+
] as const;
|
|
168
|
+
|
|
169
|
+
/** One `claude -p '/usage'` invocation → parsed usage, or null if it produced no
|
|
170
|
+
* limit lines (claude prints only a local-stats footer when its own usage fetch
|
|
171
|
+
* errors/throttles) or failed to run. */
|
|
172
|
+
async function probeUsageOnce(env: Record<string, string>, now: number): Promise<FullUsage | null> {
|
|
173
|
+
let out: string;
|
|
174
|
+
try {
|
|
175
|
+
const p = Bun.spawn([resolveRealClaude(), "-p", "/usage", "--output-format", "json"], {
|
|
176
|
+
env,
|
|
177
|
+
stdout: "pipe",
|
|
178
|
+
stderr: "pipe",
|
|
179
|
+
});
|
|
180
|
+
out = await new Response(p.stdout).text();
|
|
181
|
+
const errText = await new Response(p.stderr).text();
|
|
182
|
+
await p.exited;
|
|
183
|
+
if (p.exitCode !== 0) {
|
|
184
|
+
log("usage.probe_failed", { exit: p.exitCode ?? "signal", stderr: errText.trim().slice(0, 200) });
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
} catch (e) {
|
|
188
|
+
log("usage.probe_failed", { err: String((e as Error).message ?? e) });
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
|
|
193
|
+
const full = parseUsageTextFull(j.success ? j.data.result : out, now);
|
|
194
|
+
if (!full) log("usage.probe_unparsed", { sample: out.trim().slice(0, 120) });
|
|
195
|
+
return full;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Run `claude -p '/usage'` (free, 0 tokens) and parse all three limit kinds.
|
|
200
|
+
* Pass `configDir` to sample a specific account (its CLAUDE_CONFIG_DIR); omit to
|
|
201
|
+
* sample the live account. All ambient credential overrides are scrubbed so the
|
|
202
|
+
* probe meters exactly the OAuth credential in the (possibly namespaced)
|
|
203
|
+
* keychain item. The empty-footer case (claude's own usage call throttled) is
|
|
204
|
+
* transient, so retry it a couple of times. Returns null if it never yields data.
|
|
205
|
+
*/
|
|
206
|
+
export async function probeUsage(configDir?: string, now = Date.now()): Promise<FullUsage | null> {
|
|
207
|
+
const env: Record<string, string> = { ...process.env, TOKENMAXXING_PROBE: "1" };
|
|
208
|
+
for (const k of CRED_ENV_OVERRIDES) delete env[k];
|
|
209
|
+
if (configDir) env.CLAUDE_CONFIG_DIR = configDir;
|
|
210
|
+
|
|
211
|
+
for (let attempt = 0; ; attempt++) {
|
|
212
|
+
const full = await probeUsageOnce(env, now);
|
|
213
|
+
if (full || attempt >= 2) return full;
|
|
214
|
+
await delay(1500);
|
|
215
|
+
}
|
|
216
|
+
}
|