whoburnedmore 0.6.0 → 0.8.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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/dist/index.js +338 -123
  3. package/package.json +2 -3
package/README.md CHANGED
@@ -62,7 +62,7 @@ npx whoburnedmore --local
62
62
  | `npx whoburnedmore --no-submit` | Print local stats only, send nothing |
63
63
  | `npx whoburnedmore login` | Sign in to claim a public handle + join the leaderboard |
64
64
  | `npx whoburnedmore logout` | Forget the local token (your data is untouched) |
65
- | `npx whoburnedmore install-sync` | Keep your dashboard live with a background sync (every 3h) |
65
+ | `npx whoburnedmore install-sync` | Keep your dashboard live with a background sync (hourly) |
66
66
  | `npx whoburnedmore uninstall-sync` | Remove the background sync |
67
67
 
68
68
  ## Supported tools
@@ -88,7 +88,7 @@ It uses a device flow — a code appears in your terminal, you approve it in the
88
88
  Want your dashboard to stay fresh without re-running by hand?
89
89
 
90
90
  ```bash
91
- npx whoburnedmore install-sync # background sync every 3h (launchd / cron / scheduled task)
91
+ npx whoburnedmore install-sync # background sync hourly (launchd / cron / scheduled task)
92
92
  npx whoburnedmore uninstall-sync # remove it
93
93
  ```
94
94
 
package/dist/index.js CHANGED
@@ -12,7 +12,7 @@ import { createRequire as createRequire4 } from "node:module";
12
12
  import { platform as platform3 } from "node:os";
13
13
  import { join as join7 } from "node:path";
14
14
  import { createInterface } from "node:readline/promises";
15
- import pc2 from "picocolors";
15
+ import pc3 from "picocolors";
16
16
 
17
17
  // src/args.ts
