whoburnedmore 0.9.1 → 0.9.4

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.
Files changed (2) hide show
  1. package/dist/index.js +554 -139
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { spawn } from "node:child_process";
10
10
  import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
11
11
  import { createRequire as createRequire4 } from "node:module";
12
12
  import { platform as platform3 } from "node:os";
13
- import { join as join7 } from "node:path";
13
+ import { join as join9 } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
15
  import pc2 from "picocolors";
16
16
 
@@ -101,7 +101,10 @@ async function post(path, body) {
101
101
  res = await fetch(`${apiBase()}${path}`, {
102
102
  method: "POST",
103
103
  headers: { "Content-Type": "application/json" },
104
- body: JSON.stringify(body)
104
+ body: JSON.stringify(body),
105
+ // Bound the request so a slow/black-holing/hostile server can't hang the CLI
106
+ // — or the unattended 15-minute background sync — indefinitely.
107
+ signal: AbortSignal.timeout(3e4)
105
108
  });
106
109
  } catch {
107
110
  throw new Error(
@@ -144,7 +147,8 @@ async function anonRemove(anonKey) {
144
147
  const res = await fetch(`${apiBase()}/v1/anon`, {
145
148
  method: "DELETE",
146
149
  headers: { "Content-Type": "application/json" },
147
- body: JSON.stringify({ anonKey })
150
+ body: JSON.stringify({ anonKey }),
151
+ signal: AbortSignal.timeout(3e4)
148
152
  });
149
153
  if (res.status !== 200) {
150
154
  const b = await res.json().catch(() => ({}));
@@ -167,8 +171,8 @@ import {
167
171
  existsSync as existsSync2,
168
172
  mkdirSync as mkdirSync2,
169
173
  readFileSync as readFileSync2,
170
- renameSync,
171
- rmSync,
174
+ renameSync as renameSync2,
175
+ rmSync as rmSync2,
172
176
  statSync,
173
177
  writeFileSync as writeFileSync2
174
178
  } from "node:fs";
@@ -182,6 +186,8 @@ import {
182
186
  existsSync,
183
187
  mkdirSync,
184
188
  readFileSync,
189
+ renameSync,
190
+ rmSync,
185
191
  writeFileSync
186
192
  } from "node:fs";
187
193
  import { homedir } from "node:os";
@@ -211,10 +217,20 @@ function loadConfig(dir = defaultConfigDir()) {
211
217
  function saveConfig(dir = defaultConfigDir(), config = {}) {
212
218
  mkdirSync(dir, { recursive: true });
213
219
  const file = join(dir, "config.json");
214
- writeFileSync(file, JSON.stringify(config, null, 2), { mode: 384 });
220
+ const tmp = join(dir, `config.json.${process.pid}.tmp`);
215
221
  try {
216
- chmodSync(file, 384);
217
- } catch {
222
+ writeFileSync(tmp, JSON.stringify(config, null, 2), { mode: 384 });
223
+ try {
224
+ chmodSync(tmp, 384);
225
+ } catch {
226
+ }
227
+ renameSync(tmp, file);
228
+ } catch (err) {
229
+ try {
230
+ rmSync(tmp, { force: true });
231
+ } catch {
232
+ }
233
+ throw err;
218
234
  }
219
235
  }
220
236
  function ensureAnonKey(dir = defaultConfigDir()) {
@@ -250,11 +266,55 @@ var STABLE_NPM_CANDIDATES = [
250
266
  "/usr/bin/npm"
251
267
  ];
252
268
  var LATEST_PACKAGE_SPEC = "whoburnedmore@latest";
269
+ var SYNC_PATH_DIRS = [
270
+ "/opt/homebrew/bin",
271
+ "/usr/local/bin",
272
+ "/usr/bin",
273
+ "/bin",
274
+ "/usr/sbin",
275
+ "/sbin"
276
+ ];
277
+ function syncPathEnv(npmPath = resolveNpmPath()) {
278
+ const dir = dirname(npmPath);
279
+ const dirs = [];
280
+ if (dir && dir !== "." && dir !== "/" && dir !== npmPath) dirs.push(dir);
281
+ for (const d of SYNC_PATH_DIRS) {
282
+ if (!dirs.includes(d)) dirs.push(d);
283
+ }
284
+ return dirs.join(":");
285
+ }
286
+ var FORWARDED_ENV_VARS = [
287
+ "WHOBURNEDMORE_CONFIG_DIR",
288
+ // identity: where the anonKey lives
289
+ "WHOBURNEDMORE_API",
290
+ // endpoint: where submits go
291
+ "WHOBURNEDMORE_WEB",
292
+ // dashboard URL shown to the user
293
+ "XDG_CONFIG_HOME",
294
+ // base for the default config dir
295
+ "CLAUDE_CONFIG_DIR"
296
+ // a primary usage-collection source
297
+ ];
298
+ function syncEnv(opts) {
299
+ const env = opts?.env ?? process.env;
300
+ const pairs = [["PATH", syncPathEnv(opts?.npmPath)]];
301
+ for (const key of FORWARDED_ENV_VARS) {
302
+ const value = env[key];
303
+ if (typeof value === "string" && value.length > 0 && !/[\r\n]/.test(value)) {
304
+ pairs.push([key, value]);
305
+ }
306
+ }
307
+ return pairs;
308
+ }
253
309
  function syncLogPath() {
254
310
  return join2(defaultConfigDir(), "sync.log");
255
311
  }
256
- function buildLaunchdPlist(commandArgs = syncCommandArgs(), logPath = syncLogPath()) {
312
+ function buildLaunchdPlist(commandArgs = syncCommandArgs(), logPath = syncLogPath(), envPairs = syncEnv({ npmPath: commandArgs[0] })) {
257
313
  const programArguments = commandArgs.map((arg) => ` <string>${xmlEscape(arg)}</string>`).join("\n");
314
+ const envEntries = envPairs.map(
315
+ ([k, v]) => ` <key>${xmlEscape(k)}</key>
316
+ <string>${xmlEscape(v)}</string>`
317
+ ).join("\n");
258
318
  return `<?xml version="1.0" encoding="UTF-8"?>
259
319
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
260
320
  <plist version="1.0">
@@ -265,6 +325,15 @@ function buildLaunchdPlist(commandArgs = syncCommandArgs(), logPath = syncLogPat
265
325
  <array>
266
326
  ${programArguments}
267
327
  </array>
328
+ <!-- launchd runs jobs with a minimal PATH (/usr/bin:/bin:/usr/sbin:/sbin)
329
+ that excludes Homebrew. npm's shebang (and the package bin npm exec
330
+ spawns) is #!/usr/bin/env node, so node must be on PATH or every tick
331
+ dies with "env: node: No such file or directory". We also forward the
332
+ user's identity/endpoint/collection env so background == foreground. -->
333
+ <key>EnvironmentVariables</key>
334
+ <dict>
335
+ ${envEntries}
336
+ </dict>
268
337
  <key>StartInterval</key>
269
338
  <integer>${SYNC_INTERVAL_MINUTES * 60}</integer>
270
339
  <!-- Run once right after login/reboot so a machine that was off (or asleep)
@@ -398,7 +467,10 @@ function cronSchedule(mins = SYNC_INTERVAL_MINUTES) {
398
467
  }
399
468
  function expectedLinuxCronLine(opts) {
400
469
  const command = syncCommandArgs(opts?.npmPath).map(shellQuote).join(" ");
401
- return `${cronSchedule()} ${command} >${shellQuote(opts?.logPath ?? syncLogPath())} 2>&1`;
470
+ const envPrefix = syncEnv({ npmPath: opts?.npmPath ?? resolveNpmPath() }).map(([k, v]) => `${k}=${shellQuote(v)}`).join(" ");
471
+ const redirect = `>${shellQuote(opts?.logPath ?? syncLogPath())} 2>&1`;
472
+ const commandField = `${envPrefix} ${command} ${redirect}`.replaceAll("%", "\\%");
473
+ return `${cronSchedule()} ${commandField}`;
402
474
  }
403
475
  var SYSTEMD_UNIT = "whoburnedmore-sync";
404
476
  function systemdUserDir() {
@@ -412,15 +484,17 @@ function systemdTimerPath() {
412
484
  return join2(systemdUserDir(), `${SYSTEMD_UNIT}.timer`);
413
485
  }
414
486
  function systemdQuote(value) {
415
- return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
487
+ return `"${value.replaceAll("\\", "\\\\").replaceAll("%", "%%").replaceAll('"', '\\"')}"`;
416
488
  }
417
- function buildSystemdService(commandArgs = syncCommandArgs()) {
489
+ function buildSystemdService(commandArgs = syncCommandArgs(), envPairs = syncEnv({ npmPath: commandArgs[0] })) {
418
490
  const execStart = commandArgs.map(systemdQuote).join(" ");
491
+ const envLines = envPairs.map(([k, v]) => `Environment=${systemdQuote(`${k}=${v}`)}`).join("\n");
419
492
  return `[Unit]
420
493
  Description=whoburnedmore background token-usage sync
421
494
 
422
495
  [Service]
423
496
  Type=oneshot
497
+ ${envLines}
424
498
  ExecStart=${execStart}
425
499
  `;
426
500
  }
@@ -478,8 +552,8 @@ function tryInstallSystemd() {
478
552
  { stdio: "ignore" }
479
553
  );
480
554
  if (res.status !== 0) {
481
- rmSync(systemdServicePath(), { force: true });
482
- rmSync(systemdTimerPath(), { force: true });
555
+ rmSync2(systemdServicePath(), { force: true });
556
+ rmSync2(systemdTimerPath(), { force: true });
483
557
  return null;
484
558
  }
485
559
  return `systemd user timer installed, syncing every ${syncIntervalLabel()} (run \`loginctl enable-linger\` to keep syncing while logged out)`;
@@ -490,7 +564,7 @@ function uninstallAutoSync() {
490
564
  const plistPath = launchAgentPath();
491
565
  if (existsSync2(plistPath)) {
492
566
  spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
493
- rmSync(plistPath, { force: true });
567
+ rmSync2(plistPath, { force: true });
494
568
  }
495
569
  return "launchd agent removed";
496
570
  }
@@ -508,8 +582,8 @@ function uninstallAutoSync() {
508
582
  ["--user", "disable", "--now", `${SYSTEMD_UNIT}.timer`],
509
583
  { stdio: "ignore" }
510
584
  );
511
- rmSync(systemdServicePath(), { force: true });
512
- rmSync(systemdTimerPath(), { force: true });
585
+ rmSync2(systemdServicePath(), { force: true });
586
+ rmSync2(systemdTimerPath(), { force: true });
513
587
  spawnSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
514
588
  removed = true;
515
589
  }
@@ -585,7 +659,7 @@ function rotateLogIfLarge(path = syncLogPath(), capBytes = 256 * 1024) {
585
659
  try {
586
660
  if (!existsSync2(path)) return false;
587
661
  if (statSync(path).size <= capBytes) return false;
588
- renameSync(path, `${path}.1`);
662
+ renameSync2(path, `${path}.1`);
589
663
  return true;
590
664
  } catch {
591
665
  return false;
@@ -673,46 +747,23 @@ async function daemonLoop(deps) {
673
747
  // src/collect.ts
674
748
  import { execFile } from "node:child_process";
675
749
  import { createRequire as createRequire3 } from "node:module";
676
- import { dirname as dirname3, join as join6 } from "node:path";
750
+ import { dirname as dirname3, join as join8 } from "node:path";
677
751
  import { promisify } from "node:util";
678
752
 
679
753
  // src/attribution.ts
680
754
  import { readFileSync as readFileSync3, readdirSync, statSync as statSync2 } from "node:fs";
681
755
  import { homedir as homedir3 } from "node:os";
682
- import { basename, join as join3 } from "node:path";
683
-
684
- // src/pricing.ts
685
- var TABLE = [
686
- { match: /opus/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
687
- { match: /sonnet/i, price: { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
688
- { match: /haiku/i, price: { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 } },
689
- { match: /fable/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
690
- { match: /gpt-4o|gpt-4\.1/i, price: { in: 2.5, out: 10, cacheWrite: 2.5, cacheRead: 1.25 } },
691
- { match: /gpt-5|o3|o4|codex/i, price: { in: 1.25, out: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
692
- { match: /gemini.*flash/i, price: { in: 0.15, out: 0.6, cacheWrite: 0.15, cacheRead: 0.0375 } },
693
- { match: /gemini/i, price: { in: 1.25, out: 5, cacheWrite: 1.25, cacheRead: 0.31 } }
694
- ];
695
- function estimateCostUSD(model, t) {
696
- const row = TABLE.find((r) => r.match.test(model));
697
- if (!row) return 0;
698
- const p = row.price;
699
- const usd = (t.inputTokens * p.in + t.outputTokens * p.out + t.cacheCreationTokens * p.cacheWrite + t.cacheReadTokens * p.cacheRead) / 1e6;
700
- return usd > 0 ? usd : 0;
701
- }
702
-
703
- // src/attribution.ts
756
+ import { join as join3 } from "node:path";
704
757
  var CLAUDE_PROJECTS = join3(homedir3(), ".claude", "projects");
705
758
  var CODEX_SESSIONS = join3(homedir3(), ".codex", "sessions");
706
759
  var MAX_FILES = 5e3;
707
760
  var MAX_FILE_BYTES = 64 * 1024 * 1024;
708
761
  var TIME_BUDGET_MS = 12e3;
709
762
  var MAX_STATS = 300;
710
- var MAX_PROJECTS = 500;
711
763
  function createAccumulator() {
712
764
  return {
713
765
  tools: /* @__PURE__ */ new Map(),
714
766
  skills: /* @__PURE__ */ new Map(),
715
- projects: /* @__PURE__ */ new Map(),
716
767
  agent: {
717
768
  messageCount: 0,
718
769
  subagentMessages: 0,
@@ -720,7 +771,6 @@ function createAccumulator() {
720
771
  totalTokens: 0,
721
772
  userMessageCount: 0
722
773
  },
723
- titles: /* @__PURE__ */ new Map(),
724
774
  sessionMessages: /* @__PURE__ */ new Map()
725
775
  };
726
776
  }
@@ -761,9 +811,6 @@ function processRecord(rec, acc, ctx) {
761
811
  sk.tokens += recTokens;
762
812
  acc.skills.set(s, sk);
763
813
  }
764
- if (r.type === "ai-title" && typeof r.aiTitle === "string" && r.aiTitle && typeof r.sessionId === "string" && r.sessionId) {
765
- acc.titles.set(r.sessionId, r.aiTitle.slice(0, 200));
766
- }
767
814
  const content = r.message?.content;
768
815
  if (!Array.isArray(content)) return;
769
816
  const isAssistant = r.type === "assistant" || r.message?.role === "assistant";
@@ -800,25 +847,6 @@ function processRecord(rec, acc, ctx) {
800
847
  (acc.sessionMessages.get(r.sessionId) ?? 0) + 1
801
848
  );
802
849
  }
803
- if (typeof r.cwd === "string" && r.cwd && tokens > 0) {
804
- const name = basename(r.cwd).slice(0, 128) || "unknown";
805
- const model = typeof r.message?.model === "string" ? r.message.model : "unknown";
806
- const u = r.message?.usage;
807
- const num3 = (v) => {
808
- const x = Math.round(Number(v));
809
- return Number.isFinite(x) && x > 0 ? x : 0;
810
- };
811
- const cost = estimateCostUSD(model, {
812
- inputTokens: num3(u?.input_tokens),
813
- outputTokens: num3(u?.output_tokens),
814
- cacheCreationTokens: num3(u?.cache_creation_input_tokens),
815
- cacheReadTokens: num3(u?.cache_read_input_tokens)
816
- });
817
- const p = acc.projects.get(name) ?? { tokens: 0, costUSD: 0 };
818
- p.tokens += tokens;
819
- p.costUSD += cost;
820
- acc.projects.set(name, p);
821
- }
822
850
  } else if (isUser) {
823
851
  for (const block of content) {
824
852
  if (block && typeof block === "object" && block.type === "tool_result" && block.is_error === true) {
@@ -844,20 +872,11 @@ function toToolStats(map) {
844
872
  return base;
845
873
  });
846
874
  }
847
- function toProjectStats(map) {
848
- return [...map.entries()].map(([name, v]) => ({
849
- name,
850
- tokens: v.tokens,
851
- costUSD: Number(v.costUSD.toFixed(6))
852
- })).filter((p) => p.tokens > 0).sort((a, b) => b.tokens - a.tokens).slice(0, MAX_PROJECTS);
853
- }
854
875
  function accumulatorToResult(acc) {
855
876
  return {
856
877
  tools: toToolStats(acc.tools),
857
878
  skills: toSkillStats(acc.skills),
858
- projects: toProjectStats(acc.projects),
859
879
  agent: { ...acc.agent },
860
- titles: acc.titles,
861
880
  sessionMessages: acc.sessionMessages,
862
881
  complete: true
863
882
  };
@@ -867,7 +886,7 @@ function numTok(v) {
867
886
  return Number.isFinite(x) && x > 0 ? x : 0;
868
887
  }
869
888
  function createCodexContext() {
870
- return { cwd: "", model: "unknown", pending: [] };
889
+ return { pending: [] };
871
890
  }
872
891
  function processCodexRecord(rec, acc, ctx) {
873
892
  if (!rec || typeof rec !== "object") return;
@@ -875,11 +894,7 @@ function processCodexRecord(rec, acc, ctx) {
875
894
  if (!r.payload || typeof r.payload !== "object") return;
876
895
  const pl = r.payload;
877
896
  const ptype = pl.type;
878
- if (r.type === "session_meta" || r.type === "turn_context") {
879
- if (typeof pl.cwd === "string" && pl.cwd) ctx.cwd = pl.cwd;
880
- if (typeof pl.model === "string" && pl.model) ctx.model = pl.model;
881
- return;
882
- }
897
+ if (r.type === "session_meta" || r.type === "turn_context") return;
883
898
  if (ptype === "function_call" || ptype === "custom_tool_call" || ptype === "local_shell_call") {
884
899
  const raw = typeof pl.name === "string" ? pl.name : ptype === "local_shell_call" ? "local_shell" : "";
885
900
  const name = raw.slice(0, 128);
@@ -905,19 +920,6 @@ function processCodexRecord(rec, acc, ctx) {
905
920
  }
906
921
  acc.agent.messageCount += 1;
907
922
  acc.agent.totalTokens += tokens;
908
- if (ctx.cwd) {
909
- const name = basename(ctx.cwd).slice(0, 128) || "unknown";
910
- const cost = estimateCostUSD(ctx.model, {
911
- inputTokens,
912
- outputTokens,
913
- cacheCreationTokens: 0,
914
- cacheReadTokens
915
- });
916
- const proj = acc.projects.get(name) ?? { tokens: 0, costUSD: 0 };
917
- proj.tokens += tokens;
918
- proj.costUSD += cost;
919
- acc.projects.set(name, proj);
920
- }
921
923
  const per = ctx.pending.length > 0 ? Math.floor(tokens / ctx.pending.length) : 0;
922
924
  for (const tu of ctx.pending) {
923
925
  const t = acc.tools.get(tu.name);
@@ -1272,6 +1274,361 @@ async function collectCursor() {
1272
1274
  return { entries: [], blocks: [], found: false };
1273
1275
  }
1274
1276
 
1277
+ // src/native/claude.ts
1278
+ import { readdir, readFile } from "node:fs/promises";
1279
+ import { homedir as homedir5 } from "node:os";
1280
+ import { join as join6 } from "node:path";
1281
+
1282
+ // src/pricing.ts
1283
+ var TABLE = [
1284
+ { match: /opus/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
1285
+ { match: /sonnet/i, price: { in: 3, out: 15, cacheWrite: 3.75, cacheRead: 0.3 } },
1286
+ { match: /haiku/i, price: { in: 0.8, out: 4, cacheWrite: 1, cacheRead: 0.08 } },
1287
+ { match: /fable/i, price: { in: 15, out: 75, cacheWrite: 18.75, cacheRead: 1.5 } },
1288
+ { match: /gpt-4o|gpt-4\.1/i, price: { in: 2.5, out: 10, cacheWrite: 2.5, cacheRead: 1.25 } },
1289
+ { match: /gpt-5|o3|o4|codex/i, price: { in: 1.25, out: 10, cacheWrite: 1.25, cacheRead: 0.125 } },
1290
+ { match: /gemini.*flash/i, price: { in: 0.15, out: 0.6, cacheWrite: 0.15, cacheRead: 0.0375 } },
1291
+ { match: /gemini/i, price: { in: 1.25, out: 5, cacheWrite: 1.25, cacheRead: 0.31 } }
1292
+ ];
1293
+ function estimateCostUSD(model, t) {
1294
+ const row = TABLE.find((r) => r.match.test(model));
1295
+ if (!row) return 0;
1296
+ const p = row.price;
1297
+ const usd = (t.inputTokens * p.in + t.outputTokens * p.out + t.cacheCreationTokens * p.cacheWrite + t.cacheReadTokens * p.cacheRead) / 1e6;
1298
+ return usd > 0 ? usd : 0;
1299
+ }
1300
+
1301
+ // src/native/claude.ts
1302
+ function num3(n) {
1303
+ const v = Math.round(Number(n));
1304
+ return Number.isFinite(v) && v > 0 ? v : 0;
1305
+ }
1306
+ function reqTokens(r) {
1307
+ return r.inputTokens + r.outputTokens + r.cacheCreationTokens + r.cacheReadTokens;
1308
+ }
1309
+ function localDate(iso) {
1310
+ const t = Date.parse(iso);
1311
+ if (!Number.isFinite(t)) return null;
1312
+ const d = new Date(t);
1313
+ const y = d.getFullYear();
1314
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1315
+ const day = String(d.getDate()).padStart(2, "0");
1316
+ return `${y}-${m}-${day}`;
1317
+ }
1318
+ var syntheticCounter = 0;
1319
+ function parseClaudeLine(raw) {
1320
+ const trimmed = raw.trim();
1321
+ if (!trimmed) return null;
1322
+ let obj;
1323
+ try {
1324
+ obj = JSON.parse(trimmed);
1325
+ } catch {
1326
+ return null;
1327
+ }
1328
+ const message = obj.message;
1329
+ if (!message || typeof message !== "object") return null;
1330
+ const usage = message.usage;
1331
+ if (!usage || typeof usage !== "object") return null;
1332
+ if (message.role !== void 0 && message.role !== "assistant") return null;
1333
+ const date = localDate(String(obj.timestamp ?? ""));
1334
+ if (!date) return null;
1335
+ const messageId = typeof message.id === "string" ? message.id : "";
1336
+ const requestId = typeof obj.requestId === "string" ? obj.requestId : "";
1337
+ const hasRealId = messageId !== "" || requestId !== "";
1338
+ const key = hasRealId ? `${messageId}|${requestId}` : `synthetic|${date}|${syntheticCounter += 1}`;
1339
+ return {
1340
+ key,
1341
+ hasRealId,
1342
+ date,
1343
+ model: typeof message.model === "string" ? message.model : "unknown",
1344
+ inputTokens: num3(usage.input_tokens),
1345
+ outputTokens: num3(usage.output_tokens),
1346
+ cacheCreationTokens: num3(usage.cache_creation_input_tokens),
1347
+ cacheReadTokens: num3(usage.cache_read_input_tokens)
1348
+ };
1349
+ }
1350
+ function accumulateClaudeLines(acc, lines) {
1351
+ for (const line of lines) {
1352
+ const r = parseClaudeLine(line);
1353
+ if (!r) continue;
1354
+ const prev = acc.get(r.key);
1355
+ if (!prev || reqTokens(r) > reqTokens(prev)) acc.set(r.key, r);
1356
+ }
1357
+ }
1358
+ function finalizeClaudeEntries(acc) {
1359
+ const byDayModel = /* @__PURE__ */ new Map();
1360
+ for (const r of acc.values()) {
1361
+ const k = `${r.date}|${r.model}`;
1362
+ let b = byDayModel.get(k);
1363
+ if (!b) {
1364
+ b = {
1365
+ date: r.date,
1366
+ model: r.model,
1367
+ inputTokens: 0,
1368
+ outputTokens: 0,
1369
+ cacheCreationTokens: 0,
1370
+ cacheReadTokens: 0,
1371
+ requestCount: 0
1372
+ };
1373
+ byDayModel.set(k, b);
1374
+ }
1375
+ b.inputTokens += r.inputTokens;
1376
+ b.outputTokens += r.outputTokens;
1377
+ b.cacheCreationTokens += r.cacheCreationTokens;
1378
+ b.cacheReadTokens += r.cacheReadTokens;
1379
+ if (r.hasRealId) b.requestCount += 1;
1380
+ }
1381
+ const entries = [];
1382
+ for (const b of byDayModel.values()) {
1383
+ const tokens = b.inputTokens + b.outputTokens + b.cacheCreationTokens + b.cacheReadTokens;
1384
+ if (tokens === 0) continue;
1385
+ entries.push({
1386
+ date: b.date,
1387
+ tool: "claude",
1388
+ model: b.model,
1389
+ inputTokens: b.inputTokens,
1390
+ outputTokens: b.outputTokens,
1391
+ cacheCreationTokens: b.cacheCreationTokens,
1392
+ cacheReadTokens: b.cacheReadTokens,
1393
+ costUSD: estimateCostUSD(b.model, b),
1394
+ origin: "cli",
1395
+ verified: false,
1396
+ requestCount: b.requestCount
1397
+ });
1398
+ }
1399
+ return entries;
1400
+ }
1401
+ function resolveClaudeConfigRoots(env = process.env) {
1402
+ const override = env.CLAUDE_CONFIG_DIR;
1403
+ return override ? override.split(",").map((s) => s.trim()).filter(Boolean) : [join6(homedir5(), ".claude"), join6(homedir5(), ".config", "claude")];
1404
+ }
1405
+ function resolveClaudeProjectDirs(env = process.env) {
1406
+ return resolveClaudeConfigRoots(env).map((r) => join6(r, "projects"));
1407
+ }
1408
+ async function listJsonl(dir) {
1409
+ let dirents;
1410
+ try {
1411
+ dirents = await readdir(dir, { withFileTypes: true });
1412
+ } catch {
1413
+ return [];
1414
+ }
1415
+ const out = [];
1416
+ for (const d of dirents) {
1417
+ const full = join6(dir, d.name);
1418
+ if (d.isDirectory()) {
1419
+ out.push(...await listJsonl(full));
1420
+ } else if (d.isFile() && d.name.endsWith(".jsonl")) {
1421
+ out.push(full);
1422
+ }
1423
+ }
1424
+ return out;
1425
+ }
1426
+ var NATIVE_READ_BUDGET_MS = 2e4;
1427
+ function* splitLines(content) {
1428
+ let start = 0;
1429
+ for (let i = 0; i < content.length; i++) {
1430
+ if (content[i] === "\n") {
1431
+ yield content.slice(start, i);
1432
+ start = i + 1;
1433
+ }
1434
+ }
1435
+ if (start < content.length) yield content.slice(start);
1436
+ }
1437
+ async function collectClaudeNative(env = process.env, opts = {}) {
1438
+ const dirs = resolveClaudeProjectDirs(env);
1439
+ const files = [];
1440
+ for (const dir of dirs) files.push(...await listJsonl(dir));
1441
+ if (files.length === 0) return { entries: [], found: false, filesScanned: 0 };
1442
+ const now = opts.now ?? Date.now;
1443
+ const deadline = now() + (opts.budgetMs ?? NATIVE_READ_BUDGET_MS);
1444
+ const acc = /* @__PURE__ */ new Map();
1445
+ let scanned = 0;
1446
+ for (const f of files) {
1447
+ if (now() > deadline) {
1448
+ return { entries: [], found: false, filesScanned: scanned, timedOut: true };
1449
+ }
1450
+ let content;
1451
+ try {
1452
+ content = await readFile(f, "utf8");
1453
+ } catch {
1454
+ continue;
1455
+ }
1456
+ accumulateClaudeLines(acc, splitLines(content));
1457
+ scanned += 1;
1458
+ }
1459
+ return { entries: finalizeClaudeEntries(acc), found: true, filesScanned: scanned };
1460
+ }
1461
+
1462
+ // src/native/codex.ts
1463
+ import { readdir as readdir2, readFile as readFile2 } from "node:fs/promises";
1464
+ import { homedir as homedir6 } from "node:os";
1465
+ import { join as join7 } from "node:path";
1466
+ function num4(n) {
1467
+ const v = Math.round(Number(n));
1468
+ return Number.isFinite(v) && v > 0 ? v : 0;
1469
+ }
1470
+ function localDate2(iso) {
1471
+ const t = Date.parse(iso);
1472
+ if (!Number.isFinite(t)) return null;
1473
+ const d = new Date(t);
1474
+ const y = d.getFullYear();
1475
+ const m = String(d.getMonth() + 1).padStart(2, "0");
1476
+ const day = String(d.getDate()).padStart(2, "0");
1477
+ return `${y}-${m}-${day}`;
1478
+ }
1479
+ function readTokenFields(payload) {
1480
+ const info = payload.info;
1481
+ const src = info?.total_token_usage ?? payload;
1482
+ const input = src.input_tokens;
1483
+ const output = src.output_tokens;
1484
+ if (input === void 0 && output === void 0) return null;
1485
+ return {
1486
+ input: num4(input),
1487
+ cached: num4(src.cached_input_tokens),
1488
+ output: num4(output),
1489
+ reasoning: num4(src.reasoning_output_tokens)
1490
+ };
1491
+ }
1492
+ function parseCodexRollout(lines) {
1493
+ let model = "unknown";
1494
+ let lastDate = null;
1495
+ let last = null;
1496
+ let turnCount = 0;
1497
+ for (const raw of lines) {
1498
+ const trimmed = raw.trim();
1499
+ if (!trimmed) continue;
1500
+ let obj;
1501
+ try {
1502
+ obj = JSON.parse(trimmed);
1503
+ } catch {
1504
+ continue;
1505
+ }
1506
+ const payload = obj.payload;
1507
+ if (!payload || typeof payload !== "object") continue;
1508
+ const kind = obj.type;
1509
+ if (kind === "session_meta" || kind === "turn_context") {
1510
+ if (typeof payload.model === "string" && payload.model) model = payload.model;
1511
+ }
1512
+ if (payload.type === "token_count") {
1513
+ const fields = readTokenFields(payload);
1514
+ if (!fields) continue;
1515
+ last = fields;
1516
+ turnCount += 1;
1517
+ const d = localDate2(String(obj.timestamp ?? ""));
1518
+ if (d) lastDate = d;
1519
+ }
1520
+ }
1521
+ if (!last || !lastDate) return null;
1522
+ const cacheReadTokens = last.cached;
1523
+ const inputTokens = Math.max(0, last.input - last.cached);
1524
+ const outputTokens = last.output + last.reasoning;
1525
+ if (inputTokens + outputTokens + cacheReadTokens === 0) return null;
1526
+ return {
1527
+ date: lastDate,
1528
+ model,
1529
+ inputTokens,
1530
+ outputTokens,
1531
+ cacheCreationTokens: 0,
1532
+ cacheReadTokens,
1533
+ turnCount
1534
+ };
1535
+ }
1536
+ function accumulateCodexSession(acc, lines) {
1537
+ const s = parseCodexRollout(lines);
1538
+ if (!s) return;
1539
+ const k = `${s.date}|${s.model}`;
1540
+ let b = acc.get(k);
1541
+ if (!b) {
1542
+ b = {
1543
+ date: s.date,
1544
+ model: s.model,
1545
+ inputTokens: 0,
1546
+ outputTokens: 0,
1547
+ cacheCreationTokens: 0,
1548
+ cacheReadTokens: 0,
1549
+ requestCount: 0
1550
+ };
1551
+ acc.set(k, b);
1552
+ }
1553
+ b.inputTokens += s.inputTokens;
1554
+ b.outputTokens += s.outputTokens;
1555
+ b.cacheCreationTokens += s.cacheCreationTokens;
1556
+ b.cacheReadTokens += s.cacheReadTokens;
1557
+ b.requestCount += s.turnCount;
1558
+ }
1559
+ function finalizeCodexEntries(acc) {
1560
+ const entries = [];
1561
+ for (const b of acc.values()) {
1562
+ entries.push({
1563
+ date: b.date,
1564
+ tool: "codex",
1565
+ model: b.model,
1566
+ inputTokens: b.inputTokens,
1567
+ outputTokens: b.outputTokens,
1568
+ cacheCreationTokens: b.cacheCreationTokens,
1569
+ cacheReadTokens: b.cacheReadTokens,
1570
+ costUSD: estimateCostUSD(b.model, b),
1571
+ origin: "cli",
1572
+ verified: false,
1573
+ requestCount: b.requestCount
1574
+ });
1575
+ }
1576
+ return entries;
1577
+ }
1578
+ function resolveCodexSessionsDir(env = process.env) {
1579
+ const home = env.CODEX_HOME && env.CODEX_HOME.trim() ? env.CODEX_HOME.trim() : join7(homedir6(), ".codex");
1580
+ return join7(home, "sessions");
1581
+ }
1582
+ async function listJsonl2(dir) {
1583
+ let dirents;
1584
+ try {
1585
+ dirents = await readdir2(dir, { withFileTypes: true });
1586
+ } catch {
1587
+ return [];
1588
+ }
1589
+ const out = [];
1590
+ for (const d of dirents) {
1591
+ const full = join7(dir, d.name);
1592
+ if (d.isDirectory()) out.push(...await listJsonl2(full));
1593
+ else if (d.isFile() && d.name.endsWith(".jsonl")) out.push(full);
1594
+ }
1595
+ return out;
1596
+ }
1597
+ function* splitLines2(content) {
1598
+ let start = 0;
1599
+ for (let i = 0; i < content.length; i++) {
1600
+ if (content[i] === "\n") {
1601
+ yield content.slice(start, i);
1602
+ start = i + 1;
1603
+ }
1604
+ }
1605
+ if (start < content.length) yield content.slice(start);
1606
+ }
1607
+ var NATIVE_READ_BUDGET_MS2 = 2e4;
1608
+ async function collectCodexNative(env = process.env, opts = {}) {
1609
+ const dir = resolveCodexSessionsDir(env);
1610
+ const files = await listJsonl2(dir);
1611
+ if (files.length === 0) return { entries: [], found: false, filesScanned: 0 };
1612
+ const now = opts.now ?? Date.now;
1613
+ const deadline = now() + (opts.budgetMs ?? NATIVE_READ_BUDGET_MS2);
1614
+ const acc = /* @__PURE__ */ new Map();
1615
+ let scanned = 0;
1616
+ for (const f of files) {
1617
+ if (now() > deadline) {
1618
+ return { entries: [], found: false, filesScanned: scanned, timedOut: true };
1619
+ }
1620
+ let content;
1621
+ try {
1622
+ content = await readFile2(f, "utf8");
1623
+ } catch {
1624
+ continue;
1625
+ }
1626
+ accumulateCodexSession(acc, splitLines2(content));
1627
+ scanned += 1;
1628
+ }
1629
+ return { entries: finalizeCodexEntries(acc), found: true, filesScanned: scanned };
1630
+ }
1631
+
1275
1632
  // src/collect.ts
1276
1633
  var execFileAsync = promisify(execFile);
1277
1634
  var SOURCES = [
@@ -1340,9 +1697,12 @@ function mapCcusageDaily(tool, json) {
1340
1697
  };
1341
1698
  });
1342
1699
  } else {
1700
+ const modelsUsed = Array.isArray(day.modelsUsed) ? day.modelsUsed.filter(
1701
+ (m) => typeof m === "string" && m.length > 0
1702
+ ) : [];
1343
1703
  candidates = [
1344
1704
  {
1345
- model: "unknown",
1705
+ model: modelsUsed.length === 1 ? modelsUsed[0] : "unknown",
1346
1706
  inputTokens: norm(day.inputTokens),
1347
1707
  outputTokens: norm(day.outputTokens),
1348
1708
  cacheCreationTokens: norm(day.cacheCreationTokens),
@@ -1409,7 +1769,7 @@ function resolveCcusageBin() {
1409
1769
  const pkgPath = require3.resolve("ccusage/package.json");
1410
1770
  const pkg = require3("ccusage/package.json");
1411
1771
  const rel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.ccusage ?? "ccusage";
1412
- const binPath = join6(dirname3(pkgPath), rel);
1772
+ const binPath = join8(dirname3(pkgPath), rel);
1413
1773
  if (/\.(c|m)?js$/.test(binPath)) {
1414
1774
  return { cmd: process.execPath, prefixArgs: [binPath] };
1415
1775
  }
@@ -1430,6 +1790,9 @@ function dedupeDaily(entries) {
1430
1790
  prev.cacheReadTokens += e.cacheReadTokens;
1431
1791
  prev.costUSD = Number((prev.costUSD + e.costUSD).toFixed(6));
1432
1792
  prev.verified = prev.verified && e.verified;
1793
+ if (e.requestCount !== void 0 || prev.requestCount !== void 0) {
1794
+ prev.requestCount = (prev.requestCount ?? 0) + (e.requestCount ?? 0);
1795
+ }
1433
1796
  }
1434
1797
  return [...byKey.values()];
1435
1798
  }
@@ -1462,7 +1825,18 @@ function dedupeBlocks(blocks) {
1462
1825
  }
1463
1826
  return [...byStart.values()];
1464
1827
  }
1465
- async function runCcusageOnce(cmd, args) {
1828
+ function selectSourceEntries(source, ccusageEntries, native) {
1829
+ if (source === "claude" && native.claude.found && native.claude.entries.length > 0)
1830
+ return native.claude.entries;
1831
+ if (source === "codex" && native.codex.found && native.codex.entries.length > 0)
1832
+ return native.codex.entries;
1833
+ return ccusageEntries;
1834
+ }
1835
+ function ccusageClaudeEnv(env = process.env) {
1836
+ if (env.CLAUDE_CONFIG_DIR && env.CLAUDE_CONFIG_DIR.trim()) return env;
1837
+ return { ...env, CLAUDE_CONFIG_DIR: resolveClaudeConfigRoots(env).join(",") };
1838
+ }
1839
+ async function runCcusageOnce(cmd, args, env) {
1466
1840
  try {
1467
1841
  const { stdout } = await execFileAsync(cmd, args, {
1468
1842
  encoding: "utf8",
@@ -1470,7 +1844,8 @@ async function runCcusageOnce(cmd, args) {
1470
1844
  // A single source shouldn't be able to hang the whole run. 25s is plenty
1471
1845
  // for a healthy local read; a hung source gets killed and (if transient)
1472
1846
  // retried once below rather than stalling everything for minutes.
1473
- timeout: 25e3
1847
+ timeout: 25e3,
1848
+ ...env ? { env } : {}
1474
1849
  });
1475
1850
  if (!stdout) return { json: null, transient: false };
1476
1851
  try {
@@ -1484,18 +1859,29 @@ async function runCcusageOnce(cmd, args) {
1484
1859
  return { json: null, transient };
1485
1860
  }
1486
1861
  }
1487
- async function runCcusage(cmd, args) {
1488
- const first = await runCcusageOnce(cmd, args);
1862
+ async function runCcusage(cmd, args, env) {
1863
+ const first = await runCcusageOnce(cmd, args, env);
1489
1864
  if (first.json !== null || !first.transient) return first.json;
1490
- return (await runCcusageOnce(cmd, args)).json;
1865
+ return (await runCcusageOnce(cmd, args, env)).json;
1491
1866
  }
1492
1867
  var COLLECT_STAGES = SOURCES.length + 4;
1493
1868
  async function collectAll(onProgress) {
1494
1869
  const { cmd, prefixArgs } = resolveCcusageBin();
1495
1870
  let done = 0;
1496
1871
  const tick = () => onProgress?.(++done, COLLECT_STAGES, "");
1872
+ const nativeClaudeTask = collectClaudeNative().catch(
1873
+ () => ({ entries: [], found: false, filesScanned: 0 })
1874
+ );
1875
+ const nativeCodexTask = collectCodexNative().catch(
1876
+ () => ({ entries: [], found: false, filesScanned: 0 })
1877
+ );
1497
1878
  const sourceTasks = SOURCES.map(async (source) => {
1498
- const json = await runCcusage(cmd, [...prefixArgs, source, "daily", "--json", "--offline"]);
1879
+ const env = source === "claude" ? ccusageClaudeEnv() : void 0;
1880
+ const json = await runCcusage(
1881
+ cmd,
1882
+ [...prefixArgs, source, "daily", "--json", "--offline"],
1883
+ env
1884
+ );
1499
1885
  tick();
1500
1886
  return { source, mapped: json ? mapCcusageDaily(source, json) : [] };
1501
1887
  });
@@ -1519,18 +1905,22 @@ async function collectAll(onProgress) {
1519
1905
  tick();
1520
1906
  return a;
1521
1907
  });
1522
- const [sourceResults, sessions, blocks, cursor, attribution] = await Promise.all([
1908
+ const [sourceResults, sessions, blocks, cursor, attribution, nativeClaude, nativeCodex] = await Promise.all([
1523
1909
  Promise.all(sourceTasks),
1524
1910
  sessionTask,
1525
1911
  blockTask,
1526
1912
  cursorTask,
1527
- attributionTask
1913
+ attributionTask,
1914
+ nativeClaudeTask,
1915
+ nativeCodexTask
1528
1916
  ]);
1917
+ const native = { claude: nativeClaude, codex: nativeCodex };
1529
1918
  const entries = [];
1530
1919
  const toolsFound = [];
1531
1920
  for (const { source, mapped } of sourceResults) {
1532
- if (mapped.length > 0) {
1533
- entries.push(...mapped);
1921
+ const chosen = selectSourceEntries(source, mapped, native);
1922
+ if (chosen.length > 0) {
1923
+ entries.push(...chosen);
1534
1924
  toolsFound.push(source);
1535
1925
  }
1536
1926
  }
@@ -1539,14 +1929,12 @@ async function collectAll(onProgress) {
1539
1929
  blocks.push(...cursor.blocks);
1540
1930
  toolsFound.push("cursor");
1541
1931
  }
1542
- const { tools, skills, projects, agent, titles, sessionMessages, complete } = attribution;
1932
+ const { tools, skills, agent, sessionMessages, complete } = attribution;
1543
1933
  onProgress?.(COLLECT_STAGES, COLLECT_STAGES, "");
1544
1934
  const dedupedSessions = dedupeSessions(sessions).map((s) => {
1545
- const title = titles.get(s.sessionId);
1546
1935
  const messageCount = sessionMessages.get(s.sessionId);
1547
1936
  return {
1548
1937
  ...s,
1549
- ...title ? { title } : {},
1550
1938
  ...messageCount ? { messageCount } : {}
1551
1939
  };
1552
1940
  });
@@ -1555,14 +1943,13 @@ async function collectAll(onProgress) {
1555
1943
  // schema: entries ≤ 20000, sessions/blocks ≤ 10000), keeping the
1556
1944
  // highest-token rows. Without this, a power user with >10000 distinct sessions
1557
1945
  // would have their ENTIRE submit rejected with a 400 instead of a capped one.
1558
- // tools/skills/projects are already bounded upstream (attribution caps).
1946
+ // tools/skills are already bounded upstream (attribution caps).
1559
1947
  entries: capByTokens(dedupeDaily(entries), 2e4, entryTokens),
1560
1948
  sessions: capByTokens(dedupedSessions, 1e4, entryTokens),
1561
1949
  blocks: capByTokens(dedupeBlocks(blocks), 1e4, (b) => b.totalTokens),
1562
1950
  toolsFound,
1563
1951
  tools,
1564
1952
  skills,
1565
- projects,
1566
1953
  agent,
1567
1954
  attributionComplete: complete
1568
1955
  };
@@ -5747,7 +6134,18 @@ var DailyUsageEntry = external_exports.object({
5747
6134
  /** Where this entry came from. Defaults to the local CLI for back-compat. */
5748
6135
  origin: UsageOrigin.default("cli"),
5749
6136
  /** True when the numbers come from a provider's authoritative usage API. */
5750
- verified: external_exports.boolean().default(false)
6137
+ verified: external_exports.boolean().default(false),
6138
+ /**
6139
+ * Structural fingerprint: the number of DISTINCT provider requests
6140
+ * (unique message-id + request-id pairs) that summed into this entry. A real
6141
+ * heavy day is the product of thousands of distinct API requests; a hand-typed
6142
+ * fabrication has none. The server uses this as an anti-fraud signal — a
6143
+ * billion-token day backed by ~zero real requests is the forgery signature.
6144
+ * Optional + back-compat: older CLIs and the ccusage fallback path (which
6145
+ * cannot see request ids) omit it, and an omitted fingerprint is never
6146
+ * penalized.
6147
+ */
6148
+ requestCount: external_exports.number().int().nonnegative().optional()
5751
6149
  });
5752
6150
  var Timestamp = external_exports.string().min(1).max(40);
5753
6151
  var SessionEntry = external_exports.object({
@@ -5760,8 +6158,6 @@ var SessionEntry = external_exports.object({
5760
6158
  cacheReadTokens: tokenCount,
5761
6159
  costUSD: external_exports.number().nonnegative(),
5762
6160
  lastActivity: Timestamp,
5763
- /** Human-readable AI-generated session title (from transcripts). Optional. */
5764
- title: external_exports.string().max(200).optional(),
5765
6161
  /** Number of assistant messages in this session (from transcripts). Optional. */
5766
6162
  messageCount: external_exports.number().int().nonnegative().optional()
5767
6163
  });
@@ -5778,11 +6174,6 @@ var ToolStat = external_exports.object({
5778
6174
  /** Tokens burned on turns that used this tool (turn tokens split across its tool calls). Optional. */
5779
6175
  tokens: external_exports.number().int().nonnegative().optional()
5780
6176
  });
5781
- var ProjectStat = external_exports.object({
5782
- name: external_exports.string().min(1).max(128),
5783
- tokens: external_exports.number().int().nonnegative(),
5784
- costUSD: external_exports.number().nonnegative()
5785
- });
5786
6177
  var AgentStat = external_exports.object({
5787
6178
  /** Total assistant messages across transcripts. */
5788
6179
  messageCount: external_exports.number().int().nonnegative(),
@@ -5816,15 +6207,13 @@ var SubmitPayload = external_exports.object({
5816
6207
  tools: external_exports.array(ToolStat).max(300).optional(),
5817
6208
  /** Optional skill-usage frequencies parsed from local transcripts. */
5818
6209
  skills: external_exports.array(SkillStat).max(300).optional(),
5819
- /** Optional per-project usage totals parsed from local transcripts. */
5820
- projects: external_exports.array(ProjectStat).max(500).optional(),
5821
6210
  /** Optional subagent-vs-main rollup parsed from local transcripts. */
5822
6211
  agent: AgentStat.optional(),
5823
6212
  /**
5824
6213
  * Set when the transcript scan completed within its time budget, i.e. the
5825
- * tool/skill/project/agent rollups are a FULL snapshot. The server refreshes
5826
- * the dashboard breakdowns unconditionally for a full snapshot; for a partial
5827
- * one (flag absent/false) it keeps its no-shrink guard. Back-compat: omittable.
6214
+ * tool/skill/agent rollups are a FULL snapshot. The server refreshes the
6215
+ * dashboard breakdowns unconditionally for a full snapshot; for a partial one
6216
+ * (flag absent/false) it keeps its no-shrink guard. Back-compat: omittable.
5828
6217
  */
5829
6218
  attributionComplete: external_exports.boolean().optional(),
5830
6219
  /** Optional friends-board code (from `--board=<code>`): auto-join this board on submit. */
@@ -5837,7 +6226,16 @@ var SubmitPayload = external_exports.object({
5837
6226
  * never needs it, and a wrong/missing code only skips the org attach (the
5838
6227
  * personal submit still succeeds).
5839
6228
  */
5840
- orgCode: external_exports.string().min(1).max(64).optional()
6229
+ orgCode: external_exports.string().min(1).max(64).optional(),
6230
+ /**
6231
+ * The submitter's local UTC offset in minutes EAST of UTC (IST = +330,
6232
+ * US-Pacific DST = -420; i.e. `-new Date().getTimezoneOffset()`). ccusage dates
6233
+ * usage in this local zone, so the server uses the offset to compute each
6234
+ * member's daily/weekly leaderboard window in THEIR calendar day rather than a
6235
+ * UTC one. Back-compat: omittable — older CLIs don't send it and the board falls
6236
+ * back to its default offset. Range clamps to real zones (UTC-14..+14).
6237
+ */
6238
+ tzOffsetMinutes: external_exports.number().int().min(-840).max(840).optional()
5841
6239
  });
5842
6240
  var AnonSubmitPayload = SubmitPayload.extend({
5843
6241
  /** Client-generated secret (hex). The server stores only its hash. */
@@ -5923,7 +6321,16 @@ var OrgProvisionInput = external_exports.object({
5923
6321
  ownerEmail: external_exports.string().email().max(254).optional(),
5924
6322
  description: external_exports.string().max(2e3).optional(),
5925
6323
  boardVisibility: OrgBoardVisibility.optional(),
5926
- window: OrgWindow.optional()
6324
+ window: OrgWindow.optional(),
6325
+ /** Brand accent applied to the org homepage theming. */
6326
+ accentColor: HexColor.optional(),
6327
+ /**
6328
+ * Create the org UNOWNED and mint a one-time owner-claim ("admin sign-in")
6329
+ * token in the same call. The first person to open the link + sign in becomes
6330
+ * the owner; the token is then consumed. When set, an owner email/handle is
6331
+ * NOT required — the link is how the first admin is assigned.
6332
+ */
6333
+ issueClaimLink: external_exports.boolean().optional()
5927
6334
  });
5928
6335
  var OrgSettingsInput = external_exports.object({
5929
6336
  name: external_exports.string().min(1).max(120).optional(),
@@ -5949,18 +6356,21 @@ function formatTokens(n) {
5949
6356
  function formatUSD(n) {
5950
6357
  return `$${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}`;
5951
6358
  }
6359
+ function sanitizeServerText(s) {
6360
+ return s.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
6361
+ }
5952
6362
  function submitNextStepLines(result) {
5953
6363
  if (result.boardUrl) {
5954
6364
  const code = result.boardCode ?? result.boardUrl.split("/").filter(Boolean).pop() ?? "";
5955
6365
  return [
5956
- ` \u{1F91D} You're on the board: ${result.boardUrl}`,
6366
+ ` \u{1F91D} You're on the board: ${sanitizeServerText(result.boardUrl)}`,
5957
6367
  " \u2192 Open it to see who burned more.",
5958
- ` \u2192 Get a friend on it \u2014 have them run: npx whoburnedmore --board=${code}`,
6368
+ ` \u2192 Get a friend on it \u2014 have them run: npx whoburnedmore --board=${sanitizeServerText(code)}`,
5959
6369
  " \u2192 Sign in on the page and add your X to claim your spot and own your rank."
5960
6370
  ];
5961
6371
  }
5962
6372
  return [
5963
- ` Your dashboard: ${result.dashboardUrl}`,
6373
+ ` Your dashboard: ${sanitizeServerText(result.dashboardUrl)}`,
5964
6374
  " \u2192 Sign in and add your X on the page to get on the leaderboard and claim your rank.",
5965
6375
  " Private until you do. Manage anytime: `npx whoburnedmore private` \xB7 `public` \xB7 `remove`."
5966
6376
  ];
@@ -5968,7 +6378,7 @@ function submitNextStepLines(result) {
5968
6378
 
5969
6379
  // src/local-dashboard.ts
5970
6380
  function esc(s) {
5971
- return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6381
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
5972
6382
  }
5973
6383
  function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(), connect) {
5974
6384
  const today = generatedAt.toISOString().slice(0, 10);
@@ -6181,8 +6591,12 @@ async function publishLocal(payload, deps) {
6181
6591
  }
6182
6592
  const key = deps.ensureAnonKey();
6183
6593
  const res = await deps.anonSubmit(key, payload);
6184
- deps.log(` Published \u2014 you're on the board: ${res.dashboardUrl}`);
6185
- deps.openBrowser(claimUrl(res.dashboardUrl, key));
6594
+ deps.log(` Published \u2014 you're on the board: ${sanitizeServerText(res.dashboardUrl)}`);
6595
+ if (isTrustedWebUrl(res.dashboardUrl)) {
6596
+ deps.openBrowser(claimUrl(res.dashboardUrl, key));
6597
+ } else {
6598
+ deps.log(" (The server returned an unexpected address, so it was not auto-opened.)");
6599
+ }
6186
6600
  deps.log(
6187
6601
  " Sign in there to claim it, or `npx whoburnedmore private` to hide it again."
6188
6602
  );
@@ -6264,7 +6678,7 @@ async function confirm(question) {
6264
6678
  function showLocalDashboard(payload) {
6265
6679
  const dir = defaultConfigDir();
6266
6680
  mkdirSync3(dir, { recursive: true });
6267
- const file = join7(dir, "dashboard.html");
6681
+ const file = join9(dir, "dashboard.html");
6268
6682
  writeFileSync3(
6269
6683
  file,
6270
6684
  renderDashboardHtml(payload.entries, /* @__PURE__ */ new Date(), {
@@ -6299,7 +6713,7 @@ async function run(flags) {
6299
6713
  } finally {
6300
6714
  progress.stop();
6301
6715
  }
6302
- const { entries, sessions, blocks, tools, skills, projects, agent, attributionComplete } = collected;
6716
+ const { entries, sessions, blocks, tools, skills, agent, attributionComplete } = collected;
6303
6717
  if (entries.length === 0) {
6304
6718
  console.log();
6305
6719
  console.log(" Nothing to burn yet \u2014 no local usage found from any coding agent.");
@@ -6307,13 +6721,13 @@ async function run(flags) {
6307
6721
  return;
6308
6722
  }
6309
6723
  const payload = { cliVersion: VERSION, entries };
6724
+ payload.tzOffsetMinutes = -(/* @__PURE__ */ new Date()).getTimezoneOffset();
6310
6725
  if (sessions.length > 0) payload.sessions = sessions;
6311
6726
  if (blocks.length > 0) payload.blocks = blocks;
6312
6727
  if (tools.length > 0) payload.tools = tools;
6313
6728
  if (skills.length > 0) payload.skills = skills;
6314
- if (projects.length > 0) payload.projects = projects;
6315
6729
  if (agent.messageCount > 0) payload.agent = agent;
6316
- if (attributionComplete && (tools.length > 0 || skills.length > 0 || projects.length > 0))
6730
+ if (attributionComplete && (tools.length > 0 || skills.length > 0))
6317
6731
  payload.attributionComplete = true;
6318
6732
  applyScope(payload, flags);
6319
6733
  if (flags.dryRun) {
@@ -6366,7 +6780,7 @@ async function run(flags) {
6366
6780
  console.log(
6367
6781
  pc2.dim(" The server returned an unexpected dashboard address, so it was NOT auto-opened. Open it yourself only if you trust it:")
6368
6782
  );
6369
- console.log(` ${baseUrl}`);
6783
+ console.log(` ${sanitizeServerText(baseUrl)}`);
6370
6784
  }
6371
6785
  }
6372
6786
  const lines = submitNextStepLines(result);
@@ -6395,8 +6809,9 @@ async function linkServerInstall(token) {
6395
6809
  }
6396
6810
  const anonKey = ensureAnonKey();
6397
6811
  const linked = await redeemServerInstall(token, anonKey);
6812
+ const handle = sanitizeServerText(linked.handle);
6398
6813
  console.log(
6399
- linked.alreadyLinked ? ` This machine is already linked to @${linked.handle}.` : ` Linked this machine to @${linked.handle}.`
6814
+ linked.alreadyLinked ? ` This machine is already linked to @${handle}.` : ` Linked this machine to @${handle}.`
6400
6815
  );
6401
6816
  if (linked.mergedDays > 0) {
6402
6817
  console.log(pc2.dim(` Merged ${linked.mergedDays} existing usage day${linked.mergedDays === 1 ? "" : "s"} from this machine.`));
@@ -6419,7 +6834,7 @@ async function linkServerInstall(token) {
6419
6834
  } catch {
6420
6835
  console.log(pc2.dim(" Linked, but background sync could not be installed automatically. Run `npx whoburnedmore install-sync` to retry."));
6421
6836
  }
6422
- console.log(` Profile: ${linked.profileUrl}`);
6837
+ console.log(` Profile: ${sanitizeServerText(linked.profileUrl)}`);
6423
6838
  }
6424
6839
  function waitOrAbort(ms, signal) {
6425
6840
  if (signal.aborted) return Promise.resolve();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whoburnedmore",
3
- "version": "0.9.1",
3
+ "version": "0.9.4",
4
4
  "description": "Find out who burned more — submit your AI coding-agent token usage to the public leaderboard at whoburnedmore.com",
5
5
  "type": "module",
6
6
  "bin": {