@mauricode/token-derby 2.12.2 → 2.12.4

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
@@ -11,7 +11,7 @@ Token Derby runs on the **real** output tokens your Claude Code produces — tha
11
11
  ## Install
12
12
 
13
13
  ```bash
14
- npm i -g @mauricode/token-derby
14
+ npm i -g @mauricode/token-derby@latest
15
15
  ```
16
16
 
17
17
  Requires Node 20+.
package/dist/bin.js CHANGED
@@ -782,13 +782,14 @@ function apiBase() {
782
782
  return process.env.TOKEN_DERBY_API_BASE ?? ENVIRONMENTS[selectedEnv()].apiBase;
783
783
  }
784
784
  var HEARTBEAT_INTERVAL_MS = 6e4;
785
+ var SCAN_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 0.75;
785
786
  var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
786
787
 
787
788
  // src/version.ts
788
789
  import { createRequire } from "module";
789
790
  function readVersion() {
790
- if ("2.12.2".length > 0) {
791
- return "2.12.2";
791
+ if ("2.12.4".length > 0) {
792
+ return "2.12.4";
792
793
  }
793
794
  try {
794
795
  const req = createRequire(import.meta.url);
@@ -885,8 +886,8 @@ function getIdentity() {
885
886
  function _resetIdentityCacheForTests() {
886
887
  identityCache = null;
887
888
  }
888
- async function request(method, path8, body, horseAuthToken, fetchImpl = fetch) {
889
- const url = path8.startsWith("http") ? path8 : `${apiBase()}${path8}`;
889
+ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch) {
890
+ const url = path9.startsWith("http") ? path9 : `${apiBase()}${path9}`;
890
891
  const headers = {};
891
892
  headers[CLI_VERSION_HEADER] = CLI_VERSION;
892
893
  headers["user-agent"] = `token-derby/${CLI_VERSION}`;
@@ -1414,7 +1415,7 @@ import { Box as Box8, Text as Text8, useApp } from "ink";
1414
1415
 
1415
1416
  // src/ui/StatusScreen.tsx
1416
1417
  import { Box as Box7, Text as Text7 } from "ink";
1417
- import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1418
+ import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1418
1419
  var MODEL_LABELS = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
1419
1420
  function ModelList(props) {
1420
1421
  const { primaryModel } = props;
@@ -1438,6 +1439,30 @@ function StatusScreen(props) {
1438
1439
  const elapsedPct = elapsed(race);
1439
1440
  const timeLeft = formatDuration(race.time_left_seconds);
1440
1441
  const lvl = levelInfo((own?.xp ?? 0) + (own?.live_xp ?? 0));
1442
+ const divisionField = own?.division === void 0 ? [] : race.horses.filter((h) => h.division === own.division);
1443
+ const divisionRank = own ? divisionField.indexOf(own) + 1 : 0;
1444
+ const showDivision = (race.league_division_names?.length ?? 0) > 0 && divisionRank > 0;
1445
+ const rows = [
1446
+ { label: "Tokens (race):", value: String(own?.current_tokens ?? 0) },
1447
+ { label: "Position:", value: `${own?.rank ?? "\u2014"} of ${race.horses.length}` },
1448
+ ...showDivision ? [{ label: "Position (Division):", value: `${divisionRank} of ${divisionField.length}` }] : [],
1449
+ { label: "Leader:", value: leaderText(leader) },
1450
+ ...showDivision ? [{ label: "Leader (Division):", value: leaderText(divisionField[0]) }] : [],
1451
+ { label: "Race elapsed:", value: `${(elapsedPct * 100).toFixed(0)}% ${bar(elapsedPct, 20)}` },
1452
+ { label: "Time left:", value: timeLeft },
1453
+ {
1454
+ label: "XP:",
1455
+ value: lvl.next_level_xp === null ? `${lvl.xp} (max level) ${bar(1, 20)}` : `${lvl.xp_into_level}/${lvl.xp_for_level} \u2192 Lvl. ${lvl.level + 1} ${bar(lvl.progress, 20)}`
1456
+ },
1457
+ {
1458
+ label: "Last heartbeat:",
1459
+ value: /* @__PURE__ */ jsxs5(Fragment2, { children: [
1460
+ lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1461
+ " ",
1462
+ /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1463
+ ] })
1464
+ }
1465
+ ];
1441
1466
  return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1442
1467
  /* @__PURE__ */ jsxs5(Text7, { children: [
1443
1468
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
@@ -1469,40 +1494,7 @@ function StatusScreen(props) {
1469
1494
  ] })
1470
1495
  ] }),
1471
1496
  /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
1472
- /* @__PURE__ */ jsxs5(Text7, { children: [
1473
- "Tokens (race): ",
1474
- own?.current_tokens ?? 0
1475
- ] }),
1476
- /* @__PURE__ */ jsxs5(Text7, { children: [
1477
- "Position: ",
1478
- own?.rank ?? "\u2014",
1479
- " of ",
1480
- race.horses.length
1481
- ] }),
1482
- /* @__PURE__ */ jsxs5(Text7, { children: [
1483
- "Leader: ",
1484
- leader ? `${leader.name}${leader.user_name ? ` (${leader.user_name})` : ""} \u2014 ${leader.current_tokens}` : "\u2014"
1485
- ] }),
1486
- /* @__PURE__ */ jsxs5(Text7, { children: [
1487
- "Race elapsed: ",
1488
- (elapsedPct * 100).toFixed(0),
1489
- "% ",
1490
- bar(elapsedPct, 20)
1491
- ] }),
1492
- /* @__PURE__ */ jsxs5(Text7, { children: [
1493
- "Time left: ",
1494
- timeLeft
1495
- ] }),
1496
- /* @__PURE__ */ jsxs5(Text7, { children: [
1497
- "XP: ",
1498
- lvl.next_level_xp === null ? `${lvl.xp} (max level) ${bar(1, 20)}` : `${lvl.xp_into_level}/${lvl.xp_for_level} \u2192 Lvl. ${lvl.level + 1} ${bar(lvl.progress, 20)}`
1499
- ] }),
1500
- /* @__PURE__ */ jsxs5(Text7, { children: [
1501
- "Last heartbeat: ",
1502
- lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1503
- " ",
1504
- /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1505
- ] }),
1497
+ /* @__PURE__ */ jsx7(StatLines, { rows }),
1506
1498
  stalled && /* @__PURE__ */ jsxs5(Text7, { color: "yellow", children: [
1507
1499
  "\u26A0 ",
1508
1500
  stallReason ?? "Can't read token usage",
@@ -1513,6 +1505,17 @@ function StatusScreen(props) {
1513
1505
  /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
1514
1506
  ] });
1515
1507
  }
1508
+ function StatLines(props) {
1509
+ const width = Math.max(...props.rows.map((r) => r.label.length)) + 1;
1510
+ return /* @__PURE__ */ jsx7(Fragment2, { children: props.rows.map((r) => /* @__PURE__ */ jsxs5(Text7, { children: [
1511
+ r.label.padEnd(width),
1512
+ r.value
1513
+ ] }, r.label)) });
1514
+ }
1515
+ function leaderText(h) {
1516
+ if (!h) return "\u2014";
1517
+ return `${h.name}${h.user_name ? ` (${h.user_name})` : ""} \u2014 ${h.current_tokens}`;
1518
+ }
1516
1519
  function elapsed(race) {
1517
1520
  const start = new Date(race.start_time).getTime();
1518
1521
  const end = new Date(race.end_time).getTime();
@@ -1580,27 +1583,168 @@ function runHeartbeatLoop(opts) {
1580
1583
  }
1581
1584
 
1582
1585
  // src/tokens/transcripts.ts
1586
+ import * as fs4 from "fs/promises";
1587
+ import * as path6 from "path";
1588
+
1589
+ // src/tokens/pool.ts
1590
+ var SCAN_CONCURRENCY = 12;
1591
+ async function mapWithConcurrency(items, limit, fn) {
1592
+ const out = new Array(items.length);
1593
+ let next = 0;
1594
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1595
+ while (true) {
1596
+ const i = next++;
1597
+ if (i >= items.length) return;
1598
+ out[i] = await fn(items[i], i);
1599
+ }
1600
+ });
1601
+ await Promise.all(workers);
1602
+ return out;
1603
+ }
1604
+
1605
+ // src/tokens/scan-cache.ts
1583
1606
  import * as fs3 from "fs/promises";
1584
1607
  import * as path5 from "path";
1608
+ var CACHE_VERSION = 1;
1609
+ function isEntry(v) {
1610
+ const e = v;
1611
+ return !!e && typeof e.mtimeMs === "number" && typeof e.size === "number" && typeof e.offset === "number";
1612
+ }
1613
+ var ScanCache = class _ScanCache {
1614
+ constructor(source, entries) {
1615
+ this.source = source;
1616
+ this.entries = entries;
1617
+ }
1618
+ source;
1619
+ entries;
1620
+ touched = /* @__PURE__ */ new Set();
1621
+ static async open(source) {
1622
+ return new _ScanCache(source, await loadEntries(source));
1623
+ }
1624
+ /**
1625
+ * Total bytes this source was known to hold at its last completed scan, for
1626
+ * diagnosing which source is blowing the beat budget. 0 = never scanned.
1627
+ */
1628
+ static async knownBytes(source) {
1629
+ let total = 0;
1630
+ for (const entry of (await loadEntries(source)).values()) total += entry.size;
1631
+ return total;
1632
+ }
1633
+ has(file) {
1634
+ return this.entries.has(file);
1635
+ }
1636
+ /**
1637
+ * Fold an append-only file, consuming only the bytes added since last time.
1638
+ *
1639
+ * A trailing line with no newline yet is folded into the RETURNED value but
1640
+ * not committed to the cache — matching a plain whole-file read, while still
1641
+ * re-reading that line once the writer completes it.
1642
+ */
1643
+ async readIncremental(file, fold) {
1644
+ const st = await fs3.stat(file);
1645
+ const prev = this.entries.get(file);
1646
+ this.touched.add(file);
1647
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1648
+ const grew = prev !== void 0 && st.size > prev.size;
1649
+ const start = grew ? prev.offset : 0;
1650
+ const acc = grew ? prev.value : fold.empty();
1651
+ const { lines, tail, consumedTo } = await readCompleteLines(file, start, st.size);
1652
+ const committed = lines.length > 0 ? fold.append(acc, lines) : acc;
1653
+ this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: consumedTo, value: committed });
1654
+ return tail === null ? committed : fold.append(committed, [tail]);
1655
+ }
1656
+ /**
1657
+ * For files that are rewritten whole rather than appended to (Gemini's .json
1658
+ * chats). Gated on mtime+size, recomputed in full whenever either moves.
1659
+ */
1660
+ async readWhenChanged(file, compute) {
1661
+ const st = await fs3.stat(file);
1662
+ const prev = this.entries.get(file);
1663
+ this.touched.add(file);
1664
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1665
+ const value = await compute(await fs3.readFile(file, "utf8"));
1666
+ this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: st.size, value });
1667
+ return value;
1668
+ }
1669
+ /** Persist, dropping any entry not read since `open` so the file can't grow forever. */
1670
+ async save() {
1671
+ for (const key of [...this.entries.keys()]) {
1672
+ if (!this.touched.has(key)) this.entries.delete(key);
1673
+ }
1674
+ const target = cacheFile(this.source);
1675
+ const tmp = `${target}.tmp`;
1676
+ try {
1677
+ await fs3.mkdir(path5.dirname(target), { recursive: true });
1678
+ await fs3.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
1679
+ await fs3.rename(tmp, target);
1680
+ } catch {
1681
+ }
1682
+ }
1683
+ };
1684
+ function cacheFile(source) {
1685
+ return path5.join(homeDir(), "scan-cache", `${source}.json`);
1686
+ }
1687
+ async function loadEntries(source) {
1688
+ let parsed;
1689
+ try {
1690
+ parsed = JSON.parse(await fs3.readFile(cacheFile(source), "utf8"));
1691
+ } catch {
1692
+ return /* @__PURE__ */ new Map();
1693
+ }
1694
+ if (parsed?.version !== CACHE_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
1695
+ return /* @__PURE__ */ new Map();
1696
+ }
1697
+ const out = /* @__PURE__ */ new Map();
1698
+ for (const [file, entry] of Object.entries(parsed.files)) {
1699
+ if (isEntry(entry)) out.set(file, entry);
1700
+ }
1701
+ return out;
1702
+ }
1703
+ async function readCompleteLines(file, start, end) {
1704
+ if (end <= start) return { lines: [], tail: null, consumedTo: start };
1705
+ const fh = await fs3.open(file, "r");
1706
+ try {
1707
+ const buf = Buffer.allocUnsafe(end - start);
1708
+ const { bytesRead } = await fh.read(buf, 0, end - start, start);
1709
+ const chunk = buf.subarray(0, bytesRead);
1710
+ const lastNl = chunk.lastIndexOf(10);
1711
+ if (lastNl === -1) {
1712
+ return { lines: [], tail: chunk.toString("utf8") || null, consumedTo: start };
1713
+ }
1714
+ const tail = chunk.subarray(lastNl + 1).toString("utf8");
1715
+ return {
1716
+ lines: chunk.subarray(0, lastNl).toString("utf8").split("\n"),
1717
+ tail: tail === "" ? null : tail,
1718
+ consumedTo: start + lastNl + 1
1719
+ };
1720
+ } finally {
1721
+ await fh.close();
1722
+ }
1723
+ }
1724
+
1725
+ // src/tokens/transcripts.ts
1585
1726
  var MAX_PROJECT_DEPTH = 8;
