@mauricode/token-derby 3.1.2 → 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.2".length > 0) {
866
- return "3.1.2";
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,15 @@ 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
+ }
982
+ function logDir() {
983
+ return path2.join(homeDir(), "logs");
984
+ }
985
+ function logFile() {
986
+ return path2.join(logDir(), "token-derby.log");
987
+ }
913
988
 
914
989
  // src/identity/identity.ts
915
990
  async function readIdentityFile() {
@@ -964,6 +1039,63 @@ function validateDisplayName(name) {
964
1039
  return { ok: true, name: trimmed };
965
1040
  }
966
1041
 
1042
+ // src/log/logger.ts
1043
+ import * as fs2 from "fs";
1044
+ var SECRET_KEY = /token|secret|authorization|credential|password/i;
1045
+ function redact(fields) {
1046
+ const out = {};
1047
+ for (const [key, value] of Object.entries(fields)) {
1048
+ out[key] = SECRET_KEY.test(key) ? "[redacted]" : value;
1049
+ }
1050
+ return out;
1051
+ }
1052
+ function formatLine(at, level, event, fields) {
1053
+ const body = fields && Object.keys(fields).length > 0 ? ` ${JSON.stringify(redact(fields))}` : "";
1054
+ return `${at.toISOString()} ${level.padEnd(5)} ${event}${body}
1055
+ `;
1056
+ }
1057
+ var MAX_BYTES = 2e6;
1058
+ var MAX_FILES = 5;
1059
+ var currentBytes = null;
1060
+ var disabled = false;
1061
+ function rotate() {
1062
+ const base = logFile();
1063
+ fs2.rmSync(`${base}.${MAX_FILES - 1}`, { force: true });
1064
+ for (let i = MAX_FILES - 2; i >= 1; i--) {
1065
+ if (fs2.existsSync(`${base}.${i}`)) fs2.renameSync(`${base}.${i}`, `${base}.${i + 1}`);
1066
+ }
1067
+ if (fs2.existsSync(base)) fs2.renameSync(base, `${base}.1`);
1068
+ currentBytes = 0;
1069
+ }
1070
+ function write(level, event, fields) {
1071
+ if (disabled) return;
1072
+ try {
1073
+ append(level, event, fields);
1074
+ } catch {
1075
+ disabled = true;
1076
+ }
1077
+ }
1078
+ function append(level, event, fields) {
1079
+ const line = formatLine(/* @__PURE__ */ new Date(), level, event, fields);
1080
+ const bytes = Buffer.byteLength(line);
1081
+ fs2.mkdirSync(logDir(), { recursive: true });
1082
+ if (currentBytes === null) {
1083
+ currentBytes = fs2.existsSync(logFile()) ? fs2.statSync(logFile()).size : 0;
1084
+ }
1085
+ if (currentBytes > 0 && currentBytes + bytes > MAX_BYTES) rotate();
1086
+ fs2.appendFileSync(logFile(), line, "utf8");
1087
+ currentBytes += bytes;
1088
+ }
1089
+ function logInfo(event, fields) {
1090
+ write("INFO", event, fields);
1091
+ }
1092
+ function logWarn(event, fields) {
1093
+ write("WARN", event, fields);
1094
+ }
1095
+ function logError(event, fields) {
1096
+ write("ERROR", event, fields);
1097
+ }
1098
+
967
1099
  // src/api/client.ts
968
1100
  var ApiError = class extends Error {
969
1101
  constructor(code, message, status) {
@@ -975,6 +1107,10 @@ var ApiError = class extends Error {
975
1107
  code;
976
1108
  status;
977
1109
  };
1110
+ var SECRET_SEGMENT = /^\/(claims|races\/admin)\/[^/]+/;
1111
+ function loggablePath(path9) {
1112
+ return path9.replace(SECRET_SEGMENT, (match) => `${match.slice(0, match.lastIndexOf("/") + 1)}[redacted]`);
1113
+ }
978
1114
  var identityCache = null;
979
1115
  function getIdentity() {
980
1116
  if (!identityCache) identityCache = loadIdentity();
@@ -995,6 +1131,9 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
995
1131
  }
996
1132
  if (horseAuthToken) headers["authorization"] = `Bearer ${horseAuthToken}`;
997
1133
  if (body !== void 0) headers["content-type"] = "application/json";
1134
+ const safePath = loggablePath(path9);
1135
+ logInfo("http.req", { method, path: safePath });
1136
+ const startedAt2 = Date.now();
998
1137
  let res;
999
1138
  try {
1000
1139
  res = await fetchImpl(url, {
@@ -1003,8 +1142,10 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
1003
1142
  body: body !== void 0 ? JSON.stringify(body) : void 0
1004
1143
  });
1005
1144
  } catch (e) {
1145
+ logError("http.err", { method, path: safePath, ms: Date.now() - startedAt2, message: e?.message ?? "fetch failed" });
1006
1146
  throw new ApiError("NETWORK_ERROR", e?.message ?? "fetch failed", 0);
1007
1147
  }
1148
+ logInfo("http.res", { method, path: safePath, status: res.status, ms: Date.now() - startedAt2 });
1008
1149
  const text = await res.text();
1009
1150
  const contentType = res.headers.get("content-type") ?? "";
1010
1151
  let parsed = null;
@@ -1021,6 +1162,14 @@ async function request(method, path9, body, horseAuthToken, fetchImpl = fetch, i
1021
1162
  }
1022
1163
  throw new ApiError("NETWORK_ERROR", `HTTP ${res.status}`, res.status);
1023
1164
  }
1165
+ if (parsed === null) {
1166
+ const got = contentType || "no content-type";
1167
+ throw new ApiError(
1168
+ "NETWORK_ERROR",
1169
+ `Expected JSON from ${method} ${safePath} but the server returned ${got} (HTTP ${res.status}). The API may be unavailable or the request was rejected upstream.`,
1170
+ res.status
1171
+ );
1172
+ }
1024
1173
  return parsed;
1025
1174
  }
1026
1175
 
@@ -1124,7 +1273,7 @@ function logoutDevice() {
1124
1273
  async function stableCreateCommand() {
1125
1274
  let exitCode = 0;
1126
1275
  const app = render(
1127
- React3.createElement(HorseCreator, {
1276
+ React2.createElement(HorseCreator, {
1128
1277
  onSubmit: async (name, colors) => {
1129
1278
  try {
1130
1279
  await createStableHorse({ name, colors });
@@ -1156,7 +1305,7 @@ async function stableCreateCommand() {
1156
1305
  }
1157
1306
 
1158
1307
  // src/commands/stable-list.tsx
1159
- import React4 from "react";
1308
+ import React3 from "react";
1160
1309
  import { render as render2, Box as Box4, Text as Text4 } from "ink";
1161
1310
  import { jsx as jsx4, jsxs as jsxs2 } from "react/jsx-runtime";
1162
1311
  async function stableListCommand() {
@@ -1176,13 +1325,13 @@ async function stableListCommand() {
1176
1325
  return 0;
1177
1326
  }
1178
1327
  const app = render2(
1179
- React4.createElement(StableList, { horses })
1328
+ React3.createElement(StableList, { horses })
1180
1329
  );
1181
1330
  await app.waitUntilExit();
1182
1331
  return 0;
1183
1332
  }
1184
1333
  function StableList({ horses }) {
1185
- React4.useEffect(() => {
1334
+ React3.useEffect(() => {
1186
1335
  setImmediate(() => process.exit(0));
1187
1336
  }, []);
1188
1337
  return /* @__PURE__ */ jsxs2(Box4, { flexDirection: "column", children: [
@@ -1251,7 +1400,7 @@ async function stableDeleteCommand(name) {
1251
1400
  }
1252
1401
 
1253
1402
  // src/commands/stable-edit.ts
1254
- import React6 from "react";
1403
+ import React4 from "react";
1255
1404
  import { render as render3 } from "ink";
1256
1405
 
1257
1406
  // src/ui/HorsePicker.tsx
@@ -1308,121 +1457,699 @@ function HorsePicker({ horses, onPick, onCancel, prompt = "Pick a horse to race:
1308
1457
  ] });
1309
1458
  }
1310
1459
 
1311
- // src/commands/stable-edit.ts
1312
- async function stableEditCommand(name) {
1313
- const horses = await fetchStable();
1314
- if (!horses) return 1;
1315
- const existing = await pickHorseToEdit(horses, name);
1316
- if (existing === "not_found") {
1317
- console.error(`No horse named "${name}" in your stable.`);
1318
- return 1;
1319
- }
1320
- if (existing === "empty") {
1321
- console.log("No horses in your stable. Run `token-derby stable create` to make one.");
1322
- return 0;
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";
1323
1473
  }
1324
- if (existing === "cancelled") {
1325
- console.log("Cancelled.");
1326
- return 1;
1474
+ dir;
1475
+ };
1476
+ async function readRoot(dir, read) {
1477
+ try {
1478
+ return await read();
1479
+ } catch (e) {
1480
+ if (e?.code === "ENOENT") throw new SourceRootMissing(dir);
1481
+ throw e;
1327
1482
  }
1328
- const initialEquipped = existing.equipped_hat ?? null;
1329
- let exitCode = 0;
1330
- const app = render3(
1331
- React6.createElement(HorseCreator, {
1332
- initialColors: existing.colors,
1333
- initialName: existing.name,
1334
- lockName: true,
1335
- initialLevel: levelFromXp(existing.xp),
1336
- hats: existing.hats,
1337
- initialEquipped,
1338
- onSubmit: async (_name, colors, hatChoice) => {
1339
- const colorsChanged = !sameColors(colors, existing.colors);
1340
- const hatChanged = hatChoice !== void 0 && hatChoice !== initialEquipped;
1341
- try {
1342
- if (colorsChanged) await updateStableHorse(existing.stable_horse_id, { colors });
1343
- if (hatChanged) await equipHat(existing.stable_horse_id, { hat_index: hatChoice });
1344
- app.unmount();
1345
- if (!colorsChanged && !hatChanged) {
1346
- console.log(`No changes for "${existing.name}".`);
1347
- } else {
1348
- const parts = [];
1349
- if (colorsChanged) parts.push("colors");
1350
- if (hatChanged) parts.push(hatChoice === null ? "hat unequipped" : "hat equipped");
1351
- console.log(`\u2713 Updated "${existing.name}" (${parts.join(", ")}).`);
1352
- }
1353
- } catch (e) {
1354
- app.unmount();
1355
- if (e instanceof ApiError) {
1356
- console.error(`Error: ${e.code} ${e.message}`);
1357
- exitCode = 1;
1358
- return;
1359
- }
1360
- throw e;
1361
- }
1362
- },
1363
- onCancel: () => {
1364
- app.unmount();
1365
- console.log("Cancelled.");
1366
- exitCode = 1;
1483
+ }
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 } });
1497
+ }
1498
+
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;
1367
1513
  }
1368
- })
1369
- );
1370
- await app.waitUntilExit();
1371
- return exitCode;
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);
1521
+ }
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;
1372
1527
  }
1373
- function sameColors(a, b) {
1374
- return a.body === b.body && a.mane === b.mane && a.tail === b.tail && a.saddle === b.saddle;
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);
1546
+ }
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);
1567
+ }
1568
+ }
1375
1569
  }
1376
- async function fetchStable() {
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);
1574
+ }
1575
+
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;
1377
1629
  try {
1378
- const resp = await listStable();
1379
- return resp.horses;
1630
+ entries = await fs4.readdir(dir, { withFileTypes: true });
1380
1631
  } catch (e) {
1381
- if (e instanceof ApiError) {
1382
- console.error(`Error: ${e.code} ${e.message}`);
1383
- return null;
1384
- }
1632
+ if (e?.code === "ENOENT") return [];
1385
1633
  throw e;
1386
1634
  }
1387
- }
1388
- async function pickHorseToEdit(horses, name) {
1389
- if (name) {
1390
- const found = horses.find((h) => h.name === name);
1391
- return found ?? "not_found";
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);
1392
1640
  }
1393
- if (horses.length === 0) return "empty";
1394
- const picked = await new Promise((resolve) => {
1395
- const app = render3(
1396
- React6.createElement(HorsePicker, {
1397
- horses,
1398
- onPick: (h) => {
1399
- app.unmount();
1400
- resolve(h);
1401
- },
1402
- onCancel: () => {
1403
- app.unmount();
1404
- resolve(null);
1405
- }
1406
- })
1407
- );
1408
- });
1409
- return picked ?? "cancelled";
1641
+ return out;
1410
1642
  }
