claude-rpc 1.3.0 → 1.3.1

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/SECURITY.md CHANGED
@@ -304,6 +304,12 @@ untrusted or remote input into a shell:
304
304
  (`src/git.js`).
305
305
  - `gh repo view --json isPrivate` — auto-hide GitHub-private repos from the card
306
306
  (`src/privacy.js`); 1.5s timeout, silent skip if `gh` is absent.
307
+ - `ps -eo comm=,args=` (macOS/Linux) / `powershell.exe Get-CimInstance
308
+ Win32_Process` filtered to `claude|node|bun|deno` (Windows) — the daemon's
309
+ 60-second "is Claude Code still running?" liveness poll (`src/claude-proc.js`),
310
+ so an open-but-quiet session shows *idle* instead of vanishing. Read-only, the
311
+ result is one boolean, nothing is logged or sent anywhere; disable with
312
+ `processDetection: false`.
307
313
  - `gh gist` / `gh --version` — gist badge publishing, only under 3b
308
314
  (`src/gist.js`).
309
315
  - `npm root -g` / `npm install -g` — resolve / promote the global install during
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "1.3.0",
3
+ "version": "1.3.1",
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",
@@ -0,0 +1,97 @@
1
+ // "Is Claude Code actually running?" — process-level liveness detection.
2
+ //
3
+ // The daemon's only passive signals for "Claude Code is open" were hook
4
+ // freshness (state.lastActivity) and transcript mtimes (findLiveSessions).
5
+ // Both go quiet the moment the user stops typing: a session left open at the
6
+ // prompt writes NOTHING to disk, so after staleSessionMin the daemon concluded
7
+ // "Claude Code not running" and cleared the card — even with idleWhenOpen:true,
8
+ // whose staleMs backstop exists precisely because transcript silence was
9
+ // previously indistinguishable from a closed terminal. Asking the OS whether a
10
+ // Claude Code process exists removes that ambiguity: alive → the card stays up
11
+ // as 'idle' indefinitely; gone → the existing stale paths clear it.
12
+ //
13
+ // Detection is deliberately conservative about what counts as Claude Code:
14
+ // - a process literally named `claude` / `claude.exe` (native installer)
15
+ // - a runtime (node/bun/deno) whose command line points into the
16
+ // `@anthropic-ai/claude-code` package (npm/npx installs)
17
+ // - a binary run from the native installer's version store
18
+ // (~/.local/share/claude/versions/<v>)
19
+ // It must NOT match claude-rpc itself (this daemon is `node .../claude-rpc/
20
+ // src/daemon.js` — a bare /claude/ substring test would make the daemon see
21
+ // itself as Claude Code and the card would never clear). Every pattern below
22
+ // therefore requires a word boundary right after "claude".
23
+ //
24
+ // Adopted from the Teksya fork's groundwork (fork commit 46b62a5).
25
+ import { execFile } from 'node:child_process';
26
+
27
+ // One process-table query per call is the whole cost model; the daemon calls
28
+ // on a slow timer (see CLAUDE_PROC_POLL_MS in daemon.js), so no caching here.
29
+ const EXEC_TIMEOUT_MS = 15_000;
30
+
31
+ // Does this (name, command line) pair look like a Claude Code process?
32
+ // Exported for tests. `name` is the executable name where the platform gives
33
+ // us one (Win32_Process.Name / ps comm); `cmdline` is the full command line.
34
+ export function looksLikeClaudeCode(name, cmdline) {
35
+ const n = String(name || '').toLowerCase();
36
+ const c = String(cmdline || '').replace(/\\/g, '/').toLowerCase();
37
+ // The Claude DESKTOP app's binary is ALSO named claude(.exe) — Windows
38
+ // installs under …/AnthropicClaude/, macOS ships as Claude.app. It is not
39
+ // Claude Code; matching it would pin the card up whenever the chat app is
40
+ // open. Path check comes first so nothing below can re-admit it.
41
+ if (c.includes('anthropicclaude') || c.includes('claude.app/')) return false;
42
+ // npm / npx install: node …/node_modules/@anthropic-ai/claude-code/cli.js
43
+ if (c.includes('@anthropic-ai/claude-code/')) return true;
44
+ // Native-install version store: …/.local/share/claude/versions/<v>
45
+ if (c.includes('/share/claude/versions/')) return true;
46
+ // A command-line token that IS `claude` / `claude.exe` — bare (as typed in a
47
+ // shell, argv[0] preserved) or as the tail of a path. The boundary required
48
+ // right after "claude" is what keeps claude-rpc from matching.
49
+ if (/(^|[/"'])claude(\.exe)?["']?(\s|$)/.test(c)) return true;
50
+ // No command line to inspect (platform hid it) — fall back to the process
51
+ // name alone. Only safe here because the desktop-app exclusion above could
52
+ // not run; a bare-name match is still far likelier Code than the chat app
53
+ // being unreadable.
54
+ if (!c && (n === 'claude' || n === 'claude.exe')) return true;
55
+ return false;
56
+ }
57
+
58
+ function execText(file, args) {
59
+ return new Promise((resolve) => {
60
+ execFile(file, args, { timeout: EXEC_TIMEOUT_MS, windowsHide: true, maxBuffer: 8 * 1024 * 1024 },
61
+ (err, stdout) => resolve(err ? null : String(stdout || '')));
62
+ });
63
+ }
64
+
65
+ // Resolve to true/false when the platform answered, null when the query itself
66
+ // failed (missing tool, timeout, unsupported platform) — null means "unknown"
67
+ // and callers must fall back to the old transcript-only behavior. Never throws.
68
+ export async function detectClaudeProcess() {
69
+ if (process.platform === 'win32') {
70
+ // Only the names that can host Claude Code — a full process dump with
71
+ // command lines is much slower than this filtered CIM query.
72
+ const script = "Get-CimInstance Win32_Process -Filter \"Name='claude.exe' OR Name='node.exe' OR Name='bun.exe' OR Name='deno.exe'\" | ForEach-Object { $_.Name + [char]9 + $_.CommandLine }";
73
+ const out = await execText('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
74
+ if (out === null) return null;
75
+ for (const line of out.split('\n')) {
76
+ const tab = line.indexOf('\t');
77
+ const name = tab === -1 ? line.trim() : line.slice(0, tab).trim();
78
+ const cmd = tab === -1 ? '' : line.slice(tab + 1);
79
+ if (looksLikeClaudeCode(name, cmd)) return true;
80
+ }
81
+ return false;
82
+ }
83
+ // macOS / Linux. comm is the executable name (first token); args follows.
84
+ const out = await execText('ps', ['-eo', 'comm=,args=']);
85
+ if (out === null) return null;
86
+ for (const line of out.split('\n')) {
87
+ const trimmed = line.trim();
88
+ if (!trimmed) continue;
89
+ const sp = trimmed.indexOf(' ');
90
+ const comm = sp === -1 ? trimmed : trimmed.slice(0, sp);
91
+ const args = sp === -1 ? '' : trimmed.slice(sp + 1);
92
+ // comm may be a path on some ps builds — compare its basename.
93
+ const base = comm.slice(comm.lastIndexOf('/') + 1);
94
+ if (looksLikeClaudeCode(base, args)) return true;
95
+ }
96
+ return false;
97
+ }
package/src/daemon.js CHANGED
@@ -7,6 +7,7 @@ import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStat
7
7
  import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession, throttleDecision } from './presence.js';
8
8
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
9
9
  import { scan, readAggregate, findLiveSessions, readSessionTokens, readSessionModel } from './scanner.js';
10
+ import { detectClaudeProcess } from './claude-proc.js';
10
11
  import { detectGithubUrl } from './git.js';
11
12
  import { applyPrivacy } from './privacy.js';
12
13
  import { pauseUntil } from './pause.js';
@@ -100,6 +101,12 @@ try {
100
101
  let config = loadConfigWithLog();
101
102
  let aggregate = readAggregate() || null;
102
103
  let liveSessions = [];
104
+ // OS-level "a Claude Code process exists" flag (see claude-proc.js). Injected
105
+ // into every resolved state so applyIdle can tell "open but quiet" (→ idle)
106
+ // from "actually closed" (→ stale) — transcript mtimes alone can't, which is
107
+ // why an untouched-but-open session used to clear the card after staleSessionMin.
108
+ // null = unknown (never checked / detection unavailable) → old behavior.
109
+ let claudeProcAlive = null;
103
110
  let client = null;
104
111
  let connected = false;
105
112
  let connecting = false; // login() in flight — see the watchdog note in connect()
@@ -147,6 +154,10 @@ const rotationCursor = makeRotationCursor();
147
154
  // Which concurrent session's card we're currently showing. pickActiveSession
148
155
  // keeps it sticky — see resolvePresence.
149
156
  let displayedSessionId = null;
157
+ // sessionId → transcript path, remembered from the last tick the session was
158
+ // inside the live window. Lets token enrichment keep reading the (mtime-
159
+ // cached) transcript after it goes quiet instead of zeroing the card's count.
160
+ const sessionPathMemo = new Map();
150
161
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
151
162
  // from a moving transcript mtime, and missing-hook scenarios leave it null —
152
163
  // either case would make startTimestamp jump on every rotation.
@@ -216,6 +227,10 @@ function resolvePresence(opts = {}) {
216
227
  // Attach live sessions BEFORE applyIdle so the stale/idle decision can
217
228
  // see ongoing transcript activity, not just this daemon's hook state.
218
229
  state.liveSessions = opts.liveSessions || liveSessions;
230
+ // Same injection pattern for OS-level process liveness — standalone callers
231
+ // (preview/api) that pass an explicit state simply leave it undefined and
232
+ // get the historical transcript-only behavior.
233
+ if (state.claudeProcessAlive === undefined) state.claudeProcessAlive = claudeProcAlive;
219
234
  state = applyIdle(state, config);
220
235
  // Shipped overlay sits on top of idle/working/thinking — but never over
221
236
  // stale (we don't celebrate when Claude isn't running).
@@ -228,18 +243,43 @@ function resolvePresence(opts = {}) {
228
243
  // events is always {0,0,0,0}. The transcript is the only running source
229
244
  // of truth — readSessionTokens is mtime-cached, so this is cheap unless
230
245
  // the session is actively writing.
231
- if (state.cwd && state.status !== 'stale') {
232
- const cwdLower = state.cwd.toLowerCase();
233
- const match = (state.liveSessions || []).find(s =>
234
- (s.cwd || '').toLowerCase() === cwdLower
235
- );
236
- if (match) {
237
- const t = readSessionTokens(match.path);
246
+ // Matching is by SESSION ID first: the transcript file is named
247
+ // <sessionId>.jsonl, so this is exact. The old cwd-only match silently
248
+ // broke whenever the two cwd notions diverged — state.cwd follows the
249
+ // hooks (UserPromptSubmit re-stamps it after every in-session `cd`) while
250
+ // the live-session cwd is read from the transcript HEAD (fixed at session
251
+ // start) — leaving the card's token count stuck at 0. The id→path memo
252
+ // keeps enrichment alive after the transcript ages out of the live window
253
+ // (>90s quiet), so an idle card keeps its final token count instead of
254
+ // dropping to zero. cwd remains the fallback for borrowed states (whose
255
+ // sessionId belongs to the QUIET session, not the transcript we adopted)
256
+ // and legacy id-less states.
257
+ if (state.status !== 'stale') {
258
+ const live = state.liveSessions || [];
259
+ let tpath = null;
260
+ if (state.sessionId && !state.borrowed) {
261
+ const bySid = live.find((s) => basename(s.path || '', '.jsonl') === state.sessionId);
262
+ if (bySid) {
263
+ tpath = bySid.path;
264
+ if (!sessionPathMemo.has(state.sessionId) && sessionPathMemo.size >= 64) {
265
+ sessionPathMemo.delete(sessionPathMemo.keys().next().value);
266
+ }
267
+ sessionPathMemo.set(state.sessionId, tpath);
268
+ } else {
269
+ tpath = sessionPathMemo.get(state.sessionId) || null;
270
+ }
271
+ }
272
+ if (!tpath && state.cwd) {
273
+ const cwdLower = state.cwd.toLowerCase();
274
+ tpath = live.find((s) => (s.cwd || '').toLowerCase() === cwdLower)?.path || null;
275
+ }
276
+ if (tpath) {
277
+ const t = readSessionTokens(tpath);
238
278
  if (t) state.tokens = t;
239
279
  // The hook only learns the model at SessionStart; a mid-session /model
240
280
  // switch is visible only in the transcript. Render-time override — the
241
281
  // state file stays hook-owned.
242
- const liveModel = readSessionModel(match.path);
282
+ const liveModel = readSessionModel(tpath);
243
283
  if (liveModel) state.model = liveModel;
244
284
  }
245
285
  }
@@ -769,6 +809,45 @@ function refreshLiveSessions() {
769
809
  refreshLiveSessions();
770
810
  setInterval(refreshLiveSessions, 30_000);
771
811
 
812
+ // OS-level Claude Code liveness poll (see claude-proc.js). This is what lets
813
+ // an open-but-untouched session stay 'idle' forever instead of being declared
814
+ // stale after staleSessionMin — hook state and transcript mtimes both go
815
+ // silent when the user merely stops typing, but the process table doesn't lie.
816
+ // 60s cadence: one filtered process-table query per minute is negligible, and
817
+ // a card lingering up to a minute after Claude truly exits is fine (the same
818
+ // order as the transcript-based paths). Kill switch: processDetection:false.
819
+ // A single failed query keeps the last known answer (a transient PowerShell/ps
820
+ // hiccup must not flash the card off); two consecutive failures degrade to
821
+ // null = unknown, which restores the old transcript-only behavior.
822
+ const CLAUDE_PROC_POLL_MS = 60_000;
823
+ let claudeProcFailStreak = 0;
824
+ async function refreshClaudeProc() {
825
+ if (config.processDetection === false) { claudeProcAlive = null; return; }
826
+ try {
827
+ const alive = await detectClaudeProcess();
828
+ if (alive === null) {
829
+ claudeProcFailStreak += 1;
830
+ if (claudeProcFailStreak >= 2 && claudeProcAlive !== null) {
831
+ log('claude-proc: detection unavailable — falling back to transcript-only liveness');
832
+ claudeProcAlive = null;
833
+ }
834
+ return;
835
+ }
836
+ claudeProcFailStreak = 0;
837
+ if (alive !== claudeProcAlive) {
838
+ log(`claude-proc: Claude Code process ${alive ? 'detected' : 'gone'}`);
839
+ claudeProcAlive = alive;
840
+ resetTransmit();
841
+ pushPresence();
842
+ }
843
+ } catch (e) {
844
+ log('claude-proc poll failed:', e.message);
845
+ }
846
+ }
847
+ refreshClaudeProc();
848
+ const claudeProcTimer = setInterval(refreshClaudeProc, CLAUDE_PROC_POLL_MS);
849
+ if (claudeProcTimer.unref) claudeProcTimer.unref();
850
+
772
851
  // ── Connection watchdog (auto-heal) ──────────────────────────────────────────
773
852
  // The reconnect path is event-driven (the 'disconnected' handler + login
774
853
  // failures + setActivity errors). But a connection can rot in ways that emit
@@ -54,6 +54,12 @@ export const DEFAULT_CONFIG = {
54
54
  // up through short pauses, at the cost of a closed terminal showing idle for
55
55
  // up to staleSessionMin minutes before clearing).
56
56
  idleWhenOpen: false,
57
+ // OS-level "is a Claude Code process running?" check (src/claude-proc.js,
58
+ // polled every 60s). When it affirmatively finds one, a quiet-but-open
59
+ // session stays 'idle' instead of going stale — the transcript-silence
60
+ // guesswork above only applies when detection is off, unavailable, or the
61
+ // process is confirmed gone. Set false to disable the process-table query.
62
+ processDetection: true,
57
63
  // When true, the daemon CLEARS Discord activity entirely once the state
58
64
  // goes stale — your profile shows nothing instead of an "Away" frame.
59
65
  hideWhenStale: true,
package/src/format.js CHANGED
@@ -885,6 +885,15 @@ export function applyIdle(state, cfg = {}) {
885
885
  const idleMs = (cfg.idleThresholdSec || 60) * 1000;
886
886
  const staleMs = Math.max(60_000, (cfg.staleSessionMin || 5) * 60 * 1000);
887
887
  const notificationMs = (cfg.notificationWindowSec || 8) * 1000;
888
+ // OS-level liveness (set by the daemon from claude-proc.detectClaudeProcess,
889
+ // like liveSessions): true = a Claude Code process definitely exists right
890
+ // now. Transcripts and hooks both go silent when the user just stops typing,
891
+ // so without this every quiet-but-open session eventually read as "closed"
892
+ // and the card was cleared mid-use. Only an affirmative true changes
893
+ // behavior — false/null/absent (detection failed, unsupported platform,
894
+ // standalone preview/API callers) keeps the historical transcript-only
895
+ // logic, so this can only ever KEEP a card up, never strand one.
896
+ const procAlive = state.claudeProcessAlive === true;
888
897
  // Closing the terminal kills Claude Code without firing SessionEnd, so the
889
898
  // only passive "is it gone?" signal is "no transcript is being written".
890
899
  // DEFAULT (false): clear the card within ~90-120s of the transcript going
@@ -927,7 +936,24 @@ export function applyIdle(state, cfg = {}) {
927
936
  }
928
937
 
929
938
  // Truly dormant: no live transcripts AND local state is old → stale.
930
- if (ageMs > staleMs && liveAgeMs > staleMs) return staleWipe(state);
939
+ // Unless the OS says a Claude Code process is still running — then the user
940
+ // simply walked away from an open session, which is 'idle', not 'stale'.
941
+ // The current-activity slots are wiped like the idle transition below so a
942
+ // long-dormant card can't keep showing an hours-old tool/file.
943
+ if (ageMs > staleMs && liveAgeMs > staleMs) {
944
+ if (procAlive) {
945
+ return {
946
+ ...state,
947
+ status: 'idle',
948
+ currentTool: null,
949
+ currentFile: null,
950
+ filesOpened: [],
951
+ filesEdited: [],
952
+ filesRead: [],
953
+ };
954
+ }
955
+ return staleWipe(state);
956
+ }
931
957
 
932
958
  // Local state is stale but a live transcript exists somewhere on disk.
933
959
  // Borrow the most-recent live session as our "active" context, since the
@@ -944,7 +970,9 @@ export function applyIdle(state, cfg = {}) {
944
970
  // clears the card within ~90-120s of the last write. Opt in with
945
971
  // idleWhenOpen:true to keep showing 'idle' through short pauses; the
946
972
  // staleMs dormancy backstop above still clears it if Claude is truly gone.
947
- if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
973
+ // A confirmed-alive Claude Code process overrides the heuristic entirely —
974
+ // the process being up IS the answer the transcript silence was guessing at.
975
+ if (liveSessions.length === 0 && !idleWhenOpen && !procAlive) return staleWipe(state);
948
976
  return state;
949
977
  }
950
978
  if (ageMs > idleMs) {
@@ -954,8 +982,9 @@ export function applyIdle(state, cfg = {}) {
954
982
  // Hooks quiet AND no live transcripts. By default (idleWhenOpen=false)
955
983
  // treat Claude as gone and go stale now. With idleWhenOpen:true the
956
984
  // session is treated as open-but-paused and drops to idle; the staleMs
957
- // backstop above clears it later if Claude actually exited.
958
- if (liveSessions.length === 0 && !idleWhenOpen) return staleWipe(state);
985
+ // backstop above clears it later if Claude actually exited. A confirmed
986
+ // Claude Code process short-circuits the guess — drop to idle instead.
987
+ if (liveSessions.length === 0 && !idleWhenOpen && !procAlive) return staleWipe(state);
959
988
  // Going idle — wipe "current activity" indicators so rotation frames
960
989
  // gated on filesEdited / currentFile / currentTool stop showing stale
961
990
  // active-session data. Keep the session counters (messages/tools/tokens)
package/src/install.js CHANGED
@@ -284,10 +284,18 @@ export async function addStartupEntry(exePath) {
284
284
  // daemon. Launch through a tiny .vbs shim via wscript (window style 0) so the
285
285
  // unattended startup path is windowless, like every other launch path. We
286
286
  // avoid schtasks deliberately — SECURITY.md advertises "no scheduled task".
287
- let runCmd = `"${exePath}" daemon`;
287
+ // The command comes from daemonLaunch like the mac/linux branches — the old
288
+ // hardcoded `"<exe>" daemon` form assumed packaged mode, so npm installs got
289
+ // a Run entry of `"node.exe" daemon` (no script path) and login-autostart
290
+ // silently launched nothing.
291
+ const launch = daemonLaunch(exePath);
292
+ let runCmd = [launch.exe, ...launch.args].map((p) => `"${p}"`).join(' ');
288
293
  try {
289
294
  mkdirSync(CANONICAL_INSTALL_DIR, { recursive: true });
290
- writeFileSync(STARTUP_VBS, `CreateObject("WScript.Shell").Run """${exePath}"" daemon", 0, False\r\n`);
295
+ // In a VBS string literal a doubled quote is an escaped quote, so each
296
+ // `""x""` below reaches the shell as `"x"`.
297
+ const vbsCmd = [launch.exe, ...launch.args].map((p) => `""${p}""`).join(' ');
298
+ writeFileSync(STARTUP_VBS, `CreateObject("WScript.Shell").Run "${vbsCmd}", 0, False\r\n`);
291
299
  runCmd = `wscript.exe "${STARTUP_VBS}"`;
292
300
  } catch { /* couldn't write the shim — fall back to the direct (windowed) entry */ }
293
301
  await regCommand([
package/src/presence.js CHANGED
@@ -76,10 +76,13 @@ export function pickActiveSession(states, displayedId, now, idleMs) {
76
76
  if (current && isLive(current)) {
77
77
  return { state: current, sessionId: current.sessionId, liveCount };
78
78
  }
79
- // Otherwise show the most-recently-active live session — or, if none are live,
80
- // the most-recent overall so the card follows the last session into idle/stale
81
- // rather than blanking.
82
- const chosen = byRecent.find(isLive) || byRecent[0];
79
+ // Otherwise show the most-recently-active live session — or, if none are
80
+ // live, the most-recent NOT-closed session (a window sitting idle beats one
81
+ // that fired SessionEnd: the closed session's fresher lastActivity would
82
+ // otherwise drag the card stale while a sibling is still open), and only
83
+ // then the most-recent overall so the card follows the last session into
84
+ // idle/stale rather than blanking.
85
+ const chosen = byRecent.find(isLive) || byRecent.find((s) => !s.claudeClosed) || byRecent[0];
83
86
  return { state: chosen, sessionId: chosen.sessionId, liveCount };
84
87
  }
85
88
 
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 = '1.3.0';
14
+ const BAKED = '1.3.1';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {