tokenmaxxing 1.58.0 → 1.59.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
@@ -90,7 +90,7 @@ The [switching policy](docs/content/docs/switching.mdx) is one rule: under both
90
90
 
91
91
  The [profile](docs/content/docs/switching-profile.mdx) records observed cache rewrites across swaps and does not infer subscription quota savings.
92
92
 
93
- **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the active model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move, and a launch, whose model is unknown, gates every configured family.
93
+ **Model-aware trigger.** Claude subscriptions also enforce **per-model weekly caps** - currently only for Sonnet and Fable (there is no Opus-only quota), and Fable's tighter limit binds *before* the aggregate (e.g. 80% week-Fable at only 50% week-all-models). This cap isn't in statusLine stdin, so when the session's model is in `policy.switchModels`, or the seat's tee is older than the TTL, we read it from the `limits[]` rows of the direct no-spend usage read for the seat (free, 0 tokens, one attempt with backoff, with the `/usage` probe under the seat's store refreshing the token when the read needs it) and add `week(<activeModel>) >= threshold` to the trigger. A Fable session moves on the Fable cap; a Sonnet session rides the aggregate. Candidate screening applies the same per-model gate: a burnt Fable cap screens an account out of a move. A session's model is the `--model` value it was launched with: the supervisor reads it from the launch flags, uses it for launch placement and the seat watch, and hands it to the session's hooks in `TOKENMAXXING_MODEL`, which the hooks read ahead of the per-account tee. With the default `policy.switchModels`, a session launched as Opus, Sonnet, or Haiku gates no per-model cap, so it runs on an account whose Fable cap is spent while the session and weekly windows have headroom. A launch without `--model`, or with a value that names no model family (`best`, `opusplan`), is unknown: launch placement and the seat watch gate every configured family for it, and its hooks fall back to the model in the account's tee. A move off a walled seat, or one that follows a subagent's five-hour or weekly refusal, screens candidates on every configured family whatever the launch model.
94
94
 
95
95
  ---
96
96
 
package/README.md CHANGED
@@ -84,7 +84,7 @@ claude # use claude as always; each session gets its own accoun
84
84
  Each session runs on its own seat. One rule: while the seat is under its bars, nothing happens; once it is at or over a bar, the session moves to the usable account that ranks first. The session bar is `thresholds.session` (90) minus `policy.projectionMargin` (3), so **87%** of the 5-hour window by default, and the weekly bar is **98%**. The bars also screen candidates on any of:
85
85
 
86
86
  - **Session** (5-hour) or **week (all models)** - the aggregate windows, fed free/push-based by the statusLine into one tee per account.
87
- - **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the active model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate.
87
+ - **Per-model weekly cap** - the most capable model (Fable) has its own tighter weekly limit that binds *before* the aggregate (per-model caps currently exist only for Sonnet and Fable, and Sonnet's is generous). tokenmaxxing reads it from the direct no-spend usage read for the seat (free, 0 tokens, one attempt per interval) whenever the seat's tee is older than `policy.usagePollTtlMs` or the session's model is one of `policy.switchModels`, backing off after a failed sample, so a Fable session moves on the Fable cap while a Sonnet session rides the aggregate. A session launched with `--model` on another family (`claude --model opus`) ignores the Fable cap from launch on: it is placed on, and moves to, an account whose Fable cap is spent as long as that account's session and weekly windows have headroom. The exception is a move off an account the server already refused (an enforced-limit wall), which screens every configured family.
88
88
 
89
89
  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, minus the margin) 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).
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.58.0",
3
+ "version": "1.59.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/cli/status.ts CHANGED
@@ -88,9 +88,14 @@ async function collect(p: Provider, cfg: Config, now: number, cached: boolean):
88
88
  if (!cached && idx.accounts.length > 0) {
89
89
  await withLock(p.pool.lockFile, async () => {
90
90
  idx = loadAccounts(p.pool);
91
- console.error(c.dim(`sampling ${p.name} usage...`));
92
- liveId = p.liveId();
93
- reports = await p.samplePool(idx.accounts, liveId, now);
91
+ const notice = process.stderr.isTTY;
92
+ if (notice) process.stderr.write(c.dim(`sampling ${p.name} usage...`));
93
+ try {
94
+ liveId = p.liveId();
95
+ reports = await p.samplePool(idx.accounts, liveId, now);
96
+ } finally {
97
+ if (notice) process.stderr.write("\r\x1b[2K");
98
+ }
94
99
  saveAccounts(p.pool, idx);
95
100
  });
96
101
  }
