tokenmaxxing 0.2.0 → 0.3.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 -3
- package/README.md +4 -6
- package/package.json +5 -10
- package/src/cli/add.ts +129 -0
- package/src/cli/doctor.ts +80 -0
- package/src/cli/init.ts +124 -0
- package/src/cli/ls.ts +24 -0
- package/src/cli/rename.ts +33 -0
- package/src/cli/render.ts +36 -0
- package/src/cli/rm.ts +28 -0
- package/src/cli/status.ts +99 -0
- package/src/cli/switch.ts +61 -0
- package/src/entries/sessionstart.ts +39 -0
- package/src/entries/statusline.ts +52 -0
- package/src/entries/stophook.ts +48 -0
- package/src/entries/supervisor.ts +202 -0
- package/src/lib/atomic.ts +24 -0
- package/src/lib/claudebin.ts +21 -0
- package/src/lib/claudejson.ts +40 -0
- package/src/lib/claudelock.ts +54 -0
- package/src/lib/credstore.ts +97 -0
- package/src/lib/decide.ts +149 -0
- package/src/lib/http.ts +19 -0
- package/src/lib/install.ts +87 -0
- package/src/lib/keychain.ts +84 -0
- package/src/lib/lock.ts +78 -0
- package/src/lib/log.ts +28 -0
- package/src/lib/oauth.ts +117 -0
- package/src/lib/paths.ts +90 -0
- package/src/lib/picker.ts +79 -0
- package/src/lib/sample.ts +144 -0
- package/src/lib/sessions.ts +27 -0
- package/src/lib/settings.ts +141 -0
- package/src/lib/state.ts +125 -0
- package/src/lib/swap.ts +143 -0
- package/src/lib/tty.ts +14 -0
- package/src/lib/types.ts +152 -0
- package/src/lib/usage.ts +216 -0
- package/src/main.ts +82 -0
- package/dist/tokenmaxxing +0 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// Choose the best account to switch TO when the active one crosses threshold.
|
|
2
|
+
// Policy: exclude the current account and any that need reauth or are still
|
|
3
|
+
// rate-limited (usage >= threshold and not yet past resets_at). Among the rest,
|
|
4
|
+
// prefer the account whose weekly window expires soonest: weekly limits reset
|
|
5
|
+
// at a fixed per-account time and unused allowance is forfeited at reset, so
|
|
6
|
+
// quota nearest its reset is use-it-or-lose-it and should be drained first.
|
|
7
|
+
// Tiebreak on lowest 7-day usage, then soonest 5h reset.
|
|
8
|
+
|
|
9
|
+
import { minBy, sortBy } from "es-toolkit";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { AccountSchema, type Account } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const PickCtxSchema = z.object({
|
|
14
|
+
now: z.number(),
|
|
15
|
+
threshold: z.number(),
|
|
16
|
+
currentAccountUuid: z.string().nullable(),
|
|
17
|
+
});
|
|
18
|
+
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
19
|
+
|
|
20
|
+
/** An account is "exhausted" if a window is >= threshold and hasn't reset yet. */
|
|
21
|
+
export function isExhausted(a: Account, ctx: PickCtx): boolean {
|
|
22
|
+
const u = a.lastUsage;
|
|
23
|
+
if (!u) return false;
|
|
24
|
+
const blocked = (w: { usedPercentage: number; resetsAt: number | null }) =>
|
|
25
|
+
w.usedPercentage >= ctx.threshold && (w.resetsAt == null || w.resetsAt > ctx.now);
|
|
26
|
+
return blocked(u.fiveHour) || blocked(u.sevenDay);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
/** Epoch ms when the account's weekly quota is next forfeited. The weekly reset
|
|
32
|
+
* is a fixed per-account anchor, so a stale (past) resetsAt extrapolates
|
|
33
|
+
* forward in 7-day steps; an account with no sampled reset sorts last. */
|
|
34
|
+
export function weeklyExpiry(a: Account, now: number): number {
|
|
35
|
+
const r = a.lastUsage?.sevenDay.resetsAt;
|
|
36
|
+
if (r == null) return Number.POSITIVE_INFINITY;
|
|
37
|
+
if (r > now) return r;
|
|
38
|
+
return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
42
|
+
const candidates = accounts.filter(
|
|
43
|
+
(a) =>
|
|
44
|
+
a.accountUuid !== ctx.currentAccountUuid &&
|
|
45
|
+
!a.needsReauth &&
|
|
46
|
+
!isExhausted(a, ctx),
|
|
47
|
+
);
|
|
48
|
+
if (candidates.length === 0) return null;
|
|
49
|
+
|
|
50
|
+
// soonest weekly expiry first; tiebreak lowest 7-day usage, then soonest 5h reset.
|
|
51
|
+
return sortBy(candidates, [
|
|
52
|
+
(a) => weeklyExpiry(a, ctx.now),
|
|
53
|
+
(a) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
|
|
54
|
+
(a) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
|
|
55
|
+
])[0]!;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** When an account becomes usable again: the latest reset among its over-threshold
|
|
59
|
+
* windows (all must reset), or `now` if nothing is over. */
|
|
60
|
+
export function usableAt(a: Account, threshold: number, now: number): number {
|
|
61
|
+
const u = a.lastUsage;
|
|
62
|
+
if (!u) return now;
|
|
63
|
+
const blocking = [u.fiveHour, u.sevenDay]
|
|
64
|
+
.filter((w) => w.usedPercentage >= threshold && w.resetsAt != null)
|
|
65
|
+
.map((w) => w.resetsAt as number);
|
|
66
|
+
return blocking.length ? Math.max(...blocking) : now;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const EarliestResetSchema = z.object({ account: AccountSchema, availableAt: z.number() });
|
|
70
|
+
export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
71
|
+
|
|
72
|
+
/** For the all-depleted case: the account (not current, not reauth) that becomes
|
|
73
|
+
* usable soonest. */
|
|
74
|
+
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
75
|
+
const mapped = accounts
|
|
76
|
+
.filter((a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
|
|
77
|
+
.map((a) => ({ account: a, availableAt: usableAt(a, ctx.threshold, ctx.now) }));
|
|
78
|
+
return minBy(mapped, (x) => x.availableAt) ?? null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Live-sample a PARKED account's `/usage` without disturbing the live login.
|
|
2
|
+
// `/usage` is free (0 tokens), so `status` can show every account's real usage.
|
|
3
|
+
// We install the account's parked credential into a throwaway CLAUDE_CONFIG_DIR
|
|
4
|
+
// (the isolated credential claude reads for that dir), run `claude -p /usage`
|
|
5
|
+
// against it, then tear the item down.
|
|
6
|
+
//
|
|
7
|
+
// Contamination guards (the incident that motivated these):
|
|
8
|
+
// 1. probeUsage scrubs every ambient credential-override env var, so an
|
|
9
|
+
// inherited CLAUDE_CODE_OAUTH_TOKEN can't hijack the isolated probe.
|
|
10
|
+
// 2. before trusting a backup we verify (roles endpoint) that its token really
|
|
11
|
+
// belongs to this account - a mislabeled backup (drifted harvest) surfaces
|
|
12
|
+
// as an explicit error, never as another account's bars.
|
|
13
|
+
//
|
|
14
|
+
// Refresh tokens rotate single-use, so the one hazard is a rotation we fail to
|
|
15
|
+
// capture. Two guards: refresh an expiring token OURSELVES up front, and
|
|
16
|
+
// capture-before-delete the isolated item in case claude rotated it anyway.
|
|
17
|
+
//
|
|
18
|
+
// The caller MUST hold the tokenmaxxing flock so a parked refresh cannot collide
|
|
19
|
+
// with an in-flight performSwap refreshing the same account.
|
|
20
|
+
|
|
21
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { z } from "zod";
|
|
24
|
+
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
25
|
+
import { credItemFor, paths } from "./paths.ts";
|
|
26
|
+
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
27
|
+
import { refreshCredential, isAccessTokenExpiring, fetchTokenOrg, InvalidGrantError } from "./oauth.ts";
|
|
28
|
+
import { FullUsageSchema, probeUsage } from "./usage.ts";
|
|
29
|
+
import { CredentialBlobSchema, type Account, type OAuthCreds, type RolesResponse } from "./types.ts";
|
|
30
|
+
|
|
31
|
+
/** Result of a live sample: the fresh usage, or why it could not be taken. */
|
|
32
|
+
export const SampleOutcomeSchema = z.discriminatedUnion("ok", [
|
|
33
|
+
z.object({ ok: z.literal(true), usage: FullUsageSchema }),
|
|
34
|
+
z.object({ ok: z.literal(false), reason: z.string() }),
|
|
35
|
+
]);
|
|
36
|
+
export type SampleOutcome = z.infer<typeof SampleOutcomeSchema>;
|
|
37
|
+
|
|
38
|
+
/** Verify `creds` belongs to `account`; null on match, else the mismatch reason. */
|
|
39
|
+
async function identityMismatch(creds: OAuthCreds, account: Account): Promise<string | null> {
|
|
40
|
+
let org: RolesResponse;
|
|
41
|
+
try {
|
|
42
|
+
org = await fetchTokenOrg(creds.accessToken);
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return `credential identity check failed: ${String((e as Error).message ?? e)}`;
|
|
45
|
+
}
|
|
46
|
+
if (org.organization_uuid === account.organizationUuid) return null;
|
|
47
|
+
return `credential actually belongs to ${org.organization_name} (org ${org.organization_uuid.slice(0, 8)})`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Live-sample `account`'s `/usage` in isolation. On a dead refresh token or a
|
|
52
|
+
* mislabeled credential it sets `account.needsReauth` in place (the caller
|
|
53
|
+
* persists accounts.json). Mutates only the passed object and keychain items.
|
|
54
|
+
*/
|
|
55
|
+
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
56
|
+
const backup = parkedTarget(account.keychainItem);
|
|
57
|
+
const parkedRaw = await readItem(backup);
|
|
58
|
+
if (!parkedRaw) return { ok: false, reason: "no parked credential - re-add with `tokenmaxxing add`" };
|
|
59
|
+
|
|
60
|
+
let creds: OAuthCreds;
|
|
61
|
+
try {
|
|
62
|
+
creds = CredentialBlobSchema.parse(JSON.parse(parkedRaw)).claudeAiOauth;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
return { ok: false, reason: `parked credential unreadable (${String((e as Error).message ?? e).slice(0, 80)}) - re-add with \`tokenmaxxing add\`` };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Hand claude a token with comfortable headroom so it won't run its own refresh
|
|
68
|
+
// (which claude does within 120s of expiry). Refresh + persist ourselves first.
|
|
69
|
+
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
70
|
+
try {
|
|
71
|
+
creds = await refreshCredential(creds);
|
|
72
|
+
await writeItem(backup, JSON.stringify({ claudeAiOauth: creds }));
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof InvalidGrantError) {
|
|
75
|
+
account.needsReauth = true;
|
|
76
|
+
return { ok: false, reason: "refresh token dead - re-auth with `tokenmaxxing add`" };
|
|
77
|
+
}
|
|
78
|
+
return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const mismatch = await identityMismatch(creds, account);
|
|
83
|
+
if (mismatch) {
|
|
84
|
+
account.needsReauth = true;
|
|
85
|
+
return { ok: false, reason: `${mismatch} - this account's own credential is gone; re-auth with \`tokenmaxxing add\`` };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const dir = join(paths.sampleDir, credItemFor(account.accountUuid));
|
|
89
|
+
rmSync(dir, { recursive: true, force: true });
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
const isoTarget = isolatedTarget(dir);
|
|
92
|
+
const installed = JSON.stringify({ claudeAiOauth: creds });
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await writeItem(isoTarget, installed);
|
|
96
|
+
writeFileSync(join(dir, ".claude.json"), JSON.stringify({ oauthAccount: account.oauthAccount, hasCompletedOnboarding: true }));
|
|
97
|
+
const usage = await probeUsage(dir);
|
|
98
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
99
|
+
} finally {
|
|
100
|
+
// capture-before-delete: never discard a rotation claude may have performed.
|
|
101
|
+
const afterIso = await readItem(isoTarget);
|
|
102
|
+
if (afterIso && afterIso !== installed) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
103
|
+
await deleteItem(isoTarget);
|
|
104
|
+
rmSync(dir, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Live-sample the ACTIVE account off the live login, verifying the live
|
|
110
|
+
* credential belongs to it. `/usage` with no CLAUDE_CONFIG_DIR meters the live
|
|
111
|
+
* keychain item. A drifted active label surfaces as an error, not another
|
|
112
|
+
* account's bars.
|
|
113
|
+
*/
|
|
114
|
+
export async function probeActiveUsage(account: Account): Promise<SampleOutcome> {
|
|
115
|
+
const liveRaw = await readItem(liveTarget());
|
|
116
|
+
if (!liveRaw) return { ok: false, reason: "no live credential - run `claude` and `/login`" };
|
|
117
|
+
let creds: OAuthCreds;
|
|
118
|
+
try {
|
|
119
|
+
creds = CredentialBlobSchema.parse(JSON.parse(liveRaw)).claudeAiOauth;
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { ok: false, reason: `live credential blob unreadable (${String((e as Error).message ?? e).slice(0, 80)})` };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// A running claude keeps the live token fresh; after long idle it may not have.
|
|
125
|
+
if (isAccessTokenExpiring(creds, 300_000)) {
|
|
126
|
+
try {
|
|
127
|
+
await withClaudeRefreshLock(async () => {
|
|
128
|
+
const raw2 = (await readItem(liveTarget())) ?? liveRaw;
|
|
129
|
+
const current = CredentialBlobSchema.parse(JSON.parse(raw2)).claudeAiOauth;
|
|
130
|
+
creds = isAccessTokenExpiring(current, 300_000) ? await refreshCredential(current) : current;
|
|
131
|
+
if (creds !== current) await writeItem(liveTarget(), mergeIntoLive(raw2, creds));
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e instanceof InvalidGrantError) return { ok: false, reason: "live refresh token dead - run `claude` and `/login`" };
|
|
135
|
+
return { ok: false, reason: `token refresh failed: ${String((e as Error).message ?? e)}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const mismatch = await identityMismatch(creds, account);
|
|
140
|
+
if (mismatch) return { ok: false, reason: `live ${mismatch} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
141
|
+
|
|
142
|
+
const usage = await probeUsage();
|
|
143
|
+
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|
|
144
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Persist the flags a managed session was launched with, so any later relaunch
|
|
2
|
+
// of that session id (a fresh supervisor invocation, or the depleted-pool
|
|
3
|
+
// recovery in #20) re-applies them instead of dropping --dangerously-skip-
|
|
4
|
+
// permissions / --model / etc.
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { paths } from "./paths.ts";
|
|
10
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
11
|
+
|
|
12
|
+
const SessionSchema = z.object({ flags: z.array(z.string()), cwd: z.string() });
|
|
13
|
+
|
|
14
|
+
function sessionFile(sid: string): string {
|
|
15
|
+
return join(paths.home, "sessions", `${sid}.json`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function saveSessionFlags(sid: string, flags: string[], cwd: string): void {
|
|
19
|
+
mkdirSync(join(paths.home, "sessions"), { recursive: true });
|
|
20
|
+
writeFileAtomic(sessionFile(sid), JSON.stringify({ flags, cwd }));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadSessionFlags(sid: string): string[] | null {
|
|
24
|
+
const f = sessionFile(sid);
|
|
25
|
+
if (!existsSync(f)) return null;
|
|
26
|
+
return SessionSchema.parse(JSON.parse(readFileSync(f, "utf8"))).flags;
|
|
27
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Idempotent merge of tokenmaxxing's three entries into the user-owned
|
|
2
|
+
// ~/.claude/settings.json: a statusLine shim, a Stop hook, a SessionStart hook.
|
|
3
|
+
// We APPEND to existing hook arrays and WRAP the existing statusLine - never clobber.
|
|
4
|
+
|
|
5
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import { paths } from "./paths.ts";
|
|
9
|
+
import { writeFileAtomic } from "./atomic.ts";
|
|
10
|
+
|
|
11
|
+
/** Absolute path to the installed tokenmaxxing binary the settings entries call. */
|
|
12
|
+
export function installedBin(): string {
|
|
13
|
+
return join(paths.binDir, "tokenmaxxing");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const HookCmdSchema = z.looseObject({ type: z.string(), command: z.string() });
|
|
17
|
+
const HookGroupSchema = z.looseObject({ matcher: z.string().optional(), hooks: z.array(HookCmdSchema).default([]) });
|
|
18
|
+
const StatusLineSchema = z.looseObject({ type: z.string(), command: z.string() });
|
|
19
|
+
const SettingsSchema = z.looseObject({
|
|
20
|
+
statusLine: StatusLineSchema.optional(),
|
|
21
|
+
hooks: z.record(z.string(), z.array(HookGroupSchema)).optional(),
|
|
22
|
+
});
|
|
23
|
+
type Settings = z.infer<typeof SettingsSchema>;
|
|
24
|
+
type HookGroup = z.infer<typeof HookGroupSchema>;
|
|
25
|
+
|
|
26
|
+
const PRIOR_STATUSLINE_FILE = join(paths.home, "prior-statusline.json");
|
|
27
|
+
|
|
28
|
+
const SUBCMD = {
|
|
29
|
+
statusline: "__statusline",
|
|
30
|
+
stop: "__stop-hook",
|
|
31
|
+
sessionStart: "__session-start",
|
|
32
|
+
} as const;
|
|
33
|
+
|
|
34
|
+
function readSettings(): Settings {
|
|
35
|
+
if (!existsSync(paths.claudeSettings)) return {};
|
|
36
|
+
return SettingsSchema.parse(JSON.parse(readFileSync(paths.claudeSettings, "utf8")));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function writeSettings(s: Settings): void {
|
|
40
|
+
writeFileAtomic(paths.claudeSettings, JSON.stringify(s, null, 2) + "\n", 0o644);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True if a hook/statusline command string is one tokenmaxxing installed. */
|
|
44
|
+
export function isOurCommand(cmd: string | undefined): boolean {
|
|
45
|
+
if (!cmd) return false;
|
|
46
|
+
return (
|
|
47
|
+
cmd.includes(SUBCMD.statusline) ||
|
|
48
|
+
cmd.includes(SUBCMD.stop) ||
|
|
49
|
+
cmd.includes(SUBCMD.sessionStart) ||
|
|
50
|
+
// also match the installed bin path even if the subcommand text changes
|
|
51
|
+
cmd.includes(installedBin())
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function ourHookGroup(sub: string): HookGroup {
|
|
56
|
+
return { hooks: [{ type: "command", command: `${JSON.stringify(installedBin())} ${sub}` }] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function appendHook(s: Settings, event: string, sub: string): void {
|
|
60
|
+
s.hooks ??= {};
|
|
61
|
+
s.hooks[event] ??= [];
|
|
62
|
+
const arr = s.hooks[event]!;
|
|
63
|
+
const present = arr.some((g) => g.hooks?.some((h) => h.command?.includes(sub)));
|
|
64
|
+
if (!present) arr.push(ourHookGroup(sub));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function removeHook(s: Settings, event: string, sub: string): void {
|
|
68
|
+
const arr = s.hooks?.[event];
|
|
69
|
+
if (!arr) return;
|
|
70
|
+
s.hooks![event] = arr.filter((g) => !g.hooks?.some((h) => h.command?.includes(sub)));
|
|
71
|
+
if (s.hooks![event]!.length === 0) delete s.hooks![event];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const InstallResultSchema = z.object({ priorStatusLine: z.string().nullable() });
|
|
75
|
+
export type InstallResult = z.infer<typeof InstallResultSchema>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Install the three entries. Returns the prior statusLine command that was
|
|
79
|
+
* wrapped (stored to disk so the shim can chain to it and uninstall can restore).
|
|
80
|
+
*/
|
|
81
|
+
export function installSettings(): InstallResult {
|
|
82
|
+
const s = readSettings();
|
|
83
|
+
|
|
84
|
+
// ---- statusLine: capture prior (unless it's already ours), then wrap.
|
|
85
|
+
let prior: string | null;
|
|
86
|
+
if (s.statusLine && !isOurCommand(s.statusLine.command)) {
|
|
87
|
+
prior = s.statusLine.command;
|
|
88
|
+
writeFileAtomic(PRIOR_STATUSLINE_FILE, JSON.stringify({ command: prior }) + "\n", 0o644);
|
|
89
|
+
} else {
|
|
90
|
+
prior = readPriorStatusLine();
|
|
91
|
+
}
|
|
92
|
+
s.statusLine = {
|
|
93
|
+
type: "command",
|
|
94
|
+
command: `${JSON.stringify(installedBin())} ${SUBCMD.statusline}`,
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ---- hooks: append ours if absent.
|
|
98
|
+
appendHook(s, "Stop", SUBCMD.stop);
|
|
99
|
+
appendHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
100
|
+
|
|
101
|
+
writeSettings(s);
|
|
102
|
+
return { priorStatusLine: prior };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Remove our three entries and restore the prior statusLine if we have it. */
|
|
106
|
+
export function uninstallSettings(): void {
|
|
107
|
+
const s = readSettings();
|
|
108
|
+
removeHook(s, "Stop", SUBCMD.stop);
|
|
109
|
+
removeHook(s, "SessionStart", SUBCMD.sessionStart);
|
|
110
|
+
if (s.statusLine && isOurCommand(s.statusLine.command)) {
|
|
111
|
+
const prior = readPriorStatusLine();
|
|
112
|
+
if (prior) s.statusLine = { type: "command", command: prior };
|
|
113
|
+
else delete s.statusLine;
|
|
114
|
+
}
|
|
115
|
+
writeSettings(s);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const PriorStatusLineSchema = z.object({ command: z.string() });
|
|
119
|
+
|
|
120
|
+
export function readPriorStatusLine(): string | null {
|
|
121
|
+
if (!existsSync(PRIOR_STATUSLINE_FILE)) return null;
|
|
122
|
+
return PriorStatusLineSchema.parse(JSON.parse(readFileSync(PRIOR_STATUSLINE_FILE, "utf8"))).command;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const SettingsCheckSchema = z.object({
|
|
126
|
+
statusLineOk: z.boolean(),
|
|
127
|
+
stopOk: z.boolean(),
|
|
128
|
+
sessionStartOk: z.boolean(),
|
|
129
|
+
});
|
|
130
|
+
export type SettingsCheck = z.infer<typeof SettingsCheckSchema>;
|
|
131
|
+
|
|
132
|
+
export function checkSettings(): SettingsCheck {
|
|
133
|
+
const s = readSettings();
|
|
134
|
+
const has = (event: string, sub: string) =>
|
|
135
|
+
!!s.hooks?.[event]?.some((g) => g.hooks?.some((h) => h.command?.includes(sub)));
|
|
136
|
+
return {
|
|
137
|
+
statusLineOk: isOurCommand(s.statusLine?.command),
|
|
138
|
+
stopOk: has("Stop", SUBCMD.stop),
|
|
139
|
+
sessionStartOk: has("SessionStart", SUBCMD.sessionStart),
|
|
140
|
+
};
|
|
141
|
+
}
|
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
|
+
}
|