tokenmaxxing 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/DESIGN.md CHANGED
@@ -48,7 +48,7 @@ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limi
48
48
 
49
49
  ### 3.2 Detect + swap + signal (Stop hook, per turn)
50
50
  1. Read `usage.json`; `exit 0` fast if both windows `< 95%` (metered per `organizationUuid`).
51
- 2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (lowest 7-day, not rate-limited, soonest `resets_at` tiebreak), and **swap the credential** (§3.4).
51
+ 2. Else take a `flock` on `~/.config/tokenmaxxing/lock`, re-check under it (parallel sessions race - first winner already swapped), pick the best parked account (not rate-limited, soonest-expiring weekly window first since unused allowance is forfeited at the fixed per-account reset, lowest 7-day usage tiebreak), and **swap the credential** (§3.4).
52
52
  3. Write `respawn/<session_id>` (atomic temp+rename) and `SIGTERM` the parent `claude` (`kill -TERM $PPID`). The turn is already committed, so this is a clean stop.
53
53
 
54
54
  ### 3.3 Respawn (supervisor)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts and hot-swap when quota fills, resuming your session on the fresh account.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/picker.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  // Choose the best account to switch TO when the active one crosses threshold.
2
2
  // Policy: exclude the current account and any that need reauth or are still
3
3
  // rate-limited (usage >= threshold and not yet past resets_at). Among the rest,
4
- // prefer lowest 7-day usage; tiebreak on soonest resets_at.
4
+ // prefer the account whose weekly window expires soonest: weekly limits reset
5
+ // at a fixed per-account time and unused allowance is forfeited at reset, so
6
+ // quota nearest its reset is use-it-or-lose-it and should be drained first.
7
+ // Tiebreak on lowest 7-day usage, then soonest 5h reset.
5
8
 
6
9
  import { minBy, sortBy } from "es-toolkit";
7
10
  import { z } from "zod";
@@ -23,6 +26,18 @@ export function isExhausted(a: Account, ctx: PickCtx): boolean {
23
26
  return blocked(u.fiveHour) || blocked(u.sevenDay);
24
27
  }
25
28
 
29
+ const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
30
+
31
+ /** Epoch ms when the account's weekly quota is next forfeited. The weekly reset
32
+ * is a fixed per-account anchor, so a stale (past) resetsAt extrapolates
33
+ * forward in 7-day steps; an account with no sampled reset sorts last. */
34
+ export function weeklyExpiry(a: Account, now: number): number {
35
+ const r = a.lastUsage?.sevenDay.resetsAt;
36
+ if (r == null) return Number.POSITIVE_INFINITY;
37
+ if (r > now) return r;
38
+ return r + (Math.floor((now - r) / WEEK_MS) + 1) * WEEK_MS;
39
+ }
40
+
26
41
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
27
42
  const candidates = accounts.filter(
28
43
  (a) =>
@@ -32,8 +47,9 @@ export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
32
47
  );
33
48
  if (candidates.length === 0) return null;
34
49
 
35
- // lowest 7-day usage first; tiebreak on soonest 5h reset.
50
+ // soonest weekly expiry first; tiebreak lowest 7-day usage, then soonest 5h reset.
36
51
  return sortBy(candidates, [
52
+ (a) => weeklyExpiry(a, ctx.now),
37
53
  (a) => a.lastUsage?.sevenDay.usedPercentage ?? 0,
38
54
  (a) => a.lastUsage?.fiveHour.resetsAt ?? Number.POSITIVE_INFINITY,
39
55
  ])[0]!;
