tokenmaxxing 1.113.0 → 1.114.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
@@ -53,7 +53,7 @@ The switching path runs no long-lived daemon. The statusline pushes usage. Hooks
53
53
  ## 3. How a move happens
54
54
 
55
55
  ### 3.1 Usage feed (free, push-based)
56
- The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (write-on-change, O(ms)), and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). A session with no pooled seat writes no tee. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family, at most once per the account's sample interval (§5): the direct no-spend usage read with the seat's stored token. The compact child still sets `TOKENMAXXING_PROBE=1` so the hook entries return at once. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it. A sample that finds the stored access token expired has Claude Code refresh it first (§5).
56
+ The Stop hook's stdin has no usage data, but the **statusLine does** (`rate_limits.{five_hour,seven_day}.{used_percentage,resets_at}`, after every turn, 300ms debounce, zero cost). tokenmaxxing's statusLine tees that to `usage/<uuid8>.json` for the session's seat, read from the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` (write-on-change, O(ms)), and renders its own native line (it replaced the earlier pass-through delegation on 2026-07-11: install takes the statusLine slot outright, so a pre-existing custom statusline command is overwritten). A session with no pooled seat writes no tee. A stream-json session runs no statusLine, so its supervisor writes the same tee from the child's stdout: it relays every chunk unchanged, tees each `rate_limit_event` line's `unifiedWindows` (the same response-header figures, pushed on every one-point move), and restamps the tee on each `assistant` line. The seat is sampled when its tee is older than `policy.usagePollTtlMs`, and for the per-model rows when the active model is a gated family, at most once per the account's sample interval (§5): the direct no-spend usage read with the seat's stored token. The compact child still sets `TOKENMAXXING_PROBE=1` so the hook entries return at once. The sample takes one attempt per interval; a failed sample doubles the interval up to 30 minutes, and a success resets it. A sample that finds the stored access token expired has Claude Code refresh it first (§5).
57
57
 
58
58
  ### 3.2 Detect + decide + signal (Stop hook, per turn)
59
59
  1. Resolve the seat: the pooled account whose store the inherited `CLAUDE_SECURESTORAGE_CONFIG_DIR` names. No seat (a session started outside the supervisor) means nothing to move.
