claude-rpc 0.15.3 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.15.3",
3
+ "version": "0.15.5",
4
4
  "description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/cli.js CHANGED
@@ -18,7 +18,7 @@ import { runHookCli } from './hook.js';
18
18
  import { install as runInstall, uninstall as runUninstall, isInstalled, migrateConfig, installHooks, ensureCanonicalExe, installMcp, uninstallMcp, mcpServerCommand, setupOutro } from './install.js';
19
19
  import { startTui } from './tui.js';
20
20
  import { generateInsights } from './insights.js';
21
- import { maybeNudge } from './nudge.js';
21
+ import { maybeNudge, pickTodayMilestone } from './nudge.js';
22
22
  import { badgeSvg } from './badge.js';
23
23
  import { fmtCost } from './pricing.js';
24
24
  import { addPrivateCwd, removePrivateCwd, listPrivateCwds, resolveVisibility } from './privacy.js';
@@ -26,7 +26,7 @@ import { parseDuration, setPause, clearPause, pauseUntil } from './pause.js';
26
26
  import { loadConfig, hasUserConfig } from './config.js';
27
27
  import * as lb from './leaderboard.js';
28
28
  import { VERSION } from './version.js';
29
- import { fail, tailLines, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
29
+ import { fail, tailLines, heat, sparkline, fmtDelta, topPercentile, EX_USER_ERROR, EX_BAD_STATE, EX_SYS_ERROR } from './ui.js';
30
30
  import { randomUUID } from 'node:crypto';
31
31
  import { createInterface } from 'node:readline';
32
32
  import { basename } from 'node:path';
@@ -144,24 +144,24 @@ function shortPath(p) {
144
144
  return home && p.startsWith(home) ? '~' + p.slice(home.length) : p;
145
145
  }
146
146
 
147
- // 24-bar histogram of hour-of-day activity.
147
+ // 24-bar histogram of hour-of-day activity, heat-graded per hour — the
148
+ // peak hour literally glows (bold rust) instead of relying on a color swap.
148
149
  function renderHourHistogram(byHour, opts = {}) {
149
150
  const heightChars = ' ▁▂▃▄▅▆▇█';
150
151
  let max = 0;
151
152
  for (let h = 0; h < 24; h++) max = Math.max(max, byHour?.[h]?.activeMs || 0);
152
153
  if (max <= 0) return [' (no hourly data yet)'];
153
- const bars = [];
154
+ const colored = [];
154
155
  for (let h = 0; h < 24; h++) {
155
156
  const ms = byHour?.[h]?.activeMs || 0;
156
157
  const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
157
- bars.push(heightChars[idx]);
158
+ const ch = heightChars[idx];
159
+ colored.push(h === opts.peakHour ? `${c.bold}${heat(1)}${ch}${c.reset}` : `${heat(ms / max)}${ch}${c.reset}`);
158
160
  }
159
- const peakH = opts.peakHour ?? bars.findIndex((b) => b === heightChars[Math.min(8, ...bars.map((_, i) => i).filter(() => true))]);
160
- const colored = bars.map((ch, h) => h === opts.peakHour ? `${c.magenta}${c.bold}${ch}${c.reset}` : `${c.green}${ch}${c.reset}`).join('');
161
161
  // Hour labels under every 3rd hour.
162
162
  const labels = '00 03 06 09 12 15 18 21 ';
163
163
  return [
164
- ` ${colored}`,
164
+ ` ${colored.join('')}`,
165
165
  ` ${c.dim}${labels}${c.reset}`,
166
166
  ];
167
167
  }
@@ -196,13 +196,14 @@ function renderHeatmap(byDay, days = 91) {
196
196
  }
197
197
  const cols = Math.ceil(cells.length / 7);
198
198
 
199
- // Quantize: 0 / >0 / >15m / >1h / >3h
199
+ // Quantize: 0 / >0 / >15m / >1h / >3h — mapped onto the shared heat ramp
200
+ // so the heatmap, bars, and histograms all speak the same temperature.
200
201
  const shade = (ms) => {
201
202
  if (ms <= 0) return `${c.dim}·${c.reset}`;
202
- if (ms < 15 * 60_000) return `${c.gray}▪${c.reset}`;
203
- if (ms < 60 * 60_000) return `${c.green}▪${c.reset}`;
204
- if (ms < 3 * 3600_000) return `${c.green}${c.bold}▪${c.reset}`;
205
- return `${c.magenta}${c.bold}▪${c.reset}`;
203
+ if (ms < 15 * 60_000) return `${heat(0.2)}▪${c.reset}`;
204
+ if (ms < 60 * 60_000) return `${heat(0.5)}▪${c.reset}`;
205
+ if (ms < 3 * 3600_000) return `${heat(0.8)}▪${c.reset}`;
206
+ return `${c.bold}${heat(1)}▪${c.reset}`;
206
207
  };
207
208
 
208
209
  // Month labels along the top (where the month changes within the visible window).
@@ -234,7 +235,7 @@ function renderHeatmap(byDay, days = 91) {
234
235
  }
235
236
 
236
237
  // Footer legend.
237
- const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${c.gray}▪${c.reset} ${c.green}▪${c.reset} ${c.green}${c.bold}▪${c.reset} ${c.magenta}${c.bold}▪${c.reset} ${c.dim}more${c.reset}`;
238
+ const legend = ` ${c.dim}less${c.reset} ${c.dim}·${c.reset} ${heat(0.2)}▪${c.reset} ${heat(0.5)}▪${c.reset} ${heat(0.8)}▪${c.reset} ${c.bold}${heat(1)}▪${c.reset} ${c.dim}more${c.reset}`;
238
239
  lines.push('');
239
240
  lines.push(legend);
240
241
  return lines;
@@ -244,11 +245,69 @@ function pair(label, value, valueColor = c.cyan) {
244
245
  return `${c.dim}${label.padEnd(14)}${c.reset} ${valueColor}${value}${c.reset}`;
245
246
  }
246
247
 
247
- // ASCII bar for a value relative to max.
248
+ // ASCII bar for a value relative to max. Fill color is heat-graded by the
249
+ // ratio (calm green → amber → rust), so intensity reads at a glance; with
250
+ // colors off it degrades to the same monochrome bar as before.
248
251
  function bar(val, max, width = 22) {
249
252
  if (!max || max <= 0) return '';
250
253
  const filled = Math.max(0, Math.min(width, Math.round((val / max) * width)));
251
- return `${c.magenta}${'█'.repeat(filled)}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
254
+ return `${heat(val / max) || c.magenta}${'█'.repeat(filled)}${c.reset}${c.dim}${'░'.repeat(width - filled)}${c.reset}`;
255
+ }
256
+
257
+ // The last n calendar days of byDay entries (oldest → today), null where a
258
+ // day has no activity. Date-stepped rather than ms arithmetic so DST shifts
259
+ // can't skip or duplicate a day.
260
+ function lastDays(byDay, n) {
261
+ const out = [];
262
+ const d = new Date();
263
+ d.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
264
+ d.setDate(d.getDate() - (n - 1));
265
+ for (let i = 0; i < n; i++) {
266
+ out.push(byDay?.[dayKey(d.getTime())] || null);
267
+ d.setDate(d.getDate() + 1);
268
+ }
269
+ return out;
270
+ }
271
+
272
+ const dayTokens = (d) => d
273
+ ? (d.inputTokens || 0) + (d.outputTokens || 0) + (d.cacheReadTokens || 0) + (d.cacheWriteTokens || 0)
274
+ : 0;
275
+
276
+ // The `today` box, shared by `claude-rpc today` and `claude-rpc status`.
277
+ // Each headline metric carries its own context — a ▲/▼ against the trailing
278
+ // 7-day average (today excluded), a percentile callout on standout days, the
279
+ // day's cost, and a 14-day sparkline — so the numbers answer "is that a lot?"
280
+ // instead of making you remember.
281
+ function todayBoxLines(vars, aggregate) {
282
+ const byDay = aggregate?.byDay || {};
283
+ const series = lastDays(byDay, 15);
284
+ const today = series.at(-1);
285
+ const prior7 = series.slice(-8, -1);
286
+ const avg = (xs) => xs.reduce((a, b) => a + b, 0) / (xs.length || 1);
287
+
288
+ const dActive = fmtDelta(today?.activeMs || 0, avg(prior7.map((d) => d?.activeMs || 0)), { vs: 'vs 7-day avg' });
289
+ const dPrompts = fmtDelta(today?.userMessages || 0, avg(prior7.map((d) => d?.userMessages || 0)));
290
+ const dTokens = fmtDelta(dayTokens(today), avg(prior7.map(dayTokens)));
291
+ const pctLabel = topPercentile(Object.values(byDay).map((d) => d?.activeMs || 0), today?.activeMs || 0);
292
+
293
+ const lines = [
294
+ pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}${dActive ? ` ${dActive}` : ''}${pctLabel ? ` ${c.magenta}· ${pctLabel}${c.reset}` : ''}`, ''),
295
+ pair('prompts', `${c.yellow}${vars.todayPrompts}${c.reset}${dPrompts ? ` ${dPrompts}` : ''}`, ''),
296
+ pair('tool calls', vars.todayToolsFmt, c.yellow),
297
+ pair('sessions', String(vars.todaySessions || 0)),
298
+ pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dTokens ? ` ${dTokens}` : ''}`, ''),
299
+ pair(' in+out', vars.todayTokensRealFmt, c.gray),
300
+ pair(' cache', vars.todayCacheTokensFmt, c.gray),
301
+ ];
302
+ if (today?.cost) {
303
+ lines.push(pair('cost', `${c.green}≈${fmtCost(today.cost)}${c.reset} ${c.dim}+${(today.linesAdded || 0).toLocaleString()} lines today${c.reset}`, ''));
304
+ }
305
+ const spark = sparkline(series.slice(1).map((d) => d?.activeMs || 0));
306
+ if (spark) {
307
+ lines.push('');
308
+ lines.push(pair('last 14d', `${spark} ${c.dim}← today${c.reset}`, ''));
309
+ }
310
+ return lines;
252
311
  }
