@mauricode/token-derby 3.0.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
@@ -199,9 +199,31 @@ var HATS = [
199
199
  // Claim-only: awarded by an admin, never from a roll. Only the A channel cycles,
200
200
  // so the inset motif shifts colour while the cap body stays a static rust brown.
201
201
  // The body colour is kept out of the frame list so the motif never merges into it.
202
- { id: "contributor_cap", name: "Contributor Cap", rarity: "legendary", width: 11, anchor_x: 23, rows: [".....Q.....", "....QQQ....", "...QQQQQ...", "..QAAAQQQ..", "..QQAQQQQ..", ".QQQAQAAQQ.", ".QQQAQAQAQ.", ".QQQQQAQAQ.", ".QQQQQAAQQ.", ".QQQQQQQQQ."], colors: { A: "#21138b", Q: "#6f2801" }, animation: { type: "cycle", frames: ["#FF8C42", "#E0457B", "#8B5CF6", "#3B82F6", "#22D3EE", "#34D399", "#A3E635", "#FBBF24"], fps: 6 }, rollable: false }
202
+ { id: "contributor_cap", name: "Contributor Cap", rarity: "legendary", width: 11, anchor_x: 23, rows: [".....Q.....", "....QQQ....", "...QQQQQ...", "..QAAAQQQ..", "..QQAQQQQ..", ".QQQAQAAQQ.", ".QQQAQAQAQ.", ".QQQQQAQAQ.", ".QQQQQAAQQ.", ".QQQQQQQQQ."], colors: { A: "#21138b", Q: "#6f2801" }, animation: { type: "cycle", frames: ["#FF8C42", "#E0457B", "#8B5CF6", "#3B82F6", "#22D3EE", "#34D399", "#A3E635", "#FBBF24"], fps: 6 }, rollable: false },
203
+ // ── LIMITED (6) ────────────────────────────────────────────────────────
204
+ // London series. Claim-only by construction: limited is absent from
205
+ // TIER_WEIGHTS, so no roll can reach this tier.
206
+ { id: "bearskin", name: "Bearskin", rarity: "limited", width: 11, anchor_x: 23, rows: ["...CCCCC.Q.", "..CAAAAACQ.", "..AAAAAACQ.", "..AAAAAAAQ.", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..AAAAAAA..", "..DDDDDDD..", "..AAAAAAA.."], variants: [{ A: "#23232A", Q: "#C8102E", C: "#15151A", D: "#FFD300" }], rollable: false },
207
+ { id: "roundel_cap", name: "Roundel Cap", rarity: "limited", width: 11, anchor_x: 23, rows: ["...........", "..AAAAAA...", ".AAAAAAAA..", ".AAQQQQQAA.", ".AAQDDDQAA.", ".AFFFFFFFA.", ".AAQDDDQAA.", ".AAQQQQQAA.", ".AAAAAAAAA.", ".FFFFFFFFFF"], variants: [{ A: "#0B3B8C", Q: "#E32017", C: "#1050B8", D: "#F4F1E8", F: "#072A66" }], rollable: false },
208
+ { id: "union_topper", name: "Union Topper", rarity: "limited", width: 11, anchor_x: 23, rows: ["..CCCCCCC..", "..AQAAAQA..", "..AAQDQAA..", "..QQQDQQQ..", "..DDDDDDD..", "..QQQDQQQ..", "..AAQDQAA..", "..AQAAAQA..", ".CCCCCCCCC.", ".FFFFFFFFF."], variants: [{ A: "#0B2C6B", Q: "#F2F6FA", C: "#081F4D", D: "#C8102E", F: "#061838" }], rollable: false },
209
+ { id: "crown_jewels", name: "Crown Jewels", rarity: "limited", width: 11, anchor_x: 23, rows: ["...........", "....QQQ....", ".....Q.....", "..QQ.Q.QQ..", ".Q..QQQ..Q.", ".QCCCQCCCQ.", ".QCCCQCCCQ.", ".QQCCQCCQQ.", "..QQQQQQQ..", "..AAAAAAA.."], variants: [{ A: "#f5f5f5", Q: "#ffd700", C: "#5d0a8a" }], rollable: false },
210
+ { id: "black_cab", name: "Black Cab", rarity: "limited", width: 11, anchor_x: 22, rows: ["...........", "....CCC....", "..AAAAAA...", "..AQQAQA...", ".AAQQAQAAA.", ".AAAAAAAAAF", "AAAAAAAAAAF", "ADDAAAADDA.", ".DD....DD..", "..........."], variants: [{ A: "#141416", Q: "#8FB8D8", C: "#F5C518", D: "#3A3A40", F: "#D9DDE3" }], rollable: false },
211
+ { id: "london_bus", name: "London Bus", rarity: "limited", width: 11, anchor_x: 22, rows: ["...........", "...........", ".AAAAAAAAAA", "AAAAAAAAAAA", "ACCCACCCAAC", "AAAAAAAAACC", "ACCCACCCACC", "AAAAAAAAAAA", "AQQAAAAAQQA", ".QQ.....QQ."], variants: [{ A: "#e32402", Q: "#0d0c0c", C: "#dcaf09" }], rollable: false }
203
212
  ];
204
213
 
214
+ // ../shared/dist/hat-shape.js
215
+ function isAnimatedHat(hat) {
216
+ return "animation" in hat;
217
+ }
218
+ function hatColors(hat, variantIdx = 0) {
219
+ if (isAnimatedHat(hat))
220
+ return hat.colors;
221
+ return hat.variants[variantIdx] ?? hat.variants[0];
222
+ }
223
+ function channelColor(colors, ch) {
224
+ return colors[ch] ?? colors.A;
225
+ }
226
+
205
227
  // ../shared/dist/series-transform.js
206
228
  var PACE_WINDOW_MS = 15 * 6e4;
207
229
 
@@ -433,7 +455,7 @@ function composeHatGrid(baseSprite, hat, variantIdx, horseColors) {
433
455
  grid[y + ext][x + horseOffsetX] = tagToColor(tag, horseColors);
434
456
  }
435
457
  }
436
- const hatColors = hatColorsFor(hat, variantIdx);
458
+ const colors = hatColors(hat, variantIdx);
437
459
  for (let y = 0; y < hatH; y++) {
438
460
  const row = hat.rows[y];
439
461
  for (let x = 0; x < hatW; x++) {
@@ -441,7 +463,7 @@ function composeHatGrid(baseSprite, hat, variantIdx, horseColors) {
441
463
  if (ch === "." || ch === void 0) continue;
442
464
  const gx = hat.anchor_x + x + horseOffsetX;
443
465
  if (gx < 0 || gx >= canvasW) continue;
444
- const color = ch === "A" ? hatColors.A : hatColors.Q ?? hatColors.A;
466
+ const color = channelColor(colors, ch);
445
467
  grid[y][gx] = color;
446
468
  }
447
469
  }
@@ -463,10 +485,6 @@ function tagToColor(tag, c) {
463
485
  return FIXED_COLORS.H;
464
486
  }
465
487
  }
466
- function hatColorsFor(hat, variantIdx) {
467
- if (hat.rarity === "legendary") return hat.colors;
468
- return hat.variants[variantIdx] ?? hat.variants[0];
469
- }
470
488
 
471
489
  // src/ui/half-blocks.ts
472
490
  var RESET = "\x1B[0m";
@@ -538,19 +556,19 @@ import { useEffect, useState } from "react";
538
556
  import { Box as Box2, Text as Text2 } from "ink";
539
557
  import { jsx as jsx2 } from "react/jsx-runtime";
540
558
  function AnimatedHorseSprite({ sprite, colors, hat }) {
541
- const isLegendary = hat.rarity === "legendary";
542
- const frames = isLegendary ? hat.animation.frames : [];
543
- const fps = isLegendary ? hat.animation.fps : 1;
559
+ const animated = isAnimatedHat(hat);
560
+ const frames = animated ? hat.animation.frames : [];
561
+ const fps = animated ? hat.animation.fps : 1;
544
562
  const [idx, setIdx] = useState(0);
545
563
  useEffect(() => {
546
- if (!isLegendary || frames.length <= 1) return;
564
+ if (!animated || frames.length <= 1) return;
547
565
  const interval = setInterval(
548
566
  () => setIdx((i) => (i + 1) % frames.length),
549
567
  Math.max(1, Math.round(1e3 / fps))
550
568
  );
551
569
  return () => clearInterval(interval);
552
- }, [isLegendary, frames.length, fps]);
553
- const renderedHat = isLegendary && frames[idx] ? { ...hat, colors: { ...hat.colors, A: frames[idx] } } : hat;
570
+ }, [animated, frames.length, fps]);
571
+ const renderedHat = animated && frames[idx] ? { ...hat, colors: { ...hat.colors, A: frames[idx] } } : hat;
554
572
  const { grid } = composeHatGrid(sprite, renderedHat, 0, colors);
555
573
  const lines = hexGridToHalfBlocks(grid);
556
574
  return /* @__PURE__ */ jsx2(Box2, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx2(Text2, { children: line }, i)) });
@@ -739,7 +757,7 @@ function HorseCreator({
739
757
  "]"
740
758
  ] })
741
759
  ] }),
742
- /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: previewHatDef ? previewHatDef.rarity === "legendary" ? /* @__PURE__ */ jsx3(AnimatedHorseSprite, { sprite: MAIN_SPRITE, colors, hat: previewHatDef }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors, hat: { hat: previewHatDef, variant: previewHat?.variant ?? 0 } }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
760
+ /* @__PURE__ */ jsx3(Box3, { marginBottom: 1, children: previewHatDef ? isAnimatedHat(previewHatDef) ? /* @__PURE__ */ jsx3(AnimatedHorseSprite, { sprite: MAIN_SPRITE, colors, hat: previewHatDef }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors, hat: { hat: previewHatDef, variant: previewHat?.variant ?? 0 } }) : /* @__PURE__ */ jsx3(HorseSprite, { sprite: MAIN_SPRITE, colors }) }),
743
761
  /* @__PURE__ */ jsx3(Box3, { flexDirection: "column", children: rows.map((r, i) => {
744
762
  const cursor = i === rowIdx ? "\u25BA" : " ";
745
763
  if (r.kind === "color") {
@@ -785,8 +803,8 @@ function renderHatLabel(hats, hatChoice, initialEquipped) {
785
803
  if (!collected) return /* @__PURE__ */ jsx3(Text3, { dimColor: true, children: "?" });
786
804
  const hat = hatById(collected.id);
787
805
  const name = hat?.name ?? collected.id;
788
- const variantSuffix = hat && hat.rarity !== "legendary" && collected.variant !== void 0 ? ` #${collected.variant + 1}` : "";
789
- const rarityColor = hat ? hat.rarity === "legendary" ? "yellow" : hat.rarity === "epic" ? "magenta" : hat.rarity === "rare" ? "blue" : "gray" : "gray";
806
+ const variantSuffix = hat && !isAnimatedHat(hat) && collected.variant !== void 0 ? ` #${collected.variant + 1}` : "";
807
+ const rarityColor = hat ? hat.rarity === "limited" ? "magentaBright" : hat.rarity === "legendary" ? "yellow" : hat.rarity === "epic" ? "magenta" : hat.rarity === "rare" ? "blue" : "gray" : "gray";
790
808
  const equippedMark = initialEquipped === hatChoice ? " \u2713" : "";
791
809
  return /* @__PURE__ */ jsxs(Fragment, { children: [
792
810
  name,
@@ -839,12 +857,13 @@ function apiBase() {
839
857
  var HEARTBEAT_INTERVAL_MS = 6e4;
840
858
  var SCAN_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 0.75;
841
859
  var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
860
+ var PRIMARY_SILENT_THRESHOLD = 10;
842
861
 
843
862
  // src/version.ts
844
863
  import { createRequire } from "module";
845
864
  function readVersion() {
846
- if ("3.0.0".length > 0) {
847
- return "3.0.0";
865
+ if ("3.1.2".length > 0) {
866
+ return "3.1.2";
848
867
  }
849
868
  try {
850
869
  const req = createRequire(import.meta.url);
@@ -879,7 +898,11 @@ function activeRacesDir() {
879
898
  return path2.join(homeDir(), "active-races");
880
899
  }
881
900
  function claudeProjectsDir() {
882
- 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");
883
906
  }
884
907
  function codexSessionsDir() {
885
908
  return process.env.TOKEN_DERBY_CODEX_DIR ?? path2.join(os2.homedir(), ".codex");
@@ -1541,7 +1564,7 @@ function ModelList(props) {
1541
1564
  ] }) });
1542
1565
  }
1543
1566
  function StatusScreen(props) {
1544
- 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;
1545
1568
  if (!race) {
1546
1569
  return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { children: "Joining race\u2026" }) });
1547
1570
  }
@@ -1612,6 +1635,15 @@ function StatusScreen(props) {
1612
1635
  "\u26A0 ",
1613
1636
  stallReason ?? "Can't read token usage",
1614
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."
1615
1647
  ] })
1616
1648
  ] }),
1617
1649
  primaryModel && /* @__PURE__ */ jsx7(ModelList, { primaryModel }),
@@ -1851,6 +1883,24 @@ async function readCompleteLines(file, start, end) {
1851
1883
  }
1852
1884
  }
1853
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
+
1854
1904
  // src/tokens/transcripts.ts
1855
1905
  var MAX_PROJECT_DEPTH = 8;
1856
1906
  function conversationId(file, root) {
@@ -1887,29 +1937,33 @@ async function sumTokens() {
1887
1937
  return { input, output };
1888
1938
  }
1889
1939
  async function listJsonlFiles(root) {
1890
- const projects = await fs4.readdir(root);
1940
+ const entries = await readEntries(root, true);
1891
1941
  const out = [];
1892
- for (const project of projects) {
1893
- const projectDir = path6.join(root, project);
1894
- const stat4 = await fs4.stat(projectDir);
1895
- if (!stat4.isDirectory()) continue;
1896
- 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);
1897
1945
  }
1898
1946
  return out;
1899
1947
  }
1900
1948
  async function collectJsonl(dir, depth, out) {
1901
1949
  if (depth <= 0) return;
1902
- const entries = await fs4.readdir(dir);
1903
- for (const entry of entries) {
1904
- if (entry.endsWith(".jsonl")) {
1905
- out.push(path6.join(dir, entry));
1906
- } else if (depth > 1) {
1907
- const child = path6.join(dir, entry);
1908
- const st = await fs4.stat(child);
1909
- 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);
1910
1955
  }
1911
1956
  }
1912
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
+ }
1913
1967
  function addNum(value) {
1914
1968
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
1915
1969
  }
