@mauricode/token-derby 3.1.3 → 4.0.0

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
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  // src/commands/stable-create.ts
19
- import React3 from "react";
19
+ import React2 from "react";
20
20
  import { render } from "ink";
21
21
 
22
22
  // src/ui/HorseCreator.tsx
@@ -25,10 +25,9 @@ import { Box as Box3, Text as Text3, useInput } from "ink";
25
25
  import TextInput from "ink-text-input";
26
26
 
27
27
  // ../shared/dist/models.js
28
- var MODEL_KEYS = ["claude", "codex", "gemini"];
29
- var SECONDARY_WEIGHT = 0.5;
30
- function isModelKey(v) {
31
- return typeof v === "string" && MODEL_KEYS.includes(v);
28
+ var MODEL_FAMILIES = ["anthropic", "openai", "google"];
29
+ function zeroPerFamily() {
30
+ return { anthropic: 0, openai: 0, google: 0 };
32
31
  }
33
32
 
34
33
  // ../shared/dist/constants.js
@@ -102,51 +101,22 @@ var ACHIEVEMENT_DESCRIPTIONS = {
102
101
  "Racer!": "Raced continuously for an hour",
103
102
  "Overtake!": "Overtook another horse",
104
103
  "Pacesetter!": "Led the race for an hour straight",
105
- "Stampede!": "Gained 7,000+ tokens in a single minute",
104
+ "Stampede!": "Gained 70,000+ tokens in a single minute",
106
105
  "Took the lead!": "Charged into first place",
107
106
  "Comeback!": "Climbed from last place to the top half",
108
- "Pulled Away!": "Grew the lead by 5,000+ tokens in a minute"
107
+ "Pulled Away!": "Grew the lead by 50,000+ tokens in a minute"
109
108
  };
110
109
  function overtakeDescription(positionsClimbed) {
111
110
  if (positionsClimbed <= 1)
112
111
  return "Overtook another horse";
113
112
  return `Overtook ${positionsClimbed} horses`;
114
113
  }
115
- var TOKEN_INPUT_MULTIPLIER = 10;
116
- function tokenMultiplier(race) {
117
- return race.counts_input ? TOKEN_INPUT_MULTIPLIER : 1;
118
- }
119
- function describeAchievement(event, race) {
114
+ function describeAchievement(event) {
120
115
  if (event.name === "Overtake!") {
121
116
  return overtakeDescription(Math.floor(event.xp / 3));
122
117
  }
123
- const m = tokenMultiplier(race);
124
- if (event.name === "Stampede!") {
125
- return `Gained ${(MIDRACE_THRESHOLDS.stampede_tokens * m).toLocaleString("en-US")}+ tokens in a single minute`;
126
- }
127
- if (event.name === "Pulled Away!") {
128
- return `Grew the lead by ${(MIDRACE_THRESHOLDS.pulled_away_gap * m).toLocaleString("en-US")}+ tokens in a minute`;
129
- }
130
118
  return ACHIEVEMENT_DESCRIPTIONS[event.name];
131
119
  }
132
- var MIDRACE_THRESHOLDS = {
133
- warm_up_fraction: 0.08,
134
- // first 8% of race time
135
- streak_hour_ms: 36e5,
136
- // 1 hour for Racer!/Pacesetter!
137
- racer_dt_cap_ms: 9e4,
138
- // single-tick credit cap for Racer!
139
- stampede_tokens: 7e3,
140
- // tokens-in-a-minute threshold
141
- stampede_cooldown_ms: 72e5,
142
- // 2 hours
143
- pulled_away_gap: 5e3,
144
- // gap-growth threshold per minute
145
- pulled_away_cooldown_ms: 72e5,
146
- // 2 hours
147
- recent_events_retention_ms: 9e4
148
- // sliding window for recent_events
149
- };
150
120
 
151
121
  // ../shared/dist/hats.js
152
122
  function hatById(id) {
@@ -280,38 +250,132 @@ function toTag(c, y) {
280
250
  }
281
251
  }
282
252
 
253
+ // ../shared/dist/scoring/modifiers/stamina.js
254
+ var FULL_STAMINA = 100;
255
+ var RECOVER_TICK_CAP_MS = 9e4;
256
+ var STATE_KEY = "level";
257
+ var PARAMS = {
258
+ sustainable_pace: {
259
+ label: "Sustainable pace (tokens/min)",
260
+ min: 1e4,
261
+ max: 2e5,
262
+ step: 2500,
263
+ default: 4e4
264
+ },
265
+ drain_per_min: {
266
+ label: "Drain rate (stamina/min above pace)",
267
+ min: 1,
268
+ max: 12,
269
+ step: 1,
270
+ default: 4
271
+ },
272
+ max_drain_per_min: {
273
+ label: "Max drain rate (stamina/min)",
274
+ min: 2,
275
+ max: 20,
276
+ step: 1,
277
+ default: 6
278
+ },
279
+ recover_per_min: {
280
+ label: "Recovery rate (stamina/min at or below pace)",
281
+ min: 1,
282
+ max: 8,
283
+ step: 1,
284
+ default: 2
285
+ },
286
+ taper_floor: {
287
+ label: "Taper floor (stamina %)",
288
+ min: 10,
289
+ max: 60,
290
+ step: 5,
291
+ default: 25
292
+ },
293
+ tired_multiplier: {
294
+ label: "Tired multiplier",
295
+ min: 0.2,
296
+ max: 0.9,
297
+ step: 0.05,
298
+ default: 0.5
299
+ }
300
+ };
301
+ var fmt = (n) => Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }).format(n);
302
+ var stamina = {
303
+ id: "stamina",
304
+ label: "Stamina",
305
+ description: "Horses running above a sustainable pace tire and score less until they recover.",
306
+ enabledByDefault: false,
307
+ params: PARAMS,
308
+ apply(ctx) {
309
+ const level = ctx.state[STATE_KEY] ?? FULL_STAMINA;
310
+ const minutes = ctx.dt_ms / 6e4;
311
+ if (minutes <= 0)
312
+ return { multiplier: 1, state: { [STATE_KEY]: level } };
313
+ const step = staminaStep({ level, pace: ctx.delta / minutes, minutes, params: ctx.params });
314
+ return { multiplier: step.multiplier, state: { [STATE_KEY]: step.level } };
315
+ },
316
+ preview(params) {
317
+ const p = params;
318
+ const drained = staminaStep({ level: FULL_STAMINA, pace: p.sustainable_pace * 2, minutes: 1, params: p });
319
+ const perMin = FULL_STAMINA - drained.level;
320
+ const spent = staminaStep({ level: 0, pace: 0, minutes: 1, params: p });
321
+ return [
322
+ { label: "Draining begins above", value: `${fmt(p.sustainable_pace)} tokens/min` },
323
+ { label: "At twice that pace, full stamina reaches the floor in", value: `${fmt((FULL_STAMINA - p.taper_floor) / perMin)} min` },
324
+ { label: "A fully spent horse scores at", value: `${fmt(spent.multiplier * 100)}%` },
325
+ { label: "Empty to full takes", value: `${fmt(FULL_STAMINA / spent.level)} min` }
326
+ ];
327
+ }
328
+ };
329
+ function staminaStep(input) {
330
+ const { level, pace, minutes, params } = input;
331
+ const multiplier = level >= params.taper_floor ? 1 : params.tired_multiplier + (1 - params.tired_multiplier) * (level / params.taper_floor);
332
+ let next = level;
333
+ if (pace > params.sustainable_pace) {
334
+ const perMin = Math.min((pace / params.sustainable_pace - 1) * params.drain_per_min, params.max_drain_per_min);
335
+ next -= perMin * minutes;
336
+ } else {
337
+ const credited = Math.min(minutes, RECOVER_TICK_CAP_MS / 6e4);
338
+ next += params.recover_per_min * credited;
339
+ }
340
+ return { multiplier, level: Math.max(0, Math.min(FULL_STAMINA, next)) };
341
+ }
342
+
343
+ // ../shared/dist/scoring/registry.js
344
+ var MODIFIERS = {
345
+ stamina
346
+ };
347
+ var MODIFIER_IDS = Object.keys(MODIFIERS);
348
+
349
+ // ../shared/dist/scoring/modifier.js
350
+ function resolveParams(modifier, overrides = {}) {
351
+ const out = {};
352
+ for (const [key, bound] of Object.entries(modifier.params)) {
353
+ const value = overrides[key];
354
+ out[key] = typeof value === "number" && Number.isFinite(value) ? value : bound.default;
355
+ }
356
+ return out;
357
+ }
358
+
283
359
  // ../shared/dist/scoring.js
360
+ function settingFor(race, id) {
361
+ if (race.modifiers)
362
+ return race.modifiers[id];
363
+ if (id !== "stamina")
364
+ return void 0;
365
+ return race.stamina ? { enabled: true, params: race.stamina_config ?? {} } : { enabled: false };
366
+ }
367
+ function staminaOf(horse) {
368
+ return horse.modifier_states?.stamina?.[STATE_KEY] ?? FULL_STAMINA;
369
+ }
284
370
  function scoredOf(horse) {
285
371
  return horse.scored_tokens ?? horse.current_tokens;
286
372
  }
287
- var STAMINA = {
288
- SUSTAINABLE_PACE: 4e3,
289
- DRAIN_PER_MIN: 4,
290
- MAX_DRAIN_PER_MIN: 6,
291
- RECOVER_PER_MIN: 2,
292
- RECOVER_TICK_CAP_MS: 9e4,
293
- TAPER_FLOOR: 25,
294
- TIRED_MULTIPLIER: 0.5
295
- };
373
+ function resolveModifierParams(race, id) {
374
+ return resolveParams(MODIFIERS[id], settingFor(race, id)?.params ?? {});
375
+ }
296
376
  function resolveStaminaConfig(race) {
297
- const c = race.stamina_config ?? {};
298
- return {
299
- sustainable_pace: c.sustainable_pace ?? STAMINA.SUSTAINABLE_PACE,
300
- drain_per_min: c.drain_per_min ?? STAMINA.DRAIN_PER_MIN,
301
- max_drain_per_min: c.max_drain_per_min ?? STAMINA.MAX_DRAIN_PER_MIN,
302
- recover_per_min: c.recover_per_min ?? STAMINA.RECOVER_PER_MIN,
303
- taper_floor: c.taper_floor ?? STAMINA.TAPER_FLOOR,
304
- tired_multiplier: c.tired_multiplier ?? STAMINA.TIRED_MULTIPLIER
305
- };
377
+ return resolveModifierParams(race, "stamina");
306
378
  }
307
- var STAMINA_PARAM_BOUNDS = {
308
- sustainable_pace: { min: 1e3, max: 2e4, step: 250, default: STAMINA.SUSTAINABLE_PACE },
309
- drain_per_min: { min: 1, max: 12, step: 1, default: STAMINA.DRAIN_PER_MIN },
310
- max_drain_per_min: { min: 2, max: 20, step: 1, default: STAMINA.MAX_DRAIN_PER_MIN },
311
- recover_per_min: { min: 1, max: 8, step: 1, default: STAMINA.RECOVER_PER_MIN },
312
- taper_floor: { min: 10, max: 60, step: 5, default: STAMINA.TAPER_FLOOR },
313
- tired_multiplier: { min: 0.2, max: 0.9, step: 0.05, default: STAMINA.TIRED_MULTIPLIER }
314
- };
315
379
 
316
380
  // ../shared/dist/devices.js
317
381
  var DEVICE_CODE_LENGTH = Math.ceil(SECRET_TOKEN_BYTES * 4 / 3);
@@ -857,13 +921,13 @@ function apiBase() {
857
921
  var HEARTBEAT_INTERVAL_MS = 6e4;
858
922
  var SCAN_TIMEOUT_MS = HEARTBEAT_INTERVAL_MS * 0.75;
859
923
  var HEARTBEAT_RETRY_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 15e3];
860
- var PRIMARY_SILENT_THRESHOLD = 10;
924
+ var SILENT_THRESHOLD = 10;
861
925
 
862
926
  // src/version.ts
863
927
  import { createRequire } from "module";