253
312
 
254
313
  function showStatus() {
@@ -295,15 +354,7 @@ function showStatus() {
295
354
  }
296
355
 
297
356
  if (aggregate) {
298
- box('today', [
299
- pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
300
- pair('prompts', String(vars.todayPrompts), c.yellow),
301
- pair('tool calls', vars.todayToolsFmt, c.yellow),
302
- pair('sessions', String(vars.todaySessions || 0)),
303
- pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
304
- pair(' in+out', vars.todayTokensRealFmt, c.gray),
305
- pair(' cache', vars.todayCacheTokensFmt, c.gray),
306
- ]);
357
+ box('today', todayBoxLines(vars, aggregate));
307
358
  console.log('');
308
359
 
309
360
  box('streak', [
@@ -446,17 +497,15 @@ function showToday() {
446
497
  console.log(` ${c.bold}${c.magenta}◆ Today${c.reset} ${c.dim}— ${new Date().toLocaleDateString()}${c.reset}`);
447
498
  console.log('');
448
499
 
449
- box('today', [
450
- pair('active', `${c.bold}${c.green}${vars.todayHours}${c.reset}`, ''),
451
- pair('prompts', String(vars.todayPrompts), c.yellow),
452
- pair('tool calls', vars.todayToolsFmt, c.yellow),
453
- pair('sessions', String(vars.todaySessions || 0)),
454
- pair('tokens', `${c.bold}${vars.todayTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
455
- pair(' in+out', vars.todayTokensRealFmt, c.gray),
456
- pair(' cache', vars.todayCacheTokensFmt, c.gray),
457
- ]);
500
+ box('today', todayBoxLines(vars, aggregate));
458
501
  console.log('');
459
502
 
503
+ // One quiet celebration line on milestone days (token round numbers
504
+ // crossed today, day-N anniversaries). The share nudge below owns streak
505
+ // records and round session/hour counts — no overlap.
506
+ const milestone = pickTodayMilestone(aggregate, dayTokens(lastDays(aggregate?.byDay, 1)[0]));
507
+ if (milestone) console.log(` ${c.magenta}✶${c.reset} ${c.bold}${milestone}${c.reset}\n`);
508
+
460
509
  if (aggregate?.byHour && Object.keys(aggregate.byHour).length) {
461
510
  box('when you code · hour of day', renderHourHistogram(aggregate.byHour, { peakHour: vars.peakHourNum }), 40);
462
511
  console.log('');
@@ -481,12 +530,23 @@ function showWeek() {
481
530
  console.log(` ${c.bold}${c.magenta}◆ This week${c.reset} ${c.dim}— ${weekKey(Date.now())}${c.reset}`);
482
531
  console.log('');
483
532
 
533
+ // Week-over-week context: this week so far vs the whole of last week.
534
+ // Early in the week "▼" is expected — the vs label keeps that honest.
535
+ const prevRef = new Date();
536
+ prevRef.setHours(12, 0, 0, 0); // noon — immune to DST midnight edges
537
+ prevRef.setDate(prevRef.getDate() - 7);
538
+ const wkNow = aggregate?.byWeek?.[weekKey(Date.now())];
539
+ const wkPrev = aggregate?.byWeek?.[weekKey(prevRef.getTime())];
540
+ const dWkActive = fmtDelta(wkNow?.activeMs || 0, wkPrev?.activeMs || 0, { vs: 'vs last week' });
541
+ const dWkPrompts = fmtDelta(wkNow?.userMessages || 0, wkPrev?.userMessages || 0);
542
+ const dWkTokens = fmtDelta(dayTokens(wkNow), dayTokens(wkPrev));
543
+
484
544
  box('this week', [
485
- pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}`, ''),
486
- pair('prompts', String(vars.weekPrompts), c.yellow),
545
+ pair('active', `${c.bold}${c.green}${vars.weekHours}${c.reset}${dWkActive ? ` ${dWkActive}` : ''}`, ''),
546
+ pair('prompts', `${c.yellow}${vars.weekPrompts}${c.reset}${dWkPrompts ? ` ${dWkPrompts}` : ''}`, ''),
487
547
  pair('tool calls', vars.weekToolsFmt, c.yellow),
488
548
  pair('sessions', String(vars.weekSessions || 0)),
489
- pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}`, ''),
549
+ pair('tokens', `${c.bold}${vars.weekTokensFmt}${c.reset} ${c.dim}grand total${c.reset}${dWkTokens ? ` ${dWkTokens}` : ''}`, ''),
490
550
  ]);
491
551
  console.log('');
492
552
 
@@ -513,7 +573,8 @@ function showWeek() {
513
573
  const h = ms / 3_600_000;
514
574
  const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
515
575
  const prefix = isToday ? `${c.bold}` : '';
516
- return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
576
+ const peak = ms === maxMs && ms > 0 ? ` ${c.bold}${heat(1)}◆${c.reset}` : '';
577
+ return `${prefix}${label.padEnd(12)}${c.reset} ${bar(ms, maxMs)} ${c.cyan}${hStr.padStart(5)}${c.reset}${peak}${isToday ? ` ${c.dim}← today${c.reset}` : ''}`;
517
578
  });
518
579
  box('this week · daily breakdown', lines);
519
580
  console.log('');
@@ -1137,22 +1198,49 @@ async function doSquadCmd(argv) {
1137
1198
  });
1138
1199
  }
1139
1200
 
1140
- // ── Link (CLI web pairing) ─────────────────────────────────────────────
1201
+ // ── Link (one profile across machines) ───────────────────────────────────
1141
1202
  //
1142
- // `claude-rpc link <code>`the code comes from claude-rpc.vercel.app/squads
1143
- // while logged in with GitHub. Claims it against this install's instanceId,
1144
- // which verifies the profile (✓) and unlocks managing squads from the
1145
- // browser. Replaces the gist dance for anyone who uses the website.
1146
-
1147
- async function doLink(argv) {
1148
- const code = (argv[0] || '').trim();
1149
- if (!code) {
1150
- fail('usage: claude-rpc link <code>', {
1151
- hint: 'log in at https://claude-rpc.vercel.app/squads — it shows you the code',
1152
- code: EX_USER_ERROR,
1203
+ // Two-sided by designone verb owns the whole story:
1204
+ // claude-rpc link on your MAIN (verified) machine mints a
1205
+ // one-time code, no browser needed
1206
+ // claude-rpc link <code> on the NEW machine claims it, merging this
1207
+ // install into the same leaderboard identity
1208
+ // The browser fallback lives at claude-rpc.vercel.app/link (log in with
1209
+ // GitHub → same code) for when the other machine isn't handy. Claiming
1210
+ // verifies the profile () and unlocks managing squads from the browser.
1211
+
1212
+ const LINK_PAGE = 'https://claude-rpc.vercel.app/link';
1213
+
1214
+ // Mint side: this machine asks the worker for a code. The worker only obliges
1215
+ // when this install's canonical profile is verified — the ✓ a claim grants
1216
+ // has to root in an already-proven identity.
1217
+ async function linkMint(ctx) {
1218
+ const r = await ctx.post('/pair/start', {});
1219
+ if (r.status === 403) {
1220
+ return fail('link codes come from a verified machine — this one isn\'t yet', {
1221
+ hint: `verify here first (claude-rpc profile verify), or mint in the browser: ${LINK_PAGE}`,
1222
+ code: EX_BAD_STATE,
1153
1223
  });
1154
1224
  }
1225
+ if (r.status !== 200 || !r.json?.code) {
1226
+ return fail(`could not mint a link code: ${r.json?.error || r.status}`, { code: EX_SYS_ERROR });
1227
+ }
1228
+ const mins = Math.round((r.json.expiresInSec || 600) / 60);
1229
+ console.log('');
1230
+ console.log(` ${c.green}✓${c.reset} link code: ${c.cyan}${c.bold}${r.json.code}${c.reset} ${c.dim}(expires in ${mins} min)${c.reset}`);
1231
+ console.log('');
1232
+ console.log(` ${c.dim}on the new machine:${c.reset}`);
1233
+ console.log(` npx claude-rpc@latest setup`);
1234
+ console.log(` ${c.cyan}claude-rpc link ${r.json.code}${c.reset}`);
1235
+ console.log('');
1236
+ console.log(` ${c.dim}one leaderboard profile — stats from every linked machine count as one${c.reset}`);
1237
+ console.log('');
1238
+ }
1239
+
1240
+ async function doLink(argv) {
1155
1241
  const ctx = squadAuth();
1242
+ const code = (argv[0] || '').trim();
1243
+ if (!code) return linkMint(ctx);
1156
1244
  // Make sure the profile row exists server-side before claiming — same
1157
1245
  // pre-publish profileVerify does, so link works on a fresh `profile on`.
1158
1246
  if (lb.profileIsPublishable(ctx.cfg.profile || {})) {
@@ -1162,7 +1250,7 @@ async function doLink(argv) {
1162
1250
  const r = await ctx.post('/pair/claim', { code });
1163
1251
  if (r.status !== 200) {
1164
1252
  return fail(`link failed: ${r.json?.error || r.status}`,
1165
- { hint: 'grab a fresh code from https://claude-rpc.vercel.app/squads and try again', code: EX_SYS_ERROR });
1253
+ { hint: `get a fresh code: run \`claude-rpc link\` on your main machine, or ${LINK_PAGE}`, code: EX_SYS_ERROR });
1166
1254
  }
1167
1255
  // Mirror the verified identity locally so `profile status` agrees.
1168
1256
  const userCfg = readJson(CONFIG_PATH, {});
@@ -1174,7 +1262,7 @@ async function doLink(argv) {
1174
1262
  // canonical handle, one board row across all your machines.
1175
1263
  console.log(` ${c.green}✓${c.reset} this machine now merges into ${c.cyan}@${r.json.handle}${c.reset} ${c.dim}— stats from all your machines count as one${c.reset}`);
1176
1264
  }
1177
- console.log(` ${c.dim}head back to https://claude-rpc.vercel.app/squads it picks the link up automatically${c.reset}`);
1265
+ console.log(` ${c.dim}started in a browser tab? it picks the link up automatically${c.reset}`);
1178
1266
  }
1179
1267
 
1180
1268
  // ── Community totals ─────────────────────────────────────────────────────
@@ -1323,7 +1411,7 @@ function profileNextStep() {
1323
1411
  if (!next) {
1324
1412
  console.log(` ${c.dim}→ all set — you're live at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/u/${encodeURIComponent(p.handle)}${c.reset}`);
1325
1413
  } else if (next.key === 'verify') {
1326
- console.log(` ${c.dim}→ next: log in at${c.reset} ${c.cyan}https://claude-rpc.vercel.app/squads${c.reset}${c.dim}, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset}`);
1414
+ console.log(` ${c.dim}→ next: run${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine that's already verified, then${c.reset} ${c.cyan}claude-rpc link <code>${c.reset} ${c.dim}here — first machine? ${LINK_PAGE}${c.reset}`);
1327
1415
  } else {
1328
1416
  console.log(` ${c.dim}→ next:${c.reset} ${c.cyan}${next.cmd}${c.reset} ${c.dim}(${next.label})${c.reset}`);
1329
1417
  }
@@ -1366,12 +1454,12 @@ function profileStatus() {
1366
1454
  : `${c.cyan}${s.cmd}${c.reset}${i === nextIdx ? ` ${c.dim}← next${c.reset}` : ''}`;
1367
1455
  return `${mark} ${i + 1}. ${label}${' '.repeat(Math.max(1, 20 - s.label.length))}${tail}`;
1368
1456
  });
1369
- // Web pairing is the primary verify path; the gist dance stays available
1457
+ // Link codes are the primary verify path; the gist dance stays available
1370
1458
  // for terminals with no browser nearby.
1371
1459
  if (!steps[2].done) {
1372
1460
  lines.push('');
1373
- lines.push(`${c.dim}the code comes from${c.reset} ${c.cyan}https://claude-rpc.vercel.app/squads${c.reset} ${c.dim}(log in with GitHub)${c.reset}`);
1374
- lines.push(`${c.dim}no browser? fall back to${c.reset} ${c.cyan}claude-rpc profile verify${c.reset}`);
1461
+ lines.push(`${c.dim}the code comes from${c.reset} ${c.cyan}claude-rpc link${c.reset} ${c.dim}on a machine you already verified${c.reset}`);
1462
+ lines.push(`${c.dim}first machine? log in at${c.reset} ${c.cyan}${LINK_PAGE}${c.reset} ${c.dim}— or no browser:${c.reset} ${c.cyan}claude-rpc profile verify${c.reset}`);
1375
1463
  }
1376
1464
  box('next steps', lines);
1377
1465
  }
@@ -1704,7 +1792,7 @@ function help() {
1704
1792
  ['community', 'Opt in/out of anonymous community totals (on|off|status|report)'],
1705
1793
  ['profile', 'Public leaderboard identity (status|set|on|off|publish|verify)'],
1706
1794
  ['squad', 'Private mini-leaderboards with friends (create|join|leave|status)'],
1707
- ['link', 'Pair this install with your web login (code from /squads page)'],
1795
+ ['link', 'Link machines into one profile (mints a code; `link <code>` claims it)'],
1708
1796
  ['doctor', 'Run a diagnostic checklist — common-failure triage (--fix to auto-repair)'],
1709
1797
  ['tail', 'Tail the daemon log file'],
1710
1798
  ['daemon', 'Run daemon in foreground (debug)'],
package/src/install.js CHANGED
@@ -570,6 +570,7 @@ export function setupOutro(target, changed = true) {
570
570
  if (IS_PACKAGED) point('start daemon', `"${target}" daemon`, 'also runs automatically at login');
571
571
  else point('manage daemon', 'claude-rpc start · stop · status');
572
572
  point('config', CONFIG_PATH, 'a working Discord app is bundled — set clientId only to use your own');
573
+ point('other machine?', 'claude-rpc link', 'run it there, claim the code here — one leaderboard profile');
573
574
  console.log('');
574
575
  }
575
576
 
package/src/nudge.js CHANGED
@@ -63,6 +63,31 @@ export function pickShareNudge(agg) {
63
63
  return out[0];
64
64
  }
65
65
 
66
+ // A quiet, local celebration line for `claude-rpc today`. Complements the
67
+ // share nudges above — which own streak records and round session/hour
68
+ // counts — without overlapping them: this detects lifetime-token round
69
+ // numbers CROSSED TODAY (no state file needed — the crossing happened today
70
+ // iff total ≥ mark and total − todayTokens < mark) and round "day N"
71
+ // anniversaries (which are only true on the day itself). Returns one string
72
+ // or null; the caller styles it.
73
+ const TOKEN_MARKS = [1e9, 5e9, 1e10, 2.5e10, 5e10, 1e11, 2.5e11, 5e11, 1e12];
74
+ const DAY_MARKS = new Set([50, 100, 200, 365, 500, 730, 1000]);
75
+ const fmtTok = (n) => n >= 1e12 ? `${n / 1e12}T` : n >= 1e9 ? `${n / 1e9}B` : `${n / 1e6}M`;
76
+
77
+ export function pickTodayMilestone(agg, todayTokens = 0) {
78
+ if (!agg || typeof agg !== 'object') return null;
79
+ const total = (agg.inputTokens || 0) + (agg.outputTokens || 0)
80
+ + (agg.cacheReadTokens || 0) + (agg.cacheWriteTokens || 0);
81
+ for (let i = TOKEN_MARKS.length - 1; i >= 0; i--) {
82
+ const mark = TOKEN_MARKS[i];
83
+ if (total >= mark && total - todayTokens < mark) {
84
+ return `crossed ${fmtTok(mark)} lifetime tokens today`;
85
+ }
86
+ }
87
+ if (DAY_MARKS.has(agg.daysSinceFirst || 0)) return `day ${agg.daysSinceFirst} with claude`;
88
+ return null;
89
+ }
90
+
66
91
  function readLastKey(path = NUDGE_STATE) {
67
92
  try { return JSON.parse(readFileSync(path, 'utf8')).key || null; }
68
93
  catch { return null; }
package/src/tui.js CHANGED
@@ -13,6 +13,7 @@ import { loadConfig } from './config.js';
13
13
  import { PID_PATH } from './paths.js';
14
14
  import { fmtCost } from './pricing.js';
15
15
  import { generateInsights } from './insights.js';
16
+ import { heat } from './ui.js';
16
17
 
17
18
  // ── ANSI ────────────────────────────────────────────────────────────────────
18
19
  const ESC = '\x1b[';
@@ -74,10 +75,11 @@ function width() { return Math.max(50, Math.min(120, process.stdout.columns ||
74
75
  function height() { return Math.max(20, process.stdout.rows || 24); }
75
76
 
76
77
  function rule(w) { return C.gray + '─'.repeat(w - 4) + C.reset; }
78
+ // Heat-graded fill (same ramp as the CLI views) — intensity reads at a glance.
77
79
  function bar(value, max, w = 16) {
78
80
  if (!max || max <= 0) return ''.padEnd(w);
79
81
  const filled = Math.max(0, Math.min(w, Math.round((value / max) * w)));
80
- return '█'.repeat(filled) + ' '.repeat(w - filled);
82
+ return `${heat(value / max) || C.magenta}${'█'.repeat(filled)}${C.reset}` + ' '.repeat(w - filled);
81
83
  }
82
84
 
83
85
  // Pad a (possibly ANSI-colored) line with spaces so its VISIBLE width hits n.
@@ -166,7 +168,7 @@ function tabToday(_, data) {
166
168
  const ms = agg.byHour?.[h]?.activeMs || 0;
167
169
  const idx = ms > 0 ? Math.max(1, Math.min(8, Math.round((ms / max) * 8))) : 0;
168
170
  const ch = heightChars[idx];
169
- bars.push(h === v.peakHourNum ? `${C.magenta}${C.bold}${ch}${C.reset}` : `${C.green}${ch}${C.reset}`);
171
+ bars.push(h === v.peakHourNum ? `${C.bold}${heat(1)}${ch}${C.reset}` : `${heat(ms / max)}${ch}${C.reset}`);
170
172
  }
171
173
  out.push(bars.join(''));
172
174
  out.push(`${C.dim}00 03 06 09 12 15 18 21${C.reset}`);
@@ -212,7 +214,8 @@ function tabWeek(_, data) {
212
214
  const h = ms / 3_600_000;
213
215
  const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
214
216
  const prefix = isToday ? C.bold : '';
215
- out.push(`${prefix}${label.padEnd(11)}${C.reset} ${C.magenta}${bar(ms, maxMs, 18)}${C.reset} ${C.cyan}${hStr.padStart(5)}${C.reset}${isToday ? ` ${C.dim}← today${C.reset}` : ''}`);
217
+ const peak = ms === maxMs && ms > 0 ? ` ${C.bold}${heat(1)}◆${C.reset}` : '';
218
+ out.push(`${prefix}${label.padEnd(11)}${C.reset} ${bar(ms, maxMs, 18)} ${C.cyan}${hStr.padStart(5)}${C.reset}${peak}${isToday ? ` ${C.dim}← today${C.reset}` : ''}`);
216
219
  }
217
220
  }
218
221
  }
@@ -267,7 +270,7 @@ function tabLifetime(_, data) {
267
270
  const h = p.activeMs / 3_600_000;
268
271
  const hStr = h < 1 ? `${Math.round(h * 60)}m` : (h < 10 ? `${h.toFixed(1)}h` : `${Math.round(h)}h`);
269
272
  const pretty = humanProject(name).slice(0, 18).padEnd(20);
270
- out.push(`${pretty} ${C.magenta}${bar(p.activeMs, maxMs, 16)}${C.reset} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
273
+ out.push(`${pretty} ${bar(p.activeMs, maxMs, 16)} ${C.cyan}${hStr.padStart(5)}${C.reset}`);
271
274
  }
272
275
  }
273
276
  return out;
@@ -291,7 +294,7 @@ function tabCost(_, data) {
291
294
  const max = byModel[0][1];
292
295
  for (const [m, val] of byModel) {
293
296
  const pretty = String(m).padEnd(20);
294
- out.push(`${pretty} ${C.magenta}${bar(val, max, 18)}${C.reset} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
297
+ out.push(`${pretty} ${bar(val, max, 18)} ${C.cyan}${fmtCost(val).padStart(8)}${C.reset}`);
295
298
  }
296
299
  }
297
300
 
@@ -330,7 +333,7 @@ function tabCode(_, data) {
330
333
  const max = langs[0][1].edits || 1;
331
334
  for (const [name, l] of langs) {
332
335
  const pretty = name.slice(0, 18).padEnd(20);
333
- out.push(`${pretty} ${C.magenta}${bar(l.edits, max, 18)}${C.reset} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
336
+ out.push(`${pretty} ${bar(l.edits, max, 18)} ${C.cyan}${String(l.edits).padStart(6)}${C.reset}`);
334
337
  }
335
338
  }
336
339
 
package/src/ui.js CHANGED
@@ -81,6 +81,68 @@ export function fail(label, { hint = '', code = EX_USER_ERROR } = {}) {
81
81
  process.exit(code);
82
82
  }
83
83
 
84
+ // ── Heat / sparkline / comparison primitives ──────────────────────────────
85
+ //
86
+ // Shared by the focused views (today / week / status / TUI). All of these
87
+ // degrade to plain glyphs when colors are off, so piped output stays clean.
88
+
89
+ // Intensity 0..1 → a 256-color ramp matching the site palette: calm green
90
+ // for light activity, amber for solid, rust for hot. `tty` is overridable so
91
+ // the ramp is unit-testable in a non-TTY test runner.
92
+ const HEAT_RAMP = [
93
+ [0.25, '\x1b[38;5;65m'], // sage — barely warm
94
+ [0.45, '\x1b[38;5;71m'], // green — steady
95
+ [0.65, '\x1b[38;5;178m'], // amber — solid
96
+ [0.85, '\x1b[38;5;208m'], // orange — heavy
97
+ [Infinity, '\x1b[38;5;166m'], // rust — peak
98
+ ];
99
+ export function heat(t, { tty = TTY } = {}) {
100
+ if (!tty) return '';
101
+ if (!(t > 0)) return c.dim;
102
+ for (const [ceil, color] of HEAT_RAMP) if (t < ceil) return color;
103
+ return HEAT_RAMP.at(-1)[1];
104
+ }
105
+
106
+ // Heat-colored sparkline of a numeric series (▁▂▃▄▅▆▇█), scaled to its own
107
+ // max. Zero/empty input → ''. With colors off this is just the glyphs.
108
+ const SPARK_CHARS = ' ▁▂▃▄▅▆▇█';
109
+ export function sparkline(values, { tty = TTY } = {}) {
110
+ const max = Math.max(0, ...values.map((v) => v || 0));
111
+ if (!(max > 0)) return '';
112
+ const out = values.map((raw) => {
113
+ const v = raw || 0;
114
+ const idx = v > 0 ? Math.max(1, Math.min(8, Math.round((v / max) * 8))) : 0;
115
+ return `${heat(v / max, { tty })}${SPARK_CHARS[idx]}`;
116
+ }).join('');
117
+ return out + (tty ? c.reset : '');
118
+ }
119
+
120
+ // "▲ +18% vs 7-day avg" — current vs a baseline, colored by direction.
121
+ // A quiet day isn't a failure, so down renders gray, not red. Returns ''
122
+ // when the baseline is too small to compare against (fresh installs).
123
+ export function fmtDelta(current, baseline, { vs = '' } = {}) {
124
+ if (!(baseline > 0)) return '';
125
+ const pct = Math.round(((current || 0) - baseline) / baseline * 100);
126
+ const tail = vs ? ` ${c.dim}${vs}${c.reset}` : '';
127
+ if (pct === 0) return `${c.dim}≈ ${vs || 'usual'}${c.reset}`;
128
+ const up = pct > 0;
129
+ const shown = Math.min(Math.abs(pct), 999); // a 40×-average day reads "+999%", not noise
130
+ return `${up ? c.green : c.gray}${up ? '▲' : '▼'} ${up ? '+' : '−'}${shown}%${c.reset}${tail}`;
131
+ }
132
+
133
+ // Percentile callout for a standout value among its history ("top 10% day").
134
+ // Quiet unless there's real history to rank against and the value is high —
135
+ // a callout on every middling day would train the eye to skip it.
136
+ export function topPercentile(values, v, { min = 14 } = {}) {
137
+ const past = values.filter((x) => x > 0);
138
+ if (past.length < min || !(v > 0)) return '';
139
+ const rank = past.filter((x) => x <= v).length / past.length;
140
+ if (rank >= 1) return 'best day yet';
141
+ if (rank >= 0.9) return 'top 10% day';
142
+ if (rank >= 0.75) return 'top 25% day';
143
+ return '';
144
+ }
145
+
84
146
  // Return the last n lines of a log file's raw text, trimming the trailing
85
147
  // empty element that split('\n') produces when the file ends with a newline.
86
148
  // When the file lacks a trailing newline the last element is the last real
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.15.3';
14
+ const BAKED = '0.15.5';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {