claude-mission-control 1.17.0 → 1.18.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.
package/README.md CHANGED
@@ -35,6 +35,16 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
35
35
 
36
36
  *Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
37
37
 
38
+ **New in 1.18.0**
39
+
40
+ - Hall of Fame (⌘K → Hall of Fame, or from Hive): your best sessions over a week, 30 days or all time, across seven awards from most commits shipped and best value per commit to the grinder and the delegator. Each award makes a share card.
41
+
42
+ ![Hall of Fame: podiums for most commits shipped, best value per commit and the grinder, each entry with its robot avatar, project, model and score, and a share card drawn for the most commits award](docs/screenshots/hall-of-fame.png)
43
+
44
+ **New in 1.17.1**
45
+
46
+ - Burn rate is now your pace over the last 30 minutes. It used to divide lifetime cost by process age, which read in the thousands per hour right after resuming an old session.
47
+
38
48
  **New in 1.17.0**
39
49
 
40
50
  - Hive view (⬢ in the header): every live session as a character card. Each gets a generated face, a personality earned from what it has done ("the delegator", "the big spender", "the new kid"), a rank by spend, and its latest tool call on a live ticker.
@@ -226,7 +236,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
226
236
  - Worktree sessions (`.claude/worktrees/…`) are folded into their parent project and badged `⎇`.
227
237
  - **Notifications** — the moment any session flips to "waiting for input", you get a macOS notification (with sound) naming the project and what it needs — the question, the command awaiting permission, or Claude's last line. Fires once per wait, never on server restart. Mute a single noisy project from its slide-over (the bell button at the top), or disable everything with `CLAUDE_DASH_NOTIFY=0` in the plist. Notifications arrive via Script Editor/osascript — if you don't see them, allow it under System Settings → Notifications.
228
238
  - **Cost estimates** — every session, project, and live row carries a `≈$` figure computed from the token counts in its transcript. On an API-key login it is labelled *estimated cost*; on a subscription it is *list-price value*, a relative weight rather than a bill (hover a cost chip for the wording). Rates are Anthropic list prices bundled with the dashboard (cached 2026-10-06). If your organization deploys Claude Code's `modelPricing` managed setting, the dashboard reads it and reports at your contracted rates; you can also put the same `modelPricing` block in `config.json` (⚙ settings → *copy skeleton*). A model the table doesn't know is priced at the Opus tier and marked with an amber `?` so a stale table is never silent. Subagent tokens are included.
229
- - **Live cost** — running sessions show their cost so far on the departures board and in Mission Control, with a `$/h` burn rate once the session is five minutes old.
239
+ - **Live cost** — running sessions show their cost so far on the departures board and in Mission Control, with a `$/h` burn rate: the pace over the last 30 minutes, shown after five minutes of watching. A resumed session's earlier history doesn't count toward it.
230
240
  - **Session cost breakdown** — the `≈$ ▾` button in a transcript's header opens a table by model (input, output, cache read, cache write), the subagent share, and the session's cache hit rate.
231
241
  - **Context and tools** — each live session shows how full its context window was on the last turn (`412k / 1M`, amber from 70%, red from 90%), so you can see a compaction or a fresh session coming. Mission Control panes add the session's tool-call count with failures in amber, and the transcript's cost panel shows the same count in its footer. Read from the same incremental transcript scan as everything else; subagents are not included.
232
242
  - **Most expensive sessions** — in the stats view: the ten costliest sessions all time, click to open.
package/lib/collector.js CHANGED
@@ -15,7 +15,7 @@ const { worktreeRoot } = require('./paths');
15
15
  const { sendNotification, newlyWaiting, overdueWaits, isProjectMuted, trackWaits } = require('./notify');
16
16
  const { readConfig, readPricing } = require('./config');
17
17
  const { hiveRoster } = require('./hive');
18
- const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
18
+ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, recordCostSample, topByCost } = require('./pricing');
19
19
  const { readPlan } = require('./plan');
20
20
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
21
21
  const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
@@ -39,6 +39,7 @@ class Collector {
39
39
  this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
40
40
  this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null, metaBySession: new Map(), teams: [] };
41
41
  this.waitingSince = new Map(); // sessionId -> when it entered 'waiting'
42
+ this.costSamples = new Map(); // sessionId -> [[t, usd], ...] for burn rate
42
43
  this.idleNotified = new Set(); // nagged once for the current wait
43
44
  this.listeners = new Set();
44
45
  this.events = []; // notification history for the catch-up bell, newest first
@@ -402,6 +403,8 @@ class Collector {
402
403
  // displayed digit would. Null until the transcript has been scanned.
403
404
  const report = costReport(usageBySession.get(s.sessionId) || {}, pricing);
404
405
  const estCost = report.usd >= 0.005 ? Math.round(report.usd * 100) / 100 : null;
406
+ const samples = this.costSamples.get(s.sessionId) || [];
407
+ this.costSamples.set(s.sessionId, recordCostSample(samples, report.usd));
405
408
  return {
406
409
  quietMin,
407
410
  waitingMin: (() => {
@@ -419,7 +422,7 @@ class Collector {
419
422
  name: s.name,
420
423
  model: modelBySession.get(s.sessionId) || null,
421
424
  estCost,
422
- burnRate: burnRate(estCost, s.startedAt),
425
+ burnRate: burnRate(samples),
423
426
  assumed: report.assumedModels.length > 0,
424
427
  context: contextBySession.get(s.sessionId) || null,
425
428
  tools: toolsBySession.get(s.sessionId) || null,
@@ -441,6 +444,8 @@ class Collector {
441
444
  resumeCommand: resumeCommand(s.cwd, s.sessionId),
442
445
  };
443
446
  });
447
+ const stillLive = new Set(liveSessions.map((s) => s.sessionId));
448
+ for (const id of this.costSamples.keys()) if (!stillLive.has(id)) this.costSamples.delete(id);
444
449
  const roster = hiveRoster(liveSessions);
445
450
  for (const s of liveSessions) s.hive = roster[s.sessionId];
446
451
 
@@ -706,6 +711,33 @@ class Collector {
706
711
  };
707
712
  }
708
713
 
714
+ // Every scanned session, summarised for the Hall of Fame.
715
+ fameSessions() {
716
+ const pricing = readPricing();
717
+ const out = [];
718
+ for (const g of this.raw.transcriptGroups.values()) {
719
+ if (isIgnored(g.path)) continue;
720
+ for (const m of g.sessions) {
721
+ const ts = toolSummary(m.tools);
722
+ out.push({
723
+ sessionId: m.sessionId,
724
+ title: this.titleOf(m).title,
725
+ projectName: friendlyName(g.path),
726
+ projectPath: g.path,
727
+ model: m.model || null,
728
+ cost: Math.round(costReport(combinedUsage(m), pricing).usd * 100) / 100,
729
+ startedAt: m.startedAt || null,
730
+ lastActivityAt: m.lastActivityAt,
731
+ tools: ts ? ts.total : 0,
732
+ errors: ts ? ts.errors : 0,
733
+ subagents: m.subagentCount || 0,
734
+ files: m.changeCount || 0,
735
+ });
736
+ }
737
+ }
738
+ return out;
739
+ }
740
+
709
741
  // Transcript file + title for one session, for the transcript viewer.
710
742
  findSessionFile(sessionId) {
711
743
  for (const g of this.raw.transcriptGroups.values()) {
package/lib/demo.js CHANGED
@@ -503,5 +503,37 @@ function demoProject() {
503
503
  };
504
504
  }
505
505
 
506
- module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject };
506
+
507
+ // Hall of Fame: the same shape lib/fame.js returns, over invented sessions.
508
+ function demoFame() {
509
+ const now = Date.now();
510
+ const e = (sessionId, title, projectName, model, label, ago) => ({ sessionId, title, projectName, model, label, lastActivityAt: now - ago });
511
+ const mig = ['demo2222-2222-4222-8222-222222222222', 'Migrate posts to the new block format', 'Blog Engine', 'claude-fable-5'];
512
+ const cart = ['demo6666-6666-4666-8666-666666666666', 'Cart totals rounding bug', 'Acme Storefront', 'claude-fable-5'];
513
+ const split = ['demo7777-7777-4777-8777-777777777777', 'Split the product page into blocks', 'Acme Storefront', 'claude-opus-5'];
514
+ const rss = ['demo8888-8888-4888-8888-888888888888', 'Fix the RSS feed date format', 'Blog Engine', 'claude-sonnet-5'];
515
+ const deps = ['demo9999-9999-4999-8999-999999999999', 'Bump dependencies and re-run the suite', 'Acme Storefront', 'claude-haiku-4-5'];
516
+ const onb = ['demo3333-3333-4333-8333-333333333333', 'Design the onboarding flow', 'Acme Storefront', 'claude-opus-5'];
517
+ return {
518
+ days: 30, sessions: 23, commits: 41,
519
+ awards: [
520
+ { key: 'shipped', title: 'Most commits shipped', blurb: 'commits made while the session was working', entries: [
521
+ e(...split, '11 commits', 26 * HOUR), e(...mig, '8 commits', MIN), e(...cart, '6 commits', 30 * HOUR)] },
522
+ { key: 'value', title: 'Best value', blurb: 'lowest cost per commit, three commits or more', entries: [
523
+ e(...deps, '$0.82 per commit', 3 * DAY), e(...cart, '$1.84 per commit', 30 * HOUR), e(...rss, '$2.23 per commit', 2 * DAY)] },
524
+ { key: 'grinder', title: 'The grinder', blurb: 'most tool calls in one session', entries: [
525
+ e(...mig, '388 tool calls', MIN), e(...split, '301 tool calls', 26 * HOUR), e(...onb, '164 tool calls', 3 * HOUR)] },
526
+ { key: 'clean', title: 'Cleanest run', blurb: 'fewest failed tool calls, fifty calls or more', entries: [
527
+ e(...mig, '388 calls, 0 failed', MIN), e(...rss, '92 calls, 0 failed', 2 * DAY), e(...split, '301 calls, 2 failed', 26 * HOUR)] },
528
+ { key: 'delegator', title: 'The delegator', blurb: 'most subagents handed work', entries: [
529
+ e(...mig, '4 subagents', MIN), e(...split, '3 subagents', 26 * HOUR)] },
530
+ { key: 'reach', title: 'Widest reach', blurb: 'most files edited', entries: [
531
+ e(...split, '27 files edited', 26 * HOUR), e(...mig, '19 files edited', MIN), e(...onb, '9 files edited', 3 * HOUR)] },
532
+ { key: 'spender', title: 'Big spender', blurb: 'highest list-price value', entries: [
533
+ e(...mig, '$61.20', MIN), e(...split, '$31.40', 26 * HOUR), e(...onb, '$18.20', 3 * HOUR)] },
534
+ ],
535
+ };
536
+ }
537
+
538
+ module.exports = { demoState, demoStats, demoSession, demoChanges, demoInventory, demoToolset, demoProject, demoFame };
507
539
 
package/lib/fame.js ADDED
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+ // Hall of Fame: your best sessions over a period, by award. Pure; the server
3
+ // feeds it session summaries plus `git log` commits per project.
4
+
5
+ const PAD = 5 * 60000; // same padded activity window as cost per commit
6
+
7
+ // sessionId -> commits made inside its window. A commit inside several
8
+ // sessions' windows goes to the one that started most recently, so a long
9
+ // resumed session doesn't claim every commit made while it was open.
10
+ function commitsBySession(sessions, commitsByProject) {
11
+ const out = new Map();
12
+ const byProject = new Map();
13
+ for (const s of sessions) {
14
+ if (!byProject.has(s.projectPath)) byProject.set(s.projectPath, []);
15
+ byProject.get(s.projectPath).push(s);
16
+ }
17
+ for (const [projectPath, list] of byProject) {
18
+ for (const c of commitsByProject[projectPath] || []) {
19
+ let best = null;
20
+ for (const s of list) {
21
+ if (!s.lastActivityAt) continue;
22
+ const start = (s.startedAt || s.lastActivityAt) - PAD;
23
+ if (c.ts < start || c.ts > s.lastActivityAt + PAD) continue;
24
+ if (!best || (s.startedAt || 0) > (best.startedAt || 0)) best = s;
25
+ }
26
+ if (best) out.set(best.sessionId, (out.get(best.sessionId) || 0) + 1);
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ const money = (n) => (n >= 100 ? `$${Math.round(n)}` : `$${n.toFixed(2)}`);
33
+ const plural = (n, one, many = one + 's') => `${n} ${n === 1 ? one : many}`;
34
+
35
+ // Each award: who qualifies, the number it ranks by, and how it reads.
36
+ const AWARDS = [
37
+ {
38
+ key: 'shipped', title: 'Most commits shipped', blurb: 'commits made while the session was working',
39
+ ok: (s) => s.commits >= 1, score: (s) => s.commits, label: (s) => plural(s.commits, 'commit'),
40
+ },
41
+ {
42
+ key: 'value', title: 'Best value', blurb: 'lowest cost per commit, three commits or more',
43
+ ok: (s) => s.commits >= 3 && s.cost >= 0.5, score: (s) => -s.cost / s.commits,
44
+ label: (s) => `${money(s.cost / s.commits)} per commit`,
45
+ },
46
+ {
47
+ key: 'grinder', title: 'The grinder', blurb: 'most tool calls in one session',
48
+ ok: (s) => s.tools >= 1, score: (s) => s.tools, label: (s) => plural(s.tools, 'tool call'),
49
+ },
50
+ {
51
+ key: 'clean', title: 'Cleanest run', blurb: 'fewest failed tool calls, fifty calls or more',
52
+ ok: (s) => s.tools >= 50, score: (s) => -(s.errors / s.tools) + s.tools / 1e7,
53
+ label: (s) => `${s.tools} calls, ${s.errors} failed`,
54
+ },
55
+ {
56
+ key: 'delegator', title: 'The delegator', blurb: 'most subagents handed work',
57
+ ok: (s) => s.subagents >= 1, score: (s) => s.subagents, label: (s) => plural(s.subagents, 'subagent'),
58
+ },
59
+ {
60
+ key: 'reach', title: 'Widest reach', blurb: 'most files edited',
61
+ ok: (s) => s.files >= 1, score: (s) => s.files, label: (s) => plural(s.files, 'file') + ' edited',
62
+ },
63
+ {
64
+ key: 'spender', title: 'Big spender', blurb: 'highest list-price value',
65
+ ok: (s) => s.cost >= 0.01, score: (s) => s.cost, label: (s) => money(s.cost),
66
+ },
67
+ ];
68
+
69
+ // sessions: [{ sessionId, title, projectName, projectPath, model, cost,
70
+ // startedAt, lastActivityAt, tools, errors, subagents, files }]
71
+ function hallOfFame(sessions, commitsByProject, { days = 30, now = Date.now(), podium = 3 } = {}) {
72
+ const cut = days ? now - days * 86400000 : 0;
73
+ const inPeriod = (sessions || []).filter((s) => (s.lastActivityAt || 0) >= cut);
74
+ const counts = commitsBySession(inPeriod, commitsByProject || {});
75
+ const rows = inPeriod.map((s) => ({ ...s, commits: counts.get(s.sessionId) || 0 }));
76
+ const awards = [];
77
+ for (const a of AWARDS) {
78
+ const ranked = rows
79
+ .filter(a.ok)
80
+ .sort((x, y) => a.score(y) - a.score(x) || (y.lastActivityAt || 0) - (x.lastActivityAt || 0))
81
+ .slice(0, podium);
82
+ if (!ranked.length) continue;
83
+ awards.push({
84
+ key: a.key,
85
+ title: a.title,
86
+ blurb: a.blurb,
87
+ entries: ranked.map((s) => ({
88
+ sessionId: s.sessionId,
89
+ title: s.title,
90
+ projectName: s.projectName,
91
+ model: s.model || null,
92
+ label: a.label(s),
93
+ lastActivityAt: s.lastActivityAt,
94
+ })),
95
+ });
96
+ }
97
+ let commits = 0;
98
+ for (const n of counts.values()) commits += n;
99
+ return { days, sessions: rows.length, commits, awards };
100
+ }
101
+
102
+ module.exports = { hallOfFame, commitsBySession, AWARDS };
package/lib/pricing.js CHANGED
@@ -165,16 +165,36 @@ function typicalWait(buckets) {
165
165
  return null;
166
166
  }
167
167
 
168
- // Whole dollars per hour for a running session, null under $1/h. Null for the
169
- // first five minutes too: one turn in a fresh session would otherwise read as
170
- // hundreds per hour. Whole dollars because a cents figure drifts with the clock
171
- // and would churn the SSE state fingerprint on every poll.
168
+ // Spend per hour at the current pace: the cost added over the last 30
169
+ // minutes of samples, not lifetime cost over process age. A resumed session
170
+ // carries its whole history in its transcript, so lifetime/age read in the
171
+ // thousands right after a resume and near zero for a long-lived process.
172
+ // Null until five minutes of samples exist, and under $1/h. Whole dollars so
173
+ // the SSE state fingerprint doesn't churn.
174
+ const BURN_WINDOW_MS = 30 * 60 * 1000;
172
175
  const BURN_MIN_MS = 5 * 60 * 1000;
173
- function burnRate(usd, startedAt, now = Date.now()) {
174
- if (!usd || !startedAt) return null;
175
- const ms = now - startedAt;
176
- if (ms < BURN_MIN_MS) return null;
177
- const perHour = Math.round(usd / (ms / 3600000));
176
+ const BURN_SAMPLE_MS = 30 * 1000;
177
+
178
+ // Appends [now, usd] to a session's samples (at most one per 30s unless the
179
+ // cost moved) and drops what has aged out of the window. A cost that went
180
+ // down means the transcript was rewritten: start over.
181
+ function recordCostSample(samples, usd, now = Date.now()) {
182
+ if (usd == null || !Number.isFinite(usd)) return samples;
183
+ const last = samples[samples.length - 1];
184
+ if (last && usd < last[1]) samples.length = 0;
185
+ const prev = samples[samples.length - 1];
186
+ if (!prev || usd !== prev[1] || now - prev[0] >= BURN_SAMPLE_MS) samples.push([now, usd]);
187
+ while (samples.length > 2 && samples[1][0] <= now - BURN_WINDOW_MS) samples.shift();
188
+ return samples;
189
+ }
190
+
191
+ function burnRate(samples, now = Date.now()) {
192
+ if (!samples || samples.length < 2) return null;
193
+ const first = samples[0];
194
+ const last = samples[samples.length - 1];
195
+ const span = last[0] - first[0];
196
+ if (span < BURN_MIN_MS || now - last[0] > BURN_WINDOW_MS) return null;
197
+ const perHour = Math.round((last[1] - first[1]) / (span / 3600000));
178
198
  return perHour >= 1 ? perHour : null;
179
199
  }
180
200
 
@@ -226,4 +246,4 @@ function planValue(plan, weekCost) {
226
246
  };
227
247
  }
228
248
 
229
- module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, cacheHitRatio, topByCost, contextWindow };
249
+ module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, recordCostSample, cacheHitRatio, topByCost, contextWindow };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.17.0",
3
+ "version": "1.18.0",
4
4
  "description": "Local dashboard for Claude Code: live sessions, transcripts, costs, git status — all your projects in one place.",
5
5
  "main": "server.js",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -716,6 +716,26 @@
716
716
  .hv-foot .v { font-size: 13px; margin-top: 2px; }
717
717
  .hv-foot .v.bad { color: var(--bad); }
718
718
  .hv-note { color: var(--faint); font-size: 11px; padding: 0 20px 24px; }
719
+ .fm-sum { color: var(--muted); font-size: 12px; margin: 4px 0 14px; }
720
+ .fm-award { margin: 0 0 18px; padding: 12px 14px; background: var(--surface); border-radius: 2px; }
721
+ .fm-head { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
722
+ .fm-title { font-family: var(--disp-font); font-size: 18px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
723
+ .fm-blurb { color: var(--faint); font-size: 11.5px; flex: 1; min-width: 0; }
724
+ .fm-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-top: 1px solid var(--line); min-width: 0; }
725
+ .fm-head + .fm-row { border-top: none; margin-top: 6px; }
726
+ .fm-row .flap { font-family: var(--disp-font); font-weight: 600; font-size: 14px; flex: none; }
727
+ .fm-row.first .flap { color: var(--warn); font-size: 18px; }
728
+ .fm-row .hv-avatar { width: 32px; height: 32px; }
729
+ .fm-row.first .hv-avatar { width: 44px; height: 44px; }
730
+ .fm-who { flex: 1; min-width: 0; }
731
+ .fm-who .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; font-size: 13px; }
732
+ .fm-row.first .fm-who .t { font-weight: 700; }
733
+ .fm-who .t:hover { color: var(--warn); }
734
+ .fm-who .p { color: var(--faint); font-size: 11px; }
735
+ .fm-val { flex: none; font-size: 12.5px; text-align: right; }
736
+ .fm-row.first .fm-val { color: var(--warn); font-weight: 700; }
737
+ .fm-share img { width: 100%; border-radius: 6px; border: 1px solid var(--line); margin-top: 10px; }
738
+ .fm-period button.on { color: var(--warn); border-color: var(--warn); }
719
739
  @keyframes hvblink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
720
740
  .hv-avatar .eyes { transform-box: fill-box; transform-origin: center; animation: hvblink 4.5s infinite; }
721
741
  @media (prefers-reduced-motion: reduce) { .hv-avatar .eyes { animation: none; } }
@@ -947,6 +967,9 @@
947
967
  #mc-grid { grid-template-columns: 1fr; padding: 12px 16px; }
948
968
  .hv-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 12px 16px 0; }
949
969
  .hv-tile:last-child:nth-child(odd) { grid-column: span 2; }
970
+ .fm-award { padding: 10px 12px; }
971
+ .fm-head .fm-title { flex: 1; }
972
+ .fm-blurb { flex: 1 1 100%; order: 3; }
950
973
  .hv-grid { grid-template-columns: 1fr; padding: 12px 16px 16px; }
951
974
  .hv-note { padding: 0 16px 24px; }
952
975
  .hv-money .big { font-size: 32px; }
@@ -1012,6 +1035,7 @@
1012
1035
  <span class="mc-title">Hive</span>
1013
1036
  <span class="mc-sub" id="hive-sub"></span>
1014
1037
  <span class="spacer"></span>
1038
+ <button class="copy" data-fame title="Your best sessions by award">Hall of Fame →</button>
1015
1039
  <button class="d-close" id="hive-close" title="Close (Esc)">✕</button>
1016
1040
  </div>
1017
1041
  <div class="hv-tiles" id="hive-tiles"></div>
@@ -1312,7 +1336,7 @@ function liveCard(s) {
1312
1336
  <div class="dep-right">
1313
1337
  ${modelChip(s.model)}
1314
1338
  ${s.estCost != null ? `<span class="dep-cost">${costChip(s.estCost, { cls: 'dep-cost-main', assumed: s.assumed })}${
1315
- s.burnRate ? `<span class="dep-burn" title="Estimated spend per hour at the current pace">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1339
+ s.burnRate ? `<span class="dep-burn" title="Spend per hour over the last 30 minutes">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1316
1340
  ${contextMeter(s.context)}
1317
1341
  ${s.waitingMin != null
1318
1342
  ? `<span class="dep-elapsed overdue" title="No reply for ${s.waitingMin} minutes — session started ${elapsed(s.startedAt)} ago">${waitLabel(s.waitingMin)}</span>`
@@ -3408,6 +3432,7 @@ async function openWeekReport() {
3408
3432
  // Always drawn in the dark departures palette so shared cards look alike.
3409
3433
  const CARD = { w: 1200, h: 630, bg: '#0d1215', flap: '#151d22', flapHi: '#1c262c', split: 'rgba(0,0,0,0.45)', ink: '#e9e4d6', muted: '#93a09e', faint: '#5d6b6b', line: '#202b31', green: '#46c073', amber: '#ffb020' };
3410
3434
  let shareCanvas = null;
3435
+ let shareName = 'card.png';
3411
3436
 
3412
3437
  async function renderShareCard() {
3413
3438
  const { w, days } = weekReport;
@@ -3485,15 +3510,139 @@ async function renderShareCard() {
3485
3510
  text('npx claude-mission-control', CARD.w - 64, 578, mono(16), CARD.muted, 'right');
3486
3511
 
3487
3512
  shareCanvas = c;
3513
+ shareName = `week-with-claude-${new Date().toISOString().slice(0, 10)}.png`;
3488
3514
  return c.toDataURL('image/png');
3489
3515
  }
3490
3516
 
3517
+ // ---------- hall of fame ----------
3518
+ const fame = { days: 30, data: null };
3519
+ const FAME_PERIODS = [[7, 'week'], [30, '30 days'], [0, 'all time']];
3520
+ const famePeriod = (d) => (d === 7 ? 'the last 7 days' : d === 30 ? 'the last 30 days' : 'all time');
3521
+
3522
+ async function openFame(days = fame.days) {
3523
+ fame.days = days;
3524
+ closeHive();
3525
+ openDrawerShell('Hall of Fame', 'your best sessions, by award');
3526
+ $('#d-actions').innerHTML = `<span class="fm-period">${FAME_PERIODS.map(([d, l]) =>
3527
+ `<button class="copy${d === days ? ' on' : ''}" data-fame-days="${d}">${l}</button>`).join(' ')}</span>`;
3528
+ try {
3529
+ const r = await fetch(`/api/fame?days=${days}`);
3530
+ if (!r.ok) throw new Error();
3531
+ fame.data = await r.json();
3532
+ } catch {
3533
+ $('#d-body').innerHTML = '<div class="d-empty">Could not load the Hall of Fame.</div>';
3534
+ return;
3535
+ }
3536
+ if (fame.days !== days || $('#d-title').textContent !== 'Hall of Fame') return;
3537
+ const f = fame.data;
3538
+ if (!f.awards.length) {
3539
+ $('#d-body').innerHTML = `<div class="d-empty">No sessions in ${famePeriod(days)} yet.</div>`;
3540
+ return;
3541
+ }
3542
+ $('#d-body').innerHTML = `<div class="fm-sum">${f.sessions} session${f.sessions === 1 ? '' : 's'} · ${f.commits} commit${f.commits === 1 ? '' : 's'} in ${famePeriod(days)}. Commits count toward the session working in that repo when they were made.</div>` +
3543
+ f.awards.map((a) => `<div class="fm-award" data-award="${esc(a.key)}">
3544
+ <div class="fm-head"><span class="fm-title">${esc(a.title)}</span><span class="fm-blurb">${esc(a.blurb)}</span>
3545
+ <button class="copy" data-fame-share="${esc(a.key)}" title="Make a share card for this award">share card</button></div>
3546
+ ${a.entries.map((e, i) => `<div class="fm-row${i === 0 ? ' first' : ''}">
3547
+ <span class="flap">#${i + 1}</span>${hvAvatar(e.sessionId, '')}
3548
+ <div class="fm-who"><div class="t" data-session="${esc(e.sessionId)}" title="${esc(e.title)}">${esc(e.title)}</div>
3549
+ <div class="p">${esc(e.projectName)}${e.model ? ` · ${esc(modelShort(e.model))}` : ''} · ${esc(relTime(e.lastActivityAt))}</div></div>
3550
+ <span class="fm-val">${esc(e.label)}</span></div>`).join('')}
3551
+ <div class="fm-share"></div>
3552
+ </div>`).join('');
3553
+ }
3554
+
3555
+ // Titles are often a raw first prompt; keep home paths off a shareable image.
3556
+ function cardSafe(s) {
3557
+ return String(s || '').replace(/\/(Users|home)\/[^/\s]+/g, '~').replace(/[A-Z]:\\Users\\[^\\\s]+/gi, '~');
3558
+ }
3559
+
3560
+ function avatarImage(id) {
3561
+ const svg = hvAvatar(id, '')
3562
+ .replace('class="hv-avatar" ', 'xmlns="http://www.w3.org/2000/svg" width="240" height="240" ')
3563
+ .replaceAll('var(--bg)', CARD.bg).replaceAll('var(--ink)', CARD.ink).replaceAll('var(--warn)', CARD.amber);
3564
+ return new Promise((resolve) => {
3565
+ const img = new Image();
3566
+ img.onload = () => resolve(img);
3567
+ img.onerror = () => resolve(null);
3568
+ img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg);
3569
+ });
3570
+ }
3571
+
3572
+ async function renderFameCard(award) {
3573
+ await Promise.all(['700 140px Oswald', '500 20px "JetBrains Mono"'].map((f) => document.fonts.load(f).catch(() => {})));
3574
+ const c = document.createElement('canvas');
3575
+ c.width = CARD.w * 2;
3576
+ c.height = CARD.h * 2;
3577
+ const g = c.getContext('2d');
3578
+ g.scale(2, 2);
3579
+ const oswald = (px, wt = 600) => `${wt} ${px}px Oswald, "Arial Narrow", sans-serif`;
3580
+ const mono = (px, wt = 500) => `${wt} ${px}px "JetBrains Mono", ui-monospace, monospace`;
3581
+ const text = (str, x, y, font, color, align = 'left', spacing = 0) => {
3582
+ g.font = font; g.fillStyle = color; g.textAlign = align;
3583
+ if ('letterSpacing' in g) g.letterSpacing = `${spacing}px`;
3584
+ g.fillText(str, x, y);
3585
+ if ('letterSpacing' in g) g.letterSpacing = '0px';
3586
+ };
3587
+ const fit = (str, max, font) => {
3588
+ g.font = font;
3589
+ let t = str;
3590
+ while (t.length > 1 && g.measureText(t).width > max) t = t.slice(0, -2) + '…';
3591
+ return t;
3592
+ };
3593
+ const win = award.entries[0];
3594
+
3595
+ g.fillStyle = CARD.bg; g.fillRect(0, 0, CARD.w, CARD.h);
3596
+ text('❯ HALL OF FAME', 64, 92, oswald(30), CARD.amber, 'left', 2);
3597
+ text(famePeriod(fame.days).toUpperCase(), CARD.w - 64, 90, mono(18), CARD.muted, 'right', 2);
3598
+ g.fillStyle = CARD.line; g.fillRect(64, 116, CARD.w - 128, 2);
3599
+
3600
+ text(award.title.toUpperCase(), 58, 222, oswald(84, 700), CARD.ink, 'left', 1);
3601
+ text(award.blurb, 64, 266, mono(20), CARD.muted);
3602
+
3603
+ const av = await avatarImage(win.sessionId);
3604
+ g.fillStyle = CARD.flap; g.beginPath(); g.roundRect(64, 300, CARD.w - 128, 170, 8); g.fill();
3605
+ if (av) g.drawImage(av, 88, 318, 134, 134);
3606
+ text(fit(cardSafe(win.title), CARD.w - 128 - 200 - 260, mono(26, 700)), 246, 362, mono(26, 700), CARD.ink);
3607
+ text(fit(`${win.projectName}${win.model ? ' · ' + modelShort(win.model) : ''}`, 560, mono(18)), 246, 398, mono(18), CARD.muted);
3608
+ text(win.label, CARD.w - 92, 400, oswald(50, 600), CARD.amber, 'right');
3609
+ text('#1', CARD.w - 92, 346, oswald(26, 600), CARD.muted, 'right');
3610
+
3611
+ award.entries.slice(1).forEach((e, i) => {
3612
+ const y = 516 + i * 34;
3613
+ text(`#${i + 2}`, 64, y, oswald(22, 600), CARD.faint);
3614
+ text(fit(`${cardSafe(e.title)} · ${e.projectName}`, 760, mono(17)), 110, y, mono(17), CARD.muted);
3615
+ text(e.label, CARD.w - 64, y, mono(17), CARD.muted, 'right');
3616
+ });
3617
+
3618
+ text('npx claude-mission-control', CARD.w - 64, 606, mono(16), CARD.muted, 'right');
3619
+ shareCanvas = c;
3620
+ shareName = `hall-of-fame-${award.key}.png`;
3621
+ return c.toDataURL('image/png');
3622
+ }
3623
+
3624
+ document.addEventListener('click', async (e) => {
3625
+ if (e.target.closest('[data-fame]')) { openFame(); return; }
3626
+ const p = e.target.closest('[data-fame-days]');
3627
+ if (p) { openFame(Number(p.dataset.fameDays)); return; }
3628
+ const sb = e.target.closest('[data-fame-share]');
3629
+ if (!sb || !fame.data) return;
3630
+ const award = fame.data.awards.find((a) => a.key === sb.dataset.fameShare);
3631
+ if (!award) return;
3632
+ for (const el of document.querySelectorAll('.fm-share')) el.innerHTML = '';
3633
+ const box = sb.closest('.fm-award').querySelector('.fm-share');
3634
+ box.innerHTML = '<div class="d-empty">Drawing…</div>';
3635
+ const url = await renderFameCard(award);
3636
+ box.innerHTML = `<img alt="${esc(award.title)} share card" src="${url}">
3637
+ <div style="margin-top:10px;display:flex;gap:8px"><button class="copy" data-card-download>download PNG ⇩</button><button class="copy" data-card-copy>copy image</button></div>`;
3638
+ });
3639
+
3491
3640
  document.addEventListener('click', async (e) => {
3492
3641
  if (!shareCanvas) return;
3493
3642
  if (e.target.closest('[data-card-download]')) {
3494
3643
  const a = document.createElement('a');
3495
3644
  a.href = shareCanvas.toDataURL('image/png');
3496
- a.download = `week-with-claude-${new Date().toISOString().slice(0, 10)}.png`;
3645
+ a.download = shareName;
3497
3646
  a.click();
3498
3647
  toast('Card downloaded');
3499
3648
  } else if (e.target.closest('[data-card-copy]')) {
@@ -3560,6 +3709,7 @@ function paletteItems(q) {
3560
3709
  items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
3561
3710
  items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
3562
3711
  items.push({ k: '⬢', label: 'Hive', hint: 'live sessions as characters · who is winning', run: openHive });
3712
+ items.push({ k: '★', label: 'Hall of Fame', hint: 'your best sessions by award · share cards', run: () => openFame() });
3563
3713
  items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
3564
3714
  items.push({ k: '⌗', label: 'Plugins & MCP', hint: 'what is installed, enabled, needs auth', run: openInventory });
3565
3715
  items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });
package/server.js CHANGED
@@ -16,9 +16,11 @@ const { friendlyName } = require('./lib/names');
16
16
  const cfg = require('./lib/config');
17
17
  const { isProjectMuted } = require('./lib/notify');
18
18
  const { recentCommits, linkCommitsToSessions, commitCosts } = require('./lib/gitlog');
19
+ const { hallOfFame } = require('./lib/fame');
19
20
  const { createRemoteServer } = require('./lib/remote');
20
21
  const { sessionChangeList, sessionFileDiff } = require('./lib/changes');
21
- const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject } = require('./lib/demo');
22
+ const { demoState, demoStats, demoSession, demoChanges, demoInventory, demoProject, demoFame } = require('./lib/demo');
23
+ const fameCache = new Map(); // days -> { at, data }; git log per project is too slow to run per click
22
24
  const { runSelfUpdate } = require('./lib/update');
23
25
  const { readInventory } = require('./lib/inventory');
24
26
  const { readToolset } = require('./lib/toolset');
@@ -123,6 +125,7 @@ const server = http.createServer((req, res) => {
123
125
  if (url === '/api/state') return json(res, 200, demoState());
124
126
  if (url === '/api/health') return json(res, 200, { ok: true, demo: true, version: VERSION });
125
127
  if (url === '/api/stats') return json(res, 200, demoStats());
128
+ if (url === '/api/fame') return json(res, 200, demoFame());
126
129
  if (url === '/api/inventory') return json(res, 200, demoInventory());
127
130
  if (url === '/api/session') {
128
131
  const q = new URL(req.url, 'http://localhost').searchParams;
@@ -273,6 +276,24 @@ const server = http.createServer((req, res) => {
273
276
  return;
274
277
  }
275
278
 
279
+ if (url === '/api/fame') {
280
+ const raw = Number(new URL(req.url, 'http://localhost').searchParams.get('days'));
281
+ const days = [7, 30, 0].includes(raw) ? raw : 30;
282
+ const hit = fameCache.get(days);
283
+ if (hit && Date.now() - hit.at < 60000) return json(res, 200, hit.data);
284
+ const sessions = collector.fameSessions();
285
+ const cut = days ? Date.now() - days * 86400000 : 0;
286
+ const paths = [...new Set(sessions.filter((s) => s.lastActivityAt >= cut).map((s) => s.projectPath))];
287
+ Promise.all(paths.map((p) => recentCommits(p, 1000).then((c) => [p, c])))
288
+ .then((pairs) => {
289
+ const data = hallOfFame(sessions, Object.fromEntries(pairs), { days });
290
+ fameCache.set(days, { at: Date.now(), data });
291
+ json(res, 200, data);
292
+ })
293
+ .catch((e) => json(res, 500, { error: String(e.message).slice(0, 200) }));
294
+ return;
295
+ }
296
+
276
297
  if (url === '/api/inventory') {
277
298
  readInventory(collector.projectPaths())
278
299
  .then((inv) => json(res, 200, inv))