tokenmaxxing 1.171.0 → 1.173.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tokenmaxxing
2
2
 
3
- **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with the most headroom per running session, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
3
+ **Automatic Claude Code account switching.** Run `claude` exactly as you always do; each session starts on the pooled account with room for it whose weekly quota is closest to expiring unused, and when that account nears its usage limit, tokenmaxxing resumes the session on a fresher account at a safe turn boundary - same conversation, compacted on the account it leaves and restarted under another account's credential store with a first prompt that asks Claude to continue. Works across many concurrent sessions, which spread over the pool instead of draining one account together. When the whole pool is at its limit, a session queues first come first served for a reset (at most eight sessions wait on one account; overflow waits for the next reset with room) and pauses with a countdown until its assigned reset, when that reset falls within twice `policy.maxWaitMs` (default 1h - a longer wait stays put rather than parking your terminal for hours).
4
4
 
5
5
  > **Scope:** Claude Code on macOS and Linux, plus OpenAI's Codex CLI as a second switching pool; grok and Muse Code as status-only pools. It pools **subscription** accounts (Pro/Max), not API keys.
6
6
 
@@ -62,7 +62,7 @@ Each session runs on its own seat. One rule: while the seat is under its bars, n
62
62
 
63
63
  The bars' headroom is deliberate: it's the budget to reach a clean turn boundary before the account's real limit. The session bar sits lower (90) because a 5-hour reset is cheap to sit out; weekly quota is use-it-or-lose-it, so it drains closer to the limit (98). The default margin is 0, so the configured thresholds and the effective bars read the same.
64
64
 
65
- Launches and moves rank usable accounts by session-window headroom per running session, `(bar - used) / (sessions + 1)`, so two fresh accounts alternate and a third session goes to whichever has more headroom left per session; ties break by pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset, highest first), then soonest weekly reset, then lowest weekly use. Organization membership is not an input.
65
+ Launches and moves rank usable accounts in groups first: plan headroom before usage credits, a credential store that read cleanly, room for a whole session (session-window headroom per running session, `(bar - used) / (sessions + 1)`, scaled by plan size, at least 15 unless the session window resets within the hour), a weekly reset within 4 days, and a usage figure under 6 hours old. Within a group, the highest pace pressure scaled by plan size wins (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, divided by time to its reset), so weekly quota closest to expiring unused is spent first; ties break by soonest weekly reset, then headroom per session, then lowest weekly use, then fewest sessions. Plan size reads the account's tier: a Claude multiplier divided by 5 (`max 20x` is 4, `pro` 0.2), Codex relative to Plus (`prolite` 5, `pro` 10, `promax` 20). Organization membership is not an input.
66
66
 
67
67
  - Every periodic check tick folds fresh session tees (the statusline of a terminal session, the `rate_limit_event` stream of a T3 Code or Agent SDK session) into the index and samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval (15 minutes for an empty account or one with no session on this host, shrinking to `policy.usagePollTtlMs` as a live account nears a bar, and 15 minutes again at or over an aggregate bar), with the direct no-spend usage read; a move reads the cached figures, which are as fresh as each account's last successful attempt or its sessions' latest push. A sample that finds the stored access token expired has Claude Code refresh it first (`claude -p /usage` under that store) and reads with the new token.
68
68
  - When no account is usable, the session queues first come first served onto a reset target (at most eight waiters per account; overflow waits for the next reset with room) and pauses until that reset if it lands within twice `policy.maxWaitMs`, and otherwise stays put.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.171.0",
3
+ "version": "1.173.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/claude.ts CHANGED
@@ -140,6 +140,12 @@ async function storeUsable(a: Account): Promise<boolean> {
140
140
  }
141
141
  }
142
142
 
143
+ export function claudePlanWeight(a: Account): number {
144
+ const multiplier = a.tier?.match(/(\d+)x\b/)?.[1];
145
+ if (multiplier != null) return Number(multiplier) / 5;
146
+ return a.tier?.startsWith("pro") ? 0.2 : 1;
147
+ }
148
+
143
149
  export type Seat = { account: Account; depletedUntil?: number };
144
150
 
145
151
  export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Account) => boolean = () => true): Seat | null {
@@ -150,7 +156,7 @@ export function pickSeat(now: number, model: ModelInfo | null, eligible: (a: Acc
150
156
  for (const a of idx.accounts) dirty = foldTee(a, bars) || dirty;
151
157
  if (dirty) saveAccounts(claudePool, idx);
152
158
  const seats = presence();
153
- const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats };
159
+ const ctx: PickCtx = { now, thresholds: bars, currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats, planWeight: claudePlanWeight };
154
160
  const candidates = idx.accounts.filter(eligible);
155
161
  const best = pickBest(candidates, ctx);
156
162
  if (best) return { account: best };
@@ -204,7 +210,7 @@ export async function borrowClaudeSeat(pid: number, named?: string): Promise<Sea
204
210
  }
205
211
  const open = idx.accounts.filter((a) => a.needsReauth !== true);
206
212
  const usable = await Promise.all(open.map(storeUsable));
207
- const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living) };
213
+ const ctx: PickCtx = { now: Date.now(), thresholds: bars, currentId: null, families: gatedFamilies(null, cfg.policy.switchModels), seats: presence(living), planWeight: claudePlanWeight };
208
214
  const picked = pickBest(open.filter((_, i) => usable[i]), ctx);
209
215
  if (!picked) return null;
210
216
  writePresence({ dir: paths.presenceDir, id: seatId, accountId: picked.id, pid });
@@ -361,6 +367,7 @@ export const claude: Provider = {
361
367
  apiKeys: claudeApiKeys,
362
368
  liveId,
363
369
  presence,
370
+ planWeight: claudePlanWeight,
364
371
  gatedFamilies: (cfg) => {
365
372
  const seat = liveId();
366
373
  const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
package/src/lib/codex.ts CHANGED
@@ -41,13 +41,22 @@ function applyUsage(account: Account, usage: CodexUsage, at: number): void {
41
41
  if (usage.planType != null) account.tier = usage.planType;
42
42
  }
43
43
 
44
- type CodexReadFailure = { ok: false; reason: string; deadGrant: boolean; expiring?: boolean; rejectedToken?: string };
44
+ type CodexReadFailure = { ok: false; reason: string; deadGrant: boolean; deadAccessToken?: string; expiring?: boolean; rejected?: boolean; rejectedToken?: string };
45
45
  type CodexReadOutcome = { ok: true; usage: CodexUsage; at: number } | CodexReadFailure;
46
46
 
47
47
  const REJECTED_REFRESH_BACKOFF_MS = 60 * 60 * 1000;
48
48
 
49
49
  function afterRejection(rejected: CodexReadFailure, refreshFailure: CodexReadFailure): CodexReadFailure {
50
- return { ...refreshFailure, reason: `${rejected.reason}; refresh skipped: ${refreshFailure.reason}` };
50
+ return { ...refreshFailure, rejected: true, reason: `${rejected.reason}; refresh skipped: ${refreshFailure.reason}` };
51
+ }
52
+
53
+ function storeHolds(accountId: string, accessToken: string | undefined): boolean {
54
+ if (accessToken === undefined) return true;
55
+ try {
56
+ return readCodexStoreAuth(accountId)?.tokens.access_token === accessToken;
57
+ } catch {
58
+ return false;
59
+ }
51
60
  }
52
61
 
53
62
  async function freshCodexAuth(account: Account, now: number, refresh: boolean, holder?: string, rejectedToken?: string): Promise<{ ok: true; auth: CodexAuthJson } | CodexReadFailure> {
@@ -70,7 +79,7 @@ async function freshCodexAuth(account: Account, now: number, refresh: boolean, h
70
79
  try {
71
80
  auth = await refreshCodexAuth({ auth, now });
72
81
  } catch (e) {
73
- if (e instanceof CodexInvalidGrantError) return { ok: false, reason: e.message, deadGrant: true };
82
+ if (e instanceof CodexInvalidGrantError) return { ok: false, reason: e.message, deadGrant: true, deadAccessToken: auth.tokens.access_token };
74
83
  if (e instanceof CodexRefreshFailedError) return { ok: false, reason: e.message, deadGrant: false };
75
84
  throw e;
76
85
  }
@@ -85,22 +94,25 @@ async function readCodexUsageOnce(account: Account, now: number, refresh: boolea
85
94
  const at = Date.now();
86
95
  return { ok: true, usage: await fetchCodexUsage({ auth: fresh.auth, at }), at };
87
96
  } catch (e) {
88
- if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false, rejectedToken: e.status === 401 ? fresh.auth.tokens.access_token : undefined };
97
+ if (e instanceof CodexUsageReadError) return { ok: false, reason: e.message, deadGrant: false, rejected: e.status === 401, rejectedToken: e.status === 401 ? fresh.auth.tokens.access_token : undefined };
89
98
  throw e;
90
99
  }
91
100
  }
92
101
 
93
102
  async function readCodexUsage(account: Account, now: number, refresh: boolean, holder?: string): Promise<CodexReadOutcome> {
94
103
  const outcome = await readCodexUsageOnce(account, now, refresh, holder);
95
- if (outcome.ok || outcome.rejectedToken === undefined || !refresh) return outcome;
104
+ if (outcome.ok || outcome.rejectedToken === undefined) return outcome;
96
105
  const { rejectedToken } = outcome;
97
106
  const fresh = await withLock(codexPool.lockFile, () => freshCodexAuth(account, now, true, holder, rejectedToken));
98
- return fresh.ok ? readCodexUsageOnce(account, now, false) : afterRejection(outcome, fresh);
107
+ if (!fresh.ok) return afterRejection(outcome, fresh);
108
+ const retried = await readCodexUsageOnce(account, now, false);
109
+ return retried.ok ? retried : { ...retried, rejectedToken: undefined };
99
110
  }
100
111
 
101
- export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean; holder?: string }): Promise<Observation | null> {
112
+ export async function observeCodex(account: Account, cfg: Config, now: number, opts: { probe: boolean; refresh: boolean; holder?: string; rejected?: Set<string> }): Promise<Observation | null> {
102
113
  if (opts.probe && (account.lastUsageAt == null || now - account.lastUsageAt > cfg.policy.usagePollTtlMs)) {
103
114
  const outcome = await readCodexUsage(account, now, opts.refresh, opts.holder);
115
+ if (!outcome.ok && outcome.rejected === true) opts.rejected?.add(account.id);
104
116
  await withLock(codexPool.lockFile, () => {
105
117
  const idx = loadAccounts(codexPool);
106
118
  const a = idx.accounts.find((x) => x.id === account.id);
@@ -109,7 +121,7 @@ export async function observeCodex(account: Account, cfg: Config, now: number, o
109
121
  if (a.lastUsageAt == null || outcome.at > a.lastUsageAt) {
110
122
  applyUsage(a, outcome.usage, outcome.at);
111
123
  }
112
- } else if (outcome.deadGrant) {
124
+ } else if (outcome.deadGrant && storeHolds(a.id, outcome.deadAccessToken)) {
113
125
  a.needsReauth = true;
114
126
  }
115
127
  saveAccounts(codexPool, idx);
@@ -126,23 +138,31 @@ async function samplePool(accounts: Account[], now: number): Promise<Map<string,
126
138
  applyUsage(account, outcome.usage, outcome.at);
127
139
  reports.set(account.id, { ok: true, source: "probe" });
128
140
  } else {
129
- if (outcome.deadGrant) account.needsReauth = true;
130
141
  reports.set(account.id, { ok: false, reason: outcome.reason });
131
142
  }
132
143
  };
133
144
  const expiring: { account: Account; rejected?: CodexReadFailure }[] = [];
134
145
  await Promise.all(
135
146
  accounts.map(async (account) => {
136
- const outcome = await readCodexUsage(account, now, false);
147
+ const outcome = await readCodexUsageOnce(account, now, false);
137
148
  if (!outcome.ok && (outcome.expiring === true || outcome.rejectedToken !== undefined)) expiring.push({ account, rejected: outcome.rejectedToken === undefined ? undefined : outcome });
138
149
  else record(account, outcome);
139
150
  }),
140
151
  );
141
152
  if (expiring.length > 0) {
142
- const rotated = await withLock(codexPool.lockFile, () => Promise.all(expiring.map(async ({ account, rejected }) => ({ account, rejected, fresh: await freshCodexAuth(account, now, true, undefined, rejected?.rejectedToken) }))));
153
+ const rotated = await withLock(codexPool.lockFile, async () => {
154
+ const results = await Promise.all(expiring.map(async ({ account, rejected }) => ({ account, rejected, fresh: await freshCodexAuth(account, now, true, undefined, rejected?.rejectedToken) })));
155
+ const dead = new Set(results.filter(({ fresh }) => !fresh.ok && fresh.deadGrant).map(({ account }) => account.id));
156
+ if (dead.size > 0) {
157
+ const idx = loadAccounts(codexPool);
158
+ for (const a of idx.accounts) if (dead.has(a.id)) a.needsReauth = true;
159
+ saveAccounts(codexPool, idx);
160
+ }
161
+ return results;
162
+ });
143
163
  await Promise.all(
144
164
  rotated.map(async ({ account, rejected, fresh }) =>
145
- record(account, fresh.ok ? await readCodexUsage(account, now, false) : rejected ? afterRejection(rejected, fresh) : fresh),
165
+ record(account, fresh.ok ? await readCodexUsageOnce(account, now, false) : rejected ? afterRejection(rejected, fresh) : fresh),
146
166
  ),
147
167
  );
148
168
  }
@@ -256,8 +276,31 @@ function install(): void {
256
276
  console.log(c.yellow("`tokenmaxxing doctor` lists seats still needing trust."));
257
277
  }
258
278
 
279
+ // Relative to Plus. prolite (Pro 100) is 5x per OpenAI's launch; pro (Pro 200) was 20x and is about 10x for new
280
+ // subscribers; promax (Pro 500) is unpublished beyond "the most". Business, Enterprise, and Edu seats match Plus.
281
+ const CODEX_PLAN_WEIGHTS: Record<string, number> = {
282
+ free: 0.2,
283
+ go: 0.5,
284
+ plus: 1,
285
+ team: 1,
286
+ business: 1,
287
+ enterprise: 1,
288
+ ent26: 1,
289
+ edu: 1,
290
+ edu_plus: 1,
291
+ prolite: 5,
292
+ self_serve_business_prolite: 5,
293
+ edu_pro: 5,
294
+ pro: 10,
295
+ promax: 20,
296
+ };
297
+
298
+ export function codexPlanWeight(a: Account): number {
299
+ return (a.tier == null ? undefined : CODEX_PLAN_WEIGHTS[a.tier.toLowerCase()]) ?? 1;
300
+ }
301
+
259
302
  export function codexPickCtx(now: number, currentId: string | null): PickCtx {
260
- return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null };
303
+ return { now, thresholds: thresholdBars(loadConfig()), currentId, families: null, seats: null, planWeight: codexPlanWeight };
261
304
  }
262
305
 
263
306
  export function pickCodexSeat(now: number, eligible: (a: Account) => boolean = (a) => codexStoreUsable(a.id)): Account | null {
@@ -271,10 +314,11 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
271
314
  const now = Date.now();
272
315
  const seatId = `seat-${pid}`;
273
316
  const cfg = loadConfig();
317
+ const rejected = new Set<string>();
274
318
  await Promise.all(
275
319
  loadAccounts(codexPool)
276
320
  .accounts.filter((a) => a.needsReauth !== true && codexStoreUsable(a.id))
277
- .map((a) => observeCodex(a, cfg, now, { probe: true, refresh: false })),
321
+ .map((a) => observeCodex(a, cfg, now, { probe: true, refresh: false, rejected })),
278
322
  );
279
323
  const granted = await withLock(codexPool.lockFile, (): SeatBorrow => {
280
324
  const ctx = codexPickCtx(now, null);
@@ -283,6 +327,9 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
283
327
  const held = living.find((p) => p.id === seatId);
284
328
  const heldAccount = held ? (idx.accounts.find((x) => x.id === held.accountId) ?? null) : null;
285
329
  if (heldAccount) {
330
+ if (rejected.has(heldAccount.id)) {
331
+ return { denied: "the usage endpoint rejects the account this pid holds and its store could not be refreshed now - borrow again later" };
332
+ }
286
333
  if (heldAccount.needsReauth === true) {
287
334
  return { denied: "the account this pid holds needs reauthentication - run `tokenmaxxing auth --codex` and borrow again" };
288
335
  }
@@ -295,7 +342,7 @@ export async function borrowCodexSeat(pid: number): Promise<SeatBorrow> {
295
342
  const closed = new Set(living.filter((p) => p.id === parent || p.id.startsWith(PI_PRESENCE_PREFIX)).map((p) => p.accountId));
296
343
  const lent = countBy(living, (p) => p.accountId);
297
344
  const usable = idx.accounts.filter(
298
- (a) => !closed.has(a.id) && a.needsReauth !== true && !isExhausted(a, ctx) && codexStoreUsable(a.id)
345
+ (a) => !closed.has(a.id) && !rejected.has(a.id) && a.needsReauth !== true && !isExhausted(a, ctx) && codexStoreUsable(a.id)
299
346
  );
300
347
  const plan = usable.filter((a) => !onCredits(a, ctx));
301
348
  const open = plan.length > 0 ? plan : usable;
@@ -319,6 +366,7 @@ export const codex: Provider = {
319
366
  statusOnly: false,
320
367
  liveId,
321
368
  presence,
369
+ planWeight: codexPlanWeight,
322
370
  gatedFamilies: () => null,
323
371
  observeLive: (account, cfg, now, opts) => observeCodex(account, cfg, now, { probe: opts.probe, refresh: true }),
324
372
  samplePool,
package/src/lib/decide.ts CHANGED
@@ -199,7 +199,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
199
199
  while (true) {
200
200
  const cur = loadAccounts(p.pool);
201
201
  const seat = seatOf(cur);
202
- const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats };
202
+ const ctx: PickCtx = { now, thresholds: bars, currentId: seat?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
203
203
  const credited = !enforced2 && seat != null && !seat.needsReauth && !isExhausted(seat, ctx) && onCredits(seat, ctx);
204
204
  const best = pickBest(usable(cur.accounts).filter((a) => !((credited || keyId != null) && opts.refused?.includes(a.id))), ctx);
205
205
  if (credited && (best == null || onCredits(best, ctx))) return { swapped: false, account: null, reason: "no-plan-headroom" };
@@ -232,7 +232,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
232
232
  while (true) {
233
233
  const fresh = loadAccounts(p.pool);
234
234
  const current = seatOf(fresh);
235
- const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats };
235
+ const ctx: PickCtx = { now, thresholds: bars, currentId: current?.id ?? null, families: switchFamilies, seats, planWeight: p.planWeight };
236
236
  const others = liveWaitClaims(now).filter((c) => c.sessionId !== waiterId);
237
237
  const waiters = new Map(Object.entries(countBy(others, (c) => c.accountId)));
238
238
  const waitable = usable(fresh.accounts).filter((a) => keyId == null || !opts.refused?.includes(a.id));
package/src/lib/picker.ts CHANGED
@@ -17,6 +17,7 @@ export type PickCtx = {
17
17
  currentId: string | null;
18
18
  families: string[] | null;
19
19
  seats: Map<string, number> | null;
20
+ planWeight?: (a: Account) => number;
20
21
  };
21
22
 
22
23
  const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
@@ -166,29 +167,58 @@ export function seatHeadroom(a: Account, ctx: PickCtx): number {
166
167
 
167
168
  const MIN_SEAT_HEADROOM = 5;
168
169
 
170
+ const planWeight = (a: Account, ctx: PickCtx): number => ctx.planWeight?.(a) ?? 1;
171
+
169
172
  function seatFull(a: Account, ctx: PickCtx): boolean {
170
173
  const session = sessionWindow(a);
171
174
  if (ctx.seats == null || session == null || spendsCredits(a, ctx.thresholds)) return false;
172
- return seatHeadroom(a, ctx) < MIN_SEAT_HEADROOM;
175
+ return seatHeadroom(a, ctx) * planWeight(a, ctx) < MIN_SEAT_HEADROOM;
173
176
  }
174
177
 
175
178
  export function onCredits(a: Account, ctx: PickCtx): boolean {
176
179
  return spendsCredits(a, ctx.thresholds) && isExhausted({ ...a, hasCredits: false }, ctx);
177
180
  }
178
181
 
182
+ const DAY_MS = 24 * 60 * 60 * 1000;
183
+ const WEEKLY_SOON_MS = 4 * DAY_MS;
184
+ const THREAD_HEADROOM = 15;
185
+ const SESSION_SOON_MS = 60 * 60 * 1000;
186
+ const STALE_MS = 6 * 60 * 60 * 1000;
187
+
188
+ function cramped(a: Account, ctx: PickCtx): boolean {
189
+ const session = sessionWindow(a);
190
+ if (session == null || spendsCredits(a, ctx.thresholds)) return false;
191
+ const end = windowEnd(session);
192
+ const resetsSoon = end > ctx.now && end - ctx.now <= SESSION_SOON_MS;
193
+ return !resetsSoon && seatHeadroom(a, ctx) * planWeight(a, ctx) < THREAD_HEADROOM;
194
+ }
195
+
196
+ // Judged per window, because a fresh statusline sample refreshes the session and weekly figures but carries an old per-model cap forward.
197
+ function stale(a: Account, ctx: PickCtx): boolean {
198
+ const read = [sessionWindow(a), weeklyWindow(a), ...gatedWindows(a, ctx.families)];
199
+ return read.some((w) => w != null && windowEnd(w) > ctx.now && ctx.now - w.sampledAt > STALE_MS);
200
+ }
201
+
179
202
  const swapPreference = (ctx: PickCtx) => [
180
203
  (a: Account) => (onCredits(a, ctx) ? 1 : 0),
181
- ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx)]),
182
- (a: Account) => -pacePressure(a, ctx),
204
+ (a: Account) => ((a.storeFails ?? 0) > 0 ? 1 : 0),
205
+ (a: Account) => (a.windows.length === 0 ? 1 : 0),
206
+ // A full account ranks after every open one, and among full accounts the most room per session wins.
207
+ (a: Account) => (seatFull(a, ctx) ? 1 : 0),
208
+ (a: Account) => (seatFull(a, ctx) ? -seatHeadroom(a, ctx) * planWeight(a, ctx) : 0),
209
+ (a: Account) => (cramped(a, ctx) ? 1 : 0),
210
+ (a: Account) => (weeklyExpiry(a, ctx.now) - ctx.now <= WEEKLY_SOON_MS ? 0 : 1),
211
+ (a: Account) => (stale(a, ctx) ? 1 : 0),
212
+ (a: Account) => -pacePressure(a, ctx) * planWeight(a, ctx),
183
213
  (a: Account) => weeklyExpiry(a, ctx.now),
214
+ ...(ctx.seats == null ? [] : [(a: Account) => -seatHeadroom(a, ctx) * planWeight(a, ctx)]),
184
215
  (a: Account) => weeklyWindow(a)?.usedPercentage ?? 101,
185
216
  ...(ctx.seats == null ? [] : [(a: Account) => ctx.seats?.get(a.id) ?? 0]),
186
217
  ];
187
218
 
188
219
  export function pickBest(accounts: Account[], ctx: PickCtx): Account | null {
189
220
  const usable = accounts.filter((a) => a.id !== ctx.currentId && !a.needsReauth && !isExhausted(a, ctx));
190
- const open = usable.filter((a) => !seatFull(a, ctx));
191
- return sortBy(open.length > 0 ? open : usable, swapPreference(ctx))[0] ?? null;
221
+ return sortBy(usable, swapPreference(ctx))[0] ?? null;
192
222
  }
193
223
 
194
224
  export function usableAt(a: Account, ctx: PickCtx): number {
@@ -36,6 +36,7 @@ export type Provider = {
36
36
  liveId(): string | null;
37
37
  presence(): Map<string, number>;
38
38
  gatedFamilies(cfg: Config): string[] | null;
39
+ planWeight?(account: Account): number;
39
40
  observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
40
41
  samplePool(accounts: Account[], now: number): Promise<Map<string, SampleReport>>;
41
42
  mergeWindows(next: Window[], prev: Window[]): Window[];