1411
1643
 
1412
- // src/commands/create.ts
1413
- import * as readline2 from "readline/promises";
1414
- import { stdin as stdin2, stdout as stdout2 } from "process";
1415
- var DEFAULT_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
1416
- async function createRaceCommand(organisationName) {
1417
- const rl = readline2.createInterface({ input: stdin2, output: stdout2 });
1418
- try {
1419
- const name = (await rl.question("Race name: ")).trim();
1420
- if (!name) {
1421
- console.error("Name required.");
1422
- return 1;
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
+ }
1423
1676
  }
1424
- const startRaw = (await rl.question("Start time (ISO 8601, blank = now): ")).trim();
1425
- const start = startRaw ? startRaw : (/* @__PURE__ */ new Date()).toISOString();
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
+ }
1731
+ });
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();
1426
2153
  if (!isIso(start)) {
1427
2154
  console.error("Invalid start time.");
1428
2155
  return 1;
@@ -1450,12 +2177,14 @@ async function createRaceCommand(organisationName) {
1450
2177
  console.error("Organisation name must be 1\u201312 alphanumeric characters.");
1451
2178
  return 1;
1452
2179
  }
1453
- const countInputRaw = (await rl.question("Count input tokens (fresh input + cache creation) toward race totals? [y/N]: ")).trim().toLowerCase();
1454
- const counts_input = countInputRaw === "y" || countInputRaw === "yes";
1455
- const top5Raw = (await rl.question("Count only each racer's 5 most-active conversations toward their primary model's score? [y/N]: ")).trim().toLowerCase();
1456
- const primary_top5 = top5Raw === "y" || top5Raw === "yes";
1457
- const staminaRaw = (await rl.question("Stamina \u2014 horses that run flat out tire and score less until they recover? [y/N]: ")).trim().toLowerCase();
1458
- const stamina = staminaRaw === "y" || staminaRaw === "yes";
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
+ }
1459
2188
  const resp = await createRace({
1460
2189
  name,
1461
2190
  start_time: start,
@@ -1463,9 +2192,7 @@ async function createRaceCommand(organisationName) {
1463
2192
  tz,
1464
2193
  ...max !== void 0 ? { max_participants: max } : {},
1465
2194
  ...org ? { organisation_name: org } : {},
1466
- ...counts_input ? { counts_input: true } : {},
1467
- ...primary_top5 ? { primary_top5: true } : {},
1468
- ...stamina ? { stamina: true } : {}
2195
+ ...modifiers.length > 0 ? { modifiers } : {}
1469
2196
  });
1470
2197
  console.log("");
1471
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");
@@ -1478,14 +2205,8 @@ async function createRaceCommand(organisationName) {
1478
2205
  if (org) {
1479
2206
  console.log(` Restricted to organisation: ${org}`);
1480
2207
  }
1481
- if (counts_input) {
1482
- console.log(" Counting input + output tokens (excluding cache reads).");
1483
- }
1484
- if (primary_top5) {
1485
- console.log(" Primary score counts only each racer's top 5 conversations per beat.");
1486
- }
1487
- if (stamina) {
1488
- console.log(" Stamina on \u2014 horses running above a sustainable pace will tire.");
2208
+ for (const id of modifiers) {
2209
+ console.log(` ${MODIFIERS[id].label} on \u2014 ${MODIFIERS[id].description}`);
1489
2210
  }
1490
2211
  console.log(` Share with participants: token-derby join ${resp.join_code}`);
1491
2212
  return 0;
@@ -1506,37 +2227,28 @@ function isIso(s) {
1506
2227
  }
1507
2228
 
1508
2229
  // src/commands/join.ts
1509
- import React9 from "react";
2230
+ import React5 from "react";
1510
2231
  import { render as render4 } from "ink";
1511
2232
 
1512
- // src/ui/PrimaryPicker.tsx
1513
- import { useState as useState4 } from "react";
1514
- import { Box as Box6, Text as Text6, useInput as useInput3 } from "ink";
1515
- import { jsx as jsx6, jsxs as jsxs4 } from "react/jsx-runtime";
1516
- var LABELS = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
1517
- function PrimaryPicker({ onPick }) {
1518
- const [i, setI] = useState4(0);
1519
- useInput3((_input, key) => {
1520
- if (key.upArrow) setI((p) => (p + MODEL_KEYS.length - 1) % MODEL_KEYS.length);
1521
- else if (key.downArrow) setI((p) => (p + 1) % MODEL_KEYS.length);
1522
- else if (key.return) onPick(MODEL_KEYS[i]);
1523
- });
1524
- return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", children: [
1525
- /* @__PURE__ */ jsx6(Text6, { bold: true, children: "Pick your primary model for this race (counts 1:1; the others count at 50%)." }),
1526
- /* @__PURE__ */ jsx6(Text6, { dimColor: true, children: "This is locked for the whole race \u2014 you can't change it, even by rejoining." }),
1527
- MODEL_KEYS.map((m, idx) => /* @__PURE__ */ jsxs4(Text6, { color: idx === i ? "cyan" : void 0, children: [
1528
- idx === i ? "\u276F " : " ",
1529
- LABELS[m]
1530
- ] }, m))
1531
- ] });
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}=`));
1532
2244
  }
1533
2245
 
1534
2246
  // src/stable/active-race.ts
1535
- import * as fs2 from "fs/promises";
1536
- import * as path4 from "path";
2247
+ import * as fs8 from "fs/promises";
2248
+ import * as path7 from "path";
1537
2249
  async function saveActiveRace(active) {
1538
- await fs2.mkdir(activeRacesDir(), { recursive: true });
1539
- await fs2.writeFile(
2250
+ await fs8.mkdir(activeRacesDir(), { recursive: true });
2251
+ await fs8.writeFile(
1540
2252
  activeRaceFile(active.join_code),
1541
2253
  JSON.stringify(active, null, 2) + "\n",
1542
2254
  "utf8"
@@ -1544,29 +2256,30 @@ async function saveActiveRace(active) {
1544
2256
  }
1545
2257
 
1546
2258
  // src/runtime/run-race.tsx
1547
- import { useEffect as useEffect2, useRef, useState as useState5 } from "react";
1548
- 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";
1549
2261
 
1550
2262
  // src/ui/StatusScreen.tsx
1551
- import { Box as Box7, Text as Text7 } from "ink";
1552
- import { Fragment as Fragment2, jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
1553
- 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";
1554
2265
  function ModelList(props) {
1555
- const { primaryModel } = props;
1556
- const secondaryTag = ` (${Math.round(SECONDARY_WEIGHT * 100)}%)`;
1557
- return /* @__PURE__ */ jsx7(Box7, { marginTop: 1, children: /* @__PURE__ */ jsxs5(Text7, { children: [
1558
- "Models: ",
1559
- 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: [
1560
2270
  i > 0 ? " \xB7 " : "",
1561
- MODEL_LABELS[m],
1562
- /* @__PURE__ */ jsx7(Text7, { dimColor: true, children: m === primaryModel ? " (primary)" : secondaryTag })
1563
- ] }, 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)" })
1564
2277
  ] }) });
1565
2278
  }
1566
2279
  function StatusScreen(props) {
1567
- 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;
1568
2281
  if (!race) {
1569
- 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" }) });
1570
2283
  }
1571
2284
  const own = race.horses.find((h) => h.horse_id === ownHorseId);
1572
2285
  const leader = race.horses[0];
@@ -1592,36 +2305,36 @@ function StatusScreen(props) {
1592
2305
  },
1593
2306
  {
1594
2307
  label: "Last heartbeat:",
1595
- value: /* @__PURE__ */ jsxs5(Fragment2, { children: [
2308
+ value: /* @__PURE__ */ jsxs4(Fragment2, { children: [
1596
2309
  lastHeartbeatAgoSec === null ? "\u2014" : `${lastHeartbeatAgoSec}s ago`,
1597
2310
  " ",
1598
- /* @__PURE__ */ jsx7(Text7, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
2311
+ /* @__PURE__ */ jsx6(Text6, { color: lastHeartbeatOk ? "green" : "yellow", children: lastHeartbeatOk ? "\u2713" : "\u26A0" })
1599
2312
  ] })
1600
2313
  }
1601
2314
  ];
1602
- return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
1603
- /* @__PURE__ */ jsxs5(Text7, { children: [
2315
+ return /* @__PURE__ */ jsxs4(Box6, { flexDirection: "column", borderStyle: "round", paddingX: 1, children: [
2316
+ /* @__PURE__ */ jsxs4(Text6, { children: [
1604
2317
  "\u{1F3C7} TOKEN DERBY \u2500\u2500\u2500 ",
1605
- /* @__PURE__ */ jsx7(Text7, { bold: true, children: race.name }),
2318
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: race.name }),
1606
2319
  " \u2500\u2500\u2500 status: ",
1607
- /* @__PURE__ */ jsx7(Text7, { color: statusColor(race.status), children: race.status })
2320
+ /* @__PURE__ */ jsx6(Text6, { color: statusColor(race.status), children: race.status })
1608
2321
  ] }),
1609
- /* @__PURE__ */ jsxs5(Box7, { marginTop: 1, flexDirection: "row", children: [
1610
- /* @__PURE__ */ jsx7(HorseSprite, { sprite: MINI_SPRITE, colors: ownColors }),
1611
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
1612
- /* @__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: [
1613
2326
  " ",
1614
2327
  ownHorseName,
1615
2328
  " ",
1616
- /* @__PURE__ */ jsxs5(Text7, { color: "cyan", children: [
2329
+ /* @__PURE__ */ jsxs4(Text6, { color: "cyan", children: [
1617
2330
  "[Lvl. ",
1618
2331
  lvl.level,
1619
2332
  "]"
1620
2333
  ] })
1621
2334
  ] }),
1622
- /* @__PURE__ */ jsxs5(Text7, { children: [
2335
+ /* @__PURE__ */ jsxs4(Text6, { children: [
1623
2336
  " ",
1624
- /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
2337
+ /* @__PURE__ */ jsxs4(Text6, { dimColor: true, children: [
1625
2338
  "(",
1626
2339
  ownUserName,
1627
2340
  ")"
@@ -1629,30 +2342,39 @@ function StatusScreen(props) {
1629
2342
  ] })
1630
2343
  ] })
1631
2344
  ] }),
1632
- /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", marginTop: 1, children: [
1633
- /* @__PURE__ */ jsx7(StatLines, { rows }),
1634
- 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: [
1635
2348
  "\u26A0 ",
1636
2349
  stallReason ?? "Can't read token usage",
1637
2350
  ". Your race continues."
1638
2351
  ] }),
1639
- !stalled && primarySilent && /* @__PURE__ */ jsxs5(Text7, { color: "yellow", children: [
1640
- "\u26A0 No ",
1641
- MODEL_LABELS[primaryModel ?? "claude"],
1642
- " transcripts in ",
1643
- PRIMARY_SILENT_THRESHOLD,
1644
- " beats",
1645
- primarySourceDir ? ` \u2014 nothing under ${primarySourceDir}` : "",
1646
- ". Your race continues, but your horse cannot move until they can be read."
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."
1647
2369
  ] })
1648
2370
  ] }),
1649
- primaryModel && /* @__PURE__ */ jsx7(ModelList, { primaryModel }),
1650
- /* @__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." }) })
1651
2373
  ] });
1652
2374
  }
1653
2375
  function StatLines(props) {
1654
2376
  const width = Math.max(...props.rows.map((r) => r.label.length)) + 1;
1655
- 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: [
1656
2378
  r.label.padEnd(width),
1657
2379
  r.value
1658
2380
  ] }, r.label)) });
@@ -1674,17 +2396,17 @@ function bar(pct, width) {
1674
2396
  return "\u2593".repeat(filled) + "\u2591".repeat(width - filled);
1675
2397
  }
1676
2398
  function staminaLine(own, race) {
1677
- const stamina = own?.stamina ?? 100;
2399
+ const stamina2 = staminaOf(own ?? {});
1678
2400
  const cfg = resolveStaminaConfig(race);
1679
2401
  const floor = cfg.taper_floor;
1680
- const band = stamina > 50 ? "green" : stamina >= floor ? "amber" : "red";
2402
+ const band = stamina2 > 50 ? "green" : stamina2 >= floor ? "amber" : "red";
1681
2403
  const color = band === "green" ? "green" : band === "amber" ? "yellow" : "red";
1682
- const pct = Math.max(0, Math.min(1, stamina / 100));
1683
- 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;
1684
2406
  return {
1685
2407
  label: "Stamina:",
1686
- value: /* @__PURE__ */ jsxs5(Text7, { color, children: [
1687
- `${Math.round(stamina)}% ${bar(pct, 20)}`,
2408
+ value: /* @__PURE__ */ jsxs4(Text6, { color, children: [
2409
+ `${Math.round(stamina2)}% ${bar(pct, 20)}`,
1688
2410
  multiplier !== null ? ` \xD7${multiplier.toFixed(2)}` : ""
1689
2411
  ] })
1690
2412
  };
@@ -1709,6 +2431,7 @@ function runHeartbeatLoop(opts) {
1709
2431
  let stopped = false;
1710
2432
  let pending = null;
1711
2433
  const stop = () => {
2434
+ if (!stopped) logInfo("beat.stop", { retry: retryIndex });
1712
2435
  stopped = true;
1713
2436
  if (timer) clearTimeout(timer);
1714
2437
  timer = null;
@@ -1721,9 +2444,16 @@ function runHeartbeatLoop(opts) {
1721
2444
  const tick = async () => {
1722
2445
  if (stopped) return;
1723
2446
  try {
1724
- if (!pending) pending = await opts.prepareBeat();
2447
+ if (!pending) {
2448
+ logInfo("beat.prepare.start");
2449
+ const startedAt2 = Date.now();
2450
+ pending = await opts.prepareBeat();
2451
+ logInfo("beat.prepare.done", { seq: pending.seq, ms: Date.now() - startedAt2 });
2452
+ }
1725
2453
  const snapshot = pending;
2454
+ const sentAt = Date.now();
1726
2455
  const resp = await opts.sendBeat(snapshot);
2456
+ logInfo("beat.send.ok", { seq: snapshot.seq, ms: Date.now() - sentAt, race_status: resp.race_status });
1727
2457
  pending = null;
1728
2458
  retryIndex = 0;
1729
2459
  opts.onSuccess(resp, snapshot);
@@ -1736,6 +2466,13 @@ function runHeartbeatLoop(opts) {
1736
2466
  } catch (err) {
1737
2467
  opts.onError(err);
1738
2468
  const delay = opts.retryDelaysMs[Math.min(retryIndex, opts.retryDelaysMs.length - 1)] ?? 1e3;
2469
+ logError("beat.send.err", {
2470
+ seq: pending?.seq,
2471
+ code: err?.code,
2472
+ message: err?.message ?? String(err),
2473
+ retry: retryIndex,
2474
+ next_ms: delay
2475
+ });
1739
2476
  retryIndex += 1;
1740
2477
  schedule(delay);
1741
2478
  }
@@ -1743,30 +2480,13 @@ function runHeartbeatLoop(opts) {
1743
2480
  schedule(0);
1744
2481
  }
1745
2482
 
1746
- // src/tokens/transcripts.ts
1747
- import * as fs4 from "fs/promises";
1748
- import * as path6 from "path";
1749
-
1750
- // src/tokens/pool.ts
1751
- var SCAN_CONCURRENCY = 12;
1752
- async function mapWithConcurrency(items, limit, fn) {
1753
- const out = new Array(items.length);
1754
- let next = 0;
1755
- const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1756
- while (true) {
1757
- const i = next++;
1758
- if (i >= items.length) return;
1759
- out[i] = await fn(items[i], i);
1760
- }
1761
- });
1762
- await Promise.all(workers);
1763
- return out;
1764
- }
2483
+ // src/tokens/harnesses/engine.ts
2484
+ import * as fs10 from "fs/promises";
1765
2485
 
1766
2486
  // src/tokens/scan-cache.ts
1767
- import * as fs3 from "fs/promises";
1768
- import * as path5 from "path";
1769
- var CACHE_VERSION = 1;
2487
+ import * as fs9 from "fs/promises";
2488
+ import * as path8 from "path";
2489
+ var CACHE_VERSION = 2;
1770
2490
  function isEntry(v) {
1771
2491
  const e = v;
1772
2492
  return !!e && typeof e.mtimeMs === "number" && typeof e.size === "number" && typeof e.offset === "number";
@@ -1802,7 +2522,7 @@ var ScanCache = class _ScanCache {
1802
2522
  * re-reading that line once the writer completes it.
1803
2523
  */
1804
2524
  async readIncremental(file, fold) {
1805
- const st = await fs3.stat(file);
2525
+ const st = await fs9.stat(file);
1806
2526
  const prev = this.entries.get(file);
1807
2527
  this.touched.add(file);
1808
2528
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
@@ -1819,11 +2539,11 @@ var ScanCache = class _ScanCache {
1819
2539
  * chats). Gated on mtime+size, recomputed in full whenever either moves.
1820
2540
  */
1821
2541
  async readWhenChanged(file, compute) {
1822
- const st = await fs3.stat(file);
2542
+ const st = await fs9.stat(file);
1823
2543
  const prev = this.entries.get(file);
1824
2544
  this.touched.add(file);
1825
2545
  if (prev && prev.mtimeMs === st.mtimeMs && prev.size === st.size) return prev.value;
1826
- const value = await compute(await fs3.readFile(file, "utf8"));
2546
+ const value = await compute(await fs9.readFile(file, "utf8"));
1827
2547
  this.entries.set(file, { mtimeMs: st.mtimeMs, size: st.size, offset: st.size, value });
1828
2548
  return value;
1829
2549
  }
@@ -1835,20 +2555,20 @@ var ScanCache = class _ScanCache {
1835
2555
  const target = cacheFile(this.source);
1836
2556
  const tmp = `${target}.tmp`;
1837
2557
  try {
1838
- await fs3.mkdir(path5.dirname(target), { recursive: true });
1839
- await fs3.writeFile(tmp, JSON.stringify({ version: CACHE_VERSION, files: Object.fromEntries(this.entries) }));
1840
- await fs3.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);
1841
2561
  } catch {
1842
2562
  }
1843
2563
  }
1844
2564
  };
1845
2565
  function cacheFile(source) {
1846
- return path5.join(homeDir(), "scan-cache", `${source}.json`);
2566
+ return path8.join(homeDir(), "scan-cache", `${source}.json`);
1847
2567
  }
1848
2568
  async function loadEntries(source) {
1849
2569
  let parsed;
1850
2570
  try {
1851
- parsed = JSON.parse(await fs3.readFile(cacheFile(source), "utf8"));
2571
+ parsed = JSON.parse(await fs9.readFile(cacheFile(source), "utf8"));
1852
2572
  } catch {
1853
2573
  return /* @__PURE__ */ new Map();
1854
2574
  }
@@ -1863,7 +2583,7 @@ async function loadEntries(source) {
1863
2583
  }
1864
2584
  async function readCompleteLines(file, start, end) {
1865
2585
  if (end <= start) return { lines: [], tail: null, consumedTo: start };
1866
- const fh = await fs3.open(file, "r");
2586
+ const fh = await fs9.open(file, "r");
1867
2587
  try {
1868
2588
  const buf = Buffer.allocUnsafe(end - start);
1869
2589
  const { bytesRead } = await fh.read(buf, 0, end - start, start);
@@ -1878,269 +2598,67 @@ async function readCompleteLines(file, start, end) {
1878
2598
  tail: tail === "" ? null : tail,
1879
2599
  consumedTo: start + lastNl + 1
1880
2600
  };
1881
- } finally {
1882
- await fh.close();
1883
- }
1884
- }
1885
-
1886
- // src/tokens/source-root.ts
1887
- var SourceRootMissing = class extends Error {
1888
- constructor(dir) {
1889
- super(`No history directory at ${dir}`);
1890
- this.dir = dir;
1891
- this.name = "SourceRootMissing";
1892
- }
1893
- dir;
1894
- };
1895
- async function readRoot(dir, read) {
1896
- try {
1897
- return await read();
1898
- } catch (e) {
1899
- if (e?.code === "ENOENT") throw new SourceRootMissing(dir);
1900
- throw e;
1901
- }
1902
- }
1903
-
1904
- // src/tokens/transcripts.ts
1905
- var MAX_PROJECT_DEPTH = 8;
1906
- function conversationId(file, root) {
1907
- const rel = path6.relative(root, file);
1908
- const [project, session] = rel.split(path6.sep);
1909
- if (project === void 0 || session === void 0) return rel.replace(/\.jsonl$/, "");
1910
- return `${project}/${session.replace(/\.jsonl$/, "")}`;
1911
- }
1912
- async function sumTokensByConversation() {
1913
- const root = claudeProjectsDir();
1914
- const files = await listJsonlFiles(root);
1915
- const cache = await ScanCache.open("claude");
1916
- const totals = await mapWithConcurrency(files, SCAN_CONCURRENCY, (f) => cache.readIncremental(f, CLAUDE_FOLD));
1917
- await cache.save();
1918
- const byConv = /* @__PURE__ */ new Map();
1919
- files.forEach((file, i) => {
1920
- const t = totals[i];
1921
- const id = conversationId(file, root);
1922
- const acc = byConv.get(id) ?? { input: 0, output: 0 };
1923
- acc.input += t.input;
1924
- acc.output += t.output;
1925
- byConv.set(id, acc);
1926
- });
1927
- return byConv;
1928
- }
1929
- async function sumTokens() {
1930
- const byConv = await sumTokensByConversation();
1931
- let input = 0;
1932
- let output = 0;
1933
- for (const t of byConv.values()) {
1934
- input += t.input;
1935
- output += t.output;
1936
- }
1937
- return { input, output };
1938
- }
1939
- async function listJsonlFiles(root) {
1940
- const entries = await readEntries(root, true);
1941
- const out = [];
1942
- for (const entry of entries) {
1943
- if (!await isDirectory(entry, root)) continue;
1944
- await collectJsonl(path6.join(root, entry.name), MAX_PROJECT_DEPTH, out);
1945
- }
1946
- return out;
1947
- }
1948
- async function collectJsonl(dir, depth, out) {
1949
- if (depth <= 0) return;
1950
- for (const entry of await readEntries(dir, false)) {
1951
- if (entry.name.endsWith(".jsonl")) {
1952
- out.push(path6.join(dir, entry.name));
1953
- } else if (depth > 1 && await isDirectory(entry, dir)) {
1954
- await collectJsonl(path6.join(dir, entry.name), depth - 1, out);
1955
- }
1956
- }
1957
- }
1958
- async function readEntries(dir, failLoud) {
1959
- if (failLoud) return readRoot(dir, () => fs4.readdir(dir, { withFileTypes: true }));
1960
- return fs4.readdir(dir, { withFileTypes: true }).catch(() => []);
1961
- }
1962
- async function isDirectory(entry, parent) {
1963
- if (entry.isDirectory()) return true;
1964
- if (!entry.isSymbolicLink()) return false;
1965
- return fs4.stat(path6.join(parent, entry.name)).then((st) => st.isDirectory()).catch(() => false);
1966
- }
1967
- function addNum(value) {
1968
- return typeof value === "number" && Number.isFinite(value) ? value : 0;
1969
- }
1970
- var CLAUDE_FOLD = {
1971
- empty: () => ({ input: 0, output: 0 }),
1972
- append: (acc, lines) => {
1973
- let { input, output } = acc;
1974
- for (const line of lines) {
1975
- if (!line.trim()) continue;
1976
- let parsed;
1977
- try {
1978
- parsed = JSON.parse(line);
1979
- } catch {
1980
- continue;
1981
- }
1982
- const usage = parsed?.message?.usage;
1983
- if (!usage) continue;
1984
- input += addNum(usage.input_tokens) + addNum(usage.cache_creation_input_tokens);
1985
- output += addNum(usage.output_tokens);
1986
- }
1987
- return { input, output };
1988
- }
1989
- };
1990
-
1991
- // src/tokens/codex.ts
1992
- import * as fs5 from "fs/promises";
1993
- import * as path7 from "path";
1994
- function num(v) {
1995
- return typeof v === "number" && Number.isFinite(v) ? v : 0;
1996
- }
1997
- async function sumCodexByConversation() {
1998
- const root = codexSessionsDir();
1999
- await readRoot(root, () => fs5.stat(root));
2000
- const files = await listCodexRollouts(root);
2001
- const cache = await ScanCache.open("codex");
2002
- const totals = await mapWithConcurrency(
2003
- files,
2004
- SCAN_CONCURRENCY,
2005
- (f) => cache.readIncremental(f, CODEX_FOLD).catch(() => ({ input: 0, output: 0 }))
2006
- );
2007
- await cache.save();
2008
- const byConv = /* @__PURE__ */ new Map();
2009
- files.forEach((file, i) => byConv.set(file, totals[i]));
2010
- return byConv;
2011
- }
2012
- async function sumCodexTokens() {
2013
- const byConv = await sumCodexByConversation();
2014
- let input = 0;
2015
- let output = 0;
2016
- for (const t of byConv.values()) {
2017
- input += t.input;
2018
- output += t.output;
2019
- }
2020
- return { input, output };
2021
- }
2022
- async function listCodexRollouts(root) {
2023
- return [
2024
- ...await collectRollouts(path7.join(root, "sessions")),
2025
- ...await collectRollouts(path7.join(root, "archived_sessions"))
2026
- ];
2027
- }
2028
- async function collectRollouts(dir) {
2029
- let entries;
2030
- try {
2031
- entries = await fs5.readdir(dir, { withFileTypes: true });
2032
- } catch (e) {
2033
- if (e?.code === "ENOENT") return [];
2034
- throw e;
2035
- }
2036
- const out = [];
2037
- for (const entry of entries) {
2038
- const full = path7.join(dir, entry.name);
2039
- if (entry.isDirectory()) out.push(...await collectRollouts(full));
2040
- else if (entry.name.startsWith("rollout-") && entry.name.endsWith(".jsonl")) out.push(full);
2041
- }
2042
- return out;
2043
- }
2044
- var CODEX_FOLD = {
2045
- empty: () => ({ input: 0, output: 0 }),
2046
- append: (acc, lines) => {
2047
- let usage = null;
2048
- for (const line of lines) {
2049
- if (!line.trim()) continue;
2050
- let parsed;
2051
- try {
2052
- parsed = JSON.parse(line);
2053
- } catch {
2054
- continue;
2055
- }
2056
- if (parsed?.payload?.type === "token_count" && parsed.payload.info?.total_token_usage) {
2057
- usage = parsed.payload.info.total_token_usage;
2058
- }
2059
- }
2060
- if (!usage) return acc;
2061
- return {
2062
- input: Math.max(0, num(usage.input_tokens) - num(usage.cached_input_tokens)),
2063
- output: num(usage.output_tokens)
2064
- };
2065
- }
2066
- };
2067
-
2068
- // src/tokens/gemini.ts
2069
- import * as fs6 from "fs/promises";
2070
- import * as path8 from "path";
2071
- function num2(v) {
2072
- return typeof v === "number" && Number.isFinite(v) ? v : 0;
2073
- }
2074
- async function sumGeminiByConversation() {
2075
- const files = await listChatFiles(geminiTmpDir());
2076
- const cache = await ScanCache.open("gemini");
2077
- const totals = await mapWithConcurrency(
2078
- files,
2079
- SCAN_CONCURRENCY,
2080
- (f) => cache.readWhenChanged(f, async (raw) => sumGeminiRaw(f, raw)).catch(() => ({ input: 0, output: 0 }))
2081
- );
2082
- await cache.save();
2083
- const byConv = /* @__PURE__ */ new Map();
2084
- files.forEach((file, i) => byConv.set(file, totals[i]));
2085
- return byConv;
2086
- }
2087
- async function sumGeminiTokens() {
2088
- const byConv = await sumGeminiByConversation();
2089
- let input = 0;
2090
- let output = 0;
2091
- for (const t of byConv.values()) {
2092
- input += t.input;
2093
- output += t.output;
2094
- }
2095
- return { input, output };
2096
- }
2097
- async function listChatFiles(root) {
2098
- const entries = await readRoot(root, () => fs6.readdir(root));
2099
- const out = [];
2100
- for (const entry of entries) {
2101
- const chatsDir = path8.join(root, entry, "chats");
2102
- let files;
2103
- try {
2104
- files = await fs6.readdir(chatsDir);
2105
- } catch {
2106
- continue;
2107
- }
2108
- for (const f of files) {
2109
- if (f.endsWith(".json") || f.endsWith(".jsonl")) out.push(path8.join(chatsDir, f));
2110
- }
2111
- }
2112
- return out;
2113
- }
2114
- function sumGeminiRaw(file, raw) {
2115
- const messages = file.endsWith(".jsonl") ? parseJsonl(raw) : parseJson(raw);
2116
- let input = 0;
2117
- let output = 0;
2118
- for (const m of messages) {
2119
- const tk = m?.tokens;
2120
- if (!tk || typeof tk !== "object") continue;
2121
- input += Math.max(0, num2(tk.input) - num2(tk.cached));
2122
- output += num2(tk.output);
2123
- }
2124
- return { input, output };
2125
- }
2126
- function parseJson(raw) {
2127
- try {
2128
- const data = JSON.parse(raw);
2129
- return Array.isArray(data?.messages) ? data.messages : [];
2130
- } catch {
2131
- return [];
2601
+ } finally {
2602
+ await fh.close();
2132
2603
  }
2133
2604
  }
2134
- function parseJsonl(raw) {
2135
- const out = [];
2136
- for (const line of raw.split("\n")) {
2137
- if (!line.trim()) continue;
2138
- try {
2139
- out.push(JSON.parse(line));
2140
- } 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);
2141
2633
  }
2142
- }
2143
- 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 };
2144
2662
  }
2145
2663
 
2146
2664
  // src/tokens/race-tokens.ts
@@ -2153,60 +2671,70 @@ async function scanWithTimeout(scan, timeoutMs, describeTimeout) {
2153
2671
  const budget = new Promise((resolve) => {
2154
2672
  timer = setTimeout(() => resolve(TIMED_OUT), timeoutMs);
2155
2673
  });
2674
+ const startedAt2 = Date.now();
2156
2675
  try {
2157
2676
  const result = await Promise.race([scan(), budget]);
2158
- if (result !== TIMED_OUT) return result;
2677
+ if (result !== TIMED_OUT) {
2678
+ if (isStall(result)) logWarn("scan.stall", { reason: result.stall, ms: Date.now() - startedAt2 });
2679
+ return result;
2680
+ }
2159
2681
  const detail = describeTimeout ? await describeTimeout() : null;
2160
- return { stall: detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s` };
2682
+ const stall = detail ?? `Token scan timed out after ${Math.round(timeoutMs / 1e3)}s`;
2683
+ logWarn("scan.timeout", { budget_ms: timeoutMs, reason: stall });
2684
+ return { stall };
2685
+ } catch (err) {
2686
+ logError("scan.error", {
2687
+ message: err?.message ?? String(err),
2688
+ stack: err?.stack,
2689
+ ms: Date.now() - startedAt2
2690
+ });
2691
+ throw err;
2161
2692
  } finally {
2162
2693
  clearTimeout(timer);
2163
2694
  }
