claude-rpc 0.3.8

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/daemon.js ADDED
@@ -0,0 +1,324 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync } from 'node:fs';
3
+ import { Client } from '@xhayper/discord-rpc';
4
+ import { readState } from './state.js';
5
+ import { buildVars, fillTemplate, framePasses, applyIdle } from './format.js';
6
+ import { scan, readAggregate, findLiveSessions } from './scanner.js';
7
+ import { detectGithubUrl } from './git.js';
8
+ import { CONFIG_PATH, STATE_PATH, PID_PATH, LOG_PATH, STATE_DIR, AGGREGATE_PATH } from './paths.js';
9
+
10
+ if (!existsSync(STATE_DIR)) mkdirSync(STATE_DIR, { recursive: true });
11
+
12
+ function log(...args) {
13
+ const line = `[${new Date().toISOString()}] ${args.map((a) => typeof a === 'string' ? a : JSON.stringify(a)).join(' ')}\n`;
14
+ try { appendFileSync(LOG_PATH, line); } catch {}
15
+ process.stdout.write(line);
16
+ }
17
+
18
+ function loadConfig() {
19
+ try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')); }
20
+ catch (e) { log('Failed to read config.json:', e.message); process.exit(1); }
21
+ }
22
+
23
+ let config = loadConfig();
24
+ let aggregate = readAggregate() || null;
25
+ let liveSessions = [];
26
+ let client = null;
27
+ let connected = false;
28
+ let lastPayloadHash = '';
29
+ let reconnectTimer = null;
30
+ let rotationIndex = 0;
31
+ let lastRotationAt = 0;
32
+ // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
33
+ // from a moving transcript mtime, and missing-hook scenarios leave it null —
34
+ // either case would make startTimestamp jump on every rotation.
35
+ let effectiveSessionStart = null;
36
+ // Track which status the rotation cursor belongs to so we can reset it cleanly
37
+ // on a status transition — otherwise the cursor carries over from idle's
38
+ // 7-frame rotation into a single-frame working state and back, producing a
39
+ // jarring "blank tick" until modulo aligns.
40
+ let rotationStatus = null;
41
+
42
+ writeFileSync(PID_PATH, String(process.pid));
43
+
44
+ function maybeAdvanceRotation(rotation, intervalMs) {
45
+ if (!Array.isArray(rotation) || rotation.length === 0) return undefined;
46
+ if (rotation.length === 1) return rotation[0];
47
+ const now = Date.now();
48
+ if (now - lastRotationAt >= intervalMs) {
49
+ rotationIndex = (rotationIndex + 1) % rotation.length;
50
+ lastRotationAt = now;
51
+ }
52
+ return rotation[rotationIndex % rotation.length];
53
+ }
54
+
55
+ // Choose frames + a status-level largeImageText override based on the new
56
+ // `presence.byStatus` block when present. Falls back to legacy `p.rotation`
57
+ // (which still works for any existing user config that doesn't use byStatus).
58
+ //
59
+ // Returns { frames, largeImageTextTpl }. A byStatus entry can be:
60
+ // { details, state, largeImageText, rotation? }
61
+ // If `rotation` is present, the base { details, state } is rendered first
62
+ // and the rotation array cycles after it. Otherwise the entry is a single
63
+ // fixed frame.
64
+ function pickFrames(p, status) {
65
+ const sb = p.byStatus?.[status];
66
+ if (sb) {
67
+ const base = { details: sb.details, state: sb.state, largeImageText: sb.largeImageText };
68
+ const frames = Array.isArray(sb.rotation) && sb.rotation.length
69
+ ? [base, ...sb.rotation]
70
+ : [base];
71
+ return { frames, largeImageTextTpl: sb.largeImageText || null };
72
+ }
73
+ if (Array.isArray(p.rotation) && p.rotation.length) {
74
+ return { frames: p.rotation, largeImageTextTpl: null };
75
+ }
76
+ return { frames: [{ details: p.details, state: p.state }], largeImageTextTpl: null };
77
+ }
78
+
79
+ function buildActivity(opts = {}) {
80
+ let state = opts.state || readState();
81
+ // Attach live sessions BEFORE applyIdle so the stale/idle decision can
82
+ // see ongoing transcript activity, not just this daemon's hook state.
83
+ state.liveSessions = opts.liveSessions || liveSessions;
84
+ state = applyIdle(state, config);
85
+ const vars = buildVars(state, config, opts.aggregate || aggregate);
86
+ const p = config.presence || {};
87
+
88
+ // Pick the active set of frames + any status-level largeImageText override.
89
+ // Reset the rotation cursor when status changes so a 7-frame idle rotation
90
+ // doesn't bleed its index into a 1-frame working state.
91
+ const { frames: rawFrames, largeImageTextTpl: statusLIT } = pickFrames(p, state.status);
92
+ if (state.status !== rotationStatus) {
93
+ rotationIndex = 0;
94
+ lastRotationAt = 0;
95
+ rotationStatus = state.status;
96
+ }
97
+
98
+ // Drop frames whose `requires` vars are empty/zero. Keeps presence tight.
99
+ const frames = rawFrames.filter((f) => framePasses(f, vars));
100
+ const safeFrames = frames.length ? frames : rawFrames.slice(0, 1);
101
+
102
+ const intervalMs = Math.max(5000, config.rotationIntervalMs || 12000);
103
+ const frame = maybeAdvanceRotation(safeFrames, intervalMs) || {};
104
+
105
+ const activity = {};
106
+ // Forcing `name` overrides whatever Discord has cached for the app's
107
+ // display name, so every user sees the same "Playing <appName>" header
108
+ // regardless of their client's stale application cache.
109
+ activity.name = config.appName || 'Claude Code';
110
+ if (frame.details) activity.details = fillTemplate(frame.details, vars).slice(0, 128);
111
+ if (frame.state) activity.state = fillTemplate(frame.state, vars).slice(0, 128);
112
+
113
+ // Image precedence: statusAssets[status] → modelAssets[modelMatch] → presence.largeImageKey.
114
+ // statusAssets lets the user swap the big image based on what Claude is doing
115
+ // (working/thinking/idle/stale/notification).
116
+ let largeKeyTpl = p.largeImageKey;
117
+ if (config.statusAssets && config.statusAssets[state.status]) {
118
+ largeKeyTpl = config.statusAssets[state.status];
119
+ } else if (config.modelAssets && state.model && state.status !== 'stale') {
120
+ const m = String(state.model).toLowerCase();
121
+ let pick = null;
122
+ if (m.includes('opus')) pick = config.modelAssets.opus;
123
+ else if (m.includes('sonnet')) pick = config.modelAssets.sonnet;
124
+ else if (m.includes('haiku')) pick = config.modelAssets.haiku;
125
+ if (!pick) pick = config.modelAssets.default;
126
+ if (pick) largeKeyTpl = pick;
127
+ }
128
+ if (largeKeyTpl) activity.largeImageKey = fillTemplate(largeKeyTpl, vars);
129
+ // largeImageText precedence: per-frame override > byStatus entry > global default.
130
+ const largeTextTpl = frame.largeImageText || statusLIT || p.largeImageText;
131
+ if (largeTextTpl) activity.largeImageText = fillTemplate(largeTextTpl, vars).slice(0, 128);
132
+
133
+ // Small image: pick the status icon directly from vars (set via config.statusIcons).
134
+ // Skips when the icon is empty (e.g. 'stale' has no asset).
135
+ const smallKey = p.smallImageKey ? fillTemplate(p.smallImageKey, vars) : vars.statusIcon;
136
+ if (smallKey && smallKey !== 'stale') {
137
+ activity.smallImageKey = smallKey;
138
+ activity.smallImageText = fillTemplate(p.smallImageText || '{statusVerbose}', vars).slice(0, 128);
139
+ }
140
+
141
+ if (state.status === 'stale') {
142
+ effectiveSessionStart = null;
143
+ } else if (state.sessionStart) {
144
+ effectiveSessionStart = state.sessionStart;
145
+ } else if (!effectiveSessionStart) {
146
+ effectiveSessionStart = state.lastActivity || Date.now();
147
+ }
148
+ if (config.showElapsed && effectiveSessionStart && state.status !== 'stale') {
149
+ // Discord IPC + @xhayper/discord-rpc expect milliseconds (not seconds).
150
+ activity.startTimestamp = effectiveSessionStart;
151
+ }
152
+
153
+ // Activity type — 0=Playing, 1=Streaming, 2=Listening, 3=Watching, 5=Competing.
154
+ // Default to Playing for backwards-compat; config can override.
155
+ if (typeof config.activityType === 'number') activity.type = config.activityType;
156
+
157
+ // Buttons: static configured set, optionally augmented with a per-project
158
+ // GitHub button when the current cwd has a github origin.
159
+ const buttons = Array.isArray(p.buttons) ? p.buttons.slice() : [];
160
+ const gh = state.status !== 'stale' ? detectGithubUrl(state.cwd) : null;
161
+ if (gh && !buttons.some((b) => /github\.com/i.test(b.url || ''))) {
162
+ buttons.unshift({ label: 'View on GitHub →', url: gh });
163
+ }
164
+ if (buttons.length) {
165
+ activity.buttons = buttons.slice(0, 2).map((b) => ({
166
+ label: fillTemplate(b.label, vars).slice(0, 32),
167
+ url: fillTemplate(b.url, vars),
168
+ }));
169
+ }
170
+ return activity;
171
+ }
172
+
173
+ async function pushPresence() {
174
+ if (!connected || !client?.user) return;
175
+ try {
176
+ // Resolve state once so we can decide whether to push or clear.
177
+ // Mirrors buildActivity's first two lines — kept here so we don't
178
+ // have to round-trip through buildActivity just to learn the status.
179
+ let resolved = readState();
180
+ resolved.liveSessions = liveSessions;
181
+ resolved = applyIdle(resolved, config);
182
+
183
+ const hideWhenStale = config.hideWhenStale !== false;
184
+ if (resolved.status === 'stale' && hideWhenStale) {
185
+ const stamp = 'cleared';
186
+ if (lastPayloadHash === stamp) return;
187
+ lastPayloadHash = stamp;
188
+ // Wipe effectiveSessionStart so the next active push gets a fresh
189
+ // elapsed timer rather than counting from a previous session.
190
+ effectiveSessionStart = null;
191
+ await client.user.clearActivity();
192
+ log('Presence cleared (stale — Claude Code not running)');
193
+ return;
194
+ }
195
+
196
+ const activity = buildActivity({ state: resolved });
197
+ const hash = JSON.stringify(activity);
198
+ if (hash === lastPayloadHash) return;
199
+ lastPayloadHash = hash;
200
+ await client.user.setActivity(activity);
201
+ log('Presence updated:', activity.details || '-', '|', activity.state || '-');
202
+ } catch (e) {
203
+ log('setActivity failed:', e.message, '|', e.stack?.split('\n').slice(0, 3).join(' | '));
204
+ }
205
+ }
206
+
207
+ async function connect() {
208
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
209
+ client = new Client({ clientId: config.clientId, transport: { type: 'ipc' } });
210
+
211
+ client.on('ready', () => {
212
+ connected = true;
213
+ log('Discord RPC connected as', client.user?.username);
214
+ lastPayloadHash = '';
215
+ pushPresence();
216
+ });
217
+ client.on('disconnected', () => {
218
+ connected = false;
219
+ log('Discord disconnected — retrying in 10s');
220
+ scheduleReconnect();
221
+ });
222
+ try { await client.login(); }
223
+ catch (e) {
224
+ log('Discord login failed:', e.message, '— retrying in 10s. Is Discord desktop running?');
225
+ scheduleReconnect();
226
+ }
227
+ }
228
+
229
+ function scheduleReconnect() {
230
+ connected = false;
231
+ if (reconnectTimer) return;
232
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, 10000);
233
+ }
234
+
235
+ function watchFiles() {
236
+ let stateTimer = null;
237
+ if (existsSync(STATE_PATH)) {
238
+ watch(STATE_PATH, () => {
239
+ clearTimeout(stateTimer);
240
+ stateTimer = setTimeout(pushPresence, 250);
241
+ });
242
+ }
243
+ watch(CONFIG_PATH, () => {
244
+ log('Config changed — reloading');
245
+ try { config = loadConfig(); lastPayloadHash = ''; pushPresence(); }
246
+ catch (e) { log('Reload failed:', e.message); }
247
+ });
248
+ if (existsSync(AGGREGATE_PATH)) {
249
+ let aggTimer = null;
250
+ watch(AGGREGATE_PATH, () => {
251
+ clearTimeout(aggTimer);
252
+ aggTimer = setTimeout(() => {
253
+ aggregate = readAggregate() || aggregate;
254
+ lastPayloadHash = '';
255
+ pushPresence();
256
+ }, 250);
257
+ });
258
+ }
259
+ }
260
+
261
+ async function runBackgroundScan({ force = false } = {}) {
262
+ try {
263
+ const t0 = Date.now();
264
+ const { aggregate: agg, scanned, skipped, removed, total } = scan({ force });
265
+ aggregate = agg;
266
+ log(`Scan complete: ${scanned} parsed / ${skipped} cached / ${removed} removed / ${total} total in ${Date.now() - t0}ms — allHours=${(agg.activeMs / 3_600_000).toFixed(1)}, sessions=${agg.sessions}, tokens=${(agg.inputTokens + agg.outputTokens)}`);
267
+ lastPayloadHash = '';
268
+ pushPresence();
269
+ } catch (e) {
270
+ log('Scan failed:', e.message);
271
+ }
272
+ }
273
+
274
+ function shutdown() {
275
+ log('Shutting down…');
276
+ try { client?.destroy(); } catch {}
277
+ try { if (existsSync(PID_PATH)) unlinkSync(PID_PATH); } catch {}
278
+ process.exit(0);
279
+ }
280
+
281
+ process.on('SIGINT', shutdown);
282
+ process.on('SIGTERM', shutdown);
283
+ process.on('SIGHUP', shutdown);
284
+
285
+ log('Claude RPC daemon starting. clientId=', config.clientId);
286
+ if (!config.clientId || config.clientId === '1234567890123456789') {
287
+ log('WARNING: config.json contains the placeholder clientId.');
288
+ }
289
+
290
+ connect();
291
+ watchFiles();
292
+
293
+ // Push presence on a tick — also drives rotation.
294
+ const pushIntervalMs = Math.max(2000, config.updateIntervalMs || 4000);
295
+ setInterval(pushPresence, pushIntervalMs);
296
+
297
+ // Initial scan + periodic rescan.
298
+ runBackgroundScan({ force: false });
299
+ const rescanMs = Math.max(60_000, (config.rescanIntervalSec || 300) * 1000);
300
+ setInterval(() => runBackgroundScan({ force: false }), rescanMs);
301
+
302
+ // Live session polling — cheap mtime walk. With the SessionEnd hook now
303
+ // flipping claudeClosed=true authoritatively (see format.applyIdle), this
304
+ // poll is no longer the primary "is Claude open?" signal — it's a hard-kill
305
+ // backstop and the source for concurrent-session detection. 30s cadence
306
+ // keeps the disk work minimal while still surfacing a new sibling session
307
+ // within a rotation cycle or two.
308
+ function refreshLiveSessions() {
309
+ try {
310
+ const thresholdMs = (config.liveSessionThresholdSec || 90) * 1000;
311
+ const next = findLiveSessions({ thresholdMs });
312
+ const prevCount = liveSessions.length;
313
+ liveSessions = next;
314
+ if (next.length !== prevCount) {
315
+ log('Concurrent sessions:', next.length, next.map((s) => `${s.project}(${s.ageSec}s)`).join(', '));
316
+ lastPayloadHash = '';
317
+ pushPresence();
318
+ }
319
+ } catch (e) {
320
+ log('live-session poll failed:', e.message);
321
+ }
322
+ }
323
+ refreshLiveSessions();
324
+ setInterval(refreshLiveSessions, 30_000);
@@ -0,0 +1,91 @@
1
+ // Inlined default config used to seed <USER_CONFIG_DIR>/config.json on first
2
+ // install. Kept in sync with config.example.json. Inlining (rather than
3
+ // bundling the JSON as an asset) keeps the build pipeline simple and works
4
+ // with both pkg and Node SEA without asset APIs.
5
+ //
6
+ // v0.3.6 shape:
7
+ // presence.byStatus.<status> — fixed template per status (the "VSCode-RPC
8
+ // style" main view). Each entry may optionally include a `rotation` array
9
+ // that cycles AFTER the base frame. Backwards-compat: a config without
10
+ // byStatus still works — the legacy top-level `presence.rotation` is used.
11
+
12
+ export const DEFAULT_CONFIG = {
13
+ clientId: "1506443909406920948",
14
+ appName: "Claude Code",
15
+ updateIntervalMs: 4000,
16
+ rotationIntervalMs: 12000,
17
+ rescanIntervalSec: 300,
18
+ idleThresholdSec: 60,
19
+ // Time (minutes) of no hook activity AND no live transcripts on disk before
20
+ // the daemon treats Claude Code as "not running". Kept short on purpose:
21
+ // when Claude isn't open, the Discord presence should disappear quickly.
22
+ // The SessionEnd hook short-circuits this — see hook.js + format.applyIdle.
23
+ staleSessionMin: 5,
24
+ // When true, the daemon CLEARS Discord activity entirely once the state
25
+ // goes stale — your profile shows nothing instead of an "Away" frame.
26
+ hideWhenStale: true,
27
+ notificationWindowSec: 8,
28
+ showElapsed: true,
29
+ activityType: 0,
30
+ statusAssets: {
31
+ working: "https://cdn.qualit.ly/clawd-working-building.gif",
32
+ thinking: "https://cdn.qualit.ly/clawd-working-typing.gif",
33
+ idle: "https://cdn.qualit.ly/clawd-sleeping.gif",
34
+ stale: "https://cdn.qualit.ly/clawd-sleeping.gif",
35
+ notification: "https://cdn.qualit.ly/clawd-notification.gif",
36
+ },
37
+ presence: {
38
+ largeImageKey: "https://cdn.qualit.ly/clawd-sleeping.gif",
39
+ // Tooltip used when a status doesn't supply its own largeImageText.
40
+ // The lifetime "credentials" line that travels with every status.
41
+ largeImageText: "{modelPretty} · {allHours} on Claude · {streakLabel}",
42
+ smallImageKey: "{statusIcon}",
43
+ smallImageText: "{statusVerbose}",
44
+
45
+ // Status-driven templates. Each status renders a fixed "what's happening
46
+ // right now" frame. Only `idle` carries an inner rotation — that's where
47
+ // the lifetime stats cycle through.
48
+ byStatus: {
49
+ working: {
50
+ details: "Working in {project}",
51
+ state: "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens",
52
+ largeImageText: "Working on a {fileLang} file",
53
+ },
54
+ thinking: {
55
+ details: "Thinking in {project}",
56
+ state: "{modelPretty} · {messagesLabel} · {tokensFmt} tokens",
57
+ largeImageText: "Reasoning with {modelPretty}",
58
+ },
59
+ notification: {
60
+ details: "Waiting on you · {project}",
61
+ state: "{modelPretty} · {messagesLabel}",
62
+ largeImageText: "Permission needed",
63
+ },
64
+ idle: {
65
+ details: "Idle in {project}",
66
+ state: "{modelPretty} · {todayHours} today",
67
+ largeImageText: "Idle · {modelPretty}",
68
+ rotation: [
69
+ { details: "This week · {weekHours}", state: "{weekPromptsLabel} · {weekTokensFmt} tokens", requires: ["weekActiveMs"] },
70
+ { details: "{streakLabel}", state: "{daysSinceFirstLabel} · {allSessionsLabel}", requires: ["streakIsMilestone"] },
71
+ { details: "Hotspot · {topEditedFile}", state: "{topEditedCountLabel} all-time", requires: ["topEditedCount"] },
72
+ { details: "{allHours} on Claude all-time", state: "{allSessionsLabel} · {allMessagesFmt} prompts", requires: ["allSessions"] },
73
+ { details: "Lifetime · {allTokensFmt} tokens", state: "{allToolsFmt} tool calls · {allFilesFmt} files", requires: ["allTools"] },
74
+ { details: "Code churn · {linesAddedFmt} added", state: "{linesNetFmt} net · {topLanguage}", requires: ["topLanguage"] },
75
+ { details: "Cost · {todayCostFmt} today", state: "{allCostFmt} all-time", requires: ["allCost"] },
76
+ ],
77
+ },
78
+ },
79
+
80
+ buttons: [
81
+ { label: "Claude Code", url: "https://claude.com/claude-code" },
82
+ ],
83
+ },
84
+ statusIcons: {
85
+ working: "working",
86
+ thinking: "thinking",
87
+ idle: "idle",
88
+ notification: "",
89
+ stale: "",
90
+ },
91
+ };