1586
1727
  function conversationId(file, root) {
1587
- const rel = path5.relative(root, file);
1588
- const [project, session] = rel.split(path5.sep);
1728
+ const rel = path6.relative(root, file);
1729
+ const [project, session] = rel.split(path6.sep);
1589
1730
  if (project === void 0 || session === void 0) return rel.replace(/\.jsonl$/, "");
1590
1731
  return `${project}/${session.replace(/\.jsonl$/, "")}`;
1591
1732
  }
1592
1733
  async function sumTokensByConversation() {
1593
1734
  const root = claudeProjectsDir();
1594
1735
  const files = await listJsonlFiles(root);
1736
+ const cache = await ScanCache.open("claude");
1737
+ const totals = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => cache.readIncremental(f, CLAUDE_FOLD));
1738
+ await cache.save();
1595
1739
  const byConv = /* @__PURE__ */ new Map();
1596
- for (const file of files) {
1597
- const t = await sumFile(file);
1740
+ files.forEach((file, i) => {
1741
+ const t = totals[i];
1598
1742
  const id = conversationId(file, root);
1599
1743
  const acc = byConv.get(id) ?? { input: 0, output: 0 };
1600
1744
  acc.input += t.input;
1601
1745
  acc.output += t.output;
1602
1746
  byConv.set(id, acc);
1603
- }
1747
+ });
1604
1748
  return byConv;
