claude-rpc 0.6.1 → 0.6.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
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",
@@ -48,12 +48,12 @@ export const DEFAULT_CONFIG = {
48
48
  byStatus: {
49
49
  working: {
50
50
  details: "Working in {project}",
51
- state: "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens",
51
+ state: "{currentToolPretty} · {currentFilePretty} · {tokensLabel}",
52
52
  largeImageText: "Working on a {fileLang} file",
53
53
  },
54
54
  thinking: {
55
55
  details: "Thinking in {project}",
56
- state: "{modelPretty} · {messagesLabel} · {tokensFmt} tokens",
56
+ state: "{modelPretty} · {messagesLabel} · {tokensLabel}",
57
57
  largeImageText: "Reasoning with {modelPretty}",
58
58
  },
59
59
  notification: {
package/src/format.js CHANGED
@@ -140,6 +140,37 @@ function fmtHour(h) {
140
140
  return `${hh}:00`;
141
141
  }
142
142
 
143
+ // Detect a cwd that would leak the user's OS username if rendered on
144
+ // Discord. Examples:
145
+ // /home/lucas → basename matches $USER → leak
146
+ // C:\Users\lucas → equals $USERPROFILE → leak
147
+ // /Users/lucas/projects/x → basename "x" ≠ user → fine
148
+ // On a real privacy-sensitive cwd (the home dir itself, with no project
149
+ // scoping), buildVars falls back to `appName` so the card reads
150
+ // "Idle in Claude Code" instead of "Idle in lucas".
151
+ function looksLikeUsernameLeak(cwd) {
152
+ if (!cwd) return false;
153
+ // Check both POSIX and Windows env vars unconditionally — a test or
154
+ // edge case might have one without the other, and over-suppressing
155
+ // the leak side is the safe direction.
156
+ const homes = [process.env.HOME, process.env.USERPROFILE].filter(Boolean);
157
+ const users = [process.env.USER, process.env.USERNAME].filter(Boolean);
158
+ // Normalize path separators so Windows-style cwds work on POSIX
159
+ // basename (which doesn't split on '\').
160
+ const norm = (p) => p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
161
+ const cwdN = norm(cwd);
162
+ for (const home of homes) {
163
+ if (cwdN === norm(home)) return true;
164
+ }
165
+ if (users.length) {
166
+ const base = cwdN.split('/').pop() || '';
167
+ for (const u of users) {
168
+ if (base === u.toLowerCase()) return true;
169
+ }
170
+ }
171
+ return false;
172
+ }
173
+
143
174
  // Trim "C:\repo\src\app\page.tsx" → "src/app/page.tsx" (3 trailing segments).
144
175
  function prettyFilePath(p) {
145
176
  if (!p) return '';
@@ -156,7 +187,14 @@ export function buildVars(state, config, aggregate) {
156
187
  // {tokens} / {tokensFmt} now means the grand total (in + out + cache).
157
188
  const sessionTokens = sessionReal + sessionCacheRead + sessionCacheWrite;
158
189
  const duration = state.sessionStart ? Date.now() - state.sessionStart : 0;
159
- const projectPretty = humanProject(state.cwd) || 'Claude Code';
190
+ // Privacy: when cwd is the user's home dir (or its basename matches the
191
+ // OS username), don't render it. "Idle in lucas" on Discord is a username
192
+ // leak to anyone viewing the card. Fall back to the configured app name.
193
+ const cwdIsLeaky = looksLikeUsernameLeak(state.cwd);
194
+ const safeCwd = cwdIsLeaky ? '' : (state.cwd || '');
195
+ const projectPretty = cwdIsLeaky
196
+ ? (config?.appName || 'Claude Code')
197
+ : (humanProject(state.cwd) || 'Claude Code');
160
198
  const currentToolPretty = humanTool(state.currentTool);
161
199
  const modelPretty = humanModel(state.model);
162
200
 
@@ -298,7 +336,7 @@ export function buildVars(state, config, aggregate) {
298
336
  statusIcon: config?.statusIcons?.[state.status] || state.status || 'idle',
299
337
  project: projectPretty,
300
338
  projectPretty,
301
- cwd: state.cwd || '',
339
+ cwd: safeCwd,
302
340
  model: state.model || 'claude',
303
341
  modelPretty,
304
342
  messages,
@@ -341,7 +379,10 @@ export function buildVars(state, config, aggregate) {
341
379
  // Literal single space — handy for blanking a line without `requires`.
342
380
  empty: ' ',
343
381
 
344
- // pluralized session labels
382
+ // Pluralized session labels. tokensLabel is empty when zero so the
383
+ // `· {tokensLabel}` suffix in default templates collapses away —
384
+ // "Bash · · 0 tokens" is not a useful frame.
385
+ tokensLabel: sessionTokens > 0 ? `${fmtNum(sessionTokens)} tokens` : '',
345
386
  messagesLabel: plural(messages, 'prompt'),
346
387
  toolsLabel: plural(tools, 'tool call'),
347
388
  filesEditedLabel: plural(filesEdited, 'edit'),
@@ -524,7 +565,40 @@ export function buildVars(state, config, aggregate) {
524
565
 
525
566
  export function fillTemplate(tpl, vars) {
526
567
  if (typeof tpl !== 'string') return tpl;
527
- return tpl.replace(/\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));
568
+ const filled = tpl.replace(/\{(\w+)\}/g, (_, key) => (key in vars ? String(vars[key]) : `{${key}}`));
569
+ return collapseSeparators(filled);
570
+ }
571
+
572
+ // After substitution, a template like "{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens"
573
+ // can resolve to "Bash · · 0 tokens" when the tool doesn't have a file path
574
+ // and no tokens have accumulated yet. Split on `·`, trim each segment, drop
575
+ // empty segments, rejoin — so empty middle vars don't leave orphan separators
576
+ // and trailing/leading separators disappear entirely. Templates without `·`
577
+ // pass through untouched.
578
+ function collapseSeparators(s) {
579
+ if (!s.includes('·')) return s;
580
+ return s.split('·').map((p) => p.trim()).filter(Boolean).join(' · ');
581
+ }
582
+
583
+ // Helper used by every "go stale" branch in applyIdle. Wipes the current-
584
+ // activity slots so rotation frames can't render yesterday's project /
585
+ // file / tool names, and zeroes the session counters that are tied to
586
+ // the now-dead session.
587
+ function staleWipe(state) {
588
+ return {
589
+ ...state,
590
+ status: 'stale',
591
+ currentTool: null,
592
+ currentFile: null,
593
+ sessionStart: null,
594
+ cwd: '',
595
+ messages: 0,
596
+ tools: 0,
597
+ filesOpened: [],
598
+ filesEdited: [],
599
+ filesRead: [],
600
+ tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
601
+ };
528
602
  }
529
603
 
530
604
  // Apply idle/stale transitions based on lastActivity age. Used by both daemon
@@ -546,22 +620,7 @@ export function applyIdle(state, cfg = {}) {
546
620
  // Authoritative close signal from the SessionEnd hook — trust it instead
547
621
  // of waiting on staleSessionMin. Any other hook clears the flag, so a
548
622
  // sibling session staying alive will reset us out of this branch.
549
- if (state.claudeClosed) {
550
- return {
551
- ...state,
552
- status: 'stale',
553
- currentTool: null,
554
- currentFile: null,
555
- sessionStart: null,
556
- cwd: '',
557
- messages: 0,
558
- tools: 0,
559
- filesOpened: [],
560
- filesEdited: [],
561
- filesRead: [],
562
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
563
- };
564
- }
623
+ if (state.claudeClosed) return staleWipe(state);
565
624
 
566
625
  // Notification is a brief status — hold it for ~8s after the hook fires,
567
626
  // then fall through to normal idle/stale processing.
@@ -578,22 +637,7 @@ export function applyIdle(state, cfg = {}) {
578
637
  const liveAgeMs = mostRecentLiveMs ? now - mostRecentLiveMs : Infinity;
579
638
 
580
639
  // Truly dormant: no live transcripts AND local state is old → stale.
581
- if (ageMs > staleMs && liveAgeMs > staleMs) {
582
- return {
583
- ...state,
584
- status: 'stale',
585
- currentTool: null,
586
- currentFile: null,
587
- sessionStart: null,
588
- cwd: '',
589
- messages: 0,
590
- tools: 0,
591
- filesOpened: [],
592
- filesEdited: [],
593
- filesRead: [],
594
- tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
595
- };
596
- }
640
+ if (ageMs > staleMs && liveAgeMs > staleMs) return staleWipe(state);
597
641
 
598
642
  // Local state is stale but a live transcript exists somewhere on disk.
599
643
  // Borrow the most-recent live session as our "active" context, since the
@@ -619,11 +663,25 @@ export function applyIdle(state, cfg = {}) {
619
663
  }
620
664
 
621
665
  // Local state is fresh.
622
- if (state.status === 'idle') return state;
666
+ if (state.status === 'idle') {
667
+ // Fast-path stale: if there are NO transcripts being written anywhere
668
+ // on disk, Claude Code isn't running. SessionEnd may not have fired
669
+ // (force-quit, OS sleep, crash). Going stale here clears Discord
670
+ // within ~90-120s of close instead of waiting the full staleMs (5min)
671
+ // — keeps the user's cwd off the card when they're away from their
672
+ // machine. The 5min legacy fallback below still catches the case
673
+ // where transcript mtime is fresh but the hook channel is silent.
674
+ if (liveSessions.length === 0) return staleWipe(state);
675
+ return state;
676
+ }
623
677
  if (ageMs > idleMs) {
624
678
  // Hook channel is quiet, but a live transcript was modified recently?
625
679
  // Keep "working" instead of dropping to "idle".
626
680
  if (liveAgeMs <= idleMs) return state;
681
+ // Hooks quiet AND no live transcripts → Claude is closed, not paused.
682
+ // Skip idle, go straight to stale. Same privacy reasoning as the
683
+ // idle-state fast-path above.
684
+ if (liveSessions.length === 0) return staleWipe(state);
627
685
  // Going idle — wipe "current activity" indicators so rotation frames
628
686
  // gated on filesEdited / currentFile / currentTool stop showing stale
629
687
  // active-session data. Keep the session counters (messages/tools/tokens)
package/src/install.js CHANGED
@@ -260,6 +260,25 @@ export function migrateConfig() {
260
260
  added.push('presence.largeImageText');
261
261
  }
262
262
 
263
+ // v0.6.3: byStatus.working.state and .thinking.state used `{tokensFmt} tokens`
264
+ // which renders "0 tokens" before any session activity has accrued — combined
265
+ // with empty `{currentFilePretty}` for tools like Bash, that surfaced as
266
+ // "Bash · · 0 tokens" on the card. New default uses `{tokensLabel}` which is
267
+ // empty until tokens > 0, and fillTemplate now collapses adjacent separators.
268
+ // Migrate only the verbatim old template — leave anything the user customized.
269
+ const OLD_WORKING = '{currentToolPretty} · {currentFilePretty} · {tokensFmt} tokens';
270
+ const OLD_THINKING = '{modelPretty} · {messagesLabel} · {tokensFmt} tokens';
271
+ if (cfg.presence.byStatus?.working?.state === OLD_WORKING &&
272
+ DEFAULT_CONFIG.presence?.byStatus?.working?.state) {
273
+ cfg.presence.byStatus.working.state = DEFAULT_CONFIG.presence.byStatus.working.state;
274
+ added.push('presence.byStatus.working.state');
275
+ }
276
+ if (cfg.presence.byStatus?.thinking?.state === OLD_THINKING &&
277
+ DEFAULT_CONFIG.presence?.byStatus?.thinking?.state) {
278
+ cfg.presence.byStatus.thinking.state = DEFAULT_CONFIG.presence.byStatus.thinking.state;
279
+ added.push('presence.byStatus.thinking.state');
280
+ }
281
+
263
282
  if (added.length === 0) {
264
283
  console.log(` config up to date → ${CONFIG_PATH}`);
265
284
  return false;
package/src/version.js CHANGED
@@ -11,7 +11,7 @@ import { readFileSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { ROOT } from './paths.js';
13
13
 
14
- const BAKED = '0.6.1';
14
+ const BAKED = '0.6.3';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {