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 +11 -1
- package/lib/collector.js +13 -2
- package/lib/demo.js +18 -2
- package/lib/hive.js +100 -0
- package/lib/pricing.js +30 -10
- package/lib/transcripts.js +5 -2
- package/package.json +1 -1
- package/public/index.html +328 -1
package/README.md
CHANGED
|
@@ -35,6 +35,16 @@ Claude Code runs in a terminal. Once you have two or three sessions going, you l
|
|
|
35
35
|
|
|
36
36
|
*Every screenshot here is demo data (`CLAUDE_DASH_DEMO=1`). The projects are invented.*
|
|
37
37
|
|
|
38
|
+
**New in 1.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
|
+

|
|
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
|
|
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 {
|
|
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(
|
|
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:
|
|
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/pricing.js
CHANGED
|
@@ -165,16 +165,36 @@ function typicalWait(buckets) {
|
|
|
165
165
|
return null;
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
168
|
+
// Spend per hour at the current pace: the cost added over the last 30
|
|
169
|
+
// minutes of samples, not lifetime cost over process age. A resumed session
|
|
170
|
+
// carries its whole history in its transcript, so lifetime/age read in the
|
|
171
|
+
// thousands right after a resume and near zero for a long-lived process.
|
|
172
|
+
// Null until five minutes of samples exist, and under $1/h. Whole dollars so
|
|
173
|
+
// the SSE state fingerprint doesn't churn.
|
|
174
|
+
const BURN_WINDOW_MS = 30 * 60 * 1000;
|
|
172
175
|
const BURN_MIN_MS = 5 * 60 * 1000;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
const BURN_SAMPLE_MS = 30 * 1000;
|
|
177
|
+
|
|
178
|
+
// Appends [now, usd] to a session's samples (at most one per 30s unless the
|
|
179
|
+
// cost moved) and drops what has aged out of the window. A cost that went
|
|
180
|
+
// down means the transcript was rewritten: start over.
|
|
181
|
+
function recordCostSample(samples, usd, now = Date.now()) {
|
|
182
|
+
if (usd == null || !Number.isFinite(usd)) return samples;
|
|
183
|
+
const last = samples[samples.length - 1];
|
|
184
|
+
if (last && usd < last[1]) samples.length = 0;
|
|
185
|
+
const prev = samples[samples.length - 1];
|
|
186
|
+
if (!prev || usd !== prev[1] || now - prev[0] >= BURN_SAMPLE_MS) samples.push([now, usd]);
|
|
187
|
+
while (samples.length > 2 && samples[1][0] <= now - BURN_WINDOW_MS) samples.shift();
|
|
188
|
+
return samples;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function burnRate(samples, now = Date.now()) {
|
|
192
|
+
if (!samples || samples.length < 2) return null;
|
|
193
|
+
const first = samples[0];
|
|
194
|
+
const last = samples[samples.length - 1];
|
|
195
|
+
const span = last[0] - first[0];
|
|
196
|
+
if (span < BURN_MIN_MS || now - last[0] > BURN_WINDOW_MS) return null;
|
|
197
|
+
const perHour = Math.round((last[1] - first[1]) / (span / 3600000));
|
|
178
198
|
return perHour >= 1 ? perHour : null;
|
|
179
199
|
}
|
|
180
200
|
|
|
@@ -226,4 +246,4 @@ function planValue(plan, weekCost) {
|
|
|
226
246
|
};
|
|
227
247
|
}
|
|
228
248
|
|
|
229
|
-
module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, cacheHitRatio, topByCost, contextWindow };
|
|
249
|
+
module.exports = { estimateCost, planValue, costReport, totalTokens, rateFor, normalizeModel, loadPricing, overrideFor, budgetLevel, typicalWait, burnRate, recordCostSample, cacheHitRatio, topByCost, contextWindow };
|
package/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; }
|
|
@@ -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="
|
|
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 });
|