@@ -1942,11 +1996,8 @@ function num(v) {
1942
1996
  }
1943
1997
  async function sumCodexByConversation() {
1944
1998
  const root = codexSessionsDir();
1945
- await fs5.stat(root);
1946
- const files = [
1947
- ...await collectRollouts(path7.join(root, "sessions")),
1948
- ...await collectRollouts(path7.join(root, "archived_sessions"))
1949
- ];
1999
+ await readRoot(root, () => fs5.stat(root));
2000
+ const files = await listCodexRollouts(root);
1950
2001
  const cache = await ScanCache.open("codex");
1951
2002
  const totals = await mapWithConcurrency(
1952
2003
  files,
@@ -1968,6 +2019,12 @@ async function sumCodexTokens() {
1968
2019
  }
1969
2020
  return { input, output };
1970
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
+ }
1971
2028
  async function collectRollouts(dir) {
1972
2029
  let entries;
1973
2030
  try {
@@ -2038,7 +2095,7 @@ async function sumGeminiTokens() {
2038
2095
  return { input, output };
2039
2096
  }
2040
2097
  async function listChatFiles(root) {
2041
- const entries = await fs6.readdir(root);
2098
+ const entries = await readRoot(root, () => fs6.readdir(root));
2042
2099
  const out = [];
2043
2100
  for (const entry of entries) {
2044
2101
  const chatsDir = path8.join(root, entry, "chats");
@@ -2136,7 +2193,7 @@ async function readAllSources(race, primary, progress) {
2136
2193
  const primaryByConv = /* @__PURE__ */ new Map();
2137
2194
  if (primaryResult.ok) {
2138
2195
  for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2139
- } else if (primaryResult.err?.code !== "ENOENT") {
2196
+ } else if (!(primaryResult.err instanceof SourceRootMissing)) {
2140
2197
  const err = primaryResult.err;
2141
2198
  return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
2142
2199
  }
@@ -2203,6 +2260,7 @@ var RaceScoreTracker = class {
2203
2260
  seq;
2204
2261
  stalls = 0;
2205
2262
  lastStall = null;
2263
+ primaryEmptyBeats = 0;
2206
2264
  primary;
2207
2265
  primaryTop5;
2208
2266
  constructor(init, primary, primaryTop5) {
@@ -2235,6 +2293,7 @@ var RaceScoreTracker = class {
2235
2293
  const v = reading.secondary[key];
2236
2294
  if (v > 0) this.lastGood[key] = v;
2237
2295
  }
2296
+ this.primaryEmptyBeats = reading.primaryByConv.size === 0 ? this.primaryEmptyBeats + 1 : 0;
2238
2297
  for (const [id, v] of reading.primaryByConv) {
2239
2298
  const prev = this.primaryConvLast[id] ?? 0;
2240
2299
  if (v > prev) this.primaryConvLast[id] = v;
@@ -2286,6 +2345,10 @@ var RaceScoreTracker = class {
2286
2345
  get stalled() {
2287
2346
  return this.stalls >= STALL_THRESHOLD;
2288
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
+ }
2289
2352
  /** Human-readable cause of the most recent stall (null once a good read recovers). */
2290
2353
  get stallReason() {
2291
2354
  return this.lastStall;
@@ -2314,6 +2377,75 @@ var RaceScoreTracker = class {
2314
2377
  }
2315
2378
  };
2316
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
+
2317
2449
  // src/runtime/run-race.tsx
2318
2450
  import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2319
2451
  function RunRace({ active, initialState, pendingMode, ownUserName }) {
@@ -2330,6 +2462,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2330
2462
  const ctrl = useRef(new AbortController());
2331
2463
  const [stalled, setStalled] = useState5(false);
2332
2464
  const [stallReason, setStallReason] = useState5(null);
2465
+ const [primarySilent, setPrimarySilent] = useState5(false);
2333
2466
  useEffect2(() => {
2334
2467
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
2335
2468
  return () => clearInterval(t);
@@ -2361,6 +2494,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2361
2494
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2362
2495
  setStalled(tracker.stalled);
2363
2496
  setStallReason(tracker.stalled ? tracker.stallReason : null);
2497
+ setPrimarySilent(tracker.primarySilent);
2364
2498
  return tracker.nextBeat();
2365
2499
  },
2366
2500
  sendBeat: async (snapshot) => {
@@ -2428,6 +2562,8 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2428
2562
  lastHeartbeatOk: lastHbOk,
2429
2563
  stalled,
2430
2564
  stallReason,
2565
+ primarySilent,
2566
+ primarySourceDir: sourceDir(active.primary_model),
2431
2567
  primaryModel: active.primary_model
2432
2568
  }
2433
2569
  ),
@@ -2495,6 +2631,28 @@ async function buildInitialState(args) {
2495
2631
  };
2496
2632
  }
2497
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
+
2498
2656
  // src/commands/join.ts
2499
2657
  function parsePrimaryFlag(argv) {
2500
2658
  for (let i = 0; i < argv.length; i++) {
@@ -2589,6 +2747,19 @@ async function joinCommand(joinCode, argv = []) {
2589
2747
  if (primaryFlag) chosenPrimary = primaryFlag;
2590
2748
  else if (process.stdout.isTTY) chosenPrimary = await pickPrimary();
2591
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
+ }
2592
2763
  let joinResp;
2593
2764
  try {
2594
2765
  joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId, primary_model: chosenPrimary });
@@ -2889,25 +3060,6 @@ async function webCommand(deps = {}) {
2889
3060
  return 0;
2890
3061
  }
2891
3062
 
2892
- // src/ui/prompt.ts
2893
- async function promptYesNo(question) {
2894
- resetStdinAfterInk();
2895
- const readline6 = await import("readline/promises");
2896
- const rl = readline6.createInterface({ input: process.stdin, output: process.stdout });
2897
- const a = (await rl.question(question)).trim().toLowerCase();
2898
- rl.close();
2899
- return a === "" || a === "y" || a === "yes";
2900
- }
2901
- function resetStdinAfterInk() {
2902
- if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
2903
- process.stdin.setRawMode(false);
2904
- }
2905
- while (process.stdin.read() !== null) {
2906
- }
2907
- process.stdin.pause();
2908
- process.stdin.ref();
2909
- }
2910
-
2911
3063
  // src/commands/login.ts
2912
3064
  function parseDeviceNameFlag(argv) {
2913
3065
  for (let i = 0; i < argv.length; i++) {
@@ -3477,13 +3629,13 @@ import { useEffect as useEffect3, useState as useState7 } from "react";
3477
3629
  import { Box as Box10, Text as Text10 } from "ink";
3478
3630
  import { jsx as jsx10 } from "react/jsx-runtime";
3479
3631
  function HatSprite({ hat, variant, centerIn }) {
3480
- const colors = hatColorsFor2(hat, variant ?? 0);
3632
+ const colors = hatColors(hat, variant ?? 0);
3481
3633
  const grid = makeHatGrid(hat, colors, centerIn);
3482
3634
  const lines = hexGridToHalfBlocks(grid);
3483
3635
  return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx10(Text10, { children: line }, i)) });
3484
3636
  }
3485
3637
  function AnimatedHatSprite({ hat, variant, centerIn }) {
3486
- if (hat.rarity !== "legendary") {
3638
+ if (!isAnimatedHat(hat)) {
3487
3639
  return /* @__PURE__ */ jsx10(HatSprite, { hat, variant, centerIn });
3488
3640
  }
3489
3641
  const frames = hat.animation.frames;
@@ -3500,10 +3652,6 @@ function AnimatedHatSprite({ hat, variant, centerIn }) {
3500
3652
  const framed = { ...hat, colors: { ...hat.colors, A: frames[idx] } };
3501
3653
  return /* @__PURE__ */ jsx10(HatSprite, { hat: framed, variant, centerIn });
3502
3654
  }
3503
- function hatColorsFor2(hat, variantIdx) {
3504
- if (hat.rarity === "legendary") return hat.colors;
3505
- return hat.variants[variantIdx] ?? hat.variants[0];
3506
- }
3507
3655
  function makeHatGrid(hat, colors, centerIn) {
3508
3656
  const w = centerIn?.w ?? hat.width;
3509
3657
  const h = centerIn?.h ?? hat.rows.length;
@@ -3518,7 +3666,7 @@ function makeHatGrid(hat, colors, centerIn) {
3518
3666
  const gx = x + offX;
3519
3667
  const gy = y + offY;
3520
3668
  if (gx < 0 || gx >= w || gy < 0 || gy >= h) continue;
3521
- grid[gy][gx] = ch === "A" ? colors.A : colors.Q ?? colors.A;
3669
+ grid[gy][gx] = channelColor(colors, ch);
3522
3670
  }
3523
3671
  }
3524
3672
  return grid;
@@ -3532,7 +3680,8 @@ var TIER_PALETTE = {
3532
3680
  common: ["#FFFFFF", "#DDDDDD", "#AAAAAA", "#9E9E9E"],
3533
3681
  rare: ["#42A5F5", "#1E88E5", "#90CAF9", "#0277BD"],
3534
3682
  epic: ["#AB47BC", "#8E24AA", "#CE93D8", "#6A1B9A"],
3535
- legendary: ["#FFD700", "#FF7F00", "#FF0000", "#FF00FF", "#00BFFF", "#7CFC00", "#8B00FF"]
3683
+ legendary: ["#FFD700", "#FF7F00", "#FF0000", "#FF00FF", "#00BFFF", "#7CFC00", "#8B00FF"],
3684
+ limited: ["#F472B6", "#EC4899", "#FF4FA3", "#FBCFE8", "#FFFFFF"]
3536
3685
  };
3537
3686
  var CONFETTI_CHARS = ["\u2726", "\u2727", "\u22C6", "\u2605", "\u2606", "\u2728", "*", "\u2022", "\u25C6", "\u25C7"];
3538
3687
  var SCENE_W = 32;
@@ -3635,7 +3784,7 @@ function ConfettiBurst({ tier }) {
3635
3784
  var CLOSED_HOLD_MS = 3e3;
3636
3785
  function RollReveal({ outcome, onDone }) {
3637
3786
  const isNoHat = outcome.kind === "no_hat";
3638
- const isLegendary = outcome.kind !== "no_hat" && outcome.hat.rarity === "legendary";
3787
+ const isShowpiece = outcome.kind !== "no_hat" && (outcome.hat.rarity === "legendary" || outcome.hat.rarity === "limited");
3639
3788
  const [phase, setPhase] = useState8("closed");
3640
3789
  useEffect4(() => {
3641
3790
  const timers = [];
@@ -3647,10 +3796,10 @@ function RollReveal({ outcome, onDone }) {
3647
3796
  } else {
3648
3797
  timers.push(setTimeout(() => setPhase("burst"), CLOSED_HOLD_MS + 700));
3649
3798
  timers.push(setTimeout(() => setPhase("reveal"), CLOSED_HOLD_MS + 1650));
3650
- timers.push(setTimeout(onDone, CLOSED_HOLD_MS + (isLegendary ? 4650 : 2650)));
3799
+ timers.push(setTimeout(onDone, CLOSED_HOLD_MS + (isShowpiece ? 4650 : 2650)));
3651
3800
  }
3652
3801
  return () => timers.forEach(clearTimeout);
3653
- }, [isNoHat, isLegendary, onDone]);
3802
+ }, [isNoHat, isShowpiece, onDone]);
3654
3803
  if (phase === "closed") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_CLOSED, color: BOX_COLOR });
3655
3804
  if (phase === "open1") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
3656
3805
  if (phase === "open2") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
@@ -3659,7 +3808,7 @@ function RollReveal({ outcome, onDone }) {
3659
3808
  return /* @__PURE__ */ jsx11(ConfettiBurst, { tier: outcome.hat.rarity });
3660
3809
  }
3661
3810
  if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
3662
- return outcome.hat.rarity === "legendary" ? /* @__PURE__ */ jsx11(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx11(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
3811
+ return isAnimatedHat(outcome.hat) ? /* @__PURE__ */ jsx11(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx11(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
3663
3812
  }
3664
3813
 
3665
3814
  // src/ui/reveal.ts
@@ -3750,7 +3899,7 @@ async function rollCommand() {
3750
3899
  await runReveal(outcome);
3751
3900
  if (result.result === "hat") {
3752
3901
  const hat = hatById(result.collected.id);
3753
- const variantSuffix = hat.rarity !== "legendary" && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3902
+ const variantSuffix = !isAnimatedHat(hat) && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3754
3903
  console.log(`
3755
3904
  \u2728 ${hat.name}${variantSuffix} [${hat.rarity.toUpperCase()}]
3756
3905
  `);
@@ -3788,8 +3937,9 @@ async function claimCommand(token) {
3788
3937
  console.error("Usage: token-derby claim <token>");
3789
3938
  return 2;
3790
3939
  }
3940
+ let probe;
3791
3941
  try {
3792
- await probeClaim(token);
3942
+ probe = await probeClaim(token);
3793
3943
  } catch (e) {
3794
3944
  if (e instanceof ApiError) {
3795
3945
  console.error(`Error: ${e.code} ${e.message}`);
@@ -3811,7 +3961,9 @@ async function claimCommand(token) {
3811
3961
  console.error("No horses in your stable. Run `token-derby stable create` first.");
3812
3962
  return 1;
3813
3963
  }
3814
- console.log("\n\u{1F381} A cosmetic has been awarded to you.\n");
3964
+ console.log(probe.entry_count > 1 ? `
3965
+ \u{1F381} A pack of ${probe.entry_count} cosmetics \u2014 one of them will be yours.
3966
+ ` : "\n\u{1F381} A cosmetic has been awarded to you.\n");
3815
3967
  const picked = await new Promise((resolve) => {
3816
3968
  const app = render7(React15.createElement(HorsePicker, {
3817
3969
  horses: stable.horses,
@@ -3855,7 +4007,7 @@ async function claimCommand(token) {
3855
4007
  await runReveal(outcome);
3856
4008
  if (result.result === "hat") {
3857
4009
  const hat2 = hatById(result.collected.id);
3858
- const variantSuffix2 = hat2.rarity !== "legendary" && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
4010
+ const variantSuffix2 = !isAnimatedHat(hat2) && result.collected.variant !== void 0 ? ` #${result.collected.variant + 1}` : "";
3859
4011
  console.log(`
3860
4012
  \u2728 ${hat2.name}${variantSuffix2} [${hat2.rarity.toUpperCase()}]
3861
4013
  `);
@@ -3881,12 +4033,9 @@ ${picked.name} already has ${hat?.name ?? result.hat_id}${variantSuffix}. +${res
3881
4033
 
3882
4034
  // src/commands/org-join.ts
3883
4035
  async function orgJoinCommand(token) {
3884
- if (!token) {
3885
- console.error("Usage: token-derby organisation join <join-token>");
3886
- return 2;
3887
- }
4036
+ const join_token = token?.trim();
3888
4037
  try {
3889
- const resp = await joinOrganisation({ join_token: token });
4038
+ const resp = await joinOrganisation(join_token ? { join_token } : {});
3890
4039
  console.log(`Joined organisation: ${resp.org_name}`);
3891
4040
  return 0;
3892
4041
  } catch (e) {
@@ -3964,7 +4113,9 @@ Stable management:
3964
4113
  token-derby stable delete <name> Remove a horse from your stable
3965
4114
 
3966
4115
  Organisations:
3967
- token-derby organisation join <token> Join an organisation with a join token
4116
+ token-derby organisation join [token] Join an organisation with a join token,
4117
+ or leave the token out to join by your
4118
+ verified email domain
3968
4119
  token-derby web Open the web org manager (create orgs,
3969
4120
  manage schedules, webhooks, members)
3970
4121
 
@@ -4027,7 +4178,7 @@ async function main() {
4027
4178
  const sub = argv[1];
4028
4179
  if (sub === "join") return orgJoinCommand(argv[2]);
4029
4180
  console.error(`Organisation management has moved to the web: token-derby web`);
4030
- console.error(`The only CLI organisation command is: organisation join <token>`);
4181
+ console.error(`The only CLI organisation command is: organisation join [token]`);
4031
4182
  return 2;
4032
4183
  }
4033
4184
  if (cmd === "create") {