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 +11 -1
- package/lib/collector.js +34 -2
- package/lib/demo.js +33 -1
- package/lib/fame.js +102 -0
- package/lib/pricing.js +30 -10
- package/package.json +1 -1
- package/public/index.html +152 -2
- package/server.js +22 -1
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
|
+

|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
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="
|
|
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 =
|
|
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))
|