tokenmaxxing 1.34.0 → 1.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) 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 +22 -16
  16. package/src/entries/codexsupervisor.ts +3 -3
  17. package/src/entries/sessionstart.ts +3 -2
  18. package/src/entries/statusline.ts +15 -13
  19. package/src/entries/stopfailurehook.ts +7 -5
  20. package/src/entries/stophook.ts +3 -2
  21. package/src/lib/claude.ts +320 -0
  22. package/src/lib/codex.ts +353 -0
  23. package/src/lib/codexauth.ts +6 -0
  24. package/src/lib/codexpresence.ts +0 -10
  25. package/src/lib/codexusage.ts +14 -32
  26. package/src/lib/decide.ts +86 -174
  27. package/src/lib/oauth.ts +7 -0
  28. package/src/lib/paths.ts +14 -6
  29. package/src/lib/picker.ts +56 -37
  30. package/src/lib/provider.ts +34 -0
  31. package/src/lib/sample.ts +33 -33
  32. package/src/lib/state.ts +49 -16
  33. package/src/lib/swap.ts +41 -38
  34. package/src/lib/types.ts +17 -51
  35. package/src/lib/usage.ts +17 -4
  36. package/src/main.ts +20 -20
  37. package/src/cli/codexadd.ts +0 -89
  38. package/src/cli/codexinit.ts +0 -120
  39. package/src/cli/codexrm.ts +0 -36
  40. package/src/cli/codexswitch.ts +0 -113
  41. package/src/cli/onboard.ts +0 -92
  42. package/src/lib/codexdecide.ts +0 -157
  43. package/src/lib/codexpick.ts +0 -80
  44. package/src/lib/codexsample.ts +0 -48
  45. package/src/lib/codexstate.ts +0 -22
  46. package/src/lib/codexswap.ts +0 -70
package/src/lib/decide.ts CHANGED
@@ -1,14 +1,11 @@
1
- import { maxBy } from "es-toolkit";
2
1
  import { z } from "zod";
3
2
  import { withLock } from "./lock.ts";
4
- import { paths } from "./paths.ts";
5
- import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, loadUsageSnapshot, saveAccounts, saveDepletedWait, writeUsage } from "./state.ts";
6
- import { readOAuthAccount } from "./claudejson.ts";
7
- import { isSkippableSwapError, performSwap } from "./swap.ts";
8
- import { isExhausted, nextWeeklyReset, pickBest, pickEarliestReset, thresholdBars, usableAt } from "./picker.ts";
9
- import { familyTokens, gatedFamilies, keepRows, probeUsage } from "./usage.ts";
3
+ import { POST_SWAP_COOLDOWN_MS, loadAccounts, loadConfig, loadDepletedWait, loadLastSwapAt, saveAccounts, saveDepletedWait } from "./state.ts";
4
+ import { isExhausted, limitWindows, nextWeeklyReset, pickBest, pickEarliestReset, sessionWindow, thresholdBars, usableAt, weeklyWindow, type PickCtx } from "./picker.ts";
5
+ import { familyTokens } from "./usage.ts";
10
6
  import { log } from "./log.ts";
11
- import { AccountSchema, UsageStateSchema, type Account, type Config, type EnforcedLimit, type Thresholds, type UsageState, type UsageWindow } from "./types.ts";
7
+ import type { Observation, Provider } from "./provider.ts";
8
+ import { AccountSchema, type Account, type EnforcedLimit } from "./types.ts";
12
9
 
13
10
  const SwapDecisionSchema = z.object({
14
11
  swapped: z.boolean(),
@@ -21,201 +18,132 @@ export type SwapDecision = z.infer<typeof SwapDecisionSchema>;
21
18
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
22
19
  const FIVE_HOURS_MS = 5 * 60 * 60 * 1000;
23
20
 
24
- function liveUsed(input: { window: UsageWindow; windowMs: number; sampledAt: number; now: number }): number {
25
- const { window: w, windowMs, sampledAt, now } = input;
26
- if (w.resetsAt != null) return w.resetsAt <= now ? 0 : w.usedPercentage;
27
- if (now >= sampledAt + windowMs) return 0;
28
- return w.usedPercentage;
29
- }
30
-
31
- function rowsSampledAt(account: Account, now: number): number {
32
- return account.lastUsage?.rowsAt ?? account.lastUsageAt ?? now;
33
- }
34
-
35
- function capForFamily(account: Account, family: string, now: number): UsageWindow | undefined {
36
- const rows = Object.entries(account.lastUsage?.perModel ?? {})
37
- .filter(([k]) => familyTokens(k).includes(family))
38
- .map(([, w]) => w);
39
- return maxBy(rows, (w) => liveUsed({ window: w, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }));
40
- }
41
-
42
- function isOver(u: UsageState | null, account: Account | undefined, bars: Thresholds, cfg: Config, now: number): boolean {
21
+ function isOver(account: Account | undefined, observed: Observation | null, ctx: PickCtx): boolean {
43
22
  if (!account) return false;
44
- if (account.enforcedUntil != null && account.enforcedUntil > now) return true;
45
- if (!u || u.account !== account.accountUuid) return false;
46
- if (
47
- liveUsed({ window: u.fiveHour, windowMs: FIVE_HOURS_MS, sampledAt: u.ts, now }) >= bars.session ||
48
- liveUsed({ window: u.sevenDay, windowMs: WEEK_MS, sampledAt: u.ts, now }) >= bars.weekly
49
- ) return true;
50
- for (const family of gatedFamilies(u.model, cfg.policy.switchModels)) {
51
- const cap = capForFamily(account, family, now);
52
- if (cap && liveUsed({ window: cap, windowMs: WEEK_MS, sampledAt: rowsSampledAt(account, now), now }) >= bars.weekly) return true;
53
- }
54
- return false;
55
- }
56
-
57
- function needsPerModel(u: UsageState | null, cfg: Config): boolean {
58
- return u != null && gatedFamilies(u.model, cfg.policy.switchModels).length > 0;
59
- }
60
-
61
- const SnapshotsSchema = z.object({
62
- u: UsageStateSchema.nullable(),
63
- uAt: z.number().nullable(),
64
- });
65
- type Snapshots = z.infer<typeof SnapshotsSchema>;
66
-
67
- function usageFresh(u: UsageState | null, uAt: number | null, account: string | null, ttl: number, now: number): boolean {
68
- return u != null && u.account === account && uAt != null && now - uAt <= ttl;
69
- }
70
-
71
- function freshest(u: UsageState | null, uAt: number | null, account: Account | undefined): UsageState | null {
72
- if (!u || uAt == null) return u;
73
- const stored = account?.lastUsage;
74
- if (stored && account.lastUsageAt != null && u.account === account.accountUuid && uAt < account.lastUsageAt) {
75
- return { ...u, fiveHour: stored.fiveHour, sevenDay: stored.sevenDay, ts: account.lastUsageAt };
76
- }
77
- return { ...u, ts: uAt };
23
+ if (account.needsReauth === true) return true;
24
+ if (account.enforcedUntil != null && account.enforcedUntil > ctx.now) return true;
25
+ if (!observed) return false;
26
+ return isExhausted({ ...account, windows: observed.windows }, ctx);
78
27
  }
79
28
 
80
- async function loadFreshSnapshots(cfg: Config, account: string | null, now: number, probe: boolean): Promise<Snapshots> {
81
- const snap = loadUsageSnapshot();
82
- let u = snap?.state ?? null;
83
- let uAt = snap?.at ?? null;
84
- const ttl = cfg.policy.usagePollTtlMs;
85
- const stored = loadAccounts().accounts.find((a) => a.accountUuid === account);
86
- const probeAttempted = stored?.lastProbeAt != null && now - stored.lastProbeAt <= ttl;
87
- const walled = stored?.enforcedUntil != null && stored.enforcedUntil > now;
88
- if (probe && account && stored && !walled && !probeAttempted && (!usageFresh(u, uAt, account, ttl, now) || needsPerModel(u, cfg))) {
89
- const startedAt = Date.now();
90
- const full = await probeUsage();
91
- const ts = Date.now();
92
- if (readOAuthAccount()?.accountUuid === account) {
93
- if (full) {
94
- const teed = loadUsageSnapshot();
95
- if (teed && usageFresh(teed.state, teed.at, account, ttl, ts)) {
96
- u = teed.state;
97
- uAt = teed.at;
98
- } else {
99
- u = { fiveHour: full.fiveHour, sevenDay: full.sevenDay, account, ts, model: null };
100
- writeUsage(u);
101
- uAt = ts;
102
- }
103
- const expected = gatedFamilies(u?.model ?? null, cfg.policy.switchModels);
104
- const rows = Object.keys(full.perModel);
105
- if (expected.length > 0 && !expected.some((f) => rows.some((k) => familyTokens(k).includes(f)))) {
106
- log("usage.no_permodel_row", { families: expected.join(","), rows: rows.join(",") });
107
- }
108
- }
109
- await withLock(paths.lockFile, () => {
110
- const idx = loadAccounts();
111
- const a = idx.accounts.find((x) => x.accountUuid === account);
112
- if (!a) return;
113
- a.lastProbeAt = startedAt;
114
- if (full) {
115
- if (!a.lastUsage) a.lastUsageAt = startedAt;
116
- a.lastUsage = keepRows({ ...(a.lastUsage ?? full), perModel: full.perModel }, a.lastUsage, startedAt);
117
- }
118
- saveAccounts(idx);
119
- });
120
- }
121
- }
122
- return { u, uAt };
29
+ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
30
+ const { family } = limit;
31
+ const familyReset =
32
+ family == null
33
+ ? null
34
+ : limitWindows(account)
35
+ .filter((w) => familyTokens(w.name ?? "").includes(family))
36
+ .map((w) => w.resetsAt)
37
+ .find((r): r is number => r != null) ?? null;
38
+ const session = sessionWindow(account)?.resetsAt ?? null;
39
+ const cachedReset =
40
+ limit.kind === "session"
41
+ ? session != null && session > now ? session : null
42
+ : nextWeeklyReset(familyReset ?? weeklyWindow(account)?.resetsAt ?? null, now);
43
+ return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
123
44
  }
124
45
 
125
- export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
126
- const activeAccount = readOAuthAccount()?.accountUuid ?? null;
46
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), anticipatory = false, enforced: EnforcedLimit | null = null): Promise<SwapDecision> {
47
+ const activeId = p.liveId();
127
48
 
128
- const lastSwapAt = loadLastSwapAt();
49
+ const lastSwapAt = loadLastSwapAt(p.pool);
129
50
  if (!enforced && lastSwapAt != null && now - lastSwapAt < POST_SWAP_COOLDOWN_MS) {
130
- return depletedReplay(now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
51
+ return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "post-swap-cooldown" };
131
52
  }
132
53
 
133
54
  const cfg = loadConfig();
134
55
  const bars = thresholdBars(cfg);
135
-
136
- const { u: teeUsage, uAt } = await loadFreshSnapshots(cfg, activeAccount, now, enforced == null);
137
- const stored = loadAccounts().accounts.find((a) => a.accountUuid === activeAccount);
138
- const usage = freshest(teeUsage, uAt, stored);
139
-
140
- if (!enforced && !isOver(usage, stored, bars, cfg, now)) {
141
- const measured = usage != null && activeAccount != null && usage.account === activeAccount;
142
- if (!measured) {
143
- const replay = depletedReplay(now);
56
+ const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
57
+ const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
58
+ const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
59
+ const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
60
+
61
+ if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg) })) {
62
+ if (!observed) {
63
+ const replay = depletedReplay(p, now);
144
64
  if (replay) return replay;
145
65
  }
146
66
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
147
67
  }
148
68
 
149
- return withLock(paths.lockFile, async () => {
150
- const idx = loadAccounts();
151
- const account2 = readOAuthAccount()?.accountUuid ?? null;
152
- const tee = loadUsageSnapshot();
153
- const active = account2 ? idx.accounts.find((a) => a.accountUuid === account2) : undefined;
154
- const u2 = tee ? freshest(tee.state, tee.at, active) : usage;
69
+ return withLock(p.pool.lockFile, async () => {
70
+ const lastSwapAt2 = loadLastSwapAt(p.pool);
71
+ if (!enforced && lastSwapAt2 != null && now - lastSwapAt2 < POST_SWAP_COOLDOWN_MS) {
72
+ return { swapped: false, account: null, reason: "raced-already-swapped" };
73
+ }
74
+ const idx = loadAccounts(p.pool);
75
+ const id2 = p.liveId();
76
+ const active = id2 ? idx.accounts.find((a) => a.id === id2) : undefined;
155
77
 
156
- const origin = enforced ? idx.accounts.find((a) => a.accountUuid === enforced.account) : undefined;
78
+ const origin = enforced ? idx.accounts.find((a) => a.id === enforced.account) : undefined;
157
79
  const prior = origin?.enforcedUntil != null && origin.enforcedUntil > now;
158
80
  if (enforced && origin) {
159
81
  origin.enforcedUntil = Math.max(origin.enforcedUntil ?? 0, enforcedWall(enforced, origin, now));
160
82
  origin.lastProbeAt = now;
161
- saveAccounts(idx);
83
+ saveAccounts(p.pool, idx);
162
84
  log("usage.enforced_limit", { kind: enforced.kind, family: enforced.family ?? undefined, resetsAt: origin.enforcedUntil, blind: prior || enforced.blind, live: origin === active });
163
85
  }
164
86
  const enforced2 = enforced && origin && origin === active ? enforced : null;
165
87
 
166
- if (account2 != null && !active) {
88
+ if (id2 != null && !active) {
167
89
  return { swapped: false, account: null, reason: "live-credential-not-in-pool" };
168
90
  }
169
91
 
170
- if (active && tee && tee.state.account === account2 && (active.lastUsageAt == null || tee.at >= active.lastUsageAt)) {
171
- active.lastUsageAt = tee.state.sampledAt ?? tee.at;
172
- active.lastUsage = keepRows({ fiveHour: tee.state.fiveHour, sevenDay: tee.state.sevenDay, perModel: {} }, active.lastUsage, active.lastUsageAt);
173
- saveAccounts(idx);
92
+ const obs2 = active ? await p.observeLive(active, cfg, now, { probe: false }) : null;
93
+ if (active && obs2 && (active.lastUsageAt == null || obs2.at > active.lastUsageAt)) {
94
+ active.windows = p.mergeWindows(obs2.windows, active.windows);
95
+ active.lastUsageAt = obs2.at;
96
+ saveAccounts(p.pool, idx);
174
97
  }
175
98
 
99
+ const families = p.gatedFamilies(cfg);
176
100
  const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
177
101
  const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
178
- const gated = blindScreen ? cfg.policy.switchModels : gatedFamilies(u2?.model ?? null, cfg.policy.switchModels);
102
+ const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
179
103
  const family = enforced?.family ?? null;
180
- const switchFamilies = family != null && !gated.includes(family) ? [...gated, family] : gated;
104
+ const switchFamilies = screened == null ? null : family != null && !screened.includes(family) ? [...screened, family] : screened;
181
105
 
182
- const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentAccountUuid: account2, switchFamilies });
183
- if (!enforced2 && !isOver(u2, active, bars, cfg, now) && !(enforced && seatExhausted)) {
184
- return depletedReplay(now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
106
+ const seatExhausted = active != null && isExhausted(active, { now, thresholds: bars, currentId: id2, families: switchFamilies });
107
+ if (!enforced2 && !isOver(active, obs2, { now, thresholds: bars, currentId: id2, families }) && !(enforced && seatExhausted)) {
108
+ return depletedReplay(p, now) ?? { swapped: false, account: null, reason: "raced-already-swapped" };
185
109
  }
186
110
 
187
- const seatOf = (idx2: { activeAccountUuid: string | null; accounts: Account[] }): Account | null =>
188
- idx2.accounts.find((a) => a.accountUuid === account2) ??
189
- idx2.accounts.find((a) => a.accountUuid === idx2.activeAccountUuid) ??
190
- null;
111
+ const seatOf = (cur: { activeId: string | null; accounts: Account[] }): Account | null =>
112
+ cur.accounts.find((a) => a.id === id2) ?? cur.accounts.find((a) => a.id === cur.activeId) ?? null;
191
113
 
114
+ const present = p.presentIds();
192
115
  const rejected = new Set<string>();
193
- const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.accountUuid));
116
+ const usable = (accounts: Account[]): Account[] => accounts.filter((a) => !rejected.has(a.id) && (a.id === id2 || !present.has(a.id)));
194
117
  const skipOrThrow = (e: unknown, candidate: Account): void => {
195
- if (!isSkippableSwapError(e)) throw e;
196
- rejected.add(candidate.accountUuid);
197
- log("decide.candidate_rejected", { account: candidate.accountUuid.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
118
+ if (p.classifySwapError(e) === "fatal") throw e;
119
+ rejected.add(candidate.id);
120
+ log("decide.candidate_rejected", { account: candidate.id.slice(0, 8), error: e instanceof Error ? e.message : String(e) });
198
121
  };
199
122
  while (true) {
200
- const cur = loadAccounts();
123
+ const cur = loadAccounts(p.pool);
201
124
  const seat = seatOf(cur);
202
- const ctx = { now, thresholds: bars, currentAccountUuid: seat?.accountUuid ?? null, switchFamilies };
125
+ const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies };
203
126
  const best = pickBest(usable(cur.accounts), ctx);
204
127
  if (!best) break;
205
128
  try {
206
- await performSwap(best);
129
+ await p.swap(best);
207
130
  } catch (e) {
208
131
  skipOrThrow(e, best);
209
132
  continue;
210
133
  }
211
- log("decide.swap", { account: best.accountUuid.slice(0, 8), enforced: enforced2 != null });
134
+ log("decide.swap", { account: best.id.slice(0, 8), enforced: enforced2 != null });
212
135
  return { swapped: true, account: best, reason: "swapped" };
213
136
  }
214
137
 
138
+ if (!p.waitsWhenDepleted) {
139
+ log("decide.depleted", { waitUntil: 0 });
140
+ return { swapped: false, account: null, reason: "all-depleted" };
141
+ }
142
+
215
143
  while (true) {
216
- const fresh = loadAccounts();
144
+ const fresh = loadAccounts(p.pool);
217
145
  const current = seatOf(fresh);
218
- const ctx = { now, thresholds: bars, currentAccountUuid: current?.accountUuid ?? null, switchFamilies };
146
+ const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies };
219
147
  const currentAt = current ? usableAt(current, ctx) : Number.POSITIVE_INFINITY;
220
148
  const other = pickEarliestReset(usable(fresh.accounts), ctx);
221
149
 
@@ -230,48 +158,32 @@ export async function evaluateAndMaybeSwap(now = Date.now(), anticipatory = fals
230
158
  return { swapped: false, account: null, reason: "all-depleted", ...(Number.isFinite(waitUntil) ? { waitUntil } : {}) };
231
159
  }
232
160
 
233
- const isCurrent = target.accountUuid === (current?.accountUuid ?? null);
161
+ const isCurrent = target.id === (current?.id ?? null);
234
162
  if (!isCurrent && !anticipatory) {
235
- log("decide.depleted_no_park", { account: target.accountUuid.slice(0, 8), waitUntil });
163
+ log("decide.depleted_no_park", { account: target.id.slice(0, 8), waitUntil });
236
164
  return { swapped: false, account: null, reason: "all-depleted", waitUntil };
237
165
  }
238
166
  if (!isCurrent) {
239
167
  try {
240
- await performSwap(target);
168
+ await p.swap(target);
241
169
  } catch (e) {
242
170
  skipOrThrow(e, target);
243
171
  continue;
244
172
  }
245
173
  }
246
- saveDepletedWait({ waitUntil, accountUuid: target.accountUuid, ts: now });
247
- log("decide.depleted_wait", { account: target.accountUuid.slice(0, 8), waitUntil });
174
+ saveDepletedWait({ waitUntil, id: target.id, ts: now });
175
+ log("decide.depleted_wait", { account: target.id.slice(0, 8), waitUntil });
248
176
  return { swapped: !isCurrent, account: target, reason: "depleted-wait", waitUntil };
249
177
  }
250
178
  });
251
179
  }
252
180
 
253
- function depletedReplay(now: number): SwapDecision | null {
181
+ function depletedReplay(p: Provider, now: number): SwapDecision | null {
182
+ if (!p.waitsWhenDepleted) return null;
254
183
  const rec = loadDepletedWait();
255
184
  if (!rec || rec.waitUntil <= now) return null;
256
- const account = loadAccounts().accounts.find((a) => a.accountUuid === rec.accountUuid) ?? null;
185
+ const account = loadAccounts(p.pool).accounts.find((a) => a.id === rec.id) ?? null;
257
186
  if (!account) return null;
258
- if (account.accountUuid !== (readOAuthAccount()?.accountUuid ?? null)) return null;
187
+ if (account.id !== p.liveId()) return null;
259
188
  return { swapped: false, account, reason: "depleted-wait", waitUntil: rec.waitUntil };
260
189
  }
261
-
262
- function enforcedWall(limit: EnforcedLimit, account: Account, now: number): number {
263
- const cached = account.lastUsage;
264
- const { family } = limit;
265
- const familyReset =
266
- family == null
267
- ? null
268
- : Object.entries(cached?.perModel ?? {})
269
- .filter(([k]) => familyTokens(k).includes(family))
270
- .map(([, w]) => w.resetsAt)
271
- .find((r): r is number => r != null) ?? null;
272
- const cachedReset =
273
- limit.kind === "session"
274
- ? cached?.fiveHour.resetsAt != null && cached.fiveHour.resetsAt > now ? cached.fiveHour.resetsAt : null
275
- : nextWeeklyReset(familyReset ?? cached?.sevenDay.resetsAt ?? null, now);
276
- return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
277
- }
package/src/lib/oauth.ts CHANGED
@@ -40,6 +40,13 @@ export function isDeadCredential(creds: OAuthCreds): boolean {
40
40
  return creds.refreshToken === "" || creds.accessToken === "";
41
41
  }
42
42
 
43
+ export function claudeTierLabel(input: { subscriptionType?: string; rateLimitTier?: string }): string | null {
44
+ const segments = input.rateLimitTier?.split("_") ?? [];
45
+ const multiplier = segments.find((seg) => seg.length > 1 && seg.endsWith("x") && Number.isInteger(Number(seg.slice(0, -1))));
46
+ if (input.subscriptionType == null) return multiplier ?? null;
47
+ return multiplier ? `${input.subscriptionType} ${multiplier}` : input.subscriptionType;
48
+ }
49
+
43
50
  export async function refreshCredential(creds: OAuthCreds, now = Date.now(), signal?: AbortSignal): Promise<OAuthCreds> {
44
51
  if (isDeadCredential(creds)) throw new InvalidGrantError("credential was cleared after a failed refresh");
45
52
  const scope = (creds.scopes?.length ? creds.scopes : DEFAULT_SCOPES).join(" ");
package/src/lib/paths.ts CHANGED
@@ -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;
@@ -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
+ };