claude-rpc 0.17.2 → 0.18.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.17.2",
3
+ "version": "0.18.0",
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",
package/src/daemon.js CHANGED
@@ -2,8 +2,8 @@
2
2
  import { writeFileSync, readFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { basename, dirname } from 'node:path';
4
4
  import { Client } from './discord-ipc.js';
5
- import { readState, sweepStaleStateTmp } from './state.js';
6
- import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton } from './presence.js';
5
+ import { readState, sweepStaleStateTmp, listSessionStates, sweepStaleSessionStates } from './state.js';
6
+ import { makeRotationCursor, pickFrames, selectFrame, resolveLargeImageKey, shouldShowGithubButton, pickActiveSession } from './presence.js';
7
7
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
8
8
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
9
9
  import { detectGithubUrl } from './git.js';
@@ -96,6 +96,9 @@ let reconnectDelayMs = RECONNECT_BASE_MS;
96
96
  // 12-frame rotation into a single-frame working state and back, producing a
97
97
  // jarring "blank tick" until modulo aligns.
98
98
  const rotationCursor = makeRotationCursor();
99
+ // Which concurrent session's card we're currently showing. pickActiveSession
100
+ // keeps it sticky — see resolvePresence.
101
+ let displayedSessionId = null;
99
102
  // Stabilizes Discord's elapsed timer: applyIdle can synthesize a sessionStart
100
103
  // from a moving transcript mtime, and missing-hook scenarios leave it null —
101
104
  // either case would make startTimestamp jump on every rotation.
@@ -121,8 +124,10 @@ try {
121
124
  } catch { /* unreadable PID file — fall through and claim it */ }
122
125
  writeFileSync(PID_PATH, String(process.pid));
123
126
 
124
- // Reclaim any per-pid state tmp files orphaned by a hard-killed writer.
127
+ // Reclaim any per-pid state tmp files orphaned by a hard-killed writer, plus
128
+ // per-session state files from sessions that ended long ago.
125
129
  sweepStaleStateTmp();
130
+ sweepStaleSessionStates();
126
131
 
127
132
  // pickFrames / selectFrame / resolveLargeImageKey now live in presence.js (pure
128
133
  // + unit-tested). The rotation cursor (rotationCursor) is owned here and passed
@@ -135,7 +140,29 @@ sweepStaleStateTmp();
135
140
  // previously this chain ran twice and only buildActivity applied the trigger
136
141
  // overlay, so the two could diverge.
