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.
package/src/scanner.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdirSync, readFileSync, statSync, existsSync, writeFileSync, mkdirSync, renameSync } from 'node:fs';
2
2
  import { join, dirname, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
3
4
  import { CLAUDE_PROJECTS, SCAN_CACHE_PATH, AGGREGATE_PATH, DATA_DIR, EVENTS_LOG_PATH } from './paths.js';
4
5
  import { languageOf } from './languages.js';
5
6
  import { costFor, pricingKeyFor } from './pricing.js';
@@ -327,6 +328,18 @@ function listTranscripts(projectsDir) {
327
328
  return results;
328
329
  }
329
330
 
331
+ // Walk multiple project roots in one pass. Used by scan() to support
332
+ // `additionalProjectsDirs` config + the `claude-rpc backfill <path>` command
333
+ // for ad-hoc imports. Deduplicates by absolute path so overlapping roots
334
+ // don't double-count.
335
+ function listAllTranscripts(dirs) {
336
+ const all = new Set();
337
+ for (const d of dirs) {
338
+ for (const fp of listTranscripts(d)) all.add(fp);
339
+ }
340
+ return Array.from(all);
341
+ }
342
+
330
343
  function isSubagentPath(p) {
331
344
  return /[\\/]subagents[\\/]/.test(p);
332
345
  }
@@ -722,12 +735,45 @@ function aggregateFrom(cache) {
722
735
  return agg;
723
736
  }
724
737
 
738
+ // Known alternate locations Claude Code transcripts could live in besides
739
+ // ~/.claude/projects. Returned filtered to those that actually exist. Most
740
+ // installs only use the default; this is for older Claude Code versions,
741
+ // XDG-strict setups, or restored backups.
742
+ export function discoverAltProjectDirs() {
743
+ const home = homedir();
744
+ const candidates = [
745
+ join(home, '.config', 'claude', 'projects'),
746
+ join(home, '.local', 'share', 'claude', 'projects'),
747
+ join(home, 'AppData', 'Roaming', 'claude', 'projects'),
748
+ join(home, 'AppData', 'Local', 'claude', 'projects'),
749
+ join(home, 'Library', 'Application Support', 'claude', 'projects'),
750
+ ];
751
+ return candidates.filter((p) => existsSync(p) && p !== CLAUDE_PROJECTS);
752
+ }
753
+
725
754
  // Incremental scan: re-parse only changed files. Returns {aggregate, scanned, skipped, removed}.
726
- export function scan({ projectsDir = CLAUDE_PROJECTS, onProgress, force = false } = {}) {
755
+ //
756
+ // Accepts either:
757
+ // { projectsDir: '/path' } — single root (legacy)
758
+ // { projectsDirs: ['a', 'b'] } — multi-root (backfill/import)
759
+ // When neither is set, defaults to [CLAUDE_PROJECTS, ...discoverAltProjectDirs()].
760
+ // Auto-discovery is cheap (existsSync per known location) so it runs every
761
+ // scan — a freshly-restored backup at one of the alt paths gets picked up
762
+ // without any user action.
763
+ export function scan({ projectsDir, projectsDirs, onProgress, force = false, extraDirs = [] } = {}) {
764
+ const dirs = [];
765
+ if (projectsDirs && projectsDirs.length) dirs.push(...projectsDirs);
766
+ else if (projectsDir) dirs.push(projectsDir);
767
+ else {
768
+ dirs.push(CLAUDE_PROJECTS);
769
+ dirs.push(...discoverAltProjectDirs());
770
+ }
771
+ for (const d of extraDirs) if (!dirs.includes(d)) dirs.push(d);
772
+
727
773
  const cache = readCache();
728
774
  cache.files = cache.files || {};
729
775
  const seen = new Set();
730
- const transcripts = listTranscripts(projectsDir);
776
+ const transcripts = listAllTranscripts(dirs);
731
777
  let scanned = 0;
732
778
  let skipped = 0;
733
779
  for (const fp of transcripts) {
@@ -750,13 +796,22 @@ export function scan({ projectsDir = CLAUDE_PROJECTS, onProgress, force = false
750
796
  // skip corrupt file but keep prior cache entry
751
797
  }
752
798
  }
753
- // Remove cache entries for deleted transcripts
799
+ // Remove cache entries for transcripts that disappeared from disk. Only
800
+ // wipe entries whose root is one of the dirs we just scanned — otherwise
801
+ // a one-off backfill against a subset of dirs would nuke cache for
802
+ // unrelated paths.
754
803
  let removed = 0;
804
+ const sep = process.platform === 'win32' ? '\\' : '/';
805
+ const dirPrefixes = dirs.map((d) => d.replace(/[/\\]+$/, '') + sep);
755
806
  for (const key of Object.keys(cache.files)) {
756
- if (!seen.has(key)) { delete cache.files[key]; removed += 1; }
807
+ if (seen.has(key)) continue;
808
+ if (dirPrefixes.some((p) => key.startsWith(p))) {
809
+ delete cache.files[key];
810
+ removed += 1;
811
+ }
757
812
  }
758
813
  writeCache(cache);
759
814
  const aggregate = aggregateFrom(cache);
760
815
  writeAggregate(aggregate);
761
- return { aggregate, scanned, skipped, removed, total: transcripts.length };
816
+ return { aggregate, scanned, skipped, removed, total: transcripts.length, dirs };
762
817
  }
@@ -0,0 +1,172 @@
1
+ // Data-shape helpers used by the dashboard's /api routes. Pure functions
2
+ // over the aggregate + state files; no HTTP concerns. Tested separately
3
+ // from the routing layer.
4
+
5
+ import { readFileSync } from 'node:fs';
6
+ import { basename } from 'node:path';
7
+ import { readState } from '../state.js';
8
+ import { buildVars, fillTemplate, applyIdle, framePasses } from '../format.js';
9
+ import { readAggregate, findLiveSessions, dayKey } from '../scanner.js';
10
+ import { CONFIG_PATH } from '../paths.js';
11
+
12
+ export function loadConfig() {
13
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); } catch { return {}; }
14
+ }
15
+
16
+ export function rangeToDays(range) {
17
+ if (range === 'all') return Infinity;
18
+ if (range === '1y') return 365;
19
+ const n = parseInt(range, 10);
20
+ return Number.isFinite(n) && n > 0 ? n : 90;
21
+ }
22
+
23
+ // Filter byDay to a windowed slice; also recompute roll-ups (top files etc.)
24
+ // scoped to that window. Returns a shape similar to the aggregate but trimmed.
25
+ export function windowedAggregate(agg, range) {
26
+ if (!agg) return null;
27
+ const days = rangeToDays(range);
28
+ if (!Number.isFinite(days)) return agg; // 'all' → pass through
29
+
30
+ const today = new Date(); today.setHours(0, 0, 0, 0);
31
+ const keepKeys = new Set();
32
+ for (let i = 0; i < days; i++) {
33
+ const d = new Date(today); d.setDate(d.getDate() - i);
34
+ keepKeys.add(dayKey(d.getTime()));
35
+ }
36
+
37
+ const byDay = {};
38
+ let activeMs = 0, prompts = 0, toolCalls = 0, lines = 0, linesRem = 0, cost = 0, sessions = 0;
39
+ let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
40
+ for (const [k, day] of Object.entries(agg.byDay || {})) {
41
+ if (!keepKeys.has(k)) continue;
42
+ byDay[k] = day;
43
+ activeMs += day.activeMs || 0;
44
+ prompts += day.userMessages || 0;
45
+ toolCalls += day.toolCalls || 0;
46
+ lines += day.linesAdded || 0;
47
+ linesRem += day.linesRemoved || 0;
48
+ cost += day.cost || 0;
49
+ sessions += day.sessions || 0;
50
+ inputTokens += day.inputTokens || 0;
51
+ outputTokens += day.outputTokens || 0;
52
+ cacheReadTokens += day.cacheReadTokens || 0;
53
+ cacheWriteTokens += day.cacheWriteTokens || 0;
54
+ }
55
+
56
+ return {
57
+ range,
58
+ byDay,
59
+ activeMs,
60
+ userMessages: prompts,
61
+ toolCalls,
62
+ linesAdded: lines,
63
+ linesRemoved: linesRem,
64
+ linesNet: lines - linesRem,
65
+ estimatedCost: cost,
66
+ sessions,
67
+ inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
68
+ grandTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
69
+ // Pass-through global keys for context.
70
+ streak: agg.streak,
71
+ longestStreak: agg.longestStreak,
72
+ daysSinceFirst: agg.daysSinceFirst,
73
+ peakHour: agg.peakHour,
74
+ bestDay: agg.bestDay,
75
+ projects: agg.projects || {},
76
+ toolBreakdown: agg.toolBreakdown || {},
77
+ topEditedFiles: agg.topEditedFiles || [],
78
+ languages: agg.languages || {},
79
+ bashCommands: agg.bashCommands || {},
80
+ webDomains: agg.webDomains || {},
81
+ subagents: agg.subagents || {},
82
+ costByModel: agg.costByModel || {},
83
+ modelsUsed: agg.modelsUsed || {},
84
+ mcpToolCalls: agg.mcpToolCalls || 0,
85
+ builtinToolCalls: agg.builtinToolCalls || 0,
86
+ byHour: agg.byHour || {},
87
+ byWeekday: agg.byWeekday || {},
88
+ notifications: agg.notifications || 0,
89
+ };
90
+ }
91
+
92
+ // Live snapshot: current state + lifetime aggregate + rendered rotation frames.
93
+ // Used by GET /api/state — the SSE 'state' event tells the client to refetch.
94
+ export function snapshot() {
95
+ const config = loadConfig();
96
+ const live = findLiveSessions({ thresholdMs: 90_000 });
97
+ let state = readState();
98
+ state.liveSessions = live;
99
+ state = applyIdle(state, config);
100
+ const aggregate = readAggregate() || {};
101
+ const vars = buildVars(state, config, aggregate);
102
+ const p = config.presence || {};
103
+ const frames = (p.rotation || []).map((f) => ({
104
+ details: fillTemplate(f.details || '', vars),
105
+ state: fillTemplate(f.state || '', vars),
106
+ passes: framePasses(f, vars),
107
+ requires: f.requires || null,
108
+ }));
109
+ return {
110
+ now: Date.now(),
111
+ state,
112
+ aggregate: {
113
+ sessions: aggregate.sessions,
114
+ subagentRuns: aggregate.subagentRuns,
115
+ userMessages: aggregate.userMessages,
116
+ toolCalls: aggregate.toolCalls,
117
+ uniqueFiles: aggregate.uniqueFiles,
118
+ activeMs: aggregate.activeMs,
119
+ wallMs: aggregate.wallMs,
120
+ inputTokens: aggregate.inputTokens,
121
+ outputTokens: aggregate.outputTokens,
122
+ cacheReadTokens: aggregate.cacheReadTokens,
123
+ cacheWriteTokens: aggregate.cacheWriteTokens,
124
+ byDay: aggregate.byDay || {},
125
+ byHour: aggregate.byHour || {},
126
+ byWeekday: aggregate.byWeekday || {},
127
+ projects: aggregate.projects || {},
128
+ toolBreakdown: aggregate.toolBreakdown || {},
129
+ topEditedFiles: (aggregate.topEditedFiles || []).slice(0, 12).map((e) => ({ file: basename(e.path), path: e.path, count: e.count })),
130
+ streak: aggregate.streak,
131
+ longestStreak: aggregate.longestStreak,
132
+ daysSinceFirst: aggregate.daysSinceFirst,
133
+ bestDay: aggregate.bestDay,
134
+ peakHour: aggregate.peakHour,
135
+ linesAdded: aggregate.linesAdded || 0,
136
+ linesRemoved: aggregate.linesRemoved || 0,
137
+ linesNet: aggregate.linesNet || 0,
138
+ languages: aggregate.languages || {},
139
+ bashCommands: aggregate.bashCommands || {},
140
+ webDomains: aggregate.webDomains || {},
141
+ subagents: aggregate.subagents || {},
142
+ mcpToolCalls: aggregate.mcpToolCalls || 0,
143
+ builtinToolCalls: aggregate.builtinToolCalls || 0,
144
+ estimatedCost: aggregate.estimatedCost || 0,
145
+ costByModel: aggregate.costByModel || {},
146
+ modelsUsed: aggregate.modelsUsed || {},
147
+ notifications: aggregate.notifications || 0,
148
+ },
149
+ vars,
150
+ frames,
151
+ };
152
+ }
153
+
154
+ export function projectDrilldown(name) {
155
+ const agg = readAggregate() || {};
156
+ const projects = agg.projects || {};
157
+ const project = projects[name];
158
+ if (!project) return null;
159
+ return {
160
+ name,
161
+ ...project,
162
+ files: (agg.topEditedFiles || []).slice(0, 25),
163
+ tools: agg.toolBreakdown || {},
164
+ };
165
+ }
166
+
167
+ export function dayDetail(dayKeyStr) {
168
+ const agg = readAggregate() || {};
169
+ const day = (agg.byDay || {})[dayKeyStr];
170
+ if (!day) return null;
171
+ return { day: dayKeyStr, ...day };
172
+ }
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ // Local web dashboard for Claude RPC.
3
+ //
4
+ // Split layout (since the v0.4 refactor):
5
+ // server/index.js — this file: HTTP lifecycle, request dispatch, SIGINT
6
+ // server/api.js — data helpers (snapshot, windowedAggregate, drilldowns)
7
+ // server/routes.js — declarative /api/* route table
8
+ // server/sse.js — SSE broadcast + file watchers
9
+ // server/page.js — all browser-side assets (HTML/CSS/JS strings)
10
+ //
11
+ // Zero deps, vanilla browser JS, SVG charts. Designed to run alongside the
12
+ // daemon on localhost:47474 so the user can poke at their own data.
13
+
14
+ import { createServer } from 'node:http';
15
+ import { exec } from 'node:child_process';
16
+ import { ROUTES, JSON_HEADERS } from './routes.js';
17
+ import { projectDrilldown, dayDetail } from './api.js';
18
+ import { sseClients, watchSources } from './sse.js';
19
+ import { buildHtml } from './page.js';
20
+
21
+ // Pre-compose the HTML once at startup — the only dynamic bit is the port
22
+ // (used in a breadcrumb), which is fixed for the life of the daemon.
23
+ const HTML = buildHtml({ port: Number(process.env.CLAUDE_RPC_PORT) || 47474 });
24
+
25
+ const PORT = Number(process.env.CLAUDE_RPC_PORT) || 47474;
26
+
27
+ function parseUrl(rawUrl) {
28
+ const url = new URL(rawUrl, 'http://x');
29
+ return { path: url.pathname, query: Object.fromEntries(url.searchParams) };
30
+ }
31
+
32
+ const server = createServer((req, res) => {
33
+ const { path, query } = parseUrl(req.url);
34
+ const key = `${req.method} ${path}`;
35
+
36
+ // SSE endpoint — client subscribes once, gets pushed updates on file
37
+ // changes (debounced 200ms in sse.js).
38
+ if (req.method === 'GET' && path === '/events') {
39
+ res.writeHead(200, {
40
+ 'content-type': 'text/event-stream',
41
+ 'cache-control': 'no-store',
42
+ 'connection': 'keep-alive',
43
+ });
44
+ res.write(': hello\n\n');
45
+ sseClients.add(res);
46
+ req.on('close', () => sseClients.delete(res));
47
+ return;
48
+ }
49
+
50
+ // Project drilldown. Path-prefix dispatch (the project name is in the
51
+ // URL itself, not in a query string).
52
+ if (req.method === 'GET' && path.startsWith('/api/project/')) {
53
+ const name = decodeURIComponent(path.slice('/api/project/'.length));
54
+ const result = projectDrilldown(name);
55
+ res.writeHead(result ? 200 : 404, JSON_HEADERS);
56
+ res.end(JSON.stringify(result || { error: 'not found' }));
57
+ return;
58
+ }
59
+
60
+ // Day detail. Same pattern — day key in the URL path.
61
+ if (req.method === 'GET' && path.startsWith('/api/day/')) {
62
+ const day = decodeURIComponent(path.slice('/api/day/'.length));
63
+ const result = dayDetail(day);
64
+ res.writeHead(result ? 200 : 404, JSON_HEADERS);
65
+ res.end(JSON.stringify(result || { error: 'not found' }));
66
+ return;
67
+ }
68
+
69
+ // Static /api/* endpoints (state, aggregate, insights, badge.svg, card.svg).
70
+ const handler = ROUTES.get(key);
71
+ if (handler) return handler(req, res, { query });
72
+
73
+ // Page.
74
+ if (req.method === 'GET' && (path === '/' || path === '/index.html')) {
75
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' });
76
+ res.end(HTML);
77
+ return;
78
+ }
79
+
80
+ res.writeHead(404).end('not found');
81
+ });
82
+
83
+ watchSources();
84
+
85
+ server.listen(PORT, '127.0.0.1', () => {
86
+ const url = `http://127.0.0.1:${PORT}`;
87
+ console.log(`◆ Claude RPC dashboard: ${url}`);
88
+ console.log(' Ctrl-C to stop.');
89
+ if (!process.env.CLAUDE_RPC_NO_OPEN) {
90
+ const opener = process.platform === 'win32' ? `start "" "${url}"`
91
+ : process.platform === 'darwin' ? `open "${url}"`
92
+ : `xdg-open "${url}"`;
93
+ exec(opener, () => {});
94
+ }
95
+ });
96
+
97
+ process.on('SIGINT', () => process.exit(0));
98
+ process.on('SIGTERM', () => process.exit(0));