claude-rpc 0.3.10 → 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/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +344 -0
- package/src/cli.js +147 -1
- package/src/daemon.js +38 -5
- package/src/doctor.js +396 -0
- package/src/privacy.js +231 -0
- package/src/scanner.js +101 -5
- package/src/server/api.js +172 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +18 -325
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
package/src/privacy.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Privacy mode — controls what the Discord card shows about a given cwd.
|
|
2
|
+
// Three layers, highest-priority first:
|
|
3
|
+
//
|
|
4
|
+
// 1. Per-project ./.claude-rpc.json file in the project root.
|
|
5
|
+
// { "visibility": "hidden|name-only|public", "projectName": "alias" }
|
|
6
|
+
// Shortcut: { "private": true } == { "visibility": "hidden" }
|
|
7
|
+
// 2. Runtime list at ~/.claude-rpc/private-list.json, toggled by
|
|
8
|
+
// `claude-rpc private` / `claude-rpc public`.
|
|
9
|
+
// 3. Auto-detection of GitHub private repos via the `gh` CLI when
|
|
10
|
+
// installed. Best-effort, silently skips when gh isn't available
|
|
11
|
+
// or auth isn't set up. Cached per-cwd with 5min TTL.
|
|
12
|
+
//
|
|
13
|
+
// "Visibility" levels:
|
|
14
|
+
// public everything as-is (default)
|
|
15
|
+
// name-only project name kept, but currentFile / currentTool / files
|
|
16
|
+
// arrays cleared so the card doesn't leak paths
|
|
17
|
+
// hidden cwd cleared entirely; daemon then short-circuits to
|
|
18
|
+
// clearActivity (same effect as hideWhenStale)
|
|
19
|
+
//
|
|
20
|
+
// Aggregates (scanner) and the local TUI/web dashboards are NEVER affected
|
|
21
|
+
// by these flags. Privacy is a one-way valve from local state → Discord.
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import { join, basename, dirname, resolve as resolvePath } from 'node:path';
|
|
26
|
+
import { DATA_DIR } from './paths.js';
|
|
27
|
+
|
|
28
|
+
const PRIVATE_LIST_PATH = join(DATA_DIR, 'private-list.json');
|
|
29
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
const projectFileCache = new Map(); // cwd → { ts, value | null }
|
|
32
|
+
const ghPrivateCache = new Map(); // cwd → { ts, value: bool | null }
|
|
33
|
+
|
|
34
|
+
// ── Per-project .claude-rpc.json ────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
// Walk up from `cwd` looking for a .claude-rpc.json. Stops at the first
|
|
37
|
+
// match or at a .git directory (the project root). Lets a subdirectory
|
|
38
|
+
// inherit the parent's privacy config.
|
|
39
|
+
function findProjectFile(cwd) {
|
|
40
|
+
if (!cwd) return null;
|
|
41
|
+
let dir = cwd;
|
|
42
|
+
while (true) {
|
|
43
|
+
const candidate = join(dir, '.claude-rpc.json');
|
|
44
|
+
if (existsSync(candidate)) return candidate;
|
|
45
|
+
if (existsSync(join(dir, '.git'))) return null;
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return null;
|
|
48
|
+
dir = parent;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readProjectConfig(cwd) {
|
|
53
|
+
if (!cwd) return null;
|
|
54
|
+
const cached = projectFileCache.get(cwd);
|
|
55
|
+
if (cached && Date.now() - cached.ts < TTL_MS) return cached.value;
|
|
56
|
+
let value = null;
|
|
57
|
+
const path = findProjectFile(cwd);
|
|
58
|
+
if (path) {
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
61
|
+
if (parsed && typeof parsed === 'object') value = parsed;
|
|
62
|
+
} catch { /* broken JSON ≡ no override */ }
|
|
63
|
+
}
|
|
64
|
+
projectFileCache.set(cwd, { ts: Date.now(), value });
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeProjectConfig(cfg) {
|
|
69
|
+
if (!cfg) return null;
|
|
70
|
+
let visibility = cfg.visibility;
|
|
71
|
+
if (cfg.private === true && !visibility) visibility = 'hidden';
|
|
72
|
+
if (!['public', 'name-only', 'hidden'].includes(visibility)) visibility = null;
|
|
73
|
+
return {
|
|
74
|
+
visibility,
|
|
75
|
+
projectName: typeof cfg.projectName === 'string' ? cfg.projectName : null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Runtime private-list ────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function readPrivateList() {
|
|
82
|
+
if (!existsSync(PRIVATE_LIST_PATH)) return { paths: [] };
|
|
83
|
+
try {
|
|
84
|
+
const v = JSON.parse(readFileSync(PRIVATE_LIST_PATH, 'utf8'));
|
|
85
|
+
if (Array.isArray(v?.paths)) return v;
|
|
86
|
+
} catch {}
|
|
87
|
+
return { paths: [] };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writePrivateList(list) {
|
|
91
|
+
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
|
92
|
+
writeFileSync(PRIVATE_LIST_PATH, JSON.stringify(list, null, 2));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function addPrivateCwd(cwd) {
|
|
96
|
+
const list = readPrivateList();
|
|
97
|
+
const abs = resolvePath(cwd);
|
|
98
|
+
if (!list.paths.includes(abs)) {
|
|
99
|
+
list.paths.push(abs);
|
|
100
|
+
writePrivateList(list);
|
|
101
|
+
}
|
|
102
|
+
return list.paths;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function removePrivateCwd(cwd) {
|
|
106
|
+
const list = readPrivateList();
|
|
107
|
+
const abs = resolvePath(cwd);
|
|
108
|
+
list.paths = list.paths.filter((p) => p !== abs);
|
|
109
|
+
writePrivateList(list);
|
|
110
|
+
return list.paths;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function listPrivateCwds() {
|
|
114
|
+
return readPrivateList().paths;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isInPrivateList(cwd) {
|
|
118
|
+
if (!cwd) return false;
|
|
119
|
+
const abs = resolvePath(cwd);
|
|
120
|
+
return readPrivateList().paths.some(
|
|
121
|
+
(p) => abs === p || abs.startsWith(p + '/') || abs.startsWith(p + '\\')
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── GitHub-private detection (best-effort, gh CLI) ──────────────────────
|
|
126
|
+
|
|
127
|
+
function detectGithubPrivate(cwd) {
|
|
128
|
+
if (!cwd) return null;
|
|
129
|
+
const cached = ghPrivateCache.get(cwd);
|
|
130
|
+
if (cached && Date.now() - cached.ts < TTL_MS) return cached.value;
|
|
131
|
+
let value = null;
|
|
132
|
+
try {
|
|
133
|
+
const out = execFileSync(
|
|
134
|
+
'gh',
|
|
135
|
+
['repo', 'view', '--json', 'isPrivate', '-q', '.isPrivate'],
|
|
136
|
+
{ cwd, stdio: ['ignore', 'pipe', 'ignore'], timeout: 1500 }
|
|
137
|
+
).toString().trim();
|
|
138
|
+
if (out === 'true') value = true;
|
|
139
|
+
else if (out === 'false') value = false;
|
|
140
|
+
} catch { /* gh missing, not auth'd, not a repo, timeout — unknown */ }
|
|
141
|
+
ghPrivateCache.set(cwd, { ts: Date.now(), value });
|
|
142
|
+
return value;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Resolution + application ────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
// Resolve effective visibility for a cwd.
|
|
148
|
+
// Returns: { visibility, projectName, reason }
|
|
149
|
+
export function resolveVisibility(cwd, config = {}) {
|
|
150
|
+
const proj = normalizeProjectConfig(readProjectConfig(cwd));
|
|
151
|
+
if (proj?.visibility) {
|
|
152
|
+
return { visibility: proj.visibility, projectName: proj.projectName, reason: '.claude-rpc.json' };
|
|
153
|
+
}
|
|
154
|
+
if (isInPrivateList(cwd)) {
|
|
155
|
+
return { visibility: 'hidden', projectName: proj?.projectName ?? null, reason: 'private-list' };
|
|
156
|
+
}
|
|
157
|
+
const patterns = config?.privacy?.patterns || [];
|
|
158
|
+
if (patterns.length && cwd) {
|
|
159
|
+
const leaf = basename(cwd);
|
|
160
|
+
for (const p of patterns) {
|
|
161
|
+
if (matchesPattern(leaf, p)) {
|
|
162
|
+
const mode = config?.privacy?.mode || 'hidden';
|
|
163
|
+
return { visibility: mode, projectName: proj?.projectName ?? null, reason: `config pattern '${p}'` };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (config?.privacy?.autoDetectGithubPrivate !== false) {
|
|
168
|
+
const isPrivate = detectGithubPrivate(cwd);
|
|
169
|
+
if (isPrivate === true) {
|
|
170
|
+
const mode = config?.privacy?.githubPrivateMode || 'hidden';
|
|
171
|
+
return { visibility: mode, projectName: proj?.projectName ?? null, reason: 'github private repo' };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return { visibility: 'public', projectName: proj?.projectName ?? null, reason: 'default' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Glob-lite. '*' matches any run; otherwise plain text. Case-insensitive.
|
|
178
|
+
function matchesPattern(name, pattern) {
|
|
179
|
+
if (pattern === name) return true;
|
|
180
|
+
if (!pattern.includes('*')) return false;
|
|
181
|
+
const re = new RegExp('^' + pattern.split('*').map(escapeRe).join('.*') + '$', 'i');
|
|
182
|
+
return re.test(name);
|
|
183
|
+
}
|
|
184
|
+
function escapeRe(s) { return s.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); }
|
|
185
|
+
|
|
186
|
+
// Apply privacy to a post-applyIdle state. Pure function — no IO.
|
|
187
|
+
export function applyPrivacy(state, config = {}) {
|
|
188
|
+
if (!state || state.status === 'stale') return state;
|
|
189
|
+
const { visibility, projectName } = resolveVisibility(state.cwd || '', config);
|
|
190
|
+
|
|
191
|
+
if (visibility === 'public') {
|
|
192
|
+
if (projectName) {
|
|
193
|
+
return { ...state, cwd: joinCwdAlias(state.cwd, projectName), _privacy: { visibility, alias: true } };
|
|
194
|
+
}
|
|
195
|
+
return state;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (visibility === 'name-only') {
|
|
199
|
+
return {
|
|
200
|
+
...state,
|
|
201
|
+
cwd: projectName ? joinCwdAlias(state.cwd, projectName) : state.cwd,
|
|
202
|
+
currentTool: null,
|
|
203
|
+
currentFile: null,
|
|
204
|
+
filesEdited: [],
|
|
205
|
+
filesRead: [],
|
|
206
|
+
filesOpened: [],
|
|
207
|
+
_privacy: { visibility, alias: !!projectName },
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// hidden
|
|
212
|
+
return {
|
|
213
|
+
...state,
|
|
214
|
+
cwd: '',
|
|
215
|
+
currentTool: null,
|
|
216
|
+
currentFile: null,
|
|
217
|
+
filesEdited: [],
|
|
218
|
+
filesRead: [],
|
|
219
|
+
filesOpened: [],
|
|
220
|
+
_privacy: { visibility, alias: false },
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function joinCwdAlias(cwd, alias) {
|
|
225
|
+
if (!cwd) return alias;
|
|
226
|
+
const sep = cwd.includes('\\') ? '\\' : '/';
|
|
227
|
+
const parts = cwd.split(/[\\/]/).filter(Boolean);
|
|
228
|
+
if (!parts.length) return alias;
|
|
229
|
+
parts[parts.length - 1] = alias;
|
|
230
|
+
return (cwd.startsWith('/') ? '/' : '') + parts.join(sep);
|
|
231
|
+
}
|
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
|
}
|
|
@@ -350,6 +363,47 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
350
363
|
return cwd;
|
|
351
364
|
}
|
|
352
365
|
|
|
366
|
+
// Per-transcript token cache. Reading a multi-MB .jsonl on every push tick
|
|
367
|
+
// (4s) would be wasteful, so we only re-parse when the file's mtime has
|
|
368
|
+
// advanced since the last read.
|
|
369
|
+
const sessionTokenCache = new Map(); // path → { mtime, tokens }
|
|
370
|
+
|
|
371
|
+
// Sum input/output/cache tokens from a single transcript JSONL.
|
|
372
|
+
//
|
|
373
|
+
// We need this because Claude Code's hook payloads don't carry usage data —
|
|
374
|
+
// tokens are an assistant-message field, not a tool-call field, so PostToolUse
|
|
375
|
+
// hooks fire with no `usage` block to capture. The live transcript is the
|
|
376
|
+
// only source of truth for the current session's running token count.
|
|
377
|
+
//
|
|
378
|
+
// Returns null when the file can't be read; { input, output, cacheRead,
|
|
379
|
+
// cacheWrite } otherwise. Cached by mtime — repeat calls with no file
|
|
380
|
+
// activity are O(1).
|
|
381
|
+
export function readSessionTokens(path) {
|
|
382
|
+
let st;
|
|
383
|
+
try { st = statSync(path); } catch { return null; }
|
|
384
|
+
const cached = sessionTokenCache.get(path);
|
|
385
|
+
if (cached && cached.mtime === st.mtimeMs) return cached.tokens;
|
|
386
|
+
|
|
387
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
388
|
+
try {
|
|
389
|
+
const raw = readFileSync(path, 'utf8');
|
|
390
|
+
for (const line of raw.split('\n')) {
|
|
391
|
+
if (!line) continue;
|
|
392
|
+
const r = safeJson(line);
|
|
393
|
+
if (!r || r.type !== 'assistant') continue;
|
|
394
|
+
const u = r.message?.usage;
|
|
395
|
+
if (!u) continue;
|
|
396
|
+
tokens.input += u.input_tokens || 0;
|
|
397
|
+
tokens.output += u.output_tokens || 0;
|
|
398
|
+
tokens.cacheRead += u.cache_read_input_tokens || 0;
|
|
399
|
+
tokens.cacheWrite += u.cache_creation_input_tokens || 0;
|
|
400
|
+
}
|
|
401
|
+
} catch { return null; }
|
|
402
|
+
|
|
403
|
+
sessionTokenCache.set(path, { mtime: st.mtimeMs, tokens });
|
|
404
|
+
return tokens;
|
|
405
|
+
}
|
|
406
|
+
|
|
353
407
|
// Detect live sessions by transcript mtime. Returns array of { path, project, cwd, mtime, ageSec }.
|
|
354
408
|
// A session is "live" if its .jsonl was modified within thresholdMs.
|
|
355
409
|
export function findLiveSessions({ projectsDir = CLAUDE_PROJECTS, thresholdMs = 90_000 } = {}) {
|
|
@@ -681,12 +735,45 @@ function aggregateFrom(cache) {
|
|
|
681
735
|
return agg;
|
|
682
736
|
}
|
|
683
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
|
+
|
|
684
754
|
// Incremental scan: re-parse only changed files. Returns {aggregate, scanned, skipped, removed}.
|
|
685
|
-
|
|
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
|
+
|
|
686
773
|
const cache = readCache();
|
|
687
774
|
cache.files = cache.files || {};
|
|
688
775
|
const seen = new Set();
|
|
689
|
-
const transcripts =
|
|
776
|
+
const transcripts = listAllTranscripts(dirs);
|
|
690
777
|
let scanned = 0;
|
|
691
778
|
let skipped = 0;
|
|
692
779
|
for (const fp of transcripts) {
|
|
@@ -709,13 +796,22 @@ export function scan({ projectsDir = CLAUDE_PROJECTS, onProgress, force = false
|
|
|
709
796
|
// skip corrupt file but keep prior cache entry
|
|
710
797
|
}
|
|
711
798
|
}
|
|
712
|
-
// Remove cache entries for
|
|
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.
|
|
713
803
|
let removed = 0;
|
|
804
|
+
const sep = process.platform === 'win32' ? '\\' : '/';
|
|
805
|
+
const dirPrefixes = dirs.map((d) => d.replace(/[/\\]+$/, '') + sep);
|
|
714
806
|
for (const key of Object.keys(cache.files)) {
|
|
715
|
-
if (
|
|
807
|
+
if (seen.has(key)) continue;
|
|
808
|
+
if (dirPrefixes.some((p) => key.startsWith(p))) {
|
|
809
|
+
delete cache.files[key];
|
|
810
|
+
removed += 1;
|
|
811
|
+
}
|
|
716
812
|
}
|
|
717
813
|
writeCache(cache);
|
|
718
814
|
const aggregate = aggregateFrom(cache);
|
|
719
815
|
writeAggregate(aggregate);
|
|
720
|
-
return { aggregate, scanned, skipped, removed, total: transcripts.length };
|
|
816
|
+
return { aggregate, scanned, skipped, removed, total: transcripts.length, dirs };
|
|
721
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));
|