claude-mission-control 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.17.1**
39
+
40
+ - 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.
41
+
42
+ **New in 1.17.0**
43
+
44
+ - 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.
45
+
46
+ ![Hive view: three live sessions as character cards with robot avatars, personalities, rank by spend with the gap to the leader, each session's last tool call, context and share-of-spend bars, and a strip of live totals with the 5-hour limit forecast](docs/screenshots/hive.png)
47
+
38
48
  **New in 1.16.0**
39
49
 
40
50
  - The dashboard fits a phone screen: departures rows stack, slide-overs go full width, and nothing scrolls sideways. The live demo now shows phone visitors the real phone layout.
@@ -220,7 +230,7 @@ If something looks off, the log is at `~/Library/Logs/claude-dashboard.log`. To
220
230
  - Worktree sessions (`.claude/worktrees/…`) are folded into their parent project and badged `⎇`.
221
231
  - **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.
222
232
  - **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.
223
- - **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.
233
+ - **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.
224
234
  - **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.
225
235
  - **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.
226
236
  - **Most expensive sessions** — in the stats view: the ten costliest sessions all time, click to open.
package/lib/collector.js CHANGED
@@ -14,7 +14,8 @@ const { isIgnored } = require('./ignore');
14
14
  const { worktreeRoot } = require('./paths');
15
15
  const { sendNotification, newlyWaiting, overdueWaits, isProjectMuted, trackWaits } = require('./notify');
16
16
  const { readConfig, readPricing } = require('./config');
17
- const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
17
+ const { hiveRoster } = require('./hive');
18
+ const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, recordCostSample, topByCost } = require('./pricing');
18
19
  const { readPlan } = require('./plan');
19
20
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
20
21
  const { WINDOWS, recordSample, forecastWindow, shouldAlert, clockLabel, durationLabel } = require('./forecast');
@@ -38,6 +39,7 @@ class Collector {
38
39
  this.state = { generatedAt: 0, quota: null, liveSessions: [], projects: [], errors: [] };
39
40
  this.raw = { live: [], transcriptGroups: new Map(), history: new Map(), git: new Map(), quota: null, metaBySession: new Map(), teams: [] };
40
41
  this.waitingSince = new Map(); // sessionId -> when it entered 'waiting'
42
+ this.costSamples = new Map(); // sessionId -> [[t, usd], ...] for burn rate
41
43
  this.idleNotified = new Set(); // nagged once for the current wait
42
44
  this.listeners = new Set();
43
45
  this.events = []; // notification history for the catch-up bell, newest first
@@ -344,6 +346,7 @@ class Collector {
344
346
  const usageBySession = new Map();
345
347
  const contextBySession = new Map();
346
348
  const toolsBySession = new Map();
349
+ const lastToolBySession = new Map();
347
350
  for (const g of this.raw.transcriptGroups.values()) {
348
351
  for (const m of g.sessions) {
349
352
  titleBySession.set(m.sessionId, sessionTitle(m, sessionNames).title);
@@ -352,6 +355,7 @@ class Collector {
352
355
  contextBySession.set(m.sessionId, contextSummary(m.context));
353
356
  const ts = toolSummary(m.tools);
354
357
  toolsBySession.set(m.sessionId, ts ? { total: ts.total, errors: ts.errors } : null);
358
+ if (m.lastTool) lastToolBySession.set(m.sessionId, m.lastTool);
355
359
  const subs = subagentSummary(m);
356
360
  if (subs) subsBySession.set(m.sessionId, subs);
357
361
  }
@@ -399,6 +403,8 @@ class Collector {
399
403
  // displayed digit would. Null until the transcript has been scanned.
400
404
  const report = costReport(usageBySession.get(s.sessionId) || {}, pricing);
401
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));
402
408
  return {
403
409
  quietMin,
404
410
  waitingMin: (() => {
@@ -416,10 +422,11 @@ class Collector {
416
422
  name: s.name,
417
423
  model: modelBySession.get(s.sessionId) || null,
418
424
  estCost,
419
- burnRate: burnRate(estCost, s.startedAt),
425
+ burnRate: burnRate(samples),
420
426
  assumed: report.assumedModels.length > 0,
421
427
  context: contextBySession.get(s.sessionId) || null,
422
428
  tools: toolsBySession.get(s.sessionId) || null,
429
+ lastAction: lastToolBySession.get(s.sessionId) || null,
423
430
  // Background sessions go by the name Agent View shows.
424
431
  title: (job && job.name) || titleBySession.get(s.sessionId) || s.name,
425
432
  background: s.kind === 'bg' || job
@@ -437,6 +444,10 @@ class Collector {
437
444
  resumeCommand: resumeCommand(s.cwd, s.sessionId),
438
445
  };
439
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);
449
+ const roster = hiveRoster(liveSessions);
450
+ for (const s of liveSessions) s.hive = roster[s.sessionId];
440
451
 
441
452
  // Pinned sessions, newest first, resolved to display data.
442
453
  const pinnedIds = new Set(readConfig().pinnedSessions || []);
package/lib/demo.js CHANGED
@@ -37,6 +37,13 @@ function sess(now, o) {
37
37
  };
38
38
  }
39
39
 
40
+ // One of `steps` every 7 seconds, so the Hive ticker moves in the demo.
41
+ function tick(now, steps) {
42
+ const n = Math.floor(now / 7000);
43
+ const [verb, target, tool] = steps[n % steps.length];
44
+ return { verb, target, tool, at: n * 7000 };
45
+ }
46
+
40
47
  function demoState() {
41
48
  seq = 0;
42
49
  const now = Date.now();
@@ -79,8 +86,10 @@ function demoState() {
79
86
  {
80
87
  sessionId: S1, pid: 1111, cwd: '/demo/acme-storefront', projectPath: '/demo/acme-storefront',
81
88
  projectName: 'Acme Storefront', isWorktree: false, model: 'claude-opus-5',
82
- estCost: 24.6, burnRate: 11, assumed: false, context: { tokens: 412000, window: 1000000, pct: 41 }, tools: { total: 142, errors: 3 },
89
+ estCost: 24.6, burnRate: 11, assumed: false, context: { tokens: 412000, window: 1000000, pct: 41 }, tools: { total: 142, errors: 16 },
83
90
  title: 'Wire Stripe checkout into the cart', status: 'waiting',
91
+ lastAction: { verb: 'asking you a question', target: '', tool: 'AskUserQuestion', at: now - 74 * MIN },
92
+ hive: { label: 'the trier', why: '16 failed tool calls and still going', rank: 2, behind: 36.6 },
84
93
  waitingFor: null,
85
94
  waitingReason: { kind: 'question', text: 'Which payment provider should checkout use for the wallet flow?', options: ['Payment Request Button', 'Native per wallet'] },
86
95
  startedAt: now - 2 * HOUR - 10 * MIN, statusUpdatedAt: now - 74 * MIN, quietMin: null, waitingMin: 74,
@@ -92,6 +101,11 @@ function demoState() {
92
101
  projectName: 'Blog Engine', isWorktree: false, model: 'claude-fable-5',
93
102
  estCost: 61.2, burnRate: 19, assumed: false, context: { tokens: 731000, window: 1000000, pct: 73 }, tools: { total: 388, errors: 0 },
94
103
  title: 'Migrate posts to the new block format', status: 'busy',
104
+ lastAction: tick(now, [
105
+ ['editing', 'convert-shortcodes.php', 'Edit'], ['running', 'Run the block validation suite', 'Bash'],
106
+ ['reading', 'post-1042.html', 'Read'], ['searching for', '\\[gallery', 'Grep'], ['briefing an agent', 'Review converted posts 41 to 60', 'Agent'],
107
+ ]),
108
+ hive: { label: 'the delegator', why: 'handed work to 4 subagents', rank: 1, behind: null },
95
109
  background: { jobId: 'b7e2c901', detail: 'converting shortcodes post by post; 41 of 120 done', state: 'working' },
96
110
  waitingFor: null, waitingReason: null, startedAt: now - 3 * HOUR - 12 * MIN, statusUpdatedAt: now - MIN, quietMin: null, waitingMin: null,
97
111
  currentTask: { activeForm: 'Converting legacy shortcodes' },
@@ -108,9 +122,11 @@ function demoState() {
108
122
  projectName: 'Data Pipeline', isWorktree: false, model: 'claude-sonnet-5',
109
123
  estCost: 3.4, burnRate: 9, assumed: false, context: { tokens: 186000, window: 1000000, pct: 19 }, tools: { total: 37, errors: 1 },
110
124
  title: 'Backfill the analytics warehouse', status: 'waiting',
125
+ lastAction: { verb: 'running', target: 'node scripts/backfill.mjs --apply', tool: 'Bash', at: now - 2 * MIN },
126
+ hive: { label: 'the new kid', why: 'started in the last 15 minutes', rank: 3, behind: 21.2 },
111
127
  waitingFor: null,
112
128
  waitingReason: { kind: 'permission', text: 'Bash node scripts/backfill.mjs --apply --from 2026-01-01' },
113
- startedAt: now - 22 * MIN, statusUpdatedAt: now - 2 * MIN, quietMin: null, waitingMin: null,
129
+ startedAt: now - 12 * MIN, statusUpdatedAt: now - 2 * MIN, quietMin: null, waitingMin: null,
114
130
  currentTask: { activeForm: 'Applying the backfill' }, tasksSummary: { completed: 4, inProgress: 1, pending: 4 },
115
131
  subagents: null, resumeCommand: 'claude --resume demo',
116
132
  agents: null,
package/lib/hive.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+ // Hive view: what each live session is doing right now, and a personality
3
+ // earned from how it has behaved so far. Pure functions; the collector
4
+ // attaches the results to liveSessions.
5
+ const path = require('path');
6
+
7
+ const TARGET_MAX = 48;
8
+
9
+ function clip(s, n = TARGET_MAX) {
10
+ const t = String(s || '').replace(/\s+/g, ' ').trim();
11
+ return t.length > n ? t.slice(0, n - 1) + '…' : t;
12
+ }
13
+
14
+ function base(p) {
15
+ return typeof p === 'string' && p ? path.basename(p) : '';
16
+ }
17
+
18
+ function host(u) {
19
+ try {
20
+ return new URL(u).hostname.replace(/^www\./, '');
21
+ } catch {
22
+ return '';
23
+ }
24
+ }
25
+
26
+ // A tool_use block reduced to "verb target", e.g. editing / server.js.
27
+ // Never carries a full path: the card only needs the file name.
28
+ function actionOf(name, input) {
29
+ const i = input && typeof input === 'object' ? input : {};
30
+ const n = String(name || '');
31
+ switch (n) {
32
+ case 'Edit':
33
+ case 'MultiEdit':
34
+ case 'NotebookEdit':
35
+ return { verb: 'editing', target: clip(base(i.file_path || i.notebook_path)) };
36
+ case 'Write':
37
+ return { verb: 'writing', target: clip(base(i.file_path)) };
38
+ case 'Read':
39
+ return { verb: 'reading', target: clip(base(i.file_path)) };
40
+ case 'Bash':
41
+ return { verb: 'running', target: clip(i.description || i.command) };
42
+ case 'Grep':
43
+ return { verb: 'searching for', target: clip(i.pattern) };
44
+ case 'Glob':
45
+ return { verb: 'looking for', target: clip(i.pattern) };
46
+ case 'WebFetch':
47
+ return { verb: 'reading', target: clip(host(i.url)) };
48
+ case 'WebSearch':
49
+ return { verb: 'searching the web for', target: clip(i.query) };
50
+ case 'Task':
51
+ case 'Agent':
52
+ return { verb: 'briefing an agent', target: clip(i.description) };
53
+ case 'TodoWrite':
54
+ return { verb: 'updating the plan', target: '' };
55
+ case 'AskUserQuestion':
56
+ return { verb: 'asking you a question', target: '' };
57
+ case 'Skill':
58
+ return { verb: 'using skill', target: clip(i.skill) };
59
+ }
60
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(n);
61
+ if (mcp) return { verb: 'using', target: clip(mcp[1].replace(/^plugin_|^claude_ai_/, '').replace(/_/g, ' ')) };
62
+ return { verb: 'using', target: clip(n) };
63
+ }
64
+
65
+ // Ordered: the first rule a session matches is its personality, unless a
66
+ // higher-spending session already took that one, then the next match.
67
+ const RULES = [
68
+ { label: 'the overstuffed', test: (s) => s.context && s.context.pct >= 85, why: (s) => `context ${s.context.pct}% full` },
69
+ { label: 'the delegator', test: (s) => s.subagents && s.subagents.count >= 3, why: (s) => `handed work to ${s.subagents.count} subagents` },
70
+ { label: 'the trier', test: (s) => s.tools && s.tools.errors >= 5 && s.tools.errors / s.tools.total >= 0.1, why: (s) => `${s.tools.errors} failed tool calls and still going` },
71
+ { label: 'the big spender', test: (s) => s.burnRate >= 15, why: (s) => `burning $${s.burnRate} an hour` },
72
+ { label: 'the night shift', test: (s) => !!s.background, why: () => 'works in the background' },
73
+ { label: 'the grinder', test: (s) => s.tools && s.tools.total >= 250, why: (s) => `${s.tools.total} tool calls so far` },
74
+ { label: 'the new kid', test: (s, now) => s.startedAt && now - s.startedAt < 15 * 60000, why: () => 'started in the last 15 minutes' },
75
+ { label: 'the frugal one', test: (s) => s.burnRate != null && s.burnRate < 3 && s.tools && s.tools.total >= 20, why: (s) => `only $${s.burnRate} an hour` },
76
+ { label: 'the steady one', test: () => true, why: () => 'nothing unusual, just working' },
77
+ ];
78
+
79
+ // Returns sessionId -> { label, why, rank, behind }. Rank is by spend;
80
+ // `behind` is the dollar gap to the session one place above.
81
+ function hiveRoster(sessions, now = Date.now()) {
82
+ const list = (sessions || []).slice().sort((a, b) => (b.estCost || 0) - (a.estCost || 0));
83
+ const taken = new Set();
84
+ const out = {};
85
+ list.forEach((s, idx) => {
86
+ const matches = RULES.filter((r) => r.test(s, now));
87
+ const pick = matches.find((r) => !taken.has(r.label)) || matches[0];
88
+ taken.add(pick.label);
89
+ const above = idx > 0 ? list[idx - 1].estCost || 0 : null;
90
+ out[s.sessionId] = {
91
+ label: pick.label,
92
+ why: pick.why(s),
93
+ rank: idx + 1,
94
+ behind: above == null ? null : Math.round((above - (s.estCost || 0)) * 100) / 100,
95
+ };
96
+ });
97
+ return out;
98
+ }
99
+
100
+ module.exports = { actionOf, hiveRoster, RULES };
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 };
@@ -9,6 +9,7 @@ const { CLAUDE_DIR, worktreeRoot } = require('./paths');
9
9
  const { estimateCost, costReport, totalTokens, cacheHitRatio, contextWindow } = require('./pricing');
10
10
  const { recordChange, mergeChanges, realPathOf } = require('./changes');
11
11
  const { parseAgentMeta } = require('./teams');
12
+ const { actionOf } = require('./hive');
12
13
 
13
14
  const PROJECTS_DIR = path.join(CLAUDE_DIR, 'projects');
14
15
  const HEAD_BYTES = 64 * 1024;
@@ -16,7 +17,7 @@ const TAIL_BYTES = 16 * 1024;
16
17
  // How many recent tool_use ids to remember for attributing failed results.
17
18
  const TOOL_RING = 256;
18
19
 
19
- function countToolUses(scan, content) {
20
+ function countToolUses(scan, content, at = null) {
20
21
  if (!Array.isArray(content)) return;
21
22
  for (const b of content) {
22
23
  if (!b || b.type !== 'tool_use' || !b.id) continue;
@@ -26,6 +27,7 @@ function countToolUses(scan, content) {
26
27
  const tools = scan.tools || (scan.tools = {});
27
28
  const t = tools[name] || (tools[name] = { count: 0, errors: 0 });
28
29
  t.count++;
30
+ scan.lastTool = { ...actionOf(name, b.input), tool: name, at };
29
31
  ring.push({ id: b.id, name });
30
32
  if (ring.length > TOOL_RING) ring.shift();
31
33
  }
@@ -186,6 +188,7 @@ async function scanFile(abs, sessionIdFromName) {
186
188
  meta.changes = scan.changes || null;
187
189
  meta.context = scan.lastContext || null;
188
190
  meta.tools = scan.tools || null;
191
+ meta.lastTool = scan.lastTool || null;
189
192
 
190
193
  cache.set(abs, { mtimeMs: st.mtimeMs, size: st.size, meta, scan });
191
194
  return meta;
@@ -227,7 +230,7 @@ function scanLine(line, scan) {
227
230
  const m = rec.message;
228
231
  if (!m || !m.usage) return;
229
232
  if ((m.model || '').startsWith('<')) return; // '<synthetic>' harness records
230
- countToolUses(scan, m.content); // every record: blocks of one message are split across records
233
+ countToolUses(scan, m.content, rec.timestamp ? Date.parse(rec.timestamp) || null : null); // every record: blocks of one message are split across records
231
234
  if (m.id && m.id === scan.lastMsgId) return;
232
235
  scan.lastMsgId = m.id || null;
233
236
  const model = m.model || 'unknown';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-mission-control",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
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
@@ -638,6 +638,88 @@
638
638
  .mc-pane-body { flex: 1; overflow-y: auto; padding: 6px 12px 10px; font-size: 12px; }
639
639
  .mc-pane-body .t-turn { margin-top: 8px; }
640
640
 
641
+ /* ---------- hive: one character card per live session ---------- */
642
+ #hive {
643
+ position: fixed; inset: 0; z-index: 15;
644
+ background: var(--bg); overflow-y: auto;
645
+ }
646
+ #hive[hidden] { display: none !important; }
647
+ #hive .mc-head { position: sticky; top: 0; z-index: 1; }
648
+ .hv-tiles {
649
+ display: grid; gap: 10px; padding: 14px 20px 0;
650
+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
651
+ }
652
+ .hv-tile { background: var(--surface); border-radius: 2px; padding: 9px 12px; min-width: 0; }
653
+ .hv-tile .k { color: var(--faint); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; }
654
+ .hv-tile .v { font-family: var(--disp-font); font-size: 26px; font-weight: 500; line-height: 1.15; }
655
+ .hv-tile .s { color: var(--faint); font-size: 11px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
656
+ .hv-tile.warn .v, .hv-tile .eta { color: var(--warn); }
657
+ .hv-grid {
658
+ display: grid; gap: 12px; padding: 12px 20px 20px;
659
+ grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
660
+ align-items: start;
661
+ }
662
+ .hv-empty { color: var(--faint); font-size: 13px; padding: 24px 20px; }
663
+ .hv-card {
664
+ background: var(--surface); border-radius: 2px; min-width: 0;
665
+ border-top: 3px solid var(--hue); display: flex; flex-direction: column;
666
+ }
667
+ .hv-card.waiting { border-top-color: var(--warn); box-shadow: inset 0 0 0 1px var(--warn-bg); }
668
+ .hv-top { display: flex; gap: 12px; align-items: flex-start; padding: 14px 14px 6px; }
669
+ .hv-avatar { flex: none; width: 64px; height: 64px; }
670
+ .hv-who { flex: 1; min-width: 0; }
671
+ .hv-name {
672
+ font-weight: 700; font-size: 14px; line-height: 1.3; cursor: pointer;
673
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
674
+ }
675
+ .hv-name:hover { color: var(--warn); }
676
+ .hv-persona { color: var(--muted); font-style: italic; font-size: 12px; margin-top: 2px; cursor: help; }
677
+ .hv-proj { color: var(--faint); font-size: 11px; margin-top: 3px; display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
678
+ .hv-rank { flex: none; text-align: right; }
679
+ .hv-rank .n { font-family: var(--disp-font); font-size: 30px; font-weight: 600; line-height: 1; padding: 2px 8px; }
680
+ .hv-rank .gap { color: var(--faint); font-size: 10.5px; margin-top: 5px; white-space: nowrap; }
681
+ .hv-rank .gap.lead { color: var(--accent); }
682
+ .hv-money { display: flex; align-items: baseline; gap: 10px; padding: 4px 14px 10px; flex-wrap: wrap; }
683
+ .hv-money .big { font-family: var(--disp-font); font-size: 38px; font-weight: 500; line-height: 1; }
684
+ .hv-money .rate { color: var(--muted); font-size: 12px; }
685
+ .hv-state {
686
+ margin: 0 14px; padding: 7px 10px; border-radius: 2px; background: var(--surface2);
687
+ display: flex; gap: 10px; align-items: center; min-width: 0; font-size: 12px;
688
+ }
689
+ .hv-state .txt { color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
690
+ .hv-card.waiting .hv-state { background: var(--warn-bg); }
691
+ .hv-card.waiting .hv-state .txt { color: var(--ink); }
692
+ .hv-sec { padding: 12px 14px 0; }
693
+ .hv-label { color: var(--faint); font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; display: flex; justify-content: space-between; gap: 8px; }
694
+ .hv-call { margin-top: 5px; font-size: 13px; display: flex; gap: 6px; min-width: 0; }
695
+ .hv-call .verb { color: var(--muted); flex: none; }
696
+ .hv-call .tgt { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
697
+ .hv-call.fresh .tgt { color: var(--accent); }
698
+ .hv-trail { margin-top: 4px; font-size: 11.5px; }
699
+ .hv-trail div { color: var(--faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
700
+ .hv-trail div:nth-child(2) { opacity: 0.75; }
701
+ .hv-trail div:nth-child(3) { opacity: 0.5; }
702
+ .hv-task { margin-top: 6px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
703
+ .hv-task .prompt-ch { color: var(--accent); margin-right: 6px; }
704
+ .hv-bars { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; padding: 14px 14px 0; }
705
+ .hv-bar .row { display: flex; justify-content: space-between; color: var(--faint); font-size: 10.5px; letter-spacing: 0.06em; text-transform: uppercase; }
706
+ .hv-bar .row b { color: var(--muted); font-weight: 500; letter-spacing: 0; }
707
+ .hv-bar .track { height: 4px; background: var(--surface2); margin-top: 5px; border-radius: 2px; overflow: hidden; }
708
+ .hv-bar .fill { display: block; height: 100%; background: var(--hue); }
709
+ .hv-bar.warn .fill { background: var(--warn); }
710
+ .hv-bar.bad .fill { background: var(--bad); }
711
+ .hv-foot {
712
+ display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
713
+ padding: 12px 14px 12px; margin-top: 12px; border-top: 1px solid var(--line);
714
+ }
715
+ .hv-foot .k { color: var(--faint); font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase; }
716
+ .hv-foot .v { font-size: 13px; margin-top: 2px; }
717
+ .hv-foot .v.bad { color: var(--bad); }
718
+ .hv-note { color: var(--faint); font-size: 11px; padding: 0 20px 24px; }
719
+ @keyframes hvblink { 0%, 92%, 100% { transform: scaleY(1); } 96% { transform: scaleY(0.1); } }
720
+ .hv-avatar .eyes { transform-box: fill-box; transform-origin: center; animation: hvblink 4.5s infinite; }
721
+ @media (prefers-reduced-motion: reduce) { .hv-avatar .eyes { animation: none; } }
722
+
641
723
  .mc-agents .agents { max-height: 160px; overflow-y: auto; }
642
724
  .agents { padding: 4px 12px 6px; border-bottom: 1px solid var(--line); font-size: 11.5px; flex: none; }
643
725
  .agents .team { color: var(--faint); text-transform: uppercase; letter-spacing: 0.08em; font-size: 10.5px; margin-bottom: 2px; }
@@ -863,6 +945,11 @@
863
945
  #drawer pre, #mc pre { max-width: 100%; overflow-x: auto; }
864
946
  #drawer code { overflow-wrap: anywhere; }
865
947
  #mc-grid { grid-template-columns: 1fr; padding: 12px 16px; }
948
+ .hv-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 12px 16px 0; }
949
+ .hv-tile:last-child:nth-child(odd) { grid-column: span 2; }
950
+ .hv-grid { grid-template-columns: 1fr; padding: 12px 16px 16px; }
951
+ .hv-note { padding: 0 16px 24px; }
952
+ .hv-money .big { font-size: 32px; }
866
953
  .heat { max-width: 100%; }
867
954
  }
868
955
  </style>
@@ -881,6 +968,7 @@
881
968
  <div id="quota"></div>
882
969
  <span id="clock" class="flap" title="board time"></span>
883
970
  <button id="mc-btn" title="Mission Control — every live session at once">▦</button>
971
+ <button id="hive-btn" title="Hive: every live session as a character card">⬢</button>
884
972
  <button id="bell-btn" title="Catch-up — what happened while you were away">🔔<span id="bell-badge" hidden></span></button>
885
973
  <button id="gear" title="Settings">⚙</button>
886
974
  </header>
@@ -919,6 +1007,17 @@
919
1007
  </div>
920
1008
  <div id="mc-grid"></div>
921
1009
  </div>
1010
+ <div id="hive" hidden>
1011
+ <div class="mc-head">
1012
+ <span class="mc-title">Hive</span>
1013
+ <span class="mc-sub" id="hive-sub"></span>
1014
+ <span class="spacer"></span>
1015
+ <button class="d-close" id="hive-close" title="Close (Esc)">✕</button>
1016
+ </div>
1017
+ <div class="hv-tiles" id="hive-tiles"></div>
1018
+ <div id="hive-grid" class="hv-grid"></div>
1019
+ <div class="hv-note">Personalities come from what each session has actually done; hover one to see why. Ranks go by list-price spend.</div>
1020
+ </div>
922
1021
  <div id="drawer-backdrop" hidden></div>
923
1022
  <aside id="drawer" hidden>
924
1023
  <div class="d-head">
@@ -1213,7 +1312,7 @@ function liveCard(s) {
1213
1312
  <div class="dep-right">
1214
1313
  ${modelChip(s.model)}
1215
1314
  ${s.estCost != null ? `<span class="dep-cost">${costChip(s.estCost, { cls: 'dep-cost-main', assumed: s.assumed })}${
1216
- s.burnRate ? `<span class="dep-burn" title="Estimated spend per hour at the current pace">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1315
+ s.burnRate ? `<span class="dep-burn" title="Spend per hour over the last 30 minutes">$${s.burnRate}/h</span>` : ''}</span>` : ''}
1217
1316
  ${contextMeter(s.context)}
1218
1317
  ${s.waitingMin != null
1219
1318
  ? `<span class="dep-elapsed overdue" title="No reply for ${s.waitingMin} minutes — session started ${elapsed(s.startedAt)} ago">${waitLabel(s.waitingMin)}</span>`
@@ -1441,6 +1540,7 @@ function render(state) {
1441
1540
  renderPinned(state);
1442
1541
  if (typeof renderBell === 'function') renderBell(state);
1443
1542
  if (typeof mcSync === 'function') mcSync();
1543
+ if (typeof hiveSync === 'function') hiveSync();
1444
1544
  renderDigest(state);
1445
1545
 
1446
1546
  // grid + dormant
@@ -2136,6 +2236,7 @@ const mc = { open: false, panes: new Map(), timer: null, polling: false };
2136
2236
 
2137
2237
  function openMissionControl() {
2138
2238
  if (mc.open) return;
2239
+ closeHive();
2139
2240
  mc.open = true;
2140
2241
  $('#mc').hidden = false;
2141
2242
  mcSync();
@@ -2278,6 +2379,230 @@ $('#mc-grid').addEventListener('toggle', (e) => {
2278
2379
  if (d) localStorage.setItem(`agentsOpen:${d.dataset.agentsKey}`, d.open ? '1' : '0');
2279
2380
  }, true);
2280
2381
 
2382
+ // ---------- hive ----------
2383
+ // One character card per live session: a generated face, a personality the
2384
+ // server earned from real behaviour, rank by spend, and the latest tool call.
2385
+ const hive = { open: false, timer: null, trail: new Map(), ranks: new Map(), hues: new Map() };
2386
+
2387
+ function hvHash(str) {
2388
+ let h = 2166136261;
2389
+ for (const c of String(str)) { h ^= c.charCodeAt(0); h = Math.imul(h, 16777619); }
2390
+ // Finalizer: ids that share a long prefix still land far apart.
2391
+ h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
2392
+ return h >>> 0;
2393
+ }
2394
+
2395
+ // Twelve hues spaced so neighbours on the board read as different colours.
2396
+ const HV_HUES = [4, 28, 46, 88, 140, 168, 192, 214, 236, 262, 292, 330];
2397
+ function hvHue(id) {
2398
+ return hive.hues.get(id) ?? HV_HUES[hvHash(id) % HV_HUES.length];
2399
+ }
2400
+
2401
+ // Each session keeps its own hue unless an earlier-started live session
2402
+ // already wears it; then it takes the next free one.
2403
+ function hvAssignHues(live) {
2404
+ hive.hues = new Map();
2405
+ const used = new Set();
2406
+ for (const s of live.slice().sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0))) {
2407
+ let i = hvHash(s.sessionId) % HV_HUES.length;
2408
+ for (let k = 0; k < HV_HUES.length && used.has(i); k++) i = (i + 1) % HV_HUES.length;
2409
+ used.add(i);
2410
+ hive.hues.set(s.sessionId, HV_HUES[i]);
2411
+ }
2412
+ }
2413
+
2414
+ // A small robot face, the same one every time for the same session.
2415
+ function hvAvatar(id, status) {
2416
+ const h = hvHash(id);
2417
+ const col = `hsl(${hvHue(id)} 52% 58%)`;
2418
+ const eye = status === 'waiting' ? 'var(--warn)' : 'var(--ink)';
2419
+ const ant = [
2420
+ `<line x1="28" y1="12" x2="28" y2="5" stroke="${col}" stroke-width="2"/><circle cx="28" cy="4.5" r="3" fill="${col}"/>`,
2421
+ `<line x1="21" y1="12" x2="16" y2="5" stroke="${col}" stroke-width="2"/><line x1="35" y1="12" x2="40" y2="5" stroke="${col}" stroke-width="2"/><circle cx="16" cy="4.5" r="2.2" fill="${col}"/><circle cx="40" cy="4.5" r="2.2" fill="${col}"/>`,
2422
+ `<rect x="3" y="25" width="5" height="10" rx="1.5" fill="${col}"/><rect x="48" y="25" width="5" height="10" rx="1.5" fill="${col}"/>`,
2423
+ ][(h >>> 9) % 3];
2424
+ const eyes = [
2425
+ `<circle cx="22" cy="27" r="3.2"/><circle cx="34" cy="27" r="3.2"/>`,
2426
+ `<rect x="19.5" y="24.5" width="5" height="5"/><rect x="31.5" y="24.5" width="5" height="5"/>`,
2427
+ `<rect x="17" y="25" width="22" height="4" rx="2"/>`,
2428
+ `<circle cx="28" cy="27" r="4.6"/>`,
2429
+ ][(h >>> 11) % 4];
2430
+ const mouth = [
2431
+ `<line x1="22" y1="36" x2="34" y2="36" stroke="${col}" stroke-width="2" stroke-linecap="round"/>`,
2432
+ `<path d="M22 34.5 Q28 39.5 34 34.5" fill="none" stroke="${col}" stroke-width="2" stroke-linecap="round"/>`,
2433
+ `<rect x="21" y="34.5" width="3.5" height="3" fill="${col}"/><rect x="26.25" y="34.5" width="3.5" height="3" fill="${col}"/><rect x="31.5" y="34.5" width="3.5" height="3" fill="${col}"/>`,
2434
+ `<circle cx="28" cy="36" r="2.2" fill="${col}"/>`,
2435
+ ][(h >>> 13) % 4];
2436
+ const rx = (h >>> 15) % 2 ? 11 : 4;
2437
+ return `<svg class="hv-avatar" viewBox="0 0 56 56" aria-hidden="true">${ant}
2438
+ <rect x="8" y="12" width="40" height="38" rx="${rx}" fill="${col}"/>
2439
+ <rect x="13" y="18" width="30" height="24" rx="3" fill="var(--bg)"/>
2440
+ <g class="eyes" fill="${eye}">${eyes}</g>${mouth}</svg>`;
2441
+ }
2442
+
2443
+ function hvAgo(at) {
2444
+ if (!at) return '';
2445
+ const sec = Math.max(0, Math.floor((Date.now() - at) / 1000));
2446
+ if (sec < 5) return 'just now';
2447
+ if (sec < 60) return `${sec}s ago`;
2448
+ return relTime(at);
2449
+ }
2450
+
2451
+ function hvBar(label, value, pct, level = '') {
2452
+ const w = Math.max(0, Math.min(100, Number(pct) || 0));
2453
+ return `<div class="hv-bar ${level}"><div class="row"><span>${label}</span><b>${value}</b></div>
2454
+ <div class="track"><span class="fill" style="width:${w}%"></span></div></div>`;
2455
+ }
2456
+
2457
+ function hvTiles(live) {
2458
+ const spend = live.reduce((n, s) => n + (s.estCost || 0), 0);
2459
+ const burn = live.reduce((n, s) => n + (s.burnRate || 0), 0);
2460
+ const waiting = live.filter((s) => s.status === 'waiting');
2461
+ const longest = Math.max(0, ...waiting.map((s) => s.waitingMin || 0));
2462
+ const tile = (k, v, sub, cls = '') => `<div class="hv-tile ${cls}"><div class="k">${k}</div><div class="v">${v}</div><div class="s">${sub}</div></div>`;
2463
+ const out = [
2464
+ tile('Live', live.length, `${live.length - waiting.length} working · ${waiting.length} need you`),
2465
+ tile('Spend', fmtCost(spend) || '$0', esc(costWording().noun)),
2466
+ tile('Burn', `$${Math.round(burn)}/h`, 'live sessions combined'),
2467
+ tile('Needs you', waiting.length, waiting.length ? (longest ? esc(waitLabel(longest)) : 'just asked') : 'nobody is waiting', waiting.length ? 'warn' : ''),
2468
+ ];
2469
+ const q = lastState.quota;
2470
+ if (q && !q.stale && q.utilization != null) {
2471
+ const f = lastState.forecast && lastState.forecast.session;
2472
+ const sub = f && f.hitAt ? `<span class="eta">→ ${esc(fmtWhen(f.hitAt))}</span> at this pace` : 'on pace to last the window';
2473
+ out.push(tile('5-hour limit', `${Math.round(q.utilization)}%`, sub, q.utilization >= 80 ? 'warn' : ''));
2474
+ }
2475
+ return out.join('');
2476
+ }
2477
+
2478
+ function hvCard(s, total) {
2479
+ const r = s.hive || {};
2480
+ const prevRank = hive.ranks.get(s.sessionId);
2481
+ hive.ranks.set(s.sessionId, r.rank);
2482
+ const flip = prevRank != null && prevRank !== r.rank ? ' flip' : '';
2483
+ const gap = r.rank === 1
2484
+ ? (total.count > 1 ? '<div class="gap lead">leading</div>' : '')
2485
+ : r.behind != null ? `<div class="gap">$${r.behind.toFixed(2)} behind</div>` : '';
2486
+ const w = costWording();
2487
+ const rl = s.status === 'waiting' ? reasonLine(s.waitingReason) : null;
2488
+ const stateTxt = rl ? `${rl.glyph ? rl.glyph + ' ' : ''}${rl.short}`
2489
+ : s.status === 'waiting' ? 'waiting for you'
2490
+ : s.quietMin ? `no output for ${s.quietMin}m`
2491
+ : s.background && s.background.detail ? `↳ ${s.background.detail}`
2492
+ : `${elapsed(s.startedAt)} on the clock`;
2493
+ const a = s.lastAction;
2494
+ const fresh = a && a.at && Date.now() - a.at < 10000 ? ' fresh' : '';
2495
+ const call = a
2496
+ ? `<div class="hv-call${fresh}"><span class="verb">${esc(a.verb)}</span><span class="tgt" title="${esc(a.target)}">${esc(a.target)}</span></div>`
2497
+ : '<div class="hv-call"><span class="verb">no tool calls yet</span></div>';
2498
+ const trail = (hive.trail.get(s.sessionId) || []).slice(0, -1).reverse().slice(0, 3)
2499
+ .map((t) => `<div>${esc(t.verb)} ${esc(t.target)}</div>`).join('');
2500
+ const ts = s.tasksSummary;
2501
+ const task = s.currentTask
2502
+ ? `<div class="hv-task"><span class="prompt-ch">❯</span>${esc(s.currentTask.activeForm)}${ts ? ` · ${ts.completed}/${ts.completed + ts.inProgress + ts.pending}` : ''}</div>`
2503
+ : '';
2504
+ const ctx = s.context;
2505
+ const share = total.spend > 0 ? Math.round(((s.estCost || 0) / total.spend) * 100) : 0;
2506
+ const started = s.startedAt ? new Date(s.startedAt).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', hour12: false }) : '';
2507
+ return `<div class="hv-card${s.status === 'waiting' ? ' waiting' : ''}" style="--hue:hsl(${hvHue(s.sessionId)} 52% 58%)">
2508
+ <div class="hv-top">
2509
+ ${hvAvatar(s.sessionId, s.status)}
2510
+ <div class="hv-who">
2511
+ <div class="hv-name" data-session="${esc(s.sessionId)}" title="Open the full transcript">${esc(s.title || s.projectName)}</div>
2512
+ ${r.label ? `<div class="hv-persona" title="${esc(r.why || '')}">${esc(r.label)}</div>` : ''}
2513
+ <div class="hv-proj">${esc(s.projectName)}${modelChip(s.model)}${s.background ? `<span class="badge" title="${esc(BG_TIP)}">bg</span>` : ''}</div>
2514
+ </div>
2515
+ <div class="hv-rank"><span class="flap n${flip}">#${r.rank || '·'}</span>${gap}</div>
2516
+ </div>
2517
+ <div class="hv-money"><span class="big" title="${esc(`${w.noun}: ${w.tip}`)}">${fmtCost(s.estCost) || '$0.00'}</span>${s.burnRate ? `<span class="rate">$${s.burnRate}/h</span>` : ''}</div>
2518
+ <div class="hv-state">${statusFlap(s, 'hv:')}<span class="txt" title="${esc(rl ? rl.full : stateTxt)}">${esc(stateTxt)}</span></div>
2519
+ <div class="hv-sec">
2520
+ <div class="hv-label"><span>Last call</span><span class="hv-ago" data-at="${a && a.at ? a.at : ''}">${hvAgo(a && a.at)}</span></div>
2521
+ ${call}${trail ? `<div class="hv-trail">${trail}</div>` : ''}${task}
2522
+ </div>
2523
+ <div class="hv-bars">
2524
+ ${hvBar('Context', ctx ? `${ctx.pct}%` : 'n/a', ctx ? ctx.pct : 0, ctx && ctx.pct >= 90 ? 'bad' : ctx && ctx.pct >= 70 ? 'warn' : '')}
2525
+ ${hvBar('Share of spend', `${share}%`, share)}
2526
+ </div>
2527
+ <div class="hv-foot">
2528
+ <div><div class="k">Tools</div><div class="v">${s.tools ? s.tools.total : 0}</div></div>
2529
+ <div><div class="k">Failed</div><div class="v${s.tools && s.tools.errors ? ' bad' : ''}">${s.tools ? s.tools.errors : 0}</div></div>
2530
+ <div><div class="k">Agents</div><div class="v">${s.subagents ? s.subagents.count : 0}</div></div>
2531
+ <div><div class="k">Started</div><div class="v">${esc(started)}</div></div>
2532
+ </div>
2533
+ </div>`;
2534
+ }
2535
+
2536
+ // Remember the last few distinct actions seen while the page is open, so a
2537
+ // card shows a short trail rather than only the latest call.
2538
+ function hvRecordTrail(live) {
2539
+ const ids = new Set();
2540
+ for (const s of live) {
2541
+ ids.add(s.sessionId);
2542
+ const a = s.lastAction;
2543
+ if (!a) continue;
2544
+ const list = hive.trail.get(s.sessionId) || [];
2545
+ const last = list[list.length - 1];
2546
+ if (last && last.verb === a.verb && last.target === a.target) last.at = a.at;
2547
+ else {
2548
+ list.push({ verb: a.verb, target: a.target, at: a.at });
2549
+ if (list.length > 4) list.shift();
2550
+ hive.trail.set(s.sessionId, list);
2551
+ }
2552
+ }
2553
+ for (const id of hive.trail.keys()) if (!ids.has(id)) hive.trail.delete(id);
2554
+ }
2555
+
2556
+ function hiveSync() {
2557
+ if (!lastState) return;
2558
+ const live = lastState.liveSessions || [];
2559
+ hvRecordTrail(live);
2560
+ if (!hive.open) return;
2561
+ hvAssignHues(live);
2562
+ $('#hive-sub').textContent = live.length ? `${live.length} session${live.length === 1 ? '' : 's'} live` : '';
2563
+ patch($('#hive-tiles'), hvTiles(live));
2564
+ if (!live.length) {
2565
+ patch($('#hive-grid'), '<div class="hv-empty">No sessions running. Start one with <code class="mono">claude</code> in any project.</div>');
2566
+ return;
2567
+ }
2568
+ const sorted = live.slice().sort((a, b) => ((a.hive && a.hive.rank) || 99) - ((b.hive && b.hive.rank) || 99));
2569
+ const total = { count: live.length, spend: live.reduce((n, s) => n + (s.estCost || 0), 0) };
2570
+ patch($('#hive-grid'), sorted.map((s) => hvCard(s, total)).join(''));
2571
+ }
2572
+
2573
+ function hvTick() {
2574
+ for (const el of document.querySelectorAll('#hive .hv-ago[data-at]')) {
2575
+ const at = Number(el.dataset.at);
2576
+ if (!at) continue;
2577
+ el.textContent = hvAgo(at);
2578
+ const call = el.closest('.hv-sec').querySelector('.hv-call');
2579
+ if (call) call.classList.toggle('fresh', Date.now() - at < 10000);
2580
+ }
2581
+ }
2582
+
2583
+ function openHive() {
2584
+ if (hive.open) return;
2585
+ closeMissionControl();
2586
+ hive.open = true;
2587
+ $('#hive').hidden = false;
2588
+ if (location.hash !== '#hive') history.replaceState(null, '', '#hive');
2589
+ hiveSync();
2590
+ hive.timer = setInterval(hvTick, 1000);
2591
+ }
2592
+
2593
+ function closeHive() {
2594
+ if (!hive.open) return;
2595
+ hive.open = false;
2596
+ $('#hive').hidden = true;
2597
+ clearInterval(hive.timer);
2598
+ hive.timer = null;
2599
+ if (location.hash === '#hive') history.replaceState(null, '', location.pathname + location.search);
2600
+ }
2601
+
2602
+ $('#hive-btn').addEventListener('click', openHive);
2603
+ $('#hive-close').addEventListener('click', closeHive);
2604
+ if (location.hash === '#hive') openHive();
2605
+
2281
2606
  // ---------- search ----------
2282
2607
  async function runSearch(q, deep) {
2283
2608
  openDrawerShell(`Search — "${q}"`, deep ? 'searching full transcripts…' : '');
@@ -2787,6 +3112,7 @@ document.addEventListener('keydown', (e) => {
2787
3112
  closeDetail();
2788
3113
  if (typeof closePalette === 'function') closePalette();
2789
3114
  if (!drawerOpen && typeof closeMissionControl === 'function') closeMissionControl();
3115
+ if (!drawerOpen && typeof closeHive === 'function') closeHive();
2790
3116
  }
2791
3117
  });
2792
3118
 
@@ -3233,6 +3559,7 @@ function paletteItems(q) {
3233
3559
  const st = lastState || { projects: [], liveSessions: [] };
3234
3560
  items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
3235
3561
  items.push({ k: '▦', label: 'Mission Control', hint: 'every live session at once', run: openMissionControl });
3562
+ items.push({ k: '⬢', label: 'Hive', hint: 'live sessions as characters · who is winning', run: openHive });
3236
3563
  items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
3237
3564
  items.push({ k: '⌗', label: 'Plugins & MCP', hint: 'what is installed, enabled, needs auth', run: openInventory });
3238
3565
  items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });