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 +2 -2
- package/package.json +1 -1
- package/src/lib/claude.ts +9 -2
- package/src/lib/codex.ts +63 -15
- package/src/lib/decide.ts +2 -2
- package/src/lib/picker.ts +35 -5
- package/src/lib/provider.ts +1 -0
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
182
|
-
(a: Account) =>
|
|
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
|
-
|
|
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 {
|
package/src/lib/provider.ts
CHANGED
|
@@ -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[];
|