claude-rpc 0.13.4 → 0.13.5

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/README.md CHANGED
@@ -185,7 +185,7 @@ For a complete account of the sensitive things claude-rpc does — startup persi
185
185
  └────────────┘
186
186
  ```
187
187
 
188
- No database, no message bus, no background polling when Claude Code isn't running. State on disk you can `cat` and `jq`. The single runtime dependency is `@xhayper/discord-rpc`.
188
+ No database, no message bus, no background polling when Claude Code isn't running. State on disk you can `cat` and `jq`. **Zero runtime dependencies** — even the Discord Rich Presence IPC client is hand-rolled (`src/discord-ipc.js`).
189
189
 
190
190
  1. **hook** ([`src/hook.js`](src/hook.js)) — Claude Code spawns it on every lifecycle event. Parses the JSON from stdin and mutates the shared state file. Runs in ~20ms.
191
191
  2. **daemon** ([`src/daemon.js`](src/daemon.js)) — long-running. Connects to Discord's local IPC, watches the state file, pushes presence frames every few seconds. Exponential backoff with jitter on reconnect; `daemon.log` rotates at 5 MB.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-rpc",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
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",
@@ -37,9 +37,7 @@
37
37
  "format:check": "prettier --check \"src/**/*.js\" \"test/**/*.js\"",
38
38
  "typecheck": "tsc -p jsconfig.json"
39
39
  },
40
- "dependencies": {
41
- "@xhayper/discord-rpc": "^1.2.1"
42
- },
40
+ "dependencies": {},
43
41
  "devDependencies": {
44
42
  "@eslint/js": "^10.0.1",
45
43
  "esbuild": "^0.24.0",
@@ -52,6 +50,10 @@
52
50
  "engines": {
53
51
  "node": ">=18"
54
52
  },
53
+ "publishConfig": {
54
+ "access": "public",
55
+ "provenance": true
56
+ },
55
57
  "keywords": [
56
58
  "claude",
57
59
  "claude-code",
package/src/daemon.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { writeFileSync, existsSync, unlinkSync, watch, appendFileSync, mkdirSync, statSync, renameSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
- import { Client } from '@xhayper/discord-rpc';
4
+ import { Client } from './discord-ipc.js';
5
5
  import { readState } from './state.js';
6
6
  import { buildVars, fillTemplate, framePasses, applyIdle, applyShipped, applyTrigger } from './format.js';
7
7
  import { scan, readAggregate, findLiveSessions, readSessionTokens } from './scanner.js';
@@ -259,7 +259,7 @@ function buildActivity(opts = {}) {
259
259
  effectiveSessionStart = state.lastActivity || Date.now();
260
260
  }
261
261
  if (config.showElapsed && effectiveSessionStart && state.status !== 'stale') {
262
- // Discord IPC + @xhayper/discord-rpc expect milliseconds (not seconds).
262
+ // Discord IPC expects millisecond timestamps (not seconds).
263
263
  activity.startTimestamp = effectiveSessionStart;
264
264
  }
265
265
 
@@ -366,7 +366,7 @@ async function pushPresence() {
366
366
 
367
367
  // Heuristic: does this error indicate the IPC transport itself is dead
368
368
  // (vs. a transient/application-level failure)? Matches the common broken-pipe
369
- // / closed-socket shapes from @xhayper/discord-rpc and the underlying net
369
+ // / closed-socket shapes from our IPC client (src/discord-ipc.js) and the net
370
370
  // socket so we only force a reconnect when the connection is actually gone.
371
371
  function isConnectionError(e) {
372
372
  const code = (e && e.code) || '';
@@ -0,0 +1,336 @@
1
+ // Minimal, dependency-free Discord Rich Presence IPC client.
2
+ //
3
+ // claude-rpc only ever needs *local presence* — connect to the Discord
4
+ // desktop client's IPC socket, set/clear an activity. It never touches
5
+ // Discord's REST API, OAuth, the gateway, or voice. `@xhayper/discord-rpc`
6
+ // (our former sole runtime dependency) shipped all of that plus undici, ws,
7
+ // and the @discordjs/* stack — ~10 transitive packages for a feature that is,
8
+ // on the wire, an 8-byte header and a JSON blob over a named pipe / unix
9
+ // socket. This module reimplements exactly the slice the daemon uses, so the
10
+ // published package has ZERO runtime dependencies.
11
+ //
12
+ // Wire protocol (unchanged Discord IPC):
13
+ // frame = <op:uint32 LE> <len:uint32 LE> <json utf8 of length len>
14
+ // op 0 HANDSHAKE · op 1 FRAME · op 2 CLOSE · op 3 PING · op 4 PONG
15
+ // handshake → { v: 1, client_id }
16
+ // READY ← { cmd:'DISPATCH', evt:'READY', data:{ user, config } }
17
+ // request → { cmd, args, nonce } response matched back by nonce
18
+ //
19
+ // The activity-object → payload mapping below is a faithful copy of
20
+ // @xhayper's ClientUser.setActivity, so the rendered card is byte-identical
21
+ // to what shipped through v0.13.4.
22
+ import net from 'node:net';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { randomUUID } from 'node:crypto';
26
+ import { EventEmitter } from 'node:events';
27
+
28
+ export const OP_HANDSHAKE = 0;
29
+ export const OP_FRAME = 1;
30
+ export const OP_CLOSE = 2;
31
+ export const OP_PING = 3;
32
+ export const OP_PONG = 4;
33
+
34
+ const CONNECT_TIMEOUT_MS = 10_000; // matches @xhayper's connect() timeout
35
+
36
+ // Same resolution order @xhayper used: XDG_RUNTIME_DIR → TMPDIR → TMP → TEMP → /tmp.
37
+ function getTempDir() {
38
+ const { XDG_RUNTIME_DIR, TMPDIR, TMP, TEMP } = process.env;
39
+ return fs.realpathSync(XDG_RUNTIME_DIR ?? TMPDIR ?? TMP ?? TEMP ?? `${path.sep}tmp`);
40
+ }
41
+
42
+ // Candidate IPC socket paths, mirroring @xhayper's defaultPathList.
43
+ // win32: named pipe \\?\pipe\discord-ipc-{0..9} (no existence pre-check)
44
+ // posix: <tmp>/discord-ipc-{0..9}, plus snap + flatpak subdirs on linux.
45
+ // On posix we only keep paths that actually exist, exactly like the library —
46
+ // connecting to a non-existent unix socket just wastes a syscall per id.
47
+ export function candidatePaths(platform = process.platform) {
48
+ const out = [];
49
+ if (platform === 'win32') {
50
+ for (let i = 0; i < 10; i++) out.push(`\\\\?\\pipe\\discord-ipc-${i}`);
51
+ return out;
52
+ }
53
+ let base;
54
+ try {
55
+ base = getTempDir();
56
+ } catch {
57
+ base = '/tmp';
58
+ }
59
+ const dirs = [base];
60
+ if (platform === 'linux') {
61
+ dirs.push(path.join(base, 'snap.discord'));
62
+ dirs.push(path.join(base, 'app', 'com.discordapp.Discord'));
63
+ }
64
+ for (const dir of dirs) {
65
+ for (let i = 0; i < 10; i++) {
66
+ const p = path.join(dir, `discord-ipc-${i}`);
67
+ if (fs.existsSync(p)) out.push(p);
68
+ }
69
+ }
70
+ return out;
71
+ }
72
+
73
+ // Encode one IPC frame: 8-byte little-endian header + JSON body.
74
+ export function encodeFrame(op, data) {
75
+ const body = data === undefined ? Buffer.alloc(0) : Buffer.from(JSON.stringify(data));
76
+ const header = Buffer.alloc(8);
77
+ header.writeUInt32LE(op, 0);
78
+ header.writeUInt32LE(body.length, 4);
79
+ return Buffer.concat([header, body]);
80
+ }
81
+
82
+ // Stateful decoder: feed it socket chunks, get back complete {op, data} frames.
83
+ // Handles partial reads and multiple frames coalesced into one chunk.
84
+ export function createFrameDecoder() {
85
+ let buf = Buffer.alloc(0);
86
+ return function push(chunk) {
87
+ buf = buf.length ? Buffer.concat([buf, chunk]) : chunk;
88
+ const frames = [];
89
+ while (buf.length >= 8) {
90
+ const op = buf.readUInt32LE(0);
91
+ const len = buf.readUInt32LE(4);
92
+ if (buf.length < 8 + len) break; // wait for the rest of the body
93
+ const body = buf.subarray(8, 8 + len);
94
+ buf = buf.subarray(8 + len);
95
+ let data;
96
+ try {
97
+ data = body.length ? JSON.parse(body.toString()) : undefined;
98
+ } catch {
99
+ continue; // skip a malformed frame, keep draining the buffer
100
+ }
101
+ frames.push({ op, data });
102
+ }
103
+ return frames;
104
+ };
105
+ }
106
+
107
+ // Friendly activity object → Discord's SET_ACTIVITY payload. Faithful copy of
108
+ // @xhayper ClientUser.setActivity (the subset claude-rpc uses). Kept verbatim
109
+ // so existing config renders identically.
110
+ export function formatActivity(activity = {}, pid) {
111
+ const a = {
112
+ name: activity.name,
113
+ type: activity.type ?? 0, // 0 = Playing
114
+ instance: !!activity.instance,
115
+ };
116
+ if (activity.type === 1 && activity.url) a.url = activity.url; // Streaming only
117
+ if (activity.details) a.details = activity.details;
118
+ if (activity.state) a.state = activity.state;
119
+
120
+ if (activity.startTimestamp || activity.endTimestamp) {
121
+ a.timestamps = {};
122
+ const start = activity.startTimestamp instanceof Date ? activity.startTimestamp.getTime() : activity.startTimestamp;
123
+ const end = activity.endTimestamp instanceof Date ? activity.endTimestamp.getTime() : activity.endTimestamp;
124
+ if (typeof start === 'number') a.timestamps.start = start;
125
+ if (typeof end === 'number') a.timestamps.end = end;
126
+ }
127
+
128
+ if (activity.largeImageKey || activity.smallImageKey || activity.largeImageText || activity.smallImageText) {
129
+ a.assets = {};
130
+ if (activity.largeImageKey) a.assets.large_image = activity.largeImageKey;
131
+ if (activity.smallImageKey) a.assets.small_image = activity.smallImageKey;
132
+ if (activity.largeImageText) a.assets.large_text = activity.largeImageText;
133
+ if (activity.smallImageText) a.assets.small_text = activity.smallImageText;
134
+ }
135
+
136
+ if (activity.buttons?.length) a.buttons = activity.buttons;
137
+
138
+ return { pid: pid ?? process?.pid ?? 0, activity: a };
139
+ }
140
+
141
+ // Drop-in for the slice of @xhayper's `Client` the daemon relies on:
142
+ // new Client({ clientId, transport:{ type:'ipc', pathList? } })
143
+ // client.on('ready'|'disconnected', …) · await client.login()
144
+ // client.user.{username, setActivity(a), clearActivity()} · client.destroy()
145
+ export class Client extends EventEmitter {
146
+ constructor(options = {}) {
147
+ super();
148
+ this.clientId = options.clientId;
149
+ // pathList override is used by tests to point at a fake server; production
150
+ // leaves it undefined and we discover the real Discord socket.
151
+ this._pathList = options.transport?.pathList;
152
+ this.socket = null;
153
+ this.user = null;
154
+ this._pending = new Map(); // nonce → { resolve, reject }
155
+ this._connected = false;
156
+ this._decode = null;
157
+ }
158
+
159
+ async _openSocket() {
160
+ const paths = this._pathList ?? candidatePaths();
161
+ for (const p of paths) {
162
+ const socket = await new Promise((resolve) => {
163
+ const s = net.createConnection(p);
164
+ const onErr = () => {
165
+ s.removeListener('connect', onOk);
166
+ resolve(null);
167
+ };
168
+ const onOk = () => {
169
+ s.removeListener('error', onErr);
170
+ resolve(s);
171
+ };
172
+ s.once('connect', onOk);
173
+ s.once('error', onErr);
174
+ });
175
+ if (socket) return socket;
176
+ }
177
+ return null;
178
+ }
179
+
180
+ // Connect, handshake, and resolve once Discord sends READY (which also
181
+ // populates `user`). Mirrors @xhayper: login() with no scopes === connect().
182
+ async login() {
183
+ const socket = await this._openSocket();
184
+ if (!socket) {
185
+ const err = new Error('Could not connect to Discord client');
186
+ err.code = 'ECONNREFUSED';
187
+ throw err;
188
+ }
189
+ this.socket = socket;
190
+ this._decode = createFrameDecoder();
191
+
192
+ const ready = new Promise((resolve, reject) => {
193
+ const timer = setTimeout(() => {
194
+ const err = new Error('Connection timed out');
195
+ err.code = 'ETIMEDOUT';
196
+ reject(err);
197
+ }, CONNECT_TIMEOUT_MS);
198
+ if (typeof timer === 'object' && 'unref' in timer) timer.unref();
199
+ this._readyResolve = () => {
200
+ clearTimeout(timer);
201
+ resolve();
202
+ };
203
+ this._readyReject = (e) => {
204
+ clearTimeout(timer);
205
+ reject(e);
206
+ };
207
+ });
208
+
209
+ socket.on('data', (chunk) => this._onData(chunk));
210
+ socket.on('close', () => this._onClose('Connection ended'));
211
+ socket.on('error', () => this._onClose('socket error'));
212
+
213
+ this._send(OP_HANDSHAKE, { v: 1, client_id: this.clientId });
214
+
215
+ await ready;
216
+ // No OAuth scopes are ever requested, so READY === ready, same as the lib.
217
+ this.emit('ready');
218
+ }
219
+
220
+ _send(op, data) {
221
+ if (!this.socket) return;
222
+ try {
223
+ this.socket.write(encodeFrame(op, data));
224
+ } catch {
225
+ // Broken pipe mid-write — the 'close'/'error' handler will drive the
226
+ // daemon's reconnect. Swallow so a write race can't crash the process.
227
+ }
228
+ }
229
+
230
+ // Send a command frame and resolve when the nonce-matched reply arrives.
231
+ request(cmd, args) {
232
+ return new Promise((resolve, reject) => {
233
+ if (!this.socket) {
234
+ const err = new Error('Not connected');
235
+ err.code = 'ENOTCONN';
236
+ reject(err);
237
+ return;
238
+ }
239
+ const nonce = randomUUID();
240
+ this._pending.set(nonce, { resolve, reject });
241
+ this._send(OP_FRAME, { cmd, args, nonce });
242
+ });
243
+ }
244
+
245
+ _onData(chunk) {
246
+ let frames;
247
+ try {
248
+ frames = this._decode(chunk);
249
+ } catch {
250
+ return;
251
+ }
252
+ for (const { op, data } of frames) this._onFrame(op, data);
253
+ }
254
+
255
+ _onFrame(op, msg) {
256
+ if (op === OP_PING) {
257
+ this._send(OP_PONG, msg);
258
+ return;
259
+ }
260
+ if (op === OP_CLOSE) {
261
+ this._onClose(msg);
262
+ return;
263
+ }
264
+ if (op !== OP_FRAME || !msg) return;
265
+
266
+ if (msg.cmd === 'DISPATCH' && msg.evt === 'READY') {
267
+ this.user = this._buildUser(msg.data?.user || {});
268
+ this._connected = true;
269
+ if (this._readyResolve) this._readyResolve();
270
+ return;
271
+ }
272
+
273
+ // Nonce-matched response to a request() (e.g. SET_ACTIVITY).
274
+ if (msg.nonce && this._pending.has(msg.nonce)) {
275
+ const { resolve, reject } = this._pending.get(msg.nonce);
276
+ this._pending.delete(msg.nonce);
277
+ if (msg.evt === 'ERROR') {
278
+ const err = new Error(msg.data?.message || 'Discord RPC error');
279
+ err.code = msg.data?.code;
280
+ reject(err);
281
+ } else {
282
+ resolve(msg);
283
+ }
284
+ }
285
+ }
286
+
287
+ // Wrap the READY user payload with the activity methods the daemon calls on
288
+ // `client.user`. Spreading the raw fields preserves `.username` (and id, etc).
289
+ _buildUser(raw) {
290
+ return {
291
+ ...raw,
292
+ setActivity: (activity, pid) => this.request('SET_ACTIVITY', formatActivity(activity, pid)),
293
+ clearActivity: (pid) => this.request('SET_ACTIVITY', { pid: pid ?? process?.pid ?? 0 }),
294
+ };
295
+ }
296
+
297
+ _onClose(reason) {
298
+ const wasConnected = this._connected || !!this.socket;
299
+ // Fail any in-flight requests so awaiters don't hang forever.
300
+ for (const { reject } of this._pending.values()) {
301
+ const err = new Error(typeof reason === 'string' ? reason : 'Connection closed');
302
+ err.code = 'ECONNRESET';
303
+ reject(err);
304
+ }
305
+ this._pending.clear();
306
+ if (this._readyReject && !this._connected) {
307
+ const err = new Error('Connection closed before ready');
308
+ err.code = 'ECONNRESET';
309
+ this._readyReject(err);
310
+ }
311
+ this._readyResolve = this._readyReject = null;
312
+ this._teardownSocket();
313
+ this._connected = false;
314
+ if (wasConnected) this.emit('disconnected');
315
+ }
316
+
317
+ _teardownSocket() {
318
+ if (!this.socket) return;
319
+ try {
320
+ this.socket.removeAllListeners();
321
+ this.socket.destroy();
322
+ } catch {
323
+ // already gone
324
+ }
325
+ this.socket = null;
326
+ }
327
+
328
+ destroy() {
329
+ // Best-effort close; don't emit 'disconnected' on an explicit teardown
330
+ // (the daemon calls destroy() itself and manages its own reconnect).
331
+ this._readyResolve = this._readyReject = null;
332
+ this._pending.clear();
333
+ this._teardownSocket();
334
+ this._connected = false;
335
+ }
336
+ }
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.13.4';
14
+ const BAKED = '0.13.5';
15
15
 
16
16
  function readPkgVersion() {
17
17
  try {