claude-rpc 0.3.8 → 0.3.11
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 +9 -1
- package/src/daemon.js +18 -1
- package/src/install.js +41 -6
- package/src/paths.js +14 -1
- package/src/scanner.js +41 -0
package/package.json
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-rpc",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.11",
|
|
4
4
|
"description": "Discord Rich Presence for Claude Code — live model, project, tokens, and lifetime stats driven by Claude Code's hook system.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Archer Simmons",
|
|
8
|
+
"homepage": "https://github.com/rar-file/claude-rpc#readme",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/rar-file/claude-rpc.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/rar-file/claude-rpc/issues"
|
|
15
|
+
},
|
|
8
16
|
"bin": {
|
|
9
17
|
"claude-rpc": "./bin/claude-rpc.js"
|
|
10
18
|
},
|
package/src/daemon.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileS
|
|
|
3
3
|
import { Client } from '@xhayper/discord-rpc';
|
|
4
4
|
import { readState } from './state.js';
|
|
5
5
|
import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
|
|
6
|
-
import { scan, readAggregate, findLiveSessions } from './scanner.js';
|
|
6
|
+
import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
|
|
7
7
|
import { detectGithubUrl } from './git.js';
|
|
8
8
|
import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
|
|
9
9
|
|
|
@@ -82,6 +82,23 @@ function buildActivity(opts = {}) {
|
|
|
82
82
|
// see ongoing transcript activity, not just this daemon's hook state.
|
|
83
83
|
state.liveSessions = opts.liveSessions || liveSessions;
|
|
84
84
|
state = applyIdle(state, config);
|
|
85
|
+
|
|
86
|
+
// Pull live session tokens from the transcript file. Claude Code's hook
|
|
87
|
+
// payloads don't include usage data, so state.tokens from PostToolUse
|
|
88
|
+
// events is always {0,0,0,0}. The transcript is the only running source
|
|
89
|
+
// of truth — readSessionTokens is mtime-cached, so this is cheap unless
|
|
90
|
+
// the session is actively writing.
|
|
91
|
+
if (state.cwd && state.status !== 'stale') {
|
|
92
|
+
const cwdLower = state.cwd.toLowerCase();
|
|
93
|
+
const match = (state.liveSessions || []).find(s =>
|
|
94
|
+
(s.cwd || '').toLowerCase() === cwdLower
|
|
95
|
+
);
|
|
96
|
+
if (match) {
|
|
97
|
+
const t = readSessionTokens(match.path);
|
|
98
|
+
if (t) state.tokens = t;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
const vars = buildVars(state, config, opts.aggregate || aggregate);
|
|
86
103
|
const p = config.presence || {};
|
|
87
104
|
|
package/src/install.js
CHANGED
|
@@ -10,8 +10,8 @@ import {
|
|
|
10
10
|
import { dirname, join, resolve } from 'node:path';
|
|
11
11
|
import { spawn } from 'node:child_process';
|
|
12
12
|
import {
|
|
13
|
-
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR,
|
|
14
|
-
HOOK_SCRIPT, IS_PACKAGED,
|
|
13
|
+
CLAUDE_SETTINGS, CONFIG_PATH, USER_CONFIG_DIR, ROOT,
|
|
14
|
+
HOOK_SCRIPT, IS_PACKAGED, IS_NPM_INSTALL,
|
|
15
15
|
CANONICAL_EXE, CANONICAL_INSTALL_DIR, CANONICAL_EXE_NAME,
|
|
16
16
|
} from './paths.js';
|
|
17
17
|
import { DEFAULT_CONFIG } from './default-config.js';
|
|
@@ -42,10 +42,16 @@ function isOurHookCommand(cmd) {
|
|
|
42
42
|
export function installHooks(exePath) {
|
|
43
43
|
const settings = readJson(CLAUDE_SETTINGS, {});
|
|
44
44
|
settings.hooks = settings.hooks || {};
|
|
45
|
-
//
|
|
45
|
+
// Three modes, three shapes:
|
|
46
|
+
// packaged → `"<exe>" hook <event>` (canonical exe, no node)
|
|
47
|
+
// npm → `claude-rpc hook <event>` (bin shim resolves through PATH;
|
|
48
|
+
// survives `npm update` and nvm version switches)
|
|
49
|
+
// dev → `node "<src/hook.js>" <event>` (cloned-source iteration)
|
|
46
50
|
const cmdFor = IS_PACKAGED
|
|
47
51
|
? (event) => `"${exePath}" hook ${event}`
|
|
48
|
-
:
|
|
52
|
+
: IS_NPM_INSTALL
|
|
53
|
+
? (event) => `claude-rpc hook ${event}`
|
|
54
|
+
: (event) => `node "${HOOK_SCRIPT.replace(/\\/g, '/')}" ${event}`;
|
|
49
55
|
|
|
50
56
|
for (const event of EVENTS) {
|
|
51
57
|
const bucket = settings.hooks[event] = settings.hooks[event] || [];
|
|
@@ -181,6 +187,25 @@ export function ensureCanonicalExe(currentExe) {
|
|
|
181
187
|
}
|
|
182
188
|
|
|
183
189
|
export function seedConfig() {
|
|
190
|
+
// npm-install upgrade path: prior v0.3.8 (and earlier) seeded config inside
|
|
191
|
+
// node_modules/claude-rpc/config.json. New shape puts it under USER_CONFIG_DIR.
|
|
192
|
+
// If the legacy file exists and the new one doesn't, copy first so the user
|
|
193
|
+
// doesn't lose their clientId.
|
|
194
|
+
if (IS_NPM_INSTALL) {
|
|
195
|
+
const legacyPath = join(ROOT, 'config.json');
|
|
196
|
+
try {
|
|
197
|
+
if (!existsSync(CONFIG_PATH) && existsSync(legacyPath)) {
|
|
198
|
+
mkdirSync(USER_CONFIG_DIR, { recursive: true });
|
|
199
|
+
copyFileSync(legacyPath, CONFIG_PATH);
|
|
200
|
+
console.log(` config migrated → ${CONFIG_PATH}`);
|
|
201
|
+
console.log(` (was: ${legacyPath} — safe to delete on next 'npm update')`);
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.warn(` ! legacy-config migration skipped: ${e.message}`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
184
209
|
if (existsSync(CONFIG_PATH)) {
|
|
185
210
|
console.log(` config exists → ${CONFIG_PATH}`);
|
|
186
211
|
return false;
|
|
@@ -264,8 +289,18 @@ export async function install({ exePath, withStartup = true } = {}) {
|
|
|
264
289
|
catch (e) { console.warn(` startup entry failed: ${e.message}`); }
|
|
265
290
|
}
|
|
266
291
|
console.log('\nDone.');
|
|
267
|
-
console.log(`Edit ${CONFIG_PATH} to set your Discord clientId, then
|
|
268
|
-
|
|
292
|
+
console.log(`Edit ${CONFIG_PATH} to set your Discord clientId, then run:`);
|
|
293
|
+
// Per-mode "start" instructions — packaged exe takes a daemon subcommand,
|
|
294
|
+
// the npm bin shim handles `start` as a subcommand of itself, and dev
|
|
295
|
+
// mode runs the daemon script directly through node.
|
|
296
|
+
if (IS_PACKAGED) {
|
|
297
|
+
console.log(` "${target}" daemon`);
|
|
298
|
+
} else if (IS_NPM_INSTALL) {
|
|
299
|
+
console.log(` claude-rpc start`);
|
|
300
|
+
} else {
|
|
301
|
+
console.log(` node "${join(ROOT, 'src', 'daemon.js').replace(/\\/g, '/')}"`);
|
|
302
|
+
console.log(` # or: claude-rpc start (if you've run \`npm link\`)`);
|
|
303
|
+
}
|
|
269
304
|
}
|
|
270
305
|
|
|
271
306
|
export async function uninstall() {
|
package/src/paths.js
CHANGED
|
@@ -11,6 +11,15 @@ export const IS_PACKAGED = typeof process.pkg !== 'undefined'
|
|
|
11
11
|
|
|
12
12
|
export const ROOT = resolve(__dirname, '..');
|
|
13
13
|
|
|
14
|
+
// True when we're running from a node_modules tree — `npm install -g claude-rpc`
|
|
15
|
+
// or a local project's `node_modules`. Distinct from packaged mode (single
|
|
16
|
+
// SEA exe) and dev mode (cloned repo, no node_modules wrapper).
|
|
17
|
+
export const IS_NPM_INSTALL = !IS_PACKAGED && /[\\/]node_modules[\\/]/i.test(ROOT);
|
|
18
|
+
|
|
19
|
+
// "Installed" covers both real distribution paths — config and runtime
|
|
20
|
+
// artifacts live outside the install tree so they survive package updates.
|
|
21
|
+
export const IS_INSTALLED = IS_PACKAGED || IS_NPM_INSTALL;
|
|
22
|
+
|
|
14
23
|
// In packaged mode, persist user config in the per-OS app-data directory.
|
|
15
24
|
// In dev mode, keep config.json next to the source tree for easy iteration.
|
|
16
25
|
// Windows: %APPDATA%\claude-rpc\
|
|
@@ -28,7 +37,11 @@ function userConfigDir() {
|
|
|
28
37
|
return join(xdg, 'claude-rpc');
|
|
29
38
|
}
|
|
30
39
|
export const USER_CONFIG_DIR = userConfigDir();
|
|
31
|
-
|
|
40
|
+
// Persist config under USER_CONFIG_DIR whenever we're "installed" — packaged
|
|
41
|
+
// exe OR npm-installed. The dev path keeps config next to source for easy
|
|
42
|
+
// iteration. Putting it in node_modules would mean every `npm update` blows
|
|
43
|
+
// away the user's clientId.
|
|
44
|
+
export const CONFIG_PATH = IS_INSTALLED
|
|
32
45
|
? join(USER_CONFIG_DIR, 'config.json')
|
|
33
46
|
: join(ROOT, 'config.json');
|
|
34
47
|
|
package/src/scanner.js
CHANGED
|
@@ -350,6 +350,47 @@ function readTranscriptCwd(path, mtimeMs) {
|
|
|
350
350
|
return cwd;
|
|
351
351
|
}
|
|
352
352
|
|
|
353
|
+
// Per-transcript token cache. Reading a multi-MB .jsonl on every push tick
|
|
354
|
+
// (4s) would be wasteful, so we only re-parse when the file's mtime has
|
|
355
|
+
// advanced since the last read.
|
|
356
|
+
const sessionTokenCache = new Map(); // path → { mtime, tokens }
|
|
357
|
+
|
|
358
|
+
// Sum input/output/cache tokens from a single transcript JSONL.
|
|
359
|
+
//
|
|
360
|
+
// We need this because Claude Code's hook payloads don't carry usage data —
|
|
361
|
+
// tokens are an assistant-message field, not a tool-call field, so PostToolUse
|
|
362
|
+
// hooks fire with no `usage` block to capture. The live transcript is the
|
|
363
|
+
// only source of truth for the current session's running token count.
|
|
364
|
+
//
|
|
365
|
+
// Returns null when the file can't be read; { input, output, cacheRead,
|
|
366
|
+
// cacheWrite } otherwise. Cached by mtime — repeat calls with no file
|
|
367
|
+
// activity are O(1).
|
|
368
|
+
export function readSessionTokens(path) {
|
|
369
|
+
let st;
|
|
370
|
+
try { st = statSync(path); } catch { return null; }
|
|
371
|
+
const cached = sessionTokenCache.get(path);
|
|
372
|
+
if (cached && cached.mtime === st.mtimeMs) return cached.tokens;
|
|
373
|
+
|
|
374
|
+
const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
|
|
375
|
+
try {
|
|
376
|
+
const raw = readFileSync(path, 'utf8');
|
|
377
|
+
for (const line of raw.split('\n')) {
|
|
378
|
+
if (!line) continue;
|
|
379
|
+
const r = safeJson(line);
|
|
380
|
+
if (!r || r.type !== 'assistant') continue;
|
|
381
|
+
const u = r.message?.usage;
|
|
382
|
+
if (!u) continue;
|
|
383
|
+
tokens.input += u.input_tokens || 0;
|
|
384
|
+
tokens.output += u.output_tokens || 0;
|
|
385
|
+
tokens.cacheRead += u.cache_read_input_tokens || 0;
|
|
386
|
+
tokens.cacheWrite += u.cache_creation_input_tokens || 0;
|
|
387
|
+
}
|
|
388
|
+
} catch { return null; }
|
|
389
|
+
|
|
390
|
+
sessionTokenCache.set(path, { mtime: st.mtimeMs, tokens });
|
|
391
|
+
return tokens;
|
|
392
|
+
}
|
|
393
|
+
|
|
353
394
|
// Detect live sessions by transcript mtime. Returns array of { path, project, cwd, mtime, ageSec }.
|
|
354
395
|
// A session is "live" if its .jsonl was modified within thresholdMs.
|
|
355
396
|
export function findLiveSessions({ projectsDir = CLAUDE_PROJECTS, thresholdMs = 90_000 } = {}) {
|