2164
2695
  }
2165
- var SCALAR_READERS = {
2166
- claude: sumTokens,
2167
- codex: sumCodexTokens,
2168
- gemini: sumGeminiTokens
2169
- };
2170
- var BY_CONVERSATION_READERS = {
2171
- claude: sumTokensByConversation,
2172
- codex: sumCodexByConversation,
2173
- gemini: sumGeminiByConversation
2174
- };
2175
- function scoreFor(race, t) {
2176
- return race.counts_input ? t.input + t.output : t.output;
2177
- }
2178
- async function readAllSources(race, primary, progress) {
2179
- progress?.begin(primary);
2180
- const primaryScan = BY_CONVERSATION_READERS[primary]().then(
2181
- (map) => ({ ok: true, map }),
2182
- (err) => ({ ok: false, err })
2183
- ).finally(() => progress?.end(primary));
2184
- const secondaryKeys = MODEL_KEYS.filter((k) => k !== primary);
2185
- const secondaryScans = secondaryKeys.map((k) => {
2186
- progress?.begin(k);
2187
- return SCALAR_READERS[k]().then((t) => scoreFor(race, t)).catch(() => 0).finally(() => progress?.end(k));
2188
- });
2189
- const [primaryResult, secondaryValues] = await Promise.all([
2190
- primaryScan,
2191
- Promise.all(secondaryScans)
2192
- ]);
2193
- const primaryByConv = /* @__PURE__ */ new Map();
2194
- if (primaryResult.ok) {
2195
- for (const [id, totals] of primaryResult.map) primaryByConv.set(id, scoreFor(race, totals));
2196
- } else if (!(primaryResult.err instanceof SourceRootMissing)) {
2197
- const err = primaryResult.err;
2198
- return { stall: `Can't read ${primary} token usage: ${err?.message ?? String(err)}` };
2199
- }
2200
- const secondary = { claude: 0, codex: 0, gemini: 0 };
2201
- secondaryKeys.forEach((k, i) => {
2202
- 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));
2203
2707
  });
2204
- 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
+ }
2205
2733
  }
2206
2734
 
2207
2735
  // src/tokens/scan-progress.ts
2208
2736
  function skipVar(key) {
2209
- return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2737
+ return HARNESSES[key].overrideVar;
2210
2738
  }
2211
2739
  function formatBytes(bytes) {
2212
2740
  if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(1)} GB`;
@@ -2240,45 +2768,30 @@ async function diagnoseScanTimeout(timeoutMs, progress) {
2240
2768
  return describeScanTimeout(timeoutMs, outstanding);
2241
2769
  }
2242
2770
 
2243
- // src/tokens/primary-cap.ts
2244
- var PRIMARY_TOP_CONVERSATIONS = 5;
2245
- function primaryConversationCap(enabled) {
2246
- return enabled ? PRIMARY_TOP_CONVERSATIONS : Infinity;
2247
- }
2248
-
2249
2771
  // src/tokens/race-score.ts
2250
2772
  var STALL_THRESHOLD = 5;
2251
- function zero() {
2252
- return { claude: 0, codex: 0, gemini: 0 };
2773
+ function cloneAnchors(a) {
2774
+ return { anthropic: { ...a.anthropic }, openai: { ...a.openai }, google: { ...a.google } };
2253
2775
  }
2254
2776
  var RaceScoreTracker = class {
2255
- acked;
2256
- lastGood;
2257
- primaryConvAcked;
2258
- primaryConvLast;
2777
+ convAcked;
2778
+ convLast;
2259
2779
  counted;
2260
2780
  seq;
2261
2781
  stalls = 0;
2262
2782
  lastStall = null;
2263
- primaryEmptyBeats = 0;
2264
- primary;
2265
- primaryTop5;
2266
- constructor(init, primary, primaryTop5) {
2267
- this.acked = { ...init.acked };
2268
- this.lastGood = { ...init.lastGood };
2269
- this.primaryConvAcked = { ...init.primaryConvAcked };
2270
- this.primaryConvLast = { ...init.primaryConvAcked };
2271
- 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 };
2272
2788
  this.seq = init.seq;
2273
- this.primary = primary;
2274
- this.primaryTop5 = primaryTop5;
2275
2789
  }
2276
2790
  /**
2277
2791
  * Record a scan result.
2278
2792
  * - `null` or a `{ stall }` reading → stall (warning), anchors untouched. A
2279
2793
  * stall reading also captures its cause for the UI.
2280
- * - otherwise → secondaries advance scalar lastGood (never down to 0); the
2281
- * primary's per-conversation latest readings are updated (monotonic).
2794
+ * - otherwise → every model's per-conversation readings advance (monotonic).
2282
2795
  */
2283
2796
  recordReading(reading) {
2284
2797
  if (reading === null || isStall(reading)) {
@@ -2288,181 +2801,91 @@ var RaceScoreTracker = class {
2288
2801
  }
2289
2802
  this.stalls = 0;
2290
2803
  this.lastStall = null;
2291
- for (const key of MODEL_KEYS) {
2292
- if (key === this.primary) continue;
2293
- const v = reading.secondary[key];
2294
- if (v > 0) this.lastGood[key] = v;
2295
- }
2296
- this.primaryEmptyBeats = reading.primaryByConv.size === 0 ? this.primaryEmptyBeats + 1 : 0;
2297
- for (const [id, v] of reading.primaryByConv) {
2298
- const prev = this.primaryConvLast[id] ?? 0;
2299
- 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
+ }
2300
2811
  }
2301
2812
  }
2302
2813
  /** Frozen payload for the next heartbeat. Pure — call repeatedly for retries. */
2303
2814
  nextBeat() {
2304
- const components = zero();
2305
- for (const key of MODEL_KEYS) {
2306
- if (key === this.primary) continue;
2307
- components[key] = Math.max(0, this.lastGood[key] - this.acked[key]);
2308
- }
2309
- const pending = [];
2310
- for (const [id, last] of Object.entries(this.primaryConvLast)) {
2311
- const d = Math.max(0, last - (this.primaryConvAcked[id] ?? 0));
2312
- 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;
2313
2822
  }
2314
- pending.sort((a, b) => b - a);
2315
- const cap = primaryConversationCap(this.primaryTop5);
2316
- const take = cap === Infinity ? pending.length : Math.min(cap, pending.length);
2317
- let primarySum = 0;
2318
- for (const d of pending.slice(0, take)) primarySum += d;
2319
- components[this.primary] = primarySum;
2320
2823
  return {
2321
2824
  seq: this.seq + 1,
2322
2825
  components,
2323
- readings: { ...this.lastGood },
2324
- primaryConvReadings: { ...this.primaryConvLast }
2826
+ convReadings: cloneAnchors(this.convLast)
2325
2827
  };
2326
2828
  }
2327
2829
  /** Commit a heartbeat the server accepted. `serverLastSeq` self-heals drift. */
2328
2830
  ack(snapshot, serverLastSeq) {
2329
- for (const key of MODEL_KEYS) {
2330
- if (key === this.primary) continue;
2331
- this.acked[key] = snapshot.readings[key];
2332
- }
2333
- this.primaryConvAcked = { ...snapshot.primaryConvReadings };
2334
- 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];
2335
2833
  this.seq = Math.max(snapshot.seq, serverLastSeq);
2336
2834
  }
2337
2835
  /** Pin anchors to the latest readings so the next deltas are 0 (pending race). */
2338
2836
  reprime() {
2339
- for (const key of MODEL_KEYS) {
2340
- if (key === this.primary) continue;
2341
- this.acked[key] = this.lastGood[key];
2342
- }
2343
- this.primaryConvAcked = { ...this.primaryConvLast };
2837
+ this.convAcked = cloneAnchors(this.convLast);
2344
2838
  }
2345
2839
  get stalled() {
2346
2840
  return this.stalls >= STALL_THRESHOLD;
2347
2841
  }
2348
- /** The primary source has produced no conversations for long enough to be worth saying. */
2349
- get primarySilent() {
2350
- return this.primaryEmptyBeats >= PRIMARY_SILENT_THRESHOLD;
2842
+ /** No source has produced any conversations for long enough to be worth saying. */
2843
+ get sourcesSilent() {
2844
+ return this.emptyBeats >= SILENT_THRESHOLD;
2351
2845
  }
2352
2846
  /** Human-readable cause of the most recent stall (null once a good read recovers). */
2353
2847
  get stallReason() {
2354
2848
  return this.lastStall;
2355
2849
  }
2356
- /** Cumulative primary tokens credited so far (for the UI's primary "since join" row). */
2357
- primaryCounted() {
2358
- return this.counted;
2850
+ /** Cumulative tokens credited per family since joining (for the UI's model rows). */
2851
+ countedPerFamily() {
2852
+ return { ...this.counted };
2359
2853
  }
2360
- /** Secondary "since join" totals = lastGood − baseline (for the UI). Primary key is 0 here. */
2361
- secondarySinceJoin(baseline) {
2362
- const out = zero();
2363
- for (const key of MODEL_KEYS) {
2364
- if (key === this.primary) continue;
2365
- out[key] = Math.max(0, this.lastGood[key] - baseline[key]);
2366
- }
2367
- 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;
2368
2859
  }
2369
2860
  toState() {
2370
2861
  return {
2371
- acked: { ...this.acked },
2372
- lastGood: { ...this.lastGood },
2373
- primaryConvAcked: { ...this.primaryConvAcked },
2374
- primaryCounted: this.counted,
2862
+ convAcked: cloneAnchors(this.convAcked),
2863
+ counted: { ...this.counted },
2375
2864
  seq: this.seq
2376
2865
  };
2377
2866
  }
2378
2867
  };
2379
2868
 
2380
- // src/tokens/source-probe.ts
2381
- import * as fs7 from "fs/promises";
2382
- var ROOTS = {
2383
- claude: claudeProjectsDir,
2384
- codex: codexSessionsDir,
2385
- gemini: geminiTmpDir
2386
- };
2387
- var LISTERS = {
2388
- claude: listJsonlFiles,
2389
- codex: listCodexRollouts,
2390
- gemini: listChatFiles
2391
- };
2392
- var LABELS2 = { claude: "Claude", codex: "Codex", gemini: "Gemini" };
2393
- function sourceDir(key) {
2394
- return ROOTS[key]();
2395
- }
2396
- async function probeSource(key) {
2397
- const dir = ROOTS[key]();
2398
- const exists = await fs7.stat(dir).then((st) => st.isDirectory()).catch(() => false);
2399
- if (!exists) return { key, dir, exists: false, projects: 0, transcripts: 0 };
2400
- const entries = await fs7.readdir(dir, { withFileTypes: true }).catch(() => []);
2401
- const projects = entries.filter((e) => e.isDirectory() || e.isSymbolicLink()).length;
2402
- const files = await LISTERS[key](dir).catch(() => []);
2403
- return { key, dir, exists: true, projects, transcripts: files.length };
2404
- }
2405
- function overrideVar(key) {
2406
- return `TOKEN_DERBY_${key.toUpperCase()}_DIR`;
2407
- }
2408
- async function confirmEmptySource(opts) {
2409
- if (opts.probe.transcripts > 0) return true;
2410
- opts.warn(describeEmptySource(opts.probe));
2411
- if (!opts.interactive) return true;
2412
- return opts.ask();
2413
- }
2414
- function describeEmptySource(probe) {
2415
- const label = LABELS2[probe.key];
2416
- const populated = probe.exists && probe.projects > 0;
2417
- const reason = !probe.exists ? "does not exist" : populated ? `holds ${probe.projects} project ${probe.projects === 1 ? "directory" : "directories"}, none of which could be read` : "exists, but holds no transcripts";
2418
- const lines = [
2419
- `\u26A0 No ${label} transcripts found \u2014 your horse will not move.`,
2420
- ``,
2421
- ` Looked in: ${probe.dir}`,
2422
- ` (${reason})`,
2423
- ``
2424
- ];
2425
- if (populated) {
2426
- lines.push(
2427
- ` The directory is there and has history in it, so this is usually a`,
2428
- ` dangling symlink or a permissions problem on one of those projects.`,
2429
- ` To find dangling links:`,
2430
- ` find ${probe.dir} -type l ! -exec test -e {} \\; -print`,
2431
- ``
2432
- );
2433
- }
2434
- lines.push(
2435
- ` Token Derby counts ${label} usage from this machine's own filesystem.`,
2436
- ` If ${label} runs in a container, over SSH, or on another machine, join`,
2437
- ` the race from there instead.`
2438
- );
2439
- if (probe.key === "claude") {
2440
- lines.push(
2441
- ` If CLAUDE_CONFIG_DIR relocated your config, Token Derby follows it \u2014`,
2442
- ` check it points at the config root, not the projects directory.`
2443
- );
2444
- }
2445
- lines.push(``, ` To read them from somewhere else: export ${overrideVar(probe.key)}=<dir>`);
2446
- return lines.join("\n");
2447
- }
2448
-
2449
2869
  // src/runtime/run-race.tsx