137
142
  function resolvePresence(opts = {}) {
138
- let state = opts.state || readState();
143
+ let state;
144
+ if (opts.state) {
145
+ state = opts.state; // standalone caller (preview/api) passes an explicit state
146
+ } else {
147
+ // Multi-session: each session writes its own state-<id>.json. Pick which one
148
+ // to show, sticking with the current session while it's active so the card
149
+ // doesn't thrash between projects (which also stabilizes the elapsed timer
150
+ // and the GitHub button — both follow the displayed session). Fall back to
151
+ // the legacy global state.json when there are no per-session files.
152
+ const sessions = listSessionStates();
153
+ if (sessions.length) {
154
+ const idleMs = Math.max(30_000, (config.idleThresholdSec || 60) * 1000);
155
+ const picked = pickActiveSession(sessions, displayedSessionId, Date.now(), idleMs);
156
+ displayedSessionId = picked.sessionId;
157
+ state = picked.state || readState();
158
+ // Party count from the SAME per-session list we selected from, so the
159
+ // "N sessions" field stays consistent with the card instead of wobbling
160
+ // on transcript-mtime timing.
161
+ state._liveCount = picked.liveCount;
162
+ } else {
163
+ state = readState();
164
+ }
165
+ }
139
166
  // Attach live sessions BEFORE applyIdle so the stale/idle decision can
140
167
  // see ongoing transcript activity, not just this daemon's hook state.
141
168
  state.liveSessions = opts.liveSessions || liveSessions;
@@ -257,7 +284,9 @@ function buildActivity(opts = {}) {
257
284
  // Concurrent sessions render natively via Discord's party field — the card
258
285
  // shows "(2 of 2)" with no template work. Only attached when more than one
259
286
  // live session exists (a party of one is noise). Opt out: showPartySize:false.
260
- const liveCount = (state.liveSessions || []).length;
287
+ // Prefer the per-session count (consistent with the displayed session); fall
288
+ // back to the transcript-derived count for the legacy single-state path.
289
+ const liveCount = state._liveCount != null ? state._liveCount : (state.liveSessions || []).length;
261
290
  if (config.showPartySize !== false && liveCount > 1) {
262
291
  activity.partyId = 'claude-rpc';
263
292
  activity.partySize = liveCount;
package/src/hook.js CHANGED
@@ -125,9 +125,17 @@ function parseInput() {
125
125
  // directly (avoids spawning a child process per hook).
126
126
  export function processHookEvent(event, input = {}) {
127
127
  const now = Date.now();
128
+ // Each session writes its OWN state file (state-<sessionId>.json) so concurrent
129
+ // sessions stop clobbering one shared state.json — that thrash made the card
130
+ // jump between projects, the timer reset, and counters over-count. Subagent
131
+ // hooks carry the parent's session_id, so their activity rolls up correctly.
132
+ // No id (legacy payload) → the global state.json, preserving old behavior.
133
+ const sid = input.session_id || input.sessionId || null;
134
+ const update = (fn) => updateState(fn, sid);
135
+ const reset = (seed) => resetState(seed, sid);
128
136
 
129
137
  function setActivity(patch) {
130
- updateState((s) => {
138
+ update((s) => {
131
139
  Object.assign(s, patch);
132
140
  s.lastActivity = now;
133
141
  // Any hook firing means Claude Code is alive — clear the closed flag
@@ -140,7 +148,7 @@ export function processHookEvent(event, input = {}) {
140
148
 
141
149
  switch (event) {
142
150
  case 'SessionStart': {
143
- resetState({
151
+ reset({
144
152
  cwd: input.cwd || process.cwd(),
145
153
  model: input.model?.id || input.model || 'claude',
146
154
  status: 'idle',
@@ -148,7 +156,7 @@ export function processHookEvent(event, input = {}) {
148
156
  break;
149
157
  }
150
158
  case 'UserPromptSubmit': {
151
- updateState((s) => {
159
+ update((s) => {
152
160
  s.messages += 1;
153
161
  s.lastUserPrompt = now;
154
162
  s.lastActivity = now;
@@ -164,7 +172,7 @@ export function processHookEvent(event, input = {}) {
164
172
  const toolName = input.tool_name || input.toolName || 'tool';
165
173
  const toolInput = input.tool_input || input.toolInput || {};
166
174
  const file = toolInput.file_path || toolInput.path || toolInput.notebook_path || null;
167
- updateState((s) => {
175
+ update((s) => {
168
176
  s.tools += 1;
169
177
  s.toolBreakdown[toolName] = (s.toolBreakdown[toolName] || 0) + 1;
170
178
  s.currentTool = toolName;
@@ -211,7 +219,7 @@ export function processHookEvent(event, input = {}) {
211
219
  }
212
220
  }
213
221
  }
214
- updateState((s) => {
222
+ update((s) => {
215
223
  s.currentTool = null;
216
224
  s.toolStartedAt = null;
217
225
  s.lastActivity = now;
@@ -236,7 +244,7 @@ export function processHookEvent(event, input = {}) {
236
244
  break;
237
245
  }
238
246
  case 'Notification': {
239
- updateState((s) => {
247
+ update((s) => {
240
248
  s.status = 'notification';
241
249
  s.lastNotification = now;
242
250
  s.lastActivity = now;
@@ -254,7 +262,7 @@ export function processHookEvent(event, input = {}) {
254
262
  // rewriting earlier context, not advancing a turn. Surface it as its
255
263
  // own state so the card stops reading "Thinking…" for the 10-60s
256
264
  // compactions can take on big sessions.
257
- updateState((s) => {
265
+ update((s) => {
258
266
  s.status = 'compacting';
259
267
  s.compactStartedAt = now;
260
268
  s.compactTrigger = input.trigger || input.matcher || null;
@@ -276,7 +284,7 @@ export function processHookEvent(event, input = {}) {
276
284
  // staleSessionMin timeout. applyIdle short-circuits to stale when it
277
285
  // sees claudeClosed=true. Any subsequent hook from another live
278
286
  // session will flip the flag back to false.
279
- updateState((s) => {
287
+ update((s) => {
280
288
  s.status = 'stale';
281
289
  s.claudeClosed = true;
282
290
  s.currentTool = null;
package/src/presence.js CHANGED
@@ -55,6 +55,34 @@ export function selectFrame(rawFrames, vars, status, cursor, intervalMs, framePa
55
55
  return frames[cursor.index % frames.length] || {};
56
56
  }
57
57
 
58
+ // Pick which session's card to show when several Claude sessions run at once.
59
+ // Each session writes its own state-<id>.json (lastActivity stamped on every
60
+ // hook), so `states` is one entry per session. Behavior: STICK to the currently
61
+ // shown session while it's still active (lastActivity within idleMs), so the
62
+ // card doesn't thrash between sessions while you're working in one; only once
63
+ // the shown session goes idle do we switch to the most-recently-active session.
64
+ // Returns { state, sessionId, liveCount } — liveCount feeds the "N sessions"
65
+ // party field so it stays consistent with what's displayed.
66
+ export function pickActiveSession(states, displayedId, now, idleMs) {
67
+ const list = (states || []).filter((s) => s && s.sessionId);
68
+ if (!list.length) return { state: null, sessionId: null, liveCount: 0 };
69
+ // A just-ended session (SessionEnd → claudeClosed) stamps a recent
70
+ // lastActivity but is gone — never count it live or stick to it.
71
+ const isLive = (s) => !s.claudeClosed && now - (s.lastActivity || 0) <= idleMs;
72
+ const liveCount = list.filter(isLive).length;
73
+ const byRecent = [...list].sort((a, b) => (b.lastActivity || 0) - (a.lastActivity || 0));
74
+ // Stickiness: keep the shown session while it's still active.
75
+ const current = list.find((s) => s.sessionId === displayedId);
76
+ if (current && isLive(current)) {
77
+ return { state: current, sessionId: current.sessionId, liveCount };
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];
83
+ return { state: chosen, sessionId: chosen.sessionId, liveCount };
84
+ }
85
+
58
86
  // Should the daemon auto-add a "View on GitHub →" button for this cwd? The
59
87
  // button URL is read from .git/config (no `gh` needed), but private-repo
60
88
  // detection DOES need the gh CLI — so on a machine without gh a private repo
package/src/state.js CHANGED
@@ -68,8 +68,8 @@ function ensureDir() {
68
68
  // serialize through an exclusive lock file. The lock is strictly best-effort:
69
69
  // if we can't get it within LOCK_MAX_WAIT_MS we proceed anyway (a slightly racy
70
70
  // write is better than a dropped hook), and a stale lock from a crashed process
71
- // is reclaimed after LOCK_STALE_MS.
72
- const LOCK_PATH = STATE_PATH + '.lock';
71
+ // is reclaimed after LOCK_STALE_MS. The lock path is per state file (passed in)
72
+ // so per-session writers don't serialize against each other.
73
73
  const LOCK_STALE_MS = 2000;
74
74
  const LOCK_RETRY_MS = 4;
75
75
  const LOCK_MAX_WAIT_MS = 1000;
@@ -85,18 +85,18 @@ function sleepSync(ms) {
85
85
  }
86
86
  }
87
87
 
88
- function acquireLock() {
88
+ function acquireLock(lockPath) {
89
89
  const deadline = Date.now() + LOCK_MAX_WAIT_MS;
90
90
  for (;;) {
91
91
  try {
92
92
  // 'wx' fails if the file exists — that's our mutex.
93
- return openSync(LOCK_PATH, 'wx');
93
+ return openSync(lockPath, 'wx');
94
94
  } catch (err) {
95
95
  if (err.code !== 'EEXIST') return null; // unexpected (perms, etc.) — go lockless
96
96
  try {
97
- if (Date.now() - statSync(LOCK_PATH).mtimeMs > LOCK_STALE_MS) {
97
+ if (Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS) {
98
98
  try {
99
- unlinkSync(LOCK_PATH);
99
+ unlinkSync(lockPath);
100
100
  } catch {
101
101
  /* someone else reclaimed it first */
102
102
  }
@@ -111,7 +111,7 @@ function acquireLock() {
111
111
  }
112
112
  }
113
113
 
114
- function releaseLock(fd) {
114
+ function releaseLock(fd, lockPath) {
115
115
  if (fd === null) return;
116
116
  // Only unlink the lock if the path still points at OUR lock file. If this
117
117
  // process somehow held it past LOCK_STALE_MS, a sibling has reclaimed the
@@ -120,7 +120,7 @@ function releaseLock(fd) {
120
120
  let ours;
121
121
  try {
122
122
  const a = fstatSync(fd);
123
- const b = statSync(LOCK_PATH);
123
+ const b = statSync(lockPath);
124
124
  ours = a.ino === b.ino && a.dev === b.dev;
125
125
  } catch {
126
126
  ours = false; // lock already gone — nothing to unlink
@@ -132,40 +132,53 @@ function releaseLock(fd) {
132
132
  }
133
133
  if (!ours) return;
134
134
  try {
135
- unlinkSync(LOCK_PATH);
135
+ unlinkSync(lockPath);
136
136
  } catch {
137
137
  /* already removed */
138
138
  }
139
139
  }
140
140
 
141
- function withLock(fn) {
142
- const fd = acquireLock();
141
+ function withLock(lockPath, fn) {
142
+ const fd = acquireLock(lockPath);
143
143
  try {
144
144
  return fn();
145
145
  } finally {
146
- releaseLock(fd);
146
+ releaseLock(fd, lockPath);
147
147
  }
148
148
  }
149
149
 
150
- export function readState() {
150
+ // Resolve the state file for a session. No id → the legacy global state.json
151
+ // (single-session and back-compat). With an id → a per-session file, so
152
+ // concurrent sessions stop clobbering each other's status/tools/tokens and the
153
+ // daemon can pick which one to show. The id is a Claude Code session UUID;
154
+ // sanitize defensively for the filename regardless.
155
+ export function statePathFor(sessionId) {
156
+ if (!sessionId) return STATE_PATH;
157
+ const safe = String(sessionId).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64) || 'unknown';
158
+ return join(STATE_DIR, `state-${safe}.json`);
159
+ }
160
+
161
+ export function readState(sessionId) {
151
162
  ensureDir();
152
- if (!existsSync(STATE_PATH)) return { ...DEFAULT_STATE };
163
+ const path = statePathFor(sessionId);
164
+ if (!existsSync(path)) return { ...DEFAULT_STATE };
153
165
  try {
154
- const raw = readFileSync(STATE_PATH, 'utf8');
166
+ const raw = readFileSync(path, 'utf8');
155
167
  return { ...DEFAULT_STATE, ...JSON.parse(raw) };
156
168
  } catch {
157
169
  return { ...DEFAULT_STATE };
158
170
  }
159
171
  }
160
172
 
161
- export function writeState(next) {
173
+ export function writeState(next, sessionId) {
162
174
  ensureDir();
163
- // Per-process tmp name: two processes writing the shared STATE_PATH + '.tmp'
164
- // would clobber each other's tmp before rename. The pid suffix keeps the
175
+ const path = statePathFor(sessionId);
176
+ // Per-process tmp name: two processes writing the same <path>.tmp would
177
+ // clobber each other's tmp before rename. The pid suffix keeps the
165
178
  // atomic-rename guarantee intact even on the best-effort lockless path.
166
- const tmp = `${STATE_PATH}.${process.pid}.tmp`;
179
+ const tmp = `${path}.${process.pid}.tmp`;
167
180
  writeFileSync(tmp, JSON.stringify(next, null, 2));
168
- renameSync(tmp, STATE_PATH);
181
+ renameSync(tmp, path);
169
182
  }
170
183
 
171
184
  // Best-effort sweep of orphaned per-pid tmp files (`state.json.<pid>.tmp`) left
@@ -184,19 +197,62 @@ export function sweepStaleStateTmp(now = Date.now()) {
184
197
  } catch { /* STATE_DIR missing / unreadable — nothing to sweep */ }
185
198
  }
186
199
 
187
- export function updateState(mutator) {
188
- return withLock(() => {
189
- const current = readState();
200
+ // All per-session states currently on disk, each tagged with its sessionId
201
+ // (recovered from the `state-<id>.json` filename). The daemon uses this to pick
202
+ // which session to show. Excludes the legacy global state.json (no `-<id>`),
203
+ // and tmp/lock siblings (they don't end in `.json`).
204
+ export function listSessionStates() {
205
+ ensureDir();
206
+ const out = [];
207
+ let names;
208
+ try { names = readdirSync(STATE_DIR); } catch { return out; }
209
+ for (const name of names) {
210
+ const m = /^state-(.+)\.json$/.exec(name);
211
+ if (!m) continue;
212
+ try {
213
+ const parsed = JSON.parse(readFileSync(join(STATE_DIR, name), 'utf8'));
214
+ out.push({ ...DEFAULT_STATE, ...parsed, sessionId: m[1] });
215
+ } catch { /* torn/broken file mid-write — skip this tick */ }
216
+ }
217
+ return out;
218
+ }
219
+
220
+ // Remove per-session state files whose last activity is older than maxAgeMs — a
221
+ // session that ended without cleanup, or a crashed one. Called periodically by
222
+ // the daemon; never touches the global state.json. Returns the count removed.
223
+ export function sweepStaleSessionStates(maxAgeMs = 6 * 60 * 60 * 1000, now = Date.now()) {
224
+ let removed = 0, names;
225
+ try { names = readdirSync(STATE_DIR); } catch { return 0; }
226
+ for (const name of names) {
227
+ if (!/^state-.+\.json$/.test(name)) continue;
228
+ const full = join(STATE_DIR, name);
229
+ try {
230
+ const last = JSON.parse(readFileSync(full, 'utf8')).lastActivity || 0;
231
+ if (now - last > maxAgeMs) { unlinkSync(full); removed++; }
232
+ } catch {
233
+ // Unparseable — age out by file mtime so a corrupt file still clears.
234
+ try { if (now - statSync(full).mtimeMs > maxAgeMs) { unlinkSync(full); removed++; } }
235
+ catch { /* gone */ }
236
+ }
237
+ }
238
+ return removed;
239
+ }
240
+
241
+ export function updateState(mutator, sessionId) {
242
+ const lockPath = statePathFor(sessionId) + '.lock';
243
+ return withLock(lockPath, () => {
244
+ const current = readState(sessionId);
190
245
  const next = mutator({ ...current }) ?? current;
191
- writeState(next);
246
+ writeState(next, sessionId);
192
247
  return next;
193
248
  });
194
249
  }
195
250
 
196
- export function resetState(seed = {}) {
197
- return withLock(() => {
251
+ export function resetState(seed = {}, sessionId) {
252
+ const lockPath = statePathFor(sessionId) + '.lock';
253
+ return withLock(lockPath, () => {
198
254
  const fresh = { ...DEFAULT_STATE, sessionStart: Date.now(), lastActivity: Date.now(), ...seed };
199
- writeState(fresh);
255
+ writeState(fresh, sessionId);
200
256
  return fresh;
201
257
  });
202
258
  }
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.17.2';
14
+ const BAKED = '0.18.0';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {