claude-rpc 0.3.11 → 0.5.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.
@@ -1,257 +1,17 @@
1
- #!/usr/bin/env node
2
- // Local web dashboard for Claude RPC.
3
- // Zero deps, single-file HTML, vanilla JS, SVG charts.
1
+ // All browser-side assets for the local web dashboard, packaged as JS
2
+ // string constants so the bundler picks them up cleanly. Three blocks:
4
3
  //
5
- // Phase 3 overhaul:
6
- // - Multiple API routes (windowed aggregate, project drilldown, day detail, insights, badge)
7
- // - SSE /events for push updates (replaces 2s polling)
8
- // - Range selector wired through every panel
9
- // - New panels: live rail, cost, languages, code churn, bash, web domains, insights
10
- // - Hash-routed drawer/modal for project/day drilldowns
11
- // - Theme toggle, keyboard shortcuts
12
- import { createServer } from 'node:http';
13
- import { readFileSync, watch } from 'node:fs';
14
- import { exec } from 'node:child_process';
15
- import { basename, dirname } from 'node:path';
16
- import { readState } from './state.js';
17
- import { buildVars, fillTemplate, applyIdle, framePasses, humanProject } from './format.js';
18
- import { readAggregate, findLiveSessions, dayKey } from './scanner.js';
19
- import { CONFIG_PATH, STATE_PATH, AGGREGATE_PATH } from './paths.js';
20
- import { generateInsights } from './insights.js';
21
- import { badgeSvg } from './badge.js';
22
-
23
- const PORT = Number(process.env.CLAUDE_RPC_PORT) || 47474;
24
-
25
- function loadConfig() {
26
- try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
27
- }
28
-
29
- // ── Data helpers ─────────────────────────────────────────────────────────────
30
-
31
- function rangeToDays(range) {
32
- if (range === 'all') return Infinity;
33
- if (range === '1y') return 365;
34
- const n = parseInt(range, 10);
35
- return Number.isFinite(n) && n > 0 ? n : 90;
36
- }
37
-
38
- // Filter byDay to a windowed slice; also recompute roll-ups (top files etc.)
39
- // scoped to that window. Returns a shape similar to the aggregate but trimmed.
40
- function windowedAggregate(agg, range) {
41
- if (!agg) return null;
42
- const days = rangeToDays(range);
43
- if (!Number.isFinite(days)) return agg; // 'all' → pass through
44
-
45
- const today = new Date(); today.setHours(0, 0, 0, 0);
46
- const keepKeys = new Set();
47
- for (let i = 0; i < days; i++) {
48
- const d = new Date(today); d.setDate(d.getDate() - i);
49
- keepKeys.add(dayKey(d.getTime()));
50
- }
51
-
52
- const byDay = {};
53
- let activeMs = 0, prompts = 0, toolCalls = 0, lines = 0, linesRem = 0, cost = 0, sessions = 0;
54
- let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
55
- for (const [k, day] of Object.entries(agg.byDay || {})) {
56
- if (!keepKeys.has(k)) continue;
57
- byDay[k] = day;
58
- activeMs += day.activeMs || 0;
59
- prompts += day.userMessages || 0;
60
- toolCalls += day.toolCalls || 0;
61
- lines += day.linesAdded || 0;
62
- linesRem += day.linesRemoved || 0;
63
- cost += day.cost || 0;
64
- sessions += day.sessions || 0;
65
- inputTokens += day.inputTokens || 0;
66
- outputTokens += day.outputTokens || 0;
67
- cacheReadTokens += day.cacheReadTokens || 0;
68
- cacheWriteTokens += day.cacheWriteTokens || 0;
69
- }
70
-
71
- return {
72
- range,
73
- byDay,
74
- activeMs,
75
- userMessages: prompts,
76
- toolCalls,
77
- linesAdded: lines,
78
- linesRemoved: linesRem,
79
- linesNet: lines - linesRem,
80
- estimatedCost: cost,
81
- sessions,
82
- inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
83
- grandTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
84
- // Pass-through global keys for context.
85
- streak: agg.streak,
86
- longestStreak: agg.longestStreak,
87
- daysSinceFirst: agg.daysSinceFirst,
88
- peakHour: agg.peakHour,
89
- bestDay: agg.bestDay,
90
- projects: agg.projects || {},
91
- toolBreakdown: agg.toolBreakdown || {},
92
- topEditedFiles: agg.topEditedFiles || [],
93
- languages: agg.languages || {},
94
- bashCommands: agg.bashCommands || {},
95
- webDomains: agg.webDomains || {},
96
- subagents: agg.subagents || {},
97
- costByModel: agg.costByModel || {},
98
- modelsUsed: agg.modelsUsed || {},
99
- mcpToolCalls: agg.mcpToolCalls || 0,
100
- builtinToolCalls: agg.builtinToolCalls || 0,
101
- byHour: agg.byHour || {},
102
- byWeekday: agg.byWeekday || {},
103
- notifications: agg.notifications || 0,
104
- };
105
- }
106
-
107
- function snapshot() {
108
- const config = loadConfig();
109
- const live = findLiveSessions({ thresholdMs: 90_000 });
110
- let state = readState();
111
- state.liveSessions = live;
112
- state = applyIdle(state, config);
113
- const aggregate = readAggregate() || {};
114
- const vars = buildVars(state, config, aggregate);
115
- const p = config.presence || {};
116
- const frames = (p.rotation || []).map((f) => ({
117
- details: fillTemplate(f.details || '', vars),
118
- state: fillTemplate(f.state || '', vars),
119
- passes: framePasses(f, vars),
120
- requires: f.requires || null,
121
- }));
122
- return {
123
- now: Date.now(),
124
- state,
125
- aggregate: {
126
- sessions: aggregate.sessions,
127
- subagentRuns: aggregate.subagentRuns,
128
- userMessages: aggregate.userMessages,
129
- toolCalls: aggregate.toolCalls,
130
- uniqueFiles: aggregate.uniqueFiles,
131
- activeMs: aggregate.activeMs,
132
- wallMs: aggregate.wallMs,
133
- inputTokens: aggregate.inputTokens,
134
- outputTokens: aggregate.outputTokens,
135
- cacheReadTokens: aggregate.cacheReadTokens,
136
- cacheWriteTokens: aggregate.cacheWriteTokens,
137
- byDay: aggregate.byDay || {},
138
- byHour: aggregate.byHour || {},
139
- byWeekday: aggregate.byWeekday || {},
140
- projects: aggregate.projects || {},
141
- toolBreakdown: aggregate.toolBreakdown || {},
142
- topEditedFiles: (aggregate.topEditedFiles || []).slice(0, 12).map((e) => ({ file: basename(e.path), path: e.path, count: e.count })),
143
- streak: aggregate.streak,
144
- longestStreak: aggregate.longestStreak,
145
- daysSinceFirst: aggregate.daysSinceFirst,
146
- bestDay: aggregate.bestDay,
147
- peakHour: aggregate.peakHour,
148
- // Phase 1 enrichments
149
- linesAdded: aggregate.linesAdded || 0,
150
- linesRemoved: aggregate.linesRemoved || 0,
151
- linesNet: aggregate.linesNet || 0,
152
- languages: aggregate.languages || {},
153
- bashCommands: aggregate.bashCommands || {},
154
- webDomains: aggregate.webDomains || {},
155
- subagents: aggregate.subagents || {},
156
- mcpToolCalls: aggregate.mcpToolCalls || 0,
157
- builtinToolCalls: aggregate.builtinToolCalls || 0,
158
- estimatedCost: aggregate.estimatedCost || 0,
159
- costByModel: aggregate.costByModel || {},
160
- modelsUsed: aggregate.modelsUsed || {},
161
- notifications: aggregate.notifications || 0,
162
- },
163
- vars,
164
- frames,
165
- };
166
- }
167
-
168
- function projectDrilldown(name) {
169
- const agg = readAggregate() || {};
170
- const projects = agg.projects || {};
171
- const project = projects[name];
172
- if (!project) return null;
173
- // Sum a per-day series for the project's edits from agg.byDay isn't directly
174
- // available without re-scanning. We approximate by treating the global byDay
175
- // as the project's view scaled by share-of-activity — but it's more useful
176
- // to just return per-project totals + global byDay so the client can show
177
- // the global timeline plus the project's stats.
178
- return {
179
- name,
180
- ...project,
181
- files: (agg.topEditedFiles || []).filter((f) => true).slice(0, 25), // global hotspots; future: per-project
182
- tools: agg.toolBreakdown || {},
183
- };
184
- }
185
-
186
- function dayDetail(dayKeyStr) {
187
- const agg = readAggregate() || {};
188
- const day = (agg.byDay || {})[dayKeyStr];
189
- if (!day) return null;
190
- return { day: dayKeyStr, ...day };
191
- }
192
-
193
- // ── Routes ───────────────────────────────────────────────────────────────────
194
-
195
- const ROUTES = new Map();
196
- ROUTES.set('GET /api/state', (req, res) => {
197
- res.writeHead(200, JSON_HEADERS);
198
- res.end(JSON.stringify(snapshot()));
199
- });
200
- ROUTES.set('GET /api/aggregate', (req, res, { query }) => {
201
- const range = query.range || '90d';
202
- const agg = readAggregate();
203
- res.writeHead(200, JSON_HEADERS);
204
- res.end(JSON.stringify(windowedAggregate(agg, range)));
205
- });
206
- ROUTES.set('GET /api/insights', (req, res, { query }) => {
207
- const agg = readAggregate();
208
- const lines = generateInsights(agg, { limit: parseInt(query.limit, 10) || 5 });
209
- res.writeHead(200, JSON_HEADERS);
210
- res.end(JSON.stringify({ insights: lines }));
211
- });
212
- ROUTES.set('GET /api/badge.svg', (req, res, { query }) => {
213
- const agg = readAggregate();
214
- const svg = badgeSvg({
215
- aggregate: agg,
216
- metric: query.metric || 'hours',
217
- range: query.range || '7d',
218
- label: query.label,
219
- });
220
- res.writeHead(200, {
221
- 'content-type': 'image/svg+xml; charset=utf-8',
222
- 'cache-control': 'max-age=60, public',
223
- });
224
- res.end(svg);
225
- });
226
-
227
- const JSON_HEADERS = { 'content-type': 'application/json', 'cache-control': 'no-store' };
228
-
229
- // SSE: emits {type:'state'|'aggregate'} when underlying files change.
230
- const sseClients = new Set();
231
- function broadcast(payload) {
232
- const line = `data: ${JSON.stringify(payload)}\n\n`;
233
- for (const res of sseClients) {
234
- try { res.write(line); } catch { sseClients.delete(res); }
235
- }
236
- }
237
-
238
- function watchSources() {
239
- let stTimer = null, agTimer = null;
240
- try {
241
- watch(STATE_PATH, () => {
242
- clearTimeout(stTimer);
243
- stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
244
- });
245
- } catch {}
246
- try {
247
- watch(AGGREGATE_PATH, () => {
248
- clearTimeout(agTimer);
249
- agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
250
- });
251
- } catch {}
252
- }
253
-
254
- // ── HTML ─────────────────────────────────────────────────────────────────────
4
+ // CSS — full stylesheet, dark + light themes
5
+ // LANG_PALETTE — per-language color JSON consumed by the
6
+ // client-side language stack chart
7
+ // HTML — the full HTML scaffold, interpolating
8
+ // both of the above plus the browser-side
9
+ // JS produced by HTML_SCRIPT_PLACEHOLDER()
10
+ // HTML_SCRIPT_PLACEHOLDER — the client-side runtime (SSE wiring,
11
+ // range selector, drilldowns, theme toggle,
12
+ // charts, keyboard shortcuts)
13
+ //
14
+ // Only HTML is exported. Edit the three blocks below in isolation.
255
15
 
256
16
  const CSS = `
257
17
  :root {
@@ -650,7 +410,9 @@ const LANG_PALETTE = `{
650
410
  'Config': '#888', 'Git': '#f1502f',
651
411
  }`;
652
412
 
653
- const HTML = String.raw`<!doctype html>
413
+ function buildHtml({ port }) {
414
+ const PORT = port;
415
+ return String.raw`<!doctype html>
654
416
  <html lang="en">
655
417
  <head>
656
418
  <meta charset="utf-8" />
@@ -922,6 +684,7 @@ ${HTML_SCRIPT_PLACEHOLDER()}
922
684
  </script>
923
685
  </body>
924
686
  </html>`;
687
+ }
925
688
 
926
689
  function HTML_SCRIPT_PLACEHOLDER() {
927
690
  return `(() => {
@@ -1511,74 +1274,4 @@ function HTML_SCRIPT_PLACEHOLDER() {
1511
1274
  })();`;
1512
1275
  }
1513
1276
 
1514
- // ── Server ───────────────────────────────────────────────────────────────────
1515
-
1516
- function parseUrl(rawUrl) {
1517
- const url = new URL(rawUrl, 'http://x');
1518
- return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
1519
- }
1520
-
1521
- const server = createServer((req, res) => {
1522
- const { path, query } = parseUrl(req.url);
1523
- const key = `${req.method} ${path}`;
1524
-
1525
- // SSE endpoint.
1526
- if (req.method === 'GET' && path === '/events') {
1527
- res.writeHead(200, {
1528
- 'content-type': 'text/event-stream',
1529
- 'cache-control': 'no-store',
1530
- 'connection': 'keep-alive',
1531
- });
1532
- res.write(': hello\n\n');
1533
- sseClients.add(res);
1534
- req.on('close', () => sseClients.delete(res));
1535
- return;
1536
- }
1537
-
1538
- // Project drilldown.
1539
- if (req.method === 'GET' && path.startsWith('/api/project/')) {
1540
- const name = decodeURIComponent(path.slice('/api/project/'.length));
1541
- const result = projectDrilldown(name);
1542
- res.writeHead(result ? 200 : 404, JSON_HEADERS);
1543
- res.end(JSON.stringify(result || { error: 'not found' }));
1544
- return;
1545
- }
1546
-
1547
- // Day detail.
1548
- if (req.method === 'GET' && path.startsWith('/api/day/')) {
1549
- const day = decodeURIComponent(path.slice('/api/day/'.length));
1550
- const result = dayDetail(day);
1551
- res.writeHead(result ? 200 : 404, JSON_HEADERS);
1552
- res.end(JSON.stringify(result || { error: 'not found' }));
1553
- return;
1554
- }
1555
-
1556
- // Generic API routes.
1557
- const handler = ROUTES.get(key);
1558
- if (handler) return handler(req, res, { query });
1559
-
1560
- if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
1561
- res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
1562
- res.end(HTML);
1563
- return;
1564
- }
1565
-
1566
- res.writeHead(404).end('not found');
1567
- });
1568
-
1569
- watchSources();
1570
-
1571
- server.listen(PORT, '127.0.0.1', () => {
1572
- const url = `http://127.0.0.1:${PORT}`;
1573
- console.log(`◆ Claude RPC dashboard: ${url}`);
1574
- console.log(' Ctrl-C to stop.');
1575
- if (!process.env.CLAUDE_RPC_NO_OPEN) {
1576
- const opener = process.platform === 'win32' ? `start "" "${url}"`
1577
- : process.platform === 'darwin' ? `open "${url}"`
1578
- : `xdg-open "${url}"`;
1579
- exec(opener, () => {});
1580
- }
1581
- });
1582
-
1583
- process.on('SIGINT', () => process.exit(0));
1584
- process.on('SIGTERM', () => process.exit(0));
1277
+ export { buildHtml };
@@ -0,0 +1,63 @@
1
+ // /api/* route table. Each handler reads fresh aggregate/state per request
2
+ // (cheap — these files are tiny). Returns a Map indexed by `METHOD PATH`
3
+ // — the createServer dispatch in index.js does a lookup + falls back to
4
+ // a handful of path-prefix checks (project/, day/) and the SSE / page
5
+ // endpoints.
6
+
7
+ import { readAggregate } from '../scanner.js';
8
+ import { generateInsights } from '../insights.js';
9
+ import { badgeSvg } from '../badge.js';
10
+ import { renderCard } from '../card.js';
11
+ import { snapshot, windowedAggregate } from './api.js';
12
+
13
+ export const JSON_HEADERS = {
14
+ 'content-type': 'application/json',
15
+ 'cache-control': 'no-store',
16
+ };
17
+
18
+ export const ROUTES = new Map();
19
+
20
+ ROUTES.set('GET /api/state', (req, res) => {
21
+ res.writeHead(200, JSON_HEADERS);
22
+ res.end(JSON.stringify(snapshot()));
23
+ });
24
+
25
+ ROUTES.set('GET /api/aggregate', (req, res, { query }) => {
26
+ const range = query.range || '90d';
27
+ const agg = readAggregate();
28
+ res.writeHead(200, JSON_HEADERS);
29
+ res.end(JSON.stringify(windowedAggregate(agg, range)));
30
+ });
31
+
32
+ ROUTES.set('GET /api/insights', (req, res, { query }) => {
33
+ const agg = readAggregate();
34
+ const lines = generateInsights(agg, { limit: parseInt(query.limit, 10) || 5 });
35
+ res.writeHead(200, JSON_HEADERS);
36
+ res.end(JSON.stringify({ insights: lines }));
37
+ });
38
+
39
+ ROUTES.set('GET /api/badge.svg', (req, res, { query }) => {
40
+ const agg = readAggregate();
41
+ const svg = badgeSvg({
42
+ aggregate: agg,
43
+ metric: query.metric || 'hours',
44
+ range: query.range || '7d',
45
+ label: query.label,
46
+ });
47
+ res.writeHead(200, {
48
+ 'content-type': 'image/svg+xml; charset=utf-8',
49
+ 'cache-control': 'max-age=60, public',
50
+ });
51
+ res.end(svg);
52
+ });
53
+
54
+ // Poster-style card. `?range=year|month|week|all` (default year).
55
+ ROUTES.set('GET /api/card.svg', (req, res, { query }) => {
56
+ const agg = readAggregate();
57
+ const svg = renderCard(agg, { range: query.range || 'year' });
58
+ res.writeHead(200, {
59
+ 'content-type': 'image/svg+xml; charset=utf-8',
60
+ 'cache-control': 'max-age=60, public',
61
+ });
62
+ res.end(svg);
63
+ });
@@ -0,0 +1,32 @@
1
+ // Server-Sent Events: the dashboard pushes a one-line `data:` frame to
2
+ // connected browsers whenever state.json or aggregate.json is touched.
3
+ // Replaces the old 2-second poll. Two debounced fs.watch handles, one
4
+ // shared client set.
5
+
6
+ import { watch } from 'node:fs';
7
+ import { STATE_PATH, AGGREGATE_PATH } from '../paths.js';
8
+
9
+ export const sseClients = new Set();
10
+
11
+ export function broadcast(payload) {
12
+ const line = `data: ${JSON.stringify(payload)}\n\n`;
13
+ for (const res of sseClients) {
14
+ try { res.write(line); } catch { sseClients.delete(res); }
15
+ }
16
+ }
17
+
18
+ export function watchSources() {
19
+ let stTimer = null, agTimer = null;
20
+ try {
21
+ watch(STATE_PATH, () => {
22
+ clearTimeout(stTimer);
23
+ stTimer = setTimeout(() => broadcast({ type: 'state' }), 200);
24
+ });
25
+ } catch {}
26
+ try {
27
+ watch(AGGREGATE_PATH, () => {
28
+ clearTimeout(agTimer);
29
+ agTimer = setTimeout(() => broadcast({ type: 'aggregate' }), 200);
30
+ });
31
+ } catch {}
32
+ }