864
928
  function readVersion() {
865
- if ("3.1.3".length > 0) {
866
- return "3.1.3";
929
+ if ("4.0.0".length > 0) {
930
+ return "4.0.0";
867
931
  }
868
932
  try {
869
933
  const req = createRequire(import.meta.url);
@@ -877,7 +941,6 @@ var CLI_VERSION = readVersion();
877
941
 
878
942
  // src/identity/identity.ts
879
943
  import { promises as fs } from "fs";
880
- import * as path3 from "path";
881
944
 
882
945
  // src/paths.ts
883
946
  import * as os2 from "os";
@@ -891,6 +954,9 @@ function homeDir() {
891
954
  function identityFile() {
892
955
  return path2.join(homeDir(), "identity.json");
893
956
  }
957
+ function prefsFile() {
958
+ return path2.join(homeDir(), "prefs.json");
959
+ }
894
960
  function activeRaceFile(joinCode) {
895
961
  return path2.join(homeDir(), "active-races", `${joinCode}.json`);
896
962
  }
@@ -910,6 +976,9 @@ function codexSessionsDir() {
910
976
  function geminiTmpDir() {
911
977
  return process.env.TOKEN_DERBY_GEMINI_DIR ?? path2.join(os2.homedir(), ".gemini", "tmp");
912
978
  }
979
+ function piSessionsDir() {
980
+ return process.env.TOKEN_DERBY_PI_DIR ?? path2.join(os2.homedir(), ".pi", "agent", "sessions");
981
+ }
913
982
  function logDir() {
914
983
  return path2.join(homeDir(), "logs");
915
984
  }
@@ -1204,7 +1273,7 @@ function logoutDevice() {
1204
1273
  async function stableCreateCommand() {
1205
1274
  let exitCode = 0;
1206
1275
  const app = render(
1207
- React3.createElement(HorseCreator, {
1276
+ React2.createElement(HorseCreator, {
1208
1277
  onSubmit: async (name, colors) => {
1209
1278
  try {
1210
1279
  await createStableHorse({ name, colors });
@@ -1236,7 +1305,7 @@ async function stableCreateCommand() {
1236
1305
  }
1237
1306
 
1238
1307
  // src/commands/stable-list.tsx
1239
- import React4 from "react";
1308
+ import React3 from "react";
1240
1309
  import { render as render2, Box as Box4, Text as Text4 } from "ink";
1241
1310
  import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1242
1311
  async function stableListCommand() {
@@ -1256,13 +1325,13 @@ async function stableListCommand() {
1256
1325
  return 0;
1257
1326
  }
1258
1327
  const app = render2(
1259
- React4.createElement(StableList, { horses })
1328
+ React3.createElement(StableList, { horses })
1260
1329
  );
1261
1330
  await app.waitUntilExit();
1262
1331
  return 0;
1263
1332
  }
1264
1333
  function StableList({ horses }) {
1265
- React4.useEffect(() => {
1334
+ React3.useEffect(() => {
1266
1335
  setImmediate(() => process.exit(0));
1267
1336
  }, []);
1268
1337
  return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", children: [
@@ -1331,7 +1400,7 @@ async function stableDeleteCommand(name) {
1331
1400
  }
1332
1401
 
1333
1402
  // src/commands/stable-edit.ts
1334
- import React6 from "react";
1403
+ import React4 from "react";
1335
1404
  import { render as render3 } from "ink";
1336
1405
 
1337
1406
  // src/ui/HorsePicker.tsx
@@ -1388,235 +1457,798 @@ function HorsePicker({ horses, onPick, onCancel, prompt = "Pick a horse to race:
1388
1457
  ] });
1389
1458
  }
1390
1459
 
1391
- // src/commands/stable-edit.ts
1392
- async function stableEditCommand(name) {
1393
- const horses = await fetchStable();
1394
- if (!horses) return 1;
1395
- const existing = await pickHorseToEdit(horses, name);
1396
- if (existing === "not_found") {
1397
- console.error(`No horse named "${name}" in your stable.`);
1398
- return 1;
1399
- }
1400
- if (existing === "empty") {
1401
- console.log("No horses in your stable. Run `token-derby stable create` to make one.");
1402
- return 0;
1403
- }
1404
- if (existing === "cancelled") {
1405
- console.log("Cancelled.");
1406
- return 1;
1460
+ // src/stable/prefs.ts
1461
+ import * as fs7 from "fs/promises";
1462
+
1463
+ // src/tokens/harnesses/claude-code/index.ts
1464
+ import * as fs3 from "fs/promises";
1465
+ import * as path3 from "path";
1466
+
1467
+ // src/tokens/source-root.ts
1468
+ var SourceRootMissing = class extends Error {
1469
+ constructor(dir) {
1470
+ super(`No history directory at ${dir}`);
1471
+ this.dir = dir;
1472
+ this.name = "SourceRootMissing";
1407
1473
  }
1408
- const initialEquipped = existing.equipped_hat ?? null;
1409
- let exitCode = 0;
1410
- const app = render3(
1411
- React6.createElement(HorseCreator, {
1412
- initialColors: existing.colors,
1413
- initialName: existing.name,
1414
- lockName: true,
1415
- initialLevel: levelFromXp(existing.xp),
1416
- hats: existing.hats,
1417
- initialEquipped,
1418
- onSubmit: async (_name, colors, hatChoice) => {
1419
- const colorsChanged = !sameColors(colors, existing.colors);
1420
- const hatChanged = hatChoice !== void 0 && hatChoice !== initialEquipped;
1421
- try {
1422
- if (colorsChanged) await updateStableHorse(existing.stable_horse_id, { colors });
1423
- if (hatChanged) await equipHat(existing.stable_horse_id, { hat_index: hatChoice });
1424
- app.unmount();
1425
- if (!colorsChanged && !hatChanged) {
1426
- console.log(`No changes for "${existing.name}".`);
1427
- } else {
1428
- const parts = [];
1429
- if (colorsChanged) parts.push("colors");
1430
- if (hatChanged) parts.push(hatChoice === null ? "hat unequipped" : "hat equipped");
1431
- console.log(`\u2713 Updated "${existing.name}" (${parts.join(", ")}).`);
1432
- }
1433
- } catch (e) {
1434
- app.unmount();
1435
- if (e instanceof ApiError) {
1436
- console.error(`Error: ${e.code} ${e.message}`);
1437
- exitCode = 1;
1438
- return;
1439
- }
1440
- throw e;
1441
- }
1442
- },
1443
- onCancel: () => {
1444
- app.unmount();
1445
- console.log("Cancelled.");
1446
- exitCode = 1;
1447
- }
1448
- })
1449
- );
1450
- await app.waitUntilExit();
1451
- return exitCode;
1452
- }
1453
- function sameColors(a, b) {
1454
- return a.body === b.body && a.mane === b.mane && a.tail === b.tail && a.saddle === b.saddle;
1455
- }
1456
- async function fetchStable() {
1474
+ dir;
1475
+ };
1476
+ async function readRoot(dir, read) {
1457
1477
  try {
1458
- const resp = await listStable();
1459
- return resp.horses;
1478
+ return await read();
1460
1479
  } catch (e) {
1461
- if (e instanceof ApiError) {
1462
- console.error(`Error: ${e.code} ${e.message}`);
1463
- return null;
1464
- }
1480
+ if (e?.code === "ENOENT") throw new SourceRootMissing(dir);
1465
1481
  throw e;
1466
1482
  }
1467
1483
  }
1468
- async function pickHorseToEdit(horses, name) {
1469
- if (name) {
1470
- const found = horses.find((h) => h.name === name);
1471
- return found ?? "not_found";
1472
- }
1473
- if (horses.length === 0) return "empty";
1474
- const picked = await new Promise((resolve) => {
1475
- const app = render3(
1476
- React6.createElement(HorsePicker, {
1477
- horses,
1478
- onPick: (h) => {
1479
- app.unmount();
1480
- resolve(h);
1481
- },
1482
- onCancel: () => {
1483
- app.unmount();
1484
- resolve(null);
1485
- }
1486
- })
1487
- );
1488
- });
1489
- return picked ?? "cancelled";
1484
+
1485
+ // src/tokens/harnesses/harness.ts
1486
+ function incremental(fold, families, notices) {
1487
+ return { mode: "incremental", fold, families, ...notices ? { notices } : {} };
1488
+ }
1489
+ function custom(read) {
1490
+ return { mode: "custom", read };
1491
+ }
1492
+ function wholeFile(parse2) {
1493
+ return { mode: "whole-file", parse: parse2 };
1494
+ }
1495
+ function constant(family) {
1496
+ return (state) => ({ [family]: { input: state.input, output: state.output } });
1490
1497
  }
1491
1498
 
1492
- // src/commands/create.ts
1493
- import * as readline2 from "readline/promises";
1494
- import { stdin as stdin2, stdout as stdout2 } from "process";
1495
- var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1496
- async function createRaceCommand(organisationName) {
1497
- const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
1498
- try {
1499
- const name = (await rl.question("Race name: ")).trim();
1500
- if (!name) {
1501
- console.error("Name required.");
1502
- return 1;
1503
- }
1504
- const startRaw = (await rl.question("Start time (ISO 8601, blank = now): ")).trim();
1505
- const start = startRaw ? startRaw : (/* @__PURE__ */ new Date()).toISOString();
1506
- if (!isIso(start)) {
1507
- console.error("Invalid start time.");
1508
- return 1;
1509
- }
1510
- const durationRaw = (await rl.question("Race duration (hours): ")).trim();
1511
- const durationHours = parseFloat(durationRaw);
1512
- if (!Number.isFinite(durationHours) || durationHours <= 0) {
1513
- console.error("Duration must be a positive number of hours.");
1514
- return 1;
1515
- }
1516
- const end = new Date(new Date(start).getTime() + durationHours * 36e5).toISOString();
1517
- const tz = (await rl.question(`Time zone [${DEFAULT_TZ}]: `)).trim() || DEFAULT_TZ;
1518
- const maxRaw = (await rl.question("Max participants [30]: ")).trim();
1519
- const max = maxRaw ? parseInt(maxRaw, 10) : void 0;
1520
- if (max !== void 0 && (!Number.isFinite(max) || max < 1)) {
1521
- console.error("Max participants must be a positive number.");
1522
- return 1;
1523
- }
1524
- let org = organisationName;
1525
- if (org === void 0) {
1526
- const raw = (await rl.question("Organisation (blank for none): ")).trim();
1527
- if (raw) org = raw;
1528
- }
1529
- if (org !== void 0 && !ORG_NAME_PATTERN.test(org)) {
1530
- console.error("Organisation name must be 1\u201312 alphanumeric characters.");
1531
- return 1;
1532
- }
1533
- const countInputRaw = (await rl.question("Count input tokens (fresh input + cache creation) toward race totals? [y/N]: ")).trim().toLowerCase();
1534
- const counts_input = countInputRaw === "y" || countInputRaw === "yes";
1535
- const top5Raw = (await rl.question("Count only each racer's 5 most-active conversations toward their primary model's score? [y/N]: ")).trim().toLowerCase();
1536
- const primary_top5 = top5Raw === "y" || top5Raw === "yes";
1537
- const staminaRaw = (await rl.question("Stamina \u2014 horses that run flat out tire and score less until they recover? [y/N]: ")).trim().toLowerCase();
1538
- const stamina = staminaRaw === "y" || staminaRaw === "yes";
1539
- const resp = await createRace({
1540
- name,
1541
- start_time: start,
1542
- end_time: end,
1543
- tz,
1544
- ...max !== void 0 ? { max_participants: max } : {},
1545
- ...org ? { organisation_name: org } : {},
1546
- ...counts_input ? { counts_input: true } : {},
1547
- ...primary_top5 ? { primary_top5: true } : {},
1548
- ...stamina ? { stamina: true } : {}
1549
- });
1550
- console.log("");
1551
- console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
1552
- console.log(` \u2551 JOIN CODE: ${resp.join_code.padEnd(23)}\u2551`);
1553
- console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
1554
- console.log("");
1555
- console.log(` Admin code: ${resp.admin_code}`);
1556
- console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
1557
- console.log("");
1558
- if (org) {
1559
- console.log(` Restricted to organisation: ${org}`);
1560
- }
1561
- if (counts_input) {
1562
- console.log(" Counting input + output tokens (excluding cache reads).");
1563
- }
1564
- if (primary_top5) {
1565
- console.log(" Primary score counts only each racer's top 5 conversations per beat.");
1499
+ // src/tokens/harnesses/claude-code/index.ts
1500
+ var MAX_PROJECT_DEPTH = 8;
1501
+ var TRANSCRIPT_EXT = ".jsonl";
1502
+ var CLAUDE_FOLD = {
1503
+ empty: () => ({ input: 0, output: 0 }),
1504
+ append: (acc, lines) => {
1505
+ let { input, output, last } = acc;
1506
+ for (const line of lines) {
1507
+ if (!line.trim()) continue;
1508
+ let parsed;
1509
+ try {
1510
+ parsed = JSON.parse(line);
1511
+ } catch {
1512
+ continue;
1513
+ }
1514
+ const usage = parsed?.message?.usage;
1515
+ if (!usage) continue;
1516
+ const id = parsed?.requestId ?? parsed?.message?.id ?? void 0;
1517
+ if (id !== void 0 && id === last) continue;
1518
+ last = id;
1519
+ input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1520
+ output += addNum(usage.output_tokens);
1566
1521
  }
1567
- if (stamina) {
1568
- console.log(" Stamina on \u2014 horses running above a sustainable pace will tire.");
1522
+ return last === void 0 ? { input, output } : { input, output, last };
1523
+ }
1524
+ };
1525
+ function addNum(value) {
1526
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
1527
+ }
1528
+ var claudeCode = {
1529
+ id: "claude-code",
1530
+ label: "Claude Code",
1531
+ enabledByDefault: true,
1532
+ // counted since before harnesses were configurable
1533
+ overrideVar: "TOKEN_DERBY_CLAUDE_DIR",
1534
+ hints: [
1535
+ `If CLAUDE_CONFIG_DIR relocated your config, Token Derby follows it \u2014`,
1536
+ `check it points at the config root, not the projects directory.`
1537
+ ],
1538
+ root: claudeProjectsDir,
1539
+ counting: incremental(CLAUDE_FOLD, constant("anthropic")),
1540
+ async discover(root) {
1541
+ const entries = await readRoot(root, () => fs3.readdir(root, { withFileTypes: true }));
1542
+ const out = [];
1543
+ for (const entry of entries) {
1544
+ if (!await isDirectory(entry, root)) continue;
1545
+ await collect(path3.join(root, entry.name), MAX_PROJECT_DEPTH, out);
1569
1546
  }
1570
- console.log(` Share with participants: token-derby join ${resp.join_code}`);
1571
- return 0;
1572
- } catch (e) {
1573
- if (e instanceof ApiError) {
1574
- console.error(`Error: ${e.code} ${e.message}`);
1575
- return 1;
1547
+ return out;
1548
+ },
1549
+ // A "conversation" is one top-level session: <project>/<session>. The main
1550
+ // session transcript and everything nested under <session>/subagents/** roll
1551
+ // up into the same id.
1552
+ conversationId(file, root) {
1553
+ const rel = path3.relative(root, file);
1554
+ const [project, session] = rel.split(path3.sep);
1555
+ if (project === void 0 || session === void 0) return rel.replace(/\.jsonl$/, "");
1556
+ return `${project}/${session.replace(/\.jsonl$/, "")}`;
1557
+ }
1558
+ };
1559
+ async function collect(dir, depth, out) {
1560
+ if (depth <= 0) return;
1561
+ const entries = await fs3.readdir(dir, { withFileTypes: true }).catch(() => []);
1562
+ for (const entry of entries) {
1563
+ if (entry.name.endsWith(TRANSCRIPT_EXT)) {
1564
+ out.push(path3.join(dir, entry.name));
1565
+ } else if (depth > 1 && await isDirectory(entry, dir)) {
1566
+ await collect(path3.join(dir, entry.name), depth - 1, out);
1576
1567
  }
1577
- throw e;
1578
- } finally {
1579
- rl.close();
1580
1568
  }
1581
1569
  }
1582
- function isIso(s) {
1583
- if (!s) return false;
1584
- const d = new Date(s);
1585
- return !Number.isNaN(d.getTime());
1570
+ async function isDirectory(entry, parent) {
1571
+ if (entry.isDirectory()) return true;
1572
+ if (!entry.isSymbolicLink()) return false;
1573
+ return fs3.stat(path3.join(parent, entry.name)).then((st) => st.isDirectory()).catch(() => false);
1586
1574
  }
1587
1575
 
1588
- // src/commands/join.ts
1589
- import React9 from "react";
1590
- import { render as render4 } from "ink";
1591
-
1592
- // src/ui/PrimaryPicker.tsx
1593
- import { useState as useState4 } from "react";
1594
- import { Box as Box6, Text as Text6, useInput as useInput3 } from "ink";
1595
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1596
- var LABELS = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
1597
- function PrimaryPicker({ onPick }) {
1598
- const [i, setI] = useState4(0);
1599
- useInput3((_input, key) => {
1600
- if (key.upArrow) setI((p) => (p + MODEL_KEYS.length - 1) % MODEL_KEYS.length);
1601
- else if (key.downArrow) setI((p) => (p + 1) % MODEL_KEYS.length);
1602
- else if (key.return) onPick(MODEL_KEYS[i]);
1576
+ // src/tokens/harnesses/codex-cli/index.ts
1577
+ import * as fs4 from "fs/promises";
1578
+ import * as path4 from "path";
1579
+ var SESSION_DIRS = ["sessions", "archived_sessions"];
1580
+ var ROLLOUT_PREFIX = "rollout-";
1581
+ var ROLLOUT_EXT = ".jsonl";
1582
+ var CODEX_FOLD = {
1583
+ empty: () => ({ input: 0, output: 0 }),
1584
+ append: (acc, lines) => {
1585
+ let usage = null;
1586
+ for (const line of lines) {
1587
+ if (!line.trim()) continue;
1588
+ let parsed;
1589
+ try {
1590
+ parsed = JSON.parse(line);
1591
+ } catch {
1592
+ continue;
1593
+ }
1594
+ if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
1595
+ usage = parsed.payload.info.total_token_usage;
1596
+ }
1597
+ }
1598
+ if (!usage) return acc;
1599
+ return {
1600
+ input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
1601
+ output: num(usage.output_tokens)
1602
+ };
1603
+ }
1604
+ };
1605
+ function num(v) {
1606
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
1607
+ }
1608
+ var codexCli = {
1609
+ id: "codex-cli",
1610
+ label: "Codex CLI",
1611
+ enabledByDefault: true,
1612
+ // counted since before harnesses were configurable
1613
+ overrideVar: "TOKEN_DERBY_CODEX_DIR",
1614
+ root: codexSessionsDir,
1615
+ counting: incremental(CODEX_FOLD, constant("openai")),
1616
+ async discover(root) {
1617
+ await readRoot(root, () => fs4.stat(root));
1618
+ const out = [];
1619
+ for (const dir of SESSION_DIRS) out.push(...await collect2(path4.join(root, dir)));
1620
+ return out;
1621
+ },
1622
+ /** One rollout file is one conversation. */
1623
+ conversationId(file) {
1624
+ return file;
1625
+ }
1626
+ };
1627
+ async function collect2(dir) {
1628
+ let entries;
1629
+ try {
1630
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1631
+ } catch (e) {
1632
+ if (e?.code === "ENOENT") return [];
1633
+ throw e;
1634
+ }
1635
+ const out = [];
1636
+ for (const entry of entries) {
1637
+ const full = path4.join(dir, entry.name);
1638
+ if (entry.isDirectory()) out.push(...await collect2(full));
1639
+ else if (entry.name.startsWith(ROLLOUT_PREFIX) && entry.name.endsWith(ROLLOUT_EXT)) out.push(full);
1640
+ }
1641
+ return out;
1642
+ }
1643
+
1644
+ // src/tokens/harnesses/gemini-cli/index.ts
1645
+ import * as fs5 from "fs/promises";
1646
+ import * as path5 from "path";
1647
+ var CHATS_DIR = "chats";
1648
+ var CHAT_EXTS = [".json", ".jsonl"];
1649
+ function num2(v) {
1650
+ return typeof v === "number" && Number.isFinite(v) ? v : 0;
1651
+ }
1652
+ var geminiCli = {
1653
+ id: "gemini-cli",
1654
+ label: "Gemini CLI",
1655
+ enabledByDefault: true,
1656
+ // counted since before harnesses were configurable
1657
+ overrideVar: "TOKEN_DERBY_GEMINI_DIR",
1658
+ root: geminiTmpDir,
1659
+ // Gemini chats are rewritten whole rather than appended to, so there is no
1660
+ // offset to resume from — the cache gates on mtime+size and recomputes in full.
1661
+ counting: wholeFile((raw, file) => ({ families: { google: sumRaw(file, raw) } })),
1662
+ async discover(root) {
1663
+ const entries = await readRoot(root, () => fs5.readdir(root));
1664
+ const out = [];
1665
+ for (const entry of entries) {
1666
+ const chatsDir = path5.join(root, entry, CHATS_DIR);
1667
+ let files;
1668
+ try {
1669
+ files = await fs5.readdir(chatsDir);
1670
+ } catch {
1671
+ continue;
1672
+ }
1673
+ for (const f of files) {
1674
+ if (CHAT_EXTS.some((ext) => f.endsWith(ext))) out.push(path5.join(chatsDir, f));
1675
+ }
1676
+ }
1677
+ return out;
1678
+ },
1679
+ /** One chat file is one conversation. */
1680
+ conversationId(file) {
1681
+ return file;
1682
+ }
1683
+ };
1684
+ function sumRaw(file, raw) {
1685
+ const messages = file.endsWith(".jsonl") ? parseJsonl(raw) : parseJson(raw);
1686
+ let input = 0;
1687
+ let output = 0;
1688
+ for (const m of messages) {
1689
+ const tk = m?.tokens;
1690
+ if (!tk || typeof tk !== "object") continue;
1691
+ input += Math.max(0, num2(tk.input) - num2(tk.cached));
1692
+ output += num2(tk.output);
1693
+ }
1694
+ return { input, output };
1695
+ }
1696
+ function parseJson(raw) {
1697
+ try {
1698
+ const data = JSON.parse(raw);
1699
+ return Array.isArray(data?.messages) ? data.messages : [];
1700
+ } catch {
1701
+ return [];
1702
+ }
1703
+ }
1704
+ function parseJsonl(raw) {
1705
+ const out = [];
1706
+ for (const line of raw.split("\n")) {
1707
+ if (!line.trim()) continue;
1708
+ try {
1709
+ out.push(JSON.parse(line));
1710
+ } catch {
1711
+ }
1712
+ }
1713
+ return out;
1714
+ }
1715
+
1716
+ // src/tokens/harnesses/pi/index.ts
1717
+ import * as fs6 from "fs/promises";
1718
+ import * as path6 from "path";
1719
+
1720
+ // src/tokens/pool.ts
1721
+ var SCAN_CONCURRENCY = 12;
1722
+ async function mapWithConcurrency(items, limit, fn) {
1723
+ const out = new Array(items.length);
1724
+ let next = 0;
1725
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1726
+ while (true) {
1727
+ const i = next++;
1728
+ if (i >= items.length) return;
1729
+ out[i] = await fn(items[i], i);
1730
+ }
1603
1731
  });
1604
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
1605
- /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Pick your primary model for this race (counts 1:1; the others count at 50%)." }),
1606
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "This is locked for the whole race \u2014 you can't change it, even by rejoining." }),
1607
- MODEL_KEYS.map((m, idx) => /* @__PURE__ */ jsxs4(Text6, { color: idx === i ? "cyan" : void 0, children: [
1608
- idx === i ? "\u276F " : " ",
1609
- LABELS[m]
1610
- ] }, m))
1611
- ] });
1732
+ await Promise.all(workers);
1733
+ return out;
1734
+ }
1735
+
1736
+ // src/tokens/harnesses/pi/entries.ts
1737
+ import { createHash } from "crypto";
1738
+ function num3(value) {
1739
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
1740
+ }
1741
+ function modelOf(provider, modelId) {
1742
+ return typeof provider === "string" && typeof modelId === "string" && provider && modelId ? { provider, modelId } : null;
1743
+ }
1744
+ function fingerprint(entry, rawLine) {
1745
+ if (typeof entry?.id === "string" && typeof entry?.timestamp === "string") {
1746
+ const role = entry?.message?.role ?? "";
1747
+ return [entry.id, entry.timestamp, entry.type ?? "", role].join("\0");
1748
+ }
1749
+ return createHash("sha256").update(rawLine).digest("base64url");
1750
+ }
1751
+ var PI_FOLD = {
1752
+ empty: () => ({ isSession: false, modelByEntry: {}, events: [] }),
1753
+ append: (acc, lines) => {
1754
+ let isSession = acc.isSession;
1755
+ const modelByEntry = { ...acc.modelByEntry };
1756
+ const events = [...acc.events];
1757
+ for (const line of lines) {
1758
+ if (!line.trim()) continue;
1759
+ let entry;
1760
+ try {
1761
+ entry = JSON.parse(line);
1762
+ } catch {
1763
+ continue;
1764
+ }
1765
+ if (entry?.type === "session") {
1766
+ isSession = entry.version === 3;
1767
+ continue;
1768
+ }
1769
+ if (!isSession) continue;
1770
+ const inherited = typeof entry?.parentId === "string" ? modelByEntry[entry.parentId] ?? null : null;
1771
+ let active = inherited;
1772
+ if (entry?.type === "model_change") {
1773
+ active = modelOf(entry.provider, entry.modelId);
1774
+ } else if (entry?.type === "message" && entry.message?.role === "assistant") {
1775
+ active = modelOf(entry.message.provider, entry.message.model) ?? inherited;
1776
+ }
1777
+ if (typeof entry?.id === "string") modelByEntry[entry.id] = active;
1778
+ let usage = null;
1779
+ let usageModel = active;
1780
+ if (entry?.type === "message" && (entry.message?.role === "assistant" || entry.message?.role === "toolResult")) {
1781
+ usage = entry.message.usage;
1782
+ } else if ((entry?.type === "compaction" || entry?.type === "branch_summary") && entry.usage) {
1783
+ usage = entry.usage;
1784
+ if (entry.type === "branch_summary" && typeof entry.fromId === "string") {
1785
+ usageModel = modelByEntry[entry.fromId] ?? active;
1786
+ }
1787
+ }
1788
+ if (!usage) continue;
1789
+ const input = num3(usage.input) + num3(usage.cacheWrite);
1790
+ const output = num3(usage.output);
1791
+ if (input > 0 || output > 0) {
1792
+ events.push({ fingerprint: fingerprint(entry, line), model: usageModel, input, output });
1793
+ }
1794
+ }
1795
+ return { isSession, modelByEntry, events };
1796
+ }
1797
+ };
1798
+
1799
+ // src/tokens/harnesses/pi/providers.ts
1800
+ var DIRECT = {
1801
+ anthropic: "anthropic",
1802
+ openai: "openai",
1803
+ "azure-openai-responses": "openai",
1804
+ google: "google"
1805
+ };
1806
+ var GATEWAYS = /* @__PURE__ */ new Set([
1807
+ "amazon-bedrock",
1808
+ "openrouter",
1809
+ "cloudflare-ai-gateway",
1810
+ "vercel-ai-gateway",
1811
+ "radius"
1812
+ ]);
1813
+ function resolveProvider(provider) {
1814
+ const normalised = provider.trim().toLowerCase();
1815
+ const family = DIRECT[normalised];
1816
+ if (family) return { kind: "family", family };
1817
+ if (GATEWAYS.has(normalised)) return { kind: "gateway", provider: normalised };
1818
+ return { kind: "other", provider: normalised };
1819
+ }
1820
+ function describeUncounted(uncounted) {
1821
+ const gateways = /* @__PURE__ */ new Set();
1822
+ const others = /* @__PURE__ */ new Set();
1823
+ for (const r of uncounted) {
1824
+ if (r.kind === "gateway") gateways.add(r.provider);
1825
+ else if (r.kind === "other") others.add(r.provider);
1826
+ }
1827
+ const lines = [];
1828
+ if (gateways.size > 0) {
1829
+ lines.push(
1830
+ `Pi usage on ${[...gateways].sort().join(", ")} not counted \u2014 can serve models we score, but not yet identifiable.`
1831
+ );
1832
+ }
1833
+ if (others.size > 0) {
1834
+ lines.push(
1835
+ `Pi usage on ${[...others].sort().join(", ")} not counted \u2014 not one of the model families we score.`
1836
+ );
1837
+ }
1838
+ return lines;
1839
+ }
1840
+
1841
+ // src/tokens/harnesses/pi/index.ts
1842
+ var SESSION_EXT = ".jsonl";
1843
+ var IGNORED_DIRS = /* @__PURE__ */ new Set(["subagent-artifacts"]);
1844
+ var pi = {
1845
+ id: "pi",
1846
+ label: "Pi",
1847
+ // Off until asked for: Pi arrived after the others, and adding a harness must
1848
+ // never start counting someone's history behind their back.
1849
+ enabledByDefault: false,
1850
+ overrideVar: "TOKEN_DERBY_PI_DIR",
1851
+ root: piSessionsDir,
1852
+ async discover(root) {
1853
+ await readRoot(root, () => fs6.stat(root));
1854
+ const out = [];
1855
+ await collect3(root, out);
1856
+ return out.sort();
1857
+ },
1858
+ // Unused for a whole-history harness: it groups its own conversations, since
1859
+ // one session's work can be spread across a file and its clones.
1860
+ conversationId(file) {
1861
+ return file;
1862
+ },
1863
+ counting: custom(readAll)
1864
+ };
1865
+ async function collect3(dir, out) {
1866
+ const entries = await fs6.readdir(dir, { withFileTypes: true }).catch(() => []);
1867
+ for (const entry of entries) {
1868
+ const full = path6.join(dir, entry.name);
1869
+ if (entry.isDirectory()) {
1870
+ if (!IGNORED_DIRS.has(entry.name)) await collect3(full, out);
1871
+ } else if (entry.isFile() && entry.name.endsWith(SESSION_EXT)) {
1872
+ out.push(full);
1873
+ }
1874
+ }
1875
+ }
1876
+ async function readAll(cache, files, root) {
1877
+ const states = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => cache.readIncremental(f, PI_FOLD));
1878
+ const byConversation = /* @__PURE__ */ new Map();
1879
+ const seen = /* @__PURE__ */ new Set();
1880
+ const uncounted = [];
1881
+ files.forEach((file, i) => {
1882
+ const state = states[i];
1883
+ if (!state.isSession) return;
1884
+ const conversation = conversationOf(file, root);
1885
+ for (const event of state.events) {
1886
+ if (seen.has(event.fingerprint)) continue;
1887
+ seen.add(event.fingerprint);
1888
+ if (!event.model) continue;
1889
+ const resolved = resolveProvider(event.model.provider);
1890
+ if (resolved.kind !== "family") {
1891
+ uncounted.push(resolved);
1892
+ continue;
1893
+ }
1894
+ addTo(byConversation, conversation, resolved.family, event.input, event.output);
1895
+ }
1896
+ });
1897
+ return { byConversation, notices: describeUncounted(uncounted) };
1898
+ }
1899
+ function addTo(byConversation, conversation, family, input, output) {
1900
+ const families = byConversation.get(conversation) ?? {};
1901
+ const totals = families[family] ?? { input: 0, output: 0 };
1902
+ totals.input += input;
1903
+ totals.output += output;
1904
+ families[family] = totals;
1905
+ byConversation.set(conversation, families);
1906
+ }
1907
+ function conversationOf(file, root) {
1908
+ return path6.relative(root, file).replace(/\.jsonl$/, "");
1909
+ }
1910
+
1911
+ // src/tokens/harnesses/registry.ts
1912
+ var HARNESSES = {
1913
+ "claude-code": claudeCode,
1914
+ "codex-cli": codexCli,
1915
+ "gemini-cli": geminiCli,
1916
+ pi
1917
+ };
1918
+ var HARNESS_KEYS = Object.keys(HARNESSES);
1919
+
1920
+ // src/stable/prefs.ts
1921
+ async function loadPrefs() {
1922
+ let raw;
1923
+ try {
1924
+ raw = await fs7.readFile(prefsFile(), "utf8");
1925
+ } catch {
1926
+ return {};
1927
+ }
1928
+ let parsed;
1929
+ try {
1930
+ parsed = JSON.parse(raw);
1931
+ } catch {
1932
+ return {};
1933
+ }
1934
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1935
+ const obj = parsed;
1936
+ const id = obj.default_stable_horse_id;
1937
+ const harnesses = readHarnessChoices(obj);
1938
+ return {
1939
+ ...typeof id === "string" && id !== "" ? { default_stable_horse_id: id } : {},
1940
+ ...Object.keys(harnesses).length > 0 ? { harnesses } : {}
1941
+ };
1942
+ }
1943
+ function readHarnessChoices(obj) {
1944
+ const raw = obj.harnesses;
1945
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
1946
+ const out = {};
1947
+ for (const [key, value] of Object.entries(raw)) {
1948
+ if (typeof value === "boolean" && HARNESS_KEYS.includes(key)) {
1949
+ out[key] = value;
1950
+ }
1951
+ }
1952
+ return out;
1953
+ }
1954
+ function isHarnessEnabled(prefs, key) {
1955
+ return prefs.harnesses?.[key] ?? HARNESSES[key].enabledByDefault;
1956
+ }
1957
+ function enabledHarnesses(prefs) {
1958
+ return HARNESS_KEYS.filter((key) => isHarnessEnabled(prefs, key));
1959
+ }
1960
+ async function setHarnessEnabled(key, enabled) {
1961
+ const raw = await loadRaw();
1962
+ const stored = typeof raw.harnesses === "object" && raw.harnesses !== null && !Array.isArray(raw.harnesses) ? raw.harnesses : {};
1963
+ await savePrefs({ harnesses: { ...stored, [key]: enabled } });
1964
+ }
1965
+ async function savePrefs(patch) {
1966
+ const merged = { ...await loadRaw(), ...patch };
1967
+ for (const [k, v] of Object.entries(merged)) {
1968
+ if (v === void 0) delete merged[k];
1969
+ }
1970
+ await fs7.mkdir(homeDir(), { recursive: true });
1971
+ await fs7.writeFile(prefsFile(), JSON.stringify(merged, null, 2) + "\n", "utf8");
1972
+ }
1973
+ async function loadRaw() {
1974
+ try {
1975
+ const parsed = JSON.parse(await fs7.readFile(prefsFile(), "utf8"));
1976
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
1977
+ return parsed;
1978
+ } catch {
1979
+ return {};
1980
+ }
1981
+ }
1982
+ async function setDefaultHorse(stableHorseId) {
1983
+ await savePrefs({ default_stable_horse_id: stableHorseId });
1984
+ }
1985
+ async function clearDefaultHorse() {
1986
+ await savePrefs({ default_stable_horse_id: void 0 });
1987
+ }
1988
+
1989
+ // src/stable/resolve-horse.ts
1990
+ async function resolveHorse(horses, opts = {}) {
1991
+ if (horses.length === 0) return { kind: "empty" };
1992
+ if (opts.name !== void 0) {
1993
+ const found = horses.find((h) => h.name === opts.name);
1994
+ return found ? { kind: "resolved", horse: found, via: "flag" } : { kind: "not_found", name: opts.name };
1995
+ }
1996
+ if (opts.pick) return interactive() ? { kind: "pick" } : { kind: "no_tty" };
1997
+ if (opts.autoSelect !== false) {
1998
+ const { default_stable_horse_id } = await loadPrefs();
1999
+ if (default_stable_horse_id !== void 0) {
2000
+ const found = horses.find((h) => h.stable_horse_id === default_stable_horse_id);
2001
+ if (found) return { kind: "resolved", horse: found, via: "default" };
2002
+ }
2003
+ if (horses.length === 1) return { kind: "resolved", horse: horses[0], via: "only" };
2004
+ }
2005
+ return interactive() ? { kind: "pick" } : { kind: "no_tty" };
2006
+ }
2007
+ function interactive() {
2008
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
2009
+ }
2010
+ function describeHorse(horse) {
2011
+ return `${horse.name} [Lvl. ${levelFromXp(horse.xp)}]`;
2012
+ }
2013
+ function noticeFor(choice) {
2014
+ if (choice.via === "flag") return null;
2015
+ const which = choice.via === "default" ? "your default horse" : "your only horse";
2016
+ return `Using ${which}: ${describeHorse(choice.horse)}
2017
+ (--horse <name> to pick another, --pick to choose)`;
2018
+ }
2019
+ function noTtyMessage(command) {
2020
+ return [
2021
+ `\`${command}\` needs to know which horse, and this is not an interactive terminal.`,
2022
+ "Pass --horse <name>, or set a default with `token-derby stable default <name>`."
2023
+ ].join("\n");
2024
+ }
2025
+
2026
+ // src/commands/stable-edit.ts
2027
+ async function stableEditCommand(name) {
2028
+ if (!interactive()) {
2029
+ console.error("`token-derby stable edit` needs an interactive terminal.");
2030
+ return 1;
2031
+ }
2032
+ const horses = await fetchStable();
2033
+ if (!horses) return 1;
2034
+ const existing = await pickHorseToEdit(horses, name);
2035
+ if (existing === "not_found") {
2036
+ console.error(`No horse named "${name}" in your stable.`);
2037
+ return 1;
2038
+ }
2039
+ if (existing === "empty") {
2040
+ console.log("No horses in your stable. Run `token-derby stable create` to make one.");
2041
+ return 0;
2042
+ }
2043
+ if (existing === "cancelled") {
2044
+ console.log("Cancelled.");
2045
+ return 1;
2046
+ }
2047
+ if (existing === "no_tty") {
2048
+ console.error(noTtyMessage("token-derby stable edit"));
2049
+ return 1;
2050
+ }
2051
+ const initialEquipped = existing.equipped_hat ?? null;
2052
+ let exitCode = 0;
2053
+ const app = render3(
2054
+ React4.createElement(HorseCreator, {
2055
+ initialColors: existing.colors,
2056
+ initialName: existing.name,
2057
+ lockName: true,
2058
+ initialLevel: levelFromXp(existing.xp),
2059
+ hats: existing.hats,
2060
+ initialEquipped,
2061
+ onSubmit: async (_name, colors, hatChoice) => {
2062
+ const colorsChanged = !sameColors(colors, existing.colors);
2063
+ const hatChanged = hatChoice !== void 0 && hatChoice !== initialEquipped;
2064
+ try {
2065
+ if (colorsChanged) await updateStableHorse(existing.stable_horse_id, { colors });
2066
+ if (hatChanged) await equipHat(existing.stable_horse_id, { hat_index: hatChoice });
2067
+ app.unmount();
2068
+ if (!colorsChanged && !hatChanged) {
2069
+ console.log(`No changes for "${existing.name}".`);
2070
+ } else {
2071
+ const parts = [];
2072
+ if (colorsChanged) parts.push("colors");
2073
+ if (hatChanged) parts.push(hatChoice === null ? "hat unequipped" : "hat equipped");
2074
+ console.log(`\u2713 Updated "${existing.name}" (${parts.join(", ")}).`);
2075
+ }
2076
+ } catch (e) {
2077
+ app.unmount();
2078
+ if (e instanceof ApiError) {
2079
+ console.error(`Error: ${e.code} ${e.message}`);
2080
+ exitCode = 1;
2081
+ return;
2082
+ }
2083
+ throw e;
2084
+ }
2085
+ },
2086
+ onCancel: () => {
2087
+ app.unmount();
2088
+ console.log("Cancelled.");
2089
+ exitCode = 1;
2090
+ }
2091
+ })
2092
+ );
2093
+ await app.waitUntilExit();
2094
+ return exitCode;
2095
+ }
2096
+ function sameColors(a, b) {
2097
+ return a.body === b.body && a.mane === b.mane && a.tail === b.tail && a.saddle === b.saddle;
2098
+ }
2099
+ async function fetchStable() {
2100
+ try {
2101
+ const resp = await listStable();
2102
+ return resp.horses;
2103
+ } catch (e) {
2104
+ if (e instanceof ApiError) {
2105
+ console.error(`Error: ${e.code} ${e.message}`);
2106
+ return null;
2107
+ }
2108
+ throw e;
2109
+ }
2110
+ }
2111
+ async function pickHorseToEdit(horses, name) {
2112
+ const choice = await resolveHorse(horses, { name });
2113
+ if (choice.kind === "empty") return "empty";
2114
+ if (choice.kind === "not_found") return "not_found";
2115
+ if (choice.kind === "no_tty") return "no_tty";
2116
+ if (choice.kind === "resolved") {
2117
+ const notice = noticeFor(choice);
2118
+ if (notice) console.log(notice);
2119
+ return choice.horse;
2120
+ }
2121
+ const picked = await new Promise((resolve) => {
2122
+ const app = render3(
2123
+ React4.createElement(HorsePicker, {
2124
+ horses,
2125
+ onPick: (h) => {
2126
+ app.unmount();
2127
+ resolve(h);
2128
+ },
2129
+ onCancel: () => {
2130
+ app.unmount();
2131
+ resolve(null);
2132
+ }
2133
+ })
2134
+ );
2135
+ });
2136
+ return picked ?? "cancelled";
2137
+ }
2138
+
2139
+ // src/commands/create.ts
2140
+ import * as readline2 from "readline/promises";
2141
+ import { stdin as stdin2, stdout as stdout2 } from "process";
2142
+ var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
2143
+ async function createRaceCommand(organisationName) {
2144
+ const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
2145
+ try {
2146
+ const name = (await rl.question("Race name: ")).trim();
2147
+ if (!name) {
2148
+ console.error("Name required.");
2149
+ return 1;
2150
+ }
2151
+ const startRaw = (await rl.question("Start time (ISO 8601, blank = now): ")).trim();
2152
+ const start = startRaw ? startRaw : (/* @__PURE__ */ new Date()).toISOString();
2153
+ if (!isIso(start)) {
2154
+ console.error("Invalid start time.");
2155
+ return 1;
2156
+ }
2157
+ const durationRaw = (await rl.question("Race duration (hours): ")).trim();
2158
+ const durationHours = parseFloat(durationRaw);
2159
+ if (!Number.isFinite(durationHours) || durationHours <= 0) {
2160
+ console.error("Duration must be a positive number of hours.");
2161
+ return 1;
2162
+ }
2163
+ const end = new Date(new Date(start).getTime() + durationHours * 36e5).toISOString();
2164
+ const tz = (await rl.question(`Time zone [${DEFAULT_TZ}]: `)).trim() || DEFAULT_TZ;
2165
+ const maxRaw = (await rl.question("Max participants [30]: ")).trim();
2166
+ const max = maxRaw ? parseInt(maxRaw, 10) : void 0;
2167
+ if (max !== void 0 && (!Number.isFinite(max) || max < 1)) {
2168
+ console.error("Max participants must be a positive number.");
2169
+ return 1;
2170
+ }
2171
+ let org = organisationName;
2172
+ if (org === void 0) {
2173
+ const raw = (await rl.question("Organisation (blank for none): ")).trim();
2174
+ if (raw) org = raw;
2175
+ }
2176
+ if (org !== void 0 && !ORG_NAME_PATTERN.test(org)) {
2177
+ console.error("Organisation name must be 1\u201312 alphanumeric characters.");
2178
+ return 1;
2179
+ }
2180
+ const modifiers = [];
2181
+ if (!org) {
2182
+ for (const id of MODIFIER_IDS) {
2183
+ const m = MODIFIERS[id];
2184
+ const raw = (await rl.question(`${m.label} \u2014 ${m.description} [y/N]: `)).trim().toLowerCase();
2185
+ if (raw === "y" || raw === "yes") modifiers.push(id);
2186
+ }
2187
+ }
2188
+ const resp = await createRace({
2189
+ name,
2190
+ start_time: start,
2191
+ end_time: end,
2192
+ tz,
2193
+ ...max !== void 0 ? { max_participants: max } : {},
2194
+ ...org ? { organisation_name: org } : {},
2195
+ ...modifiers.length > 0 ? { modifiers } : {}
2196
+ });
2197
+ console.log("");
2198
+ console.log(" \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557");
2199
+ console.log(` \u2551 JOIN CODE: ${resp.join_code.padEnd(23)}\u2551`);
2200
+ console.log(" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D");
2201
+ console.log("");
2202
+ console.log(` Admin code: ${resp.admin_code}`);
2203
+ console.log(" \u26A0 Save the admin code \u2014 you need it to end the race early.");
2204
+ console.log("");
2205
+ if (org) {
2206
+ console.log(` Restricted to organisation: ${org}`);
2207
+ }
2208
+ for (const id of modifiers) {
2209
+ console.log(` ${MODIFIERS[id].label} on \u2014 ${MODIFIERS[id].description}`);
2210
+ }
2211
+ console.log(` Share with participants: token-derby join ${resp.join_code}`);
2212
+ return 0;
2213
+ } catch (e) {
2214
+ if (e instanceof ApiError) {
2215
+ console.error(`Error: ${e.code} ${e.message}`);
2216
+ return 1;
2217
+ }
2218
+ throw e;
2219
+ } finally {
2220
+ rl.close();
2221
+ }
2222
+ }
2223
+ function isIso(s) {
2224
+ if (!s) return false;
2225
+ const d = new Date(s);
2226
+ return !Number.isNaN(d.getTime());
2227
+ }
2228
+
2229
+ // src/commands/join.ts
2230
+ import React5 from "react";
2231
+ import { render as render4 } from "ink";
2232
+
2233
+ // src/args.ts
2234
+ function parseFlag(args, flag) {
2235
+ for (let i = 0; i < args.length; i++) {
2236
+ if (args[i] === flag) return args[i + 1];
2237
+ const eq = `${flag}=`;
2238
+ if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
2239
+ }
2240
+ return void 0;
2241
+ }
2242
+ function hasFlag(args, flag) {
2243
+ return args.some((a) => a === flag || a.startsWith(`${flag}=`));
1612
2244
  }
1613
2245
 
1614
2246
  // src/stable/active-race.ts
1615
- import * as fs3 from "fs/promises";
1616
- import * as path4 from "path";
2247
+ import * as fs8 from "fs/promises";
2248
+ import * as path7 from "path";
1617
2249
  async function saveActiveRace(active) {
1618
- await fs3.mkdir(activeRacesDir(), { recursive: true });
1619
- await fs3.writeFile(
2250
+ await fs8.mkdir(activeRacesDir(), { recursive: true });
2251
+ await fs8.writeFile(
1620
2252
  activeRaceFile(active.join_code),
1621
2253
  JSON.stringify(active, null, 2) + "\n",
1622
2254
  "utf8"
@@ -1624,29 +2256,30 @@ async function saveActiveRace(active) {
1624
2256
  }
1625
2257
 
1626
2258
  // src/runtime/run-race.tsx
1627
- import { useEffect as useEffect2, useRef, useState as useState5 } from "react";
1628
- import { Box as Box8, Text as Text8, useApp } from "ink";
2259
+ import { useEffect as useEffect2, useRef, useState as useState4 } from "react";
2260
+ import { Box as Box7, Text as Text7, useApp } from "ink";
1629
2261
 
1630
2262
  // src/ui/StatusScreen.tsx
1631
- import { Box as Box7, Text as Text7 } from "ink";
1632
- import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1633
- var MODEL_LABELS = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
2263
+ import { Box as Box6, Text as Text6 } from "ink";
2264
+ import { Fragment as Fragment2, jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1634
2265
  function ModelList(props) {
1635
- const { primaryModel } = props;
1636
- const secondaryTag = ` (${Math.round(SECONDARY_WEIGHT * 100)}%)`;
1637
- return /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text7, { children: [
1638
- "Models: ",
1639
- MODEL_KEYS.map((m, i) => /* @__PURE__ */ jsxs5(Text7, { children: [
2266
+ const off = new Set(props.disabled ?? []);
2267
+ return /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsxs4(Text6, { children: [
2268
+ "Counting: ",
2269
+ HARNESS_KEYS.map((key, i) => /* @__PURE__ */ jsxs4(Text6, { children: [
1640
2270
  i > 0 ? " \xB7 " : "",
1641
- MODEL_LABELS[m],
1642
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: m === primaryModel ? " (primary)" : secondaryTag })
1643
- ] }, m))
2271
+ off.has(key) ? /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
2272
+ HARNESSES[key].label,
2273
+ " (off)"
2274
+ ] }) : HARNESSES[key].label
2275
+ ] }, key)),
2276
+ /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: " (all count the same)" })
1644
2277
  ] }) });
1645
2278
  }
1646
2279
  function StatusScreen(props) {
1647
- const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, primarySilent, primarySourceDir, primaryModel } = props;
2280
+ const { race, ownHorseId, ownHorseName, ownColors, ownUserName, lastHeartbeatAgoSec, lastHeartbeatOk, stalled, stallReason, sourcesSilent, degraded, notices, disabledHarnesses } = props;
1648
2281
  if (!race) {
1649
- return /* @__PURE__ */ jsx7(Box7, { flexDirection: "column", children: /* @__PURE__ */ jsx7(Text7, { children: "Joining race\u2026" }) });
2282
+ return /* @__PURE__ */ jsx6(Box6, { flexDirection: "column", children: /* @__PURE__ */ jsx6(Text6, { children: "Joining race\u2026" }) });
1650
2283
  }
1651
2284
  const own = race.horses.find((h) => h.horse_id === ownHorseId);
1652
2285
  const leader = race.horses[0];
@@ -1672,36 +2305,36 @@ function StatusScreen(props) {
1672
2305
  },
1673
2306
  {
1674
2307
  label: "Last heartbeat:",
1675
- value: /* @__PURE__ */ jsxs5(Fragment2, { children: [
2308
+ value: /* @__PURE__ */ jsxs4(Fragment2, { children: [
1676
2309
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1677
2310
  " ",
1678
- /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
2311
+ /* @__PURE__ */ jsx6(Text6, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1679
2312
  ] })
1680
2313
  }
1681
2314
  ];
1682
- return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1683
- /* @__PURE__ */ jsxs5(Text7, { children: [
2315
+ return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
2316
+ /* @__PURE__ */ jsxs4(Text6, { children: [
1684
2317
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
1685
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: race.name }),
2318
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: race.name }),
1686
2319
  " \u2500\u2500\u2500 status: ",
1687
- /* @__PURE__ */ jsx7(Text7, { color: statusColor(race.status), children: race.status })
2320
+ /* @__PURE__ */ jsx6(Text6, { color: statusColor(race.status), children: race.status })
1688
2321
  ] }),
1689
- /* @__PURE__ */ jsxs5(Box7, { marginTop: 1, flexDirection: "row", children: [
1690
- /* @__PURE__ */ jsx7(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
1691
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1692
- /* @__PURE__ */ jsxs5(Text7, { children: [
2322
+ /* @__PURE__ */ jsxs4(Box6, { marginTop: 1, flexDirection: "row", children: [
2323
+ /* @__PURE__ */ jsx6(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
2324
+ /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
2325
+ /* @__PURE__ */ jsxs4(Text6, { children: [
1693
2326
  " ",
1694
2327
  ownHorseName,
1695
2328
  " ",
1696
- /* @__PURE__ */ jsxs5(Text7, { color: "cyan", children: [
2329
+ /* @__PURE__ */ jsxs4(Text6, { color: "cyan", children: [
1697
2330
  "[Lvl. ",
1698
2331
  lvl.level,
1699
2332
  "]"
1700
2333
  ] })
1701
2334
  ] }),
1702
- /* @__PURE__ */ jsxs5(Text7, { children: [
2335
+ /* @__PURE__ */ jsxs4(Text6, { children: [
1703
2336
  " ",
1704
- /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
2337
+ /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
1705
2338
  "(",
1706
2339
  ownUserName,
1707
2340
  ")"
@@ -1709,30 +2342,39 @@ function StatusScreen(props) {
1709
2342
  ] })
1710
2343
  ] })
1711
2344
  ] }),
1712
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
1713
- /* @__PURE__ */ jsx7(StatLines, { rows }),
1714
- stalled && /* @__PURE__ */ jsxs5(Text7, { color: "yellow", children: [
2345
+ /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", marginTop: 1, children: [
2346
+ /* @__PURE__ */ jsx6(StatLines, { rows }),
2347
+ stalled && /* @__PURE__ */ jsxs4(Text6, { color: "yellow", children: [
1715
2348
  "\u26A0 ",
1716
2349
  stallReason ?? "Can't read token usage",
1717
2350
  ". Your race continues."
1718
2351
  ] }),
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."
2352
+ !stalled && (degraded?.length ?? 0) > 0 && degraded.map((d) => /* @__PURE__ */ jsxs4(Text6, { color: "yellow", children: [
2353
+ "\u26A0 ",
2354
+ d.label,
2355
+ " not counted this beat \u2014 ",
2356
+ d.message,
2357
+ ". Your other sources still count, and ",
2358
+ d.label,
2359
+ " catches up once it can be read."
2360
+ ] }, d.harness)),
2361
+ !stalled && (notices?.length ?? 0) > 0 && notices.map((n) => /* @__PURE__ */ jsxs4(Text6, { color: "yellow", children: [
2362
+ "\u26A0 ",
2363
+ n
2364
+ ] }, n)),
2365
+ !stalled && (degraded?.length ?? 0) === 0 && sourcesSilent && /* @__PURE__ */ jsxs4(Text6, { color: "yellow", children: [
2366
+ "\u26A0 No transcripts from any coding agent in ",
2367
+ SILENT_THRESHOLD,
2368
+ " beats. Your race continues, but your horse cannot move until they can be read."
1727
2369
  ] })
1728
2370
  ] }),
1729
- primaryModel && /* @__PURE__ */ jsx7(ModelList, { primaryModel }),
1730
- /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
2371
+ /* @__PURE__ */ jsx6(ModelList, { disabled: disabledHarnesses }),
2372
+ /* @__PURE__ */ jsx6(Box6, { marginTop: 1, children: /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "Press Ctrl+C to crash out of the race." }) })
1731
2373
  ] });
1732
2374
  }
1733
2375
  function StatLines(props) {
1734
2376
  const width = Math.max(...props.rows.map((r) => r.label.length)) + 1;
1735
- return /* @__PURE__ */ jsx7(Fragment2, { children: props.rows.map((r) => /* @__PURE__ */ jsxs5(Text7, { children: [
2377
+ return /* @__PURE__ */ jsx6(Fragment2, { children: props.rows.map((r) => /* @__PURE__ */ jsxs4(Text6, { children: [
1736
2378
  r.label.padEnd(width),
1737
2379
  r.value
1738
2380
  ] }, r.label)) });
@@ -1754,17 +2396,17 @@ function bar(pct, width) {
1754
2396
  return "\u2593".repeat(filled) + "\u2591".repeat(width - filled);
1755
2397
  }
1756
2398
  function staminaLine(own, race) {
1757
- const stamina = own?.stamina ?? 100;
2399
+ const stamina2 = staminaOf(own ?? {});
1758
2400
  const cfg = resolveStaminaConfig(race);
1759
2401
  const floor = cfg.taper_floor;
1760
- const band = stamina > 50 ? "green" : stamina >= floor ? "amber" : "red";
2402
+ const band = stamina2 > 50 ? "green" : stamina2 >= floor ? "amber" : "red";
1761
2403
  const color = band === "green" ? "green" : band === "amber" ? "yellow" : "red";
1762
- const pct = Math.max(0, Math.min(1, stamina / 100));
1763
- const multiplier = band === "red" ? cfg.tired_multiplier + (1 - cfg.tired_multiplier) * (stamina / floor) : null;
2404
+ const pct = Math.max(0, Math.min(1, stamina2 / 100));
2405
+ const multiplier = band === "red" ? cfg.tired_multiplier + (1 - cfg.tired_multiplier) * (stamina2 / floor) : null;
1764
2406
  return {
1765
2407
  label: "Stamina:",
1766
- value: /* @__PURE__ */ jsxs5(Text7, { color, children: [
1767
- `${Math.round(stamina)}% ${bar(pct, 20)}`,
2408
+ value: /* @__PURE__ */ jsxs4(Text6, { color, children: [
2409
+ `${Math.round(stamina2)}% ${bar(pct, 20)}`,
1768
2410
  multiplier !== null ? ` \xD7${multiplier.toFixed(2)}` : ""
1769
2411
  ] })
1770
2412
  };
@@ -1838,30 +2480,13 @@ function runHeartbeatLoop(opts) {
1838
2480
  schedule(0);
1839
2481
  }
1840
2482
 
1841
- // src/tokens/transcripts.ts
1842
- import * as fs5 from "fs/promises";
1843
- import * as path6 from "path";
1844
-
1845
- // src/tokens/pool.ts
1846
- var SCAN_CONCURRENCY = 12;
1847
- async function mapWithConcurrency(items, limit, fn) {
1848
- const out = new Array(items.length);
1849
- let next = 0;
1850
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1851
- while (true) {
1852
- const i = next++;
1853
- if (i >= items.length) return;
1854
- out[i] = await fn(items[i], i);
1855
- }
1856
- });
1857
- await Promise.all(workers);
1858
- return out;
1859
- }
2483
+ // src/tokens/harnesses/engine.ts
2484
+ import * as fs10 from "fs/promises";
1860
2485
 
1861
2486
  // src/tokens/scan-cache.ts
1862
- import * as fs4 from "fs/promises";
1863
- import * as path5 from "path";
1864
- var CACHE_VERSION = 1;
2487
+ import * as fs9 from "fs/promises";
2488
+ import * as path8 from "path";
2489
+ var CACHE_VERSION = 2;
1865
2490
  function isEntry(v) {
1866
2491
  const e = v;
1867
2492
  return !!e && typeof e.mtimeMs === "number" && typeof e.size === "number" && typeof e.offset === "number";
@@ -1897,7 +2522,7 @@ var ScanCache = class _ScanCache {
1897
2522
  * re-reading that line once the writer completes it.
1898
2523
  */
1899
2524
  async readIncremental(file, fold) {
1900
- const st = await fs4.stat(file);
2525
+ const st = await fs9.stat(file);
1901
2526
  const prev = this.entries.get(file);
1902
2527
  this.touched.add(file);
1903
2528
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
@@ -1914,11 +2539,11 @@ var ScanCache = class _ScanCache {
1914
2539
  * chats). Gated on mtime+size, recomputed in full whenever either moves.
1915
2540
  */
1916
2541
  async readWhenChanged(file, compute) {
1917
- const st = await fs4.stat(file);
2542
+ const st = await fs9.stat(file);
1918
2543
  const prev = this.entries.get(file);
1919
2544
  this.touched.add(file);
1920
2545
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1921
- const value = await compute(await fs4.readFile(file, "utf8"));
2546
+ const value = await compute(await fs9.readFile(file, "utf8"));
1922
2547
  this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: st.size, value });
1923
2548
  return value;
1924
2549
  }
@@ -1930,20 +2555,20 @@ var ScanCache = class _ScanCache {
1930
2555
  const target = cacheFile(this.source);
1931
2556
  const tmp = `${target}.tmp`;
1932
2557
  try {
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);
2558
+ await fs9.mkdir(path8.dirname(target), { recursive: true });
2559
+ await fs9.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
2560
+ await fs9.rename(tmp, target);
1936
2561
  } catch {
1937
2562
  }
1938
2563
  }
1939
2564
  };
1940
2565
  function cacheFile(source) {
1941
- return path5.join(homeDir(), "scan-cache", `${source}.json`);
2566
+ return path8.join(homeDir(), "scan-cache", `${source}.json`);
1942
2567
  }
1943
2568
  async function loadEntries(source) {
1944
2569
  let parsed;
1945
2570
  try {
1946
- parsed = JSON.parse(await fs4.readFile(cacheFile(source), "utf8"));
2571
+ parsed = JSON.parse(await fs9.readFile(cacheFile(source), "utf8"));
1947
2572
  } catch {
1948
2573
  return /* @__PURE__ */ new Map();
1949
2574
  }
@@ -1958,284 +2583,82 @@ async function loadEntries(source) {
1958
2583
  }
1959
2584
  async function readCompleteLines(file, start, end) {
1960
2585
  if (end <= start) return { lines: [], tail: null, consumedTo: start };
1961
- const fh = await fs4.open(file, "r");
1962
- try {
1963
- const buf = Buffer.allocUnsafe(end - start);
1964
- const { bytesRead } = await fh.read(buf, 0, end - start, start);
1965
- const chunk = buf.subarray(0, bytesRead);
1966
- const lastNl = chunk.lastIndexOf(10);
1967
- if (lastNl === -1) {
1968
- return { lines: [], tail: chunk.toString("utf8") || null, consumedTo: start };
1969
- }
1970
- const tail = chunk.subarray(lastNl + 1).toString("utf8");
1971
- return {
1972
- lines: chunk.subarray(0, lastNl).toString("utf8").split("\n"),
1973
- tail: tail === "" ? null : tail,
1974
- consumedTo: start + lastNl + 1
1975
- };
1976
- } finally {
1977
- await fh.close();
1978
- }
1979
- }
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
-
1999
- // src/tokens/transcripts.ts
2000
- var MAX_PROJECT_DEPTH = 8;
2001
- function conversationId(file, root) {
2002
- const rel = path6.relative(root, file);
2003
- const [project, session] = rel.split(path6.sep);
2004
- if (project === void 0 || session === void 0) return rel.replace(/\.jsonl$/, "");
2005
- return `${project}/${session.replace(/\.jsonl$/, "")}`;
2006
- }
2007
- async function sumTokensByConversation() {
2008
- const root = claudeProjectsDir();
2009
- const files = await listJsonlFiles(root);
2010
- const cache = await ScanCache.open("claude");
2011
- const totals = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => cache.readIncremental(f, CLAUDE_FOLD));
2012
- await cache.save();
2013
- const byConv = /* @__PURE__ */ new Map();
2014
- files.forEach((file, i) => {
2015
- const t = totals[i];
2016
- const id = conversationId(file, root);
2017
- const acc = byConv.get(id) ?? { input: 0, output: 0 };
2018
- acc.input += t.input;
2019
- acc.output += t.output;
2020
- byConv.set(id, acc);
2021
- });
2022
- return byConv;
2023
- }
2024
- async function sumTokens() {
2025
- const byConv = await sumTokensByConversation();
2026
- let input = 0;
2027
- let output = 0;
2028
- for (const t of byConv.values()) {
2029
- input += t.input;
2030
- output += t.output;
2031
- }
2032
- return { input, output };
2033
- }
2034
- async function listJsonlFiles(root) {
2035
- const entries = await readEntries(root, true);
2036
- const 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);
2040
- }
2041
- return out;
2042
- }
2043
- async function collectJsonl(dir, depth, out) {
2044
- if (depth <= 0) return;
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);
2050
- }
2051
- }
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
- }
2062
- function addNum(value) {
2063
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
2064
- }
2065
- var CLAUDE_FOLD = {
2066
- empty: () => ({ input: 0, output: 0 }),
2067
- append: (acc, lines) => {
2068
- let { input, output } = acc;
2069
- for (const line of lines) {
2070
- if (!line.trim()) continue;
2071
- let parsed;
2072
- try {
2073
- parsed = JSON.parse(line);
2074
- } catch {
2075
- continue;
2076
- }
2077
- const usage = parsed?.message?.usage;
2078
- if (!usage) continue;
2079
- input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
2080
- output += addNum(usage.output_tokens);
2081
- }
2082
- return { input, output };
2083
- }
2084
- };
2085
-
2086
- // src/tokens/codex.ts
2087
- import * as fs6 from "fs/promises";
2088
- import * as path7 from "path";
2089
- function num(v) {
2090
- return typeof v === "number" && Number.isFinite(v) ? v : 0;
2091
- }
2092
- async function sumCodexByConversation() {
2093
- const root = codexSessionsDir();
2094
- await readRoot(root, () => fs6.stat(root));
2095
- const files = await listCodexRollouts(root);
2096
- const cache = await ScanCache.open("codex");
2097
- const totals = await mapWithConcurrency(
2098
- files,
2099
- SCAN_CONCURRENCY,
2100
- (f) => cache.readIncremental(f, CODEX_FOLD).catch(() => ({ input: 0, output: 0 }))
2101
- );
2102
- await cache.save();
2103
- const byConv = /* @__PURE__ */ new Map();
2104
- files.forEach((file, i) => byConv.set(file, totals[i]));
2105
- return byConv;
2106
- }
2107
- async function sumCodexTokens() {
2108
- const byConv = await sumCodexByConversation();
2109
- let input = 0;
2110
- let output = 0;
2111
- for (const t of byConv.values()) {
2112
- input += t.input;
2113
- output += t.output;
2114
- }
2115
- return { input, output };
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
- }
2123
- async function collectRollouts(dir) {
2124
- let entries;
2125
- try {
2126
- entries = await fs6.readdir(dir, { withFileTypes: true });
2127
- } catch (e) {
2128
- if (e?.code === "ENOENT") return [];
2129
- throw e;
2130
- }
2131
- const out = [];
2132
- for (const entry of entries) {
2133
- const full = path7.join(dir, entry.name);
2134
- if (entry.isDirectory()) out.push(...await collectRollouts(full));
2135
- else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) out.push(full);
2136
- }
2137
- return out;
2138
- }
2139
- var CODEX_FOLD = {
2140
- empty: () => ({ input: 0, output: 0 }),
2141
- append: (acc, lines) => {
2142
- let usage = null;
2143
- for (const line of lines) {
2144
- if (!line.trim()) continue;
2145
- let parsed;
2146
- try {
2147
- parsed = JSON.parse(line);
2148
- } catch {
2149
- continue;
2150
- }
2151
- if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
2152
- usage = parsed.payload.info.total_token_usage;
2153
- }
2154
- }
2155
- if (!usage) return acc;
2156
- return {
2157
- input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
2158
- output: num(usage.output_tokens)
2159
- };
2160
- }
2161
- };
2162
-
2163
- // src/tokens/gemini.ts
2164
- import * as fs7 from "fs/promises";
2165
- import * as path8 from "path";
2166
- function num2(v) {
2167
- return typeof v === "number" && Number.isFinite(v) ? v : 0;
2168
- }
2169
- async function sumGeminiByConversation() {
2170
- const files = await listChatFiles(geminiTmpDir());
2171
- const cache = await ScanCache.open("gemini");
2172
- const totals = await mapWithConcurrency(
2173
- files,
2174
- SCAN_CONCURRENCY,
2175
- (f) => cache.readWhenChanged(f, async (raw) => sumGeminiRaw(f, raw)).catch(() => ({ input: 0, output: 0 }))
2176
- );
2177
- await cache.save();
2178
- const byConv = /* @__PURE__ */ new Map();
2179
- files.forEach((file, i) => byConv.set(file, totals[i]));
2180
- return byConv;
2181
- }
2182
- async function sumGeminiTokens() {
2183
- const byConv = await sumGeminiByConversation();
2184
- let input = 0;
2185
- let output = 0;
2186
- for (const t of byConv.values()) {
2187
- input += t.input;
2188
- output += t.output;
2189
- }
2190
- return { input, output };
2191
- }
2192
- async function listChatFiles(root) {
2193
- const entries = await readRoot(root, () => fs7.readdir(root));
2194
- const out = [];
2195
- for (const entry of entries) {
2196
- const chatsDir = path8.join(root, entry, "chats");
2197
- let files;
2198
- try {
2199
- files = await fs7.readdir(chatsDir);
2200
- } catch {
2201
- continue;
2202
- }
2203
- for (const f of files) {
2204
- if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path8.join(chatsDir, f));
2205
- }
2206
- }
2207
- return out;
2208
- }
2209
- function sumGeminiRaw(file, raw) {
2210
- const messages = file.endsWith(".jsonl") ? parseJsonl(raw) : parseJson(raw);
2211
- let input = 0;
2212
- let output = 0;
2213
- for (const m of messages) {
2214
- const tk = m?.tokens;
2215
- if (!tk || typeof tk !== "object") continue;
2216
- input += Math.max(0, num2(tk.input) - num2(tk.cached));
2217
- output += num2(tk.output);
2218
- }
2219
- return { input, output };
2220
- }
2221
- function parseJson(raw) {
2222
- try {
2223
- const data = JSON.parse(raw);
2224
- return Array.isArray(data?.messages) ? data.messages : [];
2225
- } catch {
2226
- return [];
2586
+ const fh = await fs9.open(file, "r");
2587
+ try {
2588
+ const buf = Buffer.allocUnsafe(end - start);
2589
+ const { bytesRead } = await fh.read(buf, 0, end - start, start);
2590
+ const chunk = buf.subarray(0, bytesRead);
2591
+ const lastNl = chunk.lastIndexOf(10);
2592
+ if (lastNl === -1) {
2593
+ return { lines: [], tail: chunk.toString("utf8") || null, consumedTo: start };
2594
+ }
2595
+ const tail = chunk.subarray(lastNl + 1).toString("utf8");
2596
+ return {
2597
+ lines: chunk.subarray(0, lastNl).toString("utf8").split("\n"),
2598
+ tail: tail === "" ? null : tail,
2599
+ consumedTo: start + lastNl + 1
2600
+ };
2601
+ } finally {
2602
+ await fh.close();
2227
2603
  }
2228
2604
  }
2229
- function parseJsonl(raw) {
2230
- const out = [];
2231
- for (const line of raw.split("\n")) {
2232
- if (!line.trim()) continue;
2233
- try {
2234
- out.push(JSON.parse(line));
2235
- } catch {
2605
+
2606
+ // src/tokens/harnesses/engine.ts
2607
+ async function readFile4(harness, cache, file) {
2608
+ const counting = harness.counting;
2609
+ if (counting.mode === "custom") throw new Error("custom counting is read whole-history, not per file");
2610
+ if (counting.mode === "whole-file") {
2611
+ return cache.readWhenChanged(file, async (raw) => counting.parse(raw, file));
2612
+ }
2613
+ const state = await cache.readIncremental(file, counting.fold);
2614
+ const notices = counting.notices?.(state) ?? [];
2615
+ return { families: counting.families(state), ...notices.length > 0 ? { notices } : {} };
2616
+ }
2617
+ async function count(harness) {
2618
+ const root = harness.root();
2619
+ const files = await harness.discover(root);
2620
+ const cache = await ScanCache.open(harness.id);
2621
+ const byFamily = /* @__PURE__ */ new Map();
2622
+ const notices = /* @__PURE__ */ new Set();
2623
+ const add = (id, families) => {
2624
+ const prefixed = `${harness.id}:${id}`;
2625
+ for (const [family, totals] of Object.entries(families)) {
2626
+ if (!totals) continue;
2627
+ const conversations = byFamily.get(family) ?? /* @__PURE__ */ new Map();
2628
+ const acc = conversations.get(prefixed) ?? { input: 0, output: 0 };
2629
+ acc.input += totals.input;
2630
+ acc.output += totals.output;
2631
+ conversations.set(prefixed, acc);
2632
+ byFamily.set(family, conversations);
2236
2633
  }
2237
- }
2238
- return out;
2634
+ };
2635
+ if (harness.counting.mode === "custom") {
2636
+ const reading = await harness.counting.read(cache, files, root);
2637
+ await cache.save();
2638
+ for (const notice of reading.notices ?? []) notices.add(notice);
2639
+ for (const [id, families] of reading.byConversation) add(id, families);
2640
+ return { byFamily, notices: [...notices] };
2641
+ }
2642
+ const readings = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => readFile4(harness, cache, f));
2643
+ await cache.save();
2644
+ files.forEach((file, i) => {
2645
+ const reading = readings[i];
2646
+ for (const notice of reading.notices ?? []) notices.add(notice);
2647
+ add(harness.conversationId(file, root), reading.families);
2648
+ });
2649
+ return { byFamily, notices: [...notices] };
2650
+ }
2651
+ async function probe(harness) {
2652
+ const dir = harness.root();
2653
+ const exists = await fs10.stat(dir).then((st) => st.isDirectory()).catch(() => false);
2654
+ if (!exists) return { harness, dir, exists: false, projects: 0, transcripts: 0 };
2655
+ const entries = await fs10.readdir(dir, { withFileTypes: true }).catch(() => []);
2656
+ const projects = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).length;
2657
+ const files = await harness.discover(dir).catch((e) => {
2658
+ if (e instanceof SourceRootMissing) return [];
2659
+ return [];
2660
+ });
2661
+ return { harness, dir, exists: true, projects, transcripts: files.length };
2239
2662
  }
2240
2663
 
2241
2664
  // src/tokens/race-tokens.ts
@@ -2270,56 +2693,48 @@ async function scanWithTimeout(scan, timeoutMs, describeTimeout) {
2270
2693
  clearTimeout(timer);
2271
2694
  }
2272
2695
  }
2273
- var SCALAR_READERS = {
2274
- claude: sumTokens,
2275
- codex: sumCodexTokens,
2276
- gemini: sumGeminiTokens
2277
- };
2278
- var BY_CONVERSATION_READERS = {
2279
- claude: sumTokensByConversation,
2280
- codex: sumCodexByConversation,
2281
- gemini: sumGeminiByConversation
2282
- };
2283
- function scoreFor(race, t) {
2284
- return race.counts_input ? t.input + t.output : t.output;
2285
- }
2286
- async function readAllSources(race, primary, progress) {
2287
- progress?.begin(primary);
2288
- const primaryScan = BY_CONVERSATION_READERS[primary]().then(
2289
- (map) => ({ ok: true, map }),
2290
- (err) => ({ ok: false, err })
2291
- ).finally(() => progress?.end(primary));
2292
- const secondaryKeys = MODEL_KEYS.filter((k) => k !== primary);
2293
- const secondaryScans = secondaryKeys.map((k) => {
2294
- progress?.begin(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));
2301
- });
2302
- const [primaryResult, secondaryValues] = await Promise.all([
2303
- primaryScan,
2304
- Promise.all(secondaryScans)
2305
- ]);
2306
- const primaryByConv = /* @__PURE__ */ new Map();
2307
- if (primaryResult.ok) {
2308
- for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2309
- } else if (!(primaryResult.err instanceof SourceRootMissing)) {
2310
- const err = primaryResult.err;
2311
- return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
2312
- }
2313
- const secondary = { claude: 0, codex: 0, gemini: 0 };
2314
- secondaryKeys.forEach((k, i) => {
2315
- secondary[k] = secondaryValues[i] ?? 0;
2696
+ function emptyByFamily() {
2697
+ return { anthropic: /* @__PURE__ */ new Map(), openai: /* @__PURE__ */ new Map(), google: /* @__PURE__ */ new Map() };
2698
+ }
2699
+ function scoreFor(t) {
2700
+ return t.input + t.output;
2701
+ }
2702
+ async function readAllSources(progress) {
2703
+ const enabled = enabledHarnesses(await loadPrefs());
2704
+ const scans = enabled.map((key) => {
2705
+ progress?.begin(key);
2706
+ return count(HARNESSES[key]).then((result) => ({ ok: true, key, result })).catch((err) => ({ ok: false, key, err })).finally(() => progress?.end(key));
2316
2707
  });
2317
- return { secondary, primaryByConv };
2708
+ const results = await Promise.all(scans);
2709
+ const byFamily = emptyByFamily();
2710
+ const degraded = [];
2711
+ const notices = /* @__PURE__ */ new Set();
2712
+ for (const outcome of results) {
2713
+ const harness = HARNESSES[outcome.key];
2714
+ if (!outcome.ok) {
2715
+ if (outcome.err instanceof SourceRootMissing) continue;
2716
+ const message = outcome.err?.message ?? String(outcome.err);
2717
+ logWarn("scan.source.err", { harness: outcome.key, message });
2718
+ degraded.push({ harness: outcome.key, label: harness.label, message });
2719
+ continue;
2720
+ }
2721
+ mergeInto(byFamily, outcome.result);
2722
+ for (const notice of outcome.result.notices) notices.add(notice);
2723
+ }
2724
+ return { byFamily, degraded, notices: [...notices] };
2725
+ }
2726
+ function mergeInto(byFamily, result) {
2727
+ for (const family of MODEL_FAMILIES) {
2728
+ const conversations = result.byFamily.get(family);
2729
+ if (!conversations) continue;
2730
+ const target = byFamily[family];
2731
+ for (const [id, totals] of conversations) target.set(id, scoreFor(totals));
2732
+ }
2318
2733
  }
2319
2734
 
2320
2735
  // src/tokens/scan-progress.ts
2321
2736
  function skipVar(key) {
2322
- return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2737
+ return HARNESSES[key].overrideVar;
2323
2738
  }
2324
2739
  function formatBytes(bytes) {
2325
2740
  if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
@@ -2353,45 +2768,30 @@ async function diagnoseScanTimeout(timeoutMs, progress) {
2353
2768
  return describeScanTimeout(timeoutMs, outstanding);
2354
2769
  }
2355
2770
 
2356
- // src/tokens/primary-cap.ts
2357
- var PRIMARY_TOP_CONVERSATIONS = 5;
2358
- function primaryConversationCap(enabled) {
2359
- return enabled ? PRIMARY_TOP_CONVERSATIONS : Infinity;
2360
- }
2361
-
2362
2771
  // src/tokens/race-score.ts
2363
2772
  var STALL_THRESHOLD = 5;
2364
- function zero() {
2365
- return { claude: 0, codex: 0, gemini: 0 };
2773
+ function cloneAnchors(a) {
2774
+ return { anthropic: { ...a.anthropic }, openai: { ...a.openai }, google: { ...a.google } };
2366
2775
  }
2367
2776
  var RaceScoreTracker = class {
2368
- acked;
2369
- lastGood;
2370
- primaryConvAcked;
2371
- primaryConvLast;
2777
+ convAcked;
2778
+ convLast;
2372
2779
  counted;
2373
2780
  seq;
2374
2781
  stalls = 0;
2375
2782
  lastStall = null;
2376
- primaryEmptyBeats = 0;
2377
- primary;
2378
- primaryTop5;
2379
- constructor(init, primary, primaryTop5) {
2380
- this.acked = { ...init.acked };
2381
- this.lastGood = { ...init.lastGood };
2382
- this.primaryConvAcked = { ...init.primaryConvAcked };
2383
- this.primaryConvLast = { ...init.primaryConvAcked };
2384
- this.counted = init.primaryCounted;
2783
+ emptyBeats = 0;
2784
+ constructor(init) {
2785
+ this.convAcked = cloneAnchors(init.convAcked);
2786
+ this.convLast = cloneAnchors(init.convAcked);
2787
+ this.counted = { ...init.counted };
2385
2788
  this.seq = init.seq;
2386
- this.primary = primary;
2387
- this.primaryTop5 = primaryTop5;
2388
2789
  }
2389
2790
  /**
2390
2791
  * Record a scan result.
2391
2792
  * - `null` or a `{ stall }` reading → stall (warning), anchors untouched. A
2392
2793
  * stall reading also captures its cause for the UI.
2393
- * - otherwise → secondaries advance scalar lastGood (never down to 0); the
2394
- * primary's per-conversation latest readings are updated (monotonic).
2794
+ * - otherwise → every model's per-conversation readings advance (monotonic).
2395
2795
  */
2396
2796
  recordReading(reading) {
2397
2797
  if (reading === null || isStall(reading)) {
@@ -2401,181 +2801,91 @@ var RaceScoreTracker = class {
2401
2801
  }
2402
2802
  this.stalls = 0;
2403
2803
  this.lastStall = null;
2404
- for (const key of MODEL_KEYS) {
2405
- if (key === this.primary) continue;
2406
- const v = reading.secondary[key];
2407
- if (v > 0) this.lastGood[key] = v;
2408
- }
2409
- this.primaryEmptyBeats = reading.primaryByConv.size === 0 ? this.primaryEmptyBeats + 1 : 0;
2410
- for (const [id, v] of reading.primaryByConv) {
2411
- const prev = this.primaryConvLast[id] ?? 0;
2412
- if (v > prev) this.primaryConvLast[id] = v;
2804
+ const anyConversations = MODEL_FAMILIES.some((family) => reading.byFamily[family].size > 0);
2805
+ this.emptyBeats = anyConversations ? 0 : this.emptyBeats + 1;
2806
+ for (const family of MODEL_FAMILIES) {
2807
+ for (const [id, value] of reading.byFamily[family]) {
2808
+ const prev = this.convLast[family][id] ?? 0;
2809
+ if (value > prev) this.convLast[family][id] = value;
2810
+ }
2413
2811
  }
2414
2812
  }
2415
2813
  /** Frozen payload for the next heartbeat. Pure — call repeatedly for retries. */
2416
2814
  nextBeat() {
2417
- const components = zero();
2418
- for (const key of MODEL_KEYS) {
2419
- if (key === this.primary) continue;
2420
- components[key] = Math.max(0, this.lastGood[key] - this.acked[key]);
2421
- }
2422
- const pending = [];
2423
- for (const [id, last] of Object.entries(this.primaryConvLast)) {
2424
- const d = Math.max(0, last - (this.primaryConvAcked[id] ?? 0));
2425
- if (d > 0) pending.push(d);
2815
+ const components = zeroPerFamily();
2816
+ for (const family of MODEL_FAMILIES) {
2817
+ let sum = 0;
2818
+ for (const [id, last] of Object.entries(this.convLast[family])) {
2819
+ sum += Math.max(0, last - (this.convAcked[family][id] ?? 0));
2820
+ }
2821
+ components[family] = sum;
2426
2822
  }
2427
- pending.sort((a, b) => b - a);
2428
- const cap = primaryConversationCap(this.primaryTop5);
2429
- const take = cap === Infinity ? pending.length : Math.min(cap, pending.length);
2430
- let primarySum = 0;
2431
- for (const d of pending.slice(0, take)) primarySum += d;
2432
- components[this.primary] = primarySum;
2433
2823
  return {
2434
2824
  seq: this.seq + 1,
2435
2825
  components,
2436
- readings: { ...this.lastGood },
2437
- primaryConvReadings: { ...this.primaryConvLast }
2826
+ convReadings: cloneAnchors(this.convLast)
2438
2827
  };
2439
2828
  }
2440
2829
  /** Commit a heartbeat the server accepted. `serverLastSeq` self-heals drift. */
2441
2830
  ack(snapshot, serverLastSeq) {
2442
- for (const key of MODEL_KEYS) {
2443
- if (key === this.primary) continue;
2444
- this.acked[key] = snapshot.readings[key];
2445
- }
2446
- this.primaryConvAcked = { ...snapshot.primaryConvReadings };
2447
- this.counted += snapshot.components[this.primary];
2831
+ this.convAcked = cloneAnchors(snapshot.convReadings);
2832
+ for (const family of MODEL_FAMILIES) this.counted[family] += snapshot.components[family];
2448
2833
  this.seq = Math.max(snapshot.seq, serverLastSeq);
2449
2834
  }
2450
2835
  /** Pin anchors to the latest readings so the next deltas are 0 (pending race). */
2451
2836
  reprime() {
2452
- for (const key of MODEL_KEYS) {
2453
- if (key === this.primary) continue;
2454
- this.acked[key] = this.lastGood[key];
2455
- }
2456
- this.primaryConvAcked = { ...this.primaryConvLast };
2837
+ this.convAcked = cloneAnchors(this.convLast);
2457
2838
  }
2458
2839
  get stalled() {
2459
2840
  return this.stalls >= STALL_THRESHOLD;
2460
2841
  }
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;
2842
+ /** No source has produced any conversations for long enough to be worth saying. */
2843
+ get sourcesSilent() {
2844
+ return this.emptyBeats >= SILENT_THRESHOLD;
2464
2845
  }
2465
2846
  /** Human-readable cause of the most recent stall (null once a good read recovers). */
2466
2847
  get stallReason() {
2467
2848
  return this.lastStall;
2468
2849
  }
2469
- /** Cumulative primary tokens credited so far (for the UI's primary "since join" row). */
2470
- primaryCounted() {
2471
- return this.counted;
2850
+ /** Cumulative tokens credited per family since joining (for the UI's model rows). */
2851
+ countedPerFamily() {
2852
+ return { ...this.counted };
2472
2853
  }
2473
- /** Secondary "since join" totals = lastGood − baseline (for the UI). Primary key is 0 here. */
2474
- secondarySinceJoin(baseline) {
2475
- const out = zero();
2476
- for (const key of MODEL_KEYS) {
2477
- if (key === this.primary) continue;
2478
- out[key] = Math.max(0, this.lastGood[key] - baseline[key]);
2479
- }
2480
- return out;
2854
+ /** Cumulative tokens credited across every family. */
2855
+ countedTotal() {
2856
+ let total = 0;
2857
+ for (const family of MODEL_FAMILIES) total += this.counted[family];
2858
+ return total;
2481
2859
  }
2482
2860
  toState() {
2483
2861
  return {
2484
- acked: { ...this.acked },
2485
- lastGood: { ...this.lastGood },
2486
- primaryConvAcked: { ...this.primaryConvAcked },
2487
- primaryCounted: this.counted,
2862
+ convAcked: cloneAnchors(this.convAcked),
2863
+ counted: { ...this.counted },
2488
2864
  seq: this.seq
2489
2865
  };
2490
2866
  }
2491
2867
  };
2492
2868
 
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
-
2562
2869
  // src/runtime/run-race.tsx
2563
- import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2870
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
2564
2871
  function RunRace({ active, initialState, pendingMode, ownUserName }) {
2565
2872
  const { exit } = useApp();
2566
- const [race, setRace] = useState5(null);
2567
- const [lastHbAt, setLastHbAt] = useState5(null);
2568
- const [lastHbOk, setLastHbOk] = useState5(true);
2569
- const [tickNow, setTickNow] = useState5(/* @__PURE__ */ new Date());
2570
- const [fatalError, setFatalError] = useState5(null);
2571
- const [achievements, setAchievements] = useState5([]);
2873
+ const [race, setRace] = useState4(null);
2874
+ const [lastHbAt, setLastHbAt] = useState4(null);
2875
+ const [lastHbOk, setLastHbOk] = useState4(true);
2876
+ const [tickNow, setTickNow] = useState4(/* @__PURE__ */ new Date());
2877
+ const [fatalError, setFatalError] = useState4(null);
2878
+ const [achievements, setAchievements] = useState4([]);
2572
2879
  const shownAchievementAtRef = useRef(0);
2573
- const trackerRef = useRef(new RaceScoreTracker(initialState, active.primary_model, active.primary_top5 ?? false));
2880
+ const trackerRef = useRef(new RaceScoreTracker(initialState));
2574
2881
  const pendingRef = useRef(pendingMode);
2575
2882
  const ctrl = useRef(new AbortController());
2576
- const [stalled, setStalled] = useState5(false);
2577
- const [stallReason, setStallReason] = useState5(null);
2578
- const [primarySilent, setPrimarySilent] = useState5(false);
2883
+ const [stalled, setStalled] = useState4(false);
2884
+ const [stallReason, setStallReason] = useState4(null);
2885
+ const [sourcesSilent, setSourcesSilent] = useState4(false);
2886
+ const [degraded, setDegraded] = useState4([]);
2887
+ const [notices, setNotices] = useState4([]);
2888
+ const [disabledHarnesses, setDisabledHarnesses] = useState4([]);
2579
2889
  useEffect2(() => {
2580
2890
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
2581
2891
  return () => clearInterval(t);
@@ -2592,7 +2902,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2592
2902
  const progress = new ScanProgress();
2593
2903
  try {
2594
2904
  return await scanWithTimeout(
2595
- () => readAllSources(active, active.primary_model, progress),
2905
+ () => readAllSources(progress),
2596
2906
  SCAN_TIMEOUT_MS,
2597
2907
  () => diagnoseScanTimeout(SCAN_TIMEOUT_MS, progress)
2598
2908
  );
@@ -2607,7 +2917,11 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2607
2917
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2608
2918
  setStalled(tracker.stalled);
2609
2919
  setStallReason(tracker.stalled ? tracker.stallReason : null);
2610
- setPrimarySilent(tracker.primarySilent);
2920
+ setSourcesSilent(tracker.sourcesSilent);
2921
+ setDegraded(isStall(reading) ? [] : reading.degraded);
2922
+ setNotices(isStall(reading) ? [] : reading.notices);
2923
+ const prefs = await loadPrefs();
2924
+ setDisabledHarnesses(HARNESS_KEYS.filter((k) => !isHarnessEnabled(prefs, k)));
2611
2925
  return tracker.nextBeat();
2612
2926
  },
2613
2927
  sendBeat: async (snapshot) => {
@@ -2657,13 +2971,13 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2657
2971
  }, []);
2658
2972
  const lastHeartbeatAgoSec = lastHbAt ? Math.max(0, Math.floor((tickNow.getTime() - lastHbAt.getTime()) / 1e3)) : null;
2659
2973
  if (fatalError) {
2660
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", padding: 1, children: [
2661
- /* @__PURE__ */ jsx8(Text8, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
2662
- /* @__PURE__ */ jsx8(Text8, { children: fatalError })
2974
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", padding: 1, children: [
2975
+ /* @__PURE__ */ jsx7(Text7, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
2976
+ /* @__PURE__ */ jsx7(Text7, { children: fatalError })
2663
2977
  ] });
2664
2978
  }
2665
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
2666
- /* @__PURE__ */ jsx8(
2979
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
2980
+ /* @__PURE__ */ jsx7(
2667
2981
  StatusScreen,
2668
2982
  {
2669
2983
  race,
@@ -2675,28 +2989,29 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2675
2989
  lastHeartbeatOk: lastHbOk,
2676
2990
  stalled,
2677
2991
  stallReason,
2678
- primarySilent,
2679
- primarySourceDir: sourceDir(active.primary_model),
2680
- primaryModel: active.primary_model
2992
+ sourcesSilent,
2993
+ degraded,
2994
+ notices,
2995
+ disabledHarnesses
2681
2996
  }
2682
2997
  ),
2683
- achievements.length > 0 && /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", marginTop: 1, children: [
2684
- /* @__PURE__ */ jsx8(Text8, { bold: true, children: "Achievements" }),
2998
+ achievements.length > 0 && /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
2999
+ /* @__PURE__ */ jsx7(Text7, { bold: true, children: "Achievements" }),
2685
3000
  achievements.map(({ key, event }) => {
2686
- const description = describeAchievement(event, active);
2687
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "row", children: [
2688
- /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
3001
+ const description = describeAchievement(event);
3002
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "row", children: [
3003
+ /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
2689
3004
  " ",
2690
3005
  formatClockTime(event.at),
2691
3006
  " "
2692
3007
  ] }),
2693
- /* @__PURE__ */ jsxs6(Text8, { color: "yellow", bold: true, children: [
3008
+ /* @__PURE__ */ jsxs5(Text7, { color: "yellow", bold: true, children: [
2694
3009
  "+",
2695
3010
  event.xp,
2696
3011
  " XP "
2697
3012
  ] }),
2698
- /* @__PURE__ */ jsx8(Text8, { children: event.name }),
2699
- /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
3013
+ /* @__PURE__ */ jsx7(Text7, { children: event.name }),
3014
+ /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
2700
3015
  " \u2014 ",
2701
3016
  description
2702
3017
  ] })
@@ -2722,28 +3037,81 @@ function raceViewFrom(resp) {
2722
3037
  };
2723
3038
  }
2724
3039
  async function buildInitialState(args) {
2725
- let secondary = { claude: 0, codex: 0, gemini: 0 };
2726
- const primaryConvAcked = {};
3040
+ const convAcked = { anthropic: {}, openai: {}, google: {} };
2727
3041
  try {
2728
- const now = await readAllSources(args.active, args.active.primary_model);
3042
+ const now = await readAllSources();
2729
3043
  if (!isStall(now)) {
2730
- secondary = now.secondary;
2731
- for (const [id, v] of now.primaryByConv) primaryConvAcked[id] = v;
3044
+ for (const family of MODEL_FAMILIES) {
3045
+ for (const [id, value] of now.byFamily[family]) convAcked[family][id] = value;
3046
+ }
2732
3047
  }
2733
3048
  } catch {
2734
3049
  }
2735
3050
  return {
2736
3051
  initialState: {
2737
- acked: { ...secondary },
2738
- lastGood: { ...secondary },
2739
- primaryConvAcked,
2740
- primaryCounted: 0,
3052
+ convAcked,
3053
+ counted: zeroPerFamily(),
2741
3054
  seq: args.serverLastSeq
2742
3055
  },
2743
3056
  pendingMode: args.raceStatus === "pending"
2744
3057
  };
2745
3058
  }
2746
3059
 
3060
+ // src/tokens/source-probe.ts
3061
+ async function probeAll() {
3062
+ const enabled = enabledHarnesses(await loadPrefs());
3063
+ return Promise.all(enabled.map((key) => probe(HARNESSES[key])));
3064
+ }
3065
+ async function confirmNoSources(opts) {
3066
+ if (opts.probes.some((p) => p.transcripts > 0)) return true;
3067
+ opts.warn(opts.probes.length === 0 ? describeAllDisabled() : describeNoSources(opts.probes));
3068
+ if (!opts.interactive) return true;
3069
+ return opts.ask();
3070
+ }
3071
+ function describeNoSources(probes) {
3072
+ const lines = [
3073
+ `\u26A0 No transcripts found for any coding agent \u2014 your horse will not move.`,
3074
+ ``
3075
+ ];
3076
+ for (const p of probes) {
3077
+ const label = p.harness.label;
3078
+ lines.push(` ${label}: ${p.dir}`, ` ${" ".repeat(label.length)} (${reasonFor(p)})`);
3079
+ if (p.exists && p.projects > 0) {
3080
+ lines.push(
3081
+ ` Has history in it, so this is usually a dangling symlink or a`,
3082
+ ` permissions problem. To find dangling links:`,
3083
+ ` find ${p.dir} -type l ! -exec test -e {} \\; -print`
3084
+ );
3085
+ }
3086
+ for (const hint of p.harness.hints ?? []) lines.push(` ${hint}`);
3087
+ }
3088
+ lines.push(
3089
+ ``,
3090
+ ` Token Derby counts usage from this machine's own filesystem. If your`,
3091
+ ` coding agent runs in a container, over SSH, or on another machine, join`,
3092
+ ` the race from there instead.`,
3093
+ ``,
3094
+ ` To read them from somewhere else, set the matching directory override:`,
3095
+ ` ${HARNESS_KEYS.map((k) => HARNESSES[k].overrideVar).join(" ")}`
3096
+ );
3097
+ return lines.join("\n");
3098
+ }
3099
+ function describeAllDisabled() {
3100
+ return [
3101
+ `\u26A0 Every coding agent is turned off \u2014 your horse will not move.`,
3102
+ ``,
3103
+ ` Turn one back on with: token-derby harness enable <id>`,
3104
+ ` See what is available: token-derby harness list`
3105
+ ].join("\n");
3106
+ }
3107
+ function reasonFor(probe2) {
3108
+ if (!probe2.exists) return "does not exist";
3109
+ if (probe2.projects > 0) {
3110
+ return `holds ${probe2.projects} project ${probe2.projects === 1 ? "directory" : "directories"}, none of which could be read`;
3111
+ }
3112
+ return "exists, but holds no transcripts";
3113
+ }
3114
+
2747
3115
  // src/ui/prompt.ts
2748
3116
  async function promptYesNo(question, opts = {}) {
2749
3117
  const input = opts.input ?? process.stdin;
@@ -2767,31 +3135,12 @@ function resetStdinAfterInk() {
2767
3135
  }
2768
3136
 
2769
3137
  // src/commands/join.ts
2770
- function parsePrimaryFlag(argv) {
2771
- for (let i = 0; i < argv.length; i++) {
2772
- const a = argv[i];
2773
- let value;
2774
- if (a === "--primary") value = argv[i + 1];
2775
- else if (a.startsWith("--primary=")) value = a.slice("--primary=".length);
2776
- else continue;
2777
- if (!isModelKey(value)) throw new Error(`--primary must be one of claude, codex, gemini (got ${value ?? ""})`);
2778
- return value;
2779
- }
2780
- return null;
2781
- }
2782
3138
  async function joinCommand(joinCode, argv = []) {
2783
3139
  if (!joinCode) {
2784
3140
  console.error("Usage: token-derby join <join-code>");
2785
3141
  return 2;
2786
3142
  }
2787
3143
  const code = joinCode.toUpperCase();
2788
- let primaryFlag;
2789
- try {
2790
- primaryFlag = parsePrimaryFlag(argv);
2791
- } catch (e) {
2792
- console.error(e.message);
2793
- return 2;
2794
- }
2795
3144
  const identity = await loadIdentity();
2796
3145
  if (!identity) {
2797
3146
  console.error("Run `token-derby login` to set up your identity.");
@@ -2846,7 +3195,27 @@ async function joinCommand(joinCode, argv = []) {
2846
3195
  console.error("Your stable is empty. Run `token-derby stable create` first.");
2847
3196
  return 1;
2848
3197
  }
2849
- const picked = await pickHorse(horses);
3198
+ const choice = await resolveHorse(horses, {
3199
+ name: parseFlag(argv, "--horse"),
3200
+ pick: hasFlag(argv, "--pick")
3201
+ });
3202
+ if (choice.kind === "not_found") {
3203
+ console.error(`No horse named "${choice.name}" in your stable.`);
3204
+ console.error(`Your stable: ${horses.map((h) => h.name).join(", ")}`);
3205
+ return 1;
3206
+ }
3207
+ if (choice.kind === "no_tty") {
3208
+ console.error(noTtyMessage("token-derby join"));
3209
+ return 1;
3210
+ }
3211
+ let picked;
3212
+ if (choice.kind === "resolved") {
3213
+ picked = choice.horse;
3214
+ const notice = noticeFor(choice);
3215
+ if (notice) console.log(notice);
3216
+ } else {
3217
+ picked = await pickHorse(horses);
3218
+ }
2850
3219
  if (!picked) {
2851
3220
  console.log("Cancelled.");
2852
3221
  return 1;
@@ -2855,14 +3224,8 @@ async function joinCommand(joinCode, argv = []) {
2855
3224
  chosenName = picked.name;
2856
3225
  chosenColors = picked.colors;
2857
3226
  }
2858
- let chosenPrimary = "claude";
2859
- if (!ownHorse) {
2860
- if (primaryFlag) chosenPrimary = primaryFlag;
2861
- else if (process.stdout.isTTY) chosenPrimary = await pickPrimary();
2862
- }
2863
- const effectivePrimary = ownHorse?.primary_model ?? chosenPrimary;
2864
- const proceed = await confirmEmptySource({
2865
- probe: await probeSource(effectivePrimary),
3227
+ const proceed = await confirmNoSources({
3228
+ probes: await probeAll(),
2866
3229
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
2867
3230
  warn: (text) => console.error(`
2868
3231
  ${text}
@@ -2875,7 +3238,7 @@ ${text}
2875
3238
  }
2876
3239
  let joinResp;
2877
3240
  try {
2878
- joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId, primary_model: chosenPrimary });
3241
+ joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId });
2879
3242
  } catch (e) {
2880
3243
  if (e instanceof ApiError) {
2881
3244
  if (e.code === "RACE_FULL") console.error("This race is full.");
@@ -2901,27 +3264,22 @@ ${text}
2901
3264
  horse_colors: chosenColors,
2902
3265
  joined_at: ownHorse?.joined_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2903
3266
  last_heartbeat_at: (/* @__PURE__ */ new Date(0)).toISOString(),
2904
- primary_model: joinResp.primary_model,
2905
3267
  score: {
2906
- acked: { claude: 0, codex: 0, gemini: 0 },
2907
- lastGood: { claude: 0, codex: 0, gemini: 0 },
2908
- primaryConvAcked: {},
2909
- primaryCounted: 0,
3268
+ convAcked: { anthropic: {}, openai: {}, google: {} },
3269
+ counted: { anthropic: 0, openai: 0, google: 0 },
2910
3270
  seq: ownHorse?.last_seq ?? 0
2911
- },
2912
- ...race.counts_input ? { counts_input: true } : {},
2913
- ...race.primary_top5 ? { primary_top5: true } : {}
3271
+ }
2914
3272
  };
2915
3273
  await saveActiveRace(active);
2916
3274
  const initial = await buildInitialState({ active, raceStatus: status, serverLastSeq: ownHorse?.last_seq ?? 0 });
2917
- const app = render4(React9.createElement(RunRace, { active, initialState: initial.initialState, pendingMode: initial.pendingMode, ownUserName: identity.display_name }));
3275
+ const app = render4(React5.createElement(RunRace, { active, initialState: initial.initialState, pendingMode: initial.pendingMode, ownUserName: identity.display_name }));
2918
3276
  await app.waitUntilExit();
2919
3277
  return 0;
2920
3278
  }
2921
3279
  async function pickHorse(horses) {
2922
3280
  return new Promise((resolve) => {
2923
3281
  const app = render4(
2924
- React9.createElement(HorsePicker, {
3282
+ React5.createElement(HorsePicker, {
2925
3283
  horses,
2926
3284
  onPick: (h) => {
2927
3285
  app.unmount();
@@ -2935,18 +3293,6 @@ async function pickHorse(horses) {
2935
3293
  );
2936
3294
  });
2937
3295
  }
2938
- async function pickPrimary() {
2939
- return new Promise((resolve) => {
2940
- const app = render4(
2941
- React9.createElement(PrimaryPicker, {
2942
- onPick: (m) => {
2943
- app.unmount();
2944
- resolve(m);
2945
- }
2946
- })
2947
- );
2948
- });
2949
- }
2950
3296
 
2951
3297
  // src/commands/end.ts
2952
3298
  import * as readline3 from "readline/promises";
@@ -3671,16 +4017,16 @@ function runNpmUpgrade(spawnImpl) {
3671
4017
  }
3672
4018
 
3673
4019
  // src/commands/roll.ts
3674
- import React14 from "react";
4020
+ import React7 from "react";
3675
4021
  import { render as render6 } from "ink";
3676
4022
 
3677
4023
  // src/ui/RollHorsePicker.tsx
3678
- import { useState as useState6 } from "react";
3679
- import { Box as Box9, Text as Text9, useInput as useInput4 } from "ink";
3680
- import { jsx as jsx9, jsxs as jsxs7 } from "react/jsx-runtime";
4024
+ import { useState as useState5 } from "react";
4025
+ import { Box as Box8, Text as Text8, useInput as useInput3 } from "ink";
4026
+ import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
3681
4027
  function RollHorsePicker({ horses, onPick, onCancel }) {
3682
- const [idx, setIdx] = useState6(0);
3683
- useInput4((input, key) => {
4028
+ const [idx, setIdx] = useState5(0);
4029
+ useInput3((input, key) => {
3684
4030
  if (key.escape) {
3685
4031
  onCancel();
3686
4032
  return;
@@ -3699,61 +4045,61 @@ function RollHorsePicker({ horses, onPick, onCancel }) {
3699
4045
  return;
3700
4046
  }
3701
4047
  });
3702
- return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", children: [
3703
- /* @__PURE__ */ jsx9(Text9, { children: "Pick a horse to roll for:" }),
3704
- horses.map((h, i) => /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", children: [
3705
- /* @__PURE__ */ jsx9(Box9, { flexDirection: "row", children: /* @__PURE__ */ jsxs7(Text9, { children: [
4048
+ return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
4049
+ /* @__PURE__ */ jsx8(Text8, { children: "Pick a horse to roll for:" }),
4050
+ horses.map((h, i) => /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
4051
+ /* @__PURE__ */ jsx8(Box8, { flexDirection: "row", children: /* @__PURE__ */ jsxs6(Text8, { children: [
3706
4052
  i === idx ? "\u25BA" : " ",
3707
4053
  " ",
3708
4054
  h.name,
3709
4055
  " ",
3710
- /* @__PURE__ */ jsxs7(Text9, { color: "cyan", children: [
4056
+ /* @__PURE__ */ jsxs6(Text8, { color: "cyan", children: [
3711
4057
  "[Lvl. ",
3712
4058
  levelFromXp(h.xp),
3713
4059
  "]"
3714
4060
  ] }),
3715
4061
  " ",
3716
- /* @__PURE__ */ jsxs7(Text9, { color: "yellow", children: [
4062
+ /* @__PURE__ */ jsxs6(Text8, { color: "yellow", children: [
3717
4063
  "\u2014 ",
3718
4064
  h.pending,
3719
4065
  " roll",
3720
4066
  h.pending === 1 ? "" : "s"
3721
4067
  ] })
3722
4068
  ] }) }),
3723
- /* @__PURE__ */ jsxs7(Box9, { flexDirection: "row", children: [
3724
- /* @__PURE__ */ jsx9(Text9, { children: " " }),
3725
- /* @__PURE__ */ jsx9(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
4069
+ /* @__PURE__ */ jsxs6(Box8, { flexDirection: "row", children: [
4070
+ /* @__PURE__ */ jsx8(Text8, { children: " " }),
4071
+ /* @__PURE__ */ jsx8(HorseSprite, { sprite: MINI_SPRITE, colors: h.colors })
3726
4072
  ] })
3727
4073
  ] }, h.stable_horse_id)),
3728
- /* @__PURE__ */ jsx9(Box9, { marginTop: 1, children: /* @__PURE__ */ jsx9(Text9, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
4074
+ /* @__PURE__ */ jsx8(Box8, { marginTop: 1, children: /* @__PURE__ */ jsx8(Text8, { dimColor: true, children: "\u2191/\u2193 choose \xB7 Enter pick \xB7 Esc cancel" }) })
3729
4075
  ] });
3730
4076
  }
3731
4077
 
3732
4078
  // src/ui/reveal.ts
3733
- import React13 from "react";
4079
+ import React6 from "react";
3734
4080
  import { render as render5 } from "ink";
3735
4081
 
3736
4082
  // src/ui/RollReveal.tsx
3737
- import { useState as useState8, useEffect as useEffect4, useMemo } from "react";
3738
- import { Box as Box11, Text as Text11 } from "ink";
4083
+ import { useState as useState7, useEffect as useEffect4, useMemo } from "react";
4084
+ import { Box as Box10, Text as Text10 } from "ink";
3739
4085
 
3740
4086
  // src/ui/HatSprite.tsx
3741
- import { useEffect as useEffect3, useState as useState7 } from "react";
3742
- import { Box as Box10, Text as Text10 } from "ink";
3743
- import { jsx as jsx10 } from "react/jsx-runtime";
4087
+ import { useEffect as useEffect3, useState as useState6 } from "react";
4088
+ import { Box as Box9, Text as Text9 } from "ink";
4089
+ import { jsx as jsx9 } from "react/jsx-runtime";
3744
4090
  function HatSprite({ hat, variant, centerIn }) {
3745
4091
  const colors = hatColors(hat, variant ?? 0);
3746
4092
  const grid = makeHatGrid(hat, colors, centerIn);
3747
4093
  const lines = hexGridToHalfBlocks(grid);
3748
- return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx10(Text10, { children: line }, i)) });
4094
+ return /* @__PURE__ */ jsx9(Box9, { flexDirection: "column", children: lines.map((line, i) => /* @__PURE__ */ jsx9(Text9, { children: line }, i)) });
3749
4095
  }
3750
4096
  function AnimatedHatSprite({ hat, variant, centerIn }) {
3751
4097
  if (!isAnimatedHat(hat)) {
3752
- return /* @__PURE__ */ jsx10(HatSprite, { hat, variant, centerIn });
4098
+ return /* @__PURE__ */ jsx9(HatSprite, { hat, variant, centerIn });
3753
4099
  }
3754
4100
  const frames = hat.animation.frames;
3755
4101
  const fps = hat.animation.fps;
3756
- const [idx, setIdx] = useState7(0);
4102
+ const [idx, setIdx] = useState6(0);
3757
4103
  useEffect3(() => {
3758
4104
  if (frames.length <= 1) return;
3759
4105
  const interval = setInterval(
@@ -3763,7 +4109,7 @@ function AnimatedHatSprite({ hat, variant, centerIn }) {
3763
4109
  return () => clearInterval(interval);
3764
4110
  }, [frames.length, fps]);
3765
4111
  const framed = { ...hat, colors: { ...hat.colors, A: frames[idx] } };
3766
- return /* @__PURE__ */ jsx10(HatSprite, { hat: framed, variant, centerIn });
4112
+ return /* @__PURE__ */ jsx9(HatSprite, { hat: framed, variant, centerIn });
3767
4113
  }
3768
4114
  function makeHatGrid(hat, colors, centerIn) {
3769
4115
  const w = centerIn?.w ?? hat.width;
@@ -3786,7 +4132,7 @@ function makeHatGrid(hat, colors, centerIn) {
3786
4132
  }
3787
4133
 
3788
4134
  // src/ui/RollReveal.tsx
3789
- import { jsx as jsx11 } from "react/jsx-runtime";
4135
+ import { jsx as jsx10 } from "react/jsx-runtime";
3790
4136
  var RESET3 = "\x1B[0m";
3791
4137
  var BOX_COLOR = "#E5C76B";
3792
4138
  var TIER_PALETTE = {
@@ -3856,13 +4202,13 @@ var BOX_EMPTY = [
3856
4202
  ""
3857
4203
  ].map(pad);
3858
4204
  function GiftBox({ frame, color }) {
3859
- return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: frame.map((line, i) => /* @__PURE__ */ jsx11(Text11, { children: line ? ansiFg(color) + line + RESET3 : line }, i)) });
4205
+ return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: frame.map((line, i) => /* @__PURE__ */ jsx10(Text10, { children: line ? ansiFg(color) + line + RESET3 : line }, i)) });
3860
4206
  }
3861
- function spawnParticles(tier, count, cx, cy) {
4207
+ function spawnParticles(tier, count2, cx, cy) {
3862
4208
  const palette = TIER_PALETTE[tier];
3863
4209
  const out = [];
3864
- for (let i = 0; i < count; i++) {
3865
- const angle = i / count * Math.PI * 2 + (Math.random() - 0.5) * 0.6;
4210
+ for (let i = 0; i < count2; i++) {
4211
+ const angle = i / count2 * Math.PI * 2 + (Math.random() - 0.5) * 0.6;
3866
4212
  const speed = 0.8 + Math.random() * 1.6;
3867
4213
  out.push({
3868
4214
  x: cx,
@@ -3879,7 +4225,7 @@ function ConfettiBurst({ tier }) {
3879
4225
  const cx = Math.floor(SCENE_W / 2);
3880
4226
  const cy = Math.floor(SCENE_H / 2);
3881
4227
  const particles = useMemo(() => spawnParticles(tier, 36, cx, cy), [tier, cx, cy]);
3882
- const [tick, setTick] = useState8(0);
4228
+ const [tick, setTick] = useState7(0);
3883
4229
  useEffect4(() => {
3884
4230
  const i = setInterval(() => setTick((t) => t + 1), 70);
3885
4231
  return () => clearInterval(i);
@@ -3892,13 +4238,13 @@ function ConfettiBurst({ tier }) {
3892
4238
  grid[y][x] = ansiFg(p.color) + p.char + RESET3;
3893
4239
  }
3894
4240
  }
3895
- return /* @__PURE__ */ jsx11(Box11, { flexDirection: "column", children: grid.map((row, y) => /* @__PURE__ */ jsx11(Text11, { children: row.join("") }, y)) });
4241
+ return /* @__PURE__ */ jsx10(Box10, { flexDirection: "column", children: grid.map((row, y) => /* @__PURE__ */ jsx10(Text10, { children: row.join("") }, y)) });
3896
4242
  }
3897
4243
  var CLOSED_HOLD_MS = 3e3;
3898
4244
  function RollReveal({ outcome, onDone }) {
3899
4245
  const isNoHat = outcome.kind === "no_hat";
3900
4246
  const isShowpiece = outcome.kind !== "no_hat" && (outcome.hat.rarity === "legendary" || outcome.hat.rarity === "limited");
3901
- const [phase, setPhase] = useState8("closed");
4247
+ const [phase, setPhase] = useState7("closed");
3902
4248
  useEffect4(() => {
3903
4249
  const timers = [];
3904
4250
  timers.push(setTimeout(() => setPhase("open1"), CLOSED_HOLD_MS));
@@ -3913,21 +4259,21 @@ function RollReveal({ outcome, onDone }) {
3913
4259
  }
3914
4260
  return () => timers.forEach(clearTimeout);
3915
4261
  }, [isNoHat, isShowpiece, onDone]);
3916
- if (phase === "closed") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_CLOSED, color: BOX_COLOR });
3917
- if (phase === "open1") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
3918
- if (phase === "open2") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
3919
- if (phase === "empty") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
4262
+ if (phase === "closed") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_CLOSED, color: BOX_COLOR });
4263
+ if (phase === "open1") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
4264
+ if (phase === "open2") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
4265
+ if (phase === "empty") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
3920
4266
  if (phase === "burst" && outcome.kind !== "no_hat") {
3921
- return /* @__PURE__ */ jsx11(ConfettiBurst, { tier: outcome.hat.rarity });
4267
+ return /* @__PURE__ */ jsx10(ConfettiBurst, { tier: outcome.hat.rarity });
3922
4268
  }
3923
- if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
3924
- 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 } });
4269
+ if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx10(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
4270
+ return isAnimatedHat(outcome.hat) ? /* @__PURE__ */ jsx10(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx10(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
3925
4271
  }
3926
4272
 
3927
4273
  // src/ui/reveal.ts
3928
4274
  async function runReveal(outcome) {
3929
4275
  await new Promise((resolve) => {
3930
- const app = render5(React13.createElement(RollReveal, {
4276
+ const app = render5(React6.createElement(RollReveal, {
3931
4277
  outcome,
3932
4278
  onDone: () => {
3933
4279
  app.unmount();
@@ -3943,7 +4289,7 @@ function pendingFor(horse) {
3943
4289
  const lastRolled = horse.last_rolled_level ?? Math.max(1, level - 1);
3944
4290
  return level - lastRolled;
3945
4291
  }
3946
- async function rollCommand() {
4292
+ async function rollCommand(args = []) {
3947
4293
  let stable;
3948
4294
  try {
3949
4295
  stable = await listStable();
@@ -3959,19 +4305,39 @@ async function rollCommand() {
3959
4305
  console.log("No rolls available. Level up a horse to earn a roll!");
3960
4306
  return 0;
3961
4307
  }
3962
- const picked = await new Promise((resolve) => {
3963
- const app = render6(React14.createElement(RollHorsePicker, {
3964
- horses: eligible,
3965
- onPick: (h) => {
3966
- app.unmount();
3967
- resolve(h);
3968
- },
3969
- onCancel: () => {
3970
- app.unmount();
3971
- resolve(null);
3972
- }
3973
- }));
4308
+ const choice = await resolveHorse(eligible, {
4309
+ name: parseFlag(args, "--horse"),
4310
+ autoSelect: false
3974
4311
  });
4312
+ if (choice.kind === "not_found") {
4313
+ console.error(`No horse named "${choice.name}" has a roll available.`);
4314
+ console.error(`With rolls: ${eligible.map((h) => h.name).join(", ")}`);
4315
+ return 1;
4316
+ }
4317
+ if (choice.kind === "no_tty") {
4318
+ console.error(noTtyMessage("token-derby roll"));
4319
+ return 1;
4320
+ }
4321
+ let picked;
4322
+ if (choice.kind === "resolved") {
4323
+ picked = choice.horse;
4324
+ const notice = noticeFor(choice);
4325
+ if (notice) console.log(notice);
4326
+ } else {
4327
+ picked = await new Promise((resolve) => {
4328
+ const app = render6(React7.createElement(RollHorsePicker, {
4329
+ horses: eligible,
4330
+ onPick: (h) => {
4331
+ app.unmount();
4332
+ resolve(h);
4333
+ },
4334
+ onCancel: () => {
4335
+ app.unmount();
4336
+ resolve(null);
4337
+ }
4338
+ }));
4339
+ });
4340
+ }
3975
4341
  if (!picked) {
3976
4342
  console.log("Cancelled.");
3977
4343
  return 0;
@@ -4016,7 +4382,9 @@ async function rollCommand() {
4016
4382
  console.log(`
4017
4383
  \u2728 ${hat.name}${variantSuffix} [${hat.rarity.toUpperCase()}]
4018
4384
  `);
4019
- if (await promptYesNo("Equip now? [Y/n] ")) {
4385
+ if (!interactive()) {
4386
+ console.log(`Not equipped \u2014 no terminal to confirm. Equip it with: token-derby stable edit "${chosen.name}"`);
4387
+ } else if (await promptYesNo("Equip now? [Y/n] ")) {
4020
4388
  try {
4021
4389
  await equipHat(chosen.stable_horse_id, { hat_index: result.hat_index });
4022
4390
  console.log(`Equipped on ${chosen.name}.`);
@@ -4038,21 +4406,25 @@ No hat this time. +${result.xp_awarded} XP toward your next level.
4038
4406
  `);
4039
4407
  }
4040
4408
  if (result.remaining_rolls <= 0) return 0;
4409
+ if (!interactive()) {
4410
+ console.log(`${result.remaining_rolls} more roll${result.remaining_rolls === 1 ? "" : "s"} available. Run again to spend another.`);
4411
+ return 0;
4412
+ }
4041
4413
  if (!await promptYesNo(`${result.remaining_rolls} more roll${result.remaining_rolls === 1 ? "" : "s"} available. Roll again? [Y/n] `)) return 0;
4042
4414
  }
4043
4415
  }
4044
4416
 
4045
4417
  // src/commands/claim.ts
4046
- import React15 from "react";
4418
+ import React8 from "react";
4047
4419
  import { render as render7 } from "ink";
4048
- async function claimCommand(token) {
4420
+ async function claimCommand(token, args = []) {
4049
4421
  if (!token) {
4050
4422
  console.error("Usage: token-derby claim <token>");
4051
4423
  return 2;
4052
4424
  }
4053
- let probe;
4425
+ let probe2;
4054
4426
  try {
4055
- probe = await probeClaim(token);
4427
+ probe2 = await probeClaim(token);
4056
4428
  } catch (e) {
4057
4429
  if (e instanceof ApiError) {
4058
4430
  console.error(`Error: ${e.code} ${e.message}`);
@@ -4074,23 +4446,44 @@ async function claimCommand(token) {
4074
4446
  console.error("No horses in your stable. Run `token-derby stable create` first.");
4075
4447
  return 1;
4076
4448
  }
4077
- console.log(probe.entry_count > 1 ? `
4078
- \u{1F381} A pack of ${probe.entry_count} cosmetics \u2014 one of them will be yours.
4079
- ` : "\n\u{1F381} A cosmetic has been awarded to you.\n");
4080
- const picked = await new Promise((resolve) => {
4081
- const app = render7(React15.createElement(HorsePicker, {
4082
- horses: stable.horses,
4083
- prompt: "Which horse should receive it?",
4084
- onPick: (h) => {
4085
- app.unmount();
4086
- resolve(h);
4087
- },
4088
- onCancel: () => {
4089
- app.unmount();
4090
- resolve(null);
4091
- }
4092
- }));
4449
+ const choice = await resolveHorse(stable.horses, {
4450
+ name: parseFlag(args, "--horse"),
4451
+ pick: hasFlag(args, "--pick")
4093
4452
  });
4453
+ if (choice.kind === "not_found") {
4454
+ console.error(`No horse named "${choice.name}" in your stable.`);
4455
+ console.error(`Your stable: ${stable.horses.map((h) => h.name).join(", ")}`);
4456
+ return 1;
4457
+ }
4458
+ if (choice.kind === "no_tty") {
4459
+ console.error(noTtyMessage("token-derby claim"));
4460
+ console.error("Your token is unspent.");
4461
+ return 1;
4462
+ }
4463
+ console.log(probe2.entry_count > 1 ? `
4464
+ \u{1F381} A pack of ${probe2.entry_count} cosmetics \u2014 one of them will be yours.
4465
+ ` : "\n\u{1F381} A cosmetic has been awarded to you.\n");
4466
+ let picked;
4467
+ if (choice.kind === "resolved") {
4468
+ picked = choice.horse;
4469
+ const notice = noticeFor(choice);
4470
+ if (notice) console.log(notice + "\n");
4471
+ } else {
4472
+ picked = await new Promise((resolve) => {
4473
+ const app = render7(React8.createElement(HorsePicker, {
4474
+ horses: stable.horses,
4475
+ prompt: "Which horse should receive it?",
4476
+ onPick: (h) => {
4477
+ app.unmount();
4478
+ resolve(h);
4479
+ },
4480
+ onCancel: () => {
4481
+ app.unmount();
4482
+ resolve(null);
4483
+ }
4484
+ }));
4485
+ });
4486
+ }
4094
4487
  if (!picked) {
4095
4488
  console.log("Cancelled. Your token is unspent.");
4096
4489
  return 0;
@@ -4124,7 +4517,9 @@ async function claimCommand(token) {
4124
4517
  console.log(`
4125
4518
  \u2728 ${hat2.name}${variantSuffix2} [${hat2.rarity.toUpperCase()}]
4126
4519
  `);
4127
- if (await promptYesNo("Equip now? [Y/n] ")) {
4520
+ if (!interactive()) {
4521
+ console.log(`Not equipped \u2014 no terminal to confirm. Equip it with: token-derby stable edit "${picked.name}"`);
4522
+ } else if (await promptYesNo("Equip now? [Y/n] ")) {
4128
4523
  try {
4129
4524
  await equipHat(picked.stable_horse_id, { hat_index: result.hat_index });
4130
4525
  console.log(`Equipped on ${picked.name}.`);
@@ -4144,6 +4539,121 @@ ${picked.name} already has ${hat?.name ?? result.hat_id}${variantSuffix}. +${res
4144
4539
  return 0;
4145
4540
  }
4146
4541
 
4542
+ // src/commands/stable-default.ts
4543
+ async function stableDefaultCommand(args) {
4544
+ const clear = args.includes("--clear");
4545
+ const name = args.find((a) => !a.startsWith("--"));
4546
+ if (clear && name !== void 0) {
4547
+ console.error("Pass a name or --clear, not both.");
4548
+ return 2;
4549
+ }
4550
+ if (clear) {
4551
+ await clearDefaultHorse();
4552
+ console.log("Default horse cleared.");
4553
+ return 0;
4554
+ }
4555
+ let horses;
4556
+ try {
4557
+ horses = (await listStable()).horses;
4558
+ } catch (e) {
4559
+ if (e instanceof ApiError) {
4560
+ console.error(`Error: ${e.code} ${e.message}`);
4561
+ return 1;
4562
+ }
4563
+ throw e;
4564
+ }
4565
+ if (name === void 0) return showCurrent(horses);
4566
+ const found = horses.find((h) => h.name === name);
4567
+ if (!found) {
4568
+ console.error(`No horse named "${name}" in your stable.`);
4569
+ if (horses.length > 0) console.error(`Your stable: ${horses.map((h) => h.name).join(", ")}`);
4570
+ return 1;
4571
+ }
4572
+ await setDefaultHorse(found.stable_horse_id);
4573
+ console.log(`Default horse set: ${describeHorse(found)}`);
4574
+ return 0;
4575
+ }
4576
+ async function showCurrent(horses) {
4577
+ const { default_stable_horse_id } = await loadPrefs();
4578
+ if (default_stable_horse_id === void 0) {
4579
+ console.log("No default horse set.");
4580
+ console.log("Set one with: token-derby stable default <name>");
4581
+ return 0;
4582
+ }
4583
+ const found = horses.find((h) => h.stable_horse_id === default_stable_horse_id);
4584
+ if (!found) {
4585
+ console.log("Your default horse is no longer in your stable \u2014 it will be ignored.");
4586
+ console.log("Set a new one with: token-derby stable default <name>");
4587
+ return 0;
4588
+ }
4589
+ console.log(`Default horse: ${describeHorse(found)}`);
4590
+ return 0;
4591
+ }
4592
+
4593
+ // src/commands/harness.ts
4594
+ function isHarnessKey(v) {
4595
+ return HARNESS_KEYS.includes(v);
4596
+ }
4597
+ function listValidIds() {
4598
+ return HARNESS_KEYS.join(", ");
4599
+ }
4600
+ async function harnessListCommand() {
4601
+ const prefs = await loadPrefs();
4602
+ const rows = await Promise.all(HARNESS_KEYS.map(async (key) => {
4603
+ const p = await probe(HARNESSES[key]);
4604
+ return {
4605
+ key,
4606
+ label: HARNESSES[key].label,
4607
+ on: isHarnessEnabled(prefs, key),
4608
+ chosen: prefs.harnesses?.[key] !== void 0,
4609
+ byDefault: HARNESSES[key].enabledByDefault,
4610
+ found: p.transcripts,
4611
+ dir: p.dir
4612
+ };
4613
+ }));
4614
+ const width = Math.max(...rows.map((r) => r.key.length));
4615
+ console.log("");
4616
+ for (const r of rows) {
4617
+ const state = r.on ? "on " : "off";
4618
+ const why = r.chosen ? "" : r.byDefault ? " (default)" : " (off by default \u2014 enable to count it)";
4619
+ const found = r.found === 1 ? "1 transcript" : `${r.found} transcripts`;
4620
+ console.log(` ${state} ${r.key.padEnd(width)} ${r.label}${why}`);
4621
+ console.log(` ${" ".repeat(width)} ${r.dir} (${found})`);
4622
+ }
4623
+ console.log("");
4624
+ console.log(" token-derby harness disable <id> stop counting one");
4625
+ console.log(" token-derby harness enable <id> start counting it again");
4626
+ console.log("");
4627
+ return 0;
4628
+ }
4629
+ async function harnessToggleCommand(id, enabled) {
4630
+ const verb = enabled ? "enable" : "disable";
4631
+ if (!id) {
4632
+ console.error(`Usage: token-derby harness ${verb} <id>`);
4633
+ console.error(`Available: ${listValidIds()}`);
4634
+ return 2;
4635
+ }
4636
+ if (!isHarnessKey(id)) {
4637
+ console.error(`Unknown coding agent '${id}'.`);
4638
+ console.error(`Available: ${listValidIds()}`);
4639
+ return 2;
4640
+ }
4641
+ const before = await loadPrefs();
4642
+ if (isHarnessEnabled(before, id) === enabled) {
4643
+ console.log(`${HARNESSES[id].label} is already ${enabled ? "counted" : "turned off"}.`);
4644
+ return 0;
4645
+ }
4646
+ await setHarnessEnabled(id, enabled);
4647
+ const remaining = enabledHarnesses(await loadPrefs());
4648
+ console.log(`${HARNESSES[id].label} is now ${enabled ? "counted" : "turned off"}.`);
4649
+ console.log("Takes effect on your next heartbeat \u2014 no need to rejoin.");
4650
+ if (remaining.length === 0) {
4651
+ console.log("");
4652
+ console.log("\u26A0 Every coding agent is now off, so your horse will not move.");
4653
+ }
4654
+ return 0;
4655
+ }
4656
+
4147
4657
  // src/commands/org-join.ts
4148
4658
  async function orgJoinCommand(token) {
4149
4659
  const join_token = token?.trim();
@@ -4191,7 +4701,7 @@ function envCommand(arg) {
4191
4701
  }
4192
4702
 
4193
4703
  // src/commands/logs.ts
4194
- import * as fs9 from "fs/promises";
4704
+ import * as fs11 from "fs/promises";
4195
4705
  import { existsSync as existsSync2 } from "fs";
4196
4706
  var DEFAULT_TAIL_LINES = 50;
4197
4707
  function tailCount(argv) {
@@ -4209,7 +4719,7 @@ async function logsCommand(argv) {
4209
4719
  console.log(file);
4210
4720
  const n = tailCount(argv);
4211
4721
  if (n === null) return 0;
4212
- const lines = (await fs9.readFile(file, "utf8")).split("\n").filter((l) => l.length > 0);
4722
+ const lines = (await fs11.readFile(file, "utf8")).split("\n").filter((l) => l.length > 0);
4213
4723
  console.log("");
4214
4724
  for (const line of lines.slice(-n)) console.log(line);
4215
4725
  return 0;
@@ -4246,11 +4756,20 @@ Maintenance:
4246
4756
  token-derby logs Show the path of the debug log
4247
4757
  token-derby logs --tail [n] Print the last n log lines (default 50)
4248
4758
 
4759
+ Coding agents:
4760
+ token-derby harness list Show which agents this machine counts,
4761
+ and whether they have anything to count
4762
+ token-derby harness disable <id> Stop counting one (takes effect next heartbeat)
4763
+ token-derby harness enable <id> Start counting it again
4764
+
4249
4765
  Stable management:
4250
4766
  token-derby stable create Make a new horse (interactive)
4251
4767
  token-derby stable list Show your saved horses
4252
4768
  token-derby stable edit [name] Edit an existing horse's colors (interactive picker if no name)
4253
4769
  token-derby stable delete <name> Remove a horse from your stable
4770
+ token-derby stable default [name] Show, set, or (--clear) unset the horse that
4771
+ claim/join/stable edit use when none is named.
4772
+ A stable of one is used automatically.
4254
4773
 
4255
4774
  Organisations:
4256
4775
  token-derby organisation join [token] Join an organisation with a join token,
@@ -4264,13 +4783,17 @@ Races:
4264
4783
  Create a new race (interactive). When
4265
4784
  --organisation is set, only members of
4266
4785
  that org can join.
4267
- token-derby join <join-code> Join (or resume) a race
4786
+ token-derby join <join-code> [--horse <name>|--pick]
4787
+ Join (or resume) a race
4268
4788
  token-derby end <admin-code> End a race early
4269
4789
 
4270
4790
  Cosmetics:
4271
- token-derby roll Spend a pending roll to try for a hat.
4272
- Earn rolls by leveling up horses.
4273
- token-derby claim <token> Redeem a claim token for a cosmetic
4791
+ token-derby roll [--horse <name>] Spend a pending roll to try for a hat.
4792
+ Earn rolls by leveling up horses. The picker
4793
+ is a confirmation step, so it is shown unless
4794
+ --horse names the horse outright.
4795
+ token-derby claim <token> [--horse <name>|--pick]
4796
+ Redeem a claim token for a cosmetic
4274
4797
  awarded to you by an admin.
4275
4798
 
4276
4799
  Environment:
@@ -4283,7 +4806,7 @@ Environment:
4283
4806
  `;
4284
4807
  function describeInvocation(argv) {
4285
4808
  const cmd = argv[0] ?? "(none)";
4286
- const container = cmd === "stable" || cmd === "organisation" || cmd === "org";
4809
+ const container = cmd === "stable" || cmd === "organisation" || cmd === "org" || cmd === "harness";
4287
4810
  return {
4288
4811
  cmd,
4289
4812
  sub: container ? argv[1] : void 0,
@@ -4316,6 +4839,15 @@ async function main() {
4316
4839
  if (cmd === "update") return updateCommand();
4317
4840
  if (cmd === "env") return envCommand(argv[1]);
4318
4841
  if (cmd === "logs") return logsCommand(argv.slice(1));
4842
+ if (cmd === "harness") {
4843
+ const sub = argv[1];
4844
+ if (sub === void 0 || sub === "list") return harnessListCommand();
4845
+ if (sub === "enable") return harnessToggleCommand(argv[2], true);
4846
+ if (sub === "disable") return harnessToggleCommand(argv[2], false);
4847
+ console.error(`Unknown harness subcommand: ${sub}`);
4848
+ console.error("Try: harness list | harness enable <id> | harness disable <id>");
4849
+ return 2;
4850
+ }
4319
4851
  const identity = await loadIdentity();
4320
4852
  if (!identity) {
4321
4853
  console.error("Run `token-derby login` to set up your identity before using any other command.");
@@ -4327,8 +4859,9 @@ async function main() {
4327
4859
  if (sub === "list") return stableListCommand();
4328
4860
  if (sub === "edit") return stableEditCommand(argv[2]);
4329
4861
  if (sub === "delete") return stableDeleteCommand(argv[2]);
4862
+ if (sub === "default") return stableDefaultCommand(argv.slice(2));
4330
4863
  console.error(`Unknown stable subcommand: ${sub ?? "(none)"}`);
4331
- console.error("Try: stable create | stable list | stable edit <name> | stable delete <name>");
4864
+ console.error("Try: stable create | stable list | stable edit <name> | stable delete <name> | stable default [<name>|--clear]");
4332
4865
  return 2;
4333
4866
  }
4334
4867
  if (cmd === "organisation" || cmd === "org") {
@@ -4347,21 +4880,13 @@ async function main() {
4347
4880
  if (cmd === "whoami") return whoamiCommand();
4348
4881
  if (cmd === "join") return joinCommand(argv[1], argv.slice(2));
4349
4882
  if (cmd === "end") return endCommand(argv[1]);
4350
- if (cmd === "roll") return rollCommand();
4351
- if (cmd === "claim") return claimCommand(argv[1]);
4883
+ if (cmd === "roll") return rollCommand(argv.slice(1));
4884
+ if (cmd === "claim") return claimCommand(argv[1], argv.slice(2));
4352
4885
  if (cmd === "web") return webCommand();
4353
4886
  console.error(`Unknown command: ${cmd}`);
4354
4887
  console.error(HELP);
4355
4888
  return 2;
4356
4889
  }
4357
- function parseFlag(args, flag) {
4358
- for (let i = 0; i < args.length; i++) {
4359
- if (args[i] === flag) return args[i + 1];
4360
- const eq = `${flag}=`;
4361
- if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
4362
- }
4363
- return void 0;
4364
- }
4365
4890
  var CRASH_HANDLERS_INSTALLED = /* @__PURE__ */ Symbol.for("token-derby.crash-handlers");
4366
4891
  if (!(CRASH_HANDLERS_INSTALLED in process)) {
4367
4892
  process[CRASH_HANDLERS_INSTALLED] = true;