@@ -87,7 +87,7 @@ Each terminal ran the supervisor, so each has its own child `claude`, its own `-
87
87
  The [switching policy](docs/content/docs/switching.mdx) is one rule: under both bars, hold; at or over a bar, move to the usable account that ranks first; nothing usable, queue first come first served onto a reset target (at most four waiters per account, overflow takes the soonest anyway) and wait for that reset. The same ranking places a session at launch.
88
88
 
89
89
  - Ranking (decided by consult for #79): greatest remaining headroom to the session bar divided by the number of sessions after placement, `(bar - used) / (sessions + 1)`, then highest pace pressure (remaining percent of the binding gated per-model cap, or of the weekly aggregate for an account with no gated row, over time to its reset), then soonest weekly reset, then lowest weekly usage; unknown usage ranks last. Organization membership is not an input. Codex drops the headroom key, keeps the pace ordering and the weekly tie-breaks, and excludes accounts running in another codex session, because codex has no cross-process refresh lock and two sessions must never share a login.
90
- - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval, over the direct no-spend usage read with the account's stored token. The interval is 15 minutes times the unused fraction of the bar closest to being reached, never under `policy.usagePollTtlMs`, and 15 minutes for an account at or over an aggregate bar. The windows are the session and weekly aggregates plus the `policy.switchModels` caps that are still under their bar, and an account with no stored aggregate gets `policy.usagePollTtlMs`. Every account is attempted about once per three ticks per pooled account, or once per its sample interval plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. No sampler sends a read with an expired stored access token, because the endpoint answers it with 401 and, after a few, with the same 429. A sample that finds the token expired first runs `claude -p /usage --no-session-persistence --safe-mode` with the account's store set and a throwaway `CLAUDE_CONFIG_DIR`, so Claude Code refreshes the token under its own lock, and then reads with the new token; the child's output is not read. A refresh that leaves the token expired fails the sample with no read, and one that clears the store flags the account `needsReauth`.
90
+ - Selection reads the cached figures only, after folding every account's tee. Each periodic check tick samples up to three accounts whose last sample attempt is oldest, skipping any attempted within the account's sample interval, over the direct no-spend usage read with the account's stored token. The interval is 15 minutes times the unused fraction of the bar closest to being reached, never under `policy.usagePollTtlMs`, and 15 minutes for an account at or over an aggregate bar or with no session or borrower on this host. Every sampler claims a read under the pool lock before it sends it, so concurrent hooks and `status` runs send an account one read. The windows are the session and weekly aggregates plus the `policy.switchModels` caps that are still under their bar, and an account with no stored aggregate gets `policy.usagePollTtlMs`. Every account is attempted about once per three ticks per pooled account, or once per its sample interval plus a tick when that is longer, and its cached figure is as fresh as its last successful attempt or its sessions' latest push. The pool lock covers only the reservation and the result write, never the usage GET. The usage endpoint rate limits each account on its own and names the time to come back in the `retry-after` of its HTTP 429; that time is stored as `usageRetryAt`, and no sampler (the tick, `status`, the seat's in-decision sample) reads the account before it. No sampler sends a read with an expired stored access token, because the endpoint answers it with 401 and, after a few, with the same 429. A sample that finds the token expired first runs `claude -p /usage --no-session-persistence --safe-mode` with the account's store set and a throwaway `CLAUDE_CONFIG_DIR`, so Claude Code refreshes the token under its own lock, and then reads with the new token; the child's output is not read. A refresh that leaves the token expired fails the sample with no read, and one that clears the store flags the account `needsReauth`.
91
91
  - The depleted pause happens at the bar, not at 100; Codex has no pause and rides its account until the server refuses it, because a running sibling cannot adopt another account without restarting.
92
92
  - Neither pool has a post-swap cooldown or a manual `switch`: a move is a respawn of one session under a fixed store, so nothing another session reads is invalidated by it, and placement at launch replaces the manual pick.
93
93
 
package/README.md CHANGED
@@ -90,7 +90,7 @@ The bars' headroom is deliberate: it's the budget to reach a clean turn boundary
90
90
 
91
91
  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.
92
92
 
93
- - Every periodic check tick folds fresh statusline tees 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, shrinking to `policy.usagePollTtlMs` as it 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.
93
+ - 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.
94
94
  - When no account is usable, the session queues first come first served onto a reset target (at most four waiters per account) and pauses until that reset if it lands within `policy.maxWaitMs`, and otherwise stays put.
95
95
 
96
96
  See [How switching decides](docs/content/docs/switching.mdx) for the policy and the [cache-cost profile](docs/content/docs/switching-profile.mdx) for measurements and their limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenmaxxing",
3
- "version": "1.113.0",
3
+ "version": "1.114.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",
@@ -1,3 +1,4 @@
1
+ import { once } from "node:events";
1
2
  import { existsSync, mkdirSync, rmSync, readdirSync, statSync } from "node:fs";
2
3
  import { basename, join } from "node:path";
3
4
  import type { FileSink, Subprocess } from "bun";
@@ -16,9 +17,9 @@ import { teeObservation } from "../lib/sample.ts";
16
17
  import { countdownWait, exitStatus, loopGuardTripped, raceMarkerOrExit, recordPresenceOrStop, runPassthrough, SEAT_POLL_MS, SEAT_RETRY_MS, type Say } from "../lib/supervise.ts";
17
18
  import { saveTermios } from "../lib/tty.ts";
18
19
  import { liveSessionId, loadSessionFlags, pruneStaleSessions, saveSessionFlags, writeRespawnMarker } from "../lib/sessions.ts";
19
- import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim } from "../lib/state.ts";
20
- import { gatedFamilies, modelFromFlag, scrubCredEnv } from "../lib/usage.ts";
21
- import { RespawnMarkerSchema, type Account, type Config, type ModelInfo } from "../lib/types.ts";
20
+ import { loadAccounts, loadConfig, readJsonFile, releaseWaitClaim, writeUsage } from "../lib/state.ts";
21
+ import { gatedFamilies, modelFromFlag, parseStreamLine, scrubCredEnv } from "../lib/usage.ts";
22
+ import { RespawnMarkerSchema, type Account, type Config, type ModelInfo, type UsageWindows } from "../lib/types.ts";
22
23
  import { errorMessage, log } from "../lib/log.ts";
23
24
 
24
25
  const SUBCOMMANDS = new Set([
@@ -266,6 +267,54 @@ function userLine(text: string): string {
266
267
  return `${JSON.stringify({ type: "user", message: { role: "user", content: [{ type: "text", text }] } })}\n`;
267
268
  }
268
269
 
270
+ const STDOUT_QUIET_MS = 1000;
271
+
272
+ function seatTee(seatId: string, model: ModelInfo | null): (line: string) => void {
273
+ let last: UsageWindows | null = null;
274
+ return (line) => {
275
+ const msg = parseStreamLine(line);
276
+ if (msg?.windows) last = msg.windows;
277
+ else if (msg?.type !== "assistant") return;
278
+ if (last) writeUsage({ fiveHour: last.fiveHour, sevenDay: last.sevenDay, account: seatId, ts: Date.now(), model });
279
+ };
280
+ }
281
+
282
+ function relayStdout(source: ReadableStream<Uint8Array>, onLine: (line: string) => void): () => Promise<void> {
283
+ let ended = false;
284
+ let blocked = false;
285
+ let readAt = Date.now();
286
+ const forwarded = source.pipeThrough(
287
+ new TransformStream<Uint8Array, Uint8Array>({
288
+ async transform(chunk, controller) {
289
+ readAt = Date.now();
290
+ if (!process.stdout.write(chunk)) {
291
+ blocked = true;
292
+ await once(process.stdout, "drain");
293
+ blocked = false;
294
+ }
295
+ controller.enqueue(chunk);
296
+ },
297
+ }),
298
+ );
299
+ void (async () => {
300
+ for await (const line of readLines(forwarded)) {
301
+ try {
302
+ onLine(line);
303
+ } catch (e) {
304
+ log("supervisor.tee_failed", { err: errorMessage(e) });
305
+ }
306
+ }
307
+ })()
308
+ .catch((e: unknown) => log("supervisor.stdout_relay_failed", { err: errorMessage(e) }))
309
+ .finally(() => {
310
+ ended = true;
311
+ });
312
+ return async () => {
313
+ const exitedAt = Date.now();
314
+ while (!ended && (blocked || Date.now() - Math.max(readAt, exitedAt) < STDOUT_QUIET_MS)) await Bun.sleep(50);
315
+ };
316
+ }
317
+
269
318
  class StdinRelay {
270
319
  private sink: FileSink | null = null;
271
320
  private queue: string[] = [];
@@ -430,6 +479,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
430
479
  let overriddenUntil = 0;
431
480
  let wanted: string | null = null;
432
481
  let refused: string[] = [];
482
+ let drainStdout: (() => Promise<void>) | null = null;
433
483
  try {
434
484
  while (true) {
435
485
  if (existsSync(marker)) rmSync(marker, { force: true });
@@ -439,9 +489,10 @@ export async function runSupervisor(argv: string[]): Promise<number> {
439
489
  if (terminating) return null;
440
490
  const picked = (wanted == null ? null : (loadAccounts(claudePool).accounts.find((a) => a.id === wanted) ?? null)) ?? pickSeat(gate.launchedAt, model);
441
491
  log("supervisor.launch", { sid, respawns, seat: picked?.id.slice(0, 8) ?? null, args: launchArgs.join(" "), injected: firstLine !== null });
492
+ const tee = stream && picked ? seatTee(picked.id, model) : null;
442
493
  const spawned = Bun.spawn([real, ...launchArgs], {
443
494
  stdin: relay === null ? "inherit" : "pipe",
444
- stdout: "inherit",
495
+ stdout: tee === null ? "inherit" : "pipe",
445
496
  stderr: "inherit",
446
497
  env: {
447
498
  ...(picked ? { ...scrubCredEnv(childEnv), CLAUDE_SECURESTORAGE_CONFIG_DIR: storeDirFor(picked.id) } : childEnv),
@@ -453,6 +504,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
453
504
  },
454
505
  });
455
506
  child = spawned;
507
+ const drain = tee !== null && spawned.stdout instanceof ReadableStream ? relayStdout(spawned.stdout, tee) : null;
456
508
  if (relay !== null) relay.attach(spawned.stdin!, firstLine);
457
509
  firstLine = null;
458
510
  if (picked) {
@@ -467,7 +519,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
467
519
  });
468
520
  }
469
521
  releaseClaimLocked(sid);
470
- return { child: spawned, seat: picked };
522
+ return { child: spawned, seat: picked, drain };
471
523
  });
472
524
  if (launched == null) {
473
525
  await releaseClaim();
@@ -475,6 +527,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
475
527
  }
476
528
 
477
529
  const { child: proc, seat } = launched;
530
+ drainStdout = launched.drain;
478
531
  let seatCheckAt = 0;
479
532
  await raceMarkerOrExit({
480
533
  child: proc,
@@ -494,6 +547,8 @@ export async function runSupervisor(argv: string[]): Promise<number> {
494
547
  relay?.detach();
495
548
  },
496
549
  });
550
+ await drainStdout?.();
551
+ drainStdout = null;
497
552
 
498
553
  const m = !terminating && existsSync(marker) ? await consumableMarker(marker, gate) : null;
499
554
  if (m) {
@@ -538,6 +593,7 @@ export async function runSupervisor(argv: string[]): Promise<number> {
538
593
  return exitStatus(proc);
539
594
  }
540
595
  } catch (e) {
596
+ await drainStdout?.();
541
597
  const msg = errorMessage(e);
542
598
  const text = msg.startsWith("tokenmaxxing:") ? msg : `tokenmaxxing: ${msg}`;
543
599
  say(`\n\x1b[31m${text}\x1b[0m\n`, text);
package/src/lib/claude.ts CHANGED
@@ -13,7 +13,7 @@ import { landWindows, pickBest, pickEarliestReset, thresholdBars, type PickCtx }
13
13
  import { deletePiStore } from "./piauth.ts";
14
14
  import { livingPresences, writePresence } from "./presence.ts";
15
15
  import { StoreUnusableError, type Observation, type Provider, type SampleReport, type SeatBorrow } from "./provider.ts";
16
- import { foldTee, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
16
+ import { claimSample, foldTee, readyToSample, runSample, sampleAccountUsage, sampleDue, sampleIntervalMs, teeObservation, usageBlockedUntil } from "./sample.ts";
17
17
  import { clearUsageSnapshot, liveWaitClaims, loadAccounts, loadConfig, loadUsageSnapshot, pinBinOverride, readJsonFile, saveAccounts, type Harvest } from "./state.ts";
18
18
  import { saveTermios, restoreTermios } from "./tty.ts";
19
19
  import { fetchUsageDirect, gatedFamilies, mergeWindows, modelFromFlag, scrubCredEnv, windowsOf } from "./usage.ts";
@@ -33,11 +33,10 @@ function presence(living = livingPresences(paths.presenceDir)): Map<string, numb
33
33
  const PROBE_BACKOFF_CAP_MS = 30 * 60 * 1000;
34
34
 
35
35
  async function observeLive(account: Account, cfg: Config, now: number, opts: { probe: boolean; perModel: boolean }): Promise<Observation | null> {
36
- const interval = Math.min(sampleIntervalMs(account, cfg, now) * 2 ** (account.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
37
- const probeAttempted = account.lastProbeAt != null && now - account.lastProbeAt <= interval;
36
+ const probeDue = (a: Account) => a.lastProbeAt == null || now - a.lastProbeAt > Math.min(sampleIntervalMs(a, cfg, now, true) * 2 ** (a.probeFails ?? 0), PROBE_BACKOFF_CAP_MS);
38
37
  const snap = loadUsageSnapshot(account.id);
39
38
  const fresh = snap != null && now - snap.at <= cfg.policy.usagePollTtlMs;
40
- if (opts.probe && !probeAttempted && usageBlockedUntil(account, now) == null && (!fresh || opts.perModel)) {
39
+ if (opts.probe && (!fresh || opts.perModel) && probeDue(account) && usageBlockedUntil(account, now) == null && (await claimSample(account.id, now, probeDue))) {
41
40
  const startedAt = Date.now();
42
41
  const outcome = await sampleAccountUsage(account);
43
42
  await withLock(claudePool.lockFile, () => {
@@ -67,6 +66,7 @@ async function samplePool(accounts: Account[], _liveId: string | null, now: numb
67
66
  const reports = new Map<string, SampleReport>();
68
67
  const cfg = loadConfig();
69
68
  const bars = thresholdBars(cfg);
69
+ const live = presence();
70
70
  await Promise.all(
71
71
  accounts.map(async (a) => {
72
72
  const tee = loadUsageSnapshot(a.id);
@@ -81,7 +81,8 @@ async function samplePool(accounts: Account[], _liveId: string | null, now: numb
81
81
  reports.set(a.id, { ok: false, reason: ready.reason });
82
82
  return;
83
83
  }
84
- if (!sampleDue(a, cfg, now)) {
84
+ const due = (stored: Account) => sampleDue(stored, cfg, now, live.has(a.id));
85
+ if (!due(a) || !(await claimSample(a.id, now, due))) {
85
86
  reports.set(a.id, { ok: true, source: "cached" });
86
87
  return;
87
88
  }
package/src/lib/sample.ts CHANGED
@@ -20,13 +20,13 @@ type PreparedSample = { ok: true; token: string; expiresAt: number } | { ok: fal
20
20
 
21
21
  const SAMPLE_INTERVAL_MAX_MS = 15 * 60 * 1000;
22
22
 
23
- export function sampleIntervalMs(account: Account, cfg: Config, now: number): number {
23
+ export function sampleIntervalMs(account: Account, cfg: Config, now: number, live: boolean): number {
24
24
  const floor = cfg.policy.usagePollTtlMs;
25
25
  const bars = thresholdBars(cfg);
26
26
  const aggregates = account.windows.filter((w) => w.name == null);
27
27
  if (aggregates.length === 0) return floor;
28
28
  const blocked = (w: Window) => liveUsed(w, now) >= barFor(account, w, bars, now);
29
- if (aggregates.some(blocked)) return Math.max(floor, SAMPLE_INTERVAL_MAX_MS);
29
+ if (!live || aggregates.some(blocked)) return Math.max(floor, SAMPLE_INTERVAL_MAX_MS);
30
30
  const open = [...aggregates, ...gatedWindows(account, cfg.policy.switchModels).filter((w) => !blocked(w))];
31
31
  const spare = Math.min(...open.map((w) => 1 - liveUsed(w, now) / barFor(account, w, bars, now)));
32
32
  return Math.max(floor, SAMPLE_INTERVAL_MAX_MS * spare);
@@ -34,8 +34,20 @@ export function sampleIntervalMs(account: Account, cfg: Config, now: number): nu
34
34
 
35
35
  const sampledAt = (a: Account) => Math.max(a.lastUsageAt ?? 0, a.lastProbeAt ?? 0);
36
36
 
37
- export function sampleDue(account: Account, cfg: Config, now: number): boolean {
38
- return now - sampledAt(account) > sampleIntervalMs(account, cfg, now);
37
+ export function sampleDue(account: Account, cfg: Config, now: number, live: boolean): boolean {
38
+ const since = live && gatedWindows(account, cfg.policy.switchModels).length > 0 ? (account.lastProbeAt ?? 0) : sampledAt(account);
39
+ return now - since > sampleIntervalMs(account, cfg, now, live);
40
+ }
41
+
42
+ export async function claimSample(id: string, now: number, due: (stored: Account) => boolean): Promise<boolean> {
43
+ return withLock(claudePool.lockFile, () => {
44
+ const idx = loadAccounts(claudePool);
45
+ const stored = idx.accounts.find((a) => a.id === id);
46
+ if (!stored || usageBlockedUntil(stored, now) != null || !due(stored)) return false;
47
+ stored.lastProbeAt = now;
48
+ saveAccounts(claudePool, idx);
49
+ return true;
50
+ });
39
51
  }
40
52
 
41
53
  export function teeObservation(account: Account): Observation | null {
@@ -118,6 +130,7 @@ export async function runSample(account: Account, prepared: { token: string; exp
118
130
  token = refreshed.token;
119
131
  }
120
132
  const read = await fetchUsageDirect(token);
133
+ log("usage.read", { account: account.id.slice(0, 8), ok: read.ok });
121
134
  if (read.ok) return { ok: true, usage: read.usage, via: "get" };
122
135
  if (read.retryAt == null) return { ok: false, reason: "usage read failed (see log)" };
123
136
  return { ok: false, reason: rateLimitedReason(read.retryAt, Date.now()), retryAt: read.retryAt };
@@ -145,7 +158,7 @@ export async function sampleOldest(cfg: Config): Promise<void> {
145
158
  for (const a of idx.accounts) dirty = foldTee(a, thresholdBars(cfg)) || dirty;
146
159
  const seats = seatCounts(paths.presenceDir);
147
160
  const stale = idx.accounts
148
- .filter((a) => a.needsReauth !== true && sampleDue(a, cfg, now))
161
+ .filter((a) => a.needsReauth !== true && sampleDue(a, cfg, now, seats.has(a.id)))
149
162
  .sort((a, b) => Number(seats.has(b.id)) - Number(seats.has(a.id)) || sampledAt(a) - sampledAt(b))
150
163
  .slice(0, SAMPLE_BATCH);
151
164
  if (stale.length === 0) {
package/src/lib/usage.ts CHANGED
@@ -18,6 +18,25 @@ export function parseStatusLineStdin(obj: unknown): UsageWindows | null {
18
18
  return { fiveHour: win(rl.five_hour), sevenDay: win(rl.seven_day), perModel: {} };
19
19
  }
20
20
 
21
+ const StreamWindowSchema = z.looseObject({ utilization: z.number(), resetsAt: EpochSecondsSchema });
22
+ const StreamLineSchema = z.looseObject({
23
+ type: z.string(),
24
+ rate_limit_info: z
25
+ .looseObject({
26
+ unifiedWindows: z.looseObject({ five_hour: StreamWindowSchema.optional(), seven_day: StreamWindowSchema.optional() }).optional(),
27
+ })
28
+ .optional(),
29
+ });
30
+
31
+ export function parseStreamLine(line: string): { type: string; windows: UsageWindows | null } | null {
32
+ const parsed = StreamLineSchema.safeParse(JsonTextSchema.safeParse(line).data);
33
+ if (!parsed.success) return null;
34
+ const unified = parsed.data.type === "rate_limit_event" ? parsed.data.rate_limit_info?.unifiedWindows : undefined;
35
+ const win = (w: z.infer<typeof StreamWindowSchema>): UsageWindow => ({ usedPercentage: Math.round(w.utilization * 100), resetsAt: w.resetsAt });
36
+ const windows = unified?.five_hour && unified.seven_day ? { fiveHour: win(unified.five_hour), sevenDay: win(unified.seven_day), perModel: {} } : null;
37
+ return { type: parsed.data.type, windows };
38
+ }
39
+
21
40
  export function parseStatusLineModel(obj: unknown): ModelInfo | null {
22
41
  const parsed = RateLimitsStdinSchema.safeParse(obj);
23
42
  if (!parsed.success) return null;