package/src/lib/usage.ts CHANGED
@@ -86,12 +86,16 @@ function zonedWallToEpoch(y: number, mon: number, day: number, hour: number, min
86
86
 
87
87
  /**
88
88
  * Parse a `/usage` reset clock like `Jul 11 at 12pm (Asia/Seoul)` or
89
- * `Jul 9 at 11:20pm (Asia/Seoul)` to epoch ms. The text carries no year, so we
90
- * pick the year whose resulting instant is nearest `now` (resets are always days
91
- * away, so the correct year wins by ~360 days). Returns null if unparseable.
89
+ * `Jul 10, 3:30pm (Asia/Seoul)` to epoch ms. The day-time glue is not stable
90
+ * across claude installs (observed on 2.1.206: ` at ` on macOS, `, ` on Linux);
91
+ * exactly those two glues are accepted, same-line only, so a third drift shows
92
+ * up as an unparsed clock (and the usage.reset_clock_unparsed log) instead of a
93
+ * guessed instant. The text carries no year, so we pick the year whose resulting
94
+ * instant is nearest `now` (resets are always days away, so the correct year
95
+ * wins by ~360 days). Returns null if unparseable.
92
96
  */
93
97
  export function parseResetClock(clock: string, now = Date.now()): number | null {
94
- const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})\s+at\s+(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
98
+ const m = clock.match(/\b([A-Za-z]{3,9})\s+(\d{1,2})(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)(\d{1,2})(?::(\d{2}))?\s*([ap])m\s*\(([^)]+)\)/i);
95
99
  if (!m) return null;
96
100
  const mon = MONTHS[m[1]!.slice(0, 3).toLowerCase()];
97
101
  if (mon === undefined) return null;
@@ -123,11 +127,15 @@ export function parseResetClock(clock: string, now = Date.now()): number | null
123
127
  */
124
128
  export function parseUsageTextFull(text: string, now = Date.now()): FullUsage | null {
125
129
  if (!text) return null;
126
- // The reset clock is required in full (month day at h[:mm]am/pm (tz)) inside its
127
- // optional group, so the lazy bridge is forced to find it when present yet the
128
- // group cleanly skips a line that has no clock - and it never swallows a
129
- // following "Current" entry on a single line.
130
- const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:[^\n]*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}\s+at\s+\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
130
+ // The reset clock is required in full (month day[, | at ]h[:mm]am/pm (tz))
131
+ // inside its optional group, so the lazy bridge is forced to find it when
132
+ // present yet the group cleanly skips a line that has no clock. The bridge
133
+ // refuses to cross another "Current" so a dateless clock ("resets Jul 8.")
134
+ // can never steal the NEXT entry's clock or swallow that entry. The day-time
135
+ // glue is not stable across installs (observed on 2.1.206: ` at ` on macOS,
136
+ // `, ` on Linux); exactly those two are accepted, same-line only, so a third
137
+ // drift surfaces in the usage.reset_clock_unparsed log instead of misparsing.
138
+ const re = /current (session|week \(([^)]+)\)):\s*(\d+)\s*%(?:(?:(?!current)[^\n])*?\bresets\s+([A-Z][a-z]{2,8}\s+\d{1,2}(?:[^\S\n]+at[^\S\n]+|,[^\S\n]*)\d{1,2}(?::\d{2})?\s*[ap]m\s*\([^)]+\)))?/gi;
131
139
  let session: UsageWindow | null = null;
132
140
  let weekAll: UsageWindow | null = null;
133
141
  const perModel: Record<string, UsageWindow> = {};
@@ -190,8 +198,18 @@ async function probeUsageOnce(env: Record<string, string>, now: number): Promise
190
198
  }
191
199
 
192
200
  const j = z.object({ result: z.string() }).safeParse((() => { try { return JSON.parse(out); } catch { return null; } })());
193
- const full = parseUsageTextFull(j.success ? j.data.result : out, now);
194
- if (!full) log("usage.probe_unparsed", { sample: out.trim().slice(0, 120) });
201
+ const text = j.success ? j.data.result : out;
202
+ const full = parseUsageTextFull(text, now);
203
+ if (!full) {
204
+ log("usage.probe_unparsed", { sample: text.trim().slice(0, 200) });
205
+ } else {
206
+ const clockLine = text.split("\n").find((l) => /^current /i.test(l) && /\bresets\b/i.test(l));
207
+ if (clockLine && [full.session, full.weekAll, ...Object.values(full.perModel)].every((w) => w.resetsAt === null)) {
208
+ // Percentages parsed but every reset clock was dropped: the clock format
209
+ // drifted again. Log the line so the next drift is visible in the log.
210
+ log("usage.reset_clock_unparsed", { sample: clockLine.slice(0, 120) });
211
+ }
212
+ }
195
213
  return full;
196
214
  }
197
215