@mauricode/token-derby 2.12.2 → 2.12.3

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/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.3".length > 0) {
792
+ return "2.12.3";
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}`;
@@ -1580,27 +1581,168 @@ function runHeartbeatLoop(opts) {
1580
1581
  }
1581
1582
 
1582
1583
  // src/tokens/transcripts.ts
1584
+ import * as fs4 from "fs/promises";
1585
+ import * as path6 from "path";
1586
+
1587
+ // src/tokens/pool.ts
1588
+ var SCAN_CONCURRENCY = 12;
1589
+ async function mapWithConcurrency(items, limit, fn) {
1590
+ const out = new Array(items.length);
1591
+ let next = 0;
1592
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1593
+ while (true) {
1594
+ const i = next++;
1595
+ if (i >= items.length) return;
1596
+ out[i] = await fn(items[i], i);
1597
+ }
1598
+ });
1599
+ await Promise.all(workers);
1600
+ return out;
1601
+ }
1602
+
1603
+ // src/tokens/scan-cache.ts
1583
1604
  import * as fs3 from "fs/promises";
1584
1605
  import * as path5 from "path";
1606
+ var CACHE_VERSION = 1;
1607
+ function isEntry(v) {
1608
+ const e = v;
1609
+ return !!e && typeof e.mtimeMs === "number" && typeof e.size === "number" && typeof e.offset === "number";
1610
+ }
1611
+ var ScanCache = class _ScanCache {
1612
+ constructor(source, entries) {
1613
+ this.source = source;
1614
+ this.entries = entries;
1615
+ }
1616
+ source;
1617
+ entries;
1618
+ touched = /* @__PURE__ */ new Set();
1619
+ static async open(source) {
1620
+ return new _ScanCache(source, await loadEntries(source));
1621
+ }
1622
+ /**
1623
+ * Total bytes this source was known to hold at its last completed scan, for
1624
+ * diagnosing which source is blowing the beat budget. 0 = never scanned.
1625
+ */
1626
+ static async knownBytes(source) {
1627
+ let total = 0;
1628
+ for (const entry of (await loadEntries(source)).values()) total += entry.size;
1629
+ return total;
1630
+ }
1631
+ has(file) {
1632
+ return this.entries.has(file);
1633
+ }
1634
+ /**
1635
+ * Fold an append-only file, consuming only the bytes added since last time.
1636
+ *
1637
+ * A trailing line with no newline yet is folded into the RETURNED value but
1638
+ * not committed to the cache — matching a plain whole-file read, while still
1639
+ * re-reading that line once the writer completes it.
1640
+ */
1641
+ async readIncremental(file, fold) {
1642
+ const st = await fs3.stat(file);
1643
+ const prev = this.entries.get(file);
1644
+ this.touched.add(file);
1645
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1646
+ const grew = prev !== void 0 && st.size > prev.size;
1647
+ const start = grew ? prev.offset : 0;
1648
+ const acc = grew ? prev.value : fold.empty();
1649
+ const { lines, tail, consumedTo } = await readCompleteLines(file, start, st.size);
1650
+ const committed = lines.length > 0 ? fold.append(acc, lines) : acc;
1651
+ this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: consumedTo, value: committed });
1652
+ return tail === null ? committed : fold.append(committed, [tail]);
1653
+ }
1654
+ /**
1655
+ * For files that are rewritten whole rather than appended to (Gemini's .json
1656
+ * chats). Gated on mtime+size, recomputed in full whenever either moves.
1657
+ */
1658
+ async readWhenChanged(file, compute) {
1659
+ const st = await fs3.stat(file);
1660
+ const prev = this.entries.get(file);
1661
+ this.touched.add(file);
1662
+ if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1663
+ const value = await compute(await fs3.readFile(file, "utf8"));
1664
+ this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: st.size, value });
1665
+ return value;
1666
+ }
1667
+ /** Persist, dropping any entry not read since `open` so the file can't grow forever. */
1668
+ async save() {
1669
+ for (const key of [...this.entries.keys()]) {
1670
+ if (!this.touched.has(key)) this.entries.delete(key);
1671
+ }
1672
+ const target = cacheFile(this.source);
1673
+ const tmp = `${target}.tmp`;
1674
+ try {
1675
+ await fs3.mkdir(path5.dirname(target), { recursive: true });
1676
+ await fs3.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
1677
+ await fs3.rename(tmp, target);
1678
+ } catch {
1679
+ }
1680
+ }
1681
+ };
1682
+ function cacheFile(source) {
1683
+ return path5.join(homeDir(), "scan-cache", `${source}.json`);
1684
+ }
1685
+ async function loadEntries(source) {
1686
+ let parsed;
1687
+ try {
1688
+ parsed = JSON.parse(await fs3.readFile(cacheFile(source), "utf8"));
1689
+ } catch {
1690
+ return /* @__PURE__ */ new Map();
1691
+ }
1692
+ if (parsed?.version !== CACHE_VERSION || typeof parsed.files !== "object" || parsed.files === null) {
1693
+ return /* @__PURE__ */ new Map();
1694
+ }
1695
+ const out = /* @__PURE__ */ new Map();
1696
+ for (const [file, entry] of Object.entries(parsed.files)) {
1697
+ if (isEntry(entry)) out.set(file, entry);
1698
+ }
1699
+ return out;
1700
+ }
1701
+ async function readCompleteLines(file, start, end) {
1702
+ if (end <= start) return { lines: [], tail: null, consumedTo: start };
1703
+ const fh = await fs3.open(file, "r");
1704
+ try {
1705
+ const buf = Buffer.allocUnsafe(end - start);
1706
+ const { bytesRead } = await fh.read(buf, 0, end - start, start);
1707
+ const chunk = buf.subarray(0, bytesRead);
1708
+ const lastNl = chunk.lastIndexOf(10);
1709
+ if (lastNl === -1) {
1710
+ return { lines: [], tail: chunk.toString("utf8") || null, consumedTo: start };
1711
+ }
1712
+ const tail = chunk.subarray(lastNl + 1).toString("utf8");
1713
+ return {
1714
+ lines: chunk.subarray(0, lastNl).toString("utf8").split("\n"),
1715
+ tail: tail === "" ? null : tail,
1716
+ consumedTo: start + lastNl + 1
1717
+ };
1718
+ } finally {
1719
+ await fh.close();
1720
+ }
1721
+ }
1722
+
1723
+ // src/tokens/transcripts.ts
1585
1724
  var MAX_PROJECT_DEPTH = 8;
1586
1725
  function conversationId(file, root) {
1587
- const rel = path5.relative(root, file);
1588
- const [project, session] = rel.split(path5.sep);
1726
+ const rel = path6.relative(root, file);
1727
+ const [project, session] = rel.split(path6.sep);
1589
1728
  if (project === void 0 || session === void 0) return rel.replace(/\.jsonl$/, "");
1590
1729
  return `${project}/${session.replace(/\.jsonl$/, "")}`;
1591
1730
  }
1592
1731
  async function sumTokensByConversation() {
1593
1732
  const root = claudeProjectsDir();
1594
1733
  const files = await listJsonlFiles(root);
1734
+ const cache = await ScanCache.open("claude");
1735
+ const totals = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => cache.readIncremental(f, CLAUDE_FOLD));
1736
+ await cache.save();
1595
1737
  const byConv = /* @__PURE__ */ new Map();
1596
- for (const file of files) {
1597
- const t = await sumFile(file);
1738
+ files.forEach((file, i) => {
1739
+ const t = totals[i];
1598
1740
  const id = conversationId(file, root);
1599
1741
  const acc = byConv.get(id) ?? { input: 0, output: 0 };
1600
1742
  acc.input += t.input;
1601
1743
  acc.output += t.output;
1602
1744
  byConv.set(id, acc);
1603
- }
1745
+ });
1604
1746
  return byConv;
1605
1747
  }
1606
1748
  async function sumTokens() {
@@ -1614,25 +1756,25 @@ async function sumTokens() {
1614
1756
  return { input, output };
1615
1757
  }
1616
1758
  async function listJsonlFiles(root) {
1617
- const projects = await fs3.readdir(root);
1759
+ const projects = await fs4.readdir(root);
1618
1760
  const out = [];
1619
1761
  for (const project of projects) {
1620
- const projectDir = path5.join(root, project);
1621
- const stat3 = await fs3.stat(projectDir);
1622
- if (!stat3.isDirectory()) continue;
1762
+ const projectDir = path6.join(root, project);
1763
+ const stat4 = await fs4.stat(projectDir);
1764
+ if (!stat4.isDirectory()) continue;
1623
1765
  await collectJsonl(projectDir, MAX_PROJECT_DEPTH, out);
1624
1766
  }
1625
1767
  return out;
1626
1768
  }
1627
1769
  async function collectJsonl(dir, depth, out) {
1628
1770
  if (depth <= 0) return;
1629
- const entries = await fs3.readdir(dir);
1771
+ const entries = await fs4.readdir(dir);
1630
1772
  for (const entry of entries) {
1631
1773
  if (entry.endsWith(".jsonl")) {
1632
- out.push(path5.join(dir, entry));
1774
+ out.push(path6.join(dir, entry));
1633
1775
  } else if (depth > 1) {
1634
- const child = path5.join(dir, entry);
1635
- const st = await fs3.stat(child);
1776
+ const child = path6.join(dir, entry);
1777
+ const st = await fs4.stat(child);
1636
1778
  if (st.isDirectory()) await collectJsonl(child, depth - 1, out);
1637
1779
  }
1638
1780
  }
@@ -1640,43 +1782,49 @@ async function collectJsonl(dir, depth, out) {
1640
1782
  function addNum(value) {
1641
1783
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1642
1784
  }
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;
1785
+ var CLAUDE_FOLD = {
1786
+ empty: () => ({ input: 0, output: 0 }),
1787
+ append: (acc, lines) => {
1788
+ let { input, output } = acc;
1789
+ for (const line of lines) {
1790
+ if (!line.trim()) continue;
1791
+ let parsed;
1792
+ try {
1793
+ parsed = JSON.parse(line);
1794
+ } catch {
1795
+ continue;
1796
+ }
1797
+ const usage = parsed?.message?.usage;
1798
+ if (!usage) continue;
1799
+ input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1800
+ output += addNum(usage.output_tokens);
1654
1801
  }
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);
1802
+ return { input, output };
1659
1803
  }
1660
- return { input, output };
1661
- }
1804
+ };
1662
1805
 
1663
1806
  // src/tokens/codex.ts
1664
- import * as fs4 from "fs/promises";
1665
- import * as path6 from "path";
1807
+ import * as fs5 from "fs/promises";
1808
+ import * as path7 from "path";
1666
1809
  function num(v) {
1667
1810
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
1668
1811
  }
1669
1812
  async function sumCodexByConversation() {
1670
1813
  const root = codexSessionsDir();
1671
- await fs4.stat(root);
1814
+ await fs5.stat(root);
1672
1815
  const files = [
1673
- ...await collectRollouts(path6.join(root, "sessions")),
1674
- ...await collectRollouts(path6.join(root, "archived_sessions"))
1816
+ ...await collectRollouts(path7.join(root, "sessions")),
1817
+ ...await collectRollouts(path7.join(root, "archived_sessions"))
1675
1818
  ];
1819
+ const cache = await ScanCache.open("codex");
1820
+ const totals = await mapWithConcurrency(
1821
+ files,
1822
+ SCAN_CONCURRENCY,
1823
+ (f) => cache.readIncremental(f, CODEX_FOLD).catch(() => ({ input: 0, output: 0 }))
1824
+ );
1825
+ await cache.save();
1676
1826
  const byConv = /* @__PURE__ */ new Map();
1677
- for (const file of files) {
1678
- byConv.set(file, await lastTokenCount(file));
1679
- }
1827
+ files.forEach((file, i) => byConv.set(file, totals[i]));
1680
1828
  return byConv;
1681
1829
  }
1682
1830
  async function sumCodexTokens() {
@@ -1692,58 +1840,60 @@ async function sumCodexTokens() {
1692
1840
  async function collectRollouts(dir) {
1693
1841
  let entries;
1694
1842
  try {
1695
- entries = await fs4.readdir(dir, { withFileTypes: true });
1843
+ entries = await fs5.readdir(dir, { withFileTypes: true });
1696
1844
  } catch (e) {
1697
1845
  if (e?.code === "ENOENT") return [];
1698
1846
  throw e;
1699
1847
  }
1700
1848
  const out = [];
1701
1849
  for (const entry of entries) {
1702
- const full = path6.join(dir, entry.name);
1850
+ const full = path7.join(dir, entry.name);
1703
1851
  if (entry.isDirectory()) out.push(...await collectRollouts(full));
1704
1852
  else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) out.push(full);
1705
1853
  }
1706
1854
  return out;
1707
1855
  }
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;
1856
+ var CODEX_FOLD = {
1857
+ empty: () => ({ input: 0, output: 0 }),
1858
+ append: (acc, lines) => {
1859
+ let usage = null;
1860
+ for (const line of lines) {
1861
+ if (!line.trim()) continue;
1862
+ let parsed;
1863
+ try {
1864
+ parsed = JSON.parse(line);
1865
+ } catch {
1866
+ continue;
1867
+ }
1868
+ if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
1869
+ usage = parsed.payload.info.total_token_usage;
1870
+ }
1726
1871
  }
1872
+ if (!usage) return acc;
1873
+ return {
1874
+ input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
1875
+ output: num(usage.output_tokens)
1876
+ };
1727
1877
  }
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
- }
1878
+ };
1734
1879
 
1735
1880
  // src/tokens/gemini.ts
1736
- import * as fs5 from "fs/promises";
1737
- import * as path7 from "path";
1881
+ import * as fs6 from "fs/promises";
1882
+ import * as path8 from "path";
1738
1883
  function num2(v) {
1739
1884
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
1740
1885
  }
1741
1886
  async function sumGeminiByConversation() {
1742
1887
  const files = await listChatFiles(geminiTmpDir());
1888
+ const cache = await ScanCache.open("gemini");
1889
+ const totals = await mapWithConcurrency(
1890
+ files,
1891
+ SCAN_CONCURRENCY,
1892
+ (f) => cache.readWhenChanged(f, async (raw) => sumGeminiRaw(f, raw)).catch(() => ({ input: 0, output: 0 }))
1893
+ );
1894
+ await cache.save();
1743
1895
  const byConv = /* @__PURE__ */ new Map();
1744
- for (const file of files) {
1745
- byConv.set(file, await sumGeminiFile(file));
1746
- }
1896
+ files.forEach((file, i) => byConv.set(file, totals[i]));
1747
1897
  return byConv;
1748
1898
  }
1749
1899
  async function sumGeminiTokens() {
@@ -1757,29 +1907,23 @@ async function sumGeminiTokens() {
1757
1907
  return { input, output };
1758
1908
  }
1759
1909
  async function listChatFiles(root) {
1760
- const entries = await fs5.readdir(root);
1910
+ const entries = await fs6.readdir(root);
1761
1911
  const out = [];
1762
1912
  for (const entry of entries) {
1763
- const chatsDir = path7.join(root, entry, "chats");
1913
+ const chatsDir = path8.join(root, entry, "chats");
1764
1914
  let files;
1765
1915
  try {
1766
- files = await fs5.readdir(chatsDir);
1916
+ files = await fs6.readdir(chatsDir);
1767
1917
  } catch {
1768
1918
  continue;
1769
1919
  }
1770
1920
  for (const f of files) {
1771
- if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path7.join(chatsDir, f));
1921
+ if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path8.join(chatsDir, f));
1772
1922
  }
1773
1923
  }
1774
1924
  return out;
1775
1925
  }
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
- }
1926
+ function sumGeminiRaw(file, raw) {
1783
1927
  const messages = file.endsWith(".jsonl") ? parseJsonl(raw) : parseJson(raw);
1784
1928
  let input = 0;
1785
1929
  let output = 0;
@@ -1815,6 +1959,21 @@ function parseJsonl(raw) {
1815
1959
  function isStall(r) {
1816
1960
  return "stall" in r;
1817
1961
  }
1962
+ var TIMED_OUT = /* @__PURE__ */ Symbol("scan-timeout");
1963
+ async function scanWithTimeout(scan, timeoutMs, describeTimeout) {
1964
+ let timer;
1965
+ const budget = new Promise((resolve) => {
1966
+ timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
1967
+ });
1968
+ try {
1969
+ const result = await Promise.race([scan(), budget]);
1970
+ if (result !== TIMED_OUT) return result;
1971
+ const detail = describeTimeout ? await describeTimeout() : null;
1972
+ return { stall: detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s` };
1973
+ } finally {
1974
+ clearTimeout(timer);
1975
+ }
1976
+ }
1818
1977
  var SCALAR_READERS = {
1819
1978
  claude: sumTokens,
1820
1979
  codex: sumCodexTokens,
@@ -1828,25 +1987,71 @@ var BY_CONVERSATION_READERS = {
1828
1987
  function scoreFor(race, t) {
1829
1988
  return race.counts_input ? t.input + t.output : t.output;
1830
1989
  }
1831
- async function readAllSources(race, primary) {
1990
+ async function readAllSources(race, primary, progress) {
1991
+ progress?.begin(primary);
1992
+ const primaryScan = BY_CONVERSATION_READERS[primary]().then(
1993
+ (map) => ({ ok: true, map }),
1994
+ (err) => ({ ok: false, err })
1995
+ ).finally(() => progress?.end(primary));
1996
+ const secondaryKeys = MODEL_KEYS.filter((k) => k !== primary);
1997
+ const secondaryScans = secondaryKeys.map((k) => {
1998
+ progress?.begin(k);
1999
+ return SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch(() => 0).finally(() => progress?.end(k));
2000
+ });
2001
+ const [primaryResult, secondaryValues] = await Promise.all([
2002
+ primaryScan,
2003
+ Promise.all(secondaryScans)
2004
+ ]);
1832
2005
  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
- }
2006
+ if (primaryResult.ok) {
2007
+ for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2008
+ } else if (primaryResult.err?.code !== "ENOENT") {
2009
+ const err = primaryResult.err;
2010
+ return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
1840
2011
  }
1841
2012
  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
- );
2013
+ secondaryKeys.forEach((k, i) => {
2014
+ secondary[k] = secondaryValues[i] ?? 0;
2015
+ });
1847
2016
  return { secondary, primaryByConv };
1848
2017
  }
1849
2018
 
2019
+ // src/tokens/scan-progress.ts
2020
+ function skipVar(key) {
2021
+ return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2022
+ }
2023
+ function formatBytes(bytes) {
2024
+ if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
2025
+ return `${Math.round(bytes / 1e6)} MB`;
2026
+ }
2027
+ var ScanProgress = class {
2028
+ started = /* @__PURE__ */ new Map();
2029
+ finished = /* @__PURE__ */ new Set();
2030
+ begin(key) {
2031
+ this.started.set(key, Date.now());
2032
+ }
2033
+ end(key) {
2034
+ this.finished.add(key);
2035
+ }
2036
+ /** Sources begun but never finished, longest-running first. */
2037
+ outstanding() {
2038
+ return [...this.started.entries()].filter(([key]) => !this.finished.has(key)).sort((a, b) => a[1] - b[1]).map(([key]) => key);
2039
+ }
2040
+ };
2041
+ function describeScanTimeout(timeoutMs, outstanding) {
2042
+ const budget = `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s`;
2043
+ if (outstanding.length === 0) return budget;
2044
+ const named = outstanding.map((s) => s.bytes > 0 ? `${s.key} (${formatBytes(s.bytes)})` : s.key).join(", ");
2045
+ const biggest = [...outstanding].sort((a, b) => b.bytes - a.bytes)[0];
2046
+ return `${budget} \u2014 ${named} still scanning; point ${skipVar(biggest.key)} at an empty dir to skip it`;
2047
+ }
2048
+ async function diagnoseScanTimeout(timeoutMs, progress) {
2049
+ const outstanding = await Promise.all(
2050
+ progress.outstanding().map(async (key) => ({ key, bytes: await ScanCache.knownBytes(key) }))
2051
+ );
2052
+ return describeScanTimeout(timeoutMs, outstanding);
2053
+ }
2054
+
1850
2055
  // src/tokens/primary-cap.ts
1851
2056
  var PRIMARY_TOP_CONVERSATIONS = 5;
1852
2057
  function primaryConversationCap(enabled) {
@@ -2006,19 +2211,21 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2006
2211
  }, [race?.status]);
2007
2212
  useEffect2(() => {
2008
2213
  const tracker = trackerRef.current;
2009
- const scanWithTimeout = async () => {
2214
+ const scanBeat = async () => {
2215
+ const progress = new ScanProgress();
2010
2216
  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" };
2217
+ return await scanWithTimeout(
2218
+ () => readAllSources(active, active.primary_model, progress),
2219
+ SCAN_TIMEOUT_MS,
2220
+ () => diagnoseScanTimeout(SCAN_TIMEOUT_MS, progress)
2221
+ );
2222
+ } catch (e) {
2223
+ return { stall: `Token scan failed: ${e?.message ?? String(e)}` };
2017
2224
  }
2018
2225
  };
2019
2226
  runHeartbeatLoop({
2020
2227
  prepareBeat: async () => {
2021
- const reading = await scanWithTimeout();
2228
+ const reading = await scanBeat();
2022
2229
  tracker.recordReading(reading);
2023
2230
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2024
2231
  setStalled(tracker.stalled);