tokenmaxxing 1.45.0 → 1.46.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 CHANGED
@@ -95,7 +95,7 @@ The [profile](docs/content/docs/switching-profile.mdx) records observed cache re
95
95
 
96
96
  ## 6. Honest papercuts
97
97
  - **A move restarts the process.** Every move is a `claude` stop and `--resume`; anything typed in the split second before the SIGTERM is lost, the supervisor resets terminal mode so nothing is left garbled, and the first turn on the new account re-uploads context once (prompt cache is org-scoped). A bar-triggered move compacts on the old account first so that upload is the summary, not the transcript; a move after a refusal cannot, and re-uploads the full context.
98
- - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. Projected threshold reduces this.
98
+ - **Single-turn overshoot.** If one turn jumps from under the threshold straight past the wall, that turn ends rate-limited; the StopFailure hook walls the seat and moves the session, and the next prompt continues the conversation on the fresh account. The static session-bar margin (`policy.projectionMargin`, default 3) reduces this.
99
99
  - **Unsupervised sessions are not moved.** A claude started outside the supervisor runs on whatever login its environment names, by default Claude Code's default store, which tokenmaxxing never writes.
100
100
  - **One shared identity file.** `~/.claude.json` keeps a single `oauthAccount`, which Claude Code rewrites after whichever session refreshed last, so `/status` can show another session's email. The store decides which credential a session uses.
101
101
  - **Dead stores.** A refresh Claude Code cannot complete dead-clears the store; the sampler and the decision recognize the empty tokens without a request, flag the account, and `auth` repairs it in place.
package/README.md CHANGED
@@ -101,7 +101,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
101
101
  {
102
102
  "thresholds": { "session": 90, "weekly": 98 },
103
103
  "policy": {
104
- "projectionMargin": 0,
104
+ "projectionMargin": 3,
105
105
  "switchModels": ["fable"],
106
106
  "usagePollTtlMs": 90000,
107
107
  "checkIntervalMs": 60000
@@ -109,7 +109,7 @@ See [How switching decides](docs/content/docs/switching.mdx) for the policy and
109
109
  }
110
110
  ```
111
111
 
112
- `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from each threshold bar (effective bar = threshold - margin), so a large turn is less likely to blow past a bar between checks; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
112
+ `thresholds.session` and `thresholds.weekly` are the two bars, one number each (an array `thresholds.session`, the former ladder, fails config loading with a message naming the field); `projectionMargin` is a fixed safety margin subtracted from the session bar only (default 3), so a large turn is less likely to blow past the 5-hour bar between checks; the weekly bar takes no margin because one turn cannot overshoot a week; `switchModels` names the models whose per-model cap triggers a move; `usagePollTtlMs` is how long a `/usage` attempt stays fresh, for a seat's per-model poll and for the account each check tick samples; `maxWaitMs` bounds the depleted-pool countdown - a soonest reset further out than this does not pause the session (no respawn marker is written and the session simply keeps hitting its limit until an account recovers); `checkIntervalMs` is the periodic check tick (default 60s), which `init` writes into the timer - re-run `tokenmaxxing init` after changing it so the timer unit picks up the new tick.
113
113
 
114
114
  State lives entirely in `~/.config/tokenmaxxing/`. Each account's credential store is `stores/<uuid8>/`, which Claude Code reads and refreshes as its own: on macOS the credential is a login-keychain item keyed by the store path (never plaintext on disk), on Linux a 0600 `.credentials.json` inside the store (the same plaintext model claude itself uses for `~/.claude/.credentials.json`).
115
115
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.45.0",
3
+ "version": "1.46.0",
4
4
  "description": "Automatic Claude Code account switching: pool multiple accounts, start each session on its own account, and resume a session on a fresher account when quota fills.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/lib/picker.ts CHANGED
@@ -6,7 +6,7 @@ import { AccountSchema, ThresholdsSchema, type Account, type Config, type Thresh
6
6
  export function thresholdBars(cfg: Config): Thresholds {
7
7
  return {
8
8
  session: cfg.thresholds.session - cfg.policy.projectionMargin,
9
- weekly: cfg.thresholds.weekly - cfg.policy.projectionMargin,
9
+ weekly: cfg.thresholds.weekly,
10
10
  };
11
11
  }
12
12
 
@@ -87,11 +87,22 @@ export function earliestReset(a: Account, now: number): number {
87
87
  return Math.min(session != null && session > now ? session : Number.POSITIVE_INFINITY, weeklyExpiry(a, now));
88
88
  }
89
89
 
90
- export function pacePressure(a: Account, now: number): number {
90
+ function windowResetAt(w: Window, now: number): number | null {
91
+ if (w.resetsAt != null) return nextWeeklyReset(w.resetsAt, now);
92
+ if (w.windowSeconds != null) return w.sampledAt + w.windowSeconds * 1000;
93
+ return null;
94
+ }
95
+
96
+ export function pacePressure(a: Account, ctx: PickCtx): number {
97
+ const binding = minBy(gatedWindows(a, ctx.families), (w) => Math.max(0, 100 - liveUsed(w, ctx.now)));
98
+ if (binding != null) {
99
+ const reset = windowResetAt(binding, ctx.now);
100
+ if (reset != null) return Math.max(0, 100 - liveUsed(binding, ctx.now)) / Math.max(1, reset - ctx.now);
101
+ }
91
102
  const weekly = weeklyWindow(a);
92
- const reset = nextWeeklyReset(weekly?.resetsAt ?? null, now);
103
+ const reset = nextWeeklyReset(weekly?.resetsAt ?? null, ctx.now);
93
104
  if (weekly == null || reset == null) return 0;
94
- return Math.max(0, 100 - liveUsed(weekly, now)) / Math.max(1, reset - now);
105
+ return Math.max(0, 100 - liveUsed(weekly, ctx.now)) / Math.max(1, reset - ctx.now);
95
106
  }
96
107
 
97
108
  export function seatHeadroom(a: Account, ctx: PickCtx): number {
@@ -102,7 +113,7 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
102
113
 
103
114
  const swapPreference = (ctx: PickCtx) => [
104
115
  ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
105
- (a: Account) => -pacePressure(a, ctx.now),
116
+ (a: Account) => -pacePressure(a, ctx),
106
117
  (a: Account) => weeklyExpiry(a, ctx.now),
107
118
  (a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
108
119
  ];
@@ -116,7 +127,7 @@ export function currentWins(active: Account | null, accounts: Account[], ctx: Pi
116
127
  if (!active || active.needsReauth || isExhausted(active, ctx)) return false;
117
128
  const best = pickBest(accounts, { ...ctx, currentId: null });
118
129
  if (best == null || best.id === active.id) return true;
119
- if (margin > 1) return pacePressure(best, ctx.now) <= pacePressure(active, ctx.now) * margin;
130
+ if (margin > 1) return pacePressure(best, ctx) <= pacePressure(active, ctx) * margin;
120
131
  return swapPreference(ctx).every((k) => k(active) === k(best));
121
132
  }
122
133
 
package/src/lib/types.ts CHANGED
@@ -114,7 +114,7 @@ export const ConfigSchema = z
114
114
  opencodeBin: z.string().default(""),
115
115
  policy: z
116
116
  .object({
117
- projectionMargin: z.number().min(0).max(100).default(0),
117
+ projectionMargin: z.number().min(0).max(100).default(3),
118
118
  switchModels: z
119
119
  .array(z.string())
120
120
  .default(["fable"])
@@ -125,9 +125,9 @@ export const ConfigSchema = z
125
125
  })
126
126
  .prefault({}),
127
127
  })
128
- .refine((cfg) => cfg.policy.projectionMargin < Math.min(cfg.thresholds.session, cfg.thresholds.weekly), {
128
+ .refine((cfg) => cfg.policy.projectionMargin < cfg.thresholds.session, {
129
129
  path: ["policy", "projectionMargin"],
130
- message: "must be strictly below both thresholds (the bars would hit zero and every account would read as exhausted)",
130
+ message: "must be strictly below the session threshold (the session bar would hit zero and every account would read as exhausted)",
131
131
  });
132
132
  export type Config = z.infer<typeof ConfigSchema>;
133
133