2450
- import { jsx as jsx8, jsxs as jsxs6 } from "react/jsx-runtime";
2870
+ import { jsx as jsx7, jsxs as jsxs5 } from "react/jsx-runtime";
2451
2871
  function RunRace({ active, initialState, pendingMode, ownUserName }) {
2452
2872
  const { exit } = useApp();
2453
- const [race, setRace] = useState5(null);
2454
- const [lastHbAt, setLastHbAt] = useState5(null);
2455
- const [lastHbOk, setLastHbOk] = useState5(true);
2456
- const [tickNow, setTickNow] = useState5(/* @__PURE__ */ new Date());
2457
- const [fatalError, setFatalError] = useState5(null);
2458
- 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([]);
2459
2879
  const shownAchievementAtRef = useRef(0);
2460
- const trackerRef = useRef(new RaceScoreTracker(initialState, active.primary_model, active.primary_top5 ?? false));
2880
+ const trackerRef = useRef(new RaceScoreTracker(initialState));
2461
2881
  const pendingRef = useRef(pendingMode);
2462
2882
  const ctrl = useRef(new AbortController());
2463
- const [stalled, setStalled] = useState5(false);
2464
- const [stallReason, setStallReason] = useState5(null);
2465
- 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([]);
2466
2889
  useEffect2(() => {
2467
2890
  const t = setInterval(() => setTickNow(/* @__PURE__ */ new Date()), 1e3);
2468
2891
  return () => clearInterval(t);
@@ -2479,7 +2902,7 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2479
2902
  const progress = new ScanProgress();
2480
2903
  try {
2481
2904
  return await scanWithTimeout(
2482
- () => readAllSources(active, active.primary_model, progress),
2905
+ () => readAllSources(progress),
2483
2906
  SCAN_TIMEOUT_MS,
2484
2907
  () => diagnoseScanTimeout(SCAN_TIMEOUT_MS, progress)
2485
2908
  );
@@ -2494,7 +2917,11 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2494
2917
  if (pendingRef.current && !isStall(reading)) tracker.reprime();
2495
2918
  setStalled(tracker.stalled);
2496
2919
  setStallReason(tracker.stalled ? tracker.stallReason : null);
2497
- 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)));
2498
2925
  return tracker.nextBeat();
2499
2926
  },
