whoburnedmore 0.5.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 +436 -252
  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) {
@@ -29,6 +29,9 @@ function parseBoard(args) {
29
29
  function apiBase() {
30
30
  return process.env.WHOBURNEDMORE_API ?? "https://api.whoburnedmore.com";
31
31
  }
32
+ function webBase() {
33
+ return process.env.WHOBURNEDMORE_WEB ?? "https://whoburnedmore.com";
34
+ }
32
35
  async function readJson(res) {
33
36
  const text = await res.text();
34
37
  if (!text) return {};
@@ -40,15 +43,12 @@ async function readJson(res) {
40
43
  };
41
44
  }
42
45
  }
43
- async function post(path, body, token) {
46
+ async function post(path, body) {
44
47
  let res;
45
48
  try {
46
49
  res = await fetch(`${apiBase()}${path}`, {
47
50
  method: "POST",
48
- headers: {
49
- "Content-Type": "application/json",
50
- ...token ? { Authorization: `Bearer ${token}` } : {}
51
- },
51
+ headers: { "Content-Type": "application/json" },
52
52
  body: JSON.stringify(body)
53
53
  });
54
54
  } catch {
@@ -58,34 +58,6 @@ async function post(path, body, token) {
58
58
  }
59
59
  return { status: res.status, body: await readJson(res) };
60
60
  }
61
- async function deviceStart() {
62
- const { status, body } = await post("/v1/auth/device", {});
63
- if (status !== 200) throw new Error(`device auth failed (HTTP ${status})`);
64
- return body;
65
- }
66
- async function devicePoll(deviceCode) {
67
- const { body } = await post("/v1/auth/device/token", {
68
- deviceCode
69
- });
70
- return body;
71
- }
72
- async function submitUsage(token, payload) {
73
- const { status, body } = await post(
74
- "/v1/submit",
75
- payload,
76
- token
77
- );
78
- if (status === 401) {
79
- throw new Error("session expired \u2014 run `npx whoburnedmore login` again");
80
- }
81
- if (status !== 200) {
82
- const err = body;
83
- const details = err.details?.length ? `
84
- - ${err.details.join("\n - ")}` : "";
85
- throw new Error(`${err.error ?? `submit failed (HTTP ${status})`}${details}`);
86
- }
87
- return body;
88
- }
89
61
  async function anonSubmit(anonKey, payload) {
90
62
  const { status, body } = await post("/v1/anon/submit", { ...payload, anonKey });
91
63
  if (status !== 200) {
@@ -122,14 +94,14 @@ async function anonRemove(anonKey) {
122
94
 
123
95
  // src/autosync.ts
124
96
  import { spawnSync } from "node:child_process";
125
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync as rmSync2, writeFileSync as writeFileSync2 } from "node:fs";
97
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
126
98
  import { homedir as homedir2, platform } from "node:os";
127
99
  import { join as join2 } from "node:path";
128
100
  import { fileURLToPath } from "node:url";
129
101
 
130
102
  // src/config.ts
131
103
  import { randomBytes } from "node:crypto";
132
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
104
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
133
105
  import { homedir } from "node:os";
134
106
  import { join } from "node:path";
135
107
  function defaultConfigDir() {
@@ -141,8 +113,6 @@ function loadConfig(dir = defaultConfigDir()) {
141
113
  try {
142
114
  const parsed = JSON.parse(readFileSync(file, "utf8"));
143
115
  const config = {};
144
- if (typeof parsed.token === "string") config.token = parsed.token;
145
- if (typeof parsed.handle === "string") config.handle = parsed.handle;
146
116
  if (typeof parsed.anonKey === "string") config.anonKey = parsed.anonKey;
147
117
  return Object.keys(config).length > 0 ? config : null;
148
118
  } catch {
@@ -154,9 +124,6 @@ function saveConfig(dir = defaultConfigDir(), config = {}) {
154
124
  const file = join(dir, "config.json");
155
125
  writeFileSync(file, JSON.stringify(config, null, 2), { mode: 384 });
156
126
  }
157
- function clearConfig(dir = defaultConfigDir()) {
158
- rmSync(join(dir, "config.json"), { force: true });
159
- }
160
127
  function ensureAnonKey(dir = defaultConfigDir()) {
161
128
  const config = loadConfig(dir) ?? {};
162
129
  if (config.anonKey) return config.anonKey;
@@ -166,7 +133,7 @@ function ensureAnonKey(dir = defaultConfigDir()) {
166
133
  }
167
134
 
168
135
  // src/autosync.ts
169
- var SYNC_INTERVAL_HOURS = 3;
136
+ var SYNC_INTERVAL_HOURS = 1;
170
137
  var LABEL = "com.whoburnedmore.sync";
171
138
  function syncLogPath() {
172
139
  return join2(defaultConfigDir(), "sync.log");
@@ -186,8 +153,14 @@ function buildLaunchdPlist(nodePath, scriptPath, logPath = syncLogPath()) {
186
153
  </array>
187
154
  <key>StartInterval</key>
188
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. -->
189
159
  <key>RunAtLoad</key>
190
- <false/>
160
+ <true/>
161
+ <!-- Be a good citizen: macOS schedules this with background priority. -->
162
+ <key>ProcessType</key>
163
+ <string>Background</string>
191
164
  <key>StandardOutPath</key>
192
165
  <string>${logPath}</string>
193
166
  <key>StandardErrorPath</key>
@@ -250,7 +223,7 @@ function uninstallAutoSync() {
250
223
  const plistPath = launchAgentPath();
251
224
  if (existsSync2(plistPath)) {
252
225
  spawnSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
253
- rmSync2(plistPath, { force: true });
226
+ rmSync(plistPath, { force: true });
254
227
  }
255
228
  return "launchd agent removed";
256
229
  }
@@ -283,6 +256,27 @@ function autoSyncInstalled() {
283
256
  return false;
284
257
  }
285
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
+
286
280
  // src/collect.ts
287
281
  import { spawnSync as spawnSync4 } from "node:child_process";
288
282
  import { createRequire as createRequire3 } from "node:module";
@@ -314,6 +308,7 @@ function estimateCostUSD(model, t) {
314
308
 
315
309
  // src/attribution.ts
316
310
  var CLAUDE_PROJECTS = join3(homedir3(), ".claude", "projects");
311
+ var CODEX_SESSIONS = join3(homedir3(), ".codex", "sessions");
317
312
  var MAX_FILES = 5e3;
318
313
  var MAX_FILE_BYTES = 64 * 1024 * 1024;
319
314
  var TIME_BUDGET_MS = 3e4;
@@ -349,9 +344,13 @@ function recordTokens(usage) {
349
344
  function processRecord(rec, acc, ctx) {
350
345
  if (!rec || typeof rec !== "object") return;
351
346
  const r = rec;
347
+ const recTokens = recordTokens(r.message?.usage);
352
348
  if (typeof r.attributionSkill === "string" && r.attributionSkill) {
353
349
  const s = r.attributionSkill.slice(0, 128);
354
- acc.skills.set(s, (acc.skills.get(s) ?? 0) + 1);
350
+ const sk = acc.skills.get(s) ?? { count: 0, tokens: 0 };
351
+ sk.count += 1;
352
+ sk.tokens += recTokens;
353
+ acc.skills.set(s, sk);
355
354
  }
356
355
  if (r.type === "ai-title" && typeof r.aiTitle === "string" && r.aiTitle && typeof r.sessionId === "string" && r.sessionId) {
357
356
  acc.titles.set(r.sessionId, r.aiTitle.slice(0, 200));
@@ -361,18 +360,24 @@ function processRecord(rec, acc, ctx) {
361
360
  const isAssistant = r.type === "assistant" || r.message?.role === "assistant";
362
361
  const isUser = r.type === "user" || r.message?.role === "user";
363
362
  if (isAssistant) {
363
+ const toolUses = [];
364
364
  for (const block of content) {
365
365
  if (block && typeof block === "object" && block.type === "tool_use" && typeof block.name === "string") {
366
366
  const name = block.name.slice(0, 128);
367
367
  if (!name) continue;
368
- const t = acc.tools.get(name) ?? { count: 0, errors: 0 };
369
- t.count += 1;
370
- acc.tools.set(name, t);
371
368
  const id = block.id;
372
- if (typeof id === "string" && id) ctx.toolNames.set(id, name);
369
+ toolUses.push({ name, id: typeof id === "string" ? id : void 0 });
373
370
  }
374
371
  }
375
- const tokens = recordTokens(r.message?.usage);
372
+ const perToolTokens = toolUses.length > 0 ? Math.floor(recTokens / toolUses.length) : 0;
373
+ for (const tu of toolUses) {
374
+ const t = acc.tools.get(tu.name) ?? { count: 0, errors: 0, tokens: 0 };
375
+ t.count += 1;
376
+ t.tokens += perToolTokens;
377
+ acc.tools.set(tu.name, t);
378
+ if (tu.id) ctx.toolNames.set(tu.id, tu.name);
379
+ }
380
+ const tokens = recTokens;
376
381
  acc.agent.messageCount += 1;
377
382
  acc.agent.totalTokens += tokens;
378
383
  const sidechain = r.isSidechain === true;
@@ -411,7 +416,7 @@ function processRecord(rec, acc, ctx) {
411
416
  const id = block.tool_use_id;
412
417
  const name = typeof id === "string" ? ctx.toolNames.get(id) : void 0;
413
418
  if (name) {
414
- const t = acc.tools.get(name) ?? { count: 0, errors: 0 };
419
+ const t = acc.tools.get(name) ?? { count: 0, errors: 0, tokens: 0 };
415
420
  t.errors += 1;
416
421
  acc.tools.set(name, t);
417
422
  }
@@ -420,10 +425,15 @@ function processRecord(rec, acc, ctx) {
420
425
  }
421
426
  }
422
427
  function toSkillStats(map) {
423
- return [...map.entries()].map(([name, count]) => ({ name, count })).filter((s) => s.count > 0).sort((a, b) => b.count - a.count).slice(0, MAX_STATS);
428
+ return [...map.entries()].map(([name, v]) => ({ name, count: v.count, tokens: v.tokens })).filter((s) => s.count > 0).sort((a, b) => b.tokens - a.tokens || b.count - a.count).slice(0, MAX_STATS).map((s) => s.tokens > 0 ? s : { name: s.name, count: s.count });
424
429
  }
425
430
  function toToolStats(map) {
426
- return [...map.entries()].map(([name, v]) => ({ name, count: v.count, errors: v.errors })).filter((s) => s.count > 0).sort((a, b) => b.count - a.count).slice(0, MAX_STATS).map((s) => s.errors > 0 ? s : { name: s.name, count: s.count });
431
+ return [...map.entries()].map(([name, v]) => ({ name, count: v.count, errors: v.errors, tokens: v.tokens })).filter((s) => s.count > 0).sort((a, b) => b.tokens - a.tokens || b.count - a.count).slice(0, MAX_STATS).map((s) => {
432
+ const base = { name: s.name, count: s.count };
433
+ if (s.errors > 0) base.errors = s.errors;
434
+ if (s.tokens > 0) base.tokens = s.tokens;
435
+ return base;
436
+ });
427
437
  }
428
438
  function toProjectStats(map) {
429
439
  return [...map.entries()].map(([name, v]) => ({
@@ -439,9 +449,95 @@ function accumulatorToResult(acc) {
439
449
  projects: toProjectStats(acc.projects),
440
450
  agent: { ...acc.agent },
441
451
  titles: acc.titles,
442
- sessionMessages: acc.sessionMessages
452
+ sessionMessages: acc.sessionMessages,
453
+ complete: true
443
454
  };
444
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
+ }
445
541
  function listTranscripts(dir) {
446
542
  const out = [];
447
543
  const walk = (d) => {
@@ -470,34 +566,42 @@ function listTranscripts(dir) {
470
566
  function collectAttribution() {
471
567
  const acc = createAccumulator();
472
568
  const deadline = Date.now() + TIME_BUDGET_MS;
569
+ let complete = true;
473
570
  try {
474
- for (const file of listTranscripts(CLAUDE_PROJECTS)) {
475
- if (Date.now() > deadline) break;
476
- let size = 0;
477
- try {
478
- size = statSync(file).size;
479
- } catch {
480
- 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
+ }
481
585
  }
482
- if (size > MAX_FILE_BYTES) continue;
483
- let text;
484
- try {
485
- text = readFileSync2(file, "utf8");
486
- } catch {
487
- continue;
586
+ }
587
+ for (const file of listTranscripts(CODEX_SESSIONS)) {
588
+ if (Date.now() > deadline) {
589
+ complete = false;
590
+ break;
488
591
  }
489
- const ctx = createFileContext();
490
- for (const line of text.split("\n")) {
592
+ const ctx = createCodexContext();
593
+ for (const line of readLines(file)) {
491
594
  if (!line) continue;
492
595
  try {
493
- processRecord(JSON.parse(line), acc, ctx);
596
+ processCodexRecord(JSON.parse(line), acc, ctx);
494
597
  } catch {
495
598
  }
496
599
  }
497
600
  }
498
601
  } catch {
602
+ complete = false;
499
603
  }
500
- return accumulatorToResult(acc);
604
+ return { ...accumulatorToResult(acc), complete };
501
605
  }
502
606
 
503
607
  // src/cursor.ts
@@ -720,7 +824,9 @@ async function fetchCursorEvents(cookie, maxPages = 30, pageSize = 500) {
720
824
  body: JSON.stringify({ page, pageSize }),
721
825
  signal: AbortSignal.timeout(2e4)
722
826
  });
723
- if (!res.ok) break;
827
+ if (!res.ok) {
828
+ throw new Error(`cursor usage page ${page} failed (HTTP ${res.status})`);
829
+ }
724
830
  const body = await res.json();
725
831
  const batch = body.usageEventsDisplay ?? [];
726
832
  all.push(...batch);
@@ -930,24 +1036,34 @@ function dedupeBlocks(blocks) {
930
1036
  }
931
1037
  return [...byStart.values()];
932
1038
  }
933
- function runCcusage(cmd, args) {
1039
+ function runCcusageOnce(cmd, args) {
934
1040
  const res = spawnSync4(cmd, args, {
935
1041
  encoding: "utf8",
936
1042
  maxBuffer: 64 * 1024 * 1024,
937
1043
  timeout: 12e4
938
1044
  });
939
- 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 };
940
1047
  try {
941
- return JSON.parse(res.stdout);
1048
+ return { json: JSON.parse(res.stdout), transient: false };
942
1049
  } catch {
943
- return null;
1050
+ return { json: null, transient: false };
944
1051
  }
945
1052
  }
946
- 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) {
947
1060
  const { cmd, prefixArgs } = resolveCcusageBin();
948
1061
  const entries = [];
949
1062
  const toolsFound = [];
1063
+ let done = 0;
1064
+ const tick = (label) => onProgress?.(done++, COLLECT_STAGES, label);
950
1065
  for (const source of SOURCES) {
1066
+ tick(`reading ${source}`);
951
1067
  const json = runCcusage(cmd, [
952
1068
  ...prefixArgs,
953
1069
  source,
@@ -962,17 +1078,22 @@ async function collectAll() {
962
1078
  toolsFound.push(source);
963
1079
  }
964
1080
  }
1081
+ tick("reading conversations");
965
1082
  const sessionJson = runCcusage(cmd, [...prefixArgs, "session", "--json", "--offline"]);
1083
+ tick("reading active blocks");
966
1084
  const blockJson = runCcusage(cmd, [...prefixArgs, "blocks", "--json", "--offline"]);
967
1085
  const sessions = sessionJson ? mapCcusageSessions(sessionJson) : [];
968
1086
  const blocks = blockJson ? mapCcusageBlocks(blockJson) : [];
1087
+ tick("reading cursor");
969
1088
  const cursor = await collectCursor();
970
1089
  if (cursor.found) {
971
1090
  entries.push(...cursor.entries);
972
1091
  blocks.push(...cursor.blocks);
973
1092
  toolsFound.push("cursor");
974
1093
  }
975
- 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");
976
1097
  const dedupedSessions = dedupeSessions(sessions).map((s) => {
977
1098
  const title = titles.get(s.sessionId);
978
1099
  const messageCount = sessionMessages.get(s.sessionId);
@@ -990,7 +1111,8 @@ async function collectAll() {
990
1111
  tools,
991
1112
  skills,
992
1113
  projects,
993
- agent
1114
+ agent,
1115
+ attributionComplete: complete
994
1116
  };
995
1117
  }
996
1118
 
@@ -5096,7 +5218,9 @@ var ToolStat = external_exports.object({
5096
5218
  name: external_exports.string().min(1).max(128),
5097
5219
  count: external_exports.number().int().nonnegative(),
5098
5220
  /** How many of those calls returned an error/interrupt (tool reliability). Optional. */
5099
- errors: external_exports.number().int().nonnegative().optional()
5221
+ errors: external_exports.number().int().nonnegative().optional(),
5222
+ /** Tokens burned on turns that used this tool (turn tokens split across its tool calls). Optional. */
5223
+ tokens: external_exports.number().int().nonnegative().optional()
5100
5224
  });
5101
5225
  var ProjectStat = external_exports.object({
5102
5226
  name: external_exports.string().min(1).max(128),
@@ -5115,7 +5239,9 @@ var AgentStat = external_exports.object({
5115
5239
  });
5116
5240
  var SkillStat = external_exports.object({
5117
5241
  name: external_exports.string().min(1).max(128),
5118
- count: external_exports.number().int().nonnegative()
5242
+ count: external_exports.number().int().nonnegative(),
5243
+ /** Tokens burned in records produced while this skill was active. Optional. */
5244
+ tokens: external_exports.number().int().nonnegative().optional()
5119
5245
  });
5120
5246
  var SubmitPayload = external_exports.object({
5121
5247
  cliVersion: external_exports.string().min(1).max(32),
@@ -5132,6 +5258,13 @@ var SubmitPayload = external_exports.object({
5132
5258
  projects: external_exports.array(ProjectStat).max(500).optional(),
5133
5259
  /** Optional subagent-vs-main rollup parsed from local transcripts. */
5134
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(),
5135
5268
  /** Optional friends-board code (from `--board=<code>`): auto-join this board on submit. */
5136
5269
  board: external_exports.string().min(1).max(32).optional()
5137
5270
  });
@@ -5146,7 +5279,7 @@ var LeaderboardPeriod = external_exports.enum(["today", "7d", "30d", "all"]);
5146
5279
  var LeaderboardMetric = external_exports.enum(["tokens", "cost"]);
5147
5280
 
5148
5281
  // src/output.ts
5149
- import pc from "picocolors";
5282
+ import pc2 from "picocolors";
5150
5283
  function formatTokens(n) {
5151
5284
  if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
5152
5285
  if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
@@ -5174,20 +5307,20 @@ function printSummary(entries) {
5174
5307
  byTool.set(e.tool, agg);
5175
5308
  }
5176
5309
  console.log();
5177
- console.log(pc.bold(pc.yellow(" \u{1F525} your burn report")));
5310
+ console.log(pc2.bold(pc2.yellow(" \u{1F525} your burn report")));
5178
5311
  console.log();
5179
5312
  const rows = [...byTool.entries()].sort((a, b) => b[1].tokens - a[1].tokens);
5180
5313
  for (const [tool, agg] of rows) {
5181
5314
  console.log(
5182
- ` ${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`
5183
5316
  );
5184
5317
  }
5185
- console.log(pc.dim(" " + "\u2500".repeat(46)));
5318
+ console.log(pc2.dim(" " + "\u2500".repeat(46)));
5186
5319
  console.log(
5187
- ` ${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))}`
5188
5321
  );
5189
5322
  if (todayTokens > 0) {
5190
- 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`);
5191
5324
  }
5192
5325
  console.log();
5193
5326
  }
@@ -5196,7 +5329,7 @@ function printSummary(entries) {
5196
5329
  function esc(s) {
5197
5330
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
5198
5331
  }
5199
- function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date()) {
5332
+ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date(), connect) {
5200
5333
  const today = generatedAt.toISOString().slice(0, 10);
5201
5334
  const totals = {
5202
5335
  tokens: 0,
@@ -5247,7 +5380,30 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
5247
5380
  const modelRows = [...byModel.entries()].sort((a, b) => b[1].tokens - a[1].tokens).slice(0, 12).map(
5248
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>`
5249
5382
  ).join("");
5250
- 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("");
5395
+ const connectCta = connect ? `
5396
+ <form class="connect" method="POST" action="${esc(connect.webBaseUrl)}/connect">
5397
+ <input type="hidden" name="payload" value="${Buffer.from(JSON.stringify(connect.payload)).toString("base64")}">
5398
+ <div class="connect-row">
5399
+ <div>
5400
+ <div class="connect-title">Connect your account</div>
5401
+ <div class="connect-sub">Save this dashboard to your account and claim your spot on the public leaderboard. The local numbers above become your starting point \u2014 nothing has left your machine yet.</div>
5402
+ </div>
5403
+ <button type="submit">Connect your account \u2192</button>
5404
+ </div>
5405
+ <div class="connect-note">After connecting, run <code>npx whoburnedmore</code> (no flag) once so it keeps syncing automatically in the background.</div>
5406
+ </form>` : "";
5251
5407
  return `<!doctype html>
5252
5408
  <html lang="en">
5253
5409
  <head>
@@ -5262,19 +5418,36 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
5262
5418
  font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
5263
5419
  line-height: 1.5;
5264
5420
  }
5265
- .wrap { max-width: 960px; margin: 0 auto; padding: 40px 20px 80px; }
5266
- 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; }
5267
5423
  h1 .q { color: #ea580c; }
5268
- .sub { color: #a8a29e; font-size: 14px; margin-top: 4px; }
5424
+ .sub { color: #a8a29e; font-size: 13px; margin-top: 4px; }
5269
5425
  .mono, code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
5270
- .grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin: 28px 0; }
5271
- @media (min-width: 640px) { .grid { grid-template-columns: repeat(5, 1fr); } }
5272
- .card { background: #1c1917; border: 1px solid #292524; border-radius: 12px; padding: 14px 16px; }
5273
- .label { font-size: 11px; text-transform: uppercase; letter-spacing: .1em; color: #a8a29e; }
5274
- .value { font-size: 22px; font-weight: 700; font-family: ui-monospace, monospace; margin-top: 6px; font-variant-numeric: tabular-nums; }
5275
- .value.accent { color: #ea580c; }
5276
- .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; }
5277
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; }
5278
5451
  .chart { display: flex; align-items: flex-end; gap: 2px; height: 140px; }
5279
5452
  .bar { flex: 1; background: linear-gradient(to top, #ea580c, #f97316); border-radius: 2px 2px 0 0; min-height: 0; transition: opacity .15s; }
5280
5453
  .bar:hover { opacity: .7; }
@@ -5285,57 +5458,73 @@ function renderDashboardHtml(entries, generatedAt = /* @__PURE__ */ new Date())
5285
5458
  td.mono { font-family: ui-monospace, monospace; font-size: 12px; }
5286
5459
  .foot { color: #78716c; font-size: 12px; margin-top: 32px; }
5287
5460
  .foot code { color: #d6d3d1; }
5461
+ .connect { display: block; margin: 24px 0 4px; background: linear-gradient(135deg, rgba(234,88,12,.16), rgba(249,115,22,.06)); border: 1px solid rgba(234,88,12,.5); border-radius: 14px; padding: 18px 20px; }
5462
+ .connect-row { display: flex; flex-direction: column; gap: 14px; align-items: flex-start; }
5463
+ @media (min-width: 640px) { .connect-row { flex-direction: row; align-items: center; justify-content: space-between; } }
5464
+ .connect-title { font-size: 16px; font-weight: 700; }
5465
+ .connect-sub { color: #d6d3d1; font-size: 13px; margin-top: 4px; max-width: 60ch; }
5466
+ .connect button { flex-shrink: 0; cursor: pointer; border: 0; border-radius: 10px; background: #ea580c; color: #fff; font-size: 14px; font-weight: 600; padding: 11px 18px; font-family: inherit; transition: background .15s; }
5467
+ .connect button:hover { background: #f97316; }
5468
+ .connect-note { color: #a8a29e; font-size: 12px; margin-top: 14px; padding-top: 12px; border-top: 1px solid rgba(234,88,12,.25); }
5469
+ .connect-note code { color: #fed7aa; background: rgba(0,0,0,.25); padding: 1px 6px; border-radius: 5px; }
5288
5470
  </style>
5289
5471
  </head>
5290
5472
  <body>
5291
5473
  <div class="wrap">
5292
- <h1>who burned more<span class="q">?</span></h1>
5293
- <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>
5481
+ ${connectCta}
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>
5294
5494
 
5295
- <div class="grid">
5296
- ${stat("total tokens", formatTokens(totals.tokens), true)}
5297
- ${stat("est. cost", formatUSD(totals.cost))}
5298
- ${stat("active days", String(activeDays))}
5299
- ${stat("avg / day", formatTokens(avgPerDay))}
5300
- ${stat("today", formatTokens(todayTokens))}
5301
- </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>
5302
5500
 
5303
- <div class="panel">
5304
- <h2>daily burn (last 60 days)</h2>
5305
- <div class="chart">${bars}</div>
5306
- </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>
5307
5508
 
5308
- <div class="panel">
5309
- <h2>by tool</h2>
5310
- <table>
5311
- <thead><tr><th>tool</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5312
- <tbody>${toolRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5313
- </table>
5314
- </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>
5315
5516
 
5316
- <div class="panel">
5317
- <h2>by model</h2>
5318
- <table>
5319
- <thead><tr><th>model</th><th class="num">tokens</th><th class="num">est. cost</th></tr></thead>
5320
- <tbody>${modelRows || '<tr><td colspan="3">no usage found</td></tr>'}</tbody>
5321
- </table>
5322
- </div>
5323
-
5324
- <div class="panel">
5325
- <h2>token breakdown</h2>
5326
- <table>
5327
- <tbody>
5328
- <tr><td>input</td><td class="num">${esc(formatTokens(totals.input))}</td></tr>
5329
- <tr><td>output</td><td class="num">${esc(formatTokens(totals.output))}</td></tr>
5330
- <tr><td>cache write</td><td class="num">${esc(formatTokens(totals.cacheWrite))}</td></tr>
5331
- <tr><td>cache read</td><td class="num">${esc(formatTokens(totals.cacheRead))}</td></tr>
5332
- </tbody>
5333
- </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>
5334
5523
  </div>
5335
5524
 
5336
5525
  <div class="foot">
5337
5526
  Re-run <code>npx whoburnedmore --local</code> to refresh this page.<br>
5338
- Run <code>npx whoburnedmore</code> (no flag) to get a shareable dashboard at whoburnedmore.com \u2014 no sign-in.
5527
+ ${connect ? "Use \u201CConnect your account\u201D above to save it to whoburnedmore.com and join the leaderboard." : "Run <code>npx whoburnedmore</code> (no flag) to get a shareable dashboard at whoburnedmore.com \u2014 no sign-in."}
5339
5528
  </div>
5340
5529
  </div>
5341
5530
  </body>
@@ -5362,23 +5551,45 @@ async function publishLocal(payload, deps) {
5362
5551
  // src/index.ts
5363
5552
  var require2 = createRequire4(import.meta.url);
5364
5553
  var VERSION = require2("../package.json").version;
5365
- function startSpinner(label) {
5554
+ function startProgress() {
5366
5555
  if (!process.stdout.isTTY) {
5367
- console.log(pc2.dim(` ${label}`));
5368
- 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
+ }
5369
5567
  };
5370
5568
  }
5371
- const frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
5372
- let i = 0;
5569
+ const width = 24;
5570
+ let target = 0;
5571
+ let shown = 0;
5572
+ let label = "starting\u2026";
5373
5573
  process.stdout.write("\x1B[?25l");
5374
- const timer = setInterval(() => {
5375
- i = (i + 1) % frames.length;
5376
- process.stdout.write(`\r ${pc2.yellow(frames[i])} ${pc2.dim(label)}`);
5377
- }, 80);
5378
- return () => {
5379
- clearInterval(timer);
5380
- process.stdout.write("\r\x1B[2K");
5381
- 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
+ }
5382
5593
  };
5383
5594
  }
5384
5595
  function openBrowser(url) {
@@ -5386,61 +5597,55 @@ function openBrowser(url) {
5386
5597
  const [cmd, args] = os === "darwin" ? ["open", [url]] : os === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
5387
5598
  spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
5388
5599
  }
5389
- async function login() {
5390
- const device = await deviceStart();
5391
- console.log();
5392
- console.log(` Opening ${pc2.cyan(device.verifyUrl)}`);
5393
- console.log(` Your code: ${pc2.bold(pc2.yellow(device.userCode))}`);
5394
- console.log(pc2.dim(" Sign in with Google or GitHub and approve this device."));
5395
- openBrowser(device.verifyUrl);
5396
- const deadline = Date.now() + device.expiresInSeconds * 1e3;
5397
- while (Date.now() < deadline) {
5398
- await new Promise((r) => setTimeout(r, device.pollIntervalSeconds * 1e3));
5399
- const poll = await devicePoll(device.deviceCode);
5400
- if (poll.status === "ok") {
5401
- const config = { token: poll.token, handle: poll.handle };
5402
- saveConfig(void 0, config);
5403
- console.log(` Signed in as ${pc2.bold(poll.handle)} \u2713`);
5404
- return config;
5405
- }
5406
- if (poll.status === "expired") break;
5407
- }
5408
- throw new Error("login timed out \u2014 run `npx whoburnedmore` to try again");
5409
- }
5410
5600
  async function confirm(question) {
5411
5601
  if (!process.stdin.isTTY) return false;
5412
5602
  const rl = createInterface({ input: process.stdin, output: process.stdout });
5413
- const answer = (await rl.question(`${question} ${pc2.dim("[Y/n]")} `)).trim();
5603
+ const answer = (await rl.question(`${question} ${pc3.dim("[Y/n]")} `)).trim();
5414
5604
  rl.close();
5415
5605
  return answer === "" || /^y(es)?$/i.test(answer);
5416
5606
  }
5417
- function showLocalDashboard(entries) {
5607
+ function showLocalDashboard(payload) {
5418
5608
  const dir = defaultConfigDir();
5419
5609
  mkdirSync3(dir, { recursive: true });
5420
5610
  const file = join7(dir, "dashboard.html");
5421
- writeFileSync3(file, renderDashboardHtml(entries));
5611
+ writeFileSync3(
5612
+ file,
5613
+ renderDashboardHtml(payload.entries, /* @__PURE__ */ new Date(), {
5614
+ payload,
5615
+ webBaseUrl: webBase()
5616
+ })
5617
+ );
5422
5618
  console.log();
5423
- console.log(` Local dashboard: ${pc2.cyan(`file://${file}`)}`);
5424
- 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."));
5425
5621
  openBrowser(`file://${file}`);
5426
5622
  }
5427
5623
  async function run(flags) {
5428
5624
  if (!flags.quiet) {
5429
- 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
+ }
5430
5635
  }
5431
- const stop = flags.quiet ? () => {
5432
- } : startSpinner("Calculating your burn from local usage\u2026");
5636
+ const progress = flags.quiet ? { onProgress: void 0, stop: () => {
5637
+ } } : startProgress();
5433
5638
  let collected;
5434
5639
  try {
5435
- collected = await collectAll();
5640
+ collected = await collectAll(progress.onProgress);
5436
5641
  } finally {
5437
- stop();
5642
+ progress.stop();
5438
5643
  }
5439
- const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent } = collected;
5644
+ const { entries, sessions, blocks, toolsFound, tools, skills, projects, agent, attributionComplete } = collected;
5440
5645
  if (entries.length === 0) {
5441
5646
  console.log();
5442
5647
  console.log(" Nothing to burn yet \u2014 no local usage found from any coding agent.");
5443
- 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."));
5444
5649
  return;
5445
5650
  }
5446
5651
  const payload = { cliVersion: VERSION, entries };
@@ -5450,85 +5655,72 @@ async function run(flags) {
5450
5655
  if (skills.length > 0) payload.skills = skills;
5451
5656
  if (projects.length > 0) payload.projects = projects;
5452
5657
  if (agent.messageCount > 0) payload.agent = agent;
5658
+ if (attributionComplete && (tools.length > 0 || skills.length > 0 || projects.length > 0))
5659
+ payload.attributionComplete = true;
5453
5660
  if (flags.board) payload.board = flags.board;
5454
5661
  if (flags.dryRun) {
5455
- 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"));
5456
5663
  console.log(JSON.stringify(payload, null, 2));
5457
5664
  return;
5458
5665
  }
5459
5666
  if (!flags.quiet) printSummary(entries);
5460
5667
  if (flags.local) {
5461
- showLocalDashboard(entries);
5668
+ showLocalDashboard(payload);
5462
5669
  if (!flags.quiet && process.stdin.isTTY) {
5463
5670
  await publishLocal(payload, {
5464
5671
  confirm,
5465
5672
  ensureAnonKey,
5466
5673
  anonSubmit,
5467
5674
  openBrowser,
5468
- log: (line) => console.log(pc2.dim(line))
5675
+ log: (line) => console.log(pc3.dim(line))
5469
5676
  });
5470
5677
  }
5471
5678
  return;
5472
5679
  }
5473
5680
  if (flags.noSubmit) {
5474
- console.log(pc2.dim(" --no-submit: skipped the dashboard."));
5681
+ console.log(pc3.dim(" --no-submit: skipped the dashboard."));
5475
5682
  return;
5476
5683
  }
5477
- const config = loadConfig();
5478
- if (config?.token) {
5479
- const result = await submitUsage(config.token, payload);
5480
- if (!flags.quiet) {
5481
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5482
- openBrowser(result.boardUrl ?? result.profileUrl);
5483
- }
5684
+ const anonKey = ensureAnonKey();
5685
+ const result = await anonSubmit(anonKey, payload);
5686
+ const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5687
+ if (!flags.quiet) {
5688
+ console.log(pc3.dim(" Opening your dashboard in your browser\u2026"));
5689
+ openBrowser(target);
5690
+ }
5691
+ console.log(
5692
+ ` Submitted ${pc3.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5693
+ );
5694
+ if (result.boardUrl) {
5484
5695
  console.log(
5485
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5696
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5486
5697
  );
5487
- if (result.rank) {
5488
- console.log(
5489
- ` You are ${pc2.bold(pc2.yellow(`#${result.rank}`))} with ${pc2.bold(formatTokens(result.totalTokens))} tokens burned.`
5490
- );
5491
- }
5492
- console.log(` ${pc2.cyan(result.profileUrl)}`);
5493
- if (result.boardUrl) {
5494
- console.log(` \u{1F91D} You're on the friends board: ${pc2.cyan(result.boardUrl)}`);
5495
- }
5698
+ console.log(` ${pc3.cyan(result.boardUrl)}`);
5699
+ console.log(pc3.dim(` Your dashboard: ${result.dashboardUrl}`));
5496
5700
  } else {
5497
- const anonKey = ensureAnonKey();
5498
- const result = await anonSubmit(anonKey, payload);
5499
- const target = result.boardUrl ?? claimUrl(result.dashboardUrl, anonKey);
5500
- if (!flags.quiet) {
5501
- console.log(pc2.dim(" Opening your dashboard in your browser\u2026"));
5502
- openBrowser(target);
5503
- }
5504
5701
  console.log(
5505
- ` Submitted ${pc2.bold(String(result.upserted))} day-entries from ${toolsFound.join(", ")}.`
5702
+ ` You burned ${pc3.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5506
5703
  );
5507
- if (result.boardUrl) {
5704
+ console.log(` ${pc3.cyan(result.dashboardUrl)}`);
5705
+ if (!flags.quiet) {
5508
5706
  console.log(
5509
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 \u{1F91D} you're on the friends board:`
5707
+ pc3.dim(" Claim it (name + X) on the web to own your rank, or make it private / remove it.")
5510
5708
  );
5511
- console.log(` ${pc2.cyan(result.boardUrl)}`);
5512
- console.log(pc2.dim(` Your dashboard: ${result.dashboardUrl}`));
5513
- } else {
5514
5709
  console.log(
5515
- ` You burned ${pc2.bold(formatTokens(result.totalTokens))} tokens \u2014 you're on the public leaderboard:`
5710
+ pc3.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5516
5711
  );
5517
- console.log(` ${pc2.cyan(result.dashboardUrl)}`);
5518
- if (!flags.quiet) {
5519
- console.log(
5520
- pc2.dim(" Claim it (name + X) to own your rank, or make it private / remove it.")
5521
- );
5522
- console.log(
5523
- pc2.dim(" Manage anytime: `npx whoburnedmore private` \xB7 `npx whoburnedmore public` \xB7 `npx whoburnedmore remove`.")
5524
- );
5525
- }
5526
5712
  }
5527
5713
  }
5528
- console.log();
5714
+ if (!flags.quiet && !autoSyncInstalled()) {
5715
+ try {
5716
+ installAutoSync();
5717
+ } catch {
5718
+ }
5719
+ }
5529
5720
  if (!flags.quiet) {
5721
+ console.log();
5530
5722
  console.log(
5531
- autoSyncInstalled() ? pc2.dim(" Background sync is on \u2014 your page keeps updating automatically (`npx whoburnedmore uninstall-sync` to stop).") : pc2.dim(" Re-run anytime to update \xB7 `npx whoburnedmore install-sync` to keep it live in the background.")
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.")
5532
5724
  );
5533
5725
  }
5534
5726
  }
@@ -5557,14 +5749,6 @@ async function main() {
5557
5749
  await run({ ...flags, noSubmit: false, dryRun: false, local: false });
5558
5750
  break;
5559
5751
  }
5560
- case "login":
5561
- await login();
5562
- break;
5563
- case "logout":
5564
- clearConfig();
5565
- console.log(" Logged out. Your leaderboard data is untouched.");
5566
- console.log(pc2.dim(" Delete your data anytime from your profile page."));
5567
- break;
5568
5752
  case "private":
5569
5753
  case "public": {
5570
5754
  const cfg = loadConfig();
@@ -5611,31 +5795,31 @@ async function main() {
5611
5795
  }
5612
5796
  function printHelp() {
5613
5797
  console.log(`
5614
- ${pc2.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5798
+ ${pc3.bold("whoburnedmore")} \u2014 who burned more tokens, you or them?
5615
5799
 
5616
- ${pc2.bold("usage")}
5800
+ ${pc3.bold("usage")}
5617
5801
  npx whoburnedmore burn + land on the public leaderboard, open your dashboard
5618
5802
  npx whoburnedmore --board=CODE compare with friends \u2014 join their board (no sign-in)
5619
5803
  npx whoburnedmore --local build the dashboard on your machine and open it (offline)
5620
5804
  npx whoburnedmore --dry-run print exactly what would be sent, send nothing
5621
5805
  npx whoburnedmore --no-submit print local stats only, send nothing
5622
- npx whoburnedmore private hide your anonymous dashboard from the leaderboard
5806
+ npx whoburnedmore private hide your dashboard from the leaderboard
5623
5807
  npx whoburnedmore public put it back on the leaderboard
5624
- npx whoburnedmore remove delete your anonymous dashboard and its data
5625
- npx whoburnedmore login sign in to claim a public handle + join the leaderboard
5626
- npx whoburnedmore logout forget the local token
5627
- npx whoburnedmore install-sync keep your dashboard live (background sync, 3h)
5628
- npx whoburnedmore uninstall-sync remove background sync
5808
+ npx whoburnedmore remove delete your dashboard and its data
5809
+ npx whoburnedmore uninstall-sync turn off the background sync
5810
+ npx whoburnedmore install-sync turn it back on after uninstalling
5629
5811
 
5630
- By default your dashboard is public on the leaderboard as an anonymous burner \u2014
5631
- claim it (sign in for a handle + X) to own your rank, or run \`private\`/\`remove\`
5632
- to pull it. Only daily aggregate numbers (date, tool, model, token counts, est.
5633
- cost) ever leave your machine \u2014 never prompts, code, or file names. With --local,
5634
- nothing leaves your machine at all.
5812
+ Background sync is on by default: after your first run, your page refreshes
5813
+ automatically every hour (\`uninstall-sync\` to stop). Your dashboard is public on
5814
+ the leaderboard as an anonymous burner \u2014 sign in on whoburnedmore.com to claim
5815
+ it (handle + X) and own your rank, or run \`private\`/\`remove\` to pull it. Only
5816
+ daily aggregate numbers (date, tool, model, token counts, est. cost) ever leave
5817
+ your machine \u2014 never prompts, code, or file names. With --local, nothing leaves
5818
+ your machine at all.
5635
5819
  `);
5636
5820
  }
5637
5821
  main().catch((err) => {
5638
- console.error(pc2.red(`
5822
+ console.error(pc3.red(`
5639
5823
  ${err.message}
5640
5824
  `));
5641
5825
  process.exitCode = 1;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whoburnedmore",
3
- "version": "0.5.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",