@mauricode/token-derby 3.1.0 → 3.1.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
@@ -857,12 +857,13 @@ function apiBase() {
857
857
  var HEARTBEAT_INTERVAL_MS = 6e4;
858
858
  var SCAN_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 0.75;
859
859
  var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
860
+ var PRIMARY_SILENT_THRESHOLD = 10;
860
861
 
861
862
  // src/version.ts
862
863
  import { createRequire } from "module";
863
864
  function readVersion() {
864
- if ("3.1.0".length > 0) {
865
- return "3.1.0";
865
+ if ("3.1.3".length > 0) {
866
+ return "3.1.3";
866
867
  }
867
868
  try {
868
869
  const req = createRequire(import.meta.url);
@@ -897,7 +898,11 @@ function activeRacesDir() {
897
898
  return path2.join(homeDir(), "active-races");
898
899
  }
899
900
  function claudeProjectsDir() {
900
- return process.env.TOKEN_DERBY_CLAUDE_DIR ?? path2.join(os2.homedir(), ".claude", "projects");
901
+ const override = process.env.TOKEN_DERBY_CLAUDE_DIR;
902
+ if (override) return override;
903
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
904
+ if (configDir) return path2.join(configDir, "projects");
905
+ return path2.join(os2.homedir(), ".claude", "projects");
901
906
  }
902
907
  function codexSessionsDir() {
903
908
  return process.env.TOKEN_DERBY_CODEX_DIR ?? path2.join(os2.homedir(), ".codex");
@@ -905,6 +910,12 @@ function codexSessionsDir() {
905
910
  function geminiTmpDir() {
906
911
  return process.env.TOKEN_DERBY_GEMINI_DIR ?? path2.join(os2.homedir(), ".gemini", "tmp");
907
912
  }
913
+ function logDir() {
914
+ return path2.join(homeDir(), "logs");
915
+ }
916
+ function logFile() {
917
+ return path2.join(logDir(), "token-derby.log");
918
+ }
908
919
 
909
920
  // src/identity/identity.ts
910
921
  async function readIdentityFile() {
@@ -959,6 +970,63 @@ function validateDisplayName(name) {
959
970
  return { ok: true, name: trimmed };
960
971
  }
961
972
 
973
+ // src/log/logger.ts
974
+ import * as fs2 from "fs";
975
+ var SECRET_KEY = /token|secret|authorization|credential|password/i;
976
+ function redact(fields) {
977
+ const out = {};
978
+ for (const [key, value] of Object.entries(fields)) {
979
+ out[key] = SECRET_KEY.test(key) ? "[redacted]" : value;
980
+ }
981
+ return out;
982
+ }
983
+ function formatLine(at, level, event, fields) {
984
+ const body = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(redact(fields))}` : "";
985
+ return `${at.toISOString()} ${level.padEnd(5)} ${event}${body}
986
+ `;
987
+ }
988
+ var MAX_BYTES = 2e6;
989
+ var MAX_FILES = 5;
990
+ var currentBytes = null;
991
+ var disabled = false;
992
+ function rotate() {
993
+ const base = logFile();
994
+ fs2.rmSync(`${base}.${MAX_FILES - 1}`, { force: true });
995
+ for (let i = MAX_FILES - 2; i >= 1; i--) {
996
+ if (fs2.existsSync(`${base}.${i}`)) fs2.renameSync(`${base}.${i}`, `${base}.${i + 1}`);
997
+ }
998
+ if (fs2.existsSync(base)) fs2.renameSync(base, `${base}.1`);
999
+ currentBytes = 0;
1000
+ }
1001
+ function write(level, event, fields) {
1002
+ if (disabled) return;
1003
+ try {
1004
+ append(level, event, fields);
1005
+ } catch {
1006
+ disabled = true;
1007
+ }
1008
+ }
1009
+ function append(level, event, fields) {
1010
+ const line = formatLine(/* @__PURE__ */ new Date(), level, event, fields);
1011
+ const bytes = Buffer.byteLength(line);
1012
+ fs2.mkdirSync(logDir(), { recursive: true });
1013
+ if (currentBytes === null) {
1014
+ currentBytes = fs2.existsSync(logFile()) ? fs2.statSync(logFile()).size : 0;
1015
+ }
1016
+ if (currentBytes > 0 && currentBytes + bytes > MAX_BYTES) rotate();
1017
+ fs2.appendFileSync(logFile(), line, "utf8");
1018
+ currentBytes += bytes;
1019
+ }
1020
+ function logInfo(event, fields) {
1021
+ write("INFO", event, fields);
1022
+ }
1023
+ function logWarn(event, fields) {
1024
+ write("WARN", event, fields);
1025
+ }
1026
+ function logError(event, fields) {
1027
+ write("ERROR", event, fields);
1028
+ }
1029
+
962
1030
  // src/api/client.ts
963
1031
  var ApiError = class extends Error {
964
1032
  constructor(code, message, status) {
@@ -970,6 +1038,10 @@ var ApiError = class extends Error {
970
1038
  code;
971
1039
  status;
972
1040
  };
1041
+ var SECRET_SEGMENT = /^\/(claims|races\/admin)\/[^/]+/;
1042
+ function loggablePath(path9) {
1043
+ return path9.replace(SECRET_SEGMENT, (match) => `${match.slice(0, match.lastIndexOf("/") + 1)}[redacted]`);
1044
+ }
973
1045
  var identityCache = null;
974
1046
  function getIdentity() {
975
1047
  if (!identityCache) identityCache = loadIdentity();
@@ -990,6 +1062,9 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
990
1062
  }
991
1063
  if (horseAuthToken) headers["authorization"] = `Bearer ${horseAuthToken}`;
992
1064
  if (body !== void 0) headers["content-type"] = "application/json";
1065
+ const safePath = loggablePath(path9);
1066
+ logInfo("http.req", { method, path: safePath });
1067
+ const startedAt2 = Date.now();
993
1068
  let res;
994
1069
  try {
995
1070
  res = await fetchImpl(url, {
@@ -998,8 +1073,10 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
998
1073
  body: body !== void 0 ? JSON.stringify(body) : void 0
999
1074
  });
1000
1075
  } catch (e) {
1076
+ logError("http.err", { method, path: safePath, ms: Date.now() - startedAt2, message: e?.message ?? "fetch failed" });
1001
1077
  throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
1002
1078
  }
1079
+ logInfo("http.res", { method, path: safePath, status: res.status, ms: Date.now() - startedAt2 });
1003
1080
  const text = await res.text();
1004
1081
  const contentType = res.headers.get("content-type") ?? "";
1005
1082
  let parsed = null;
@@ -1016,6 +1093,14 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
1016
1093
  }
1017
1094
  throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
1018
1095
  }
1096
+ if (parsed === null) {
1097
+ const got = contentType || "no content-type";
1098
+ throw new ApiError(
1099
+ "NETWORK_ERROR",
1100
+ `Expected JSON from ${method} ${safePath} but the server returned ${got} (HTTP ${res.status}). The API may be unavailable or the request was rejected upstream.`,
1101
+ res.status
1102
+ );
1103
+ }
1019
1104
  return parsed;
1020
1105
  }
1021
1106
 
@@ -1527,11 +1612,11 @@ function PrimaryPicker({ onPick }) {
1527
1612
  }
1528
1613
 
1529
1614
  // src/stable/active-race.ts
1530
- import * as fs2 from "fs/promises";
1615
+ import * as fs3 from "fs/promises";
1531
1616
  import * as path4 from "path";
1532
1617
  async function saveActiveRace(active) {
1533
- await fs2.mkdir(activeRacesDir(), { recursive: true });
1534
- await fs2.writeFile(
1618
+ await fs3.mkdir(activeRacesDir(), { recursive: true });
1619
+ await fs3.writeFile(
1535
1620
  activeRaceFile(active.join_code),
1536
1621
  JSON.stringify(active, null, 2) + "\n",
1537
1622
  "utf8"
@@ -1559,7 +1644,7 @@ function ModelList(props) {
1559
1644
  ] }) });
1560
1645
  }
1561
1646
  function StatusScreen(props) {
1562
- const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, primaryModel } = props;
1647
+ const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, primarySilent, primarySourceDir, primaryModel } = props;
1563
1648
  if (!race) {
1564
1649
  return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { children: "Joining race\u2026" }) });
1565
1650
  }
@@ -1630,6 +1715,15 @@ function StatusScreen(props) {
1630
1715
  "\u26A0 ",
1631
1716
  stallReason ?? "Can't read token usage",
1632
1717
  ". Your race continues."
1718
+ ] }),
1719
+ !stalled && primarySilent && /* @__PURE__ */ jsxs5(Text7, { color: "yellow", children: [
1720
+ "\u26A0 No ",
1721
+ MODEL_LABELS[primaryModel ?? "claude"],
1722
+ " transcripts in ",
1723
+ PRIMARY_SILENT_THRESHOLD,
1724
+ " beats",
1725
+ primarySourceDir ? ` \u2014 nothing under ${primarySourceDir}` : "",
1726
+ ". Your race continues, but your horse cannot move until they can be read."
1633
1727
  ] })
1634
1728
  ] }),
1635
1729
  primaryModel && /* @__PURE__ */ jsx7(ModelList, { primaryModel }),
@@ -1695,6 +1789,7 @@ function runHeartbeatLoop(opts) {
1695
1789
  let stopped = false;
1696
1790
  let pending = null;
1697
1791
  const stop = () => {
1792
+ if (!stopped) logInfo("beat.stop", { retry: retryIndex });
1698
1793
  stopped = true;
1699
1794
  if (timer) clearTimeout(timer);
1700
1795
  timer = null;
@@ -1707,9 +1802,16 @@ function runHeartbeatLoop(opts) {
1707
1802
  const tick = async () => {
1708
1803
  if (stopped) return;
1709
1804
  try {
1710
- if (!pending) pending = await opts.prepareBeat();
1805
+ if (!pending) {
1806
+ logInfo("beat.prepare.start");
1807
+ const startedAt2 = Date.now();
1808
+ pending = await opts.prepareBeat();
1809
+ logInfo("beat.prepare.done", { seq: pending.seq, ms: Date.now() - startedAt2 });
1810
+ }
1711
1811
  const snapshot = pending;
1812
+ const sentAt = Date.now();
1712
1813
  const resp = await opts.sendBeat(snapshot);
1814
+ logInfo("beat.send.ok", { seq: snapshot.seq, ms: Date.now() - sentAt, race_status: resp.race_status });
1713
1815
  pending = null;
1714
1816
  retryIndex = 0;
1715
1817
  opts.onSuccess(resp, snapshot);
@@ -1722,6 +1824,13 @@ function runHeartbeatLoop(opts) {
1722
1824
  } catch (err) {
1723
1825
  opts.onError(err);
1724
1826
  const delay = opts.retryDelaysMs[Math.min(retryIndex, opts.retryDelaysMs.length - 1)] ?? 1e3;
1827
+ logError("beat.send.err", {
1828
+ seq: pending?.seq,
1829
+ code: err?.code,
1830
+ message: err?.message ?? String(err),
1831
+ retry: retryIndex,
1832
+ next_ms: delay
1833
+ });
1725
1834
  retryIndex += 1;
1726
1835
  schedule(delay);
1727
1836
  }
@@ -1730,7 +1839,7 @@ function runHeartbeatLoop(opts) {
1730
1839
  }
1731
1840
 
1732
1841
  // src/tokens/transcripts.ts
1733
- import * as fs4 from "fs/promises";
1842
+ import * as fs5 from "fs/promises";
1734
1843
  import * as path6 from "path";
1735
1844
 
1736
1845
  // src/tokens/pool.ts
@@ -1750,7 +1859,7 @@ async function mapWithConcurrency(items, limit, fn) {
1750
1859
  }
1751
1860
 
1752
1861
  // src/tokens/scan-cache.ts
1753
- import * as fs3 from "fs/promises";
1862
+ import * as fs4 from "fs/promises";
1754
1863
  import * as path5 from "path";
1755
1864
  var CACHE_VERSION = 1;
1756
1865
  function isEntry(v) {
@@ -1788,7 +1897,7 @@ var ScanCache = class _ScanCache {
1788
1897
  * re-reading that line once the writer completes it.
1789
1898
  */
1790
1899
  async readIncremental(file, fold) {
1791
- const st = await fs3.stat(file);
1900
+ const st = await fs4.stat(file);
1792
1901
  const prev = this.entries.get(file);
1793
1902
  this.touched.add(file);
1794
1903
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
@@ -1805,11 +1914,11 @@ var ScanCache = class _ScanCache {
1805
1914
  * chats). Gated on mtime+size, recomputed in full whenever either moves.
1806
1915
  */
1807
1916
  async readWhenChanged(file, compute) {
1808
- const st = await fs3.stat(file);
1917
+ const st = await fs4.stat(file);
1809
1918
  const prev = this.entries.get(file);
1810
1919
  this.touched.add(file);
1811
1920
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1812
- const value = await compute(await fs3.readFile(file, "utf8"));
1921
+ const value = await compute(await fs4.readFile(file, "utf8"));
1813
1922
  this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: st.size, value });
1814
1923
  return value;
1815
1924
  }
@@ -1821,9 +1930,9 @@ var ScanCache = class _ScanCache {
1821
1930
  const target = cacheFile(this.source);
1822
1931
  const tmp = `${target}.tmp`;
1823
1932
  try {
1824
- await fs3.mkdir(path5.dirname(target), { recursive: true });
1825
- await fs3.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
1826
- await fs3.rename(tmp, target);
1933
+ await fs4.mkdir(path5.dirname(target), { recursive: true });
1934
+ await fs4.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
1935
+ await fs4.rename(tmp, target);
1827
1936
  } catch {
1828
1937
  }
1829
1938
  }
@@ -1834,7 +1943,7 @@ function cacheFile(source) {
1834
1943
  async function loadEntries(source) {
1835
1944
  let parsed;
1836
1945
  try {
1837
- parsed = JSON.parse(await fs3.readFile(cacheFile(source), "utf8"));
1946
+ parsed = JSON.parse(await fs4.readFile(cacheFile(source), "utf8"));
1838
1947
  } catch {
1839
1948
  return /* @__PURE__ */ new Map();
1840
1949
  }
@@ -1849,7 +1958,7 @@ async function loadEntries(source) {
1849
1958
  }
1850
1959
  async function readCompleteLines(file, start, end) {
1851
1960
  if (end <= start) return { lines: [], tail: null, consumedTo: start };
1852
- const fh = await fs3.open(file, "r");
1961
+ const fh = await fs4.open(file, "r");
1853
1962
  try {
1854
1963
  const buf = Buffer.allocUnsafe(end - start);
1855
1964
  const { bytesRead } = await fh.read(buf, 0, end - start, start);
@@ -1869,6 +1978,24 @@ async function readCompleteLines(file, start, end) {
1869
1978
  }
1870
1979
  }
1871
1980
 
1981
+ // src/tokens/source-root.ts
1982
+ var SourceRootMissing = class extends Error {
1983
+ constructor(dir) {
1984
+ super(`No history directory at ${dir}`);
1985
+ this.dir = dir;
1986
+ this.name = "SourceRootMissing";
1987
+ }
1988
+ dir;
1989
+ };
1990
+ async function readRoot(dir, read) {
1991
+ try {
1992
+ return await read();
1993
+ } catch (e) {
1994
+ if (e?.code === "ENOENT") throw new SourceRootMissing(dir);
1995
+ throw e;
1996
+ }
1997
+ }
1998
+
1872
1999
  // src/tokens/transcripts.ts
1873
2000
  var MAX_PROJECT_DEPTH = 8;
1874
2001
  function conversationId(file, root) {
@@ -1905,29 +2032,33 @@ async function sumTokens() {
1905
2032
  return { input, output };
1906
2033
  }
1907
2034
  async function listJsonlFiles(root) {
1908
- const projects = await fs4.readdir(root);
2035
+ const entries = await readEntries(root, true);
1909
2036
  const out = [];
1910
- for (const project of projects) {
1911
- const projectDir = path6.join(root, project);
1912
- const stat4 = await fs4.stat(projectDir);
1913
- if (!stat4.isDirectory()) continue;
1914
- await collectJsonl(projectDir, MAX_PROJECT_DEPTH, out);
2037
+ for (const entry of entries) {
2038
+ if (!await isDirectory(entry, root)) continue;
2039
+ await collectJsonl(path6.join(root, entry.name), MAX_PROJECT_DEPTH, out);
1915
2040
  }
1916
2041
  return out;
1917
2042
  }
1918
2043
  async function collectJsonl(dir, depth, out) {
1919
2044
  if (depth <= 0) return;
1920
- const entries = await fs4.readdir(dir);
1921
- for (const entry of entries) {
1922
- if (entry.endsWith(".jsonl")) {
1923
- out.push(path6.join(dir, entry));
1924
- } else if (depth > 1) {
1925
- const child = path6.join(dir, entry);
1926
- const st = await fs4.stat(child);
1927
- if (st.isDirectory()) await collectJsonl(child, depth - 1, out);
2045
+ for (const entry of await readEntries(dir, false)) {
2046
+ if (entry.name.endsWith(".jsonl")) {
2047
+ out.push(path6.join(dir, entry.name));
2048
+ } else if (depth > 1 && await isDirectory(entry, dir)) {
2049
+ await collectJsonl(path6.join(dir, entry.name), depth - 1, out);
1928
2050
  }
1929
2051
  }
1930
2052
  }
2053
+ async function readEntries(dir, failLoud) {
2054
+ if (failLoud) return readRoot(dir, () => fs5.readdir(dir, { withFileTypes: true }));
2055
+ return fs5.readdir(dir, { withFileTypes: true }).catch(() => []);
2056
+ }
2057
+ async function isDirectory(entry, parent) {
2058
+ if (entry.isDirectory()) return true;
2059
+ if (!entry.isSymbolicLink()) return false;
2060
+ return fs5.stat(path6.join(parent, entry.name)).then((st) => st.isDirectory()).catch(() => false);
2061
+ }
1931
2062
  function addNum(value) {
1932
2063
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1933
2064
  }
@@ -1953,18 +2084,15 @@ var CLAUDE_FOLD = {
1953
2084
  };
1954
2085
 
1955
2086
  // src/tokens/codex.ts
1956
- import * as fs5 from "fs/promises";
2087
+ import * as fs6 from "fs/promises";
1957
2088
  import * as path7 from "path";
1958
2089
  function num(v) {
1959
2090
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
1960
2091
  }
1961
2092
  async function sumCodexByConversation() {
1962
2093
  const root = codexSessionsDir();
1963
- await fs5.stat(root);
1964
- const files = [
1965
- ...await collectRollouts(path7.join(root, "sessions")),
1966
- ...await collectRollouts(path7.join(root, "archived_sessions"))
1967
- ];
2094
+ await readRoot(root, () => fs6.stat(root));
2095
+ const files = await listCodexRollouts(root);
1968
2096
  const cache = await ScanCache.open("codex");
1969
2097
  const totals = await mapWithConcurrency(
1970
2098
  files,
@@ -1986,10 +2114,16 @@ async function sumCodexTokens() {
1986
2114
  }
1987
2115
  return { input, output };
1988
2116
  }
2117
+ async function listCodexRollouts(root) {
2118
+ return [
2119
+ ...await collectRollouts(path7.join(root, "sessions")),
2120
+ ...await collectRollouts(path7.join(root, "archived_sessions"))
2121
+ ];
2122
+ }
1989
2123
  async function collectRollouts(dir) {
1990
2124
  let entries;
1991
2125
  try {
1992
- entries = await fs5.readdir(dir, { withFileTypes: true });
2126
+ entries = await fs6.readdir(dir, { withFileTypes: true });
1993
2127
  } catch (e) {
1994
2128
  if (e?.code === "ENOENT") return [];
1995
2129
  throw e;
@@ -2027,7 +2161,7 @@ var CODEX_FOLD = {
2027
2161
  };
2028
2162
 
2029
2163
  // src/tokens/gemini.ts
2030
- import * as fs6 from "fs/promises";
2164
+ import * as fs7 from "fs/promises";
2031
2165
  import * as path8 from "path";
2032
2166
  function num2(v) {
2033
2167
  return typeof v === "number" && Number.isFinite(v) ? v : 0;
@@ -2056,13 +2190,13 @@ async function sumGeminiTokens() {
2056
2190
  return { input, output };
2057
2191
  }
2058
2192
  async function listChatFiles(root) {
2059
- const entries = await fs6.readdir(root);
2193
+ const entries = await readRoot(root, () => fs7.readdir(root));
2060
2194
  const out = [];
2061
2195
  for (const entry of entries) {
2062
2196
  const chatsDir = path8.join(root, entry, "chats");
2063
2197
  let files;
2064
2198
  try {
2065
- files = await fs6.readdir(chatsDir);
2199
+ files = await fs7.readdir(chatsDir);
2066
2200
  } catch {
2067
2201
  continue;
2068
2202
  }
@@ -2114,11 +2248,24 @@ async function scanWithTimeout(scan, timeoutMs, describeTimeout) {
2114
2248
  const budget = new Promise((resolve) => {
2115
2249
  timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
2116
2250
  });
2251
+ const startedAt2 = Date.now();
2117
2252
  try {
2118
2253
  const result = await Promise.race([scan(), budget]);
2119
- if (result !== TIMED_OUT) return result;
2254
+ if (result !== TIMED_OUT) {
2255
+ if (isStall(result)) logWarn("scan.stall", { reason: result.stall, ms: Date.now() - startedAt2 });
2256
+ return result;
2257
+ }
2120
2258
  const detail = describeTimeout ? await describeTimeout() : null;
2121
- return { stall: detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s` };
2259
+ const stall = detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s`;
2260
+ logWarn("scan.timeout", { budget_ms: timeoutMs, reason: stall });
2261
+ return { stall };
2262
+ } catch (err) {
2263
+ logError("scan.error", {
2264
+ message: err?.message ?? String(err),
2265
+ stack: err?.stack,
2266
+ ms: Date.now() - startedAt2
2267
+ });
2268
+ throw err;
2122
2269
  } finally {
2123
2270
  clearTimeout(timer);
2124
2271
  }
@@ -2145,7 +2292,12 @@ async function readAllSources(race, primary, progress) {
2145
2292
  const secondaryKeys = MODEL_KEYS.filter((k) => k !== primary);
2146
2293
  const secondaryScans = secondaryKeys.map((k) => {
2147
2294
  progress?.begin(k);
2148
- return SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch(() => 0).finally(() => progress?.end(k));
2295
+ return SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch((err) => {
2296
+ if (!(err instanceof SourceRootMissing)) {
2297
+ logWarn("scan.source.err", { source: k, message: err?.message ?? String(err) });
2298
+ }
2299
+ return 0;
2300
+ }).finally(() => progress?.end(k));
2149
2301
  });
2150
2302
  const [primaryResult, secondaryValues] = await Promise.all([
2151
2303
  primaryScan,
@@ -2154,7 +2306,7 @@ async function readAllSources(race, primary, progress) {
2154
2306
  const primaryByConv = /* @__PURE__ */ new Map();
2155
2307
  if (primaryResult.ok) {
2156
2308
  for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2157
- } else if (primaryResult.err?.code !== "ENOENT") {
2309
+ } else if (!(primaryResult.err instanceof SourceRootMissing)) {
2158
2310
  const err = primaryResult.err;
2159
2311
  return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
2160
2312
  }
@@ -2221,6 +2373,7 @@ var RaceScoreTracker = class {
2221
2373
  seq;
2222
2374
  stalls = 0;
2223
2375
  lastStall = null;
2376
+ primaryEmptyBeats = 0;
2224
2377
  primary;
2225
2378
  primaryTop5;
2226
2379
  constructor(init, primary, primaryTop5) {
@@ -2253,6 +2406,7 @@ var RaceScoreTracker = class {
2253
2406
  const v = reading.secondary[key];
2254
2407
  if (v > 0) this.lastGood[key] = v;
2255
2408
  }
2409
+ this.primaryEmptyBeats = reading.primaryByConv.size === 0 ? this.primaryEmptyBeats + 1 : 0;
2256
2410
  for (const [id, v] of reading.primaryByConv) {
2257
2411
  const prev = this.primaryConvLast[id] ?? 0;
2258
2412
  if (v > prev) this.primaryConvLast[id] = v;
@@ -2304,6 +2458,10 @@ var RaceScoreTracker = class {
2304
2458
  get stalled() {
2305
2459
  return this.stalls >= STALL_THRESHOLD;
2306
2460
  }
2461
+ /** The primary source has produced no conversations for long enough to be worth saying. */
2462
+ get primarySilent() {
2463
+ return this.primaryEmptyBeats >= PRIMARY_SILENT_THRESHOLD;
2464
+ }
2307
2465
  /** Human-readable cause of the most recent stall (null once a good read recovers). */
2308
2466
  get stallReason() {
2309
2467
  return this.lastStall;
@@ -2332,6 +2490,75 @@ var RaceScoreTracker = class {
2332
2490
  }
2333
2491
  };
2334
2492
 
2493
+ // src/tokens/source-probe.ts
2494
+ import * as fs8 from "fs/promises";
2495
+ var ROOTS = {
2496
+ claude: claudeProjectsDir,
2497
+ codex: codexSessionsDir,
2498
+ gemini: geminiTmpDir
2499
+ };
2500
+ var LISTERS = {
2501
+ claude: listJsonlFiles,
2502
+ codex: listCodexRollouts,
2503
+ gemini: listChatFiles
2504
+ };
2505
+ var LABELS2 = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
2506
+ function sourceDir(key) {
2507
+ return ROOTS[key]();
2508
+ }
2509
+ async function probeSource(key) {
2510
+ const dir = ROOTS[key]();
2511
+ const exists = await fs8.stat(dir).then((st) => st.isDirectory()).catch(() => false);
2512
+ if (!exists) return { key, dir, exists: false, projects: 0, transcripts: 0 };
2513
+ const entries = await fs8.readdir(dir, { withFileTypes: true }).catch(() => []);
2514
+ const projects = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).length;
2515
+ const files = await LISTERS[key](dir).catch(() => []);
2516
+ return { key, dir, exists: true, projects, transcripts: files.length };
2517
+ }
2518
+ function overrideVar(key) {
2519
+ return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2520
+ }
2521
+ async function confirmEmptySource(opts) {
2522
+ if (opts.probe.transcripts > 0) return true;
2523
+ opts.warn(describeEmptySource(opts.probe));
2524
+ if (!opts.interactive) return true;
2525
+ return opts.ask();
2526
+ }
2527
+ function describeEmptySource(probe) {
2528
+ const label = LABELS2[probe.key];
2529
+ const populated = probe.exists && probe.projects > 0;
2530
+ const reason = !probe.exists ? "does not exist" : populated ? `holds ${probe.projects} project ${probe.projects === 1 ? "directory" : "directories"}, none of which could be read` : "exists, but holds no transcripts";
2531
+ const lines = [
2532
+ `\u26A0 No ${label} transcripts found \u2014 your horse will not move.`,
2533
+ ``,
2534
+ ` Looked in: ${probe.dir}`,
2535
+ ` (${reason})`,
2536
+ ``
2537
+ ];
2538
+ if (populated) {
2539
+ lines.push(
2540
+ ` The directory is there and has history in it, so this is usually a`,
2541
+ ` dangling symlink or a permissions problem on one of those projects.`,
2542
+ ` To find dangling links:`,
2543
+ ` find ${probe.dir} -type l ! -exec test -e {} \\; -print`,
2544
+ ``
2545
+ );
2546
+ }
2547
+ lines.push(
2548
+ ` Token Derby counts ${label} usage from this machine's own filesystem.`,
2549
+ ` If ${label} runs in a container, over SSH, or on another machine, join`,
2550
+ ` the race from there instead.`
2551
+ );
2552
+ if (probe.key === "claude") {
2553
+ lines.push(
2554
+ ` If CLAUDE_CONFIG_DIR relocated your config, Token Derby follows it \u2014`,
2555
+ ` check it points at the config root, not the projects directory.`
2556
+ );
2557
+ }
2558
+ lines.push(``, ` To read them from somewhere else: export ${overrideVar(probe.key)}=<dir>`);
2559
+ return lines.join("\n");
2560
+ }
2561
+
2335
2562
  // src/runtime/run-race.tsx
2336
2563
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2337
2564
  function RunRace({ active, initialState, pendingMode, ownUserName }) {
@@ -2348,6 +2575,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2348
2575
  const ctrl = useRef(new AbortController());
2349
2576
  const [stalled, setStalled] = useState5(false);
2350
2577
  const [stallReason, setStallReason] = useState5(null);
2578
+ const [primarySilent, setPrimarySilent] = useState5(false);
2351
2579
  useEffect2(() => {
2352
2580
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
2353
2581
  return () => clearInterval(t);
@@ -2379,6 +2607,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2379
2607
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2380
2608
  setStalled(tracker.stalled);
2381
2609
  setStallReason(tracker.stalled ? tracker.stallReason : null);
2610
+ setPrimarySilent(tracker.primarySilent);
2382
2611
  return tracker.nextBeat();
2383
2612
  },
2384
2613
  sendBeat: async (snapshot) => {
@@ -2446,6 +2675,8 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2446
2675
  lastHeartbeatOk: lastHbOk,
2447
2676
  stalled,
2448
2677
  stallReason,
2678
+ primarySilent,
2679
+ primarySourceDir: sourceDir(active.primary_model),
2449
2680
  primaryModel: active.primary_model
2450
2681
  }
2451
2682
  ),
@@ -2513,6 +2744,28 @@ async function buildInitialState(args) {
2513
2744
  };
2514
2745
  }
2515
2746
 
2747
+ // src/ui/prompt.ts
2748
+ async function promptYesNo(question, opts = {}) {
2749
+ const input = opts.input ?? process.stdin;
2750
+ const output = opts.output ?? process.stdout;
2751
+ if (input === process.stdin) resetStdinAfterInk();
2752
+ const readline6 = await import("readline/promises");
2753
+ const rl = readline6.createInterface({ input, output });
2754
+ const a = (await rl.question(question)).trim().toLowerCase();
2755
+ rl.close();
2756
+ if (a === "") return opts.defaultYes !== false;
2757
+ return a === "y" || a === "yes";
2758
+ }
2759
+ function resetStdinAfterInk() {
2760
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2761
+ process.stdin.setRawMode(false);
2762
+ }
2763
+ while (process.stdin.read() !== null) {
2764
+ }
2765
+ process.stdin.pause();
2766
+ process.stdin.ref();
2767
+ }
2768
+
2516
2769
  // src/commands/join.ts
2517
2770
  function parsePrimaryFlag(argv) {
2518
2771
  for (let i = 0; i < argv.length; i++) {
@@ -2607,6 +2860,19 @@ async function joinCommand(joinCode, argv = []) {
2607
2860
  if (primaryFlag) chosenPrimary = primaryFlag;
2608
2861
  else if (process.stdout.isTTY) chosenPrimary = await pickPrimary();
2609
2862
  }
2863
+ const effectivePrimary = ownHorse?.primary_model ?? chosenPrimary;
2864
+ const proceed = await confirmEmptySource({
2865
+ probe: await probeSource(effectivePrimary),
2866
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
2867
+ warn: (text) => console.error(`
2868
+ ${text}
2869
+ `),
2870
+ ask: () => promptYesNo("Join anyway? [y/N] ", { defaultYes: false })
2871
+ });
2872
+ if (!proceed) {
2873
+ console.log("Cancelled.");
2874
+ return 1;
2875
+ }
2610
2876
  let joinResp;
2611
2877
  try {
2612
2878
  joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId, primary_model: chosenPrimary });
@@ -2907,25 +3173,6 @@ async function webCommand(deps = {}) {
2907
3173
  return 0;
2908
3174
  }
2909
3175
 
2910
- // src/ui/prompt.ts
2911
- async function promptYesNo(question) {
2912
- resetStdinAfterInk();
2913
- const readline6 = await import("readline/promises");
2914
- const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
2915
- const a = (await rl.question(question)).trim().toLowerCase();
2916
- rl.close();
2917
- return a === "" || a === "y" || a === "yes";
2918
- }
2919
- function resetStdinAfterInk() {
2920
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2921
- process.stdin.setRawMode(false);
2922
- }
2923
- while (process.stdin.read() !== null) {
2924
- }
2925
- process.stdin.pause();
2926
- process.stdin.ref();
2927
- }
2928
-
2929
3176
  // src/commands/login.ts
2930
3177
  function parseDeviceNameFlag(argv) {
2931
3178
  for (let i = 0; i < argv.length; i++) {
@@ -3943,6 +4190,31 @@ function envCommand(arg) {
3943
4190
  return 0;
3944
4191
  }
3945
4192
 
4193
+ // src/commands/logs.ts
4194
+ import * as fs9 from "fs/promises";
4195
+ import { existsSync as existsSync2 } from "fs";
4196
+ var DEFAULT_TAIL_LINES = 50;
4197
+ function tailCount(argv) {
4198
+ const i = argv.indexOf("--tail");
4199
+ if (i === -1) return null;
4200
+ const n = Number(argv[i + 1]);
4201
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_TAIL_LINES;
4202
+ }
4203
+ async function logsCommand(argv) {
4204
+ const file = logFile();
4205
+ if (!existsSync2(file)) {
4206
+ console.log(`No log file yet \u2014 it appears at ${file} the first time a command runs.`);
4207
+ return 0;
4208
+ }
4209
+ console.log(file);
4210
+ const n = tailCount(argv);
4211
+ if (n === null) return 0;
4212
+ const lines = (await fs9.readFile(file, "utf8")).split("\n").filter((l) => l.length > 0);
4213
+ console.log("");
4214
+ for (const line of lines.slice(-n)) console.log(line);
4215
+ return 0;
4216
+ }
4217
+
3946
4218
  // src/bin.ts
3947
4219
  var HELP = `token-derby v${CLI_VERSION}
3948
4220
 
@@ -3971,6 +4243,8 @@ Identity:
3971
4243
 
3972
4244
  Maintenance:
3973
4245
  token-derby update Check for and install the latest CLI version
4246
+ token-derby logs Show the path of the debug log
4247
+ token-derby logs --tail [n] Print the last n log lines (default 50)
3974
4248
 
3975
4249
  Stable management:
3976
4250
  token-derby stable create Make a new horse (interactive)
@@ -4007,9 +4281,25 @@ Environment:
4007
4281
  TOKEN_DERBY_API_BASE Hard-override API base URL (wins over env)
4008
4282
  TOKEN_DERBY_HOME Hard-override identity/stable directory
4009
4283
  `;
4284
+ function describeInvocation(argv) {
4285
+ const cmd = argv[0] ?? "(none)";
4286
+ const container = cmd === "stable" || cmd === "organisation" || cmd === "org";
4287
+ return {
4288
+ cmd,
4289
+ sub: container ? argv[1] : void 0,
4290
+ flags: argv.filter((a) => a.startsWith("--")).map((a) => a.split("=")[0])
4291
+ };
4292
+ }
4010
4293
  async function main() {
4011
4294
  const argv = process.argv.slice(2);
4012
4295
  const cmd = argv[0];
4296
+ logInfo("cmd.start", {
4297
+ ...describeInvocation(argv),
4298
+ version: CLI_VERSION,
4299
+ node: process.version,
4300
+ pid: process.pid,
4301
+ env: selectedEnv()
4302
+ });
4013
4303
  if (!cmd || cmd === "--help" || cmd === "-h") {
4014
4304
  console.log(HELP);
4015
4305
  return 0;
@@ -4025,6 +4315,7 @@ async function main() {
4025
4315
  if (cmd === "login") return loginCommand(argv.slice(1));
4026
4316
  if (cmd === "update") return updateCommand();
4027
4317
  if (cmd === "env") return envCommand(argv[1]);
4318
+ if (cmd === "logs") return logsCommand(argv.slice(1));
4028
4319
  const identity = await loadIdentity();
4029
4320
  if (!identity) {
4030
4321
  console.error("Run `token-derby login` to set up your identity before using any other command.");
@@ -4071,9 +4362,33 @@ function parseFlag(args, flag) {
4071
4362
  }
4072
4363
  return void 0;
4073
4364
  }
4365
+ var CRASH_HANDLERS_INSTALLED = /* @__PURE__ */ Symbol.for("token-derby.crash-handlers");
4366
+ if (!(CRASH_HANDLERS_INSTALLED in process)) {
4367
+ process[CRASH_HANDLERS_INSTALLED] = true;
4368
+ process.on("uncaughtException", (err) => {
4369
+ logError("cmd.uncaught", { message: err?.message ?? String(err), stack: err?.stack });
4370
+ console.error(err?.stack ?? err);
4371
+ process.exit(1);
4372
+ });
4373
+ process.on("unhandledRejection", (reason) => {
4374
+ logError("cmd.unhandled", {
4375
+ message: reason?.message ?? String(reason),
4376
+ stack: reason?.stack
4377
+ });
4378
+ });
4379
+ }
4380
+ var startedAt = Date.now();
4074
4381
  main().then(
4075
- (code) => process.exit(code),
4382
+ (code) => {
4383
+ logInfo("cmd.exit", { code, ms: Date.now() - startedAt });
4384
+ process.exit(code);
4385
+ },
4076
4386
  (err) => {
4387
+ logError("cmd.crash", {
4388
+ message: err?.message ?? String(err),
4389
+ stack: err?.stack,
4390
+ ms: Date.now() - startedAt
4391
+ });
4077
4392
  console.error(err?.stack ?? err);
4078
4393
  process.exit(1);
4079
4394
  }