2500
2927
  sendBeat: async (snapshot) => {
@@ -2544,13 +2971,13 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2544
2971
  }, []);
2545
2972
  const lastHeartbeatAgoSec = lastHbAt ? Math.max(0, Math.floor((tickNow.getTime() - lastHbAt.getTime()) / 1e3)) : null;
2546
2973
  if (fatalError) {
2547
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", padding: 1, children: [
2548
- /* @__PURE__ */ jsx8(Text8, { color: "red", bold: true, children: "CLI version mismatch \u2014 disconnected" }),
2549
- /* @__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 })
2550
2977
  ] });
2551
2978
  }
2552
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", children: [
2553
- /* @__PURE__ */ jsx8(
2979
+ return /* @__PURE__ */ jsxs5(Box7, { flexDirection: "column", children: [
2980
+ /* @__PURE__ */ jsx7(
2554
2981
  StatusScreen,
2555
2982
  {
2556
2983
  race,
@@ -2562,28 +2989,29 @@ function RunRace({ active, initialState, pendingMode, ownUserName }) {
2562
2989
  lastHeartbeatOk: lastHbOk,
2563
2990
  stalled,
2564
2991
  stallReason,
2565
- primarySilent,
2566
- primarySourceDir: sourceDir(active.primary_model),
2567
- primaryModel: active.primary_model
2992
+ sourcesSilent,
2993
+ degraded,
2994
+ notices,
2995
+ disabledHarnesses
2568
2996
  }
2569
2997
  ),
2570
- achievements.length > 0 && /* @__PURE__ */ jsxs6(Box8, { flexDirection: "column", marginTop: 1, children: [
2571
- /* @__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" }),
2572
3000
  achievements.map(({ key, event }) => {
2573
- const description = describeAchievement(event, active);
2574
- return /* @__PURE__ */ jsxs6(Box8, { flexDirection: "row", children: [
2575
- /* @__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: [
2576
3004
  " ",
2577
3005
  formatClockTime(event.at),
2578
3006
  " "
2579
3007
  ] }),
2580
- /* @__PURE__ */ jsxs6(Text8, { color: "yellow", bold: true, children: [
3008
+ /* @__PURE__ */ jsxs5(Text7, { color: "yellow", bold: true, children: [
2581
3009
  "+",
2582
3010
  event.xp,
2583
3011
  " XP "
2584
3012
  ] }),
2585
- /* @__PURE__ */ jsx8(Text8, { children: event.name }),
2586
- /* @__PURE__ */ jsxs6(Text8, { dimColor: true, children: [
3013
+ /* @__PURE__ */ jsx7(Text7, { children: event.name }),
3014
+ /* @__PURE__ */ jsxs5(Text7, { dimColor: true, children: [
2587
3015
  " \u2014 ",
2588
3016
  description
2589
3017
  ] })
@@ -2609,28 +3037,81 @@ function raceViewFrom(resp) {
2609
3037
  };
2610
3038
  }
2611
3039
  async function buildInitialState(args) {
2612
- let secondary = { claude: 0, codex: 0, gemini: 0 };
2613
- const primaryConvAcked = {};
3040
+ const convAcked = { anthropic: {}, openai: {}, google: {} };
2614
3041
  try {
2615
- const now = await readAllSources(args.active, args.active.primary_model);
3042
+ const now = await readAllSources();
2616
3043
  if (!isStall(now)) {
2617
- secondary = now.secondary;
2618
- 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
+ }
2619
3047
  }
2620
3048
  } catch {
2621
3049
  }
2622
3050
  return {
2623
3051
  initialState: {
2624
- acked: { ...secondary },
2625
- lastGood: { ...secondary },
2626
- primaryConvAcked,
2627
- primaryCounted: 0,
3052
+ convAcked,
3053
+ counted: zeroPerFamily(),
2628
3054
  seq: args.serverLastSeq
2629
3055
  },
2630
3056
  pendingMode: args.raceStatus === "pending"
2631
3057
  };
2632
3058
  }
2633
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
+
2634
3115
  // src/ui/prompt.ts
2635
3116
  async function promptYesNo(question, opts = {}) {
2636
3117
  const input = opts.input ?? process.stdin;
@@ -2654,31 +3135,12 @@ function resetStdinAfterInk() {
2654
3135
  }
2655
3136
 
2656
3137
  // src/commands/join.ts
2657
- function parsePrimaryFlag(argv) {
2658
- for (let i = 0; i < argv.length; i++) {
2659
- const a = argv[i];
2660
- let value;
2661
- if (a === "--primary") value = argv[i + 1];
2662
- else if (a.startsWith("--primary=")) value = a.slice("--primary=".length);
2663
- else continue;
2664
- if (!isModelKey(value)) throw new Error(`--primary must be one of claude, codex, gemini (got ${value ?? ""})`);
2665
- return value;
2666
- }
2667
- return null;
2668
- }
2669
3138
  async function joinCommand(joinCode, argv = []) {
2670
3139
  if (!joinCode) {
2671
3140
  console.error("Usage: token-derby join <join-code>");
2672
3141
  return 2;
2673
3142
  }
2674
3143
  const code = joinCode.toUpperCase();
2675
- let primaryFlag;
2676
- try {
2677
- primaryFlag = parsePrimaryFlag(argv);
2678
- } catch (e) {
2679
- console.error(e.message);
2680
- return 2;
2681
- }
2682
3144
  const identity = await loadIdentity();
2683
3145
  if (!identity) {
2684
3146
  console.error("Run `token-derby login` to set up your identity.");
@@ -2733,7 +3195,27 @@ async function joinCommand(joinCode, argv = []) {
2733
3195
  console.error("Your stable is empty. Run `token-derby stable create` first.");
2734
3196
  return 1;
2735
3197
  }
2736
- 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
+ }
2737
3219
  if (!picked) {
2738
3220
  console.log("Cancelled.");
2739
3221
  return 1;
@@ -2742,14 +3224,8 @@ async function joinCommand(joinCode, argv = []) {
2742
3224
  chosenName = picked.name;
2743
3225
  chosenColors = picked.colors;
2744
3226
  }
2745
- let chosenPrimary = "claude";
2746
- if (!ownHorse) {
2747
- if (primaryFlag) chosenPrimary = primaryFlag;
2748
- else if (process.stdout.isTTY) chosenPrimary = await pickPrimary();
2749
- }
2750
- const effectivePrimary = ownHorse?.primary_model ?? chosenPrimary;
2751
- const proceed = await confirmEmptySource({
2752
- probe: await probeSource(effectivePrimary),
3227
+ const proceed = await confirmNoSources({
3228
+ probes: await probeAll(),
2753
3229
  interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
2754
3230
  warn: (text) => console.error(`
2755
3231
  ${text}
@@ -2762,7 +3238,7 @@ ${text}
2762
3238
  }
2763
3239
  let joinResp;
2764
3240
  try {
2765
- joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId, primary_model: chosenPrimary });
3241
+ joinResp = await joinRace(code, { stable_horse_id: chosenStableHorseId });
2766
3242
  } catch (e) {
2767
3243
  if (e instanceof ApiError) {
2768
3244
  if (e.code === "RACE_FULL") console.error("This race is full.");
@@ -2788,27 +3264,22 @@ ${text}
2788
3264
  horse_colors: chosenColors,
2789
3265
  joined_at: ownHorse?.joined_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2790
3266
  last_heartbeat_at: (/* @__PURE__ */ new Date(0)).toISOString(),
2791
- primary_model: joinResp.primary_model,
2792
3267
  score: {
2793
- acked: { claude: 0, codex: 0, gemini: 0 },
2794
- lastGood: { claude: 0, codex: 0, gemini: 0 },
2795
- primaryConvAcked: {},
2796
- primaryCounted: 0,
3268
+ convAcked: { anthropic: {}, openai: {}, google: {} },
3269
+ counted: { anthropic: 0, openai: 0, google: 0 },
2797
3270
  seq: ownHorse?.last_seq ?? 0
2798
- },
2799
- ...race.counts_input ? { counts_input: true } : {},
2800
- ...race.primary_top5 ? { primary_top5: true } : {}
3271
+ }
2801
3272
  };
2802
3273
  await saveActiveRace(active);
2803
3274
  const initial = await buildInitialState({ active, raceStatus: status, serverLastSeq: ownHorse?.last_seq ?? 0 });
2804
- 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 }));
2805
3276
  await app.waitUntilExit();
2806
3277
  return 0;
2807
3278
  }
2808
3279
  async function pickHorse(horses) {
2809
3280
  return new Promise((resolve) => {
2810
3281
  const app = render4(
2811
- React9.createElement(HorsePicker, {
3282
+ React5.createElement(HorsePicker, {
2812
3283
  horses,
2813
3284
  onPick: (h) => {
2814
3285
  app.unmount();
@@ -2822,18 +3293,6 @@ async function pickHorse(horses) {
2822
3293
  );
2823
3294
  });
2824
3295
  }
2825
- async function pickPrimary() {
2826
- return new Promise((resolve) => {
2827
- const app = render4(
2828
- React9.createElement(PrimaryPicker, {
2829
- onPick: (m) => {
2830
- app.unmount();
2831
- resolve(m);
2832
- }
2833
- })
2834
- );
2835
- });
2836
- }
2837
3296
 
2838
3297
  // src/commands/end.ts
2839
3298
  import * as readline3 from "readline/promises";
@@ -3558,16 +4017,16 @@ function runNpmUpgrade(spawnImpl) {
3558
4017
  }
3559
4018
 
3560
4019
  // src/commands/roll.ts
3561
- import React14 from "react";
4020
+ import React7 from "react";
3562
4021
  import { render as render6 } from "ink";
3563
4022
 
3564
4023
  // src/ui/RollHorsePicker.tsx
3565
- import { useState as useState6 } from "react";
3566
- import { Box as Box9, Text as Text9, useInput as useInput4 } from "ink";
3567
- 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";
3568
4027
  function RollHorsePicker({ horses, onPick, onCancel }) {
3569
- const [idx, setIdx] = useState6(0);
3570
- useInput4((input, key) => {
4028
+ const [idx, setIdx] = useState5(0);
4029
+ useInput3((input, key) => {
3571
4030
  if (key.escape) {
3572
4031
  onCancel();
3573
4032
  return;
@@ -3586,61 +4045,61 @@ function RollHorsePicker({ horses, onPick, onCancel }) {
3586
4045
  return;
3587
4046
  }
3588
4047
  });
3589
- return /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", children: [
3590
- /* @__PURE__ */ jsx9(Text9, { children: "Pick a horse to roll for:" }),
3591
- horses.map((h, i) => /* @__PURE__ */ jsxs7(Box9, { flexDirection: "column", children: [
3592
- /* @__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: [
3593
4052
  i === idx ? "\u25BA" : " ",
3594
4053
  " ",
3595
4054
  h.name,
3596
4055
  " ",
3597
- /* @__PURE__ */ jsxs7(Text9, { color: "cyan", children: [
4056
+ /* @__PURE__ */ jsxs6(Text8, { color: "cyan", children: [
3598
4057
  "[Lvl. ",
3599
4058
  levelFromXp(h.xp),
3600
4059
  "]"
3601
4060
  ] }),
3602
4061
  " ",
3603
- /* @__PURE__ */ jsxs7(Text9, { color: "yellow", children: [
4062
+ /* @__PURE__ */ jsxs6(Text8, { color: "yellow", children: [
3604
4063
  "\u2014 ",
3605
4064
  h.pending,
3606
4065
  " roll",
3607
4066
  h.pending === 1 ? "" : "s"
3608
4067
  ] })
3609
4068
  ] }) }),
3610
- /* @__PURE__ */ jsxs7(Box9, { flexDirection: "row", children: [
3611
- /* @__PURE__ */ jsx9(Text9, { children: " " }),
3612
- /* @__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 })
3613
4072
  ] })
3614
4073
  ] }, h.stable_horse_id)),
3615
- /* @__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" }) })
3616
4075
  ] });
3617
4076
  }
3618
4077
 
3619
4078
  // src/ui/reveal.ts
3620
- import React13 from "react";
4079
+ import React6 from "react";
3621
4080
  import { render as render5 } from "ink";
3622
4081
 
3623
4082
  // src/ui/RollReveal.tsx
3624
- import { useState as useState8, useEffect as useEffect4, useMemo } from "react";
3625
- 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";
3626
4085
 
3627
4086
  // src/ui/HatSprite.tsx
3628
- import { useEffect as useEffect3, useState as useState7 } from "react";
3629
- import { Box as Box10, Text as Text10 } from "ink";
3630
- 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";
3631
4090
  function HatSprite({ hat, variant, centerIn }) {
3632
4091
  const colors = hatColors(hat, variant ?? 0);
3633
4092
  const grid = makeHatGrid(hat, colors, centerIn);
3634
4093
  const lines = hexGridToHalfBlocks(grid);
3635
- 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)) });
3636
4095
  }
3637
4096
  function AnimatedHatSprite({ hat, variant, centerIn }) {
3638
4097
  if (!isAnimatedHat(hat)) {
3639
- return /* @__PURE__ */ jsx10(HatSprite, { hat, variant, centerIn });
4098
+ return /* @__PURE__ */ jsx9(HatSprite, { hat, variant, centerIn });
3640
4099
  }
3641
4100
  const frames = hat.animation.frames;
3642
4101
  const fps = hat.animation.fps;
3643
- const [idx, setIdx] = useState7(0);
4102
+ const [idx, setIdx] = useState6(0);
3644
4103
  useEffect3(() => {
3645
4104
  if (frames.length <= 1) return;
3646
4105
  const interval = setInterval(
@@ -3650,7 +4109,7 @@ function AnimatedHatSprite({ hat, variant, centerIn }) {
3650
4109
  return () => clearInterval(interval);
3651
4110
  }, [frames.length, fps]);
3652
4111
  const framed = { ...hat, colors: { ...hat.colors, A: frames[idx] } };
3653
- return /* @__PURE__ */ jsx10(HatSprite, { hat: framed, variant, centerIn });
4112
+ return /* @__PURE__ */ jsx9(HatSprite, { hat: framed, variant, centerIn });
3654
4113
  }
3655
4114
  function makeHatGrid(hat, colors, centerIn) {
3656
4115
  const w = centerIn?.w ?? hat.width;
@@ -3673,7 +4132,7 @@ function makeHatGrid(hat, colors, centerIn) {
3673
4132
  }
3674
4133
 
3675
4134
  // src/ui/RollReveal.tsx
3676
- import { jsx as jsx11 } from "react/jsx-runtime";
4135
+ import { jsx as jsx10 } from "react/jsx-runtime";
3677
4136
  var RESET3 = "\x1B[0m";
3678
4137
  var BOX_COLOR = "#E5C76B";
3679
4138
  var TIER_PALETTE = {
@@ -3743,13 +4202,13 @@ var BOX_EMPTY = [
3743
4202
  ""
3744
4203
  ].map(pad);
3745
4204
  function GiftBox({ frame, color }) {
3746
- 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)) });
3747
4206
  }
3748
- function spawnParticles(tier, count, cx, cy) {
4207
+ function spawnParticles(tier, count2, cx, cy) {
3749
4208
  const palette = TIER_PALETTE[tier];
3750
4209
  const out = [];
3751
- for (let i = 0; i < count; i++) {
3752
- 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;
3753
4212
  const speed = 0.8 + Math.random() * 1.6;
3754
4213
  out.push({
3755
4214
  x: cx,
@@ -3766,7 +4225,7 @@ function ConfettiBurst({ tier }) {
3766
4225
  const cx = Math.floor(SCENE_W / 2);
3767
4226
  const cy = Math.floor(SCENE_H / 2);
3768
4227
  const particles = useMemo(() => spawnParticles(tier, 36, cx, cy), [tier, cx, cy]);
3769
- const [tick, setTick] = useState8(0);
4228
+ const [tick, setTick] = useState7(0);
3770
4229
  useEffect4(() => {
3771
4230
  const i = setInterval(() => setTick((t) => t + 1), 70);
3772
4231
  return () => clearInterval(i);
@@ -3779,13 +4238,13 @@ function ConfettiBurst({ tier }) {
3779
4238
  grid[y][x] = ansiFg(p.color) + p.char + RESET3;
3780
4239
  }
3781
4240
  }
3782
- 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)) });
3783
4242
  }
3784
4243
  var CLOSED_HOLD_MS = 3e3;
3785
4244
  function RollReveal({ outcome, onDone }) {
3786
4245
  const isNoHat = outcome.kind === "no_hat";
3787
4246
  const isShowpiece = outcome.kind !== "no_hat" && (outcome.hat.rarity === "legendary" || outcome.hat.rarity === "limited");
3788
- const [phase, setPhase] = useState8("closed");
4247
+ const [phase, setPhase] = useState7("closed");
3789
4248
  useEffect4(() => {
3790
4249
  const timers = [];
3791
4250
  timers.push(setTimeout(() => setPhase("open1"), CLOSED_HOLD_MS));
@@ -3800,21 +4259,21 @@ function RollReveal({ outcome, onDone }) {
3800
4259
  }
3801
4260
  return () => timers.forEach(clearTimeout);
3802
4261
  }, [isNoHat, isShowpiece, onDone]);
3803
- if (phase === "closed") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_CLOSED, color: BOX_COLOR });
3804
- if (phase === "open1") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_1, color: BOX_COLOR });
3805
- if (phase === "open2") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_OPENING_2, color: BOX_COLOR });
3806
- 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 });
3807
4266
  if (phase === "burst" && outcome.kind !== "no_hat") {
3808
- return /* @__PURE__ */ jsx11(ConfettiBurst, { tier: outcome.hat.rarity });
4267
+ return /* @__PURE__ */ jsx10(ConfettiBurst, { tier: outcome.hat.rarity });
3809
4268
  }
3810
- if (outcome.kind === "no_hat") return /* @__PURE__ */ jsx11(GiftBox, { frame: BOX_EMPTY, color: BOX_COLOR });
3811
- return isAnimatedHat(outcome.hat) ? /* @__PURE__ */ jsx11(AnimatedHatSprite, { hat: outcome.hat, centerIn: { w: SCENE_W, h: SCENE_H } }) : /* @__PURE__ */ jsx11(HatSprite, { hat: outcome.hat, variant: outcome.variant, centerIn: { w: SCENE_W, h: SCENE_H } });
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 } });
3812
4271
  }
3813
4272
 
3814
4273
  // src/ui/reveal.ts
3815
4274
  async function runReveal(outcome) {
3816
4275
  await new Promise((resolve) => {
3817
- const app = render5(React13.createElement(RollReveal, {
4276
+ const app = render5(React6.createElement(RollReveal, {
3818
4277
  outcome,
3819
4278
  onDone: () => {
3820
4279
  app.unmount();
@@ -3830,7 +4289,7 @@ function pendingFor(horse) {
3830
4289
  const lastRolled = horse.last_rolled_level ?? Math.max(1, level - 1);
3831
4290
  return level - lastRolled;
3832
4291
  }
3833
- async function rollCommand() {
4292
+ async function rollCommand(args = []) {
3834
4293
  let stable;
3835
4294
  try {
3836
4295
  stable = await listStable();
@@ -3846,19 +4305,39 @@ async function rollCommand() {
3846
4305
  console.log("No rolls available. Level up a horse to earn a roll!");
3847
4306
  return 0;
3848
4307
  }
3849
- const picked = await new Promise((resolve) => {
3850
- const app = render6(React14.createElement(RollHorsePicker, {
3851
- horses: eligible,
3852
- onPick: (h) => {
3853
- app.unmount();
3854
- resolve(h);
3855
- },
3856
- onCancel: () => {
3857
- app.unmount();
3858
- resolve(null);
3859
- }
3860
- }));
4308
+ const choice = await resolveHorse(eligible, {
4309
+ name: parseFlag(args, "--horse"),
4310
+ autoSelect: false
3861
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
+ }
3862
4341
  if (!picked) {
3863
4342
  console.log("Cancelled.");
3864
4343
  return 0;
@@ -3903,7 +4382,9 @@ async function rollCommand() {
3903
4382
  console.log(`
3904
4383
  \u2728 ${hat.name}${variantSuffix} [${hat.rarity.toUpperCase()}]
3905
4384
  `);
3906
- 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] ")) {
3907
4388
  try {
3908
4389
  await equipHat(chosen.stable_horse_id, { hat_index: result.hat_index });
3909
4390
  console.log(`Equipped on ${chosen.name}.`);
@@ -3925,21 +4406,25 @@ No hat this time. +${result.xp_awarded} XP toward your next level.
3925
4406
  `);
3926
4407
  }
3927
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
+ }
3928
4413
  if (!await promptYesNo(`${result.remaining_rolls} more roll${result.remaining_rolls === 1 ? "" : "s"} available. Roll again? [Y/n] `)) return 0;
3929
4414
  }
3930
4415
  }
3931
4416
 
3932
4417
  // src/commands/claim.ts
3933
- import React15 from "react";
4418
+ import React8 from "react";
3934
4419
  import { render as render7 } from "ink";
3935
- async function claimCommand(token) {
4420
+ async function claimCommand(token, args = []) {
3936
4421
  if (!token) {
3937
4422
  console.error("Usage: token-derby claim <token>");
3938
4423
  return 2;
3939
4424
  }
3940
- let probe;
4425
+ let probe2;
3941
4426
  try {
3942
- probe = await probeClaim(token);
4427
+ probe2 = await probeClaim(token);
3943
4428
  } catch (e) {
3944
4429
  if (e instanceof ApiError) {
3945
4430
  console.error(`Error: ${e.code} ${e.message}`);
@@ -3961,23 +4446,44 @@ async function claimCommand(token) {
3961
4446
  console.error("No horses in your stable. Run `token-derby stable create` first.");
3962
4447
  return 1;
3963
4448
  }
3964
- console.log(probe.entry_count > 1 ? `
3965
- \u{1F381} A pack of ${probe.entry_count} cosmetics \u2014 one of them will be yours.
3966
- ` : "\n\u{1F381} A cosmetic has been awarded to you.\n");
3967
- const picked = await new Promise((resolve) => {
3968
- const app = render7(React15.createElement(HorsePicker, {
3969
- horses: stable.horses,
3970
- prompt: "Which horse should receive it?",
3971
- onPick: (h) => {
3972
- app.unmount();
3973
- resolve(h);
3974
- },
3975
- onCancel: () => {
3976
- app.unmount();
3977
- resolve(null);
3978
- }
3979
- }));
4449
+ const choice = await resolveHorse(stable.horses, {
4450
+ name: parseFlag(args, "--horse"),
4451
+ pick: hasFlag(args, "--pick")
3980
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
+ }
3981
4487
  if (!picked) {
3982
4488
  console.log("Cancelled. Your token is unspent.");
3983
4489
  return 0;
@@ -4011,7 +4517,9 @@ async function claimCommand(token) {
4011
4517
  console.log(`
4012
4518
  \u2728 ${hat2.name}${variantSuffix2} [${hat2.rarity.toUpperCase()}]
4013
4519
  `);
4014
- 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] ")) {
4015
4523
  try {
4016
4524
  await equipHat(picked.stable_horse_id, { hat_index: result.hat_index });
4017
4525
  console.log(`Equipped on ${picked.name}.`);
@@ -4031,6 +4539,121 @@ ${picked.name} already has ${hat?.name ?? result.hat_id}${variantSuffix}. +${res
4031
4539
  return 0;
4032
4540
  }
4033
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
+
4034
4657
  // src/commands/org-join.ts
4035
4658
  async function orgJoinCommand(token) {
4036
4659
  const join_token = token?.trim();
@@ -4077,6 +4700,31 @@ function envCommand(arg) {
4077
4700
  return 0;
4078
4701
  }
4079
4702
 
4703
+ // src/commands/logs.ts
4704
+ import * as fs11 from "fs/promises";
4705
+ import { existsSync as existsSync2 } from "fs";
4706
+ var DEFAULT_TAIL_LINES = 50;
4707
+ function tailCount(argv) {
4708
+ const i = argv.indexOf("--tail");
4709
+ if (i === -1) return null;
4710
+ const n = Number(argv[i + 1]);
4711
+ return Number.isInteger(n) && n > 0 ? n : DEFAULT_TAIL_LINES;
4712
+ }
4713
+ async function logsCommand(argv) {
4714
+ const file = logFile();
4715
+ if (!existsSync2(file)) {
4716
+ console.log(`No log file yet \u2014 it appears at ${file} the first time a command runs.`);
4717
+ return 0;
4718
+ }
4719
+ console.log(file);
4720
+ const n = tailCount(argv);
4721
+ if (n === null) return 0;
4722
+ const lines = (await fs11.readFile(file, "utf8")).split("\n").filter((l) => l.length > 0);
4723
+ console.log("");
4724
+ for (const line of lines.slice(-n)) console.log(line);
4725
+ return 0;
4726
+ }
4727
+
4080
4728
  // src/bin.ts
4081
4729
  var HELP = `token-derby v${CLI_VERSION}
4082
4730
 
@@ -4105,12 +4753,23 @@ Identity:
4105
4753
 
4106
4754
  Maintenance:
4107
4755
  token-derby update Check for and install the latest CLI version
4756
+ token-derby logs Show the path of the debug log
4757
+ token-derby logs --tail [n] Print the last n log lines (default 50)
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
4108
4764
 
4109
4765
  Stable management:
4110
4766
  token-derby stable create Make a new horse (interactive)
4111
4767
  token-derby stable list Show your saved horses
4112
4768
  token-derby stable edit [name] Edit an existing horse's colors (interactive picker if no name)
4113
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.
4114
4773
 
4115
4774
  Organisations:
4116
4775
  token-derby organisation join [token] Join an organisation with a join token,
@@ -4124,13 +4783,17 @@ Races:
4124
4783
  Create a new race (interactive). When
4125
4784
  --organisation is set, only members of
4126
4785
  that org can join.
4127
- 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
4128
4788
  token-derby end <admin-code> End a race early
4129
4789
 
4130
4790
  Cosmetics:
4131
- token-derby roll Spend a pending roll to try for a hat.
4132
- Earn rolls by leveling up horses.
4133
- 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
4134
4797
  awarded to you by an admin.
4135
4798
 
4136
4799
  Environment:
@@ -4141,9 +4804,25 @@ Environment:
4141
4804
  TOKEN_DERBY_API_BASE Hard-override API base URL (wins over env)
4142
4805
  TOKEN_DERBY_HOME Hard-override identity/stable directory
4143
4806
  `;
4807
+ function describeInvocation(argv) {
4808
+ const cmd = argv[0] ?? "(none)";
4809
+ const container = cmd === "stable" || cmd === "organisation" || cmd === "org" || cmd === "harness";
4810
+ return {
4811
+ cmd,
4812
+ sub: container ? argv[1] : void 0,
4813
+ flags: argv.filter((a) => a.startsWith("--")).map((a) => a.split("=")[0])
4814
+ };
4815
+ }
4144
4816
  async function main() {
4145
4817
  const argv = process.argv.slice(2);
4146
4818
  const cmd = argv[0];
4819
+ logInfo("cmd.start", {
4820
+ ...describeInvocation(argv),
4821
+ version: CLI_VERSION,
4822
+ node: process.version,
4823
+ pid: process.pid,
4824
+ env: selectedEnv()
4825
+ });
4147
4826
  if (!cmd || cmd === "--help" || cmd === "-h") {
4148
4827
  console.log(HELP);
4149
4828
  return 0;
@@ -4159,6 +4838,16 @@ async function main() {
4159
4838
  if (cmd === "login") return loginCommand(argv.slice(1));
4160
4839
  if (cmd === "update") return updateCommand();
4161
4840
  if (cmd === "env") return envCommand(argv[1]);
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
+ }
4162
4851
  const identity = await loadIdentity();
4163
4852
  if (!identity) {
4164
4853
  console.error("Run `token-derby login` to set up your identity before using any other command.");
@@ -4170,8 +4859,9 @@ async function main() {
4170
4859
  if (sub === "list") return stableListCommand();
4171
4860
  if (sub === "edit") return stableEditCommand(argv[2]);
4172
4861
  if (sub === "delete") return stableDeleteCommand(argv[2]);
4862
+ if (sub === "default") return stableDefaultCommand(argv.slice(2));
4173
4863
  console.error(`Unknown stable subcommand: ${sub ?? "(none)"}`);
4174
- 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]");
4175
4865
  return 2;
4176
4866
  }
4177
4867
  if (cmd === "organisation" || cmd === "org") {
@@ -4190,24 +4880,40 @@ async function main() {
4190
4880
  if (cmd === "whoami") return whoamiCommand();
4191
4881
  if (cmd === "join") return joinCommand(argv[1], argv.slice(2));
4192
4882
  if (cmd === "end") return endCommand(argv[1]);
4193
- if (cmd === "roll") return rollCommand();
4194
- 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));
4195
4885
  if (cmd === "web") return webCommand();
4196
4886
  console.error(`Unknown command: ${cmd}`);
4197
4887
  console.error(HELP);
4198
4888
  return 2;
4199
4889
  }
4200
- function parseFlag(args, flag) {
4201
- for (let i = 0; i < args.length; i++) {
4202
- if (args[i] === flag) return args[i + 1];
4203
- const eq = `${flag}=`;
4204
- if (args[i]?.startsWith(eq)) return args[i].slice(eq.length);
4205
- }
4206
- return void 0;
4890
+ var CRASH_HANDLERS_INSTALLED = /* @__PURE__ */ Symbol.for("token-derby.crash-handlers");
4891
+ if (!(CRASH_HANDLERS_INSTALLED in process)) {
4892
+ process[CRASH_HANDLERS_INSTALLED] = true;
4893
+ process.on("uncaughtException", (err) => {
4894
+ logError("cmd.uncaught", { message: err?.message ?? String(err), stack: err?.stack });
4895
+ console.error(err?.stack ?? err);
4896
+ process.exit(1);
4897
+ });
4898
+ process.on("unhandledRejection", (reason) => {
4899
+ logError("cmd.unhandled", {
4900
+ message: reason?.message ?? String(reason),
4901
+ stack: reason?.stack
4902
+ });
4903
+ });
4207
4904
  }
4905
+ var startedAt = Date.now();
4208
4906
  main().then(
4209
- (code) => process.exit(code),
4907
+ (code) => {
4908
+ logInfo("cmd.exit", { code, ms: Date.now() - startedAt });
4909
+ process.exit(code);
4910
+ },
4210
4911
  (err) => {
4912
+ logError("cmd.crash", {
4913
+ message: err?.message ?? String(err),
4914
+ stack: err?.stack,
4915
+ ms: Date.now() - startedAt
4916
+ });
4211
4917
  console.error(err?.stack ?? err);
4212
4918
  process.exit(1);
4213
4919
  }