tokenmaxxing 1.34.0 → 1.38.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 +1 -1
- 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/doctor.ts +13 -13
- package/src/cli/init.ts +29 -126
- package/src/cli/rename.ts +11 -45
- package/src/cli/render.ts +0 -7
- package/src/cli/rm.ts +24 -39
- package/src/cli/setuptoken.ts +16 -16
- package/src/cli/status.ts +95 -238
- package/src/cli/switch.ts +54 -46
- package/src/entries/codexstophook.ts +23 -23
- package/src/entries/codexsupervisor.ts +3 -3
- package/src/entries/sessionstart.ts +5 -3
- package/src/entries/statusline.ts +17 -19
- package/src/entries/stopfailurehook.ts +9 -7
- package/src/entries/stophook.ts +5 -4
- package/src/entries/subagentstatusline.ts +2 -7
- package/src/lib/atomic.ts +2 -8
- package/src/lib/claude.ts +320 -0
- package/src/lib/claudelock.ts +2 -3
- package/src/lib/codex.ts +345 -0
- package/src/lib/codexauth.ts +9 -7
- package/src/lib/codexoauth.ts +5 -11
- package/src/lib/codexpresence.ts +3 -19
- package/src/lib/codexusage.ts +17 -41
- package/src/lib/credstore.ts +3 -7
- package/src/lib/decide.ts +86 -174
- package/src/lib/http.ts +3 -6
- package/src/lib/install.ts +4 -7
- package/src/lib/oauth.ts +14 -12
- package/src/lib/paths.ts +15 -7
- package/src/lib/picker.ts +56 -37
- package/src/lib/proc.ts +4 -4
- package/src/lib/provider.ts +34 -0
- package/src/lib/sample.ts +33 -33
- package/src/lib/state.ts +51 -18
- package/src/lib/swap.ts +41 -38
- package/src/lib/types.ts +31 -51
- package/src/lib/usage.ts +28 -20
- package/src/main.ts +20 -20
- package/src/cli/codexadd.ts +0 -89
- package/src/cli/codexinit.ts +0 -120
- package/src/cli/codexrm.ts +0 -36
- 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
package/src/lib/install.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { writeFileAtomic } from "./atomic.ts";
|
|
|
7
7
|
import { installedBin, installSettings, isOurHookCommand, uninstallSettings } from "./settings.ts";
|
|
8
8
|
import { resolveRealClaude } from "./claudebin.ts";
|
|
9
9
|
import { loadConfig } from "./state.ts";
|
|
10
|
+
import { ErrnoSchema } from "./types.ts";
|
|
10
11
|
|
|
11
12
|
const InstallOutcomeSchema = z.object({
|
|
12
13
|
claudeWrapper: z.string(),
|
|
@@ -49,10 +50,6 @@ function isNixStorePath(path: string): boolean {
|
|
|
49
50
|
return path === "/nix/store" || path.startsWith("/nix/store/");
|
|
50
51
|
}
|
|
51
52
|
|
|
52
|
-
function isEacces(e: unknown): boolean {
|
|
53
|
-
return typeof e === "object" && e != null && "code" in e && e.code === "EACCES";
|
|
54
|
-
}
|
|
55
|
-
|
|
56
53
|
function cannotWriteRcTarget(target: string): boolean {
|
|
57
54
|
if (EnvFlagSchema.parse(process.env.TOKENMAXXING_SKIP_SHELL_RC) != null) return true;
|
|
58
55
|
if (isNixStorePath(target)) return true;
|
|
@@ -355,7 +352,7 @@ export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
|
355
352
|
try {
|
|
356
353
|
writeFileAtomic(target, `${body}${sep0}${addition}`, statSync(target).mode & 0o777);
|
|
357
354
|
} catch (e) {
|
|
358
|
-
if (
|
|
355
|
+
if (ErrnoSchema.safeParse(e).data?.code === "EACCES") return "skipped";
|
|
359
356
|
throw e;
|
|
360
357
|
}
|
|
361
358
|
return "added";
|
|
@@ -366,7 +363,7 @@ export function ensurePathInRc(rc: string): "added" | "present" | "skipped" {
|
|
|
366
363
|
try {
|
|
367
364
|
appendFileSync(target, `${sep}export PATH="${dir}:$PATH" ${PATH_LINE_MARK}\n`);
|
|
368
365
|
} catch (e) {
|
|
369
|
-
if (
|
|
366
|
+
if (ErrnoSchema.safeParse(e).data?.code === "EACCES") return "skipped";
|
|
370
367
|
throw e;
|
|
371
368
|
}
|
|
372
369
|
return "added";
|
|
@@ -411,7 +408,7 @@ export function removePathFromRc(rc: string): boolean {
|
|
|
411
408
|
try {
|
|
412
409
|
writeFileAtomic(target, kept.join("\n"), statSync(target).mode & 0o777);
|
|
413
410
|
} catch (e) {
|
|
414
|
-
if (
|
|
411
|
+
if (ErrnoSchema.safeParse(e).data?.code === "EACCES") return false;
|
|
415
412
|
throw e;
|
|
416
413
|
}
|
|
417
414
|
return true;
|
package/src/lib/oauth.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
1
|
import { http, oauthErrorCode, safeErrorDetail } from "./http.ts";
|
|
3
|
-
import {
|
|
2
|
+
import { env } from "./paths.ts";
|
|
3
|
+
import { JsonTextSchema, ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
|
|
4
4
|
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const PROFILE_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_PROFILE_URL) ?? "https://api.anthropic.com/api/oauth/profile";
|
|
5
|
+
const TOKEN_URL = env("TOKENMAXXING_OAUTH_TOKEN_URL", "https://platform.claude.com/v1/oauth/token");
|
|
6
|
+
const CLIENT_ID = env("TOKENMAXXING_OAUTH_CLIENT_ID", "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
|
|
7
|
+
const PROFILE_URL = env("TOKENMAXXING_OAUTH_PROFILE_URL", "https://api.anthropic.com/api/oauth/profile");
|
|
9
8
|
|
|
10
9
|
const DEFAULT_SCOPES = [
|
|
11
10
|
"user:profile",
|
|
@@ -40,6 +39,13 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
|
|
|
40
39
|
return creds.refreshToken === "" || creds.accessToken === "";
|
|
41
40
|
}
|
|
42
41
|
|
|
42
|
+
export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
|
|
43
|
+
const segments = input.rateLimitTier?.split("_") ?? [];
|
|
44
|
+
const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
|
|
45
|
+
if (input.subscriptionType == null) return multiplier ?? null;
|
|
46
|
+
return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
|
|
47
|
+
}
|
|
48
|
+
|
|
43
49
|
export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
|
|
44
50
|
if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
|
|
45
51
|
const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
|
|
@@ -69,9 +75,7 @@ export async function refreshCredential(creds: OAuthCreds, now = Date.now(), sig
|
|
|
69
75
|
throw new Error(`token refresh failed (HTTP ${res.status}): ${detail}`);
|
|
70
76
|
}
|
|
71
77
|
|
|
72
|
-
const parsed = RefreshResponseSchema.safeParse((
|
|
73
|
-
try { return JSON.parse(text); } catch { return null; }
|
|
74
|
-
})());
|
|
78
|
+
const parsed = RefreshResponseSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
75
79
|
if (!parsed.success) {
|
|
76
80
|
throw new Error(`token endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
77
81
|
}
|
|
@@ -112,9 +116,7 @@ export async function fetchTokenIdentity(accessToken: string, signal?: AbortSign
|
|
|
112
116
|
throw new IdentityUnavailableError(res.status, `profile response body unreadable: ${e instanceof Error ? e.message : String(e)}`);
|
|
113
117
|
}
|
|
114
118
|
if (!res.ok) throw new IdentityUnavailableError(res.status, safeErrorDetail({ text }));
|
|
115
|
-
const parsed = ProfileResponseSchema.safeParse((
|
|
116
|
-
try { return JSON.parse(text); } catch { return null; }
|
|
117
|
-
})());
|
|
119
|
+
const parsed = ProfileResponseSchema.safeParse(JsonTextSchema.safeParse(text).data);
|
|
118
120
|
if (!parsed.success) throw new IdentityUnavailableError(res.status, `profile endpoint returned an unrecognized body (${text.length} bytes, withheld)`);
|
|
119
121
|
return TokenIdentitySchema.parse({
|
|
120
122
|
accountUuid: parsed.data.account.uuid,
|
package/src/lib/paths.ts
CHANGED
|
@@ -6,7 +6,7 @@ const HOME = homedir();
|
|
|
6
6
|
|
|
7
7
|
const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
|
|
8
8
|
|
|
9
|
-
function env(name: string, fallback: string): string {
|
|
9
|
+
export function env(name: string, fallback: string): string {
|
|
10
10
|
return EnvOverrideSchema.parse(process.env[name]) ?? fallback;
|
|
11
11
|
}
|
|
12
12
|
|
|
@@ -15,14 +15,11 @@ const TM_HOME = env("TOKENMAXXING_HOME", join(HOME, ".config", "tokenmaxxing"));
|
|
|
15
15
|
export const paths = {
|
|
16
16
|
home: TM_HOME,
|
|
17
17
|
configJson: join(TM_HOME, "config.json"),
|
|
18
|
-
accountsJson: join(TM_HOME, "accounts.json"),
|
|
19
18
|
usageJson: join(TM_HOME, "usage.json"),
|
|
20
|
-
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
21
19
|
depletedJson: join(TM_HOME, "depleted.json"),
|
|
22
20
|
respawnDir: join(TM_HOME, "respawn"),
|
|
23
21
|
binDir: join(TM_HOME, "bin"),
|
|
24
22
|
supervisorLink: join(TM_HOME, "bin", "claude"),
|
|
25
|
-
lockFile: join(TM_HOME, "lock"),
|
|
26
23
|
logFile: join(TM_HOME, "tokenmaxxing.log"),
|
|
27
24
|
onboardDir: join(TM_HOME, "onboard"),
|
|
28
25
|
sampleDir: join(TM_HOME, "sample"),
|
|
@@ -44,15 +41,26 @@ export const paths = {
|
|
|
44
41
|
systemdUserDir: env("TOKENMAXXING_SYSTEMD_USER_DIR", join(HOME, ".config", "systemd", "user")),
|
|
45
42
|
} as const;
|
|
46
43
|
|
|
44
|
+
export const claudePool = {
|
|
45
|
+
accountsJson: join(TM_HOME, "accounts.json"),
|
|
46
|
+
lastSwapJson: join(TM_HOME, "lastswap.json"),
|
|
47
|
+
lockFile: join(TM_HOME, "lock"),
|
|
48
|
+
} as const;
|
|
49
|
+
|
|
50
|
+
export const codexPool = {
|
|
51
|
+
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
52
|
+
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
53
|
+
lockFile: join(TM_HOME, "codex-lock"),
|
|
54
|
+
} as const;
|
|
55
|
+
|
|
56
|
+
export type PoolPaths = { accountsJson: string; lastSwapJson: string; lockFile: string };
|
|
57
|
+
|
|
47
58
|
const CODEX_HOME = env("TOKENMAXXING_CODEX_HOME", env("CODEX_HOME", join(HOME, ".codex")));
|
|
48
59
|
|
|
49
60
|
export const codexPaths = {
|
|
50
61
|
home: CODEX_HOME,
|
|
51
62
|
authJson: join(CODEX_HOME, "auth.json"),
|
|
52
63
|
hooksJson: join(CODEX_HOME, "hooks.json"),
|
|
53
|
-
accountsJson: join(TM_HOME, "codex-accounts.json"),
|
|
54
|
-
lastSwapJson: join(TM_HOME, "codex-lastswap.json"),
|
|
55
|
-
lockFile: join(TM_HOME, "codex-lock"),
|
|
56
64
|
credsDir: join(TM_HOME, "codex-creds"),
|
|
57
65
|
onboardDir: join(TM_HOME, "codex-onboard"),
|
|
58
66
|
respawnDir: join(TM_HOME, "codex-respawn"),
|
package/src/lib/picker.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { minBy, sortBy } from "es-toolkit";
|
|
1
|
+
import { maxBy, minBy, sortBy } from "es-toolkit";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { familyTokens } from "./usage.ts";
|
|
4
|
-
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type
|
|
4
|
+
import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresholds, type Window } from "./types.ts";
|
|
5
5
|
|
|
6
6
|
export function thresholdBars(cfg: Config): Thresholds {
|
|
7
7
|
return {
|
|
@@ -13,36 +13,57 @@ export function thresholdBars(cfg: Config): Thresholds {
|
|
|
13
13
|
const PickCtxSchema = z.object({
|
|
14
14
|
now: z.number(),
|
|
15
15
|
thresholds: ThresholdsSchema,
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
currentId: z.string().nullable(),
|
|
17
|
+
families: z.array(z.string()).nullable(),
|
|
18
18
|
});
|
|
19
19
|
export type PickCtx = z.infer<typeof PickCtxSchema>;
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
const SESSION_WINDOW_MAX_S = 6 * 3600;
|
|
23
|
+
|
|
24
|
+
export function isSessionWindow(w: Window): boolean {
|
|
25
|
+
return w.windowSeconds != null && w.windowSeconds <= SESSION_WINDOW_MAX_S;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
export function sessionWindow(a: Account): Window | undefined {
|
|
29
|
+
return a.windows.find((w) => w.name == null && isSessionWindow(w));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function weeklyWindow(a: Account): Window | undefined {
|
|
33
|
+
return maxBy(a.windows.filter((w) => w.name == null && !isSessionWindow(w)), (w) => w.windowSeconds ?? 0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function limitWindows(a: Account): Window[] {
|
|
37
|
+
return a.windows.filter((w) => w.name != null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function gatedWindows(a: Account, families: string[] | null): Window[] {
|
|
41
|
+
return a.windows.filter((w) => {
|
|
42
|
+
const name = w.name;
|
|
43
|
+
return name != null && (families == null || families.some((f) => familyTokens(name).includes(f)));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function liveUsed(w: Window, now: number): number {
|
|
48
|
+
if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
|
|
49
|
+
if (w.windowSeconds != null && now >= w.sampledAt + w.windowSeconds * 1000) return 0;
|
|
50
|
+
return w.usedPercentage;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function barFor(w: Window, thresholds: Thresholds): number {
|
|
54
|
+
return isSessionWindow(w) ? thresholds.session : thresholds.weekly;
|
|
55
|
+
}
|
|
29
56
|
|
|
30
|
-
function blockedUntil(w:
|
|
31
|
-
if (w.usedPercentage <
|
|
57
|
+
function blockedUntil(w: Window, bar: number): number {
|
|
58
|
+
if (w.usedPercentage < bar) return 0;
|
|
32
59
|
if (w.resetsAt != null) return w.resetsAt;
|
|
33
|
-
return
|
|
60
|
+
return w.windowSeconds != null ? w.sampledAt + w.windowSeconds * 1000 : Number.POSITIVE_INFINITY;
|
|
34
61
|
}
|
|
35
62
|
|
|
36
63
|
function blockingUntil(a: Account, ctx: PickCtx): number[] {
|
|
37
|
-
const u = a.lastUsage;
|
|
38
64
|
return [
|
|
39
|
-
...(
|
|
40
|
-
|
|
41
|
-
blockedUntil(u.fiveHour, FIVE_HOURS_MS, a.lastUsageAt, ctx.thresholds.session),
|
|
42
|
-
blockedUntil(u.sevenDay, WEEK_MS, a.lastUsageAt, ctx.thresholds.weekly),
|
|
43
|
-
]
|
|
44
|
-
: []),
|
|
45
|
-
...gatedPerModelWindows(a, ctx.switchFamilies).map((w) => blockedUntil(w, WEEK_MS, u?.rowsAt ?? a.lastUsageAt, ctx.thresholds.weekly)),
|
|
65
|
+
...a.windows.filter((w) => w.name == null).map((w) => blockedUntil(w, barFor(w, ctx.thresholds))),
|
|
66
|
+
...gatedWindows(a, ctx.families).map((w) => blockedUntil(w, barFor(w, ctx.thresholds))),
|
|
46
67
|
...(a.enforcedUntil != null ? [a.enforcedUntil] : []),
|
|
47
68
|
];
|
|
48
69
|
}
|
|
@@ -57,39 +78,37 @@ export function nextWeeklyReset(resetsAt: number | null, now: number): number |
|
|
|
57
78
|
}
|
|
58
79
|
|
|
59
80
|
export function weeklyExpiry(a: Account, now: number): number {
|
|
60
|
-
return nextWeeklyReset(a
|
|
81
|
+
return nextWeeklyReset(weeklyWindow(a)?.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
|
|
61
82
|
}
|
|
62
83
|
|
|
63
84
|
export function earliestReset(a: Account, now: number): number {
|
|
64
|
-
const
|
|
65
|
-
return Math.min(
|
|
85
|
+
const session = sessionWindow(a)?.resetsAt;
|
|
86
|
+
return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
|
|
66
87
|
}
|
|
67
88
|
|
|
68
89
|
export function pacePressure(a: Account, now: number): number {
|
|
69
|
-
const
|
|
70
|
-
const reset = nextWeeklyReset(
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
return Math.max(0, 100 - used) / Math.max(1, reset - now);
|
|
90
|
+
const weekly = weeklyWindow(a);
|
|
91
|
+
const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
|
|
92
|
+
if (weekly == null || reset == null) return 0;
|
|
93
|
+
return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
|
|
74
94
|
}
|
|
75
95
|
|
|
76
96
|
const swapPreference = (ctx: PickCtx) => [
|
|
77
97
|
(a: Account) => -pacePressure(a, ctx.now),
|
|
78
98
|
(a: Account) => weeklyExpiry(a, ctx.now),
|
|
79
|
-
(a: Account) => a
|
|
99
|
+
(a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
|
|
80
100
|
];
|
|
81
101
|
|
|
82
102
|
export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
|
|
83
|
-
const usable = accounts.filter(
|
|
84
|
-
(a) => a.accountUuid !== ctx.currentAccountUuid && !a.needsReauth && !isExhausted(a, ctx),
|
|
85
|
-
);
|
|
103
|
+
const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
|
|
86
104
|
return sortBy(usable, swapPreference(ctx))[0] ?? null;
|
|
87
105
|
}
|
|
88
106
|
|
|
89
|
-
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx): boolean {
|
|
107
|
+
export function currentWins(active: Account | null, accounts: Account[], ctx: PickCtx, margin = 1): boolean {
|
|
90
108
|
if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
|
|
91
|
-
const best = pickBest(accounts, { ...ctx,
|
|
92
|
-
if (best == null || best.
|
|
109
|
+
const best = pickBest(accounts, { ...ctx, currentId: null });
|
|
110
|
+
if (best == null || best.id === active.id) return true;
|
|
111
|
+
if (margin > 1) return pacePressure(best, ctx.now) <= pacePressure(active, ctx.now) * margin;
|
|
93
112
|
return swapPreference(ctx).every((k) => k(active) === k(best));
|
|
94
113
|
}
|
|
95
114
|
|
|
@@ -103,7 +122,7 @@ export type EarliestReset = z.infer<typeof EarliestResetSchema>;
|
|
|
103
122
|
|
|
104
123
|
export function pickEarliestReset(accounts: Account[], ctx: PickCtx): EarliestReset | null {
|
|
105
124
|
const mapped = accounts
|
|
106
|
-
.filter((a) => a.
|
|
125
|
+
.filter((a) => a.id !== ctx.currentId && !a.needsReauth)
|
|
107
126
|
.map((a) => ({ account: a, availableAt: usableAt(a, ctx) }))
|
|
108
127
|
.filter((x) => Number.isFinite(x.availableAt));
|
|
109
128
|
return minBy(mapped, (x) => x.availableAt) ?? null;
|
package/src/lib/proc.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ErrnoSchema } from "./types.ts";
|
|
2
2
|
|
|
3
3
|
export function pidStartTime(pid: number): string | null {
|
|
4
4
|
const res = Bun.spawnSync(["ps", "-p", String(pid), "-o", "lstart="], { env: { ...process.env, LC_ALL: "C" } });
|
|
@@ -12,9 +12,9 @@ export function pidExists(pid: number): boolean {
|
|
|
12
12
|
process.kill(pid, 0);
|
|
13
13
|
return true;
|
|
14
14
|
} catch (e) {
|
|
15
|
-
const
|
|
16
|
-
if (
|
|
17
|
-
if (
|
|
15
|
+
const code = ErrnoSchema.safeParse(e).data?.code;
|
|
16
|
+
if (code === "ESRCH") return false;
|
|
17
|
+
if (code === "EPERM") return true;
|
|
18
18
|
throw e;
|
|
19
19
|
}
|
|
20
20
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PoolPaths } from "./paths.ts";
|
|
2
|
+
import type { Harvest } from "./state.ts";
|
|
3
|
+
import type { Account, Config, Window } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
export type Observation = { windows: Window[]; at: number };
|
|
6
|
+
|
|
7
|
+
export type SampleReport = { ok: true; source: "statusline" | "probe" } | { ok: false; reason: string };
|
|
8
|
+
|
|
9
|
+
export type SwapFailure = "dead-grant" | "skip" | "fatal";
|
|
10
|
+
|
|
11
|
+
export type Provider = {
|
|
12
|
+
name: "claude" | "codex";
|
|
13
|
+
flag: string;
|
|
14
|
+
pool: PoolPaths;
|
|
15
|
+
waitsWhenDepleted: boolean;
|
|
16
|
+
switchMargin: number;
|
|
17
|
+
switchNote: string;
|
|
18
|
+
liveId(): string | null;
|
|
19
|
+
liveOwner(): Promise<string | null>;
|
|
20
|
+
presentIds(): Set<string>;
|
|
21
|
+
gatedFamilies(cfg: Config): string[] | null;
|
|
22
|
+
observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
|
|
23
|
+
samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
|
|
24
|
+
mergeWindows(next: Window[], prev: Window[]): Window[];
|
|
25
|
+
swap(target: Account): Promise<void>;
|
|
26
|
+
classifySwapError(e: unknown): SwapFailure;
|
|
27
|
+
removeCredentials(account: Account): Promise<void>;
|
|
28
|
+
login(): Promise<Harvest | null>;
|
|
29
|
+
importLive(): Promise<Harvest | null>;
|
|
30
|
+
preflight(): void;
|
|
31
|
+
install(): void;
|
|
32
|
+
loginStep(who: string): string;
|
|
33
|
+
windowLabel(name: string): string;
|
|
34
|
+
};
|
package/src/lib/sample.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { join } from "node:path";
|
|
|
3
3
|
import { minBy } from "es-toolkit";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
import { readItem, writeItem, deleteItem, liveTarget, parkedTarget, isolatedTarget, claudeAiOauthOnly, mergeIntoLive } from "./credstore.ts";
|
|
6
|
-
import { credItemFor, paths } from "./paths.ts";
|
|
6
|
+
import { claudePool, credItemFor, paths } from "./paths.ts";
|
|
7
7
|
import { withClaudeRefreshLock } from "./claudelock.ts";
|
|
8
8
|
import { withLock } from "./lock.ts";
|
|
9
9
|
import { readOAuthAccount } from "./claudejson.ts";
|
|
10
|
-
import { refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
11
|
-
import {
|
|
10
|
+
import { claudeTierLabel, refreshCredential, isAccessTokenExpiring, isDeadCredential, fetchTokenIdentity, describeIdentity, IdentityUnavailableError, InvalidGrantError } from "./oauth.ts";
|
|
11
|
+
import { mergeWindows, probeUsage, windowsOf } from "./usage.ts";
|
|
12
12
|
import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadLastSwapAt, saveAccounts } from "./state.ts";
|
|
13
13
|
import { keepRotatedPair } from "./swap.ts";
|
|
14
14
|
import { log } from "./log.ts";
|
|
@@ -38,13 +38,12 @@ async function checkIdentity(creds: OAuthCreds, account: Account, signal?: Abort
|
|
|
38
38
|
stale: e instanceof IdentityUnavailableError && e.status === 401,
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
-
if (identity.accountUuid === account.
|
|
41
|
+
if (identity.accountUuid === account.id) return { status: "match" };
|
|
42
42
|
return { status: "mismatch", reason: `credential actually belongs to ${describeIdentity(identity)}`, owner: identity };
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function
|
|
46
|
-
|
|
47
|
-
if (creds.rateLimitTier != null) account.rateLimitTier = creds.rateLimitTier;
|
|
45
|
+
function refreshTier(account: Account, creds: OAuthCreds): void {
|
|
46
|
+
account.tier = claudeTierLabel(creds) ?? account.tier;
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
const ProbeFailureSchema = z.object({ ok: z.literal(false), reason: z.string() });
|
|
@@ -53,9 +52,10 @@ const PreparedProbeSchema = z.discriminatedUnion("ok", [z.object({ ok: z.literal
|
|
|
53
52
|
type PreparedProbe = z.infer<typeof PreparedProbeSchema>;
|
|
54
53
|
|
|
55
54
|
async function prepareParkedProbe(account: Account, dir: string, signal?: AbortSignal): Promise<PreparedProbe> {
|
|
56
|
-
const backup = parkedTarget(account.
|
|
55
|
+
const backup = parkedTarget(credItemFor(account.id));
|
|
57
56
|
const parkedRaw = await readItem(backup);
|
|
58
57
|
if (!parkedRaw) return { ok: false, reason: "no parked credential - run `tokenmaxxing auth`" };
|
|
58
|
+
if (!account.oauthAccount) return { ok: false, reason: "account record has no oauthAccount - run `tokenmaxxing auth`" };
|
|
59
59
|
|
|
60
60
|
let creds: OAuthCreds;
|
|
61
61
|
try {
|
|
@@ -87,17 +87,17 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
87
87
|
} catch (e) {
|
|
88
88
|
return { ok: false, reason: `cannot verify the live credential's owner (${(e instanceof Error ? e.message : String(e)).slice(0, 80)}) - refusing to sample a possibly-live account` };
|
|
89
89
|
}
|
|
90
|
-
if (liveAccount === account.
|
|
90
|
+
if (liveAccount === account.id) {
|
|
91
91
|
return { ok: false, reason: "this account holds the LIVE login (active label drifted) - run `tokenmaxxing switch` to reconcile" };
|
|
92
92
|
}
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
96
|
const relocate = async (owner: TokenIdentity): Promise<string> => {
|
|
97
|
-
const pooled = loadAccounts().accounts.find((a) => a.
|
|
98
|
-
if (pooled == null || pooled.
|
|
99
|
-
await writeItem(parkedTarget(pooled.
|
|
100
|
-
log("swap.parked_relocated", { account: pooled.
|
|
97
|
+
const pooled = loadAccounts(claudePool).accounts.find((a) => a.id === owner.accountUuid) ?? null;
|
|
98
|
+
if (pooled == null || pooled.id === liveAccount) return "was left in place";
|
|
99
|
+
await writeItem(parkedTarget(credItemFor(pooled.id)), JSON.stringify({ claudeAiOauth: creds }));
|
|
100
|
+
log("swap.parked_relocated", { account: pooled.id.slice(0, 8) });
|
|
101
101
|
return "was copied to that account's slot";
|
|
102
102
|
};
|
|
103
103
|
|
|
@@ -140,8 +140,8 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
140
140
|
await writeItem(backup, JSON.stringify({ claudeAiOauth: rotated.fresh }));
|
|
141
141
|
return { failed: { ok: false, reason: `${verified.reason} - the rotated pair stays in this slot unverified until the next pass` } };
|
|
142
142
|
}
|
|
143
|
-
const trueOwner = loadAccounts().accounts.find((a) => a.
|
|
144
|
-
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account,
|
|
143
|
+
const trueOwner = loadAccounts(claudePool).accounts.find((a) => a.id === verified.owner.accountUuid) ?? null;
|
|
144
|
+
const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerId: liveAccount, expectedLiveToken: liveToken, lock });
|
|
145
145
|
account.needsReauth = true;
|
|
146
146
|
return { failed: { ok: false, reason: `${verified.reason}, whose grant this refresh rotated - the rotated token ${kept}; re-auth with \`tokenmaxxing auth\`` } };
|
|
147
147
|
});
|
|
@@ -163,7 +163,7 @@ async function prepareParkedProbe(account: Account, dir: string, signal?: AbortS
|
|
|
163
163
|
if (identity.status === "unavailable") {
|
|
164
164
|
return { ok: false, reason: identity.reason };
|
|
165
165
|
}
|
|
166
|
-
|
|
166
|
+
refreshTier(account, creds);
|
|
167
167
|
|
|
168
168
|
rmSync(dir, { recursive: true, force: true });
|
|
169
169
|
mkdirSync(dir, { recursive: true });
|
|
@@ -185,7 +185,7 @@ async function runParkedProbe(dir: string, opts: { retries?: number }): Promise<
|
|
|
185
185
|
|
|
186
186
|
async function finishParkedProbe(account: Account, prepared: { dir: string; installed: string }): Promise<void> {
|
|
187
187
|
const isoTarget = isolatedTarget(prepared.dir);
|
|
188
|
-
const backup = parkedTarget(account.
|
|
188
|
+
const backup = parkedTarget(credItemFor(account.id));
|
|
189
189
|
try {
|
|
190
190
|
const afterIso = await readItem(isoTarget);
|
|
191
191
|
if (afterIso && afterIso !== prepared.installed) {
|
|
@@ -193,7 +193,7 @@ async function finishParkedProbe(account: Account, prepared: { dir: string; inst
|
|
|
193
193
|
const installedToken = CredentialBlobSchema.parse(JSON.parse(prepared.installed)).claudeAiOauth.accessToken;
|
|
194
194
|
const parkedToken = parkedNow == null ? null : CredentialBlobSchema.parse(JSON.parse(parkedNow)).claudeAiOauth.accessToken;
|
|
195
195
|
if (parkedToken === installedToken) await writeItem(backup, claudeAiOauthOnly(afterIso));
|
|
196
|
-
else log("sample.harvest_skipped", { account: account.
|
|
196
|
+
else log("sample.harvest_skipped", { account: account.id.slice(0, 8) });
|
|
197
197
|
}
|
|
198
198
|
} finally {
|
|
199
199
|
await deleteItem(isoTarget);
|
|
@@ -202,7 +202,7 @@ async function finishParkedProbe(account: Account, prepared: { dir: string; inst
|
|
|
202
202
|
}
|
|
203
203
|
|
|
204
204
|
export async function probeParkedUsage(account: Account): Promise<SampleOutcome> {
|
|
205
|
-
const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.
|
|
205
|
+
const prepared = await prepareParkedProbe(account, join(paths.sampleDir, credItemFor(account.id)));
|
|
206
206
|
if (!prepared.ok) return prepared;
|
|
207
207
|
try {
|
|
208
208
|
return await runParkedProbe(prepared.dir, {});
|
|
@@ -214,21 +214,21 @@ export async function probeParkedUsage(account: Account): Promise<SampleOutcome>
|
|
|
214
214
|
const PREPARE_DEADLINE_MS = 20_000;
|
|
215
215
|
|
|
216
216
|
export async function sampleOldestParked(cfg: Config): Promise<void> {
|
|
217
|
-
const reserved = await withLock(
|
|
217
|
+
const reserved = await withLock(claudePool.lockFile, async () => {
|
|
218
218
|
const now = Date.now();
|
|
219
|
-
const lastSwapAt = loadLastSwapAt();
|
|
219
|
+
const lastSwapAt = loadLastSwapAt(claudePool);
|
|
220
220
|
if (lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) return null;
|
|
221
|
-
const idx = loadAccounts();
|
|
221
|
+
const idx = loadAccounts(claudePool);
|
|
222
222
|
const live = readOAuthAccount()?.accountUuid ?? null;
|
|
223
223
|
const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
|
|
224
|
-
const stale = idx.accounts.filter((a) => a.
|
|
224
|
+
const stale = idx.accounts.filter((a) => a.id !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
|
|
225
225
|
const target = minBy(stale, sampledAt);
|
|
226
226
|
if (!target) return null;
|
|
227
227
|
target.lastProbeAt = now;
|
|
228
|
-
const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.
|
|
229
|
-
saveAccounts(idx);
|
|
228
|
+
const prepared = await prepareParkedProbe(target, join(paths.sampleDir, `${credItemFor(target.id)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
|
|
229
|
+
saveAccounts(claudePool, idx);
|
|
230
230
|
if (!prepared.ok) {
|
|
231
|
-
log("sample.parked_failed", { account: target.
|
|
231
|
+
log("sample.parked_failed", { account: target.id.slice(0, 8), reason: prepared.reason.slice(0, 200) });
|
|
232
232
|
return null;
|
|
233
233
|
}
|
|
234
234
|
return { account: target, prepared };
|
|
@@ -240,18 +240,18 @@ export async function sampleOldestParked(cfg: Config): Promise<void> {
|
|
|
240
240
|
try {
|
|
241
241
|
outcome = await runParkedProbe(prepared.dir, { retries: 0 });
|
|
242
242
|
} finally {
|
|
243
|
-
await withLock(
|
|
243
|
+
await withLock(claudePool.lockFile, async () => {
|
|
244
244
|
await finishParkedProbe(account, prepared);
|
|
245
245
|
if (outcome == null) return;
|
|
246
|
-
const idx = loadAccounts();
|
|
247
|
-
const stored = idx.accounts.find((a) => a.
|
|
246
|
+
const idx = loadAccounts(claudePool);
|
|
247
|
+
const stored = idx.accounts.find((a) => a.id === account.id);
|
|
248
248
|
if (stored && outcome.ok && (stored.lastUsageAt == null || startedAt > stored.lastUsageAt)) {
|
|
249
|
-
stored.
|
|
249
|
+
stored.windows = mergeWindows(windowsOf(outcome.usage, startedAt), stored.windows);
|
|
250
250
|
stored.lastUsageAt = startedAt;
|
|
251
|
-
saveAccounts(idx);
|
|
251
|
+
saveAccounts(claudePool, idx);
|
|
252
252
|
}
|
|
253
253
|
log(outcome.ok ? "sample.parked_ok" : "sample.parked_failed", {
|
|
254
|
-
account: account.
|
|
254
|
+
account: account.id.slice(0, 8),
|
|
255
255
|
...(outcome.ok ? {} : { reason: outcome.reason.slice(0, 200) }),
|
|
256
256
|
});
|
|
257
257
|
});
|
|
@@ -300,7 +300,7 @@ export async function probeActiveUsage(account: Account): Promise<SampleOutcome>
|
|
|
300
300
|
const identity = await checkIdentity(creds, account);
|
|
301
301
|
if (identity.status === "mismatch") return { ok: false, reason: `live ${identity.reason} - active label drifted; run \`tokenmaxxing switch\`` };
|
|
302
302
|
if (identity.status === "unavailable") return { ok: false, reason: identity.reason };
|
|
303
|
-
|
|
303
|
+
refreshTier(account, creds);
|
|
304
304
|
|
|
305
305
|
const usage = await probeUsage();
|
|
306
306
|
return usage ? { ok: true, usage } : { ok: false, reason: "`/usage` returned no limit data (see log)" };
|