@@ -21,7 +21,7 @@ async function compactBeforeMove(input: { sessionId: string | null; now: number
21
21
  const liveId = codex.liveId();
22
22
  const live = liveId == null ? undefined : loadAccounts(codex.pool).accounts.find((a) => a.id === liveId);
23
23
  if (!live || live.needsReauth === true || (live.enforcedUntil != null && live.enforcedUntil > now)) return;
24
- const observed = await codex.observeLive(live, loadConfig(), now, { probe: true });
24
+ const observed = await codex.observeLive(live, loadConfig(), now, { probe: true, perModel: false });
25
25
  if (!observed || !isExhausted({ ...live, windows: observed.windows }, codexPickCtx(now, live.id))) return;
26
26
  const env: Record<string, string | undefined> = { ...process.env, TOKENMAXXING_PROBE: "1", [WRAP_DEPTH_ENV]: String(MAX_WRAP_DEPTH) };
27
27
  delete env[CODEX_SUPERVISOR_ID_ENV];
@@ -17,7 +17,8 @@ import { exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, r
17
17
  import { saveTermios } from "../lib/tty.ts";
18
18
  import { loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
19
19
  import { loadAccounts, loadConfig } from "../lib/state.ts";
20
- import { RespawnMarkerSchema, type Account } from "../lib/types.ts";
20
+ import { gatedFamilies, modelFromFlag } from "../lib/usage.ts";
21
+ import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
21
22
  import { errorMessage, log } from "../lib/log.ts";
22
23
 
23
24
  const NONINTERACTIVE_SUBCMDS = new Set([
@@ -53,6 +54,7 @@ type Analysis = {
53
54
  continueLatest: boolean;
54
55
  streamInput: boolean;
55
56
  streamOutput: boolean;
57
+ model: string | null;
56
58
  };
57
59
 
58
60
  export function analyzeArgs(argv: string[]): Analysis {
@@ -61,6 +63,7 @@ export function analyzeArgs(argv: string[]): Analysis {
61
63
  let continueLatest = false;
62
64
  let streamInput = false;
63
65
  let streamOutput = false;
66
+ let model: string | null = null;
64
67
  let printMode = false;
65
68
  let invalidSessionArg = false;
66
69
  let pickerResume = false;
@@ -98,6 +101,8 @@ export function analyzeArgs(argv: string[]): Analysis {
98
101
  else if (a.startsWith("--input-format=")) streamInput = a.slice("--input-format=".length) === "stream-json";
99
102
  else if (a === "--output-format") streamOutput = argv[++i] === "stream-json";
100
103
  else if (a.startsWith("--output-format=")) streamOutput = a.slice("--output-format=".length) === "stream-json";
104
+ else if (a === "--model") model = argv[++i] ?? null;
105
+ else if (a.startsWith("--model=")) model = a.slice("--model=".length);
101
106
  else if (VALUE_TAKING_ROOT_FLAGS.has(a)) i++;
102
107
  else if (VARIADIC_ROOT_FLAGS.has(a)) {
103
108
  while (i + 1 < argv.length && !argv[i + 1]!.startsWith("-")) i++;
@@ -113,7 +118,7 @@ export function analyzeArgs(argv: string[]): Analysis {
113
118
  const isSubcmd = firstPositional !== null && NONINTERACTIVE_SUBCMDS.has(firstPositional);
114
119
  const forkResume = forkSession && (resumeId !== null || continueLatest);
115
120
  const manage = !printMode && !isSubcmd && !invalidSessionArg && !pickerResume && !forkResume && !process.env.TOKENMAXXING_PROBE;
116
- return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput };
121
+ return { manage, sessionId, resumeId, continueLatest, streamInput, streamOutput, model };
117
122
  }
118
123
 
119
124
  export function stripSessionFlags(argv: string[]): string[] {
@@ -201,22 +206,23 @@ function consumableMarker(marker: string, gate: MarkerGate): z.infer<typeof Resp
201
206
  const SEAT_POLL_MS = 10_000;
202
207
  const SEAT_RETRY_MS = 60_000;
203
208
 
204
- function seatBlockedUntil(seatId: string, now: number): number | null {
209
+ function seatBlockedUntil(seatId: string, now: number, families: string[], cfg: Config): number | null {
205
210
  const account = loadAccounts(claudePool).accounts.find((a) => a.id === seatId);
206
211
  if (!account) return null;
207
- const cfg = loadConfig();
208
212
  const observed = teeObservation(account);
209
213
  const current = observed ? { ...account, windows: observed.windows } : account;
210
- const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families: cfg.policy.switchModels, seats: null });
214
+ const until = usableAt(current, { now, thresholds: thresholdBars(cfg), currentId: seatId, families, seats: null });
211
215
  return until > now ? until : null;
212
216
  }
213
217
 
214
- async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate): Promise<boolean> {
218
+ async function moveExhaustedSeat(seat: Account, sid: string, gate: MarkerGate, model: ModelInfo | null): Promise<boolean> {
215
219
  try {
216
220
  const now = Date.now();
217
- const until = seatBlockedUntil(seat.id, now);
221
+ const cfg = loadConfig();
222
+ const families = gatedFamilies(model, cfg.policy.switchModels);
223
+ const until = seatBlockedUntil(seat.id, now, families, cfg);
218
224
  if (until == null || until <= gate.overriddenUntil) return false;
219
- const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id);
225
+ const decision = await evaluateAndMaybeSwap(claude, now, true, null, seat.id, families);
220
226
  log("supervisor.seat_exhausted", { seat: seat.id.slice(0, 8), until, reason: decision.reason, account: decision.account?.id.slice(0, 8), waitUntil: decision.waitUntil });
221
227
  if (decision.account && (decision.swapped || decision.waitUntil !== undefined)) {
222
228
  writeRespawnMarker({ session: { sid, launchedAt: gate.launchedAt }, sessionId: sid, accountId: decision.account.id, waitUntil: decision.waitUntil ?? now, compact: true });
@@ -342,6 +348,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
342
348
  const passthroughEnv: Record<string, string | undefined> = { ...childEnv };
343
349
  delete passthroughEnv.TOKENMAXXING_SUPERVISED;
344
350
  delete passthroughEnv.TOKENMAXXING_SESSION_ID;
351
+ delete passthroughEnv.TOKENMAXXING_MODEL;
345
352
  return runPassthrough({ real, argv, env: passthroughEnv, onSpawn: (p) => { child = p; } });
346
353
  }
347
354
 
@@ -380,6 +387,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
380
387
  const effective = analyzeArgs(base);
381
388
  const relay = effective.streamInput ? new StdinRelay() : null;
382
389
  const stream = effective.streamOutput;
390
+ const model = modelFromFlag(effective.model);
383
391
  let noticeSid = sid;
384
392
  const say: Say = (terminal, text) => {
385
393
  if (stream) process.stdout.write(systemLine(noticeSid, text));
@@ -394,7 +402,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
394
402
 
395
403
  const gate: MarkerGate = { launchedAt: Date.now(), overriddenUntil };
396
404
  const { child: proc, seat } = await withLock(claudePool.lockFile, async () => {
397
- const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt);
405
+ const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
398
406
  log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
399
407
  const spawned = Bun.spawn([real, ...launchArgs], {
400
408
  stdin: relay === null ? "inherit" : "pipe",
@@ -405,6 +413,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
405
413
  TOKENMAXXING_SUPERVISED: "1",
406
414
  TOKENMAXXING_SESSION_ID: sid,
407
415
  TOKENMAXXING_LAUNCHED_AT: String(gate.launchedAt),
416
+ TOKENMAXXING_MODEL: effective.model ?? "",
408
417
  ...(picked ? { CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : {}),
409
418
  },
410
419
  });
@@ -431,7 +440,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
431
440
  tick: async () => {
432
441
  if (existsSync(marker) && consumableMarker(marker, gate) != null) return true;
433
442
  if (seat && !terminating && Date.now() >= seatCheckAt) {
434
- const decided = await moveExhaustedSeat(seat, sid, gate);
443
+ const decided = await moveExhaustedSeat(seat, sid, gate, model);
435
444
  seatCheckAt = Date.now() + (decided ? SEAT_RETRY_MS : SEAT_POLL_MS);
436
445
  }
437
446
  return false;
@@ -459,6 +468,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
459
468
  delete compactEnv.TOKENMAXXING_SUPERVISED;
460
469
  delete compactEnv.TOKENMAXXING_SESSION_ID;
461
470
  delete compactEnv.TOKENMAXXING_LAUNCHED_AT;
471
+ delete compactEnv.TOKENMAXXING_MODEL;
462
472
  const outcome = await compactClaudeSession({ real, sid: m.sessionId, env: compactEnv });
463
473
  log("supervisor.compact", { sid: m.sessionId.slice(0, 8), seat: seat.id.slice(0, 8), ok: outcome.ok, reason: outcome.ok ? undefined : outcome.reason });
464
474
  if (!outcome.ok) say(`\x1b[33m compaction did not land (${outcome.reason}) - resuming with the full context\x1b[0m\n`, `tokenmaxxing: compaction did not land; resuming with the full context. (${outcome.reason})`);
package/src/lib/claude.ts CHANGED
@@ -8,7 +8,7 @@ import { ensurePathInRc, installSupervisor, managedShellRcSkipLines, shellRcPath
8
8
  import { withLock } from "./lock.ts";
9
9
  import { errorMessage, log } from "./log.ts";
10
10
  import { claudeTierLabel, describeIdentity, fetchTokenIdentity, isDeadCredential, InvalidGrantError } from "./oauth.ts";
11
- import { claudePool, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
11
+ import { claudePool, env, paths, sampleDirFor, seatFromEnv, storeDirFor } from "./paths.ts";
12
12
  import { pickBest, pickEarliestReset, thresholdBars, type PickCtx } from "./picker.ts";
13
13
  import { seatCounts } from "./presence.ts";
14
14
  import type { Observation, Provider, SampleReport } from "./provider.ts";
@@ -16,8 +16,8 @@ import { foldTee, sampleAccountUsage, teeObservation } from "./sample.ts";
16
16
  import { clearUsageSnapshot, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, saveAccounts, type Harvest } from "./state.ts";
17
17
  import { loadSetupTokens, saveSetupTokens } from "./setuptokens.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
- import { fetchUsageDirect, gatedFamilies, mergeWindows, scrubCredentialEnv, windowsOf } from "./usage.ts";
20
- import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config } from "./types.ts";
19
+ import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredentialEnv, windowsOf } from "./usage.ts";
20
+ import { CredentialBlobSchema, JsonTextSchema, OAuthAccountSchema, type Account, type Config, type ModelInfo } from "./types.ts";
21
21
  import { c } from "../cli/render.ts";
22
22
 
23
23
  class StoreUnusableError extends Error {
@@ -37,14 +37,13 @@ function presence(): Map<string, number> {
37
37
 
38
38
  const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
39
39
 
40
- async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null> {
40
+ async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
41
41
  const ttl = cfg.policy.usagePollTtlMs;
42
42
  const interval = Math.min(ttl * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
43
43
  const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
44
44
  const snap = loadUsageSnapshot(account.id);
45
45
  const fresh = snap != null && now - snap.at <= ttl;
46
- const needsPerModel = snap != null && gatedFamilies(snap.state.model, cfg.policy.switchModels).length > 0;
47
- if (opts.probe && !probeAttempted && (!fresh || needsPerModel)) {
46
+ if (opts.probe && !probeAttempted && (!fresh || opts.perModel)) {
48
47
  const startedAt = Date.now();
49
48
  const outcome = await sampleAccountUsage(account);
50
49
  await withLock(claudePool.lockFile, () => {
@@ -125,13 +124,13 @@ async function storeUsable(a: Account): Promise<boolean> {
125
124
  }
126
125
  }
127
126
 
128
- export function pickSeat(now: number): Account | null {
127
+ export function pickSeat(now: number, model: ModelInfo | null): Account | null {
129
128
  const cfg = loadConfig();
130
129
  const idx = loadAccounts(claudePool);
131
130
  let dirty = false;
132
131
  for (const a of idx.accounts) dirty = foldTee(a) || dirty;
133
132
  if (dirty) saveAccounts(claudePool, idx);
134
- const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: cfg.policy.switchModels, seats: presence() };
133
+ const ctx: PickCtx = { now, thresholds: thresholdBars(cfg), currentId: null, families: gatedFamilies(model, cfg.policy.switchModels), seats: presence() };
135
134
  return pickBest(idx.accounts, ctx) ?? pickEarliestReset(idx.accounts, ctx)?.account ?? null;
136
135
  }
137
136
 
@@ -286,7 +285,8 @@ export const claude: Provider = {
286
285
  presence,
287
286
  gatedFamilies: (cfg) => {
288
287
  const seat = liveId();
289
- return gatedFamilies(seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null), cfg.policy.switchModels);
288
+ const model = modelFromFlag(env("TOKENMAXXING_MODEL", "")) ?? (seat == null ? null : (loadUsageSnapshot(seat)?.state.model ?? null));
289
+ return gatedFamilies(model, cfg.policy.switchModels);
290
290
  },
291
291
  observeLive,
292
292
  samplePool,
package/src/lib/decide.ts CHANGED
@@ -36,16 +36,17 @@ function enforcedWall(limit: EnforcedLimit, account: Account, now: number): numb
36
36
  return limit.resetsAt ?? cachedReset ?? now + (limit.kind === "session" ? FIVE_HOURS_MS : WEEK_MS);
37
37
  }
38
38
 
39
- export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId()): Promise<SwapDecision> {
39
+ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRespawn = false, enforced: EnforcedLimit | null = null, seatId: string | null = p.liveId(), sessionFamilies?: string[]): Promise<SwapDecision> {
40
40
  const activeId = seatId;
41
41
  const cfg = loadConfig();
42
42
  const bars = thresholdBars(cfg);
43
43
  const stored0 = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
44
44
  const walled0 = stored0?.enforcedUntil != null && stored0.enforcedUntil > now;
45
- const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0 }) : null;
45
+ const gated = sessionFamilies ?? p.gatedFamilies(cfg);
46
+ const observed = stored0 ? await p.observeLive(stored0, cfg, now, { probe: enforced == null && !walled0, perModel: gated == null || gated.length > 0 }) : null;
46
47
  const stored = loadAccounts(p.pool).accounts.find((a) => a.id === activeId);
47
48
 
48
- if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: p.gatedFamilies(cfg), seats: null })) {
49
+ if (!enforced && !isOver(stored, observed, { now, thresholds: bars, currentId: activeId, families: gated, seats: null })) {
49
50
  return { swapped: false, account: null, reason: "under-threshold-or-stale" };
50
51
  }
51
52
 
@@ -70,7 +71,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
70
71
 
71
72
  let obs2: Observation | null = null;
72
73
  for (const a of idx.accounts) {
73
- const obs = await p.observeLive(a, cfg, now, { probe: false });
74
+ const obs = await p.observeLive(a, cfg, now, { probe: false, perModel: false });
74
75
  if (a === active) obs2 = obs;
75
76
  if (obs && (a.lastUsageAt == null || obs.at > a.lastUsageAt)) {
76
77
  a.windows = p.mergeWindows(obs.windows, a.windows);
@@ -79,7 +80,7 @@ export async function evaluateAndMaybeSwap(p: Provider, now = Date.now(), canRes
79
80
  }
80
81
  saveAccounts(p.pool, idx);
81
82
 
82
- const families = p.gatedFamilies(cfg);
83
+ const families = sessionFamilies ?? p.gatedFamilies(cfg);
83
84
  const walled = active?.enforcedUntil != null && active.enforcedUntil > now;
84
85
  const blindScreen = (enforced != null && (prior || enforced.blind)) || (walled && !enforced2);
85
86
  const screened = blindScreen && families != null ? cfg.policy.switchModels : families;
@@ -18,7 +18,7 @@ export type Provider = {
18
18
  liveId(): string | null;
19
19
  presence(): Map<string, number>;
20
20
  gatedFamilies(cfg: Config): string[] | null;
21
- observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean }): Promise<Observation | null>;
21
+ observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null>;
22
22
  samplePool(accounts: Account[], liveId: string | null, now: number): Promise<Map<string, SampleReport>>;
23
23
  mergeWindows(next: Window[], prev: Window[]): Window[];
24
24
  swap(target: Account): Promise<void>;
package/src/lib/usage.ts CHANGED
@@ -44,6 +44,14 @@ export function gatedFamilies(model: ModelInfo | null, families: string[]): stri
44
44
  return family ? [family] : [];
45
45
  }
46
46
 
47
+ const MODEL_FAMILIES = ["sonnet", "opus", "haiku", "fable"];
48
+
49
+ export function modelFromFlag(value: string | null): ModelInfo | null {
50
+ const name = value?.split("[")[0]?.trim() ?? "";
51
+ const tokens = familyTokens(name);
52
+ return MODEL_FAMILIES.some((f) => tokens.includes(f)) ? { id: name, display: name } : null;
53
+ }
54
+
47
55
  const TranscriptBlockSchema = z.looseObject({ type: z.string().optional(), text: z.string().optional() });
48
56
  export const TranscriptRowSchema = z.looseObject({
49
57
  type: z.string().optional(),