1605
1749
  }
1606
1750
  async function sumTokens() {
@@ -1614,25 +1758,25 @@ async function sumTokens() {
1614
1758
  return { input, output };
1615
1759
  }
1616
1760
  async function listJsonlFiles(root) {
1617
- const projects = await fs3.readdir(root);
1761
+ const projects = await fs4.readdir(root);
1618
1762
  const out = [];
1619
1763
  for (const project of projects) {
1620
- const projectDir = path5.join(root, project);
1621
- const stat3 = await fs3.stat(projectDir);
1622
- if (!stat3.isDirectory()) continue;
1764
+ const projectDir = path6.join(root, project);
1765
+ const stat4 = await fs4.stat(projectDir);
1766
+ if (!stat4.isDirectory()) continue;
1623
1767
  await collectJsonl(projectDir, MAX_PROJECT_DEPTH, out);
1624
1768
  }
1625
1769
  return out;
1626
1770
  }
1627
1771
  async function collectJsonl(dir, depth, out) {
1628
1772
  if (depth <= 0) return;
1629
- const entries = await fs3.readdir(dir);
1773
+ const entries = await fs4.readdir(dir);
1630
1774
  for (const entry of entries) {
1631
1775
  if (entry.endsWith(".jsonl")) {
1632
- out.push(path5.join(dir, entry));
1776
+ out.push(path6.join(dir, entry));
1633
1777
  } else if (depth > 1) {
1634
- const child = path5.join(dir, entry);
1635
- const st = await fs3.stat(child);
1778
+ const child = path6.join(dir, entry);
1779
+ const st = await fs4.stat(child);
1636
1780
  if (st.isDirectory()) await collectJsonl(child, depth - 1, out);
1637
1781
  }
1638
1782
  }
@@ -1640,43 +1784,49 @@ async function collectJsonl(dir, depth, out) {
1640
1784
  function addNum(value) {
1641
1785
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1642
1786
  }
1643
- async function sumFile(file) {
1644
- const raw = await fs3.readFile(file, "utf8");
1645
- let input = 0;
1646
- let output = 0;
1647
- for (const line of raw.split("\n")) {
1648
- if (!line.trim()) continue;
1649
- let parsed;
1650
- try {
1651
- parsed = JSON.parse(line);
1652
- } catch {
1653
- continue;
1787
+ var CLAUDE_FOLD = {
1788
+ empty: () => ({ input: 0, output: 0 }),
1789
+ append: (acc, lines) => {
1790
+ let { input, output } = acc;
1791
+ for (const line of lines) {
1792
+ if (!line.trim()) continue;
1793
+ let parsed;
1794
+ try {
1795
+ parsed = JSON.parse(line);
1796
+ } catch {
1797
+ continue;
1798
+ }
1799
+ const usage = parsed?.message?.usage;
1800
+ if (!usage) continue;
1801
+ input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1802
+ output += addNum(usage.output_tokens);
1654
1803
  }
1655
- const usage = parsed?.message?.usage;
1656
- if (!usage) continue;
1657
- input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1658
- output += addNum(usage.output_tokens);
1804
+ return { input, output };
1659
1805
  }
1660
- return { input, output };
1661
- }
1806
+ };
1662
1807
 
1663
1808
  // src/tokens/codex.ts
1664
- import * as fs4 from "fs/promises";
1665
- import * as path6 from "path";
1809
+ import * as fs5 from "fs/promises";
1810
+ import * as path7 from "path";
1666
1811
  function num(v) {
1667
1812
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
1668
1813
  }
1669
1814
  async function sumCodexByConversation() {
1670
1815
  const root = codexSessionsDir();
1671
- await fs4.stat(root);
1816
+ await fs5.stat(root);
1672
1817
  const files = [
1673
- ...await collectRollouts(path6.join(root, "sessions")),
1674
- ...await collectRollouts(path6.join(root, "archived_sessions"))
1818
+ ...await collectRollouts(path7.join(root, "sessions")),
1819
+ ...await collectRollouts(path7.join(root, "archived_sessions"))
1675
1820
  ];
1821
+ const cache = await ScanCache.open("codex");
1822
+ const totals = await mapWithConcurrency(
1823
+ files,
1824
+ SCAN_CONCURRENCY,
1825
+ (f) => cache.readIncremental(f, CODEX_FOLD).catch(() => ({ input: 0, output: 0 }))
1826
+ );
1827
+ await cache.save();
1676
1828
  const byConv = /* @__PURE__ */ new Map();
1677
- for (const file of files) {
1678
- byConv.set(file, await lastTokenCount(file));
1679
- }
1829
+ files.forEach((file, i) => byConv.set(file, totals[i]));
1680
1830
  return byConv;
1681
1831
  }
1682
1832
  async function sumCodexTokens() {
@@ -1692,58 +1842,60 @@ async function sumCodexTokens() {
1692
1842
  async function collectRollouts(dir) {
1693
1843
  let entries;
1694
1844
  try {
1695
- entries = await fs4.readdir(dir, { withFileTypes: true });
1845
+ entries = await fs5.readdir(dir, { withFileTypes: true });
1696
1846
  } catch (e) {
1697
1847
  if (e?.code === "ENOENT") return [];
1698
1848
  throw e;
1699
1849
  }
1700
1850
  const out = [];
1701
1851
  for (const entry of entries) {
1702
- const full = path6.join(dir, entry.name);
1852
+ const full = path7.join(dir, entry.name);
1703
1853
  if (entry.isDirectory()) out.push(...await collectRollouts(full));
1704
1854
  else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) out.push(full);
1705
1855
  }
1706
1856
  return out;
1707
1857
  }
1708
- async function lastTokenCount(file) {
1709
- let raw;
1710
- try {
1711
- raw = await fs4.readFile(file, "utf8");
1712
- } catch {
1713
- return { input: 0, output: 0 };
1714
- }
1715
- let usage = null;
1716
- for (const line of raw.split("\n")) {
1717
- if (!line.trim()) continue;
1718
- let parsed;
1719
- try {
1720
- parsed = JSON.parse(line);
1721
- } catch {
1722
- continue;
1723
- }
1724
- if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
1725
- usage = parsed.payload.info.total_token_usage;
1858
+ var CODEX_FOLD = {
1859
+ empty: () => ({ input: 0, output: 0 }),
1860
+ append: (acc, lines) => {
1861
+ let usage = null;
1862
+ for (const line of lines) {
1863
+ if (!line.trim()) continue;
1864
+ let parsed;
1865
+ try {
1866
+ parsed = JSON.parse(line);
1867
+ } catch {
1868
+ continue;
1869
+ }
1870
+ if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
1871
+ usage = parsed.payload.info.total_token_usage;
1872
+ }
1726
1873
  }
1874
+ if (!usage) return acc;
1875
+ return {
1876
+ input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
1877
+ output: num(usage.output_tokens)
1878
+ };
1727
1879
  }
1728
- if (!usage) return { input: 0, output: 0 };
1729
- return {
1730
- input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
1731
- output: num(usage.output_tokens)
1732
- };
1733
- }
1880
+ };
1734
1881
 
1735
1882
  // src/tokens/gemini.ts
1736
- import * as fs5 from "fs/promises";
1737
- import * as path7 from "path";
1883
+ import * as fs6 from "fs/promises";
1884
+ import * as path8 from "path";
1738
1885
  function num2(v) {
1739
1886
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
1740
1887
  }
1741
1888
  async function sumGeminiByConversation() {
1742
1889
  const files = await listChatFiles(geminiTmpDir());
1890
+ const cache = await ScanCache.open("gemini");
1891
+ const totals = await mapWithConcurrency(
1892
+ files,
1893
+ SCAN_CONCURRENCY,
1894
+ (f) => cache.readWhenChanged(f, async (raw) => sumGeminiRaw(f, raw)).catch(() => ({ input: 0, output: 0 }))
1895
+ );
1896
+ await cache.save();
1743
1897
  const byConv = /* @__PURE__ */ new Map();
1744
- for (const file of files) {
1745
- byConv.set(file, await sumGeminiFile(file));
1746
- }
1898
+ files.forEach((file, i) => byConv.set(file, totals[i]));
1747
1899
  return byConv;
1748
1900
  }
1749
1901
  async function sumGeminiTokens() {
@@ -1757,29 +1909,23 @@ async function sumGeminiTokens() {
1757
1909
  return { input, output };
1758
1910
  }
1759
1911
  async function listChatFiles(root) {
1760
- const entries = await fs5.readdir(root);
1912
+ const entries = await fs6.readdir(root);
1761
1913
  const out = [];
1762
1914
  for (const entry of entries) {
1763
- const chatsDir = path7.join(root, entry, "chats");
1915
+ const chatsDir = path8.join(root, entry, "chats");
1764
1916
  let files;
1765
1917
  try {
1766
- files = await fs5.readdir(chatsDir);
1918
+ files = await fs6.readdir(chatsDir);
1767
1919
  } catch {
1768
1920
  continue;
1769
1921
  }
1770
1922
  for (const f of files) {
1771
- if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path7.join(chatsDir, f));
1923
+ if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path8.join(chatsDir, f));
1772
1924
  }
1773
1925
  }
1774
1926
  return out;
1775
1927
  }
1776
- async function sumGeminiFile(file) {
1777
- let raw;
1778
- try {
1779
- raw = await fs5.readFile(file, "utf8");
1780
- } catch {
1781
- return { input: 0, output: 0 };
1782
- }
1928
+ function sumGeminiRaw(file, raw) {
1783
1929
  const messages = file.endsWith(".jsonl") ? parseJsonl(raw) : parseJson(raw);
1784
1930
  let input = 0;
1785
1931
  let output = 0;
@@ -1815,6 +1961,21 @@ function parseJsonl(raw) {
1815
1961
  function isStall(r) {
1816
1962
  return "stall" in r;
1817
1963
  }
1964
+ var TIMED_OUT = /* @__PURE__ */ Symbol("scan-timeout");
1965
+ async function scanWithTimeout(scan, timeoutMs, describeTimeout) {
1966
+ let timer;
1967
+ const budget = new Promise((resolve) => {
1968
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
1969
+ });
1970
+ try {
1971
+ const result = await Promise.race([scan(), budget]);
1972
+ if (result !== TIMED_OUT) return result;
1973
+ const detail = describeTimeout ? await describeTimeout() : null;
1974
+ return { stall: detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s` };
1975
+ } finally {
1976
+ clearTimeout(timer);
1977
+ }
1978
+ }
1818
1979
  var SCALAR_READERS = {
1819
1980
  claude: sumTokens,
1820
1981
  codex: sumCodexTokens,
@@ -1828,25 +1989,71 @@ var BY_CONVERSATION_READERS = {
1828
1989
  function scoreFor(race, t) {
1829
1990
  return race.counts_input ? t.input + t.output : t.output;
1830
1991
  }
1831
- async function readAllSources(race, primary) {
1992
+ async function readAllSources(race, primary, progress) {
1993
+ progress?.begin(primary);
1994
+ const primaryScan = BY_CONVERSATION_READERS[primary]().then(
1995
+ (map) => ({ ok: true, map }),
1996
+ (err) => ({ ok: false, err })
1997
+ ).finally(() => progress?.end(primary));
1998
+ const secondaryKeys = MODEL_KEYS.filter((k) => k !== primary);
1999
+ const secondaryScans = secondaryKeys.map((k) => {
2000
+ progress?.begin(k);
2001
+ return SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch(() => 0).finally(() => progress?.end(k));
2002
+ });
2003
+ const [primaryResult, secondaryValues] = await Promise.all([
2004
+ primaryScan,
2005
+ Promise.all(secondaryScans)
2006
+ ]);
1832
2007
  const primaryByConv = /* @__PURE__ */ new Map();
1833
- try {
1834
- const primaryMap = await BY_CONVERSATION_READERS[primary]();
1835
- for (const [id, totals] of primaryMap) primaryByConv.set(id, scoreFor(race, totals));
1836
- } catch (e) {
1837
- if (e?.code !== "ENOENT") {
1838
- return { stall: `Can't read ${primary} token usage: ${e?.message ?? String(e)}` };
1839
- }
2008
+ if (primaryResult.ok) {
2009
+ for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2010
+ } else if (primaryResult.err?.code !== "ENOENT") {
2011
+ const err = primaryResult.err;
2012
+ return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
1840
2013
  }
1841
2014
  const secondary = { claude: 0, codex: 0, gemini: 0 };
1842
- await Promise.all(
1843
- MODEL_KEYS.filter((k) => k !== primary).map(async (k) => {
1844
- secondary[k] = await SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch(() => 0);
1845
- })
1846
- );
2015
+ secondaryKeys.forEach((k, i) => {
2016
+ secondary[k] = secondaryValues[i] ?? 0;
2017
+ });
1847
2018
  return { secondary, primaryByConv };
1848
2019
  }
1849
2020
 
2021
+ // src/tokens/scan-progress.ts
2022
+ function skipVar(key) {
2023
+ return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2024
+ }
2025
+ function formatBytes(bytes) {
2026
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
2027
+ return `${Math.round(bytes / 1e6)} MB`;
2028
+ }
2029
+ var ScanProgress = class {
2030
+ started = /* @__PURE__ */ new Map();
2031
+ finished = /* @__PURE__ */ new Set();
2032
+ begin(key) {
2033
+ this.started.set(key, Date.now());
2034
+ }
2035
+ end(key) {
2036
+ this.finished.add(key);
2037
+ }
2038
+ /** Sources begun but never finished, longest-running first. */
2039
+ outstanding() {
2040
+ return [...this.started.entries()].filter(([key]) => !this.finished.has(key)).sort((a, b) => a[1] - b[1]).map(([key]) => key);
2041
+ }
2042
+ };
2043
+ function describeScanTimeout(timeoutMs, outstanding) {
2044
+ const budget = `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s`;
2045
+ if (outstanding.length === 0) return budget;
2046
+ const named = outstanding.map((s) => s.bytes > 0 ? `${s.key} (${formatBytes(s.bytes)})` : s.key).join(", ");
2047
+ const biggest = [...outstanding].sort((a, b) => b.bytes - a.bytes)[0];
2048
+ return `${budget} \u2014 ${named} still scanning; point ${skipVar(biggest.key)} at an empty dir to skip it`;
2049
+ }
2050
+ async function diagnoseScanTimeout(timeoutMs, progress) {
2051
+ const outstanding = await Promise.all(
2052
+ progress.outstanding().map(async (key) => ({ key, bytes: await ScanCache.knownBytes(key) }))
2053
+ );
2054
+ return describeScanTimeout(timeoutMs, outstanding);
2055
+ }
2056
+
1850
2057
  // src/tokens/primary-cap.ts
1851
2058
  var PRIMARY_TOP_CONVERSATIONS = 5;
1852
2059
  function primaryConversationCap(enabled) {
@@ -2006,19 +2213,21 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2006
2213
  }, [race?.status]);
2007
2214
  useEffect2(() => {
2008
2215
  const tracker = trackerRef.current;
2009
- const scanWithTimeout = async () => {
2216
+ const scanBeat = async () => {
2217
+ const progress = new ScanProgress();
2010
2218
  try {
2011
- return await Promise.race([
2012
- readAllSources(active, active.primary_model),
2013
- new Promise((_, reject) => setTimeout(() => reject(new Error("scan timeout")), 1e4))
2014
- ]);
2015
- } catch {
2016
- return { stall: "Token scan timed out" };
2219
+ return await scanWithTimeout(
2220
+ () => readAllSources(active, active.primary_model, progress),
2221
+ SCAN_TIMEOUT_MS,
2222
+ () => diagnoseScanTimeout(SCAN_TIMEOUT_MS, progress)
2223
+ );
2224
+ } catch (e) {
2225
+ return { stall: `Token scan failed: ${e?.message ?? String(e)}` };
2017
2226
  }
2018
2227
  };
2019
2228
  runHeartbeatLoop({
2020
2229
  prepareBeat: async () => {
2021
- const reading = await scanWithTimeout();
2230
+ const reading = await scanBeat();
2022
2231
  tracker.recordReading(reading);
2023
2232
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2024
2233
  setStalled(tracker.stalled);