18
18
  function parseBoard(args) {
@@ -133,7 +133,7 @@ function ensureAnonKey(dir = defaultConfigDir()) {
133
133
  }
134
134
 
135
135
  // src/autosync.ts
136
- var SYNC_INTERVAL_HOURS = 3;
136
+ var SYNC_INTERVAL_HOURS = 1;
137
137
  var LABEL = "com.whoburnedmore.sync";
138
138
  function syncLogPath() {
139
139
  return join2(defaultConfigDir(), "sync.log");
@@ -153,8 +153,14 @@ function buildLaunchdPlist(nodePath, scriptPath, logPath = syncLogPath()) {
153
153
  </array>
154
154
  <key>StartInterval</key>
155
155
  <integer>${SYNC_INTERVAL_HOURS * 3600}</integer>
156
+ <!-- Run once right after login/reboot so a machine that was off (or asleep)
157
+ through a scheduled tick catches up immediately, then keeps to the
158
+ interval. Submits are idempotent server-side, so an extra run is safe. -->
156
159
  <key>RunAtLoad</key>
157
- <false/>
160
+ <true/>
161
+ <!-- Be a good citizen: macOS schedules this with background priority. -->
162
+ <key>ProcessType</key>
163
+ <string>Background</string>
158
164
  <key>StandardOutPath</key>
159
165
  <string>${logPath}</string>
160
166
  <key>StandardErrorPath</key>
@@ -250,6 +256,27 @@ function autoSyncInstalled() {
250
256
  return false;
251
257
  }
252
258
 
259
+ // src/banner.ts
260
+ import pc from "picocolors";
261
+ var ART = [
262
+ "\u2588\u2588\u2588\u2588\u2588 \u2588 \u2588 \u2588\u2588\u2588\u2588\u2588 \u2588 \u2588",
263
+ "\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2588 \u2588",
264
+ "\u2588\u2588\u2588\u2588\u2588 \u2588 \u2588 \u2588\u2588\u2588\u2588 \u2588 \u2588 \u2588",
265
+ "\u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588 \u2588\u2588",
266
+ "\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2588 \u2588 \u2588 \u2588"
267
+ ];
268
+ var SHADES = [226, 220, 214, 208, 202];
269
+ function printBanner() {
270
+ const color = pc.isColorSupported;
271
+ console.log();
272
+ ART.forEach((row, i) => {
273
+ console.log(" " + (color ? `\x1B[1;38;5;${SHADES[i]}m${row}\x1B[0m` : row));
274
+ });
275
+ const mark = color ? `\x1B[1;38;5;208m\u{1F525} whoburnedmore\x1B[0m` : "\u{1F525} whoburnedmore";
276
+ console.log(` ${mark} ${pc.dim("\xB7 who burned more?")}`);
277
+ console.log();
278
+ }
279
+
253
280
  // src/collect.ts
254
281
  import { spawnSync as spawnSync4 } from "node:child_process";
255
282
  import { createRequire as createRequire3 } from "node:module";
@@ -281,6 +308,7 @@ function estimateCostUSD(model, t) {
281
308
 
282
309
  // src/attribution.ts
283
310
  var CLAUDE_PROJECTS = join3(homedir3(), ".claude", "projects");
311
+ var CODEX_SESSIONS = join3(homedir3(), ".codex", "sessions");
284
312
  var MAX_FILES = 5e3;
285
313
  var MAX_FILE_BYTES = 64 * 1024 * 1024;
286
314
  var TIME_BUDGET_MS = 3e4;
@@ -421,9 +449,95 @@ function accumulatorToResult(acc) {
421
449
  projects: toProjectStats(acc.projects),
422
450
  agent: { ...acc.agent },
423
451
  titles: acc.titles,
424
- sessionMessages: acc.sessionMessages
452
+ sessionMessages: acc.sessionMessages,
453
+ complete: true
425
454
  };
426
455
  }
456
+ function numTok(v) {
457
+ const x = Math.round(Number(v));
458
+ return Number.isFinite(x) && x > 0 ? x : 0;
459
+ }
460
+ function createCodexContext() {
461
+ return { cwd: "", model: "unknown", pending: [] };
462
+ }
463
+ function processCodexRecord(rec, acc, ctx) {
464
+ if (!rec || typeof rec !== "object") return;
465
+ const r = rec;
466
+ if (!r.payload || typeof r.payload !== "object") return;
467
+ const pl = r.payload;
468
+ const ptype = pl.type;
469
+ if (r.type === "session_meta" || r.type === "turn_context") {
470
+ if (typeof pl.cwd === "string" && pl.cwd) ctx.cwd = pl.cwd;
471
+ if (typeof pl.model === "string" && pl.model) ctx.model = pl.model;
472
+ return;
473
+ }
474
+ if (ptype === "function_call" || ptype === "custom_tool_call" || ptype === "local_shell_call") {
475
+ const raw = typeof pl.name === "string" ? pl.name : ptype === "local_shell_call" ? "local_shell" : "";
476
+ const name = raw.slice(0, 128);
477
+ if (!name) return;
478
+ const id = typeof pl.call_id === "string" ? pl.call_id : void 0;
479
+ ctx.pending.push({ name, id });
480
+ const t = acc.tools.get(name) ?? { count: 0, errors: 0, tokens: 0 };
481
+ t.count += 1;
482
+ acc.tools.set(name, t);
483
+ return;
484
+ }
485
+ if (ptype === "token_count") {
486
+ const info = pl.info;
487
+ const last = info?.last_token_usage;
488
+ if (!last) return;
489
+ const inputTokens = numTok(last.input_tokens);
490
+ const cacheReadTokens = numTok(last.cached_input_tokens);
491
+ const outputTokens = numTok(last.output_tokens) + numTok(last.reasoning_output_tokens);
492
+ const tokens = numTok(last.total_tokens) || inputTokens + cacheReadTokens + outputTokens;
493
+ if (tokens <= 0) {
494
+ ctx.pending = [];
495
+ return;
496
+ }
497
+ acc.agent.messageCount += 1;
498
+ acc.agent.totalTokens += tokens;
499
+ if (ctx.cwd) {
500
+ const name = basename(ctx.cwd).slice(0, 128) || "unknown";
501
+ const cost = estimateCostUSD(ctx.model, {
502
+ inputTokens,
503
+ outputTokens,
504
+ cacheCreationTokens: 0,
505
+ cacheReadTokens
506
+ });
507
+ const proj = acc.projects.get(name) ?? { tokens: 0, costUSD: 0 };
508
+ proj.tokens += tokens;
509
+ proj.costUSD += cost;
510
+ acc.projects.set(name, proj);
511
+ }
512
+ const per = ctx.pending.length > 0 ? Math.floor(tokens / ctx.pending.length) : 0;
513
+ for (const tu of ctx.pending) {
514
+ const t = acc.tools.get(tu.name);
515
+ if (t) t.tokens += per;
516
+ }
517
+ ctx.pending = [];
518
+ }
519
+ }
520
+ function readLines(file) {
521
+ let size = 0;
522
+ try {
523
+ size = statSync(file).size;
524
+ } catch {
525
+ return [];
526
+ }
527
+ if (size > MAX_FILE_BYTES) return [];
528
+ try {
529
+ return readFileSync2(file, "utf8").split("\n");
530
+ } catch {
531
+ return [];
532
+ }
533
+ }
534
+ function claudeProjectDirs() {
535
+ const dirs = [CLAUDE_PROJECTS];
536
+ const cfg = process.env.CLAUDE_CONFIG_DIR;
537
+ if (cfg) dirs.push(join3(cfg, "projects"));
538
+ dirs.push(join3(homedir3(), ".config", "claude", "projects"));
539
+ return [...new Set(dirs)];
540
+ }
427
541
  function listTranscripts(dir) {
428
542
  const out = [];
429
543
  const walk = (d) => {
@@ -452,34 +566,42 @@ function listTranscripts(dir) {
452
566
  function collectAttribution() {
453
567
  const acc = createAccumulator();
454
568
  const deadline = Date.now() + TIME_BUDGET_MS;
569
+ let complete = true;
455
570
  try {
456
- for (const file of listTranscripts(CLAUDE_PROJECTS)) {
457
- if (Date.now() > deadline) break;
458
- let size = 0;
459
- try {
460
- size = statSync(file).size;
461
- } catch {
462
- continue;
571
+ for (const dir of claudeProjectDirs()) {
572
+ for (const file of listTranscripts(dir)) {
573
+ if (Date.now() > deadline) {
574
+ complete = false;
575
+ break;
576
+ }
577
+ const ctx = createFileContext();
578
+ for (const line of readLines(file)) {
579
+ if (!line) continue;
580
+ try {
581
+ processRecord(JSON.parse(line), acc, ctx);
582
+ } catch {
583
+ }
584
+ }
463
585
  }
464
- if (size > MAX_FILE_BYTES) continue;
465
- let text;
466
- try {
467
- text = readFileSync2(file, "utf8");
468
- } catch {
469
- continue;
586
+ }
587
+ for (const file of listTranscripts(CODEX_SESSIONS)) {
588
+ if (Date.now() > deadline) {
589
+ complete = false;
590
+ break;
470
591
  }
471
- const ctx = createFileContext();
472
- for (const line of text.split("\n")) {
592
+ const ctx = createCodexContext();
593
+ for (const line of readLines(file)) {
473
594
  if (!line) continue;
474
595
  try {
475
- processRecord(JSON.parse(line), acc, ctx);
596
+ processCodexRecord(JSON.parse(line), acc, ctx);
476
597
  } catch {
477
598
  }
478
599
  }
479
600
  }
480
601
  } catch {
602
+ complete = false;
481
603
  }
482
- return accumulatorToResult(acc);
604
+ return { ...accumulatorToResult(acc), complete };
483
605
  }
484
606
 
485
607
  // src/cursor.ts
@@ -702,7 +824,9 @@ async function fetchCursorEvents(cookie, maxPages = 30, pageSize = 500) {
702
824
  body: JSON.stringify({ page, pageSize }),
703
825
  signal: AbortSignal.timeout(2e4)
704
826
  });
705
- if (!res.ok) break;
827
+ if (!res.ok) {
828
+ throw new Error(`cursor usage page ${page} failed (HTTP ${res.status})`);
829
+ }
706
830
  const body = await res.json();
707
831
  const batch = body.usageEventsDisplay ?? [];
708
832
  all.push(...batch);
@@ -912,24 +1036,34 @@ function dedupeBlocks(blocks) {
912
1036
  }
913
1037
  return [...byStart.values()];
914
1038
  }
915
- function runCcusage(cmd, args) {
1039
+ function runCcusageOnce(cmd, args) {
916
1040
  const res = spawnSync4(cmd, args, {
917
1041
  encoding: "utf8",
918
1042
  maxBuffer: 64 * 1024 * 1024,
919
1043
  timeout: 12e4
920
1044
  });
921
- if (res.status !== 0 || !res.stdout) return null;
1045
+ const transient = res.signal != null || res.error != null;
1046
+ if (res.status !== 0 || !res.stdout) return { json: null, transient };
922
1047
  try {
923
- return JSON.parse(res.stdout);
1048
+ return { json: JSON.parse(res.stdout), transient: false };
924
1049
  } catch {
925
- return null;
1050
+ return { json: null, transient: false };
926
1051
  }
927
1052
  }
928
- async function collectAll() {
1053
+ function runCcusage(cmd, args) {
1054
+ const first = runCcusageOnce(cmd, args);
1055
+ if (first.json !== null || !first.transient) return first.json;
1056
+ return runCcusageOnce(cmd, args).json;
1057
+ }
1058
+ var COLLECT_STAGES = SOURCES.length + 4;
1059
+ async function collectAll(onProgress) {
929
1060
  const { cmd, prefixArgs } = resolveCcusageBin();
930
1061
  const entries = [];
931
1062
  const toolsFound = [];
1063
+ let done = 0;
1064
+ const tick = (label) => onProgress?.(done++, COLLECT_STAGES, label);
932
1065
  for (const source of SOURCES) {
1066
+ tick(`reading ${source}`);
933
1067
  const json = runCcusage(cmd, [
934
1068
  ...prefixArgs,
935
1069
  source,
@@ -944,17 +1078,22 @@ async function collectAll() {
944
1078
  toolsFound.push(source);
945
1079
  }
946
1080
  }
1081
+ tick("reading conversations");
947
1082
  const sessionJson = runCcusage(cmd, [...prefixArgs, "session", "--json", "--offline"]);
1083
+ tick("reading active blocks");
948
1084
  const blockJson = runCcusage(cmd, [...prefixArgs, "blocks", "--json", "--offline"]);
949
1085
  const sessions = sessionJson ? mapCcusageSessions(sessionJson) : [];
950
1086
  const blocks = blockJson ? mapCcusageBlocks(blockJson) : [];
1087
+ tick("reading cursor");
951
1088
  const cursor = await collectCursor();
952
1089
  if (cursor.found) {
953
1090
  entries.push(...cursor.entries);
954
1091
  blocks.push(...cursor.blocks);
955
1092
  toolsFound.push("cursor");
956
1093
  }
957
- const { tools, skills, projects, agent, titles, sessionMessages } = collectAttribution();
1094
+ tick("reading agent transcripts");
1095
+ const { tools, skills, projects, agent, titles, sessionMessages, complete } = collectAttribution();
1096
+ onProgress?.(done, COLLECT_STAGES, "done");
958
1097
  const dedupedSessions = dedupeSessions(sessions).map((s) => {
959
1098
  const title = titles.get(s.sessionId);
960
1099
  const messageCount = sessionMessages.get(s.sessionId);
@@ -972,7 +1111,8 @@ async function collectAll() {
972
1111
  tools,
973
1112
  skills,
974
1113
  projects,
975
- agent
1114
+ agent,
1115
+ attributionComplete: complete
976
1116
  };
977
1117
  }
978
1118
 
@@ -5118,6 +5258,13 @@ var SubmitPayload = external_exports.object({
5118
5258
  projects: external_exports.array(ProjectStat).max(500).optional(),
5119
5259
  /** Optional subagent-vs-main rollup parsed from local transcripts. */
5120
5260
  agent: AgentStat.optional(),
5261
+ /**
5262
+ * Set when the transcript scan completed within its time budget, i.e. the
5263
+ * tool/skill/project/agent rollups are a FULL snapshot. The server refreshes
5264
+ * the dashboard breakdowns unconditionally for a full snapshot; for a partial
5265
+ * one (flag absent/false) it keeps its no-shrink guard. Back-compat: omittable.
5266
+ */
5267
+ attributionComplete: external_exports.boolean().optional(),
5121
5268
  /** Optional friends-board code (from `--board=<code>`): auto-join this board on submit. */
5122
5269
  board: external_exports.string().min(1).max(32).optional()
5123
5270
  });
@@ -5132,7 +5279,7 @@ var LeaderboardPeriod = external_exports.enum(["today", "7d", "30d", "all"]);
5132
5279
  var LeaderboardMetric = external_exports.enum(["tokens", "cost"]);
5133
5280
 
5134
5281
  // src/output.ts
5135
- import pc from "picocolors";
5282
+ import pc2 from "picocolors";
5136
5283
  function formatTokens(n) {
5137
5284
  if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
5138
5285
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
@@ -5160,20 +5307,20 @@ function printSummary(entries) {
5160
5307
  byTool.set(e.tool, agg);
5161
5308
  }
5162
5309
  console.log();
5163
- console.log(pc.bold(pc.yellow(" \u{1F525} your burn report")));
5310
+ console.log(pc2.bold(pc2.yellow(" \u{1F525} your burn report")));
5164
5311
  console.log();
5165
5312
  const rows = [...byTool.entries()].sort((a, b) => b[1].tokens - a[1].tokens);
5166
5313
  for (const [tool, agg] of rows) {
5167
5314
  console.log(
5168
- ` ${pc.cyan(tool.padEnd(10))} ${formatTokens(agg.tokens).padStart(9)} tokens ${formatUSD(agg.cost).padStart(10)} ${String(agg.days.size).padStart(4)} days`
5315
+ ` ${pc2.cyan(tool.padEnd(10))} ${formatTokens(agg.tokens).padStart(9)} tokens ${formatUSD(agg.cost).padStart(10)} ${String(agg.days.size).padStart(4)} days`
5169
5316
  );
5170
5317
  }
5171
- console.log(pc.dim(" " + "\u2500".repeat(46)));
5318
+ console.log(pc2.dim(" " + "\u2500".repeat(46)));
5172
5319
  console.log(
5173
- ` ${pc.bold("total".padEnd(10))} ${pc.bold(formatTokens(totalTokens).padStart(9))} tokens ${pc.bold(formatUSD(totalCost).padStart(10))}`
5320
+ ` ${pc2.bold("total".padEnd(10))} ${pc2.bold(formatTokens(totalTokens).padStart(9))} tokens ${pc2.bold(formatUSD(totalCost).padStart(10))}`
5174
5321
  );
5175
5322
  if (todayTokens > 0) {
5176
- console.log(` ${pc.dim("today".padEnd(10))} ${formatTokens(todayTokens).padStart(9)} tokens`);
5323
+ console.log(` ${pc2.dim("today".padEnd(10))} ${formatTokens(todayTokens).padStart(9)} tokens`);
5177
5324
  }
5178
5325
  console.log();
5179
5326
  }
@@ -5233,7 +5380,18 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5233
5380
  const modelRows = [...byModel.entries()].sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 12).map(
5234
5381
  ([model, a]) => `<tr><td class="mono">${esc(model)}</td><td class="num">${esc(formatTokens(a.tokens))}</td><td class="num">${esc(formatUSD(a.cost))}</td></tr>`
5235
5382
  ).join("");
5236
- const stat = (label, value, accent = false) => `<div class="card"><div class="label">${label}</div><div class="value${accent ? " accent" : ""}">${esc(value)}</div></div>`;
5383
+ const railRow = (label, value, accent = false) => `<div class="rrow"><span class="rlabel">${esc(label)}</span><span class="rval${accent ? " accent" : ""}">${esc(value)}</span></div>`;
5384
+ const tb = [
5385
+ { label: "input", value: totals.input, color: "#3b82f6" },
5386
+ { label: "output", value: totals.output, color: "#22c55e" },
5387
+ { label: "cache write", value: totals.cacheWrite, color: "#a855f7" },
5388
+ { label: "cache read", value: totals.cacheRead, color: "#ea580c" }
5389
+ ];
5390
+ const tbSum = tb.reduce((a, p) => a + p.value, 0) || 1;
5391
+ const tbBar = tb.map((p) => `<div style="width:${p.value / tbSum * 100}%;background:${p.color}"></div>`).join("");
5392
+ const tbCells = tb.map(
5393
+ (p) => `<div class="tbcell"><div class="tblabel"><span class="dot" style="background:${p.color}"></span>${p.label}</div><div class="tbval">${esc(formatTokens(p.value))}</div><div class="tbpct">${Math.round(p.value / tbSum * 100)}%</div></div>`
5394
+ ).join("");
5237
5395
  const connectCta = connect ? `
5238
5396
  <form class="connect" method="POST" action="${esc(connect.webBaseUrl)}/connect">
5239
5397
  <input type="hidden" name="payload" value="${Buffer.from(JSON.stringify(connect.payload)).toString("base64")}">
@@ -5260,19 +5418,36 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5260
5418
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
5261
5419
  line-height: 1.5;
5262
5420
  }
5263
- .wrap { max-width: 960px; margin: 0 auto; padding: 40px 20px 80px; }
5264
- h1 { font-size: 28px; margin: 0; }
5421
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 40px 20px 80px; }
5422
+ h1 { font-size: 22px; margin: 0; line-height: 1.2; }
5265
5423
  h1 .q { color: #ea580c; }
5266
- .sub { color: #a8a29e; font-size: 14px; margin-top: 4px; }
5424
+ .sub { color: #a8a29e; font-size: 13px; margin-top: 4px; }
5267
5425
  .mono, code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
5268
- .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 28px 0; }
5269
- @media (min-width: 640px) { .grid { grid-template-columns: repeat(5, 1fr); } }
5270
- .card { background: #1c1917; border: 1px solid #292524; border-radius: 12px; padding: 14px 16px; }
5271
- .label { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: #a8a29e; }
5272
- .value { font-size: 22px; font-weight: 700; font-family: ui-monospace, monospace; margin-top: 6px; font-variant-numeric: tabular-nums; }
5273
- .value.accent { color: #ea580c; }
5274
- .panel { background: #1c1917; border: 1px solid #292524; border-radius: 12px; padding: 18px 20px; margin-top: 20px; }
5426
+ .head { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
5427
+ .flame { width: 46px; height: 46px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; border-radius: 12px; border: 1px solid #292524; background: #1c1917; font-size: 22px; }
5428
+
5429
+ /* Split layout: narrow sticky rail + wide scrolling column (mirrors the web). */
5430
+ .layout { display: grid; gap: 24px; align-items: start; }
5431
+ @media (min-width: 900px) { .layout { grid-template-columns: 280px minmax(0, 1fr); } .rail { position: sticky; top: 20px; } }
5432
+ .rail { display: flex; flex-direction: column; gap: 16px; }
5433
+ .rail-card { background: #1c1917; border: 1px solid #292524; border-radius: 14px; padding: 16px; }
5434
+ .hero-val { font-size: 30px; font-weight: 800; font-family: ui-monospace, monospace; color: #4ade80; font-variant-numeric: tabular-nums; line-height: 1.1; margin-top: 2px; }
5435
+ .rdiv { height: 1px; background: #292524; margin: 12px 0; }
5436
+ .rrow { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding: 5px 0; }
5437
+ .rlabel { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: #a8a29e; }
5438
+ .rval { font-family: ui-monospace, monospace; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
5439
+ .rval.accent { color: #4ade80; }
5440
+ .col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
5441
+ .panel { background: #1c1917; border: 1px solid #292524; border-radius: 14px; padding: 18px 20px; }
5275
5442
  .panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: #d6d3d1; margin: 0 0 14px; }
5443
+ .tbbar { display: flex; height: 12px; width: 100%; border-radius: 999px; overflow: hidden; background: #292524; }
5444
+ .tbgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 14px; }
5445
+ @media (min-width: 520px) { .tbgrid { grid-template-columns: repeat(4, 1fr); } }
5446
+ .tbcell { border: 1px solid #292524; background: #0c0a09; border-radius: 10px; padding: 8px 10px; }
5447
+ .tblabel { display: flex; align-items: center; gap: 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #a8a29e; }
5448
+ .dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; flex-shrink: 0; }
5449
+ .tbval { font-family: ui-monospace, monospace; font-weight: 700; font-size: 16px; margin-top: 4px; font-variant-numeric: tabular-nums; }
5450
+ .tbpct { font-size: 11px; color: #a8a29e; }
5276
5451
  .chart { display: flex; align-items: flex-end; gap: 2px; height: 140px; }
5277
5452
  .bar { flex: 1; background: linear-gradient(to top, #ea580c, #f97316); border-radius: 2px 2px 0 0; min-height: 0; transition: opacity .15s; }
5278
5453
  .bar:hover { opacity: .7; }
@@ -5296,48 +5471,55 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5296
5471
  </head>
5297
5472
  <body>
5298
5473
  <div class="wrap">
5299
- <h1>who burned more<span class="q">?</span></h1>
5300
- <div class="sub">your local burn report \xB7 generated ${esc(generatedAt.toISOString().slice(0, 16).replace("T", " "))} \xB7 nothing left your machine</div>
5474
+ <header class="head">
5475
+ <div class="flame">\u{1F525}</div>
5476
+ <div>
5477
+ <h1>your local burn</h1>
5478
+ <div class="sub">generated ${esc(generatedAt.toISOString().slice(0, 16).replace("T", " "))} \xB7 nothing left your machine</div>
5479
+ </div>
5480
+ </header>
5301
5481
  ${connectCta}
5302
- <div class="grid">
5303
- ${stat("total tokens", formatTokens(totals.tokens), true)}
5304
- ${stat("est. cost", formatUSD(totals.cost))}
5305
- ${stat("active days", String(activeDays))}
5306
- ${stat("avg / day", formatTokens(avgPerDay))}
5307
- ${stat("today", formatTokens(todayTokens))}
5308
- </div>
5482
+ <div class="layout">
5483
+ <aside class="rail">
5484
+ <div class="rail-card">
5485
+ <div class="rlabel">total tokens</div>
5486
+ <div class="hero-val">${esc(formatTokens(totals.tokens))}</div>
5487
+ <div class="rdiv"></div>
5488
+ ${railRow("avg / day", formatTokens(avgPerDay), true)}
5489
+ ${railRow("est. cost", formatUSD(totals.cost))}
5490
+ ${railRow("active days", String(activeDays))}
5491
+ ${railRow("today", formatTokens(todayTokens))}
5492
+ </div>
5493
+ </aside>
5309
5494
 
5310
- <div class="panel">
5311
- <h2>daily burn (last 60 days)</h2>
5312
- <div class="chart">${bars}</div>
5313
- </div>
5495
+ <div class="col">
5496
+ <div class="panel">
5497
+ <h2>daily burn (last 60 days)</h2>
5498
+ <div class="chart">${bars}</div>
5499
+ </div>
5314
5500
 
5315
- <div class="panel">
5316
- <h2>by tool</h2>
5317
- <table>
5318
- <thead><tr><th>tool</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5319
- <tbody>${toolRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5320
- </table>
5321
- </div>
5501
+ <div class="panel">
5502
+ <h2>by tool</h2>
5503
+ <table>
5504
+ <thead><tr><th>tool</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5505
+ <tbody>${toolRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5506
+ </table>
5507
+ </div>
5322
5508
 
5323
- <div class="panel">
5324
- <h2>by model</h2>
5325
- <table>
5326
- <thead><tr><th>model</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5327
- <tbody>${modelRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5328
- </table>
5329
- </div>
5509
+ <div class="panel">
5510
+ <h2>by model</h2>
5511
+ <table>
5512
+ <thead><tr><th>model</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5513
+ <tbody>${modelRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5514
+ </table>
5515
+ </div>
5330
5516
 
5331
- <div class="panel">
5332
- <h2>token breakdown</h2>
5333
- <table>
5334
- <tbody>
5335
- <tr><td>input</td><td class="num">${esc(formatTokens(totals.input))}</td></tr>
5336
- <tr><td>output</td><td class="num">${esc(formatTokens(totals.output))}</td></tr>
5337
- <tr><td>cache write</td><td class="num">${esc(formatTokens(totals.cacheWrite))}</td></tr>
5338
- <tr><td>cache read</td><td class="num">${esc(formatTokens(totals.cacheRead))}</td></tr>
5339
- </tbody>
5340
- </table>
5517
+ <div class="panel">
5518
+ <h2>token breakdown</h2>
5519
+ <div class="tbbar">${tbBar}</div>
5520
+ <div class="tbgrid">${tbCells}</div>
5521
+ </div>
5522
+ </div>
5341
5523
  </div>
5342
5524
 
5343
5525
  <div class="foot">
@@ -5369,23 +5551,45 @@ async function publishLocal(payload, deps) {
5369
5551
  // src/index.ts
5370
5552
  var require2 = createRequire4(import.meta.url);
5371
5553
  var VERSION = require2("../package.json").version;
5372
- function startSpinner(label) {
5554
+ function startProgress() {
5373
5555
  if (!process.stdout.isTTY) {
5374
- console.log(pc2.dim(` ${label}`));
5375
- return () => {
5556
+ let lastLogged = -1;
5557
+ return {
5558
+ onProgress: (done, total) => {
5559
+ const pct = Math.round(done / total * 100);
5560
+ if (pct >= lastLogged + 25 || pct === 100 && lastLogged < 100) {
5561
+ lastLogged = pct;
5562
+ console.log(pc3.dim(` reading local usage\u2026 ${pct}%`));
5563
+ }
5564
+ },
5565
+ stop: () => {
5566
+ }
5376
5567
  };
5377
5568
  }
5378
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
5379
- let i = 0;
5569
+ const width = 24;
5570
+ let target = 0;
5571
+ let shown = 0;
5572
+ let label = "starting\u2026";
5380
5573
  process.stdout.write("\x1B[?25l");
5381
- const timer = setInterval(() => {
5382
- i = (i + 1) % frames.length;
5383
- process.stdout.write(`\r ${pc2.yellow(frames[i])} ${pc2.dim(label)}`);
5384
- }, 80);
5385
- return () => {
5386
- clearInterval(timer);
5387
- process.stdout.write("\r\x1B[2K");
5388
- process.stdout.write("\x1B[?25h");
5574
+ const render = () => {
5575
+ shown += (target - shown) * 0.3;
5576
+ if (target - shown < 4e-3) shown = target;
5577
+ const filled = Math.round(shown * width);
5578
+ const bar = pc3.yellow("\u2588".repeat(filled)) + pc3.dim("\u2591".repeat(width - filled));
5579
+ const pct = String(Math.round(shown * 100)).padStart(3);
5580
+ process.stdout.write(`\r ${bar} ${pct}% ${pc3.dim(label)}\x1B[K`);
5581
+ };
5582
+ render();
5583
+ const timer = setInterval(render, 60);
5584
+ return {
5585
+ onProgress: (done, total, l) => {
5586
+ target = total > 0 ? done / total : 0;
5587
+ label = l;
5588
+ },
5589
+ stop: () => {
5590
+ clearInterval(timer);
5591
+ process.stdout.write("\r\x1B[2K\x1B[?25h");
5592
+ }
5389
5593
  };
5390
5594
  }
5391
5595
  function openBrowser(url) {
@@ -5396,7 +5600,7 @@ function openBrowser(url) {
5396
5600
  async function confirm(question) {
5397
5601
  if (!process.stdin.isTTY) return false;
5398
5602
  const rl = createInterface({ input: process.stdin, output: process.stdout });
5399
- const answer = (await rl.question(`${question} ${pc2.dim("[Y/n]")} `)).trim();
5603
+ const answer = (await rl.question(`${question} ${pc3.dim("[Y/n]")} `)).trim();
5400
5604
  rl.close();
5401
5605
  return answer === "" || /^y(es)?$/i.test(answer);
5402
5606
  }
@@ -5412,27 +5616,36 @@ function showLocalDashboard(payload) {
5412
5616
  })
5413
5617
  );
5414
5618
  console.log();
5415
- console.log(` Local dashboard: ${pc2.cyan(`file://${file}`)}`);
5416
- console.log(pc2.dim(" Re-run `npx whoburnedmore --local` to refresh it. Nothing left your machine."));
5619
+ console.log(` Local dashboard: ${pc3.cyan(`file://${file}`)}`);
5620
+ console.log(pc3.dim(" Re-run `npx whoburnedmore --local` to refresh it. Nothing left your machine."));
5417
5621
  openBrowser(`file://${file}`);
5418
5622
  }
5419
5623
  async function run(flags) {
5420
5624
  if (!flags.quiet) {
5421
- console.log(pc2.dim(`whoburnedmore v${VERSION} \xB7 ${flags.local ? "local mode" : apiBase()}`));
5625
+ printBanner();
5626
+ console.log(pc3.dim(`whoburnedmore v${VERSION} \xB7 ${flags.local ? "local mode" : apiBase()}`));
5627
+ }
5628
+ if (!flags.quiet && !flags.dryRun && !flags.noSubmit && !flags.local && process.stdin.isTTY) {
5629
+ const ok = await confirm(" Read your local usage and post your rank to the leaderboard?");
5630
+ if (!ok) {
5631
+ console.log(pc3.dim(" No worries \u2014 nothing read, nothing sent."));
5632
+ console.log(pc3.dim(" Tip: `--local` builds a private dashboard, `--dry-run` previews the payload."));
5633
+ return;
5634
+ }
5422
5635
  }
5423
- const stop = flags.quiet ? () => {
5424
- } : startSpinner("Calculating your burn from local usage\u2026");
5636
+ const progress = flags.quiet ? { onProgress: void 0, stop: () => {
5637
+ } } : startProgress();
5425
5638
  let collected;
5426
5639
  try {
5427
- collected = await collectAll();
5640
+ collected = await collectAll(progress.onProgress);
5428
5641
  } finally {
5429
- stop();
5642
+ progress.stop();
5430
5643
  }
5431
- const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent } = collected;
5644
+ const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent, attributionComplete } = collected;
5432
5645
  if (entries.length === 0) {
5433
5646
  console.log();
5434
5647
  console.log(" Nothing to burn yet \u2014 no local usage found from any coding agent.");
5435
- console.log(pc2.dim(" Use Claude Code, Codex, Gemini CLI (or friends) and come back."));
5648
+ console.log(pc3.dim(" Use Claude Code, Codex, Gemini CLI (or friends) and come back."));
5436
5649
  return;
5437
5650
  }
5438
5651
  const payload = { cliVersion: VERSION, entries };
@@ -5442,9 +5655,11 @@ async function run(flags) {
5442
5655
  if (skills.length > 0) payload.skills = skills;
5443
5656
  if (projects.length > 0) payload.projects = projects;
5444
5657
  if (agent.messageCount > 0) payload.agent = agent;
5658
+ if (attributionComplete && (tools.length > 0 || skills.length > 0 || projects.length > 0))
5659
+ payload.attributionComplete = true;
5445
5660
  if (flags.board) payload.board = flags.board;
5446
5661
  if (flags.dryRun) {
5447
- console.log(pc2.dim("\n --dry-run: this exact payload would be sent, nothing else:\n"));
5662
+ console.log(pc3.dim("\n --dry-run: this exact payload would be sent, nothing else:\n"));
5448
5663
  console.log(JSON.stringify(payload, null, 2));
5449
5664
  return;
5450
5665
  }
@@ -5457,42 +5672,42 @@ async function run(flags) {
5457
5672
  ensureAnonKey,
5458
5673
  anonSubmit,
5459
5674
  openBrowser,
5460
- log: (line) => console.log(pc2.dim(line))
5675
+ log: (line) => console.log(pc3.dim(line))
5461
5676
  });
5462
5677
  }
5463
5678
  return;
5464
5679
  }
5465
5680
  if (flags.noSubmit) {
5466
- console.log(pc2.dim(" --no-submit: skipped the dashboard."));
5681
+ console.log(pc3.dim(" --no-submit: skipped the dashboard."));
5467
5682
  return;
5468
5683
  }
5469
5684
  const anonKey = ensureAnonKey();
5470
5685
  const result = await anonSubmit(anonKey, payload);
5471
5686
  const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5472
5687
  if (!flags.quiet) {
5473
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5688
+ console.log(pc3.dim(" Opening your dashboard in your browser\u2026"));
5474
5689
  openBrowser(target);
5475
5690
  }
5476
5691
  console.log(
5477
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5692
+ ` Submitted ${pc3.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5478
5693
  );
5479
5694
  if (result.boardUrl) {
5480
5695
  console.log(
5481
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5696
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5482
5697
  );
5483
- console.log(` ${pc2.cyan(result.boardUrl)}`);
5484
- console.log(pc2.dim(` Your dashboard: ${result.dashboardUrl}`));
5698
+ console.log(` ${pc3.cyan(result.boardUrl)}`);
5699
+ console.log(pc3.dim(` Your dashboard: ${result.dashboardUrl}`));
5485
5700
  } else {
5486
5701
  console.log(
5487
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5702
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5488
5703
  );
5489
- console.log(` ${pc2.cyan(result.dashboardUrl)}`);
5704
+ console.log(` ${pc3.cyan(result.dashboardUrl)}`);
5490
5705
  if (!flags.quiet) {
5491
5706
  console.log(
5492
- pc2.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5707
+ pc3.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5493
5708
  );
5494
5709
  console.log(
5495
- pc2.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5710
+ pc3.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5496
5711
  );
5497
5712
  }
5498
5713
  }
@@ -5505,7 +5720,7 @@ async function run(flags) {
5505
5720
  if (!flags.quiet) {
5506
5721
  console.log();
5507
5722
  console.log(
5508
- autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page updates automatically every 3h (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update your page.")
5723
+ autoSyncInstalled() ? pc3.dim(" Background sync is on \u2014 your page updates automatically every hour (`npx whoburnedmore uninstall-sync` to stop).") : pc3.dim(" Re-run anytime to update your page.")
5509
5724
  );
5510
5725
  }
5511
5726
  }
@@ -5580,9 +5795,9 @@ async function main() {
5580
5795
  }
5581
5796
  function printHelp() {
5582
5797
  console.log(`
5583
- ${pc2.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5798
+ ${pc3.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5584
5799
 
5585
- ${pc2.bold("usage")}
5800
+ ${pc3.bold("usage")}
5586
5801
  npx whoburnedmore burn + land on the public leaderboard, open your dashboard
5587
5802
  npx whoburnedmore --board=CODE compare with friends \u2014 join their board (no sign-in)
5588
5803
  npx whoburnedmore --local build the dashboard on your machine and open it (offline)
@@ -5595,7 +5810,7 @@ function printHelp() {
5595
5810
  npx whoburnedmore install-sync turn it back on after uninstalling
5596
5811
 
5597
5812
  Background sync is on by default: after your first run, your page refreshes
5598
- automatically every 3h (\`uninstall-sync\` to stop). Your dashboard is public on
5813
+ automatically every hour (\`uninstall-sync\` to stop). Your dashboard is public on
5599
5814
  the leaderboard as an anonymous burner \u2014 sign in on whoburnedmore.com to claim
5600
5815
  it (handle + X) and own your rank, or run \`private\`/\`remove\` to pull it. Only
5601
5816
  daily aggregate numbers (date, tool, model, token counts, est. cost) ever leave
@@ -5604,7 +5819,7 @@ function printHelp() {
5604
5819
  `);
5605
5820
  }
5606
5821
  main().catch((err) => {
5607
- console.error(pc2.red(`
5822
+ console.error(pc3.red(`
5608
5823
  ${err.message}
5609
5824
  `));
5610
5825
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whoburnedmore",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
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": {
@@ -20,8 +20,7 @@
20
20
  },
21
21
  "dependencies": {
22
22
  "ccusage": "20.0.9",
23
- "picocolors": "^1.1.1",
24
- "tokscale": "^1.2.7"
23
+ "picocolors": "^1.1.1"
25
24
  },
26
25
  "devDependencies": {
27
26
  "@types/node": "^22.10.0",