claude-mission-control 1.15.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 +10 -0
- package/lib/collector.js +6 -0
- package/lib/demo.js +18 -2
- package/lib/hive.js +100 -0
- package/lib/transcripts.js +5 -2
- package/package.json +1 -1
- package/public/index.html +357 -0
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.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
|
+

|
|
43
|
+
|
|
44
|
+
**New in 1.16.0**
|
|
45
|
+
|
|
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.
|
|
47
|
+
|
|
38
48
|
**New in 1.15.0**
|
|
39
49
|
|
|
40
50
|
- Phone view: see who needs you and your limits on your phone, over home Wi-Fi or Tailscale. Opt-in, read-only, locked to a pairing key in the link.
|
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:
|
|
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 -
|
|
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/transcripts.js
CHANGED
|
@@ -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
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; }
|
|
@@ -835,6 +917,41 @@
|
|
|
835
917
|
opacity: 0; transition: opacity 0.18s; pointer-events: none;
|
|
836
918
|
}
|
|
837
919
|
#toast.show { opacity: 1; }
|
|
920
|
+
|
|
921
|
+
/* ---------- phones: everything in one column, nothing scrolls sideways ---------- */
|
|
922
|
+
@media (max-width: 700px) {
|
|
923
|
+
header { padding: 10px 16px; gap: 8px 10px; }
|
|
924
|
+
header h1 { font-size: 16px; }
|
|
925
|
+
#search { flex: 1 1 90px; width: auto; min-width: 0; margin-left: 0; }
|
|
926
|
+
header .spacer, #open-pref-wrap { display: none; } /* opening terminals is a desk thing; still in settings */
|
|
927
|
+
#quota { order: 10; flex: 1 1 100%; flex-wrap: wrap; gap: 8px 14px; }
|
|
928
|
+
.meter .bar { width: 52px; }
|
|
929
|
+
#live { padding: 12px 16px; }
|
|
930
|
+
main { padding: 14px 16px; }
|
|
931
|
+
.dep-row { grid-template-columns: 44px 1fr; padding: 8px 12px; }
|
|
932
|
+
.dep-proj { font-size: 17px; }
|
|
933
|
+
.dep-mid, .dep-right { grid-column: 1 / -1; }
|
|
934
|
+
.dep-right { justify-self: stretch; flex-wrap: wrap; gap: 6px 10px; }
|
|
935
|
+
.dep-title, .dep-reason { white-space: normal; overflow-wrap: anywhere; }
|
|
936
|
+
#pinned .pin-row { flex-wrap: wrap; padding: 7px 12px; }
|
|
937
|
+
#git-attn .ga-item { white-space: normal; }
|
|
938
|
+
#drawer { width: 100vw; border-left: none; }
|
|
939
|
+
#drawer .d-head { flex-wrap: wrap; padding: 12px 16px 10px; }
|
|
940
|
+
#drawer .d-head h2 { font-size: 15px; flex: 1 1 0; min-width: 0; overflow-wrap: anywhere; }
|
|
941
|
+
#drawer .d-head .d-path { display: none; }
|
|
942
|
+
#drawer .d-actions { flex-wrap: wrap; order: 3; flex: 1 1 100%; }
|
|
943
|
+
#drawer .d-actions:empty { display: none; }
|
|
944
|
+
#drawer .d-body { padding: 6px 16px 40px; }
|
|
945
|
+
#drawer pre, #mc pre { max-width: 100%; overflow-x: auto; }
|
|
946
|
+
#drawer code { overflow-wrap: anywhere; }
|
|
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; }
|
|
953
|
+
.heat { max-width: 100%; }
|
|
954
|
+
}
|
|
838
955
|
</style>
|
|
839
956
|
</head>
|
|
840
957
|
<body>
|
|
@@ -851,6 +968,7 @@
|
|
|
851
968
|
<div id="quota"></div>
|
|
852
969
|
<span id="clock" class="flap" title="board time"></span>
|
|
853
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>
|
|
854
972
|
<button id="bell-btn" title="Catch-up — what happened while you were away">🔔<span id="bell-badge" hidden></span></button>
|
|
855
973
|
<button id="gear" title="Settings">⚙</button>
|
|
856
974
|
</header>
|
|
@@ -889,6 +1007,17 @@
|
|
|
889
1007
|
</div>
|
|
890
1008
|
<div id="mc-grid"></div>
|
|
891
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>
|
|
892
1021
|
<div id="drawer-backdrop" hidden></div>
|
|
893
1022
|
<aside id="drawer" hidden>
|
|
894
1023
|
<div class="d-head">
|
|
@@ -1411,6 +1540,7 @@ function render(state) {
|
|
|
1411
1540
|
renderPinned(state);
|
|
1412
1541
|
if (typeof renderBell === 'function') renderBell(state);
|
|
1413
1542
|
if (typeof mcSync === 'function') mcSync();
|
|
1543
|
+
if (typeof hiveSync === 'function') hiveSync();
|
|
1414
1544
|
renderDigest(state);
|
|
1415
1545
|
|
|
1416
1546
|
// grid + dormant
|
|
@@ -2106,6 +2236,7 @@ const mc = { open: false, panes: new Map(), timer: null, polling: false };
|
|
|
2106
2236
|
|
|
2107
2237
|
function openMissionControl() {
|
|
2108
2238
|
if (mc.open) return;
|
|
2239
|
+
closeHive();
|
|
2109
2240
|
mc.open = true;
|
|
2110
2241
|
$('#mc').hidden = false;
|
|
2111
2242
|
mcSync();
|
|
@@ -2248,6 +2379,230 @@ $('#mc-grid').addEventListener('toggle', (e) => {
|
|
|
2248
2379
|
if (d) localStorage.setItem(`agentsOpen:${d.dataset.agentsKey}`, d.open ? '1' : '0');
|
|
2249
2380
|
}, true);
|
|
2250
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
|
+
|
|
2251
2606
|
// ---------- search ----------
|
|
2252
2607
|
async function runSearch(q, deep) {
|
|
2253
2608
|
openDrawerShell(`Search — "${q}"`, deep ? 'searching full transcripts…' : '');
|
|
@@ -2757,6 +3112,7 @@ document.addEventListener('keydown', (e) => {
|
|
|
2757
3112
|
closeDetail();
|
|
2758
3113
|
if (typeof closePalette === 'function') closePalette();
|
|
2759
3114
|
if (!drawerOpen && typeof closeMissionControl === 'function') closeMissionControl();
|
|
3115
|
+
if (!drawerOpen && typeof closeHive === 'function') closeHive();
|
|
2760
3116
|
}
|
|
2761
3117
|
});
|
|
2762
3118
|
|
|
@@ -3203,6 +3559,7 @@ function paletteItems(q) {
|
|
|
3203
3559
|
const st = lastState || { projects: [], liveSessions: [] };
|
|
3204
3560
|
items.push({ k: '⚙', label: 'Settings', hint: 'rename, hide, terminal, notifications', run: openSettings });
|
|
3205
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 });
|
|
3206
3563
|
items.push({ k: '▲', label: 'Stats', hint: 'heatmap · models · hours', run: openStats });
|
|
3207
3564
|
items.push({ k: '⌗', label: 'Plugins & MCP', hint: 'what is installed, enabled, needs auth', run: openInventory });
|
|
3208
3565
|
items.push({ k: '☰', label: 'Your week with Claude', hint: '7-day report · export', run: openWeekReport });
|