whoburnedmore 0.6.0 → 0.8.1

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 +409 -147
  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,10 +256,29 @@ function autoSyncInstalled() {
250
256
  return false;
251
257
  }
252
258
 
259
+ // src/banner.ts
260
+ import pc from "picocolors";
261
+ var WORD = "whoburnedmore";
262
+ var SHADES = [226, 220, 214, 208, 202, 196];
263
+ function paintWordmark() {
264
+ if (!pc.isColorSupported) return `${WORD}?`;
265
+ const letters = [...WORD].map((ch, i) => `\x1B[1;38;5;${SHADES[i % SHADES.length]}m${ch}`).join("");
266
+ return `${letters}\x1B[1;38;5;208m?\x1B[0m`;
267
+ }
268
+ function printBanner() {
269
+ const rule = pc.dim("\u2500".repeat(30));
270
+ console.log();
271
+ console.log(` \u{1F525} ${paintWordmark()}`);
272
+ console.log(` ${rule}`);
273
+ console.log(` ${pc.dim("who burned more \u2014 you, or them?")}`);
274
+ console.log();
275
+ }
276
+
253
277
  // src/collect.ts
254
- import { spawnSync as spawnSync4 } from "node:child_process";
278
+ import { execFile } from "node:child_process";
255
279
  import { createRequire as createRequire3 } from "node:module";
256
280
  import { dirname as dirname2, join as join6 } from "node:path";
281
+ import { promisify } from "node:util";
257
282
 
258
283
  // src/attribution.ts
259
284
  import { readFileSync as readFileSync2, readdirSync, statSync } from "node:fs";
@@ -281,9 +306,10 @@ function estimateCostUSD(model, t) {
281
306
 
282
307
  // src/attribution.ts
283
308
  var CLAUDE_PROJECTS = join3(homedir3(), ".claude", "projects");
309
+ var CODEX_SESSIONS = join3(homedir3(), ".codex", "sessions");
284
310
  var MAX_FILES = 5e3;
285
311
  var MAX_FILE_BYTES = 64 * 1024 * 1024;
286
- var TIME_BUDGET_MS = 3e4;
312
+ var TIME_BUDGET_MS = 12e3;
287
313
  var MAX_STATS = 300;
288
314
  var MAX_PROJECTS = 500;
289
315
  function createAccumulator() {
@@ -421,9 +447,95 @@ function accumulatorToResult(acc) {
421
447
  projects: toProjectStats(acc.projects),
422
448
  agent: { ...acc.agent },
423
449
  titles: acc.titles,
424
- sessionMessages: acc.sessionMessages
450
+ sessionMessages: acc.sessionMessages,
451
+ complete: true
425
452
  };
426
453
  }
454
+ function numTok(v) {
455
+ const x = Math.round(Number(v));
456
+ return Number.isFinite(x) && x > 0 ? x : 0;
457
+ }
458
+ function createCodexContext() {
459
+ return { cwd: "", model: "unknown", pending: [] };
460
+ }
461
+ function processCodexRecord(rec, acc, ctx) {
462
+ if (!rec || typeof rec !== "object") return;
463
+ const r = rec;
464
+ if (!r.payload || typeof r.payload !== "object") return;
465
+ const pl = r.payload;
466
+ const ptype = pl.type;
467
+ if (r.type === "session_meta" || r.type === "turn_context") {
468
+ if (typeof pl.cwd === "string" && pl.cwd) ctx.cwd = pl.cwd;
469
+ if (typeof pl.model === "string" && pl.model) ctx.model = pl.model;
470
+ return;
471
+ }
472
+ if (ptype === "function_call" || ptype === "custom_tool_call" || ptype === "local_shell_call") {
473
+ const raw = typeof pl.name === "string" ? pl.name : ptype === "local_shell_call" ? "local_shell" : "";
474
+ const name = raw.slice(0, 128);
475
+ if (!name) return;
476
+ const id = typeof pl.call_id === "string" ? pl.call_id : void 0;
477
+ ctx.pending.push({ name, id });
478
+ const t = acc.tools.get(name) ?? { count: 0, errors: 0, tokens: 0 };
479
+ t.count += 1;
480
+ acc.tools.set(name, t);
481
+ return;
482
+ }
483
+ if (ptype === "token_count") {
484
+ const info = pl.info;
485
+ const last = info?.last_token_usage;
486
+ if (!last) return;
487
+ const inputTokens = numTok(last.input_tokens);
488
+ const cacheReadTokens = numTok(last.cached_input_tokens);
489
+ const outputTokens = numTok(last.output_tokens) + numTok(last.reasoning_output_tokens);
490
+ const tokens = numTok(last.total_tokens) || inputTokens + cacheReadTokens + outputTokens;
491
+ if (tokens <= 0) {
492
+ ctx.pending = [];
493
+ return;
494
+ }
495
+ acc.agent.messageCount += 1;
496
+ acc.agent.totalTokens += tokens;
497
+ if (ctx.cwd) {
498
+ const name = basename(ctx.cwd).slice(0, 128) || "unknown";
499
+ const cost = estimateCostUSD(ctx.model, {
500
+ inputTokens,
501
+ outputTokens,
502
+ cacheCreationTokens: 0,
503
+ cacheReadTokens
504
+ });
505
+ const proj = acc.projects.get(name) ?? { tokens: 0, costUSD: 0 };
506
+ proj.tokens += tokens;
507
+ proj.costUSD += cost;
508
+ acc.projects.set(name, proj);
509
+ }
510
+ const per = ctx.pending.length > 0 ? Math.floor(tokens / ctx.pending.length) : 0;
511
+ for (const tu of ctx.pending) {
512
+ const t = acc.tools.get(tu.name);
513
+ if (t) t.tokens += per;
514
+ }
515
+ ctx.pending = [];
516
+ }
517
+ }
518
+ function readLines(file) {
519
+ let size = 0;
520
+ try {
521
+ size = statSync(file).size;
522
+ } catch {
523
+ return [];
524
+ }
525
+ if (size > MAX_FILE_BYTES) return [];
526
+ try {
527
+ return readFileSync2(file, "utf8").split("\n");
528
+ } catch {
529
+ return [];
530
+ }
531
+ }
532
+ function claudeProjectDirs() {
533
+ const dirs = [CLAUDE_PROJECTS];
534
+ const cfg = process.env.CLAUDE_CONFIG_DIR;
535
+ if (cfg) dirs.push(join3(cfg, "projects"));
536
+ dirs.push(join3(homedir3(), ".config", "claude", "projects"));
537
+ return [...new Set(dirs)];
538
+ }
427
539
  function listTranscripts(dir) {
428
540
  const out = [];
429
541
  const walk = (d) => {
@@ -449,37 +561,54 @@ function listTranscripts(dir) {
449
561
  walk(dir);
450
562
  return out.sort((a, b) => b.mtime - a.mtime).slice(0, MAX_FILES).map((f) => f.path);
451
563
  }
452
- function collectAttribution() {
564
+ async function collectAttribution() {
453
565
  const acc = createAccumulator();
454
566
  const deadline = Date.now() + TIME_BUDGET_MS;
567
+ let complete = true;
568
+ let sinceYield = 0;
569
+ const breathe = async () => {
570
+ if (++sinceYield >= 8) {
571
+ sinceYield = 0;
572
+ await new Promise((r) => setImmediate(r));
573
+ }
574
+ };
455
575
  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;
576
+ for (const dir of claudeProjectDirs()) {
577
+ for (const file of listTranscripts(dir)) {
578
+ if (Date.now() > deadline) {
579
+ complete = false;
580
+ break;
581
+ }
582
+ const ctx = createFileContext();
583
+ for (const line of readLines(file)) {
584
+ if (!line) continue;
585
+ try {
586
+ processRecord(JSON.parse(line), acc, ctx);
587
+ } catch {
588
+ }
589
+ }
590
+ await breathe();
463
591
  }
464
- if (size > MAX_FILE_BYTES) continue;
465
- let text;
466
- try {
467
- text = readFileSync2(file, "utf8");
468
- } catch {
469
- continue;
592
+ }
593
+ for (const file of listTranscripts(CODEX_SESSIONS)) {
594
+ if (Date.now() > deadline) {
595
+ complete = false;
596
+ break;
470
597
  }
471
- const ctx = createFileContext();
472
- for (const line of text.split("\n")) {
598
+ const ctx = createCodexContext();
599
+ for (const line of readLines(file)) {
473
600
  if (!line) continue;
474
601
  try {
475
- processRecord(JSON.parse(line), acc, ctx);
602
+ processCodexRecord(JSON.parse(line), acc, ctx);
476
603
  } catch {
477
604
  }
478
605
  }
606
+ await breathe();
479
607
  }
480
608
  } catch {
609
+ complete = false;
481
610
  }
482
- return accumulatorToResult(acc);
611
+ return { ...accumulatorToResult(acc), complete };
483
612
  }
484
613
 
485
614
  // src/cursor.ts
@@ -702,7 +831,9 @@ async function fetchCursorEvents(cookie, maxPages = 30, pageSize = 500) {
702
831
  body: JSON.stringify({ page, pageSize }),
703
832
  signal: AbortSignal.timeout(2e4)
704
833
  });
705
- if (!res.ok) break;
834
+ if (!res.ok) {
835
+ throw new Error(`cursor usage page ${page} failed (HTTP ${res.status})`);
836
+ }
706
837
  const body = await res.json();
707
838
  const batch = body.usageEventsDisplay ?? [];
708
839
  all.push(...batch);
@@ -731,6 +862,7 @@ async function collectCursor() {
731
862
  }
732
863
 
733
864
  // src/collect.ts
865
+ var execFileAsync = promisify(execFile);
734
866
  var SOURCES = [
735
867
  "claude",
736
868
  "codex",
@@ -912,49 +1044,85 @@ function dedupeBlocks(blocks) {
912
1044
  }
913
1045
  return [...byStart.values()];
914
1046
  }
915
- function runCcusage(cmd, args) {
916
- const res = spawnSync4(cmd, args, {
917
- encoding: "utf8",
918
- maxBuffer: 64 * 1024 * 1024,
919
- timeout: 12e4
920
- });
921
- if (res.status !== 0 || !res.stdout) return null;
1047
+ async function runCcusageOnce(cmd, args) {
922
1048
  try {
923
- return JSON.parse(res.stdout);
924
- } catch {
925
- return null;
1049
+ const { stdout } = await execFileAsync(cmd, args, {
1050
+ encoding: "utf8",
1051
+ maxBuffer: 64 * 1024 * 1024,
1052
+ // A single source shouldn't be able to hang the whole run. 25s is plenty
1053
+ // for a healthy local read; a hung source gets killed and (if transient)
1054
+ // retried once below rather than stalling everything for minutes.
1055
+ timeout: 25e3
1056
+ });
1057
+ if (!stdout) return { json: null, transient: false };
1058
+ try {
1059
+ return { json: JSON.parse(stdout), transient: false };
1060
+ } catch {
1061
+ return { json: null, transient: false };
1062
+ }
1063
+ } catch (err) {
1064
+ const e = err;
1065
+ const transient = e.killed === true || e.signal != null || typeof e.code === "string";
1066
+ return { json: null, transient };
926
1067
  }
927
1068
  }
928
- async function collectAll() {
1069
+ async function runCcusage(cmd, args) {
1070
+ const first = await runCcusageOnce(cmd, args);
1071
+ if (first.json !== null || !first.transient) return first.json;
1072
+ return (await runCcusageOnce(cmd, args)).json;
1073
+ }
1074
+ var COLLECT_STAGES = SOURCES.length + 4;
1075
+ async function collectAll(onProgress) {
929
1076
  const { cmd, prefixArgs } = resolveCcusageBin();
1077
+ let done = 0;
1078
+ const tick = () => onProgress?.(++done, COLLECT_STAGES, "");
1079
+ const sourceTasks = SOURCES.map(async (source) => {
1080
+ const json = await runCcusage(cmd, [...prefixArgs, source, "daily", "--json", "--offline"]);
1081
+ tick();
1082
+ return { source, mapped: json ? mapCcusageDaily(source, json) : [] };
1083
+ });
1084
+ const sessionTask = runCcusage(cmd, [...prefixArgs, "session", "--json", "--offline"]).then(
1085
+ (json) => {
1086
+ tick();
1087
+ return json ? mapCcusageSessions(json) : [];
1088
+ }
1089
+ );
1090
+ const blockTask = runCcusage(cmd, [...prefixArgs, "blocks", "--json", "--offline"]).then(
1091
+ (json) => {
1092
+ tick();
1093
+ return json ? mapCcusageBlocks(json) : [];
1094
+ }
1095
+ );
1096
+ const cursorTask = collectCursor().then((c) => {
1097
+ tick();
1098
+ return c;
1099
+ });
1100
+ const attributionTask = collectAttribution().then((a) => {
1101
+ tick();
1102
+ return a;
1103
+ });
1104
+ const [sourceResults, sessions, blocks, cursor, attribution] = await Promise.all([
1105
+ Promise.all(sourceTasks),
1106
+ sessionTask,
1107
+ blockTask,
1108
+ cursorTask,
1109
+ attributionTask
1110
+ ]);
930
1111
  const entries = [];
931
1112
  const toolsFound = [];
932
- for (const source of SOURCES) {
933
- const json = runCcusage(cmd, [
934
- ...prefixArgs,
935
- source,
936
- "daily",
937
- "--json",
938
- "--offline"
939
- ]);
940
- if (!json) continue;
941
- const mapped = mapCcusageDaily(source, json);
1113
+ for (const { source, mapped } of sourceResults) {
942
1114
  if (mapped.length > 0) {
943
1115
  entries.push(...mapped);
944
1116
  toolsFound.push(source);
945
1117
  }
946
1118
  }
947
- const sessionJson = runCcusage(cmd, [...prefixArgs, "session", "--json", "--offline"]);
948
- const blockJson = runCcusage(cmd, [...prefixArgs, "blocks", "--json", "--offline"]);
949
- const sessions = sessionJson ? mapCcusageSessions(sessionJson) : [];
950
- const blocks = blockJson ? mapCcusageBlocks(blockJson) : [];
951
- const cursor = await collectCursor();
952
1119
  if (cursor.found) {
953
1120
  entries.push(...cursor.entries);
954
1121
  blocks.push(...cursor.blocks);
955
1122
  toolsFound.push("cursor");
956
1123
  }
957
- const { tools, skills, projects, agent, titles, sessionMessages } = collectAttribution();
1124
+ const { tools, skills, projects, agent, titles, sessionMessages, complete } = attribution;
1125
+ onProgress?.(COLLECT_STAGES, COLLECT_STAGES, "");
958
1126
  const dedupedSessions = dedupeSessions(sessions).map((s) => {
959
1127
  const title = titles.get(s.sessionId);
960
1128
  const messageCount = sessionMessages.get(s.sessionId);
@@ -972,7 +1140,8 @@ async function collectAll() {
972
1140
  tools,
973
1141
  skills,
974
1142
  projects,
975
- agent
1143
+ agent,
1144
+ attributionComplete: complete
976
1145
  };
977
1146
  }
978
1147
 
@@ -5118,6 +5287,13 @@ var SubmitPayload = external_exports.object({
5118
5287
  projects: external_exports.array(ProjectStat).max(500).optional(),
5119
5288
  /** Optional subagent-vs-main rollup parsed from local transcripts. */
5120
5289
  agent: AgentStat.optional(),
5290
+ /**
5291
+ * Set when the transcript scan completed within its time budget, i.e. the
5292
+ * tool/skill/project/agent rollups are a FULL snapshot. The server refreshes
5293
+ * the dashboard breakdowns unconditionally for a full snapshot; for a partial
5294
+ * one (flag absent/false) it keeps its no-shrink guard. Back-compat: omittable.
5295
+ */
5296
+ attributionComplete: external_exports.boolean().optional(),
5121
5297
  /** Optional friends-board code (from `--board=<code>`): auto-join this board on submit. */
5122
5298
  board: external_exports.string().min(1).max(32).optional()
5123
5299
  });
@@ -5132,7 +5308,7 @@ var LeaderboardPeriod = external_exports.enum(["today", "7d", "30d", "all"]);
5132
5308
  var LeaderboardMetric = external_exports.enum(["tokens", "cost"]);
5133
5309
 
5134
5310
  // src/output.ts
5135
- import pc from "picocolors";
5311
+ import pc2 from "picocolors";
5136
5312
  function formatTokens(n) {
5137
5313
  if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
5138
5314
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
@@ -5160,20 +5336,20 @@ function printSummary(entries) {
5160
5336
  byTool.set(e.tool, agg);
5161
5337
  }
5162
5338
  console.log();
5163
- console.log(pc.bold(pc.yellow(" \u{1F525} your burn report")));
5339
+ console.log(pc2.bold(pc2.yellow(" \u{1F525} your burn report")));
5164
5340
  console.log();
5165
5341
  const rows = [...byTool.entries()].sort((a, b) => b[1].tokens - a[1].tokens);
5166
5342
  for (const [tool, agg] of rows) {
5167
5343
  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`
5344
+ ` ${pc2.cyan(tool.padEnd(10))} ${formatTokens(agg.tokens).padStart(9)} tokens ${formatUSD(agg.cost).padStart(10)} ${String(agg.days.size).padStart(4)} days`
5169
5345
  );
5170
5346
  }
5171
- console.log(pc.dim(" " + "\u2500".repeat(46)));
5347
+ console.log(pc2.dim(" " + "\u2500".repeat(46)));
5172
5348
  console.log(
5173
- ` ${pc.bold("total".padEnd(10))} ${pc.bold(formatTokens(totalTokens).padStart(9))} tokens ${pc.bold(formatUSD(totalCost).padStart(10))}`
5349
+ ` ${pc2.bold("total".padEnd(10))} ${pc2.bold(formatTokens(totalTokens).padStart(9))} tokens ${pc2.bold(formatUSD(totalCost).padStart(10))}`
5174
5350
  );
5175
5351
  if (todayTokens > 0) {
5176
- console.log(` ${pc.dim("today".padEnd(10))} ${formatTokens(todayTokens).padStart(9)} tokens`);
5352
+ console.log(` ${pc2.dim("today".padEnd(10))} ${formatTokens(todayTokens).padStart(9)} tokens`);
5177
5353
  }
5178
5354
  console.log();
5179
5355
  }
@@ -5233,7 +5409,18 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5233
5409
  const modelRows = [...byModel.entries()].sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 12).map(
5234
5410
  ([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
5411
  ).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>`;
5412
+ const railRow = (label, value, accent = false) => `<div class="rrow"><span class="rlabel">${esc(label)}</span><span class="rval${accent ? " accent" : ""}">${esc(value)}</span></div>`;
5413
+ const tb = [
5414
+ { label: "input", value: totals.input, color: "#3b82f6" },
5415
+ { label: "output", value: totals.output, color: "#22c55e" },
5416
+ { label: "cache write", value: totals.cacheWrite, color: "#a855f7" },
5417
+ { label: "cache read", value: totals.cacheRead, color: "#ea580c" }
5418
+ ];
5419
+ const tbSum = tb.reduce((a, p) => a + p.value, 0) || 1;
5420
+ const tbBar = tb.map((p) => `<div style="width:${p.value / tbSum * 100}%;background:${p.color}"></div>`).join("");
5421
+ const tbCells = tb.map(
5422
+ (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>`
5423
+ ).join("");
5237
5424
  const connectCta = connect ? `
5238
5425
  <form class="connect" method="POST" action="${esc(connect.webBaseUrl)}/connect">
5239
5426
  <input type="hidden" name="payload" value="${Buffer.from(JSON.stringify(connect.payload)).toString("base64")}">
@@ -5260,19 +5447,36 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5260
5447
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
5261
5448
  line-height: 1.5;
5262
5449
  }
5263
- .wrap { max-width: 960px; margin: 0 auto; padding: 40px 20px 80px; }
5264
- h1 { font-size: 28px; margin: 0; }
5450
+ .wrap { max-width: 1100px; margin: 0 auto; padding: 40px 20px 80px; }
5451
+ h1 { font-size: 22px; margin: 0; line-height: 1.2; }
5265
5452
  h1 .q { color: #ea580c; }
5266
- .sub { color: #a8a29e; font-size: 14px; margin-top: 4px; }
5453
+ .sub { color: #a8a29e; font-size: 13px; margin-top: 4px; }
5267
5454
  .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; }
5455
+ .head { display: flex; align-items: center; gap: 14px; margin-bottom: 22px; }
5456
+ .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; }
5457
+
5458
+ /* Split layout: narrow sticky rail + wide scrolling column (mirrors the web). */
5459
+ .layout { display: grid; gap: 24px; align-items: start; }
5460
+ @media (min-width: 900px) { .layout { grid-template-columns: 280px minmax(0, 1fr); } .rail { position: sticky; top: 20px; } }
5461
+ .rail { display: flex; flex-direction: column; gap: 16px; }
5462
+ .rail-card { background: #1c1917; border: 1px solid #292524; border-radius: 14px; padding: 16px; }
5463
+ .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; }
5464
+ .rdiv { height: 1px; background: #292524; margin: 12px 0; }
5465
+ .rrow { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; padding: 5px 0; }
5466
+ .rlabel { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: #a8a29e; }
5467
+ .rval { font-family: ui-monospace, monospace; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
5468
+ .rval.accent { color: #4ade80; }
5469
+ .col { display: flex; flex-direction: column; gap: 20px; min-width: 0; }
5470
+ .panel { background: #1c1917; border: 1px solid #292524; border-radius: 14px; padding: 18px 20px; }
5275
5471
  .panel h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .08em; color: #d6d3d1; margin: 0 0 14px; }
5472
+ .tbbar { display: flex; height: 12px; width: 100%; border-radius: 999px; overflow: hidden; background: #292524; }
5473
+ .tbgrid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 14px; }
5474
+ @media (min-width: 520px) { .tbgrid { grid-template-columns: repeat(4, 1fr); } }
5475
+ .tbcell { border: 1px solid #292524; background: #0c0a09; border-radius: 10px; padding: 8px 10px; }
5476
+ .tblabel { display: flex; align-items: center; gap: 6px; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; color: #a8a29e; }
5477
+ .dot { width: 8px; height: 8px; border-radius: 999px; display: inline-block; flex-shrink: 0; }
5478
+ .tbval { font-family: ui-monospace, monospace; font-weight: 700; font-size: 16px; margin-top: 4px; font-variant-numeric: tabular-nums; }
5479
+ .tbpct { font-size: 11px; color: #a8a29e; }
5276
5480
  .chart { display: flex; align-items: flex-end; gap: 2px; height: 140px; }
5277
5481
  .bar { flex: 1; background: linear-gradient(to top, #ea580c, #f97316); border-radius: 2px 2px 0 0; min-height: 0; transition: opacity .15s; }
5278
5482
  .bar:hover { opacity: .7; }
@@ -5296,48 +5500,55 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(),
5296
5500
  </head>
5297
5501
  <body>
5298
5502
  <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>
5503
+ <header class="head">
5504
+ <div class="flame">\u{1F525}</div>
5505
+ <div>
5506
+ <h1>your local burn</h1>
5507
+ <div class="sub">generated ${esc(generatedAt.toISOString().slice(0, 16).replace("T", " "))} \xB7 nothing left your machine</div>
5508
+ </div>
5509
+ </header>
5301
5510
  ${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>
5511
+ <div class="layout">
5512
+ <aside class="rail">
5513
+ <div class="rail-card">
5514
+ <div class="rlabel">total tokens</div>
5515
+ <div class="hero-val">${esc(formatTokens(totals.tokens))}</div>
5516
+ <div class="rdiv"></div>
5517
+ ${railRow("avg / day", formatTokens(avgPerDay), true)}
5518
+ ${railRow("est. cost", formatUSD(totals.cost))}
5519
+ ${railRow("active days", String(activeDays))}
5520
+ ${railRow("today", formatTokens(todayTokens))}
5521
+ </div>
5522
+ </aside>
5309
5523
 
5310
- <div class="panel">
5311
- <h2>daily burn (last 60 days)</h2>
5312
- <div class="chart">${bars}</div>
5313
- </div>
5524
+ <div class="col">
5525
+ <div class="panel">
5526
+ <h2>daily burn (last 60 days)</h2>
5527
+ <div class="chart">${bars}</div>
5528
+ </div>
5314
5529
 
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>
5530
+ <div class="panel">
5531
+ <h2>by tool</h2>
5532
+ <table>
5533
+ <thead><tr><th>tool</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5534
+ <tbody>${toolRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5535
+ </table>
5536
+ </div>
5322
5537
 
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>
5538
+ <div class="panel">
5539
+ <h2>by model</h2>
5540
+ <table>
5541
+ <thead><tr><th>model</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5542
+ <tbody>${modelRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5543
+ </table>
5544
+ </div>
5330
5545
 
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>
5546
+ <div class="panel">
5547
+ <h2>token breakdown</h2>
5548
+ <div class="tbbar">${tbBar}</div>
5549
+ <div class="tbgrid">${tbCells}</div>
5550
+ </div>
5551
+ </div>
5341
5552
  </div>
5342
5553
 
5343
5554
  <div class="foot">
@@ -5369,23 +5580,62 @@ async function publishLocal(payload, deps) {
5369
5580
  // src/index.ts
5370
5581
  var require2 = createRequire4(import.meta.url);
5371
5582
  var VERSION = require2("../package.json").version;
5372
- function startSpinner(label) {
5583
+ var LOADING_VIBES = [
5584
+ "counting up your token usage, right here on your machine\u2026",
5585
+ "tallying tokens across every coding agent you use\u2026",
5586
+ "adding up cache reads, writes & all the burn\u2026",
5587
+ "tokens & totals only \u2014 never your prompts or code\u2026",
5588
+ "summing up your usage, model by model\u2026",
5589
+ "working out what all that burn cost you \u{1F525}",
5590
+ "crunching your daily token totals\u2026",
5591
+ "almost there \u2014 adding it all up\u2026"
5592
+ ];
5593
+ function startProgress() {
5373
5594
  if (!process.stdout.isTTY) {
5374
- console.log(pc2.dim(` ${label}`));
5375
- return () => {
5595
+ let lastLogged = -1;
5596
+ return {
5597
+ onProgress: (done, total) => {
5598
+ const pct = Math.round(done / total * 100);
5599
+ if (pct >= lastLogged + 25 || pct === 100 && lastLogged < 100) {
5600
+ lastLogged = pct;
5601
+ console.log(pc3.dim(` counting local token usage\u2026 ${pct}%`));
5602
+ }
5603
+ },
5604
+ stop: () => {
5605
+ }
5376
5606
  };
5377
5607
  }
5378
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
5379
- let i = 0;
5608
+ const width = 24;
5609
+ let target = 0;
5610
+ let shown = 0;
5611
+ let ticks = 0;
5380
5612
  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");
5613
+ const vibe = () => {
5614
+ if (shown >= 0.9) return LOADING_VIBES[LOADING_VIBES.length - 1];
5615
+ const i = Math.floor(ticks / 18) % (LOADING_VIBES.length - 1);
5616
+ return LOADING_VIBES[i];
5617
+ };
5618
+ const render = () => {
5619
+ ticks++;
5620
+ shown += (target - shown) * 0.3;
5621
+ if (target - shown < 4e-3) shown = target;
5622
+ const filled = Math.round(shown * width);
5623
+ const bar = pc3.yellow("\u2588".repeat(filled)) + pc3.dim("\u2591".repeat(width - filled));
5624
+ const pct = String(Math.round(shown * 100)).padStart(3);
5625
+ process.stdout.write(`\r ${bar} ${pct}% ${pc3.dim(vibe())}\x1B[K`);
5626
+ };
5627
+ render();
5628
+ const timer = setInterval(render, 60);
5629
+ return {
5630
+ // Stage labels are no longer surfaced (the narration is friendlier); we only
5631
+ // consume the done/total fraction to drive the bar.
5632
+ onProgress: (done, total) => {
5633
+ target = total > 0 ? done / total : 0;
5634
+ },
5635
+ stop: () => {
5636
+ clearInterval(timer);
5637
+ process.stdout.write("\r\x1B[2K\x1B[?25h");
5638
+ }
5389
5639
  };
5390
5640
  }
5391
5641
  function openBrowser(url) {
@@ -5396,7 +5646,7 @@ function openBrowser(url) {
5396
5646
  async function confirm(question) {
5397
5647
  if (!process.stdin.isTTY) return false;
5398
5648
  const rl = createInterface({ input: process.stdin, output: process.stdout });
5399
- const answer = (await rl.question(`${question} ${pc2.dim("[Y/n]")} `)).trim();
5649
+ const answer = (await rl.question(`${question} ${pc3.dim("[Y/n]")} `)).trim();
5400
5650
  rl.close();
5401
5651
  return answer === "" || /^y(es)?$/i.test(answer);
5402
5652
  }
@@ -5412,27 +5662,37 @@ function showLocalDashboard(payload) {
5412
5662
  })
5413
5663
  );
5414
5664
  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."));
5665
+ console.log(` Local dashboard: ${pc3.cyan(`file://${file}`)}`);
5666
+ console.log(pc3.dim(" Re-run `npx whoburnedmore --local` to refresh it. Nothing left your machine."));
5417
5667
  openBrowser(`file://${file}`);
5418
5668
  }
5419
5669
  async function run(flags) {
5420
5670
  if (!flags.quiet) {
5421
- console.log(pc2.dim(`whoburnedmore v${VERSION} \xB7 ${flags.local ? "local mode" : apiBase()}`));
5671
+ printBanner();
5672
+ console.log(pc3.dim(` whoburnedmore v${VERSION} \xB7 ${flags.local ? "local mode" : apiBase()}`));
5673
+ if (!flags.dryRun && !flags.noSubmit && !flags.local) {
5674
+ console.log(
5675
+ pc3.dim(" Counting your token usage and posting your rank \u2014 only daily totals leave your machine, never your prompts or code.")
5676
+ );
5677
+ console.log(
5678
+ pc3.dim(" (`--local` keeps it fully offline \xB7 `private`/`remove` pull it anytime \xB7 details: whoburnedmore.com/trust)")
5679
+ );
5680
+ }
5681
+ console.log();
5422
5682
  }
5423
- const stop = flags.quiet ? () => {
5424
- } : startSpinner("Calculating your burn from local usage\u2026");
5683
+ const progress = flags.quiet ? { onProgress: void 0, stop: () => {
5684
+ } } : startProgress();
5425
5685
  let collected;
5426
5686
  try {
5427
- collected = await collectAll();
5687
+ collected = await collectAll(progress.onProgress);
5428
5688
  } finally {
5429
- stop();
5689
+ progress.stop();
5430
5690
  }
5431
- const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent } = collected;
5691
+ const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent, attributionComplete } = collected;
5432
5692
  if (entries.length === 0) {
5433
5693
  console.log();
5434
5694
  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."));
5695
+ console.log(pc3.dim(" Use Claude Code, Codex, Gemini CLI (or friends) and come back."));
5436
5696
  return;
5437
5697
  }
5438
5698
  const payload = { cliVersion: VERSION, entries };
@@ -5442,9 +5702,11 @@ async function run(flags) {
5442
5702
  if (skills.length > 0) payload.skills = skills;
5443
5703
  if (projects.length > 0) payload.projects = projects;
5444
5704
  if (agent.messageCount > 0) payload.agent = agent;
5705
+ if (attributionComplete && (tools.length > 0 || skills.length > 0 || projects.length > 0))
5706
+ payload.attributionComplete = true;
5445
5707
  if (flags.board) payload.board = flags.board;
5446
5708
  if (flags.dryRun) {
5447
- console.log(pc2.dim("\n --dry-run: this exact payload would be sent, nothing else:\n"));
5709
+ console.log(pc3.dim("\n --dry-run: this exact payload would be sent, nothing else:\n"));
5448
5710
  console.log(JSON.stringify(payload, null, 2));
5449
5711
  return;
5450
5712
  }
@@ -5457,42 +5719,42 @@ async function run(flags) {
5457
5719
  ensureAnonKey,
5458
5720
  anonSubmit,
5459
5721
  openBrowser,
5460
- log: (line) => console.log(pc2.dim(line))
5722
+ log: (line) => console.log(pc3.dim(line))
5461
5723
  });
5462
5724
  }
5463
5725
  return;
5464
5726
  }
5465
5727
  if (flags.noSubmit) {
5466
- console.log(pc2.dim(" --no-submit: skipped the dashboard."));
5728
+ console.log(pc3.dim(" --no-submit: skipped the dashboard."));
5467
5729
  return;
5468
5730
  }
5469
5731
  const anonKey = ensureAnonKey();
5470
5732
  const result = await anonSubmit(anonKey, payload);
5471
5733
  const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5472
5734
  if (!flags.quiet) {
5473
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5735
+ console.log(pc3.dim(" Opening your dashboard in your browser\u2026"));
5474
5736
  openBrowser(target);
5475
5737
  }
5476
5738
  console.log(
5477
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5739
+ ` Submitted ${pc3.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5478
5740
  );
5479
5741
  if (result.boardUrl) {
5480
5742
  console.log(
5481
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5743
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5482
5744
  );
5483
- console.log(` ${pc2.cyan(result.boardUrl)}`);
5484
- console.log(pc2.dim(` Your dashboard: ${result.dashboardUrl}`));
5745
+ console.log(` ${pc3.cyan(result.boardUrl)}`);
5746
+ console.log(pc3.dim(` Your dashboard: ${result.dashboardUrl}`));
5485
5747
  } else {
5486
5748
  console.log(
5487
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5749
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5488
5750
  );
5489
- console.log(` ${pc2.cyan(result.dashboardUrl)}`);
5751
+ console.log(` ${pc3.cyan(result.dashboardUrl)}`);
5490
5752
  if (!flags.quiet) {
5491
5753
  console.log(
5492
- pc2.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5754
+ pc3.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5493
5755
  );
5494
5756
  console.log(
5495
- pc2.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5757
+ pc3.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5496
5758
  );
5497
5759
  }
5498
5760
  }
@@ -5505,7 +5767,7 @@ async function run(flags) {
5505
5767
  if (!flags.quiet) {
5506
5768
  console.log();
5507
5769
  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.")
5770
+ 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
5771
  );
5510
5772
  }
5511
5773
  }
@@ -5580,9 +5842,9 @@ async function main() {
5580
5842
  }
5581
5843
  function printHelp() {
5582
5844
  console.log(`
5583
- ${pc2.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5845
+ ${pc3.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5584
5846
 
5585
- ${pc2.bold("usage")}
5847
+ ${pc3.bold("usage")}
5586
5848
  npx whoburnedmore burn + land on the public leaderboard, open your dashboard
5587
5849
  npx whoburnedmore --board=CODE compare with friends \u2014 join their board (no sign-in)
5588
5850
  npx whoburnedmore --local build the dashboard on your machine and open it (offline)
@@ -5595,7 +5857,7 @@ function printHelp() {
5595
5857
  npx whoburnedmore install-sync turn it back on after uninstalling
5596
5858
 
5597
5859
  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
5860
+ automatically every hour (\`uninstall-sync\` to stop). Your dashboard is public on
5599
5861
  the leaderboard as an anonymous burner \u2014 sign in on whoburnedmore.com to claim
5600
5862
  it (handle + X) and own your rank, or run \`private\`/\`remove\` to pull it. Only
5601
5863
  daily aggregate numbers (date, tool, model, token counts, est. cost) ever leave
@@ -5604,7 +5866,7 @@ function printHelp() {
5604
5866
  `);
5605
5867
  }
5606
5868
  main().catch((err) => {
5607
- console.error(pc2.red(`
5869
+ console.error(pc3.red(`
5608
5870
  ${err.message}
5609
5871
  `));
5610
5872
  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.1",
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",