claude-mission-control 1.16.0 → 1.17.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,12 @@ 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.0**
39
+
40
+ - 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.
41
+
42
+ ![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)
43
+
38
44
  **New in 1.16.0**
39
45
 
40
46
  - 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.
package/lib/collector.js CHANGED
@@ -14,6 +14,7 @@ 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 { hiveRoster } = require('./hive');
17
18
  const { estimateCost, planValue, costReport, budgetLevel, typicalWait, burnRate, topByCost } = require('./pricing');
18
19
  const { readPlan } = require('./plan');
19
20
  const { readTeams, agentStatus, resolveTeam } = require('./teams');
@@ -344,6 +345,7 @@ class Collector {
344
345
  const usageBySession = new Map();
345
346
  const contextBySession = new Map();
346
347
  const toolsBySession = new Map();
348
+ const lastToolBySession = new Map();
347
349
  for (const g of this.raw.transcriptGroups.values()) {
348
350
  for (const m of g.sessions) {
349
351
  titleBySession.set(m.sessionId, sessionTitle(m, sessionNames).title);
@@ -352,6 +354,7 @@ class Collector {
352
354
  contextBySession.set(m.sessionId, contextSummary(m.context));
353
355
  const ts = toolSummary(m.tools);
354
356
  toolsBySession.set(m.sessionId, ts ? { total: ts.total, errors: ts.errors } : null);
357
+ if (m.lastTool) lastToolBySession.set(m.sessionId, m.lastTool);
355
358
  const subs = subagentSummary(m);
356
359
  if (subs) subsBySession.set(m.sessionId, subs);
357
360
  }
@@ -420,6 +423,7 @@ class Collector {
420
423
  assumed: report.assumedModels.length > 0,
421
424
  context: contextBySession.get(s.sessionId) || null,
422
425
  tools: toolsBySession.get(s.sessionId) || null,
426
+ lastAction: lastToolBySession.get(s.sessionId) || null,
423
427
  // Background sessions go by the name Agent View shows.
424
428
  title: (job && job.name) || titleBySession.get(s.sessionId) || s.name,
425
429
  background: s.kind === 'bg' || job
@@ -437,6 +441,8 @@ class Collector {
437
441
  resumeCommand: resumeCommand(s.cwd, s.sessionId),
438
442
  };
439
443
  });
444
+ const roster = hiveRoster(liveSessions);
445
+ for (const s of liveSessions) s.hive = roster[s.sessionId];
440
446
 
441
447
  // Pinned sessions, newest first, resolved to display data.
442
448
  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 };
@@ -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.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
@@ -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">
@@ -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 });