@mauricode/token-derby 3.1.0 → 3.1.2

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
@@ -103,4 +103,9 @@ at usage you didn't produce.
103
103
  - `TOKEN_DERBY_API_BASE` — override the API base URL (default: `https://token-derby.mauricode.co.uk/api`)
104
104
  - `TOKEN_DERBY_HOME` — override the data directory (default: `~/.token-derby`)
105
105
  - `TOKEN_DERBY_CLAUDE_DIR` — override the transcripts directory (default: `~/.claude/projects`)
106
+ - `CLAUDE_CONFIG_DIR` — Claude Code's own config override. When set, transcripts are read from `$CLAUDE_CONFIG_DIR/projects`. `TOKEN_DERBY_CLAUDE_DIR` still wins.
107
+
108
+ Token Derby counts usage from this machine's filesystem only. If Claude Code runs
109
+ in a container, over SSH, or on another machine, join the race from there — `join`
110
+ warns before entering a race whose primary model has no transcripts to read.
106
111
  - **Top-5 conversations (primary):** a race can be created so that only each racer's **5 most-active conversations per heartbeat** count toward their **primary** model's score (secondaries unaffected). The race creator opts in at `token-derby create` (prompt) or, for organisation-scheduled races, via the "Primary top-5 cap" option on the schedule tab of `token-derby web`. Off by default (every conversation counts).
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.2".length > 0) {
866
+ return "3.1.2";
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");
@@ -1559,7 +1564,7 @@ function ModelList(props) {
1559
1564
  ] }) });
1560
1565
  }
1561
1566
  function StatusScreen(props) {
1562
- const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, primaryModel } = props;
1567
+ const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, primarySilent, primarySourceDir, primaryModel } = props;
1563
1568
  if (!race) {
1564
1569
  return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { children: "Joining race\u2026" }) });
1565
1570
  }
@@ -1630,6 +1635,15 @@ function StatusScreen(props) {
1630
1635
  "\u26A0 ",
1631
1636
  stallReason ?? "Can't read token usage",
1632
1637
  ". Your race continues."
1638
+ ] }),
1639
+ !stalled && primarySilent && /* @__PURE__ */ jsxs5(Text7, { color: "yellow", children: [
1640
+ "\u26A0 No ",
1641
+ MODEL_LABELS[primaryModel ?? "claude"],
1642
+ " transcripts in ",
1643
+ PRIMARY_SILENT_THRESHOLD,
1644
+ " beats",
1645
+ primarySourceDir ? ` \u2014 nothing under ${primarySourceDir}` : "",
1646
+ ". Your race continues, but your horse cannot move until they can be read."
1633
1647
  ] })
1634
1648
  ] }),
1635
1649
  primaryModel && /* @__PURE__ */ jsx7(ModelList, { primaryModel }),
@@ -1869,6 +1883,24 @@ async function readCompleteLines(file, start, end) {
1869
1883
  }
1870
1884
  }
1871
1885
 
1886
+ // src/tokens/source-root.ts
1887
+ var SourceRootMissing = class extends Error {
1888
+ constructor(dir) {
1889
+ super(`No history directory at ${dir}`);
1890
+ this.dir = dir;
1891
+ this.name = "SourceRootMissing";
1892
+ }
1893
+ dir;
1894
+ };
1895
+ async function readRoot(dir, read) {
1896
+ try {
1897
+ return await read();
1898
+ } catch (e) {
1899
+ if (e?.code === "ENOENT") throw new SourceRootMissing(dir);
1900
+ throw e;
1901
+ }
1902
+ }
1903
+
1872
1904
  // src/tokens/transcripts.ts
1873
1905
  var MAX_PROJECT_DEPTH = 8;
1874
1906
  function conversationId(file, root) {
@@ -1905,29 +1937,33 @@ async function sumTokens() {
1905
1937
  return { input, output };
1906
1938
  }
1907
1939
  async function listJsonlFiles(root) {
1908
- const projects = await fs4.readdir(root);
1940
+ const entries = await readEntries(root, true);
1909
1941
  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);
1942
+ for (const entry of entries) {
1943
+ if (!await isDirectory(entry, root)) continue;
1944
+ await collectJsonl(path6.join(root, entry.name), MAX_PROJECT_DEPTH, out);
1915
1945
  }
1916
1946
  return out;
1917
1947
  }
1918
1948
  async function collectJsonl(dir, depth, out) {
1919
1949
  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);
1950
+ for (const entry of await readEntries(dir, false)) {
1951
+ if (entry.name.endsWith(".jsonl")) {
1952
+ out.push(path6.join(dir, entry.name));
1953
+ } else if (depth > 1 && await isDirectory(entry, dir)) {
1954
+ await collectJsonl(path6.join(dir, entry.name), depth - 1, out);
1928
1955
  }
1929
1956
  }
1930
1957
  }
1958
+ async function readEntries(dir, failLoud) {
1959
+ if (failLoud) return readRoot(dir, () => fs4.readdir(dir, { withFileTypes: true }));
1960
+ return fs4.readdir(dir, { withFileTypes: true }).catch(() => []);
1961
+ }
1962
+ async function isDirectory(entry, parent) {
1963
+ if (entry.isDirectory()) return true;
1964
+ if (!entry.isSymbolicLink()) return false;
1965
+ return fs4.stat(path6.join(parent, entry.name)).then((st) => st.isDirectory()).catch(() => false);
1966
+ }
1931
1967
  function addNum(value) {
1932
1968
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1933
1969
  }
@@ -1960,11 +1996,8 @@ function num(v) {
1960
1996
  }
1961
1997
  async function sumCodexByConversation() {
1962
1998
  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
- ];
1999
+ await readRoot(root, () => fs5.stat(root));
2000
+ const files = await listCodexRollouts(root);
1968
2001
  const cache = await ScanCache.open("codex");
1969
2002
  const totals = await mapWithConcurrency(
1970
2003
  files,
@@ -1986,6 +2019,12 @@ async function sumCodexTokens() {
1986
2019
  }
1987
2020
  return { input, output };
1988
2021
  }
2022
+ async function listCodexRollouts(root) {
2023
+ return [
2024
+ ...await collectRollouts(path7.join(root, "sessions")),
2025
+ ...await collectRollouts(path7.join(root, "archived_sessions"))
2026
+ ];
2027
+ }
1989
2028
  async function collectRollouts(dir) {
1990
2029
  let entries;
1991
2030
  try {
@@ -2056,7 +2095,7 @@ async function sumGeminiTokens() {
2056
2095
  return { input, output };
2057
2096
  }
2058
2097
  async function listChatFiles(root) {
2059
- const entries = await fs6.readdir(root);
2098
+ const entries = await readRoot(root, () => fs6.readdir(root));
2060
2099
  const out = [];
2061
2100
  for (const entry of entries) {
2062
2101
  const chatsDir = path8.join(root, entry, "chats");
@@ -2154,7 +2193,7 @@ async function readAllSources(race, primary, progress) {
2154
2193
  const primaryByConv = /* @__PURE__ */ new Map();
2155
2194
  if (primaryResult.ok) {
2156
2195
  for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2157
- } else if (primaryResult.err?.code !== "ENOENT") {
2196
+ } else if (!(primaryResult.err instanceof SourceRootMissing)) {
2158
2197
  const err = primaryResult.err;
2159
2198
  return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
2160
2199
  }
@@ -2221,6 +2260,7 @@ var RaceScoreTracker = class {
2221
2260
  seq;
2222
2261
  stalls = 0;
2223
2262
  lastStall = null;
2263
+ primaryEmptyBeats = 0;
2224
2264
  primary;
2225
2265
  primaryTop5;
2226
2266
  constructor(init, primary, primaryTop5) {
@@ -2253,6 +2293,7 @@ var RaceScoreTracker = class {
2253
2293
  const v = reading.secondary[key];
2254
2294
  if (v > 0) this.lastGood[key] = v;
2255
2295
  }
2296
+ this.primaryEmptyBeats = reading.primaryByConv.size === 0 ? this.primaryEmptyBeats + 1 : 0;
2256
2297
  for (const [id, v] of reading.primaryByConv) {
2257
2298
  const prev = this.primaryConvLast[id] ?? 0;
2258
2299
  if (v > prev) this.primaryConvLast[id] = v;
@@ -2304,6 +2345,10 @@ var RaceScoreTracker = class {
2304
2345
  get stalled() {
2305
2346
  return this.stalls >= STALL_THRESHOLD;
2306
2347
  }
2348
+ /** The primary source has produced no conversations for long enough to be worth saying. */
2349
+ get primarySilent() {
2350
+ return this.primaryEmptyBeats >= PRIMARY_SILENT_THRESHOLD;
2351
+ }
2307
2352
  /** Human-readable cause of the most recent stall (null once a good read recovers). */
2308
2353
  get stallReason() {
2309
2354
  return this.lastStall;
@@ -2332,6 +2377,75 @@ var RaceScoreTracker = class {
2332
2377
  }
2333
2378
  };
2334
2379
 
2380
+ // src/tokens/source-probe.ts
2381
+ import * as fs7 from "fs/promises";
2382
+ var ROOTS = {
2383
+ claude: claudeProjectsDir,
2384
+ codex: codexSessionsDir,
2385
+ gemini: geminiTmpDir
2386
+ };
2387
+ var LISTERS = {
2388
+ claude: listJsonlFiles,
2389
+ codex: listCodexRollouts,
2390
+ gemini: listChatFiles
2391
+ };
2392
+ var LABELS2 = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
2393
+ function sourceDir(key) {
2394
+ return ROOTS[key]();
2395
+ }
2396
+ async function probeSource(key) {
2397
+ const dir = ROOTS[key]();
2398
+ const exists = await fs7.stat(dir).then((st) => st.isDirectory()).catch(() => false);
2399
+ if (!exists) return { key, dir, exists: false, projects: 0, transcripts: 0 };
2400
+ const entries = await fs7.readdir(dir, { withFileTypes: true }).catch(() => []);
2401
+ const projects = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).length;
2402
+ const files = await LISTERS[key](dir).catch(() => []);
2403
+ return { key, dir, exists: true, projects, transcripts: files.length };
2404
+ }
2405
+ function overrideVar(key) {
2406
+ return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2407
+ }
2408
+ async function confirmEmptySource(opts) {
2409
+ if (opts.probe.transcripts > 0) return true;
2410
+ opts.warn(describeEmptySource(opts.probe));
2411
+ if (!opts.interactive) return true;
2412
+ return opts.ask();
2413
+ }
2414
+ function describeEmptySource(probe) {
2415
+ const label = LABELS2[probe.key];
2416
+ const populated = probe.exists && probe.projects > 0;
2417
+ 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";
2418
+ const lines = [
2419
+ `\u26A0 No ${label} transcripts found \u2014 your horse will not move.`,
2420
+ ``,
2421
+ ` Looked in: ${probe.dir}`,
2422
+ ` (${reason})`,
2423
+ ``
2424
+ ];
2425
+ if (populated) {
2426
+ lines.push(
2427
+ ` The directory is there and has history in it, so this is usually a`,
2428
+ ` dangling symlink or a permissions problem on one of those projects.`,
2429
+ ` To find dangling links:`,
2430
+ ` find ${probe.dir} -type l ! -exec test -e {} \\; -print`,
2431
+ ``
2432
+ );
2433
+ }
2434
+ lines.push(
2435
+ ` Token Derby counts ${label} usage from this machine's own filesystem.`,
2436
+ ` If ${label} runs in a container, over SSH, or on another machine, join`,
2437
+ ` the race from there instead.`
2438
+ );
2439
+ if (probe.key === "claude") {
2440
+ lines.push(
2441
+ ` If CLAUDE_CONFIG_DIR relocated your config, Token Derby follows it \u2014`,
2442
+ ` check it points at the config root, not the projects directory.`
2443
+ );
2444
+ }
2445
+ lines.push(``, ` To read them from somewhere else: export ${overrideVar(probe.key)}=<dir>`);
2446
+ return lines.join("\n");
2447
+ }
2448
+
2335
2449
  // src/runtime/run-race.tsx
2336
2450
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2337
2451
  function RunRace({ active, initialState, pendingMode, ownUserName }) {
@@ -2348,6 +2462,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2348
2462
  const ctrl = useRef(new AbortController());
2349
2463
  const [stalled, setStalled] = useState5(false);
2350
2464
  const [stallReason, setStallReason] = useState5(null);
2465
+ const [primarySilent, setPrimarySilent] = useState5(false);
2351
2466
  useEffect2(() => {
2352
2467
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
2353
2468
  return () => clearInterval(t);
@@ -2379,6 +2494,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2379
2494
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2380
2495
  setStalled(tracker.stalled);
2381
2496
  setStallReason(tracker.stalled ? tracker.stallReason : null);
2497
+ setPrimarySilent(tracker.primarySilent);
2382
2498
  return tracker.nextBeat();
2383
2499
  },
2384
2500
  sendBeat: async (snapshot) => {
@@ -2446,6 +2562,8 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2446
2562
  lastHeartbeatOk: lastHbOk,
2447
2563
  stalled,
2448
2564
  stallReason,
2565
+ primarySilent,
2566
+ primarySourceDir: sourceDir(active.primary_model),
2449
2567
  primaryModel: active.primary_model
2450
2568
  }
2451
2569
  ),
@@ -2513,6 +2631,28 @@ async function buildInitialState(args) {
2513
2631
  };
2514
2632
  }
2515
2633
 
2634
+ // src/ui/prompt.ts
2635
+ async function promptYesNo(question, opts = {}) {
2636
+ const input = opts.input ?? process.stdin;
2637
+ const output = opts.output ?? process.stdout;
2638
+ if (input === process.stdin) resetStdinAfterInk();
2639
+ const readline6 = await import("readline/promises");
2640
+ const rl = readline6.createInterface({ input, output });
2641
+ const a = (await rl.question(question)).trim().toLowerCase();
2642
+ rl.close();
2643
+ if (a === "") return opts.defaultYes !== false;
2644
+ return a === "y" || a === "yes";
2645
+ }
2646
+ function resetStdinAfterInk() {
2647
+ if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2648
+ process.stdin.setRawMode(false);
2649
+ }
2650
+ while (process.stdin.read() !== null) {
2651
+ }
2652
+ process.stdin.pause();
2653
+ process.stdin.ref();
2654
+ }
2655
+
2516
2656
  // src/commands/join.ts
2517
2657
  function parsePrimaryFlag(argv) {
2518
2658
  for (let i = 0; i < argv.length; i++) {
@@ -2607,6 +2747,19 @@ async function joinCommand(joinCode, argv = []) {
2607
2747
  if (primaryFlag) chosenPrimary = primaryFlag;
2608
2748
  else if (process.stdout.isTTY) chosenPrimary = await pickPrimary();
2609
2749
  }
2750
+ const effectivePrimary = ownHorse?.primary_model ?? chosenPrimary;
2751
+ const proceed = await confirmEmptySource({
2752
+ probe: await probeSource(effectivePrimary),
2753
+ interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
2754
+ warn: (text) => console.error(`
2755
+ ${text}
2756
+ `),
2757
+ ask: () => promptYesNo("Join anyway? [y/N] ", { defaultYes: false })
2758
+ });
2759
+ if (!proceed) {
2760
+ console.log("Cancelled.");
2761
+ return 1;
2762
+ }
2610
2763
  let joinResp;
2611
2764
  try {
2612
2765
  joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId, primary_model: chosenPrimary });
@@ -2907,25 +3060,6 @@ async function webCommand(deps = {}) {
2907
3060
  return 0;
2908
3061
  }
2909
3062
 
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
3063
  // src/commands/login.ts
2930
3064
  function parseDeviceNameFlag(argv) {
2931
3065
  for (let i = 0; i < argv.length; i++) {