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.
Files changed (54) hide show
  1. package/DESIGN.md +3 -5
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/cli/add.ts +23 -40
  5. package/src/cli/auth.ts +38 -52
  6. package/src/cli/check.ts +2 -1
  7. package/src/cli/doctor.ts +13 -13
  8. package/src/cli/init.ts +29 -126
  9. package/src/cli/rename.ts +11 -45
  10. package/src/cli/render.ts +0 -7
  11. package/src/cli/rm.ts +24 -39
  12. package/src/cli/setuptoken.ts +16 -16
  13. package/src/cli/status.ts +95 -238
  14. package/src/cli/switch.ts +54 -46
  15. package/src/entries/codexstophook.ts +23 -23
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +5 -3
  18. package/src/entries/statusline.ts +17 -19
  19. package/src/entries/stopfailurehook.ts +9 -7
  20. package/src/entries/stophook.ts +5 -4
  21. package/src/entries/subagentstatusline.ts +2 -7
  22. package/src/lib/atomic.ts +2 -8
  23. package/src/lib/claude.ts +320 -0
  24. package/src/lib/claudelock.ts +2 -3
  25. package/src/lib/codex.ts +345 -0
  26. package/src/lib/codexauth.ts +9 -7
  27. package/src/lib/codexoauth.ts +5 -11
  28. package/src/lib/codexpresence.ts +3 -19
  29. package/src/lib/codexusage.ts +17 -41
  30. package/src/lib/credstore.ts +3 -7
  31. package/src/lib/decide.ts +86 -174
  32. package/src/lib/http.ts +3 -6
  33. package/src/lib/install.ts +4 -7
  34. package/src/lib/oauth.ts +14 -12
  35. package/src/lib/paths.ts +15 -7
  36. package/src/lib/picker.ts +56 -37
  37. package/src/lib/proc.ts +4 -4
  38. package/src/lib/provider.ts +34 -0
  39. package/src/lib/sample.ts +33 -33
  40. package/src/lib/state.ts +51 -18
  41. package/src/lib/swap.ts +41 -38
  42. package/src/lib/types.ts +31 -51
  43. package/src/lib/usage.ts +28 -20
  44. package/src/main.ts +20 -20
  45. package/src/cli/codexadd.ts +0 -89
  46. package/src/cli/codexinit.ts +0 -120
  47. package/src/cli/codexrm.ts +0 -36
  48. package/src/cli/codexswitch.ts +0 -113
  49. package/src/cli/onboard.ts +0 -92
  50. package/src/lib/codexdecide.ts +0 -157
  51. package/src/lib/codexpick.ts +0 -80
  52. package/src/lib/codexsample.ts +0 -48
  53. package/src/lib/codexstate.ts +0 -22
  54. package/src/lib/codexswap.ts +0 -70
@@ -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 (isEacces(e)) return "skipped";
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 (isEacces(e)) return "skipped";
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 (isEacces(e)) return false;
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 { ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
2
+ import { env } from "./paths.ts";
3
+ import { JsonTextSchema, ProfileResponseSchema, RefreshResponseSchema, TokenIdentitySchema, type OAuthCreds, type TokenIdentity } from "./types.ts";
4
4
 
5
- const EnvOverrideSchema = z.string().min(1).optional().catch(undefined);
6
- const TOKEN_URL = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_TOKEN_URL) ?? "https://platform.claude.com/v1/oauth/token";
7
- const CLIENT_ID = EnvOverrideSchema.parse(process.env.TOKENMAXXING_OAUTH_CLIENT_ID) ?? "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
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 UsageWindow } from "./types.ts";
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
- currentAccountUuid: z.string().nullable(),
17
- switchFamilies: z.array(z.string()),
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
- function gatedPerModelWindows(a: Account, families: string[]): UsageWindow[] {
22
- return Object.entries(a.lastUsage?.perModel ?? {})
23
- .filter(([model]) => families.some((f) => familyTokens(model).includes(f)))
24
- .map(([, w]) => w);
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
- const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
28
- const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
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: UsageWindow, windowMs: number, sampledAt: number | undefined, threshold: number): number {
31
- if (w.usedPercentage < threshold) return 0;
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 sampledAt != null ? sampledAt + windowMs : Number.POSITIVE_INFINITY;
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
- ...(u
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.lastUsage?.sevenDay.resetsAt ?? null, now) ?? Number.POSITIVE_INFINITY;
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 fiveHour = a.lastUsage?.fiveHour.resetsAt;
65
- return Math.min(fiveHour != null && fiveHour > now ? fiveHour : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
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 cached = a.lastUsage?.sevenDay;
70
- const reset = nextWeeklyReset(cached?.resetsAt ?? null, now);
71
- if (cached == null || reset == null) return 0;
72
- const used = cached.resetsAt != null && cached.resetsAt <= now ? 0 : cached.usedPercentage;
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.lastUsage?.sevenDay.usedPercentage ?? 101,
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, currentAccountUuid: null });
92
- if (best == null || best.accountUuid === active.accountUuid) return true;
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.accountUuid !== ctx.currentAccountUuid && !a.needsReauth)
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 { z } from "zod";
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 errno = z.object({ code: z.string() }).safeParse(e);
16
- if (errno.success && errno.data.code === "ESRCH") return false;
17
- if (errno.success && errno.data.code === "EPERM") return true;
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 { keepRows, probeUsage } from "./usage.ts";
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.accountUuid) return { status: "match" };
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 refreshPlanFields(account: Account, creds: OAuthCreds): void {
46
- if (creds.subscriptionType != null) account.subscriptionType = creds.subscriptionType;
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.keychainItem);
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.accountUuid) {
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.accountUuid === owner.accountUuid) ?? null;
98
- if (pooled == null || pooled.accountUuid === liveAccount) return "was left in place";
99
- await writeItem(parkedTarget(pooled.keychainItem), JSON.stringify({ claudeAiOauth: creds }));
100
- log("swap.parked_relocated", { account: pooled.accountUuid.slice(0, 8) });
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.accountUuid === verified.owner.accountUuid) ?? null;
144
- const kept = await keepRotatedPair({ fresh: rotated.fresh, owner: trueOwner, fallback: account, liveOwnerUuid: liveAccount, expectedLiveToken: liveToken, lock });
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
- refreshPlanFields(account, creds);
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.keychainItem);
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.accountUuid.slice(0, 8) });
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.accountUuid)));
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(paths.lockFile, async () => {
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.accountUuid !== live && a.needsReauth !== true && now - sampledAt(a) > cfg.policy.usagePollTtlMs);
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.accountUuid)}-tick`), AbortSignal.timeout(PREPARE_DEADLINE_MS));
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.accountUuid.slice(0, 8), reason: prepared.reason.slice(0, 200) });
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(paths.lockFile, async () => {
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.accountUuid === account.accountUuid);
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.lastUsage = keepRows(outcome.usage, stored.lastUsage, startedAt);
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.accountUuid.slice(0, 8),
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
- refreshPlanFields(account, creds);
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)" };