claude-rpc 0.3.11 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -136
- package/config.example.json +2 -65
- package/package.json +3 -2
- package/src/badge.js +38 -10
- package/src/card.js +345 -0
- package/src/cli.js +239 -12
- package/src/config.js +89 -0
- package/src/daemon.js +133 -23
- package/src/doctor.js +376 -0
- package/src/git.js +2 -2
- package/src/hook.js +1 -1
- package/src/install.js +51 -5
- package/src/pricing.js +29 -6
- package/src/privacy.js +231 -0
- package/src/scanner.js +62 -7
- package/src/server/api.js +175 -0
- package/src/server/index.js +98 -0
- package/src/{server.js → server/page.js} +58 -327
- package/src/server/routes.js +63 -0
- package/src/server/sse.js +32 -0
- package/src/tui.js +6 -7
- package/src/ui.js +89 -0
- package/src/version.js +26 -0
package/src/install.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
readdirSync, unlinkSync,
|
|
9
9
|
} from 'node:fs';
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
|
-
import { spawn } from 'node:child_process';
|
|
11
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
12
12
|
import {
|
|
13
13
|
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
|
|
14
14
|
HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL,
|
|
@@ -135,10 +135,10 @@ function sweepStaleCanonicalBackups() {
|
|
|
135
135
|
const prefix = CANONICAL_EXE_NAME + '.old-';
|
|
136
136
|
for (const name of readdirSync(CANONICAL_INSTALL_DIR)) {
|
|
137
137
|
if (name.startsWith(prefix)) {
|
|
138
|
-
try { unlinkSync(join(CANONICAL_INSTALL_DIR, name)); } catch {}
|
|
138
|
+
try { unlinkSync(join(CANONICAL_INSTALL_DIR, name)); } catch { /* file locked or vanished — sweep is best-effort */ }
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
|
-
} catch {}
|
|
141
|
+
} catch { /* install dir unreadable — nothing to sweep */ }
|
|
142
142
|
}
|
|
143
143
|
|
|
144
144
|
// Copy the running binary into CANONICAL_EXE if it's not already there.
|
|
@@ -160,7 +160,7 @@ export function ensureCanonicalExe(currentExe) {
|
|
|
160
160
|
console.log(` exe already installed → ${CANONICAL_EXE}`);
|
|
161
161
|
return CANONICAL_EXE;
|
|
162
162
|
}
|
|
163
|
-
} catch {}
|
|
163
|
+
} catch { /* stat failed — fall through to copy attempt */ }
|
|
164
164
|
}
|
|
165
165
|
|
|
166
166
|
try {
|
|
@@ -170,7 +170,7 @@ export function ensureCanonicalExe(currentExe) {
|
|
|
170
170
|
// renamed copy when the process exits.
|
|
171
171
|
if (process.platform === 'win32' && existsSync(CANONICAL_EXE)) {
|
|
172
172
|
try { renameSync(CANONICAL_EXE, CANONICAL_EXE + '.old-' + Date.now()); }
|
|
173
|
-
catch {}
|
|
173
|
+
catch { /* not running, no rename needed — copyFileSync below will just overwrite */ }
|
|
174
174
|
}
|
|
175
175
|
copyFileSync(currentExe, CANONICAL_EXE);
|
|
176
176
|
if (process.platform !== 'win32') chmodSync(CANONICAL_EXE, 0o755);
|
|
@@ -270,6 +270,39 @@ export function migrateConfig() {
|
|
|
270
270
|
return true;
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
// Round-trip a synthetic SessionStart event through the same launcher
|
|
274
|
+
// shape that Claude Code itself will use. Proves the hook command actually
|
|
275
|
+
// resolves and runs — without this, `setup` could happily wire a broken
|
|
276
|
+
// command and report success, leaving the user to discover the breakage
|
|
277
|
+
// the next time they open Claude Code. Returns { ok, detail }.
|
|
278
|
+
function verifyHookPipe(exePath) {
|
|
279
|
+
const cmd = IS_PACKAGED ? exePath
|
|
280
|
+
: IS_NPM_INSTALL ? 'claude-rpc'
|
|
281
|
+
: process.execPath;
|
|
282
|
+
const args = IS_PACKAGED || IS_NPM_INSTALL
|
|
283
|
+
? ['hook', 'SessionStart']
|
|
284
|
+
: [HOOK_SCRIPT, 'SessionStart'];
|
|
285
|
+
let result;
|
|
286
|
+
try {
|
|
287
|
+
result = spawnSync(cmd, args, {
|
|
288
|
+
input: '',
|
|
289
|
+
encoding: 'utf8',
|
|
290
|
+
timeout: 3000,
|
|
291
|
+
windowsHide: true,
|
|
292
|
+
});
|
|
293
|
+
} catch (e) {
|
|
294
|
+
return { ok: false, detail: `spawn failed: ${e.message}` };
|
|
295
|
+
}
|
|
296
|
+
if (result.error) return { ok: false, detail: `spawn error: ${result.error.message}` };
|
|
297
|
+
if (result.status !== 0) {
|
|
298
|
+
return { ok: false, detail: `hook exit ${result.status}: ${(result.stderr || '').trim().slice(0, 120)}` };
|
|
299
|
+
}
|
|
300
|
+
if (!result.stdout.includes('"continue"')) {
|
|
301
|
+
return { ok: false, detail: `unexpected hook output: ${result.stdout.trim().slice(0, 120)}` };
|
|
302
|
+
}
|
|
303
|
+
return { ok: true, detail: 'SessionStart round-trip succeeded' };
|
|
304
|
+
}
|
|
305
|
+
|
|
273
306
|
export async function install({ exePath, withStartup = true } = {}) {
|
|
274
307
|
if (process.platform !== 'win32' && withStartup) {
|
|
275
308
|
console.warn('Note: startup registration only works on Windows; other steps still run.');
|
|
@@ -288,6 +321,18 @@ export async function install({ exePath, withStartup = true } = {}) {
|
|
|
288
321
|
try { await addStartupEntry(target); }
|
|
289
322
|
catch (e) { console.warn(` startup entry failed: ${e.message}`); }
|
|
290
323
|
}
|
|
324
|
+
|
|
325
|
+
// Proof the hook pipe actually fires. A setup that returns success
|
|
326
|
+
// without verification is a lie — we caught broken-hook-path bugs
|
|
327
|
+
// twice during v0.3.x because no one ran a real event after install.
|
|
328
|
+
const probe = verifyHookPipe(target);
|
|
329
|
+
if (probe.ok) {
|
|
330
|
+
console.log(` hook pipe ✓ ${probe.detail}`);
|
|
331
|
+
} else {
|
|
332
|
+
console.warn(` hook pipe ✗ ${probe.detail}`);
|
|
333
|
+
console.warn(` ↳ run \`claude-rpc doctor\` for a full diagnostic`);
|
|
334
|
+
}
|
|
335
|
+
|
|
291
336
|
console.log('\nDone.');
|
|
292
337
|
console.log(`Edit ${CONFIG_PATH} to set your Discord clientId, then run:`);
|
|
293
338
|
// Per-mode "start" instructions — packaged exe takes a daemon subcommand,
|
|
@@ -301,6 +346,7 @@ export async function install({ exePath, withStartup = true } = {}) {
|
|
|
301
346
|
console.log(` node "${join(ROOT, 'src', 'daemon.js').replace(/\\/g, '/')}"`);
|
|
302
347
|
console.log(` # or: claude-rpc start (if you've run \`npm link\`)`);
|
|
303
348
|
}
|
|
349
|
+
console.log(`\nThen: \`claude-rpc doctor\` to verify everything is wired.`);
|
|
304
350
|
}
|
|
305
351
|
|
|
306
352
|
export async function uninstall() {
|
package/src/pricing.js
CHANGED
|
@@ -29,16 +29,39 @@ const PRICING = {
|
|
|
29
29
|
|
|
30
30
|
const DEFAULT = PRICING.sonnet;
|
|
31
31
|
|
|
32
|
+
const TIERS = new Set(['opus', 'sonnet', 'haiku']);
|
|
33
|
+
|
|
32
34
|
// Map a model id like "claude-opus-4-7-20251101" to a pricing key.
|
|
35
|
+
//
|
|
36
|
+
// The old implementation did `s.includes(key)` against the PRICING keys
|
|
37
|
+
// sorted by descending length, which silently mis-routed any future model
|
|
38
|
+
// id whose name contained one of those substrings out of order
|
|
39
|
+
// (e.g. `claude-sonneteer-x` matching `sonnet`). Now: split the id on `-`,
|
|
40
|
+
// find the first explicit tier token, then read the version digits that
|
|
41
|
+
// follow. Falls back from `tier-major-minor` → `tier-major` → tier generic
|
|
42
|
+
// → sonnet, in that order.
|
|
33
43
|
export function pricingKeyFor(modelId) {
|
|
34
44
|
if (!modelId) return 'sonnet';
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
45
|
+
const parts = String(modelId).toLowerCase().split('-');
|
|
46
|
+
|
|
47
|
+
let tier = null;
|
|
48
|
+
let tierIdx = -1;
|
|
49
|
+
for (let i = 0; i < parts.length; i++) {
|
|
50
|
+
if (TIERS.has(parts[i])) { tier = parts[i]; tierIdx = i; break; }
|
|
51
|
+
}
|
|
52
|
+
if (!tier) return 'sonnet'; // unknown family — sonnet-class fallback
|
|
53
|
+
|
|
54
|
+
const major = parts[tierIdx + 1];
|
|
55
|
+
const minor = parts[tierIdx + 2];
|
|
56
|
+
if (major && /^\d+$/.test(major)) {
|
|
57
|
+
if (minor && /^\d+$/.test(minor)) {
|
|
58
|
+
const k = `${tier}-${major}-${minor}`;
|
|
59
|
+
if (PRICING[k]) return k;
|
|
60
|
+
}
|
|
61
|
+
const km = `${tier}-${major}`;
|
|
62
|
+
if (PRICING[km]) return km;
|
|
40
63
|
}
|
|
41
|
-
return
|
|
64
|
+
return tier; // generic tier rates
|
|
42
65
|
}
|
|
43
66
|
|
|
44
67
|
export function ratesFor(modelId) {
|
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 { /* broken JSON ≡ no list (treat as empty rather than crash) */ }
|
|
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
|
}
|
|
@@ -345,7 +358,7 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
345
358
|
const r = safeJson(line);
|
|
346
359
|
if (r?.cwd) { cwd = r.cwd; break; }
|
|
347
360
|
}
|
|
348
|
-
} catch {}
|
|
361
|
+
} catch { /* transcript head unreadable — cwd stays null, project name falls back to slug */ }
|
|
349
362
|
cwdCache.set(path, { mtime: mtimeMs, cwd });
|
|
350
363
|
return cwd;
|
|
351
364
|
}
|
|
@@ -451,7 +464,7 @@ function readNotificationsByDay() {
|
|
|
451
464
|
const k = dayKey(e.ts);
|
|
452
465
|
out[k] = (out[k] || 0) + 1;
|
|
453
466
|
}
|
|
454
|
-
} catch {}
|
|
467
|
+
} catch { /* events log unreadable/truncated — return whatever we got, the aggregate will just under-count notifications */ }
|
|
455
468
|
return out;
|
|
456
469
|
}
|
|
457
470
|
|
|
@@ -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
|
-
|
|
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 =
|
|
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
|
|
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 (
|
|
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,175 @@
|
|
|
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 { basename } from 'node:path';
|
|
6
|
+
import { readState } from '../state.js';
|
|
7
|
+
import { buildVars, fillTemplate, applyIdle, framePasses } from '../format.js';
|
|
8
|
+
import { readAggregate, findLiveSessions, dayKey } from '../scanner.js';
|
|
9
|
+
import { loadConfig as loadSharedConfig } from '../config.js';
|
|
10
|
+
|
|
11
|
+
// Re-export under the historical name so any external callers (e.g. tests
|
|
12
|
+
// that did `import { loadConfig } from '../api.js'`) still resolve.
|
|
13
|
+
// Internally everything uses the shared loader so a bad config doesn't
|
|
14
|
+
// blank out the dashboard with `{}` — it falls back to defaults.
|
|
15
|
+
export function loadConfig() {
|
|
16
|
+
return loadSharedConfig();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function rangeToDays(range) {
|
|
20
|
+
if (range === 'all') return Infinity;
|
|
21
|
+
if (range === '1y') return 365;
|
|
22
|
+
const n = parseInt(range, 10);
|
|
23
|
+
return Number.isFinite(n) && n > 0 ? n : 90;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Filter byDay to a windowed slice; also recompute roll-ups (top files etc.)
|
|
27
|
+
// scoped to that window. Returns a shape similar to the aggregate but trimmed.
|
|
28
|
+
export function windowedAggregate(agg, range) {
|
|
29
|
+
if (!agg) return null;
|
|
30
|
+
const days = rangeToDays(range);
|
|
31
|
+
if (!Number.isFinite(days)) return agg; // 'all' → pass through
|
|
32
|
+
|
|
33
|
+
const today = new Date(); today.setHours(0, 0, 0, 0);
|
|
34
|
+
const keepKeys = new Set();
|
|
35
|
+
for (let i = 0; i < days; i++) {
|
|
36
|
+
const d = new Date(today); d.setDate(d.getDate() - i);
|
|
37
|
+
keepKeys.add(dayKey(d.getTime()));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const byDay = {};
|
|
41
|
+
let activeMs = 0, prompts = 0, toolCalls = 0, lines = 0, linesRem = 0, cost = 0, sessions = 0;
|
|
42
|
+
let inputTokens = 0, outputTokens = 0, cacheReadTokens = 0, cacheWriteTokens = 0;
|
|
43
|
+
for (const [k, day] of Object.entries(agg.byDay || {})) {
|
|
44
|
+
if (!keepKeys.has(k)) continue;
|
|
45
|
+
byDay[k] = day;
|
|
46
|
+
activeMs += day.activeMs || 0;
|
|
47
|
+
prompts += day.userMessages || 0;
|
|
48
|
+
toolCalls += day.toolCalls || 0;
|
|
49
|
+
lines += day.linesAdded || 0;
|
|
50
|
+
linesRem += day.linesRemoved || 0;
|
|
51
|
+
cost += day.cost || 0;
|
|
52
|
+
sessions += day.sessions || 0;
|
|
53
|
+
inputTokens += day.inputTokens || 0;
|
|
54
|
+
outputTokens += day.outputTokens || 0;
|
|
55
|
+
cacheReadTokens += day.cacheReadTokens || 0;
|
|
56
|
+
cacheWriteTokens += day.cacheWriteTokens || 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
range,
|
|
61
|
+
byDay,
|
|
62
|
+
activeMs,
|
|
63
|
+
userMessages: prompts,
|
|
64
|
+
toolCalls,
|
|
65
|
+
linesAdded: lines,
|
|
66
|
+
linesRemoved: linesRem,
|
|
67
|
+
linesNet: lines - linesRem,
|
|
68
|
+
estimatedCost: cost,
|
|
69
|
+
sessions,
|
|
70
|
+
inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens,
|
|
71
|
+
grandTokens: inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens,
|
|
72
|
+
// Pass-through global keys for context.
|
|
73
|
+
streak: agg.streak,
|
|
74
|
+
longestStreak: agg.longestStreak,
|
|
75
|
+
daysSinceFirst: agg.daysSinceFirst,
|
|
76
|
+
peakHour: agg.peakHour,
|
|
77
|
+
bestDay: agg.bestDay,
|
|
78
|
+
projects: agg.projects || {},
|
|
79
|
+
toolBreakdown: agg.toolBreakdown || {},
|
|
80
|
+
topEditedFiles: agg.topEditedFiles || [],
|
|
81
|
+
languages: agg.languages || {},
|
|
82
|
+
bashCommands: agg.bashCommands || {},
|
|
83
|
+
webDomains: agg.webDomains || {},
|
|
84
|
+
subagents: agg.subagents || {},
|
|
85
|
+
costByModel: agg.costByModel || {},
|
|
86
|
+
modelsUsed: agg.modelsUsed || {},
|
|
87
|
+
mcpToolCalls: agg.mcpToolCalls || 0,
|
|
88
|
+
builtinToolCalls: agg.builtinToolCalls || 0,
|
|
89
|
+
byHour: agg.byHour || {},
|
|
90
|
+
byWeekday: agg.byWeekday || {},
|
|
91
|
+
notifications: agg.notifications || 0,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Live snapshot: current state + lifetime aggregate + rendered rotation frames.
|
|
96
|
+
// Used by GET /api/state — the SSE 'state' event tells the client to refetch.
|
|
97
|
+
export function snapshot() {
|
|
98
|
+
const config = loadConfig();
|
|
99
|
+
const live = findLiveSessions({ thresholdMs: 90_000 });
|
|
100
|
+
let state = readState();
|
|
101
|
+
state.liveSessions = live;
|
|
102
|
+
state = applyIdle(state, config);
|
|
103
|
+
const aggregate = readAggregate() || {};
|
|
104
|
+
const vars = buildVars(state, config, aggregate);
|
|
105
|
+
const p = config.presence || {};
|
|
106
|
+
const frames = (p.rotation || []).map((f) => ({
|
|
107
|
+
details: fillTemplate(f.details || '', vars),
|
|
108
|
+
state: fillTemplate(f.state || '', vars),
|
|
109
|
+
passes: framePasses(f, vars),
|
|
110
|
+
requires: f.requires || null,
|
|
111
|
+
}));
|
|
112
|
+
return {
|
|
113
|
+
now: Date.now(),
|
|
114
|
+
state,
|
|
115
|
+
aggregate: {
|
|
116
|
+
sessions: aggregate.sessions,
|
|
117
|
+
subagentRuns: aggregate.subagentRuns,
|
|
118
|
+
userMessages: aggregate.userMessages,
|
|
119
|
+
toolCalls: aggregate.toolCalls,
|
|
120
|
+
uniqueFiles: aggregate.uniqueFiles,
|
|
121
|
+
activeMs: aggregate.activeMs,
|
|
122
|
+
wallMs: aggregate.wallMs,
|
|
123
|
+
inputTokens: aggregate.inputTokens,
|
|
124
|
+
outputTokens: aggregate.outputTokens,
|
|
125
|
+
cacheReadTokens: aggregate.cacheReadTokens,
|
|
126
|
+
cacheWriteTokens: aggregate.cacheWriteTokens,
|
|
127
|
+
byDay: aggregate.byDay || {},
|
|
128
|
+
byHour: aggregate.byHour || {},
|
|
129
|
+
byWeekday: aggregate.byWeekday || {},
|
|
130
|
+
projects: aggregate.projects || {},
|
|
131
|
+
toolBreakdown: aggregate.toolBreakdown || {},
|
|
132
|
+
topEditedFiles: (aggregate.topEditedFiles || []).slice(0, 12).map((e) => ({ file: basename(e.path), path: e.path, count: e.count })),
|
|
133
|
+
streak: aggregate.streak,
|
|
134
|
+
longestStreak: aggregate.longestStreak,
|
|
135
|
+
daysSinceFirst: aggregate.daysSinceFirst,
|
|
136
|
+
bestDay: aggregate.bestDay,
|
|
137
|
+
peakHour: aggregate.peakHour,
|
|
138
|
+
linesAdded: aggregate.linesAdded || 0,
|
|
139
|
+
linesRemoved: aggregate.linesRemoved || 0,
|
|
140
|
+
linesNet: aggregate.linesNet || 0,
|
|
141
|
+
languages: aggregate.languages || {},
|
|
142
|
+
bashCommands: aggregate.bashCommands || {},
|
|
143
|
+
webDomains: aggregate.webDomains || {},
|
|
144
|
+
subagents: aggregate.subagents || {},
|
|
145
|
+
mcpToolCalls: aggregate.mcpToolCalls || 0,
|
|
146
|
+
builtinToolCalls: aggregate.builtinToolCalls || 0,
|
|
147
|
+
estimatedCost: aggregate.estimatedCost || 0,
|
|
148
|
+
costByModel: aggregate.costByModel || {},
|
|
149
|
+
modelsUsed: aggregate.modelsUsed || {},
|
|
150
|
+
notifications: aggregate.notifications || 0,
|
|
151
|
+
},
|
|
152
|
+
vars,
|
|
153
|
+
frames,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function projectDrilldown(name) {
|
|
158
|
+
const agg = readAggregate() || {};
|
|
159
|
+
const projects = agg.projects || {};
|
|
160
|
+
const project = projects[name];
|
|
161
|
+
if (!project) return null;
|
|
162
|
+
return {
|
|
163
|
+
name,
|
|
164
|
+
...project,
|
|
165
|
+
files: (agg.topEditedFiles || []).slice(0, 25),
|
|
166
|
+
tools: agg.toolBreakdown || {},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function dayDetail(dayKeyStr) {
|
|
171
|
+
const agg = readAggregate() || {};
|
|
172
|
+
const day = (agg.byDay || {})[dayKeyStr];
|
|
173
|
+
if (!day) return null;
|
|
174
|
+
return { day: dayKeyStr, ...day };
|
|
175
|
+
}
|